Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3e7f3832e0 | ||
|
|
34b60f9f20 | ||
|
|
b7bfd39652 | ||
|
|
6a1103bf5f | ||
|
|
91653bca57 | ||
|
|
6be0f43dd0 | ||
|
|
370fde42f7 | ||
|
|
e5ff30823d | ||
|
|
9e86d57dac | ||
|
|
00bfee4ed2 | ||
|
|
f4bc55cd41 | ||
|
|
3a9dda13c4 | ||
|
|
9b677c6b35 | ||
|
|
933d601c03 | ||
|
|
b4f0e0bc29 | ||
|
|
9f384402fa | ||
|
|
18b69ee8b4 | ||
|
|
8c1b7af5b3 | ||
|
|
6e953ab1f4 | ||
|
|
88202efddb | ||
|
|
3f15608c59 | ||
|
|
e6a6f04ccf | ||
|
|
3b1a8ef01a | ||
|
|
fd097a647f | ||
|
|
b2bd818ac4 | ||
|
|
d71d09cbb6 | ||
|
|
557fb162c3 | ||
|
|
77a2350240 | ||
|
|
2b3d118d84 | ||
|
|
638ffcb326 | ||
|
|
bf4fba484a | ||
|
|
3ea7f44fd1 |
@@ -57,6 +57,7 @@ import (
|
|||||||
"hamlog/internal/solar"
|
"hamlog/internal/solar"
|
||||||
"hamlog/internal/spe"
|
"hamlog/internal/spe"
|
||||||
"hamlog/internal/steppir"
|
"hamlog/internal/steppir"
|
||||||
|
"hamlog/internal/tunergenius"
|
||||||
"hamlog/internal/uls"
|
"hamlog/internal/uls"
|
||||||
"hamlog/internal/ultrabeam"
|
"hamlog/internal/ultrabeam"
|
||||||
"hamlog/internal/winkeyer"
|
"hamlog/internal/winkeyer"
|
||||||
@@ -190,6 +191,12 @@ 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)
|
||||||
|
|
||||||
|
// Tuner Genius XL (4O3A) — Hardware → Tuner Genius. TCP port is fixed at 9010
|
||||||
|
// on the device, so only the IP + optional remote code are configurable.
|
||||||
|
keyTunerGeniusEnabled = "tunergenius.enabled"
|
||||||
|
keyTunerGeniusHost = "tunergenius.host"
|
||||||
|
keyTunerGeniusPassword = "tunergenius.password" // remote/AUTH code (blank on LAN)
|
||||||
|
|
||||||
// Amplifier control — Hardware → Amplifier (PowerGenius XL over TCP; SPE Expert
|
// Amplifier control — Hardware → Amplifier (PowerGenius XL over TCP; SPE Expert
|
||||||
// over USB serial or an RS232-to-Ethernet bridge). Keys keep the pgxl.* prefix
|
// over USB serial or an RS232-to-Ethernet bridge). Keys keep the pgxl.* prefix
|
||||||
// for backward compatibility with existing saved settings.
|
// for backward compatibility with existing saved settings.
|
||||||
@@ -221,6 +228,7 @@ const (
|
|||||||
keyWKEngine = "winkeyer.engine" // "winkeyer" | "serial" | "icom" | "flex" | "tci"
|
keyWKEngine = "winkeyer.engine" // "winkeyer" | "serial" | "icom" | "flex" | "tci"
|
||||||
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
|
keyWKEscClears = "winkeyer.esc_clears_call" // ESC also clears the callsign
|
||||||
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
|
keyWKSendOnType = "winkeyer.send_on_type" // key characters live as typed
|
||||||
|
keyWKEsm = "winkeyer.esm" // Enter-Sends-Message (N1MM-style CW flow)
|
||||||
keyWKCWLine = "winkeyer.cw_key_line" // serial engine: "dtr" (CW) / "rts" (PTT) or swapped
|
keyWKCWLine = "winkeyer.cw_key_line" // serial engine: "dtr" (CW) / "rts" (PTT) or swapped
|
||||||
keyWKCWInvert = "winkeyer.cw_invert" // serial engine: invert line polarity (active-LOW)
|
keyWKCWInvert = "winkeyer.cw_invert" // serial engine: invert line polarity (active-LOW)
|
||||||
|
|
||||||
@@ -475,6 +483,7 @@ type App struct {
|
|||||||
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
|
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
|
||||||
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
|
||||||
|
tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (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 // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
|
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
|
||||||
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
|
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
|
||||||
@@ -515,8 +524,10 @@ type App struct {
|
|||||||
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
pttKeyedMethod string // "cat" | "rts" | "dtr" while keyed; "" when idle
|
||||||
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
pttGen int64 // bumped on every key; a delayed unkey only fires if unchanged (guards against a stale release cutting a new transmission)
|
||||||
startupErr string // captured for surfacing to the frontend
|
startupErr string // captured for surfacing to the frontend
|
||||||
dbPath string // active database file (may be a user-chosen location)
|
settingsScoped atomic.Bool // true once a.settings is scoped to the active profile — GetUIPref/SetUIPref (per-profile) must wait for it, else an early call reads the wrong scope and e.g. resets the theme
|
||||||
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
dbPath string // settings/config database file (settings + profiles); may be a user-chosen location
|
||||||
|
logbookPath string // default SQLite logbook file (QSOs), next to the settings db — used when a profile doesn't point elsewhere
|
||||||
|
logDb *sql.DB // QSO logbook connection — MySQL, a per-profile SQLite file, or the default logbook.db (never the settings db, except on fallback)
|
||||||
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
dbBackend string // "sqlite" | "mysql" — the logbook backend actually opened at startup
|
||||||
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
dbBackendErr string // non-empty when a configured MySQL backend failed and we fell back to SQLite
|
||||||
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
|
offlineQ *offlineq.Queue // ADIF outbox: QSOs logged while the DB was unreachable
|
||||||
@@ -711,7 +722,16 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
a.dataDir = dataDir
|
a.dataDir = dataDir
|
||||||
a.dbPath = filepath.Join(dataDir, "opslog.db")
|
// Settings/config database (settings + profiles). Fresh installs use
|
||||||
|
// settings.db; existing installs keep their opslog.db (which also held the
|
||||||
|
// QSOs before they were split into a dedicated logbook file — see below).
|
||||||
|
settingsDefault := filepath.Join(dataDir, "settings.db")
|
||||||
|
legacyOpslog := filepath.Join(dataDir, "opslog.db")
|
||||||
|
if fileExists(legacyOpslog) && !fileExists(settingsDefault) {
|
||||||
|
a.dbPath = legacyOpslog
|
||||||
|
} else {
|
||||||
|
a.dbPath = settingsDefault
|
||||||
|
}
|
||||||
usingDefault := true
|
usingDefault := true
|
||||||
// config.json (in the data dir) may point the database to a user-chosen
|
// config.json (in the data dir) may point the database to a user-chosen
|
||||||
// location — e.g. another drive or a synced folder, so it survives a
|
// location — e.g. another drive or a synced folder, so it survives a
|
||||||
@@ -783,6 +803,23 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
a.db = conn
|
a.db = conn
|
||||||
|
|
||||||
|
// The QSO logbook lives in its OWN file (logbook.db) next to the settings db,
|
||||||
|
// so QSOs never share the settings/profiles database. One-time split for
|
||||||
|
// existing installs: if there's no logbook file yet but the settings db
|
||||||
|
// already holds QSOs (the legacy combined opslog.db), seed logbook.db with a
|
||||||
|
// clean copy (VACUUM INTO) — the contacts move to the logbook while the
|
||||||
|
// originals stay in the settings db as an untouched backup. Non-destructive.
|
||||||
|
a.logbookPath = filepath.Join(filepath.Dir(a.dbPath), "logbook.db")
|
||||||
|
if !fileExists(a.logbookPath) && sqliteHasQSOs(a.db) {
|
||||||
|
esc := strings.ReplaceAll(a.logbookPath, "'", "''")
|
||||||
|
if _, verr := a.db.Exec("VACUUM INTO '" + esc + "'"); verr != nil {
|
||||||
|
applog.Printf("logbook split: VACUUM INTO %s failed (%v) — the settings db will serve as the logbook", a.logbookPath, verr)
|
||||||
|
a.logbookPath = "" // fall back to using the settings db as the logbook
|
||||||
|
} else {
|
||||||
|
fmt.Printf("OpsLog: split logbook — seeded %s from the existing database (originals kept as backup)\n", a.logbookPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Wire the LOCAL config repos first — they're backed by the already-open
|
// Wire the LOCAL config repos first — they're backed by the already-open
|
||||||
// SQLite file, so the station/profiles/settings are ready instantly. Doing
|
// SQLite file, so the station/profiles/settings are ready instantly. Doing
|
||||||
// this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI
|
// this BEFORE the (possibly slow, remote) MySQL logbook connect means the UI
|
||||||
@@ -821,6 +858,7 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.settings.SetProfile(active.ID)
|
a.settings.SetProfile(active.ID)
|
||||||
|
a.settingsScoped.Store(true) // per-profile settings reads (GetUIPref…) are now safe
|
||||||
// US county resolver — its own local SQLite (data/uls.db), populated on demand
|
// US county resolver — its own local SQLite (data/uls.db), populated on demand
|
||||||
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
|
// by DownloadULSCounties. Opening (creating an empty store) is cheap and never
|
||||||
// fatal: county resolution simply stays inert until the operator downloads it.
|
// fatal: county resolution simply stays inert until the operator downloads it.
|
||||||
@@ -1110,6 +1148,8 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.startUltrabeam()
|
a.startUltrabeam()
|
||||||
// Antenna Genius switch: connect in the background if enabled.
|
// Antenna Genius switch: connect in the background if enabled.
|
||||||
a.startAntGenius()
|
a.startAntGenius()
|
||||||
|
// Tuner Genius XL ATU: connect in the background if enabled.
|
||||||
|
a.startTunerGenius()
|
||||||
// PowerGenius XL amp fan control: connect in the background if enabled.
|
// PowerGenius XL amp fan control: connect in the background if enabled.
|
||||||
a.startAmps()
|
a.startAmps()
|
||||||
|
|
||||||
@@ -1542,15 +1582,39 @@ func writeDBPointer(dataDir, path string) error {
|
|||||||
|
|
||||||
// DatabaseSettings describes the active database file for the Settings UI.
|
// DatabaseSettings describes the active database file for the Settings UI.
|
||||||
type DatabaseSettings struct {
|
type DatabaseSettings struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"` // settings/config database (settings + profiles)
|
||||||
DefaultPath string `json:"default_path"`
|
DefaultPath string `json:"default_path"` // where the settings db lives by default
|
||||||
IsCustom bool `json:"is_custom"`
|
IsCustom bool `json:"is_custom"` // config.json points it elsewhere
|
||||||
|
LogbookDefaultPath string `json:"logbook_default_path"` // default SQLite logbook file (QSOs), when a profile doesn't point elsewhere
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDatabaseSettings returns where the active database lives.
|
// GetDatabaseSettings returns where the active database lives.
|
||||||
func (a *App) GetDatabaseSettings() DatabaseSettings {
|
func (a *App) GetDatabaseSettings() DatabaseSettings {
|
||||||
def := filepath.Join(a.dataDir, "opslog.db")
|
// Default settings-db location: settings.db on fresh installs, opslog.db when
|
||||||
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def}
|
// an existing one is present (mirrors the startup resolution).
|
||||||
|
settingsDefault := filepath.Join(a.dataDir, "settings.db")
|
||||||
|
legacyOpslog := filepath.Join(a.dataDir, "opslog.db")
|
||||||
|
def := settingsDefault
|
||||||
|
if fileExists(legacyOpslog) && !fileExists(settingsDefault) {
|
||||||
|
def = legacyOpslog
|
||||||
|
}
|
||||||
|
lp := a.logbookPath
|
||||||
|
if lp == "" {
|
||||||
|
lp = a.dbPath // split disabled → the settings db doubles as the logbook
|
||||||
|
}
|
||||||
|
return DatabaseSettings{Path: a.dbPath, DefaultPath: def, IsCustom: a.dbPath != def, LogbookDefaultPath: lp}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevealDataFolder opens the folder that holds the settings database (and the
|
||||||
|
// default logbook) in the OS file manager — the "where is my data" shortcut.
|
||||||
|
func (a *App) RevealDataFolder() error {
|
||||||
|
dir := filepath.Dir(a.dbPath)
|
||||||
|
return openInFileManager(dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// openInFileManager opens a folder in Windows Explorer (matches OpenAwardsFolder).
|
||||||
|
func openInFileManager(dir string) error {
|
||||||
|
return exec.Command("explorer", dir).Start()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MySQLSettings is the shared-database (multi-operator) connection config. When
|
// MySQLSettings is the shared-database (multi-operator) connection config. When
|
||||||
@@ -1563,6 +1627,9 @@ type MySQLSettings struct {
|
|||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
|
// SqlitePath, when set (and Enabled=false), routes this profile's logbook to
|
||||||
|
// its OWN SQLite file instead of the shared app database. Empty = shared.
|
||||||
|
SqlitePath string `json:"sqlite_path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DBBackendStatus reports which backend OpsLog actually opened at startup so
|
// DBBackendStatus reports which backend OpsLog actually opened at startup so
|
||||||
@@ -1620,8 +1687,39 @@ func (a *App) connectLogbook(cfg profile.ProfileDB) (*sql.DB, string, error) {
|
|||||||
}
|
}
|
||||||
return c, "mysql", nil
|
return c, "mysql", nil
|
||||||
}
|
}
|
||||||
|
// SQLite logbook FILE, separate from the settings/config database. A profile
|
||||||
|
// may point at its own file (cfg.Path, e.g. a visiting operator's log); with
|
||||||
|
// no path it uses the default logbook.db beside the settings db. db.Open
|
||||||
|
// creates + migrates the file if it doesn't exist yet. Only when there is no
|
||||||
|
// default logbook path at all (VACUUM-INTO split failed) do we fall back to the
|
||||||
|
// settings db itself as the logbook.
|
||||||
|
lp := strings.TrimSpace(cfg.Path)
|
||||||
|
if lp == "" {
|
||||||
|
lp = a.logbookPath
|
||||||
|
}
|
||||||
|
if lp == "" {
|
||||||
return a.db, "sqlite", nil
|
return a.db, "sqlite", nil
|
||||||
}
|
}
|
||||||
|
c, err := db.Open(lp)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("open logbook %s: %w", lp, err)
|
||||||
|
}
|
||||||
|
return c, "sqlite", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sqliteHasQSOs reports whether the given (SQLite) database has at least one QSO
|
||||||
|
// row — used once at startup to decide whether to seed the split-out logbook
|
||||||
|
// file from a legacy combined database. Missing table / any error → false.
|
||||||
|
func sqliteHasQSOs(conn *sql.DB) bool {
|
||||||
|
if conn == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
var n int
|
||||||
|
if err := conn.QueryRow("SELECT EXISTS(SELECT 1 FROM qso)").Scan(&n); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return n > 0
|
||||||
|
}
|
||||||
|
|
||||||
// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active
|
// adoptBootstrapMySQL migrates a legacy config.json MySQL config into the active
|
||||||
// profile (one-time), so users who set up MySQL before it became per-profile
|
// profile (one-time), so users who set up MySQL before it became per-profile
|
||||||
@@ -1684,6 +1782,7 @@ func (a *App) GetMySQLSettings() (MySQLSettings, error) {
|
|||||||
d := p.DB
|
d := p.DB
|
||||||
out.Enabled = d.Backend == "mysql"
|
out.Enabled = d.Backend == "mysql"
|
||||||
out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database
|
out.Host, out.User, out.Password, out.Database = d.Host, d.User, d.Password, d.Database
|
||||||
|
out.SqlitePath = d.Path
|
||||||
if d.Port > 0 {
|
if d.Port > 0 {
|
||||||
out.Port = d.Port
|
out.Port = d.Port
|
||||||
}
|
}
|
||||||
@@ -1708,6 +1807,10 @@ func (a *App) SaveMySQLSettings(s MySQLSettings) error {
|
|||||||
cfg.Port = s.Port
|
cfg.Port = s.Port
|
||||||
cfg.User = strings.TrimSpace(s.User)
|
cfg.User = strings.TrimSpace(s.User)
|
||||||
cfg.Password = s.Password
|
cfg.Password = s.Password
|
||||||
|
} else if sp := strings.TrimSpace(s.SqlitePath); sp != "" {
|
||||||
|
// Separate per-profile SQLite logbook file (config stays in opslog.db).
|
||||||
|
cfg.Backend = "sqlite"
|
||||||
|
cfg.Path = sp
|
||||||
}
|
}
|
||||||
if err := a.profiles.SetDB(a.ctx, p.ID, cfg); err != nil {
|
if err := a.profiles.SetDB(a.ctx, p.ID, cfg); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -1716,6 +1819,61 @@ func (a *App) SaveMySQLSettings(s MySQLSettings) error {
|
|||||||
return a.switchLogbook(p)
|
return a.switchLogbook(p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RenameLogbook copies the ACTIVE profile's SQLite logbook to dest (with its
|
||||||
|
// QSOs), repoints the profile at it and switches the live logbook — no restart.
|
||||||
|
// Unlike "choose a dedicated file" (which points at a fresh/empty file), this
|
||||||
|
// carries the data across. The old file is deleted only when it was this
|
||||||
|
// profile's OWN dedicated file; the shared default logbook.db is left in place
|
||||||
|
// (other profiles may use it). Errors if the logbook is MySQL.
|
||||||
|
func (a *App) RenameLogbook(dest string) error {
|
||||||
|
dest = strings.TrimSpace(dest)
|
||||||
|
if dest == "" {
|
||||||
|
return fmt.Errorf("no destination given")
|
||||||
|
}
|
||||||
|
p, err := a.profiles.Active(a.ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("no active profile: %w", err)
|
||||||
|
}
|
||||||
|
if p.DB.Backend == "mysql" {
|
||||||
|
return fmt.Errorf("this profile's logbook is MySQL — no file to rename")
|
||||||
|
}
|
||||||
|
old := strings.TrimSpace(p.DB.Path)
|
||||||
|
wasDedicated := old != ""
|
||||||
|
if old == "" {
|
||||||
|
old = a.logbookPath
|
||||||
|
}
|
||||||
|
if old == "" || a.logDb == nil {
|
||||||
|
return fmt.Errorf("no logbook file to rename")
|
||||||
|
}
|
||||||
|
if strings.EqualFold(filepath.Clean(dest), filepath.Clean(old)) {
|
||||||
|
return fmt.Errorf("that is already the current logbook 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.logDb.ExecContext(a.ctx, "VACUUM INTO '"+safe+"'"); err != nil {
|
||||||
|
return fmt.Errorf("copy logbook: %w", err)
|
||||||
|
}
|
||||||
|
p.DB.Backend = "sqlite"
|
||||||
|
p.DB.Path = dest
|
||||||
|
if err := a.profiles.SetDB(a.ctx, p.ID, p.DB); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := a.switchLogbook(p); err != nil { // opens dest, closes the old conn
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if wasDedicated {
|
||||||
|
for _, f := range []string{old, old + "-wal", old + "-shm"} {
|
||||||
|
_ = os.Remove(f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// TestMySQLConnection pings the shared MySQL database with the given settings
|
// TestMySQLConnection pings the shared MySQL database with the given settings
|
||||||
// (no migrations) so the user can validate connectivity from the UI.
|
// (no migrations) so the user can validate connectivity from the UI.
|
||||||
func (a *App) TestMySQLConnection(s MySQLSettings) error {
|
func (a *App) TestMySQLConnection(s MySQLSettings) error {
|
||||||
@@ -1868,10 +2026,12 @@ func (a *App) groupDigitalSlots() bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) GetUIPref(key string) (string, error) {
|
func (a *App) GetUIPref(key string) (string, error) {
|
||||||
if a.settings == nil {
|
if a.settings == nil || !a.settingsScoped.Load() {
|
||||||
// Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store
|
// Distinct from a genuinely-empty pref: the (LOCAL SQLite) settings store
|
||||||
// isn't wired yet. There's a brief window at launch where the frontend can
|
// isn't wired AND scoped to the active profile yet. There's a brief window at
|
||||||
// call this before OnStartup has opened the DB and built the store. The UI
|
// launch where the frontend can call this before it's ready — reading then
|
||||||
|
// returns the wrong profile's (empty) value, which stopped the theme
|
||||||
|
// self-heal early ("dark theme reverts to light on reopen"). The UI
|
||||||
// uses the error to keep RETRYING rather than treat it as "unset" and fall
|
// uses the error to keep RETRYING rather than treat it as "unset" and fall
|
||||||
// back to a default — the "dark theme reverts to light after an update" bug
|
// back to a default — the "dark theme reverts to light after an update" bug
|
||||||
// (the update cleared localStorage, and the DB read gave up too early while
|
// (the update cleared localStorage, and the DB read gave up too early while
|
||||||
@@ -1882,8 +2042,8 @@ func (a *App) GetUIPref(key string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) SetUIPref(key, value string) error {
|
func (a *App) SetUIPref(key, value string) error {
|
||||||
if a.settings == nil {
|
if a.settings == nil || !a.settingsScoped.Load() {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("settings store not ready") // avoid seeding the wrong profile scope
|
||||||
}
|
}
|
||||||
return a.settings.Set(a.ctx, "ui."+key, value)
|
return a.settings.Set(a.ctx, "ui."+key, value)
|
||||||
}
|
}
|
||||||
@@ -12764,6 +12924,105 @@ func (a *App) AntGeniusDeselect(port int) error {
|
|||||||
return a.antgenius.Activate(port, 0)
|
return a.antgenius.Activate(port, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Tuner Genius XL (4O3A) ATU control (TCP, fixed port 9010) ────────────────
|
||||||
|
|
||||||
|
// TunerGeniusSettings is the JSON shape for the Hardware → Tuner Genius panel.
|
||||||
|
// The TCP port is fixed at 9010 on the device, so only the IP is configurable.
|
||||||
|
type TunerGeniusSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
Password string `json:"password"` // remote-access code; leave blank on LAN (no AUTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTunerGeniusSettings returns the persisted Tuner Genius config.
|
||||||
|
func (a *App) GetTunerGeniusSettings() (TunerGeniusSettings, error) {
|
||||||
|
out := TunerGeniusSettings{}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx, keyTunerGeniusEnabled, keyTunerGeniusHost, keyTunerGeniusPassword)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Enabled = m[keyTunerGeniusEnabled] == "1"
|
||||||
|
out.Host = m[keyTunerGeniusHost]
|
||||||
|
out.Password = m[keyTunerGeniusPassword]
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveTunerGeniusSettings persists the config and (re)starts or stops the client.
|
||||||
|
func (a *App) SaveTunerGeniusSettings(s TunerGeniusSettings) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
keyTunerGeniusEnabled: boolStr(s.Enabled),
|
||||||
|
keyTunerGeniusHost: strings.TrimSpace(s.Host),
|
||||||
|
keyTunerGeniusPassword: s.Password,
|
||||||
|
} {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.startTunerGenius()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startTunerGenius stops any existing client and starts a fresh one if enabled.
|
||||||
|
func (a *App) startTunerGenius() {
|
||||||
|
if a.tunergenius != nil {
|
||||||
|
go a.tunergenius.Stop() // background teardown so saving Settings doesn't block
|
||||||
|
a.tunergenius = nil
|
||||||
|
}
|
||||||
|
s, err := a.GetTunerGeniusSettings()
|
||||||
|
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.tunergenius = tunergenius.New(s.Host, tunergenius.DefaultPort, s.Password)
|
||||||
|
_ = a.tunergenius.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTunerGeniusStatus returns the ATU's current state for the UI poll.
|
||||||
|
func (a *App) GetTunerGeniusStatus() tunergenius.Status {
|
||||||
|
if a.tunergenius == nil {
|
||||||
|
return tunergenius.Status{}
|
||||||
|
}
|
||||||
|
return a.tunergenius.GetStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunerGeniusAutotune starts an automatic tuning cycle on the active channel.
|
||||||
|
func (a *App) TunerGeniusAutotune() error {
|
||||||
|
if a.tunergenius == nil {
|
||||||
|
return fmt.Errorf("Tuner Genius not connected — enable it in Settings → Tuner Genius")
|
||||||
|
}
|
||||||
|
return a.tunergenius.Autotune()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunerGeniusSetBypass engages (true) or clears (false) the global bypass.
|
||||||
|
func (a *App) TunerGeniusSetBypass(on bool) error {
|
||||||
|
if a.tunergenius == nil {
|
||||||
|
return fmt.Errorf("Tuner Genius not connected")
|
||||||
|
}
|
||||||
|
return a.tunergenius.SetBypass(on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunerGeniusSetOperate puts the tuner in OPERATE (true) or STANDBY (false).
|
||||||
|
func (a *App) TunerGeniusSetOperate(on bool) error {
|
||||||
|
if a.tunergenius == nil {
|
||||||
|
return fmt.Errorf("Tuner Genius not connected")
|
||||||
|
}
|
||||||
|
return a.tunergenius.SetOperate(on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunerGeniusActivate selects the active channel (1 = A, 2 = B; or antenna
|
||||||
|
// 1/2/3 on the 3-way variant).
|
||||||
|
func (a *App) TunerGeniusActivate(ch int) error {
|
||||||
|
if a.tunergenius == nil {
|
||||||
|
return fmt.Errorf("Tuner Genius not connected")
|
||||||
|
}
|
||||||
|
return a.tunergenius.Activate(ch)
|
||||||
|
}
|
||||||
|
|
||||||
// ── 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 → Amplifier panel. It covers
|
// PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers
|
||||||
@@ -13227,6 +13486,7 @@ type WinkeyerSettings struct {
|
|||||||
Engine string `json:"engine"` // keyer backend: "winkeyer" | "icom" (rig keyer via CI-V) | "tci"
|
Engine string `json:"engine"` // keyer backend: "winkeyer" | "icom" (rig keyer via CI-V) | "tci"
|
||||||
EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign
|
EscClearsCall bool `json:"esc_clears_call"` // ESC also resets the callsign
|
||||||
SendOnType bool `json:"send_on_type"` // key chars live as typed
|
SendOnType bool `json:"send_on_type"` // key chars live as typed
|
||||||
|
Esm bool `json:"esm"` // Enter-Sends-Message: Enter fires F1/F2/F3 by QSO stage
|
||||||
Macros []WKMacro `json:"macros"`
|
Macros []WKMacro `json:"macros"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13254,7 +13514,7 @@ func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) {
|
|||||||
keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
|
keyWKEnabled, keyWKPort, keyWKBaud, keyWKWPM, keyWKWeight, keyWKLeadIn,
|
||||||
keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
|
keyWKTail, keyWKRatio, keyWKFarnsworth, keyWKSidetone, keyWKMode,
|
||||||
keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
|
keyWKSwap, keyWKAutoSpace, keyWKUsePTT, keyWKSerialEcho, keyWKMacros,
|
||||||
keyWKEngine, keyWKEscClears, keyWKSendOnType, keyWKCWLine, keyWKCWInvert)
|
keyWKEngine, keyWKEscClears, keyWKSendOnType, keyWKEsm, keyWKCWLine, keyWKCWInvert)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -13269,6 +13529,7 @@ func (a *App) GetWinkeyerSettings() (WinkeyerSettings, error) {
|
|||||||
out.EscClearsCall = v == "1"
|
out.EscClearsCall = v == "1"
|
||||||
}
|
}
|
||||||
out.SendOnType = m[keyWKSendOnType] == "1"
|
out.SendOnType = m[keyWKSendOnType] == "1"
|
||||||
|
out.Esm = m[keyWKEsm] == "1"
|
||||||
out.Enabled = m[keyWKEnabled] == "1"
|
out.Enabled = m[keyWKEnabled] == "1"
|
||||||
if v := m[keyWKPort]; v != "" {
|
if v := m[keyWKPort]; v != "" {
|
||||||
out.Port = v
|
out.Port = v
|
||||||
@@ -13331,6 +13592,7 @@ func (a *App) SaveWinkeyerSettings(s WinkeyerSettings) error {
|
|||||||
keyWKEngine: strings.TrimSpace(s.Engine),
|
keyWKEngine: strings.TrimSpace(s.Engine),
|
||||||
keyWKEscClears: boolStr(s.EscClearsCall),
|
keyWKEscClears: boolStr(s.EscClearsCall),
|
||||||
keyWKSendOnType: boolStr(s.SendOnType),
|
keyWKSendOnType: boolStr(s.SendOnType),
|
||||||
|
keyWKEsm: boolStr(s.Esm),
|
||||||
keyWKCWLine: strings.TrimSpace(s.CWKeyLine),
|
keyWKCWLine: strings.TrimSpace(s.CWKeyLine),
|
||||||
keyWKCWInvert: boolStr(s.CWInvert),
|
keyWKCWInvert: boolStr(s.CWInvert),
|
||||||
} {
|
} {
|
||||||
@@ -13426,6 +13688,7 @@ func defaultWKMacros() []WKMacro {
|
|||||||
{Label: "73", Text: "<CALL> TU 73 DE <MY_CALL> "},
|
{Label: "73", Text: "<CALL> TU 73 DE <MY_CALL> "},
|
||||||
{Label: "QRL?", Text: "QRL? "},
|
{Label: "QRL?", Text: "QRL? "},
|
||||||
{Label: "AGN", Text: "AGN "},
|
{Label: "AGN", Text: "AGN "},
|
||||||
|
{Label: "QRZ?", Text: "QRZ? DE <MY_CALL> "},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+48
-2
@@ -1,10 +1,56 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.21.1",
|
||||||
|
"date": "2026-07-24",
|
||||||
|
"en": [
|
||||||
|
"Ctrl + mouse wheel zoom is now remembered across restarts (Ctrl+0 resets to 100%).",
|
||||||
|
"Log grid: award column widths are now saved like the other columns, so a width you set survives a restart.",
|
||||||
|
"CW keyer widget: added an F9 macro slot, and empty macros are now hidden (like the voice keyer) — fill them in Settings → CW Keyer.",
|
||||||
|
"New: ESM (Enter Sends Message) for CW, N1MM-style. Enable it in Settings → CW Keyer. With the keyer on in CW, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs the QSO if the macro contains <LOGQSO>.",
|
||||||
|
"The top frequency readout is now scroll-tunable: roll the mouse wheel over the hundreds / tens / units-of-kHz digit to step the frequency by 100 / 10 / 1 kHz, and the rig follows over CAT.",
|
||||||
|
"New: 4O3A Tuner Genius XL control. Enable it in Settings → Tuner Genius (IP only; port is fixed at 9010). Live SWR and forward power with Tune, Bypass and Operate/Standby, the two channels A/B (source, frequency and antenna) shown and click-selectable. Available as a docked widget, a card in the FlexRadio panel (like the PowerGenius) and a card in Station Control. Controlled directly over TCP, so it uses just one of the box's connection slots.",
|
||||||
|
"FlexRadio panel tidy-up: cards collapse from the chevron in their header (Transmit and Receive fold together), all meters are the same size, the MIC and COMP meters only show in phone modes, and the S-meter dBm now sits next to the S-value instead of on a second line.",
|
||||||
|
"Fixed the colour theme sometimes reverting to the default when reopening OpsLog — it's now restored reliably.",
|
||||||
|
"ADIF export field picker: the per-group All / None buttons work again.",
|
||||||
|
"Station Control: cards now pack tightly into balanced columns instead of leaving big gaps under shorter panels — a cleaner, more even dashboard.",
|
||||||
|
"Main-view band map: Ctrl+↑ / Ctrl+↓ jumps to the next spot above / below the current frequency and tunes to it.",
|
||||||
|
"QSO details → Awards: the 'this contact will count for' list is now compact (CODE@REF chips, full name on hover) with a capped height, so it no longer hides the awards you've selected.",
|
||||||
|
"Fixed award references (in the entry strip's Awards tab) not clearing when the callsign changes — clicking one spot then another no longer keeps the previous station's references."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Le zoom Ctrl + molette est maintenant conservé après un redémarrage (Ctrl+0 remet à 100 %).",
|
||||||
|
"Grille du log : les largeurs des colonnes de diplômes sont désormais sauvegardées comme les autres colonnes, une largeur réglée survit au redémarrage.",
|
||||||
|
"Widget keyer CW : ajout d'un emplacement de macro F9, et les macros vides sont maintenant masquées (comme le keyer vocal) — remplis-les dans Réglages → Keyer CW.",
|
||||||
|
"Nouveau : ESM (Entrée envoie le message) en CW, façon N1MM. À activer dans Réglages → Keyer CW. Avec le keyer actif en CW, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue le QSO si le macro contient <LOGQSO>.",
|
||||||
|
"L'affichage de fréquence en haut est maintenant accordable à la molette : roule la molette sur le chiffre des centaines / dizaines / unités de kHz pour changer la fréquence par pas de 100 / 10 / 1 kHz, et la radio suit en CAT.",
|
||||||
|
"Nouveau : contrôle du 4O3A Tuner Genius XL. Active-le dans Réglages → Tuner Genius (IP seulement ; port fixé à 9010). ROS et puissance directe en direct avec Accord, Bypass et Operate/Standby, et les deux canaux A/B (source, fréquence et antenne) affichés et sélectionnables d'un clic. Disponible en widget ancré, en carte dans le panneau FlexRadio (comme le PowerGenius) et en carte dans Station Control. Piloté directement en TCP, il n'utilise qu'une des connexions de la boîte.",
|
||||||
|
"Nettoyage du panneau FlexRadio : les cartes se replient via le chevron de leur en-tête (Transmit et Receive se replient ensemble), tous les meters ont la même taille, les meters MIC et COMP ne s'affichent qu'en phonie, et le dBm du S-mètre est maintenant à côté de la valeur S plutôt que sur une deuxième ligne.",
|
||||||
|
"Correction du thème qui revenait parfois au défaut à la réouverture d'OpsLog — il est maintenant restauré de façon fiable.",
|
||||||
|
"Sélecteur de champs à l'export ADIF : les boutons Tout / Aucun par groupe refonctionnent.",
|
||||||
|
"Station Control : les cartes se rangent en colonnes équilibrées et se tassent au lieu de laisser de gros trous sous les panneaux plus courts — tableau de bord plus propre et régulier.",
|
||||||
|
"Band map de l'écran principal : Ctrl+↑ / Ctrl+↓ saute au spot suivant au-dessus / en dessous de la fréquence courante et s'y accorde.",
|
||||||
|
"Détails du QSO → Diplômes : la liste « ce contact comptera pour » est maintenant compacte (pastilles CODE@REF, nom complet au survol) et de hauteur limitée, elle ne masque plus les diplômes sélectionnés.",
|
||||||
|
"Correction des références de diplômes (onglet Diplômes de la bande de saisie) qui ne se vidaient pas au changement d'indicatif — cliquer un spot puis un autre ne garde plus les références de la station précédente."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.21.0",
|
||||||
|
"date": "2026-07-24",
|
||||||
|
"en": [
|
||||||
|
"⚠️ IMPORTANT — ARCHITECTURE CHANGE. OpsLog now keeps your settings/profiles and your QSO logbook in SEPARATE database files (before, everything was in one file). On the first launch after this update, your existing database is split AUTOMATICALLY and non-destructively: your contacts are copied into a new logbook file (logbook.db) while the originals stay untouched in the settings database as a backup — nothing is deleted, no QSO is lost. Existing installs keep their opslog.db as the settings database; fresh installs name it settings.db. As a precaution, back up your OpsLog data folder before updating. Afterwards, Settings → Database shows the settings database and this profile's logbook as two separate sections.",
|
||||||
|
"Your QSOs and your settings now live in separate files: settings + profiles stay in the settings database, while contacts go to a dedicated logbook file (existing logs are migrated automatically, originals kept as a backup). A profile can also point at its own logbook file — ideal for a visiting operator, whose contacts stay out of your log without ever touching your settings or profiles. The Database panel now clearly shows the two, with an 'Open folder' shortcut, and a logbook file can be renamed/relocated (its QSOs move with it)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"⚠️ IMPORTANT — CHANGEMENT D'ARCHITECTURE. OpsLog stocke désormais tes réglages/profils et ton journal de QSO dans des fichiers de base de données SÉPARÉS (avant, tout était dans un seul fichier). Au premier lancement après cette mise à jour, ta base existante est scindée AUTOMATIQUEMENT et sans destruction : tes contacts sont copiés dans un nouveau fichier journal (logbook.db) tandis que les originaux restent intacts dans la base de réglages en sauvegarde — rien n'est supprimé, aucun QSO n'est perdu. Les installs existantes gardent leur opslog.db comme base de réglages ; les nouvelles installs la nomment settings.db. Par précaution, sauvegarde ton dossier de données OpsLog avant de mettre à jour. Ensuite, Réglages → Base de données affiche la base de réglages et le journal de ce profil en deux sections distinctes.",
|
||||||
|
"Tes QSO et tes réglages sont désormais dans des fichiers séparés : réglages + profils dans la base de réglages, contacts dans un fichier journal dédié (les journaux existants sont migrés automatiquement, les originaux gardés en sauvegarde). Un profil peut aussi pointer vers son propre fichier journal — idéal pour un opérateur de passage, dont les contacts restent hors de ton journal sans jamais toucher tes réglages ni profils. Le panneau Base de données montre maintenant clairement les deux, avec un raccourci « Ouvrir le dossier », et un fichier journal peut être renommé/déplacé (ses QSO le suivent)."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.20.12",
|
"version": "0.20.12",
|
||||||
"date": "2026-07-24",
|
"date": "2026-07-24",
|
||||||
"en": [
|
"en": [
|
||||||
"Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (‘Interlock is preventing transmission’). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the ‘follow the rig’ loop re-sent a tune command on almost every poll, and each command re-armed the ‘block TX while the antenna moves’ window — so the interlock never released. The follow loop now keys its deadband off the rig’s frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an ‘interlock set reason=’ command that SmartSDR rejects (it’s read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR ‘Tunable range’ setting (Settings → Hardware → Antenna, default 13–54 MHz = 20 m–6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m–6 m SteppIR no longer tries to move it or touches the interlock.",
|
"Fixed a motorized-antenna bug that could leave a FlexRadio permanently unable to transmit (‘Interlock is preventing transmission’). With a SteppIR whose status frequency reads intermittently (it flipped between the commanded frequency and its home value), the ‘follow the rig’ loop re-sent a tune command on almost every poll, and each command re-armed the ‘block TX while the antenna moves’ window — so the interlock never released. The follow loop now keys its deadband off the rig’s frequency (only re-tuning when the RADIO actually QSYs), immune to a flaky antenna status. Also dropped an ‘interlock set reason=’ command that SmartSDR rejects (it’s read-only) and only produced a harmless error line — the transmit-inhibit itself is unchanged and still holds TX safely while the elements move. New: a SteppIR ‘Tunable range’ setting (Settings → Hardware → Antenna, default 13–54 MHz = 20 m–6 m) — on a band outside it OpsLog leaves the antenna and TX completely alone, so tuning to 30 m on a 20 m–6 m SteppIR no longer tries to move it or touches the interlock.",
|
||||||
"New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA). Ships with the full canton reference list; matches the canton from the QSO's address or QTH, on HF, for Swiss (HB) contacts. Enable it in Awards and rescan to see your standings.",
|
"New built-in award: The Helvetia 26 Award (H26) — the 26 cantons of Switzerland (USKA), matched from the QSO's address or QTH on HF. Each canton also recognises its main cities (Genève, Lausanne, Zürich, Bellinzona…), since operators rarely write the canton itself.",
|
||||||
"FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.",
|
"FlexRadio panel: the RECEIVE card is shorter again. All the noise controls added recently made it tower, so only the everyday ones — NB, NR, ANF — now stay visible; WNB and the SmartSDR v4 DSP block (NRL/NRS/NRF/ANFL and the AI/FFT RNN & ANFT) tuck behind a 'DSP' button you expand when you need them. The button carries a dot and highlights when one of the hidden controls is switched on, so nothing active is ever out of sight, and its open/closed state is remembered.",
|
||||||
"ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.",
|
"ADIF export can now pick exactly which fields to write. The global 'Export to ADIF' dialog gains a third choice, 'Choose fields…', alongside 'Standard ADIF fields' and 'All OpsLog fields'; and right-clicking selected QSOs adds 'Export selected — choose fields…'. The picker separates the official ADIF 3.1.7 fields (grouped by category) from OpsLog / non-standard tags actually present in your log, with All/None per group and a one-click reset to defaults. Your selection is remembered for next time.",
|
||||||
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
|
"Fixed 'Incorrect string value' (MySQL error 1366) when a QSO contained non-Latin characters — Cyrillic (Я), Polish ł, etc. — e.g. updating from QRZ. It happened when the shared MySQL database had been pre-created by the hosting panel as latin1, so OpsLog's tables couldn't store those letters. OpsLog now converts the database and its tables to utf8mb4 on connect (once, automatically), and everything stores correctly. Local SQLite logbooks were never affected.",
|
||||||
@@ -17,7 +63,7 @@
|
|||||||
],
|
],
|
||||||
"fr": [
|
"fr": [
|
||||||
"Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.",
|
"Correction d'un bug d'antenne motorisée qui pouvait laisser un FlexRadio définitivement incapable d'émettre (« Interlock is preventing transmission »). Avec une SteppIR dont la fréquence de statut se lit par intermittence (elle alternait entre la fréquence commandée et sa valeur de repos), la boucle de suivi renvoyait un ordre d'accord à presque chaque cycle, et chaque ordre réarmait la fenêtre « bloquer l'émission pendant que l'antenne bouge » — l'interlock ne se relâchait donc jamais. La boucle de suivi se base maintenant sur la fréquence de la RADIO (elle ne réaccorde que quand le poste change réellement de fréquence), insensible à un statut d'antenne erratique. Retrait aussi d'une commande « interlock set reason= » que SmartSDR refuse (champ en lecture seule) et qui ne produisait qu'une ligne d'erreur sans effet — l'inhibition d'émission elle-même est inchangée et protège toujours pendant le mouvement des éléments. Nouveau : un réglage « Plage accordable » pour la SteppIR (Réglages → Matériel → Antenne, défaut 13-54 MHz = 20 m-6 m) — sur une bande hors de cette plage, OpsLog laisse totalement l'antenne et l'émission tranquilles, donc passer sur 30 m avec une SteppIR 20 m-6 m ne tente plus de la bouger ni ne touche à l'interlock.",
|
||||||
"Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA). Livré avec la liste complète des cantons ; il reconnaît le canton depuis l'adresse ou le QTH du QSO, en HF, pour les contacts suisses (HB). Active-le dans les Diplômes et relance un scan pour voir ton avancement.",
|
"Nouveau diplôme intégré : The Helvetia 26 Award (H26) — les 26 cantons de Suisse (USKA), reconnus depuis l'adresse ou le QTH du QSO en HF. Chaque canton reconnaît aussi ses principales villes (Genève, Lausanne, Zürich, Bellinzone…), car les opérateurs écrivent rarement le canton lui-même.",
|
||||||
"Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.",
|
"Panneau FlexRadio : la carte RÉCEPTION est de nouveau plus compacte. Tous les contrôles de bruit ajoutés récemment la faisaient s'allonger, donc seuls ceux du quotidien — NB, NR, ANF — restent visibles ; le WNB et le bloc DSP SmartSDR v4 (NRL/NRS/NRF/ANFL ainsi que RNN & ANFT IA/FFT) se replient derrière un bouton « DSP » que tu déplies au besoin. Le bouton porte un point et s'illumine quand l'un des contrôles masqués est activé, pour ne jamais perdre de vue quelque chose d'actif, et son état ouvert/fermé est mémorisé.",
|
||||||
"L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.",
|
"L'export ADIF permet maintenant de choisir précisément les champs à écrire. La fenêtre globale « Exporter en ADIF » gagne un troisième choix, « Choisir les champs… », à côté de « Champs ADIF standard » et « Tous les champs OpsLog » ; et le clic droit sur des QSO sélectionnés ajoute « Exporter la sélection — choisir les champs… ». Le sélecteur sépare les champs ADIF 3.1.7 officiels (regroupés par catégorie) des balises OpsLog / non standard réellement présentes dans ton log, avec Tout/Aucun par groupe et un retour aux valeurs par défaut en un clic. Ta sélection est mémorisée pour la prochaine fois.",
|
||||||
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
|
"Correction de « Incorrect string value » (erreur MySQL 1366) quand un QSO contenait des caractères non latins — cyrillique (Я), polonais ł, etc. — p. ex. lors d'une mise à jour depuis QRZ. Ça arrivait quand la base MySQL partagée avait été pré-créée en latin1 par le panel d'hébergement, empêchant les tables d'OpsLog de stocker ces lettres. OpsLog convertit désormais la base et ses tables en utf8mb4 à la connexion (une seule fois, automatiquement), et tout s'enregistre correctement. Les logbooks SQLite locaux n'étaient pas concernés.",
|
||||||
|
|||||||
+214
-14
@@ -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, Ear, Eraser, Gauge, 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';
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
GetDBConnectionInfo, GetLogbookRevision,
|
GetDBConnectionInfo, GetLogbookRevision,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection,
|
GetUltrabeamStatus, SetUltrabeamDirection,
|
||||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||||
|
GetTunerGeniusStatus, GetTunerGeniusSettings, TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate,
|
||||||
OpenExternalURL,
|
OpenExternalURL,
|
||||||
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand,
|
||||||
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
ListClusterServers, ClusterSpotStatuses, SendClusterSpot,
|
||||||
@@ -69,6 +70,7 @@ import { WorldMap, LocatorMap } from '@/components/MainMap';
|
|||||||
import { FlexPanel } from '@/components/FlexPanel';
|
import { FlexPanel } from '@/components/FlexPanel';
|
||||||
import { IcomPanel } from '@/components/IcomPanel';
|
import { IcomPanel } from '@/components/IcomPanel';
|
||||||
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||||
|
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||||
import { StatsPanel } from '@/components/StatsPanel';
|
import { StatsPanel } from '@/components/StatsPanel';
|
||||||
@@ -209,6 +211,36 @@ function fmtFreqDots(mhzStr: string): string {
|
|||||||
const frac = (fracRaw + '000000').slice(0, 6);
|
const frac = (fracRaw + '000000').slice(0, 6);
|
||||||
return `${intPart}.${frac.slice(0, 3)}.${frac.slice(3, 6)}`;
|
return `${intPart}.${frac.slice(0, 3)}.${frac.slice(3, 6)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FreqWheelDisplay renders a frequency (MHz string) like fmtFreqDots — MHz.kHz.Hz
|
||||||
|
// — but makes the three kHz digits scroll-sensitive: rolling the mouse wheel over
|
||||||
|
// the hundreds / tens / units-of-kHz digit steps the frequency by 100 / 10 / 1 kHz
|
||||||
|
// (up = wheel up). onNudge receives the delta in Hz. The MHz and Hz digits are
|
||||||
|
// static (only kHz stepping was requested). Used in the header + compact top bar.
|
||||||
|
function FreqWheelDisplay({ mhz, onNudge, className, placeholder = '—.———.———' }: {
|
||||||
|
mhz: string; onNudge: (deltaHz: number) => void; className?: string; placeholder?: string;
|
||||||
|
}) {
|
||||||
|
if (!mhz) return <span className={className}>{placeholder}</span>;
|
||||||
|
const [intPart, fracRaw = ''] = mhz.split('.');
|
||||||
|
const frac = (fracRaw + '000000').slice(0, 6);
|
||||||
|
const khz = frac.slice(0, 3); // [hundreds, tens, units] of kHz
|
||||||
|
const hz = frac.slice(3, 6);
|
||||||
|
const stepHz = [100_000, 10_000, 1_000]; // per kHz digit: 100 / 10 / 1 kHz
|
||||||
|
return (
|
||||||
|
<span className={className}>
|
||||||
|
{intPart}.
|
||||||
|
{khz.split('').map((d, i) => (
|
||||||
|
<span key={i}
|
||||||
|
onWheel={(e) => { if (e.ctrlKey || e.metaKey) return; e.preventDefault(); e.stopPropagation(); onNudge(e.deltaY < 0 ? stepHz[i] : -stepHz[i]); }}
|
||||||
|
className="cursor-ns-resize rounded-[2px] hover:bg-primary/25 transition-colors"
|
||||||
|
title="Scroll to change frequency">
|
||||||
|
{d}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
.{hz}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
// shortCatError condenses a backend error into a few words for the topbar
|
// shortCatError condenses a backend error into a few words for the topbar
|
||||||
// pill. The full message stays in the tooltip. Recognises the common cases
|
// pill. The full message stays in the tooltip. Recognises the common cases
|
||||||
// (OmniRig not installed, not registered) and otherwise truncates.
|
// (OmniRig not installed, not registered) and otherwise truncates.
|
||||||
@@ -439,6 +471,8 @@ export default function App() {
|
|||||||
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false });
|
||||||
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
|
const [agStatus, setAgStatus] = useState<AGStatus>({ connected: false, port_a: 0, port_b: 0, antennas: [] });
|
||||||
const [agEnabled, setAgEnabled] = useState(false);
|
const [agEnabled, setAgEnabled] = useState(false);
|
||||||
|
const [tgStatus, setTgStatus] = useState<TGStatus>({ connected: false });
|
||||||
|
const [tgEnabled, setTgEnabled] = useState(false);
|
||||||
// Per-port optimistic selection that the status poll must not revert until the
|
// Per-port optimistic selection that the status poll must not revert until the
|
||||||
// device confirms it (or it expires) — otherwise a stale poll right after a
|
// device confirms it (or it expires) — otherwise a stale poll right after a
|
||||||
// click reverts the UI and the click looks like it did nothing.
|
// click reverts the UI and the click looks like it did nothing.
|
||||||
@@ -806,6 +840,49 @@ export default function App() {
|
|||||||
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
|
const [wkSent, setWkSent] = useState(''); // rolling text the keyer echoes as it transmits
|
||||||
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
|
const [wkEscClears, setWkEscClears] = useState(true); // ESC also clears the callsign
|
||||||
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
|
const [wkSendOnType, setWkSendOnType] = useState(false); // key chars live as typed
|
||||||
|
const [wkEsm, setWkEsm] = useState(false); // Enter-Sends-Message (N1MM-style CW flow)
|
||||||
|
const wkEsmRef = useRef(false);
|
||||||
|
useEffect(() => { wkEsmRef.current = wkEsm; }, [wkEsm]);
|
||||||
|
|
||||||
|
// Persistent Ctrl+wheel zoom. The native WebView2 Ctrl+wheel zoom isn't saved,
|
||||||
|
// so we run our own, factor stored in localStorage and restored at startup;
|
||||||
|
// Ctrl+0 resets to 100%.
|
||||||
|
//
|
||||||
|
// We use `transform: scale` (NOT CSS `zoom`): `zoom` re-lays-out and rounds each
|
||||||
|
// element to the pixel grid, which opens ~1px seams between map tiles. `transform`
|
||||||
|
// scales the whole subtree as one composited layer, so tiles stay seamless. The
|
||||||
|
// app root is counter-sized to (100/z)vw × (100/z)vh and scaled from its top-left
|
||||||
|
// by z, so it reflows to fill that larger box and then scales back to exactly the
|
||||||
|
// window (more content visible when zoomed out, like a real browser zoom).
|
||||||
|
const appRootRef = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const KEY = 'opslog.uiZoom';
|
||||||
|
let z = parseFloat(localStorage.getItem(KEY) || '1');
|
||||||
|
if (!Number.isFinite(z) || z <= 0) z = 1;
|
||||||
|
const apply = () => {
|
||||||
|
const el = appRootRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
el.style.transformOrigin = '0 0';
|
||||||
|
el.style.transform = z === 1 ? '' : `scale(${z})`;
|
||||||
|
el.style.width = `${100 / z}vw`;
|
||||||
|
el.style.height = `${100 / z}vh`;
|
||||||
|
document.documentElement.setAttribute('data-uizoom', String(z));
|
||||||
|
};
|
||||||
|
apply();
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
if (!e.ctrlKey && !e.metaKey) return; // plain wheel is left alone (scroll / freq nudge)
|
||||||
|
e.preventDefault();
|
||||||
|
z = Math.min(2.5, Math.max(0.5, Math.round((z + (e.deltaY < 0 ? 0.1 : -0.1)) * 10) / 10));
|
||||||
|
localStorage.setItem(KEY, String(z));
|
||||||
|
apply();
|
||||||
|
};
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && (e.key === '0')) { e.preventDefault(); z = 1; localStorage.setItem(KEY, '1'); apply(); }
|
||||||
|
};
|
||||||
|
window.addEventListener('wheel', onWheel, { passive: false });
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => { window.removeEventListener('wheel', onWheel); window.removeEventListener('keydown', onKey); };
|
||||||
|
}, []);
|
||||||
// CW keyer output engine (persisted in the WinKeyer settings, chosen in
|
// CW keyer output engine (persisted in the WinKeyer settings, chosen in
|
||||||
// Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via
|
// Settings → CW Keyer): the WinKeyer hardware, or the Icom rig's own keyer via
|
||||||
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
// CI-V 0x17 (no extra hardware — sends over the CAT connection). Macros,
|
||||||
@@ -1473,6 +1550,7 @@ export default function App() {
|
|||||||
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
// Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs).
|
||||||
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0');
|
||||||
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
|
const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0');
|
||||||
|
const [showTuner, setShowTuner] = useState(() => localStorage.getItem('opslog.showTuner') !== '0');
|
||||||
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0');
|
||||||
|
|
||||||
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
// Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route
|
||||||
@@ -1668,6 +1746,42 @@ export default function App() {
|
|||||||
AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e)));
|
AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e)));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Poll the Tuner Genius XL for SWR / power / operating state. Re-read the
|
||||||
|
// enabled flag each tick so toggling it in Settings shows/hides the widget
|
||||||
|
// without an app restart.
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
|
||||||
|
try {
|
||||||
|
const s = (await GetTunerGeniusStatus()) as TGStatus;
|
||||||
|
if (!alive || !s) return;
|
||||||
|
setTgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
// Fast poll so the SWR/power meters track TX responsively (backend polls the
|
||||||
|
// device at ~400ms; 500ms here keeps the UI close behind).
|
||||||
|
const id = window.setInterval(tick, 500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const tgTune = () => {
|
||||||
|
setTgStatus((s) => ({ ...s, tuning: true })); // optimistic
|
||||||
|
TunerGeniusAutotune().catch((e) => setError(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
const tgBypass = (on: boolean) => {
|
||||||
|
setTgStatus((s) => ({ ...s, bypass: on }));
|
||||||
|
TunerGeniusSetBypass(on).catch((e) => setError(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
const tgOperate = (on: boolean) => {
|
||||||
|
setTgStatus((s) => ({ ...s, operate: on }));
|
||||||
|
TunerGeniusSetOperate(on).catch((e) => setError(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
const tgActivate = (ch: number) => {
|
||||||
|
setTgStatus((s) => ({ ...s, active: ch })); // optimistic
|
||||||
|
TunerGeniusActivate(ch).catch((e) => setError(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
|
||||||
// RX band auto-follows the TX band (only differs for cross-band work).
|
// RX band auto-follows the TX band (only differs for cross-band work).
|
||||||
useEffect(() => { setBandRx(band); }, [band]);
|
useEffect(() => { setBandRx(band); }, [band]);
|
||||||
|
|
||||||
@@ -2163,9 +2277,12 @@ export default function App() {
|
|||||||
setWkEnabled(!!s.enabled);
|
setWkEnabled(!!s.enabled);
|
||||||
setWkPort(s.port ?? '');
|
setWkPort(s.port ?? '');
|
||||||
setWkWpm(s.wpm ?? 25);
|
setWkWpm(s.wpm ?? 25);
|
||||||
setWkMacros((s.macros ?? []) as WKMacro[]);
|
// Pad to 9 slots (F1–F9) so an F9 macro always exists to fill; empty ones
|
||||||
|
// are hidden in the widget.
|
||||||
|
{ const mac = ((s.macros ?? []) as WKMacro[]).slice(); while (mac.length < 9) mac.push({ label: '', text: '' }); setWkMacros(mac); }
|
||||||
setWkEscClears(s.esc_clears_call !== false);
|
setWkEscClears(s.esc_clears_call !== false);
|
||||||
setWkSendOnType(!!s.send_on_type);
|
setWkSendOnType(!!s.send_on_type);
|
||||||
|
setWkEsm(!!s.esm);
|
||||||
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : s.engine === 'serial' ? 'serial' : 'winkeyer');
|
setWkEngine(s.engine === 'icom' ? 'icom' : s.engine === 'flex' ? 'flex' : s.engine === 'serial' ? 'serial' : 'winkeyer');
|
||||||
} catch { /* keyer not configured */ }
|
} catch { /* keyer not configured */ }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -2372,6 +2489,32 @@ export default function App() {
|
|||||||
WinkeyerBackspace().catch(() => {});
|
WinkeyerBackspace().catch(() => {});
|
||||||
}
|
}
|
||||||
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
|
function wkToggleSendOnType(on: boolean) { setWkSendOnType(on); saveWk({ send_on_type: on }); }
|
||||||
|
function wkToggleEsm(on: boolean) { setWkEsm(on); saveWk({ esm: on }); }
|
||||||
|
// ESM (Enter Sends Message): N1MM-style CW flow. When ESM is on, the CW keyer is
|
||||||
|
// active and we're in CW, Enter fires a macro by QSO stage instead of logging:
|
||||||
|
// • callsign empty → F1 (CQ)
|
||||||
|
// • callsign entered → F2 (report), then focus jumps to RST-sent
|
||||||
|
// • focus in RST-sent/rcvd → F3 (TU) — which logs IF the macro has <LOGQSO>
|
||||||
|
// Returns true when it handled the Enter (so the caller skips the plain log).
|
||||||
|
function esmHandleEnter(target: HTMLElement): boolean {
|
||||||
|
if (!(wkEsmRef.current && wkActiveRef.current && (mode || '').toUpperCase().includes('CW'))) return false;
|
||||||
|
const field = target.closest('[data-esm]')?.getAttribute('data-esm');
|
||||||
|
if (field === 'rsttx' || field === 'rstrx') {
|
||||||
|
wkSendMacro(2); // F3 (TU) — logs via its own <LOGQSO> if present
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (field === 'call') {
|
||||||
|
if (callsignValRef.current.trim() === '') {
|
||||||
|
wkSendMacro(0); // F1 (CQ)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
wkSendMacro(1); // F2 (report)
|
||||||
|
// Move focus to RST-sent so the next Enter fires F3.
|
||||||
|
(document.querySelector('[data-esm="rsttx"] input') as HTMLInputElement | null)?.focus();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false; // any other field → normal behaviour (log on Enter)
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
// don't hammer the backend at firehose rate. The mode passed to the
|
// don't hammer the backend at firehose rate. The mode passed to the
|
||||||
@@ -2836,17 +2979,19 @@ export default function App() {
|
|||||||
// reload worked-before + the band matrix, making them flicker. Compared
|
// reload worked-before + the band matrix, making them flicker. Compared
|
||||||
// via the ref so it's correct even from the stale UDP closure.
|
// via the ref so it's correct even from the stale UDP closure.
|
||||||
if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
|
if (v.trim().toUpperCase() === callsignValRef.current.trim().toUpperCase()) return;
|
||||||
|
// The callsign CHANGED (past the same-call guard) → drop the previous
|
||||||
|
// contact's award references. They're auto-added per call (live detection
|
||||||
|
// merges pickable refs into award_refs) or picked by hand, so without clearing
|
||||||
|
// here they carry over to the NEXT call — e.g. clicking one Italian spot
|
||||||
|
// (WAIP@RG), then another (WAIP@PG), then EJ7IRB still showing both. The new
|
||||||
|
// call's lookup re-detects its own refs right after. Covers wipe AND swap.
|
||||||
|
updateDetails({ award_refs: '' });
|
||||||
// QSO recorder: a non-empty callsign marks the QSO start (the recorder
|
// QSO recorder: a non-empty callsign marks the QSO start (the recorder
|
||||||
// keeps the pre-roll from before this); clearing it discards the take.
|
// keeps the pre-roll from before this); clearing it discards the take.
|
||||||
// Recording START happens on blur (leaving the callsign field), NOT here —
|
// Recording START happens on blur (leaving the callsign field), NOT here —
|
||||||
// you may type a call and work it minutes later. Clearing it cancels.
|
// you may type a call and work it minutes later. Clearing it cancels.
|
||||||
if (v.trim() === '') {
|
if (v.trim() === '') {
|
||||||
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
QSOAudioCancel(); setRecording(false); recordingCallRef.current = "";
|
||||||
// Callsign wiped → drop this contact's award references. They are auto-added
|
|
||||||
// per call (live detection merges pickable refs into award_refs), so without
|
|
||||||
// this they'd carry over to the NEXT call — e.g. IT9AOT's ref lingering when
|
|
||||||
// you then type F4BPO, showing both in the F3 Awards tab.
|
|
||||||
updateDetails({ award_refs: '' });
|
|
||||||
}
|
}
|
||||||
const isEmpty = v.trim() === '';
|
const isEmpty = v.trim() === '';
|
||||||
if (!isEmpty && !locks.start) {
|
if (!isEmpty && !locks.start) {
|
||||||
@@ -3138,7 +3283,7 @@ export default function App() {
|
|||||||
// them as shared consts avoids duplicating the (large) per-field JSX +
|
// them as shared consts avoids duplicating the (large) per-field JSX +
|
||||||
// handlers across the two layouts.
|
// handlers across the two layouts.
|
||||||
const callsignBlock = (
|
const callsignBlock = (
|
||||||
<div className="flex flex-col w-44">
|
<div className="flex flex-col w-44" data-esm="call">
|
||||||
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
<Label className="flex items-center gap-2 h-3.5" style={{ marginBottom: 6 }}>
|
||||||
{t('field.callsign')}
|
{t('field.callsign')}
|
||||||
{lookupBusy && (
|
{lookupBusy && (
|
||||||
@@ -3211,12 +3356,12 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const rstTxBlock = (
|
const rstTxBlock = (
|
||||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
|
<div className="flex flex-col w-20" data-esm="rsttx"><Label className="mb-1 h-3.5">{t('field.rstTx')}</Label>
|
||||||
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
<Combobox value={rstSent} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstSent(v); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
const rstRxBlock = (
|
const rstRxBlock = (
|
||||||
<div className="flex flex-col w-20"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
|
<div className="flex flex-col w-20" data-esm="rstrx"><Label className="mb-1 h-3.5">{t('field.rstRx')}</Label>
|
||||||
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
<Combobox value={rstRcvd} options={rstOptions(mode, rstLists)} commitOnType onChange={(v) => { setRstRcvd(v); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -3500,6 +3645,30 @@ export default function App() {
|
|||||||
noteManualEdit();
|
noteManualEdit();
|
||||||
SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
|
SetCATFrequency(Math.round(mhz * 1_000_000)).catch(() => {});
|
||||||
};
|
};
|
||||||
|
// Mouse-wheel over a kHz digit of the top frequency readout: step the frequency
|
||||||
|
// and (if CAT is connected) QSY the rig. The display updates optimistically on
|
||||||
|
// every notch; the actual radio tune is debounced so a fast scroll doesn't flood
|
||||||
|
// the CAT link. nudgeAccumRef holds the live value across a burst (setFreqMhz is
|
||||||
|
// async, so we can't re-read it between notches).
|
||||||
|
const nudgeAccumRef = useRef<number | null>(null);
|
||||||
|
const nudgeCatTimer = useRef<number | null>(null);
|
||||||
|
const nudgeFreqHz = (deltaHz: number) => {
|
||||||
|
let cur = nudgeAccumRef.current;
|
||||||
|
if (cur == null) cur = freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0;
|
||||||
|
if (!cur) return;
|
||||||
|
const newHz = Math.max(0, cur + deltaHz);
|
||||||
|
nudgeAccumRef.current = newHz;
|
||||||
|
setFreqMhz((newHz / 1_000_000).toFixed(6));
|
||||||
|
noteManualEdit();
|
||||||
|
const b = bandForMHz(newHz / 1_000_000); if (b) setBand(b);
|
||||||
|
if (nudgeCatTimer.current) window.clearTimeout(nudgeCatTimer.current);
|
||||||
|
nudgeCatTimer.current = window.setTimeout(() => {
|
||||||
|
const hz = nudgeAccumRef.current;
|
||||||
|
nudgeAccumRef.current = null;
|
||||||
|
nudgeCatTimer.current = null;
|
||||||
|
if (hz && catState.enabled && catState.connected) SetCATFrequency(hz).catch(() => {});
|
||||||
|
}, 150);
|
||||||
|
};
|
||||||
const freqBlock = (
|
const freqBlock = (
|
||||||
<div className="flex flex-col w-32">
|
<div className="flex flex-col w-32">
|
||||||
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
|
<Label className="mb-1 h-3.5 flex items-center gap-1">{t('field.txFreq')} <LockBtn k="freq" title="frequency" /></Label>
|
||||||
@@ -3881,7 +4050,7 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen overflow-hidden bg-background">
|
<div ref={appRootRef} className="flex flex-col h-screen overflow-hidden bg-background">
|
||||||
<ShutdownProgress />
|
<ShutdownProgress />
|
||||||
{/* ===== TOPBAR ===== */}
|
{/* ===== TOPBAR ===== */}
|
||||||
{compact ? (
|
{compact ? (
|
||||||
@@ -3893,7 +4062,7 @@ export default function App() {
|
|||||||
<span className="font-bold text-xs tracking-tight">OpsLog</span>
|
<span className="font-bold text-xs tracking-tight">OpsLog</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-baseline gap-1.5 font-mono ml-2">
|
<div className="flex items-baseline gap-1.5 font-mono ml-2">
|
||||||
<span className="text-sm font-semibold text-primary">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
<FreqWheelDisplay mhz={freqMhz} onNudge={nudgeFreqHz} className="text-sm font-semibold text-primary" />
|
||||||
<span className="text-[9px] text-muted-foreground">MHz</span>
|
<span className="text-[9px] text-muted-foreground">MHz</span>
|
||||||
<Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
|
<Badge variant="accent" className="font-mono ml-2 text-[9px] py-0">{band}</Badge>
|
||||||
<Badge className="bg-success-muted text-success-muted-foreground hover:bg-success-muted font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
|
<Badge className="bg-success-muted text-success-muted-foreground hover:bg-success-muted font-mono text-[9px] py-0" variant="outline">{mode}</Badge>
|
||||||
@@ -3923,7 +4092,7 @@ export default function App() {
|
|||||||
{/* Toasts and errors live in the STATUS BAR at the bottom now — the
|
{/* Toasts and errors live in the STATUS BAR at the bottom now — the
|
||||||
header band was too narrow and long messages were cut off. */}
|
header band was too narrow and long messages were cut off. */}
|
||||||
<div className="flex flex-col items-end leading-none">
|
<div className="flex flex-col items-end leading-none">
|
||||||
<span className="text-2xl font-semibold text-primary tracking-wide">{freqMhz ? fmtFreqDots(freqMhz) : '—.———.———'}</span>
|
<FreqWheelDisplay mhz={freqMhz} onNudge={nudgeFreqHz} className="text-2xl font-semibold text-primary tracking-wide" />
|
||||||
{catState.split && rxFreqMhz && (
|
{catState.split && rxFreqMhz && (
|
||||||
<span className="text-[10px] text-muted-foreground mt-0.5">
|
<span className="text-[10px] text-muted-foreground mt-0.5">
|
||||||
<span className="text-danger font-semibold mr-1">RX</span>
|
<span className="text-danger font-semibold mr-1">RX</span>
|
||||||
@@ -4091,6 +4260,21 @@ export default function App() {
|
|||||||
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{tgEnabled && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => { const v = !showTuner; setShowTuner(v); writeUiPref('opslog.showTuner', v ? '1' : '0'); }}
|
||||||
|
title={showTuner ? 'Tuner Genius — shown · click to hide' : 'Tuner Genius · click to show'}
|
||||||
|
className={cn(
|
||||||
|
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||||
|
showTuner ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Gauge className="size-4" />
|
||||||
|
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{chatAvailable && (
|
{chatAvailable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -4449,6 +4633,9 @@ export default function App() {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
|
if (e.key === 'Enter' && (e.target as HTMLElement).tagName === 'INPUT') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
// ESM (Enter Sends Message): fire the stage-appropriate CW macro instead
|
||||||
|
// of logging. Falls through to the normal log when ESM isn't active.
|
||||||
|
if (esmHandleEnter(e.target as HTMLElement)) return;
|
||||||
save();
|
save();
|
||||||
}
|
}
|
||||||
// ESC is handled globally (stop CW + optional callsign reset).
|
// ESC is handled globally (stop CW + optional callsign reset).
|
||||||
@@ -4559,7 +4746,7 @@ export default function App() {
|
|||||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||||
otherwise it shows the QRZ profile photo. */}
|
otherwise it shows the QRZ profile photo. */}
|
||||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
|
||||||
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
|
||||||
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
// the DVK with Auto CQ) can't grow the row — the row height stays set by
|
||||||
// the entry strip and each widget fills that height, scrolling inside.
|
// the entry strip and each widget fills that height, scrolling inside.
|
||||||
@@ -4646,6 +4833,18 @@ export default function App() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{showTuner && tgEnabled && (
|
||||||
|
<div className="w-[230px] shrink-0 min-h-0">
|
||||||
|
<TunerGeniusPanel
|
||||||
|
status={tgStatus}
|
||||||
|
onTune={tgTune}
|
||||||
|
onBypass={tgBypass}
|
||||||
|
onOperate={tgOperate}
|
||||||
|
onActivate={tgActivate}
|
||||||
|
onClose={() => { setShowTuner(false); writeUiPref('opslog.showTuner', '0'); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{dvkEnabled && (
|
{dvkEnabled && (
|
||||||
<div className="w-[320px] shrink-0 min-h-0">
|
<div className="w-[320px] shrink-0 min-h-0">
|
||||||
<DvkPanel
|
<DvkPanel
|
||||||
@@ -5320,6 +5519,7 @@ export default function App() {
|
|||||||
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
currentFreqHz={band && freqMhz ? Math.round(parseFloat(freqMhz) * 1_000_000) : 0}
|
||||||
onSpotClick={handleSpotClick}
|
onSpotClick={handleSpotClick}
|
||||||
onClose={() => setBandMapShown(false)}
|
onClose={() => setBandMapShown(false)}
|
||||||
|
keyNav
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useRef } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import { Flame } from 'lucide-react';
|
import { Flame, ChevronDown } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
|
// AmpCard renders the amplifier card exactly like the one in the FlexRadio panel,
|
||||||
@@ -12,51 +13,20 @@ import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from
|
|||||||
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
|
// Controls use the multi-amp API (AmpOperate/AmpPower/… by amp id) so several amps
|
||||||
// can each get their own card.
|
// can each get their own card.
|
||||||
|
|
||||||
const METER_SEGMENTS = 26;
|
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||||
|
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', display, segColor, compact }: {
|
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; display?: string;
|
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||||
segColor?: (frac: number) => string; compact?: boolean;
|
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||||
}) {
|
|
||||||
const span = hi - lo;
|
|
||||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
|
||||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
|
||||||
return (
|
|
||||||
<div className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
|
||||||
compact ? 'px-2 py-1' : 'px-2.5 py-2')}>
|
|
||||||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
|
||||||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
|
||||||
{display !== undefined ? display : (
|
|
||||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
|
||||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
|
||||||
const on = i < lit;
|
|
||||||
const frac = i / METER_SEGMENTS;
|
|
||||||
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
|
||||||
style={on
|
|
||||||
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
|
||||||
: { background: '#cfc6ad', opacity: 0.35 }} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
<button type="button" onClick={toggle}
|
||||||
|
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
</div>
|
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||||
<div className="p-3 space-y-3">{children}</div>
|
</button>
|
||||||
|
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -95,7 +65,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
if (isSPE) {
|
if (isSPE) {
|
||||||
const spe = amp.spe;
|
const spe = amp.spe;
|
||||||
return (
|
return (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!spe.connected}
|
||||||
onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})}
|
onClick={() => AmpOperate(amp.id, !spe.operate).catch(() => {})}
|
||||||
@@ -151,7 +121,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
if (isACOM) {
|
if (isACOM) {
|
||||||
const acom = amp.acom;
|
const acom = amp.acom;
|
||||||
return (
|
return (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${amp.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<button type="button" disabled={!acom.connected}
|
<button type="button" disabled={!acom.connected}
|
||||||
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
onClick={() => AmpOperate(amp.id, !acom.operate).catch(() => {})}
|
||||||
@@ -199,7 +169,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
const connected = !!pg.connected || viaFlex;
|
const connected = !!pg.connected || viaFlex;
|
||||||
const fault = flex?.amp_fault;
|
const fault = flex?.amp_fault;
|
||||||
return (
|
return (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
<button type="button" disabled={!connected}
|
<button type="button" disabled={!connected}
|
||||||
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
|
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})}
|
||||||
@@ -242,7 +212,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
{amps.map((m) => {
|
{amps.map((m) => {
|
||||||
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||||
}
|
}
|
||||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
let lo = m.lo, hi = m.hi;
|
let lo = m.lo, hi = m.hi;
|
||||||
@@ -250,7 +220,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
|
|||||||
lo = 0;
|
lo = 0;
|
||||||
hi = m.hi >= 25 ? m.hi : 25;
|
hi = m.hi >= 25 ? m.hi : 25;
|
||||||
}
|
}
|
||||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -216,8 +216,8 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
<span className="font-mono truncate text-[11px]">{selectedRef?.subgrp || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected ref chip */}
|
{/* Selected ref chip (nothing shown until one is picked) */}
|
||||||
{selectedRef ? (
|
{selectedRef && (
|
||||||
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
|
<div className="flex items-center gap-1.5 h-6 px-2 rounded border border-success-border bg-success-muted text-success-muted-foreground text-xs min-w-0">
|
||||||
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
<span className="font-mono font-semibold shrink-0">{selectedRef.code}</span>
|
||||||
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
<span className="truncate text-[10px] text-success-muted-foreground">{selectedRef.name}</span>
|
||||||
@@ -225,10 +225,6 @@ export function AwardRefSelector({ dxcc, value, onChange, fieldValues, heightCla
|
|||||||
<X className="size-3" />
|
<X className="size-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="h-6 flex items-center px-2 text-[11px] text-muted-foreground italic border border-dashed border-border rounded">
|
|
||||||
{t('awrs.pickReference')}
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add — references are always scoped to the contacted DXCC */}
|
{/* Add — references are always scoped to the contacted DXCC */}
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ interface Props {
|
|||||||
// globally from the band-map tab toolbar.
|
// globally from the band-map tab toolbar.
|
||||||
hideDigital?: boolean;
|
hideDigital?: boolean;
|
||||||
fitToBand?: boolean;
|
fitToBand?: boolean;
|
||||||
|
// keyNav enables Ctrl+↑ / Ctrl+↓ to hop to the next spot above / below the rig
|
||||||
|
// frequency (and tune to it). Only the docked Main-view band map sets this, so
|
||||||
|
// the multi-band Band Map tab (several maps) doesn't fight over the shortcut.
|
||||||
|
keyNav?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const BAND_RANGES: Record<string, [number, number]> = {
|
const BAND_RANGES: Record<string, [number, number]> = {
|
||||||
@@ -153,7 +157,7 @@ const BOT_PAD = 14; // the top-most freq label isn't clipped at y=0
|
|||||||
// last; ties broken by closeness to the rig freq).
|
// last; ties broken by closeness to the rig freq).
|
||||||
const MAX_VISIBLE_SPOTS = 30;
|
const MAX_VISIBLE_SPOTS = 30;
|
||||||
|
|
||||||
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false }: Props) {
|
export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, onClose, side = 'right', onToggleSide, hideDigital = false, fitToBand = false, keyNav = false }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const range = BAND_RANGES[band];
|
const range = BAND_RANGES[band];
|
||||||
const segments = SEGMENT_COLORS[band] ?? [];
|
const segments = SEGMENT_COLORS[band] ?? [];
|
||||||
@@ -362,6 +366,38 @@ export function BandMap({ band, spots, spotStatus, currentFreqHz, onSpotClick, o
|
|||||||
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
scrollerRef.current.scrollTop = Math.max(0, y - containerH / 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ctrl+↑ / Ctrl+↓ hop to the next spot above / below the rig frequency and tune
|
||||||
|
// to it. Higher freq is UP on the map (see freqToY), so ↑ = next higher spot.
|
||||||
|
// Only active on the docked Main-view map (keyNav) and ignored while typing.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keyNav) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (!e.ctrlKey || e.altKey || e.metaKey) return;
|
||||||
|
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
|
||||||
|
const ae = document.activeElement as HTMLElement | null;
|
||||||
|
const tag = (ae?.tagName || '').toLowerCase();
|
||||||
|
if (tag === 'input' || tag === 'textarea' || tag === 'select' || ae?.isContentEditable) return;
|
||||||
|
const list = spots
|
||||||
|
.filter((s) => (s.band ?? '') === band && s.freq_hz > 0)
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.freq_hz - b.freq_hz);
|
||||||
|
if (!list.length) return;
|
||||||
|
const cur = currentFreqHz || (lo + hi) * 500; // mid-band kHz→Hz when no rig freq
|
||||||
|
const EPS = 50; // Hz, so we don't re-pick the spot we're already sitting on
|
||||||
|
let target: Spot | undefined;
|
||||||
|
if (e.key === 'ArrowUp') {
|
||||||
|
target = list.find((s) => s.freq_hz > cur + EPS);
|
||||||
|
} else {
|
||||||
|
for (let i = list.length - 1; i >= 0; i--) { if (list[i].freq_hz < cur - EPS) { target = list[i]; break; } }
|
||||||
|
}
|
||||||
|
if (!target) return;
|
||||||
|
e.preventDefault();
|
||||||
|
onSpotClick(target);
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, [keyNav, spots, band, currentFreqHz, lo, hi, onSpotClick]);
|
||||||
|
|
||||||
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
const currentKHz = currentFreqHz ? currentFreqHz / 1000 : 0;
|
||||||
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
|
const showRigPointer = currentKHz >= lo && currentKHz <= hi;
|
||||||
const rigY = freqToY(currentKHz);
|
const rigY = freqToY(currentKHz);
|
||||||
|
|||||||
@@ -306,11 +306,11 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, detai
|
|||||||
heightClass="flex-1 min-h-0"
|
heightClass="flex-1 min-h-0"
|
||||||
/>
|
/>
|
||||||
{detected.length > 0 && (
|
{detected.length > 0 && (
|
||||||
<div className="mt-2 text-[11px] text-muted-foreground shrink-0">
|
<div className="mt-2 text-[11px] text-muted-foreground shrink-0 max-h-14 overflow-y-auto leading-snug border-t border-border/50 pt-1.5">
|
||||||
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
<span className="font-medium text-foreground/70">{t('detp.detected')}</span>{' '}
|
||||||
{detected.map((r) => (
|
{detected.map((r) => (
|
||||||
<span key={`${r.code}@${r.ref}`} className="inline-block mr-2 font-mono">
|
<span key={`${r.code}@${r.ref}`} className="inline-block mr-1.5 font-mono whitespace-nowrap" title={r.name ?? ''}>
|
||||||
{r.code}{r.ref ? `@${r.ref}` : ''}{r.name ? <span className="text-muted-foreground/70"> {r.name}</span> : null}
|
<span className="text-foreground/80">{r.code}{r.ref ? `@${r.ref}` : ''}</span>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,6 +12,34 @@ import { adif } from '@/../wailsjs/go/models';
|
|||||||
type FieldDef = adif.FieldDef;
|
type FieldDef = adif.FieldDef;
|
||||||
const PREF_KEY = 'opslog.exportFields';
|
const PREF_KEY = 'opslog.exportFields';
|
||||||
|
|
||||||
|
// One category card with its checkboxes + All/None. Defined at MODULE scope (not
|
||||||
|
// inside ExportFieldsDialog) so its component identity is stable across renders —
|
||||||
|
// an inner component is re-created every render, remounting the whole subtree and
|
||||||
|
// making the All/None buttons and checkboxes feel dead.
|
||||||
|
function GroupCard({ title, tags, warn, sel, allLabel, noneLabel, onAll, onNone, onToggle }: {
|
||||||
|
title: string; tags: string[]; warn?: boolean; sel: Set<string>;
|
||||||
|
allLabel: string; noneLabel: string;
|
||||||
|
onAll: (tags: string[]) => void; onNone: (tags: string[]) => void; onToggle: (name: string, on: boolean) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
|
||||||
|
<div className="flex items-center justify-between mb-1 gap-2">
|
||||||
|
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
|
||||||
|
<span className="flex gap-1.5 shrink-0">
|
||||||
|
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => onAll(tags)}>{allLabel}</button>
|
||||||
|
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => onNone(tags)}>{noneLabel}</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{tags.map((name) => (
|
||||||
|
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
|
||||||
|
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => onToggle(name, !!c)} />
|
||||||
|
<span className="font-mono break-all">{name}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
|
// ExportFieldsDialog lets the operator pick exactly which ADIF fields an export
|
||||||
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
|
// writes. Two groups: the official ADIF 3.1.7 dictionary (grouped by category)
|
||||||
// and the OpsLog / non-standard tags actually present in the log's extras. The
|
// and the OpsLog / non-standard tags actually present in the log's extras. The
|
||||||
@@ -69,23 +97,14 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
|||||||
onExport(fields);
|
onExport(fields);
|
||||||
};
|
};
|
||||||
|
|
||||||
const GroupCard = ({ title, tags, warn }: { title: string; tags: string[]; warn?: boolean }) => (
|
const allLabel = t('exf.all');
|
||||||
<div className={`rounded-md border p-2 ${warn ? 'border-warning-border/50 bg-warning-muted/20' : 'border-border/60'}`}>
|
const noneLabel = t('exf.none');
|
||||||
<div className="flex items-center justify-between mb-1 gap-2">
|
const cardProps = {
|
||||||
<span className={`text-[11px] font-semibold uppercase tracking-wide ${warn ? 'text-warning-muted-foreground' : 'text-muted-foreground'}`}>{title}</span>
|
sel, allLabel, noneLabel,
|
||||||
<span className="flex gap-1.5 shrink-0">
|
onAll: (tags: string[]) => setMany(tags, true),
|
||||||
<button type="button" className="text-[10px] text-primary hover:underline" onClick={() => setMany(tags, true)}>{t('exf.all')}</button>
|
onNone: (tags: string[]) => setMany(tags, false),
|
||||||
<button type="button" className="text-[10px] text-muted-foreground hover:underline" onClick={() => setMany(tags, false)}>{t('exf.none')}</button>
|
onToggle: toggle,
|
||||||
</span>
|
};
|
||||||
</div>
|
|
||||||
{tags.map((name) => (
|
|
||||||
<label key={name} className="flex items-center gap-1.5 text-[11px] cursor-pointer py-0.5">
|
|
||||||
<Checkbox checked={sel.has(name)} onCheckedChange={(c) => toggle(name, !!c)} />
|
|
||||||
<span className="font-mono break-all">{name}</span>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
<Dialog open={open} onOpenChange={(o) => { if (!o) onClose(); }}>
|
||||||
@@ -104,9 +123,9 @@ export function ExportFieldsDialog({ open, count, onExport, onClose }: {
|
|||||||
|
|
||||||
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
<div className="grid grid-cols-3 gap-3 max-h-[56vh] overflow-y-auto pr-1">
|
||||||
{/* OpsLog / non-standard group first (most relevant to keep or drop). */}
|
{/* OpsLog / non-standard group first (most relevant to keep or drop). */}
|
||||||
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn />}
|
{extras.length > 0 && <GroupCard title={t('exf.opslogGroup')} tags={extras} warn {...cardProps} />}
|
||||||
{groups.map(([g, list]) => (
|
{groups.map(([g, list]) => (
|
||||||
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} />
|
<GroupCard key={g} title={g} tags={list.map((d) => d.name)} {...cardProps} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode,
|
GetPGXLStatus, PGXLSetFanMode,
|
||||||
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
GetAmpStatuses, AmpOperate, AmpPower, AmpPowerLevel,
|
||||||
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
FlexSetAGCMode, FlexSetAGCThreshold, FlexSetAudioLevel, FlexSetMute, FlexSetRXAntenna, FlexSetTXAntenna, FlexSetSplit, FlexSetActiveSlice, FlexSetTXSlice,
|
||||||
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
FlexSetRIT, FlexSetRITFreq, FlexSetXIT, FlexSetXITFreq,
|
||||||
@@ -19,6 +20,9 @@ import { EventsOn } from '../../wailsjs/runtime/runtime';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { sMeterRST } from '@/lib/rst';
|
import { sMeterRST } from '@/lib/rst';
|
||||||
|
import { TunerCard } from '@/components/TunerCard';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
|
|
||||||
type FlexState = {
|
type FlexState = {
|
||||||
available: boolean; model?: string;
|
available: boolean; model?: string;
|
||||||
@@ -219,54 +223,29 @@ function OffsetRow({ label, on, onToggle, hz, onHz, disabled, title }: {
|
|||||||
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
// MeterBar — a segmented "LED" instrument bar (radio look) scaled by lo/hi.
|
||||||
// `display` overrides the numeric readout; `segColor` colours segments by their
|
// `display` overrides the numeric readout; `segColor` colours segments by their
|
||||||
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
// 0..1 position (zones); the top ~18% light red by default (overload/peak).
|
||||||
const METER_SEGMENTS = 26;
|
function Card({ icon: Icon, title, accent, children, ckey, open: openProp, onToggle }: {
|
||||||
function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string;
|
||||||
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
open?: boolean; onToggle?: () => void; // controlled mode — lets sibling cards share one collapse state
|
||||||
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const span = hi - lo;
|
// Collapsible: a chevron in the header hides the body. Uncontrolled by default,
|
||||||
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
// persisting per card (keyed by ckey, falling back to the title); when `open`/
|
||||||
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
// `onToggle` are supplied the parent owns the state (e.g. linked TX/RX cards).
|
||||||
return (
|
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||||
<div onClick={onClick} title={title}
|
const [openState, setOpenState] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||||
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
const controlled = openProp !== undefined;
|
||||||
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
const open = controlled ? openProp : openState;
|
||||||
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
const toggle = controlled
|
||||||
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
? (onToggle ?? (() => {}))
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
: () => setOpenState((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||||
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
|
||||||
{display !== undefined ? display : (
|
|
||||||
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
|
||||||
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
|
||||||
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
|
||||||
const on = i < lit;
|
|
||||||
const frac = i / METER_SEGMENTS;
|
|
||||||
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
|
||||||
return (
|
|
||||||
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
|
||||||
style={on
|
|
||||||
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
|
||||||
: { background: '#cfc6ad', opacity: 0.35 }} />
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
<button type="button" onClick={toggle}
|
||||||
|
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
</div>
|
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||||
<div className="p-3 space-y-3">{children}</div>
|
</button>
|
||||||
|
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -354,6 +333,25 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// TRANSMIT + RECEIVE share ONE collapse state (they sit side by side, so folding
|
||||||
|
// one folds the other and keeps the row tidy). Persisted like the other cards.
|
||||||
|
const [txrxOpen, setTxrxOpen] = useState(() => localStorage.getItem('opslog.cardOpen.txrx') !== '0');
|
||||||
|
const toggleTxrx = () => setTxrxOpen((o) => { const n = !o; localStorage.setItem('opslog.cardOpen.txrx', n ? '1' : '0'); return n; });
|
||||||
|
|
||||||
|
// Tuner Genius XL direct connection — its own card in the Flex panel (like PGXL).
|
||||||
|
const [tg, setTg] = useState<TGStatus>({ connected: false });
|
||||||
|
const [tgEnabled, setTgEnabled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = async () => {
|
||||||
|
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
|
||||||
|
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 500); // fast so meters track TX (see App.tsx)
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
|
// Configured amplifiers (Settings → Amplifier) — possibly SEVERAL (some ops
|
||||||
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
|
// run two SPEs in parallel). The card shows ONE at a time; the dropdown picks
|
||||||
// which, and the choice is remembered per panel.
|
// which, and the choice is remembered per panel.
|
||||||
@@ -422,6 +420,9 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
return () => off();
|
return () => off();
|
||||||
}, [st.rit, st.rit_freq]);
|
}, [st.rit, st.rit_freq]);
|
||||||
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
const isCW = (st.mode || '').toUpperCase().includes('CW');
|
||||||
|
// Phone (voice) modes — MIC / COMP meters only make sense here, so they're
|
||||||
|
// hidden in CW and digital.
|
||||||
|
const isPhone = /\b(SSB|USB|LSB|AM|FM|DFM|NFM)\b/i.test(st.mode || '');
|
||||||
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
const PROC = [{ v: '0', l: 'NOR' }, { v: '1', l: 'DX' }, { v: '2', l: 'DX+' }];
|
||||||
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
const AGC = [{ v: 'off', l: 'OFF' }, { v: 'slow', l: 'SLOW' }, { v: 'med', l: 'MED' }, { v: 'fast', l: 'FAST' }];
|
||||||
const CW_BW = [100, 200, 300, 400, 500];
|
const CW_BW = [100, 200, 300, 400, 500];
|
||||||
@@ -550,22 +551,22 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
};
|
};
|
||||||
const cur = [
|
const cur = [
|
||||||
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
sig && (() => { const dbm = peakHold('s', sig.value); const s = sUnit(dbm); return (
|
||||||
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={s.display} extra={`${dbm.toFixed(1)} dBm`}
|
// dBm sits inline next to the S-value (no separate line below) to save height.
|
||||||
|
<MeterBar key="s" label="S-METER" value={s.bar} lo={0} hi={19} accent="#16a34a" display={`${s.display} | ${dbm.toFixed(0)} dBm`}
|
||||||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||||||
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
onClick={onReportRST ? () => onReportRST(sMeterRST(s.s, s.over, st.mode)) : undefined}
|
||||||
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
segColor={(fr) => { const sval = fr * 19; return sval < 9 ? '#16a34a' : sval < 12.33 ? '#f59e0b' : '#dc2626'; }} />
|
||||||
); })(),
|
); })(),
|
||||||
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
fwd && (() => { const w = peakHold('p', isDbm(fwd) ? dbmToW(fwd.value) : fwd.value); return (
|
||||||
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626"
|
<MeterBar key="p" label="PWR" unit="W" lo={0} hi={120} accent="#dc2626" value={w} />
|
||||||
value={w} extra={isDbm(fwd) ? `${fwd.value.toFixed(1)} dBm` : undefined} />
|
|
||||||
); })(),
|
); })(),
|
||||||
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
swr && <MeterBar key="w" label="SWR" value={peakHold('w', swr.value)} unit="" lo={1} hi={3} accent="#d97706" />,
|
||||||
// Mic input level in dBFS — SmartSDR's scale is -40…0 dB.
|
// Mic input level in dBFS — SmartSDR's scale is -40…0 dB. Phone modes only.
|
||||||
mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
|
isPhone && mic && <MeterBar key="mic" label="MIC" value={peakHold('mic', mic.value)} unit={mic.unit || 'dB'} lo={-40} hi={0} accent="#16a34a"
|
||||||
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
segColor={(fr) => (fr >= 0.8 ? '#dc2626' : fr >= 0.7 ? '#f59e0b' : '#16a34a')} />,
|
||||||
// Speech compression — original working meter, only the top of the
|
// Speech compression — original working meter, only the top of the
|
||||||
// scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max).
|
// scale changed from the radio-reported 20 to 25 (SmartSDR's -25 max).
|
||||||
comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
|
isPhone && comp && <MeterBar key="comp" label="COMP" value={peakHold('comp', comp.value)} unit={comp.unit || 'dB'} lo={0} hi={25} accent="#0891b2" />,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">{cur}</div>
|
||||||
@@ -582,7 +583,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* TX + RX columns */}
|
{/* TX + RX columns */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
{/* TRANSMIT */}
|
{/* TRANSMIT */}
|
||||||
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626">
|
<Card icon={Zap} title={t('flxp.transmit')} accent="#dc2626" open={txrxOpen} onToggle={toggleTxrx}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
|
<span className="w-20 shrink-0 text-xs font-medium text-muted-foreground">{t('flxp.rfPower')}</span>
|
||||||
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
<Slider value={st.rf_power} disabled={off} accent="#dc2626" onChange={(v) => change('rf_power', v, () => FlexSetPower(v))} />
|
||||||
@@ -721,7 +722,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* RECEIVE */}
|
{/* RECEIVE */}
|
||||||
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2">
|
<Card icon={AudioLines} title={t('flxp.receiveActive')} accent="#0891b2" open={txrxOpen} onToggle={toggleTxrx}>
|
||||||
{/* Antenna selection sits at the very top of the RX column. */}
|
{/* Antenna selection sits at the very top of the RX column. */}
|
||||||
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
{((st.ant_list?.length ?? 0) > 0 || (st.tx_ant_list?.length ?? 0) > 0) && (
|
||||||
<div className="flex items-center gap-2 pb-3 border-b border-border/60">
|
<div className="flex items-center gap-2 pb-3 border-b border-border/60">
|
||||||
@@ -910,7 +911,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
|
The Flex doesn't report SPE amps, so this card is driven by OpsLog's own
|
||||||
SPE link rather than the Flex amplifier object. */}
|
SPE link rather than the Flex amplifier object. */}
|
||||||
{isSPE && (
|
{isSPE && (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `SPE${spe.model ? ' ' + spe.model : ''}`}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{ampPicker}
|
{ampPicker}
|
||||||
<button type="button" disabled={!spe.connected}
|
<button type="button" disabled={!spe.connected}
|
||||||
@@ -968,7 +969,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
|
{/* ACOM amplifier (serial/TCP) — shown when it's the configured amp. Driven
|
||||||
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
|
by OpsLog's own ACOM link (the Flex doesn't report ACOM amps). */}
|
||||||
{isACOM && (
|
{isACOM && (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')} · ${selAmp?.name || `ACOM${acom.model ? ' ' + acom.model : ''}`}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3 flex-wrap">
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
{ampPicker}
|
{ampPicker}
|
||||||
<button type="button" disabled={!acom.connected}
|
<button type="button" disabled={!acom.connected}
|
||||||
@@ -1015,7 +1016,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
|
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
|
||||||
this Flex-reported card so two amps don't both show. */}
|
this Flex-reported card so two amps don't both show. */}
|
||||||
{st.amp_available && !isSPE && !isACOM && (
|
{st.amp_available && !isSPE && !isACOM && (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{ampPicker}
|
{ampPicker}
|
||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
@@ -1063,7 +1064,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
|
||||||
{amp.map((m) => {
|
{amp.map((m) => {
|
||||||
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
if (/fwd|pwr/i.test(m.name || '') && /dbm/i.test(m.unit || '')) {
|
||||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, dbmToW(m.value))} unit="W" lo={0} hi={2000} accent="#dc2626" />;
|
||||||
}
|
}
|
||||||
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
const acc = /temp|degc|degf/i.test(`${m.unit}${m.name}`) ? '#ea580c' : /volt/i.test(m.unit || '') ? '#2563eb' : '#16a34a';
|
||||||
// Drain current (ID): the PGXL reports a full-scale far too small
|
// Drain current (ID): the PGXL reports a full-scale far too small
|
||||||
@@ -1076,7 +1077,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
lo = 0;
|
lo = 0;
|
||||||
hi = m.hi >= 25 ? m.hi : 25;
|
hi = m.hi >= 25 ? m.hi : 25;
|
||||||
}
|
}
|
||||||
return <MeterBar key={m.id} compact label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
return <MeterBar key={m.id} label={m.name || `AMP ${m.id}`} value={peakHold(`amp${m.id}`, m.value)} unit={m.unit} lo={lo} hi={hi} accent={acc} />;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -1084,6 +1085,10 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Tuner Genius XL — 4O3A ATU, its own card when enabled (Settings →
|
||||||
|
Tuner Genius). Same card shown in Station Control. */}
|
||||||
|
{tgEnabled && <TunerCard status={tg} t={t} />}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
// MeterBar is the ONE LED-bar meter used across the FlexRadio panel, the amplifier
|
||||||
|
// cards and the Tuner Genius card, so every meter renders at exactly the same size
|
||||||
|
// (segment count, bar height, padding). Keep it the single source of truth — don't
|
||||||
|
// re-declare a local copy in a panel, or the meters drift out of sync.
|
||||||
|
export const METER_SEGMENTS = 26;
|
||||||
|
|
||||||
|
export function MeterBar({ label, value, unit, lo, hi, accent = '#16a34a', extra, display, segColor, onClick, title, compact }: {
|
||||||
|
label: string; value: number; unit?: string; lo: number; hi: number; accent?: string; extra?: string; display?: string;
|
||||||
|
segColor?: (frac: number) => string; onClick?: () => void; title?: string; compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const span = hi - lo;
|
||||||
|
const pct = span > 0 ? Math.max(0, Math.min(100, ((value - lo) / span) * 100)) : 0;
|
||||||
|
const lit = Math.round((pct / 100) * METER_SEGMENTS);
|
||||||
|
return (
|
||||||
|
<div onClick={onClick} title={title}
|
||||||
|
className={cn('rounded-lg border border-border/70 bg-gradient-to-b from-card to-muted/40 shadow-sm min-w-0',
|
||||||
|
compact ? 'px-2 py-1' : 'px-2.5 py-2',
|
||||||
|
onClick && 'cursor-pointer hover:border-primary/60 hover:from-muted/40')}>
|
||||||
|
<div className={cn('flex items-baseline justify-between gap-1', compact ? 'mb-1' : 'mb-1.5')}>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground truncate">{label}</span>
|
||||||
|
<span className={cn('font-mono font-bold tabular-nums whitespace-nowrap text-foreground/90', compact ? 'text-xs' : 'text-sm')}>
|
||||||
|
{display !== undefined ? display : (
|
||||||
|
<>{Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1)}<span className="text-muted-foreground text-[10px] ml-0.5">{unit}</span></>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* LED bar — recessed track + gradient segments for a cleaner instrument look. */}
|
||||||
|
<div className={cn('flex gap-[2px] items-stretch rounded-[3px] bg-black/10 p-[2px]', compact ? 'h-2' : 'h-3')}>
|
||||||
|
{Array.from({ length: METER_SEGMENTS }).map((_, i) => {
|
||||||
|
const on = i < lit;
|
||||||
|
const frac = i / METER_SEGMENTS;
|
||||||
|
const col = segColor ? segColor(frac) : (frac > 0.82 ? '#dc2626' : accent);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex-1 rounded-[2px] transition-colors duration-100"
|
||||||
|
style={on
|
||||||
|
? { background: `linear-gradient(to bottom, ${col}, ${col}cc)`, boxShadow: `0 0 4px ${col}88` }
|
||||||
|
: { background: '#cfc6ad', opacity: 0.35 }} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{extra && !compact && <div className="text-[10px] text-muted-foreground/70 mt-1 text-right font-mono">{extra}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -328,6 +328,39 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
saveState(AWARD_SHOWN_KEY, [...next]);
|
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Award-column WIDTHS are persisted separately too, for the same reason as
|
||||||
|
// visibility: award columns are stripped from AG Grid's saved column-state
|
||||||
|
// round-trip, so their width would otherwise reset to the default on reopen.
|
||||||
|
// Stored as an array of { code, width } (code upper-cased); mirrored to the DB.
|
||||||
|
const AWARD_WIDTH_KEY = storageKey ? `hamlog.awardColWidths.${storageKey}` : 'hamlog.awardColWidths';
|
||||||
|
const awardWidthsRef = useRef<Record<string, number>>({});
|
||||||
|
const awardWidthsInit = useRef(false);
|
||||||
|
if (!awardWidthsInit.current) {
|
||||||
|
awardWidthsInit.current = true;
|
||||||
|
for (const it of (loadLocal(AWARD_WIDTH_KEY) ?? []) as any[]) {
|
||||||
|
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fresh machine: hydrate widths from the portable DB copy, seed the cache, and
|
||||||
|
// apply them to any award columns already on screen.
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadLocal(AWARD_WIDTH_KEY)) return;
|
||||||
|
loadRemote(AWARD_WIDTH_KEY).then((remote) => {
|
||||||
|
if (!remote || !remote.length) return;
|
||||||
|
for (const it of remote as any[]) {
|
||||||
|
if (it?.code && it?.width) awardWidthsRef.current[String(it.code).toUpperCase()] = Number(it.width);
|
||||||
|
}
|
||||||
|
seedLocal(AWARD_WIDTH_KEY, remote);
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (api && awardCols?.length) {
|
||||||
|
const ups = awardCols
|
||||||
|
.map((a) => ({ key: `award_${a.code}`, newWidth: awardWidthsRef.current[a.code.toUpperCase()] }))
|
||||||
|
.filter((u) => !!u.newWidth);
|
||||||
|
if (ups.length) api.setColumnWidths(ups);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
@@ -354,7 +387,7 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
colId: `award_${a.code}`,
|
colId: `award_${a.code}`,
|
||||||
headerName: a.code,
|
headerName: a.code,
|
||||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: awardWidthsRef.current[a.code.toUpperCase()] ?? 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
// Visibility comes from the persisted award-code set, so a column the user
|
// Visibility comes from the persisted award-code set, so a column the user
|
||||||
// showed reappears on reopen and one they didn't stays hidden.
|
// showed reappears on reopen and one they didn't stays hidden.
|
||||||
@@ -370,11 +403,17 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api || !awardCols?.length) return;
|
if (!api || !awardCols?.length) return;
|
||||||
|
const widthUps: { key: string; newWidth: number }[] = [];
|
||||||
for (const a of awardCols) {
|
for (const a of awardCols) {
|
||||||
const want = awardShown.has(a.code.toUpperCase());
|
const want = awardShown.has(a.code.toUpperCase());
|
||||||
const col = api.getColumn(`award_${a.code}`);
|
const col = api.getColumn(`award_${a.code}`);
|
||||||
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
||||||
|
// Re-apply the saved width — AG Grid keeps an existing column's width across
|
||||||
|
// a columnDefs rebuild instead of re-reading colDef.width (same quirk as hide).
|
||||||
|
const w = awardWidthsRef.current[a.code.toUpperCase()];
|
||||||
|
if (col && w && Math.round(col.getActualWidth()) !== w) widthUps.push({ key: `award_${a.code}`, newWidth: w });
|
||||||
}
|
}
|
||||||
|
if (widthUps.length) api.setColumnWidths(widthUps);
|
||||||
}, [awardCols, awardShown]);
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
@@ -421,8 +460,24 @@ export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, rowDrag
|
|||||||
const saveColumnState = useCallback(() => {
|
const saveColumnState = useCallback(() => {
|
||||||
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
if (restoringRef.current) return; // ignore the events fired by a column rebuild
|
||||||
const state = gridRef.current?.api?.getColumnState();
|
const state = gridRef.current?.api?.getColumnState();
|
||||||
if (state) saveState(colStateKey, stripAwardCols(state));
|
if (!state) return;
|
||||||
}, []);
|
saveState(colStateKey, stripAwardCols(state));
|
||||||
|
// Award columns are stripped above, so persist their widths on the side.
|
||||||
|
let changed = false;
|
||||||
|
for (const s of state) {
|
||||||
|
const id = String((s as any)?.colId ?? '');
|
||||||
|
if (id.startsWith('award_') && (s as any).width) {
|
||||||
|
const code = id.slice('award_'.length).toUpperCase();
|
||||||
|
if (awardWidthsRef.current[code] !== (s as any).width) {
|
||||||
|
awardWidthsRef.current[code] = (s as any).width;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) {
|
||||||
|
saveState(AWARD_WIDTH_KEY, Object.entries(awardWidthsRef.current).map(([code, width]) => ({ code, width })));
|
||||||
|
}
|
||||||
|
}, [colStateKey, AWARD_WIDTH_KEY]);
|
||||||
|
|
||||||
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
// columnDefs is rebuilt whenever the award columns load OR the user toggles an
|
||||||
// award column (both change the memo → restoringRef flips true at line 316). Each
|
// award column (both change the memo → restoringRef flips true at line 316). Each
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
@@ -29,7 +30,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, RenameDatabase,
|
GetDatabaseSettings, PickOpenDatabase, PickSaveDatabase, OpenDatabase, MoveDatabase, ResetDatabaseToDefault, RestartApp, CreateDatabase, RenameDatabase, RevealDataFolder, RenameLogbook,
|
||||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
@@ -186,6 +187,7 @@ type SectionId =
|
|||||||
| 'winkeyer'
|
| 'winkeyer'
|
||||||
| 'antenna'
|
| 'antenna'
|
||||||
| 'antgenius'
|
| 'antgenius'
|
||||||
|
| 'tunergenius'
|
||||||
| 'pgxl'
|
| 'pgxl'
|
||||||
| 'flex'
|
| 'flex'
|
||||||
| 'relayauto'
|
| 'relayauto'
|
||||||
@@ -204,6 +206,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
|
{ kind: 'item', label: t('sec.winkeyer'), id: 'winkeyer' },
|
||||||
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
|
{ kind: 'item', label: t('sec.antenna'), id: 'antenna' },
|
||||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
|
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius' },
|
||||||
|
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius' },
|
||||||
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
||||||
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
||||||
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
||||||
@@ -250,7 +253,7 @@ const SECTION_KEY: Partial<Record<SectionId, string>> = {
|
|||||||
adifmon: 'sec.adifmon',
|
adifmon: 'sec.adifmon',
|
||||||
uscounties: 'sec.uscounties',
|
uscounties: 'sec.uscounties',
|
||||||
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
awards: 'sec.awards', cat: 'sec.cat', rotator: 'sec.rotator', winkeyer: 'sec.winkeyer', antenna: 'sec.antenna',
|
||||||
antgenius: 'sec.antgenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
antgenius: 'sec.antgenius', tunergenius: 'sec.tunergenius', pgxl: 'sec.pgxl', flex: 'sec.flex', audio: 'sec.audio', general: 'sec.general', email: 'sec.email',
|
||||||
relayauto: 'sec.relayauto',
|
relayauto: 'sec.relayauto',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -276,6 +279,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',
|
||||||
|
tunergenius: 'Tuner Genius',
|
||||||
pgxl: 'Amplifier',
|
pgxl: 'Amplifier',
|
||||||
flex: 'FlexRadio',
|
flex: 'FlexRadio',
|
||||||
relayauto: 'Relay auto-control',
|
relayauto: 'Relay auto-control',
|
||||||
@@ -1063,6 +1067,7 @@ 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: '' });
|
||||||
|
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
|
|
||||||
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
||||||
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
||||||
@@ -1071,14 +1076,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// WinKeyer CW keyer settings + macro editor.
|
// WinKeyer CW keyer settings + macro editor.
|
||||||
type WKMac = { label: string; text: string };
|
type WKMac = { label: string; text: string };
|
||||||
type WKSettings = {
|
type WKSettings = {
|
||||||
enabled: boolean; engine: string; esc_clears_call: boolean;
|
enabled: boolean; engine: string; esc_clears_call: boolean; esm: boolean;
|
||||||
port: string; baud: number; wpm: number; weight: number;
|
port: string; baud: number; wpm: number; weight: number;
|
||||||
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
|
lead_in_ms: number; tail_ms: number; ratio: number; farnsworth: number;
|
||||||
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
|
sidetone_hz: number; mode: string; swap: boolean; autospace: boolean;
|
||||||
use_ptt: boolean; serial_echo: boolean; cw_key_line: string; cw_invert: boolean; macros: WKMac[];
|
use_ptt: boolean; serial_echo: boolean; cw_key_line: string; cw_invert: boolean; macros: WKMac[];
|
||||||
};
|
};
|
||||||
const [wk, setWk] = useState<WKSettings>({
|
const [wk, setWk] = useState<WKSettings>({
|
||||||
enabled: false, engine: 'winkeyer', esc_clears_call: true,
|
enabled: false, engine: 'winkeyer', esc_clears_call: true, esm: false,
|
||||||
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
|
port: '', baud: 1200, wpm: 25, weight: 50, lead_in_ms: 10,
|
||||||
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
|
tail_ms: 50, ratio: 50, farnsworth: 0, sidetone_hz: 600, mode: 'iambic_b',
|
||||||
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
|
swap: false, autospace: true, use_ptt: false, serial_echo: true, cw_key_line: 'dtr', cw_invert: false, macros: [],
|
||||||
@@ -1297,9 +1302,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [backupRunning, setBackupRunning] = useState(false);
|
const [backupRunning, setBackupRunning] = useState(false);
|
||||||
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [backupResult, setBackupResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean }>({ path: '', default_path: '', is_custom: false });
|
const [dbSettings, setDbSettings] = useState<{ path: string; default_path: string; is_custom: boolean; logbook_default_path?: string }>({ path: '', default_path: '', is_custom: false });
|
||||||
const [dbMsg, setDbMsg] = useState('');
|
const [dbMsg, setDbMsg] = useState('');
|
||||||
type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string };
|
type MySQLCfg = { enabled: boolean; host: string; port: number; user: string; password: string; database: string; sqlite_path?: string };
|
||||||
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
|
const [mysqlCfg, setMysqlCfg] = useState<MySQLCfg>({ enabled: false, host: '', port: 3306, user: '', password: '', database: '' });
|
||||||
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
|
const setMysqlField = (patch: Partial<MySQLCfg>) => setMysqlCfg((s) => ({ ...s, ...patch }));
|
||||||
const [mysqlMsg, setMysqlMsg] = useState('');
|
const [mysqlMsg, setMysqlMsg] = useState('');
|
||||||
@@ -1390,6 +1395,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setRotator(r);
|
setRotator(r);
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
@@ -1401,7 +1407,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const locs: any = await ListTQSLStationLocations();
|
const locs: any = await ListTQSLStationLocations();
|
||||||
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
setStationLocations((locs ?? []).map((l: any) => l.name).filter(Boolean));
|
||||||
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
} catch { /* TQSL not installed — leave the dropdown empty */ }
|
||||||
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
try { const s: any = await GetWinkeyerSettings(); if (Array.isArray(s.macros)) { while (s.macros.length < 9) s.macros.push({ label: '', text: '' }); } setWk(s); } catch {}
|
||||||
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
try { setWkPorts((await ListSerialPorts() ?? []) as string[]); } catch {}
|
||||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||||
@@ -1430,11 +1436,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
try { setRotator(await GetRotatorSettings() as any); } catch {}
|
||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
try { setExtSvc(await GetExternalServices() as any); } catch {}
|
||||||
try { setWk(await GetWinkeyerSettings() as any); } catch {}
|
try { const s: any = await GetWinkeyerSettings(); if (Array.isArray(s.macros)) { while (s.macros.length < 9) s.macros.push({ label: '', text: '' }); } setWk(s); } catch {}
|
||||||
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
try { setAudioCfg(await GetAudioSettings() as any); } catch {}
|
||||||
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
try { setEmailCfg(await GetEmailSettings() as any); } catch {}
|
||||||
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
try { setEqslCfg(await QSLGetEmailTemplates() as any); } catch {}
|
||||||
@@ -1599,6 +1606,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await SaveRotatorSettings(rotator as any);
|
await SaveRotatorSettings(rotator as any);
|
||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
|
await SaveTunerGeniusSettings(tunergenius as any);
|
||||||
await SaveAmplifiers(amps as any);
|
await SaveAmplifiers(amps as any);
|
||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
@@ -2719,6 +2727,44 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function TunerGeniusPanelSettings() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader
|
||||||
|
title="Tuner Genius XL (4O3A)"
|
||||||
|
hint={t('tg2.hint')}
|
||||||
|
/>
|
||||||
|
<div className="space-y-4 max-w-xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={tunergenius.enabled} onCheckedChange={(c) => setTunergenius((s) => ({ ...s, enabled: !!c }))} />
|
||||||
|
{t('tg2.enable')}
|
||||||
|
</label>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input
|
||||||
|
value={tunergenius.host ?? ''}
|
||||||
|
onChange={(e) => setTunergenius((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="192.168.1.61"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('tg2.portHint')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('tg2.password')}</Label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={tunergenius.password ?? ''}
|
||||||
|
onChange={(e) => setTunergenius((s) => ({ ...s, password: e.target.value }))}
|
||||||
|
placeholder={t('tg2.passwordPh')}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('tg2.passwordHint')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PGXLPanelSettings() {
|
function PGXLPanelSettings() {
|
||||||
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
||||||
// presents it as brand + model.
|
// presents it as brand + model.
|
||||||
@@ -3060,13 +3106,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
|
<Checkbox checked={wk.esc_clears_call} onCheckedChange={(c) => setWkField({ esc_clears_call: !!c })} />
|
||||||
{t('wk.escClears')}
|
{t('wk.escClears')}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer col-span-3 pb-1.5">
|
||||||
|
<Checkbox checked={wk.esm} onCheckedChange={(c) => setWkField({ esm: !!c })} />
|
||||||
|
{t('wk.esm')}
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{wk.engine === 'icom' ? (
|
{wk.engine === 'icom' ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground -mt-2">
|
|
||||||
{t('wk.icomNote')}
|
|
||||||
</p>
|
|
||||||
{(!catCfg.enabled || catCfg.backend !== 'icom') && (
|
{(!catCfg.enabled || catCfg.backend !== 'icom') && (
|
||||||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||||
<span aria-hidden>⚠</span>
|
<span aria-hidden>⚠</span>
|
||||||
@@ -3082,9 +3129,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</>
|
</>
|
||||||
) : wk.engine === 'flex' ? (
|
) : wk.engine === 'flex' ? (
|
||||||
<>
|
<>
|
||||||
<p className="text-xs text-muted-foreground -mt-2">
|
|
||||||
{t('wk.flexNote')}
|
|
||||||
</p>
|
|
||||||
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
{(!catCfg.enabled || catCfg.backend !== 'flex') && (
|
||||||
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
<p className="text-xs font-medium text-warning -mt-1 flex items-start gap-1.5">
|
||||||
<span aria-hidden>⚠</span>
|
<span aria-hidden>⚠</span>
|
||||||
@@ -4307,13 +4351,48 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setDbMsg(dbSettings.default_path || '');
|
setDbMsg(dbSettings.default_path || '');
|
||||||
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
|
function revealFolder() { RevealDataFolder().catch((e: any) => setErr(String(e?.message ?? e))); }
|
||||||
|
// Rename/relocate THIS profile's logbook, carrying the QSOs across.
|
||||||
|
async function renameLogbook() {
|
||||||
|
try {
|
||||||
|
const p = await PickSaveDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await RenameLogbook(p);
|
||||||
|
setMysqlField({ enabled: false, sqlite_path: p });
|
||||||
|
setRestartMsg(t('db.logbookRenamed'));
|
||||||
|
await refreshBackend();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
// Switching the logbook backend applies immediately (no restart): the local
|
// Switching the logbook backend applies immediately (no restart): the local
|
||||||
// SQLite file always stays the config store; only the QSO logbook moves.
|
// SQLite file always stays the config store; only the QSO logbook moves.
|
||||||
function useLocalLogbook() {
|
function useLocalLogbook() {
|
||||||
SaveMySQLSettings({ ...mysqlCfg, enabled: false } as any)
|
SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: '' } as any)
|
||||||
.then(async () => { setMysqlField({ enabled: false }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
|
.then(async () => { setMysqlField({ enabled: false, sqlite_path: '' }); setRestartMsg(t('db.switchedSqlite')); await refreshBackend(); })
|
||||||
.catch((e: any) => setErr(String(e?.message ?? e)));
|
.catch((e: any) => setErr(String(e?.message ?? e)));
|
||||||
}
|
}
|
||||||
|
// Point THIS profile's logbook at a SQLite file (settings stay in the
|
||||||
|
// settings db). A NEW name is created + migrated empty; an existing file is
|
||||||
|
// opened with its QSOs.
|
||||||
|
async function newLogbook() {
|
||||||
|
try {
|
||||||
|
const p = await PickSaveDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: p } as any);
|
||||||
|
setMysqlField({ enabled: false, sqlite_path: p });
|
||||||
|
setRestartMsg(t('db.switchedSqliteFile'));
|
||||||
|
await refreshBackend();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
async function openLogbook() {
|
||||||
|
try {
|
||||||
|
const p = await PickOpenDatabase();
|
||||||
|
if (!p) return;
|
||||||
|
await SaveMySQLSettings({ ...mysqlCfg, enabled: false, sqlite_path: p } as any);
|
||||||
|
setMysqlField({ enabled: false, sqlite_path: p });
|
||||||
|
setRestartMsg(t('db.switchedSqliteFile'));
|
||||||
|
await refreshBackend();
|
||||||
|
} catch (e: any) { setErr(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
function connectMysql() {
|
function connectMysql() {
|
||||||
SaveMySQLSettings(mysqlCfg as any)
|
SaveMySQLSettings(mysqlCfg as any)
|
||||||
.then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); })
|
.then(async () => { setRestartMsg(t('db.switchedMysql')); await refreshBackend(); })
|
||||||
@@ -4323,18 +4402,31 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<>
|
<>
|
||||||
<SectionHeader title={t('sec.database')} />
|
<SectionHeader title={t('sec.database')} />
|
||||||
|
|
||||||
{/* Logbook backend: local SQLite file (solo) or shared MySQL (multi-op).
|
{/* Settings / application database (settings + profiles) — always shown,
|
||||||
Switching is instant — no restart. Only changing the local SQLite
|
distinct from the QSO logbook so the two are never confused. */}
|
||||||
FILE needs a restart (it also stores this operator's settings). */}
|
<div className="space-y-2 max-w-2xl mb-5 border border-border/60 rounded-md p-3">
|
||||||
|
<Label>{t('db.appDb')}</Label>
|
||||||
|
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||||||
|
{dbSettings.path || '—'}
|
||||||
|
{dbSettings.is_custom
|
||||||
|
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
|
||||||
|
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground">{t('db.appDbHint')}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={revealFolder}><FolderOpen className="size-3.5" /> {t('db.openFolder')}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Logbook (QSOs) — this profile: default SQLite file, a dedicated file, or MySQL. */}
|
||||||
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
<div className="grid grid-cols-[130px_1fr] gap-2 items-center max-w-2xl mb-1">
|
||||||
<Label className="text-sm">{t('db.backend')}</Label>
|
<Label className="text-sm">{t('db.logbookLabel')}</Label>
|
||||||
<Select
|
<Select
|
||||||
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
value={mysqlCfg.enabled ? 'mysql' : 'sqlite'}
|
||||||
onValueChange={(v) => {
|
onValueChange={(v) => {
|
||||||
const enabled = v === 'mysql';
|
|
||||||
setMysqlField({ enabled });
|
|
||||||
setRestartMsg('');
|
setRestartMsg('');
|
||||||
if (!enabled) useLocalLogbook(); // switching to local applies at once
|
if (v === 'mysql') { setMysqlField({ enabled: true }); return; }
|
||||||
|
useLocalLogbook(); // SQLite → default logbook file (clears any per-profile path)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-8 w-72"><SelectValue /></SelectTrigger>
|
||||||
@@ -4356,50 +4448,29 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
) : (
|
) : (
|
||||||
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
|
<div className="max-w-2xl mb-4 text-[11px] text-muted-foreground">
|
||||||
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
|
{t('db.activeBackend')} <strong className="uppercase text-foreground">{backendStatus.active}</strong>
|
||||||
{backendStatus.active === 'mysql' && <span> · {t('db.configLocal')}</span>}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* SQLite: local logbook file management */}
|
{/* SQLite logbook file: default logbook.db, or a dedicated file for this profile. */}
|
||||||
{!mysqlCfg.enabled && (
|
{!mysqlCfg.enabled && (
|
||||||
<div className="space-y-4 max-w-2xl">
|
<div className="space-y-3 max-w-2xl">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('db.current')}</Label>
|
<Label>{t('db.logbookFile')}</Label>
|
||||||
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
<div className="font-mono text-xs bg-muted/40 border border-border rounded-md px-3 py-2 break-all">
|
||||||
{dbSettings.path || '—'}
|
{mysqlCfg.sqlite_path || dbSettings.logbook_default_path || '—'}
|
||||||
{dbSettings.is_custom
|
{mysqlCfg.sqlite_path
|
||||||
? <span className="ml-2 text-[10px] text-success">{t('db.customLoc')}</span>
|
? <span className="ml-2 text-[10px] text-success">{t('db.dedicatedFile')}</span>
|
||||||
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
: <span className="ml-2 text-[10px] text-muted-foreground">{t('db.default')}</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-muted-foreground">{t('db.defaultLabel')} <span className="font-mono">{dbSettings.default_path}</span></div>
|
<p className="text-[11px] text-muted-foreground">{t('db.logbookFileHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 variant="outline" size="sm" onClick={newLogbook}><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={openLogbook}><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={renameLogbook} title={t('db.renameLogbookTip')}><Pencil className="size-3.5" /> {t('db.renameLogbook')}</Button>
|
||||||
<Button variant="outline" size="sm" onClick={saveCopy}><Copy className="size-3.5" /> {t('db.saveCopy')}</Button>
|
{mysqlCfg.sqlite_path && <Button variant="ghost" size="sm" onClick={useLocalLogbook}>{t('db.useDefaultLogbook')}</Button>}
|
||||||
{dbSettings.is_custom && <Button variant="ghost" size="sm" onClick={resetDefault}>{t('db.resetDefault')}</Button>}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{dbMsg && (
|
|
||||||
<div className="text-xs bg-success-muted border border-success-border text-success-muted-foreground rounded-md px-3 py-3 space-y-2">
|
|
||||||
<div className="flex items-start gap-2">
|
|
||||||
<Check className="size-4 mt-0.5 shrink-0" />
|
|
||||||
<div>
|
|
||||||
<div className="font-medium">{t('db.savedRestart')}</div>
|
|
||||||
<div className="font-mono text-[10px] mt-1 break-all opacity-90">{dbMsg}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 pl-6">
|
|
||||||
<Button size="sm" onClick={() => { RestartApp().catch((e: any) => setErr(String(e?.message ?? e))); }}>
|
|
||||||
<Power className="size-3.5" /> {t('db.restartNow')}
|
|
||||||
</Button>
|
|
||||||
<span className="text-[10px] opacity-80">{t('db.restartHint')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -5008,6 +5079,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
winkeyer: WinkeyerPanel,
|
winkeyer: WinkeyerPanel,
|
||||||
antenna: UltrabeamPanel,
|
antenna: UltrabeamPanel,
|
||||||
antgenius: AntGeniusPanelSettings,
|
antgenius: AntGeniusPanelSettings,
|
||||||
|
tunergenius: TunerGeniusPanelSettings,
|
||||||
pgxl: PGXLPanelSettings,
|
pgxl: PGXLPanelSettings,
|
||||||
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
|
flex: () => <FlexBandAntennasPanel bands={lists.bands ?? []} />,
|
||||||
audio: AudioPanel,
|
audio: AudioPanel,
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ import { useI18n } from '@/lib/i18n';
|
|||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
import { AmpCard } from '@/components/AmpCard';
|
import { AmpCard } from '@/components/AmpCard';
|
||||||
|
import { TunerCard } from '@/components/TunerCard';
|
||||||
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
import {
|
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, TestStationDevice,
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
GetAmpStatuses, GetFlexState,
|
GetAmpStatuses, GetFlexState,
|
||||||
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
} 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 };
|
||||||
@@ -283,6 +286,21 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
const id = window.setInterval(load, 1500);
|
const id = window.setInterval(load, 1500);
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
// Tuner Genius XL (4O3A): a card here too, so operators without a FlexRadio
|
||||||
|
// panel still get the controls. Re-read the enabled flag so it appears/hides
|
||||||
|
// without a restart.
|
||||||
|
const [tg, setTg] = useState<TGStatus>({ connected: false });
|
||||||
|
const [tgEnabled, setTgEnabled] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = async () => {
|
||||||
|
try { const en: any = await GetTunerGeniusSettings(); if (alive) setTgEnabled(!!en?.enabled); } catch {}
|
||||||
|
try { const s: any = await GetTunerGeniusStatus(); if (alive && s) setTg(s as TGStatus); } catch {}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 500); // fast so meters track TX (see App.tsx)
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadDevices = useCallback(async () => {
|
const loadDevices = useCallback(async () => {
|
||||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
@@ -406,13 +424,15 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
}
|
}
|
||||||
// One card per configured amplifier (identical to the Flex panel's card).
|
// One card per configured amplifier (identical to the Flex panel's card).
|
||||||
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} /> });
|
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} /> });
|
||||||
|
// Tuner Genius XL card (identical to the Flex panel's).
|
||||||
|
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} /> });
|
||||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||||
|
|
||||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||||
const widgetIds = ordered.map((w) => w.id);
|
const widgetIds = ordered.map((w) => w.id);
|
||||||
|
|
||||||
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0;
|
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && amps.length === 0 && !tgEnabled;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||||
@@ -442,14 +462,15 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dashboard of FIXED-WIDTH cards that wrap. "Auto" fills the window; a fixed
|
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so
|
||||||
column count caps the container width so cards wrap onto more lines. Each
|
they pack tightly by height (no ragged gaps under short cards). "Auto"
|
||||||
card has a grip handle (left rail) as the drag initiator (the card body is
|
fits as many ~430px columns as the window allows; a fixed count caps the
|
||||||
full of buttons, so dragging the whole card was unreliable). */}
|
container width to that many columns. Each card has a grip handle (left
|
||||||
<div className="flex flex-wrap gap-4 items-start"
|
rail) as the drag initiator (the body is full of buttons). */}
|
||||||
style={cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : undefined}>
|
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance',
|
||||||
|
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}>
|
||||||
{ordered.map((w) => (
|
{ordered.map((w) => (
|
||||||
<div key={w.id} className="flex items-stretch w-[430px]"
|
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
|
||||||
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
|
||||||
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
|
||||||
<div draggable
|
<div draggable
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Gauge, Radio, ChevronDown } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { MeterBar } from '@/components/MeterBar';
|
||||||
|
import type { TGStatus, TGChannel } from '@/components/TunerGeniusPanel';
|
||||||
|
import { TunerGeniusAutotune, TunerGeniusSetBypass, TunerGeniusSetOperate, TunerGeniusActivate } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// TunerCard renders the 4O3A Tuner Genius XL exactly like the amplifier card
|
||||||
|
// (AmpCard) so Station Control and the FlexRadio panel show the SAME card. It
|
||||||
|
// mirrors the native app's two channels (A / B) with their source, frequency and
|
||||||
|
// antenna, a live PWR / SWR pair, and the Tune / Bypass / Operate actions. It
|
||||||
|
// drives the backend directly (no local state) — the caller's ~1.5s poll
|
||||||
|
// reconciles the display, just like AmpCard. Meters come from the shared MeterBar
|
||||||
|
// so they're the exact same size as the Flex/amp meters.
|
||||||
|
|
||||||
|
function Card({ icon: Icon, title, accent, children, ckey }: { icon: any; title: string; accent?: string; children: React.ReactNode; ckey?: string }) {
|
||||||
|
// Collapsible with persisted state — same behaviour as the FlexRadio panel's Card.
|
||||||
|
const storeKey = 'opslog.cardOpen.' + (ckey || title);
|
||||||
|
const [open, setOpen] = useState(() => localStorage.getItem(storeKey) !== '0');
|
||||||
|
const toggle = () => setOpen((o) => { const n = !o; localStorage.setItem(storeKey, n ? '1' : '0'); return n; });
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<button type="button" onClick={toggle}
|
||||||
|
className={cn('w-full flex items-center gap-2 px-3 py-2 bg-muted/30 hover:bg-muted/50 transition-colors text-left', open && 'border-b border-border/60')}>
|
||||||
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||||
|
<ChevronDown className={cn('ml-auto size-4 text-muted-foreground transition-transform', !open && '-rotate-90')} />
|
||||||
|
</button>
|
||||||
|
{open && <div className="p-3 space-y-3">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChannelButton — one of the two RF channels (A / B). Clicking it makes that
|
||||||
|
// channel active. Shows the source (RF Sense / Flex / CAT…), frequency and
|
||||||
|
// antenna, matching the two rows of the native 4O3A app.
|
||||||
|
function ChannelButton({ letter, ch, active, ptt, threeWay, onSelect, t }: {
|
||||||
|
letter: 'A' | 'B'; ch: TGChannel; active: boolean; ptt: boolean; threeWay: boolean;
|
||||||
|
onSelect: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const cls = ptt
|
||||||
|
? '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)]'
|
||||||
|
: active
|
||||||
|
? 'bg-gradient-to-b from-emerald-500 to-emerald-600 text-white border-emerald-400/50 shadow-[0_0_9px_rgba(16,185,129,0.4)]'
|
||||||
|
: 'bg-card text-foreground/80 border-border hover:bg-muted';
|
||||||
|
const src = ch.mode_str || '—';
|
||||||
|
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)} MHz` : '—';
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onSelect}
|
||||||
|
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||||
|
className={cn('flex-1 min-w-0 rounded-lg border px-2 py-1.5 text-left transition-all active:scale-[0.98]', cls)}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-extrabold text-sm">{letter}</span>
|
||||||
|
<span className="text-[11px] font-semibold truncate opacity-90">{src}</span>
|
||||||
|
{ptt && <span className="ml-auto text-[9px] font-bold uppercase">TX</span>}
|
||||||
|
{!ptt && active && <span className="ml-auto text-[9px] font-bold uppercase opacity-90">{t('tgp.chActiveTag')}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
<span className="font-mono text-[11px] tabular-nums truncate">{freq}</span>
|
||||||
|
{ch.antenna != null && ch.antenna > 0 && (threeWay || ch.antenna > 0) && (
|
||||||
|
<span className="ml-auto text-[10px] font-semibold whitespace-nowrap opacity-90">{t('tgp.ant')} {ch.antenna}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?: any) => string }) {
|
||||||
|
const connected = !!status.connected;
|
||||||
|
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||||
|
const fwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0;
|
||||||
|
const active = status.active ?? 1;
|
||||||
|
const a: TGChannel = status.a ?? {};
|
||||||
|
const b: TGChannel = status.b ?? {};
|
||||||
|
|
||||||
|
const title = `${t('tgp.title')}${status.host ? ' · ' + status.host : ''}`;
|
||||||
|
return (
|
||||||
|
<Card icon={Gauge} title={title} accent="#f59e0b">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => TunerGeniusSetOperate(!status.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
status.operate ? 'bg-warning text-warning-foreground border-warning shadow-[0_0_14px] shadow-warning/50' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{status.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => TunerGeniusAutotune().catch(() => {})}
|
||||||
|
className={cn('inline-flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
status.tuning ? 'bg-amber-500 text-white border-amber-500 shadow-[0_0_14px] shadow-amber-500/50 animate-pulse' : 'bg-card text-amber-600 border-amber-500 hover:bg-amber-500/10')}>
|
||||||
|
<Radio className="size-4" />{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||||
|
</button>
|
||||||
|
<button type="button" disabled={!connected}
|
||||||
|
onClick={() => TunerGeniusSetBypass(!status.bypass).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-bold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
status.bypass ? 'bg-sky-500 text-white border-sky-500 shadow-[0_0_12px] shadow-sky-500/40' : 'bg-card text-sky-600 border-sky-500/70 hover:bg-sky-500/10')}>
|
||||||
|
{t('tgp.bypass')}
|
||||||
|
</button>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{connected ? (status.bypass ? t('tgp.bypassed') : t('tgp.inLine')) : t('tgp.offline')}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
{status.message && (
|
||||||
|
<span className="px-2 py-1 rounded bg-warning-muted text-warning-muted-foreground text-xs font-bold">⚠ {status.message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connected && (
|
||||||
|
<>
|
||||||
|
{/* Channel A / B selector — click to make active (activate ch=1/2). */}
|
||||||
|
<div className="flex items-stretch gap-2">
|
||||||
|
<ChannelButton letter="A" ch={a} active={active === 1} ptt={!!a.ptt} threeWay={!!status.three_way}
|
||||||
|
onSelect={() => TunerGeniusActivate(1).catch(() => {})} t={t} />
|
||||||
|
<ChannelButton letter="B" ch={b} active={active === 2} ptt={!!b.ptt} threeWay={!!status.three_way}
|
||||||
|
onSelect={() => TunerGeniusActivate(2).catch(() => {})} t={t} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PWR + SWR meters — same grid as the Flex/amp meters so they match size. */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||||||
|
<MeterBar label={t('tgp.power')} value={fwdW} unit="W" lo={0} hi={2000}
|
||||||
|
display={fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
<MeterBar label={t('tgp.swr')} value={vswr ?? 1} lo={1} hi={3}
|
||||||
|
display={vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||||
|
segColor={(f) => (f > 0.5 ? '#dc2626' : f > 0.25 ? '#f59e0b' : '#16a34a')} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { Gauge, X, Power, Radio } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
|
export type TGChannel = {
|
||||||
|
ptt?: boolean; band?: number; mode?: number; mode_str?: string; flex?: string;
|
||||||
|
freq_mhz?: number; bypass?: boolean; antenna?: number;
|
||||||
|
};
|
||||||
|
export type TGStatus = {
|
||||||
|
connected: boolean; host?: string; last_error?: string;
|
||||||
|
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
|
||||||
|
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
|
||||||
|
antenna?: number; three_way?: boolean; message?: string;
|
||||||
|
a?: TGChannel; b?: TGChannel;
|
||||||
|
relay_c1?: number; relay_l?: number; relay_c2?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// swrColour picks a status colour for the VSWR readout: green ≤1.5, amber ≤2.0,
|
||||||
|
// red above (the usual "safe / caution / high" ATU thresholds).
|
||||||
|
function swrColour(vswr?: number): string {
|
||||||
|
if (!vswr || vswr <= 0) return 'text-muted-foreground';
|
||||||
|
if (vswr <= 1.5) return 'text-success';
|
||||||
|
if (vswr <= 2.0) return 'text-warning';
|
||||||
|
return 'text-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChanRow — a compact A/B channel line in the docked widget. Highlights the
|
||||||
|
// active channel (green) or TX (red), shows the source + frequency + antenna,
|
||||||
|
// and clicking it makes that channel active.
|
||||||
|
function ChanRow({ letter, ch, active, onSelect, t }: {
|
||||||
|
letter: 'A' | 'B'; ch: TGChannel; active: boolean; onSelect: () => void;
|
||||||
|
t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const ptt = !!ch.ptt;
|
||||||
|
const cls = ptt
|
||||||
|
? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40'
|
||||||
|
: active
|
||||||
|
? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40'
|
||||||
|
: 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60';
|
||||||
|
const src = ch.mode_str || '—';
|
||||||
|
const freq = ch.freq_mhz && ch.freq_mhz > 0 ? `${ch.freq_mhz.toFixed(3)}` : '—';
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={onSelect}
|
||||||
|
title={active ? t('tgp.chActive', { letter }) : t('tgp.chSelect', { letter })}
|
||||||
|
className={cn('w-full flex items-center gap-1.5 rounded-lg border px-2 py-1 text-left transition-all active:scale-[0.98]', cls)}>
|
||||||
|
<span className="font-extrabold text-xs w-3 shrink-0">{letter}</span>
|
||||||
|
<span className="text-[10px] font-semibold truncate opacity-90 w-12 shrink-0">{src}</span>
|
||||||
|
<span className="font-mono text-[10px] tabular-nums truncate flex-1">{freq}</span>
|
||||||
|
{ch.antenna != null && ch.antenna > 0 && (
|
||||||
|
<span className="text-[9px] font-semibold whitespace-nowrap opacity-90 shrink-0">{t('tgp.ant')}{ch.antenna}</span>
|
||||||
|
)}
|
||||||
|
{ptt && <span className="text-[9px] font-bold uppercase shrink-0">TX</span>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TunerGeniusPanel — compact docked widget for a 4O3A Tuner Genius XL ATU. Shows
|
||||||
|
// the live SWR / forward power, the two RF channels (A / B, click to activate),
|
||||||
|
// and Tune / Bypass / Operate. A fuller card (TunerCard) is shown in the FlexRadio
|
||||||
|
// panel and Station Control.
|
||||||
|
export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActivate, onClose }: {
|
||||||
|
status: TGStatus;
|
||||||
|
onTune: () => void;
|
||||||
|
onBypass: (on: boolean) => void;
|
||||||
|
onOperate: (on: boolean) => void;
|
||||||
|
onActivate: (ch: number) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
|
||||||
|
const active = status.active ?? 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||||
|
<Gauge className={cn('size-4', status.connected ? 'text-success drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||||
|
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Tuner Genius</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||||
|
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-danger')} />
|
||||||
|
<span className={status.connected ? 'text-success' : 'text-danger'}>{status.connected ? t('tgp.online') : t('tgp.offline')}</span>
|
||||||
|
</span>
|
||||||
|
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('tgp.close')}>
|
||||||
|
<X className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!status.connected ? (
|
||||||
|
<div className="flex-1 min-h-0 flex flex-col items-center justify-center text-xs gap-2 p-3">
|
||||||
|
<div className="text-muted-foreground italic animate-pulse">{t('tgp.connecting')}</div>
|
||||||
|
{status.last_error && <div className="text-danger font-mono text-[10px] break-words text-center px-2">{status.last_error}</div>}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 min-h-0 overflow-y-auto p-2.5 space-y-2.5">
|
||||||
|
{/* SWR + forward power readouts */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.swr')}</div>
|
||||||
|
<div className={cn('text-lg font-bold font-mono leading-tight', swrColour(vswr))}>
|
||||||
|
{vswr ? `${vswr.toFixed(2)}:1` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
|
||||||
|
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
|
||||||
|
<div className="text-lg font-bold font-mono leading-tight text-foreground/80">
|
||||||
|
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Channel A / B — click to activate */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<ChanRow letter="A" ch={status.a ?? {}} active={active === 1} onSelect={() => onActivate(1)} t={t} />
|
||||||
|
<ChanRow letter="B" ch={status.b ?? {}} active={active === 2} onSelect={() => onActivate(2)} t={t} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status.message && (
|
||||||
|
<div className="rounded-lg border border-warning-border bg-warning-muted text-warning-muted-foreground text-[10px] px-2 py-1 text-center break-words">
|
||||||
|
{status.message}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tune */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onTune}
|
||||||
|
disabled={status.tuning}
|
||||||
|
title={t('tgp.tuneHint')}
|
||||||
|
className={cn(
|
||||||
|
'w-full rounded-lg text-sm font-bold py-2 border transition-all active:scale-[0.98]',
|
||||||
|
status.tuning
|
||||||
|
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_10px_rgba(245,158,11,0.5)] animate-pulse'
|
||||||
|
: 'bg-gradient-to-b from-primary/90 to-primary text-primary-foreground border-primary/40 hover:brightness-110',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex items-center justify-center gap-2">
|
||||||
|
<Radio className="size-4" />
|
||||||
|
{status.tuning ? t('tgp.tuning') : t('tgp.tune')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Bypass + Operate toggles */}
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onBypass(!status.bypass)}
|
||||||
|
title={t('tgp.bypassHint')}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
||||||
|
status.bypass
|
||||||
|
? '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',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{t('tgp.bypass')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOperate(!status.operate)}
|
||||||
|
title={t('tgp.operateHint')}
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95',
|
||||||
|
status.operate
|
||||||
|
? '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-card text-muted-foreground border-border hover:bg-muted hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Power className="size-3.5" />
|
||||||
|
{status.operate ? t('tgp.operate') : t('tgp.standby')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -230,9 +230,14 @@ export function WinkeyerPanel({
|
|||||||
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
{autoCall && <span className="text-[10px] text-warning/80">{t('wkp.loopHint')}</span>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short. */}
|
{/* Macro buttons F1… — single-line (F-key + label) to keep the panel short.
|
||||||
|
Empty macros (no label AND no text) are hidden, like the voice keyer;
|
||||||
|
the F-number stays tied to the macro's real index so shortcuts match. */}
|
||||||
<div className="grid grid-cols-3 gap-1">
|
<div className="grid grid-cols-3 gap-1">
|
||||||
{macros.map((m, i) => (
|
{macros
|
||||||
|
.map((m, i) => ({ m, i }))
|
||||||
|
.filter(({ m }) => `${m.label ?? ''}${m.text ?? ''}`.trim() !== '')
|
||||||
|
.map(({ m, i }) => (
|
||||||
<button
|
<button
|
||||||
key={i}
|
key={i}
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -139,10 +139,12 @@ const en: Dict = {
|
|||||||
'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 the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', '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', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
|
'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 the grip handle on the left of a panel to move it. Pick a column count to lay them out in a grid.', 'station.dragMove': 'Drag to move this panel', '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', 'station.test': 'Test connection', 'station.testOk': 'Connected — {n} relays', 'station.testFail': 'Not connected', 'station.detectNone': 'No FTDI board found — check the cable and that the D2XX driver is installed.', 'station.detectFound': '{n} board(s) detected.',
|
||||||
'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': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||||
// CW Keyer settings panel
|
// CW Keyer settings panel
|
||||||
'wk.enable': 'Enable CW keyer (shows the keyer panel)', 'wk.engine': 'Keyer engine',
|
'wk.enable': 'Enable CW keyer (shows the keyer panel)', 'wk.engine': 'Keyer engine',
|
||||||
'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)',
|
'wk.escClears': 'ESC clears the callsign too (otherwise ESC only stops transmission)',
|
||||||
|
'wk.esm': 'ESM — Enter Sends Message (CW)',
|
||||||
|
'wk.esmHint': 'In CW with the keyer on, Enter fires a macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign entered → F2 (report) and focus jumps to RST; Enter in RST → F3 (TU), which logs if the macro contains <LOGQSO>.',
|
||||||
'wk.engWinkeyer': 'WinKeyer (K1EL, serial)', 'wk.engSerial': 'Serial port (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (rig keyer)', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (coming soon)',
|
'wk.engWinkeyer': 'WinKeyer (K1EL, serial)', 'wk.engSerial': 'Serial port (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (rig keyer)', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (coming soon)',
|
||||||
'wk.icomNote': "Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).",
|
'wk.icomNote': "Icom CI-V keys CW through the rig's own keyer over the existing CAT connection (command 0x17) — it reuses the CAT COM port set in Settings → CAT, so there's nothing else to wire up here. Put the rig in CW mode. Weight, ratio, sidetone, paddle mode… are configured on the radio; only the speed is set from here (the rig's KEY SPEED).",
|
||||||
'wk.flexNote': "FlexRadio keys CW through the radio's CWX 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).",
|
'wk.flexNote': "FlexRadio keys CW through the radio's CWX 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).",
|
||||||
@@ -252,6 +254,7 @@ const en: Dict = {
|
|||||||
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
|
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
|
||||||
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
||||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||||
|
'tg2.hint': 'OpsLog talks to the 4O3A Tuner Genius XL over TCP (port fixed at 9010), so only the device IP is needed. A docked widget then shows SWR and forward power and offers Tune, Bypass and Operate/Standby. Control it directly (not through the radio) so OpsLog uses just one of the box\'s connection slots.', 'tg2.enable': 'Enable Tuner Genius control', 'tg2.portHint': 'The TCP port is fixed at 9010 on the device.', 'tg2.password': 'Remote code', 'tg2.passwordPh': 'blank on LAN', 'tg2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||||
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to a microHAM ARCO — no PstRotator needed. LAN: set Config → LAN → CONTROL PROTOCOL to 'Yaesu GS-232A' on the ARCO and enter the same TCP port here (up to 4 parallel connections). USB: set USB CONTROL PROTOCOL to 'Yaesu GS-232A' and pick the ARCO's COM port here (baud rate doesn't matter on USB).", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
'rot.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', 'rot.arcoHint': "Talks directly to a microHAM ARCO — no PstRotator needed. LAN: set Config → LAN → CONTROL PROTOCOL to 'Yaesu GS-232A' on the ARCO and enter the same TCP port here (up to 4 parallel connections). USB: set USB CONTROL PROTOCOL to 'Yaesu GS-232A' and pick the ARCO's COM port here (baud rate doesn't matter on USB).", 'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m–6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 13–54 MHz (20 m–6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||||
@@ -286,7 +289,10 @@ const en: Dict = {
|
|||||||
'prof.hint': 'Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile.', 'prof.active': 'ACTIVE', 'prof.duplicate': 'Duplicate', 'prof.delete': 'Delete', 'prof.profileName': 'Profile name',
|
'prof.hint': 'Switch between operating identities (home / portable / SOTA / contest). Pick a profile here, then edit its fields in the other sections (Station Information, etc.) — changes are saved against the selected profile.', 'prof.active': 'ACTIVE', 'prof.duplicate': 'Duplicate', 'prof.delete': 'Delete', 'prof.profileName': 'Profile name',
|
||||||
'prof.configId': 'Configuration ID', 'prof.description': 'Description', 'prof.new': 'New', 'prof.newTitle': 'Create a new empty profile', 'prof.dupTitle': 'Clone the selected profile (keeps all its fields)', 'prof.setActive': 'Set active', 'prof.setActiveTitle': 'Activate the selected profile — new QSOs will use its MY_* fields', 'prof.deleteTitle': 'Delete the selected profile', 'prof.cantDeleteLast': 'Cannot delete the last profile', 'prof.activeSuffix': ' (active)', 'prof.viewingNote': "You're viewing {name}. The active profile is {active} — its values are stamped on new QSOs. Click Set active to switch.",
|
'prof.configId': 'Configuration ID', 'prof.description': 'Description', 'prof.new': 'New', 'prof.newTitle': 'Create a new empty profile', 'prof.dupTitle': 'Clone the selected profile (keeps all its fields)', 'prof.setActive': 'Set active', 'prof.setActiveTitle': 'Activate the selected profile — new QSOs will use its MY_* fields', 'prof.deleteTitle': 'Delete the selected profile', 'prof.cantDeleteLast': 'Cannot delete the last profile', 'prof.activeSuffix': ' (active)', 'prof.viewingNote': "You're viewing {name}. The active profile is {active} — its values are stamped on new QSOs. Click Set active to switch.",
|
||||||
// Database panel
|
// Database panel
|
||||||
'db.optSqlite': 'SQLite — local file (solo)', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.',
|
'db.optSqlite': 'SQLite — local file', 'db.optMysql': 'MySQL — shared server (multi-operator)', 'db.profileHint': 'This is the logbook for the active profile. Different profiles can point at different databases — switching profile switches the logbook.',
|
||||||
|
'db.logbookLabel': 'Logbook', 'db.openFolder': 'Open folder', 'db.dedicatedFile': 'dedicated file', 'db.useDefaultLogbook': 'Use the default logbook', 'db.renameLogbook': 'Rename / relocate…', 'db.renameLogbookTip': 'Rename or move this logbook file, carrying its QSOs across', 'db.logbookRenamed': 'Logbook renamed.',
|
||||||
|
'db.logbookFile': "This profile's logbook file", 'db.logbookFileHint': "Your QSOs live here — separate from the settings database. By default it's logbook.db next to your settings; choose a dedicated file to keep a visiting operator's contacts apart. A new file is created automatically.", 'db.chooseFile': 'Choose a dedicated file…', 'db.switchedSqliteFile': 'Logbook now uses a dedicated SQLite file.',
|
||||||
|
'db.appDb': 'Settings database (settings + profiles)', 'db.appDbHint': 'Holds your settings and profiles — NOT your QSOs (those are in the logbook below). Changing its location moves the whole install.',
|
||||||
'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓',
|
'db.saveSwitch': 'Save & switch logbook', 'db.switchedMysql': 'Logbook switched to MySQL ✓', 'db.switchedSqlite': 'Logbook switched to local SQLite ✓',
|
||||||
'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)',
|
||||||
@@ -328,6 +334,8 @@ const en: Dict = {
|
|||||||
'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}',
|
||||||
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
|
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1–F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
|
||||||
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
|
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
|
||||||
|
'tgp.online': 'online', 'tgp.offline': 'offline', 'tgp.close': 'Close', 'tgp.connecting': 'Connecting…', 'tgp.swr': 'SWR', 'tgp.power': 'Fwd power', 'tgp.tune': 'Tune', 'tgp.tuning': 'Tuning…', 'tgp.tuneHint': 'Start an automatic tuning cycle on the active channel — key the rig into a carrier so the tuner can measure SWR', 'tgp.bypass': 'Bypass', 'tgp.bypassHint': 'Toggle global bypass — route the antenna straight through, tuner out of line', 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Toggle Operate / Standby',
|
||||||
|
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line',
|
||||||
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
|
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
|
||||||
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.sliceHint': 'Click to make this the active slice — frequency, mode, DSP and spot-clicks all follow it.', 'flxp.txSlice': 'This slice transmits', 'flxp.setTxSlice': 'Move TX to this slice (transmit here)', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
||||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'ACOM offline', 'flxp.ampPick': 'Choose which amplifier this card shows', 'flxp.dspV4Hint': 'SmartSDR v4 DSP (8000/Aurora series)', 'flxp.daxHint': 'DAX as the transmit audio source (SmartSDR transmit-bar DAX button) — for WSJT-X & co', 'flxp.rnnHint': 'RNN — AI noise reduction (on/off)', 'flxp.anftHint': 'ANFT — FFT-based automatic notch filter (on/off)', 'flxp.dspNoise': 'Noise', 'flxp.dspMore': 'Show/hide advanced DSP (WNB, v4 NR/notch)',
|
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.voltage': 'VOLTAGE', 'flxp.paTemp': 'PA TEMP', 'flxp.txFilter': 'TX filter', 'flxp.micProfile': 'Mic profile', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', 'flxp.outputPower': 'OUTPUT POWER', 'flxp.speOffline': 'SPE offline', 'flxp.acomOffline': 'ACOM offline', 'flxp.ampPick': 'Choose which amplifier this card shows', 'flxp.dspV4Hint': 'SmartSDR v4 DSP (8000/Aurora series)', 'flxp.daxHint': 'DAX as the transmit audio source (SmartSDR transmit-bar DAX button) — for WSJT-X & co', 'flxp.rnnHint': 'RNN — AI noise reduction (on/off)', 'flxp.anftHint': 'ANFT — FFT-based automatic notch filter (on/off)', 'flxp.dspNoise': 'Noise', 'flxp.dspMore': 'Show/hide advanced DSP (WNB, v4 NR/notch)',
|
||||||
@@ -507,10 +515,12 @@ const fr: Dict = {
|
|||||||
'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 la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', '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', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
|
'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 la poignée à gauche d un panneau pour le déplacer. Choisis un nombre de colonnes pour la disposition.', 'station.dragMove': 'Glisser pour déplacer ce panneau', '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', 'station.test': 'Tester la connexion', 'station.testOk': 'Connecté — {n} relais', 'station.testFail': 'Non connecté', 'station.detectNone': 'Aucune carte FTDI trouvée — vérifie le câble et que le driver D2XX est installé.', 'station.detectFound': '{n} carte(s) détectée(s).',
|
||||||
'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': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.tunergenius': 'Tuner Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||||
// Panneau Manipulateur CW
|
// Panneau Manipulateur CW
|
||||||
'wk.enable': 'Activer le manipulateur CW (affiche le panneau)', 'wk.engine': 'Moteur du manipulateur',
|
'wk.enable': 'Activer le manipulateur CW (affiche le panneau)', 'wk.engine': 'Moteur du manipulateur',
|
||||||
'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)",
|
'wk.escClears': "ÉCHAP efface aussi l'indicatif (sinon ÉCHAP arrête seulement la transmission)",
|
||||||
|
'wk.esm': 'ESM — Entrée envoie le message (CW)',
|
||||||
|
'wk.esmHint': "En CW avec le keyer actif, Entrée envoie un macro selon l'étape du QSO au lieu de loguer : indicatif vide → F1 (CQ) ; indicatif saisi → F2 (report) et le focus passe au RST ; Entrée dans le RST → F3 (TU), qui logue si le macro contient <LOGQSO>.",
|
||||||
'wk.engWinkeyer': 'WinKeyer (K1EL, série)', 'wk.engSerial': 'Port série (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (keyer de la radio)', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (bientôt)',
|
'wk.engWinkeyer': 'WinKeyer (K1EL, série)', 'wk.engSerial': 'Port série (DTR=CW / RTS=PTT)', 'wk.engIcom': 'Icom CI-V (keyer de la radio)', 'wk.engFlex': 'FlexRadio (CWX)', 'wk.engTci': 'TCI (bientôt)',
|
||||||
'wk.icomNote': "L'Icom CI-V manipule la CW via le keyer interne de la radio sur la connexion CAT existante (commande 0x17) — il réutilise le port COM CAT défini dans Réglages → CAT, rien d'autre à câbler ici. Mets la radio en mode CW. Poids, ratio, sidetone, mode paddle… se règlent sur la radio ; seule la vitesse est définie ici (KEY SPEED de la radio).",
|
'wk.icomNote': "L'Icom CI-V manipule la CW via le keyer interne de la radio sur la connexion CAT existante (commande 0x17) — il réutilise le port COM CAT défini dans Réglages → CAT, rien d'autre à câbler ici. Mets la radio en mode CW. Poids, ratio, sidetone, mode paddle… se règlent sur la radio ; seule la vitesse est définie ici (KEY SPEED de la radio).",
|
||||||
'wk.flexNote': "FlexRadio manipule la CW via le keyer CWX de la radio sur la connexion SmartSDR CAT existante — pas besoin de WinKeyer ni de SmartCAT. Il réutilise la connexion définie dans Réglages → CAT, rien d'autre à câbler ici. Mets une slice en mode CW. Seule la vitesse est définie ici ; poids, sidetone et break-in se règlent sur la radio (le break-in doit être activé pour que la CW parte vraiment).",
|
'wk.flexNote': "FlexRadio manipule la CW via le keyer CWX de la radio sur la connexion SmartSDR CAT existante — pas besoin de WinKeyer ni de SmartCAT. Il réutilise la connexion définie dans Réglages → CAT, rien d'autre à câbler ici. Mets une slice en mode CW. Seule la vitesse est définie ici ; poids, sidetone et break-in se règlent sur la radio (le break-in doit être activé pour que la CW parte vraiment).",
|
||||||
@@ -611,6 +621,7 @@ const fr: Dict = {
|
|||||||
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
|
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
|
||||||
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
||||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||||
|
'tg2.hint': "OpsLog dialogue avec le 4O3A Tuner Genius XL en TCP (port fixé à 9010), seule l'IP de l'appareil est nécessaire. Un widget ancré affiche le ROS et la puissance directe et propose Accord, Bypass et Operate/Standby. Pilotage direct (pas via la radio) pour n'utiliser qu'une des connexions de la boîte.", 'tg2.enable': "Activer le contrôle du Tuner Genius", 'tg2.portHint': "Le port TCP est fixé à 9010 sur l'appareil.", 'tg2.password': 'Code distant', 'tg2.passwordPh': 'vide en LAN', 'tg2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||||
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à un microHAM ARCO — sans PstRotator. Réseau : régler Config → LAN → CONTROL PROTOCOL sur « Yaesu GS-232A » sur l'ARCO et saisir ici le même port TCP (jusqu'à 4 connexions en parallèle). USB : régler USB CONTROL PROTOCOL sur « Yaesu GS-232A » et choisir ici le port COM de l'ARCO (la vitesse est sans importance en USB).", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
'rot.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', 'rot.arcoHint': "Parle directement à un microHAM ARCO — sans PstRotator. Réseau : régler Config → LAN → CONTROL PROTOCOL sur « Yaesu GS-232A » sur l'ARCO et saisir ici le même port TCP (jusqu'à 4 connexions en parallèle). USB : régler USB CONTROL PROTOCOL sur « Yaesu GS-232A » et choisir ici le port COM de l'ARCO (la vitesse est sans importance en USB).", 'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||||
@@ -641,7 +652,10 @@ const fr: Dict = {
|
|||||||
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
|
'prof.deleteConfirm': 'Supprimer le profil « {name} » ? Tous ses réglages seront perdus.', 'prof.dupPrompt': 'Nom du nouveau profil (copie de « {name} ») :', 'prof.dupSuffix': '{name} Copie', 'prof.newPrompt': 'Nom du nouveau profil :', 'prof.newDefault': 'Nouveau profil',
|
||||||
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
|
'prof.hint': "Bascule entre tes identités d'opération (maison / portable / SOTA / contest). Choisis un profil ici, puis édite ses champs dans les autres sections (Informations station, etc.) — les changements sont enregistrés sur le profil sélectionné.", 'prof.active': 'ACTIF', 'prof.duplicate': 'Dupliquer', 'prof.delete': 'Supprimer', 'prof.profileName': 'Nom du profil',
|
||||||
'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.',
|
'prof.configId': 'ID de configuration', 'prof.description': 'Description', 'prof.new': 'Nouveau', 'prof.newTitle': 'Créer un nouveau profil vierge', 'prof.dupTitle': 'Cloner le profil sélectionné (garde tous ses champs)', 'prof.setActive': 'Activer', 'prof.setActiveTitle': 'Activer le profil sélectionné — les nouveaux QSO utiliseront ses champs MY_*', 'prof.deleteTitle': 'Supprimer le profil sélectionné', 'prof.cantDeleteLast': 'Impossible de supprimer le dernier profil', 'prof.activeSuffix': ' (actif)', 'prof.viewingNote': 'Tu consultes {name}. Le profil actif est {active} — ses valeurs sont inscrites sur les nouveaux QSO. Clique « Activer » pour basculer.',
|
||||||
'db.optSqlite': 'SQLite — fichier local (solo)', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.',
|
'db.optSqlite': 'SQLite — fichier local', 'db.optMysql': 'MySQL — serveur partagé (multi-opérateur)', 'db.profileHint': 'Ceci est le journal du profil actif. Des profils différents peuvent pointer vers des bases différentes — changer de profil change le journal.',
|
||||||
|
'db.logbookLabel': 'Journal', 'db.openFolder': 'Ouvrir le dossier', 'db.dedicatedFile': 'fichier dédié', 'db.useDefaultLogbook': 'Utiliser le journal par défaut', 'db.renameLogbook': 'Renommer / déplacer…', 'db.renameLogbookTip': 'Renommer ou déplacer ce fichier journal, en emmenant ses QSO', 'db.logbookRenamed': 'Journal renommé.',
|
||||||
|
'db.logbookFile': 'Fichier journal de ce profil', 'db.logbookFileHint': "Tes QSO sont ici — séparés de la base de réglages. Par défaut c'est logbook.db à côté de tes réglages ; choisis un fichier dédié pour isoler les contacts d'un opérateur de passage. Un nouveau fichier est créé automatiquement.", 'db.chooseFile': 'Choisir un fichier dédié…', 'db.switchedSqliteFile': 'Le journal utilise désormais un fichier SQLite dédié.',
|
||||||
|
'db.appDb': 'Base de réglages (réglages + profils)', 'db.appDbHint': "Contient tes réglages et tes profils — PAS tes QSO (ceux-ci sont dans le journal ci-dessous). Changer son emplacement déplace toute l'installation.",
|
||||||
'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓',
|
'db.saveSwitch': 'Enregistrer & basculer le journal', 'db.switchedMysql': 'Journal basculé vers MySQL ✓', 'db.switchedSqlite': 'Journal basculé vers SQLite local ✓',
|
||||||
'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)',
|
||||||
@@ -680,6 +694,8 @@ const fr: Dict = {
|
|||||||
'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}',
|
||||||
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
|
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1–F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
|
||||||
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante',
|
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante',
|
||||||
|
'tgp.online': 'en ligne', 'tgp.offline': 'hors ligne', 'tgp.close': 'Fermer', 'tgp.connecting': 'Connexion…', 'tgp.swr': 'ROS', 'tgp.power': 'Puiss. directe', 'tgp.tune': 'Accord', 'tgp.tuning': 'Accord…', 'tgp.tuneHint': "Lancer un cycle d'accord automatique sur le canal actif — passe la radio en porteuse pour que le coupleur mesure le ROS", 'tgp.bypass': 'Bypass', 'tgp.bypassHint': "Basculer le bypass global — antenne en direct, coupleur hors ligne", 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Basculer Operate / Standby',
|
||||||
|
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne',
|
||||||
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
|
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
|
||||||
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.sliceHint': 'Cliquer pour rendre cette slice active — fréquence, mode, DSP et clics de spot la suivent tous.', 'flxp.txSlice': 'Cette slice émet', 'flxp.setTxSlice': 'Déplacer le TX sur cette slice (émettre ici)', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
||||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'ACOM hors ligne', 'flxp.ampPick': 'Choisir quel amplificateur cette carte affiche', 'flxp.dspV4Hint': 'DSP SmartSDR v4 (séries 8000/Aurora)', 'flxp.daxHint': "DAX comme source audio d'émission (bouton DAX du bandeau transmit de SmartSDR) — pour WSJT-X & co", 'flxp.rnnHint': 'RNN — réduction de bruit par IA (on/off)', 'flxp.anftHint': 'ANFT — filtre notch automatique FFT (on/off)', 'flxp.dspNoise': 'Bruit', 'flxp.dspMore': 'Afficher/masquer le DSP avancé (WNB, NR/notch v4)',
|
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.voltage': 'TENSION', 'flxp.paTemp': 'TEMP PA', 'flxp.txFilter': 'Filtre TX', 'flxp.micProfile': 'Profil micro', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', 'flxp.outputPower': 'PUISSANCE DE SORTIE', 'flxp.speOffline': 'SPE hors ligne', 'flxp.acomOffline': 'ACOM hors ligne', 'flxp.ampPick': 'Choisir quel amplificateur cette carte affiche', 'flxp.dspV4Hint': 'DSP SmartSDR v4 (séries 8000/Aurora)', 'flxp.daxHint': "DAX comme source audio d'émission (bouton DAX du bandeau transmit de SmartSDR) — pour WSJT-X & co", 'flxp.rnnHint': 'RNN — réduction de bruit par IA (on/off)', 'flxp.anftHint': 'ANFT — filtre notch automatique FFT (on/off)', 'flxp.dspNoise': 'Bruit', 'flxp.dspMore': 'Afficher/masquer le DSP avancé (WNB, NR/notch v4)',
|
||||||
|
|||||||
@@ -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.12';
|
export const APP_VERSION = '0.21.1';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+19
@@ -14,6 +14,7 @@ import {extsvc} from '../models';
|
|||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
import {spe} from '../models';
|
import {spe} from '../models';
|
||||||
import {solar} from '../models';
|
import {solar} from '../models';
|
||||||
|
import {tunergenius} from '../models';
|
||||||
import {winkeyer} from '../models';
|
import {winkeyer} from '../models';
|
||||||
import {alerts} from '../models';
|
import {alerts} from '../models';
|
||||||
import {audio} from '../models';
|
import {audio} from '../models';
|
||||||
@@ -474,6 +475,10 @@ export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
|||||||
|
|
||||||
export function GetTelemetryEnabled():Promise<boolean>;
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
|
export function GetTunerGeniusSettings():Promise<main.TunerGeniusSettings>;
|
||||||
|
|
||||||
|
export function GetTunerGeniusStatus():Promise<tunergenius.Status>;
|
||||||
|
|
||||||
export function GetUIPref(arg1:string):Promise<string>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
|
|
||||||
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
export function GetUltrabeamSettings():Promise<main.UltrabeamSettings>;
|
||||||
@@ -746,6 +751,8 @@ export function RemovePassphrase(arg1:string):Promise<void>;
|
|||||||
|
|
||||||
export function RenameDatabase(arg1:string):Promise<void>;
|
export function RenameDatabase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function RenameLogbook(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>;
|
||||||
@@ -764,6 +771,8 @@ export function RestartQSORecorder():Promise<void>;
|
|||||||
|
|
||||||
export function RetryOfflineSync():Promise<number>;
|
export function RetryOfflineSync():Promise<number>;
|
||||||
|
|
||||||
|
export function RevealDataFolder():Promise<void>;
|
||||||
|
|
||||||
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
export function RotatorGoTo(arg1:number,arg2:number):Promise<void>;
|
||||||
|
|
||||||
export function RotatorPark():Promise<void>;
|
export function RotatorPark():Promise<void>;
|
||||||
@@ -838,6 +847,8 @@ export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>
|
|||||||
|
|
||||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveTunerGeniusSettings(arg1:main.TunerGeniusSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
|
|
||||||
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
export function SaveUltrabeamSettings(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
@@ -918,6 +929,14 @@ export function TestStationDevice(arg1:main.StationDevice):Promise<main.StationT
|
|||||||
|
|
||||||
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
export function TestUltrabeam(arg1:main.UltrabeamSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusActivate(arg1:number):Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusAutotune():Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusSetBypass(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function TunerGeniusSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function ULSStatus():Promise<main.ULSStatusResult>;
|
export function ULSStatus():Promise<main.ULSStatusResult>;
|
||||||
|
|
||||||
export function UltrabeamRetract():Promise<void>;
|
export function UltrabeamRetract():Promise<void>;
|
||||||
|
|||||||
@@ -902,6 +902,14 @@ export function GetTelemetryEnabled() {
|
|||||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetTunerGeniusSettings() {
|
||||||
|
return window['go']['main']['App']['GetTunerGeniusSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetTunerGeniusStatus() {
|
||||||
|
return window['go']['main']['App']['GetTunerGeniusStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetUIPref(arg1) {
|
export function GetUIPref(arg1) {
|
||||||
return window['go']['main']['App']['GetUIPref'](arg1);
|
return window['go']['main']['App']['GetUIPref'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1446,6 +1454,10 @@ export function RenameDatabase(arg1) {
|
|||||||
return window['go']['main']['App']['RenameDatabase'](arg1);
|
return window['go']['main']['App']['RenameDatabase'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RenameLogbook(arg1) {
|
||||||
|
return window['go']['main']['App']['RenameLogbook'](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);
|
||||||
}
|
}
|
||||||
@@ -1482,6 +1494,10 @@ export function RetryOfflineSync() {
|
|||||||
return window['go']['main']['App']['RetryOfflineSync']();
|
return window['go']['main']['App']['RetryOfflineSync']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function RevealDataFolder() {
|
||||||
|
return window['go']['main']['App']['RevealDataFolder']();
|
||||||
|
}
|
||||||
|
|
||||||
export function RotatorGoTo(arg1, arg2) {
|
export function RotatorGoTo(arg1, arg2) {
|
||||||
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
return window['go']['main']['App']['RotatorGoTo'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -1630,6 +1646,10 @@ export function SaveStationSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveTunerGeniusSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveTunerGeniusSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveUDPIntegration(arg1) {
|
export function SaveUDPIntegration(arg1) {
|
||||||
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
return window['go']['main']['App']['SaveUDPIntegration'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1790,6 +1810,22 @@ export function TestUltrabeam(arg1) {
|
|||||||
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
return window['go']['main']['App']['TestUltrabeam'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function TunerGeniusActivate(arg1) {
|
||||||
|
return window['go']['main']['App']['TunerGeniusActivate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunerGeniusAutotune() {
|
||||||
|
return window['go']['main']['App']['TunerGeniusAutotune']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunerGeniusSetBypass(arg1) {
|
||||||
|
return window['go']['main']['App']['TunerGeniusSetBypass'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TunerGeniusSetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['TunerGeniusSetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ULSStatus() {
|
export function ULSStatus() {
|
||||||
return window['go']['main']['App']['ULSStatus']();
|
return window['go']['main']['App']['ULSStatus']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2109,6 +2109,7 @@ export namespace main {
|
|||||||
path: string;
|
path: string;
|
||||||
default_path: string;
|
default_path: string;
|
||||||
is_custom: boolean;
|
is_custom: boolean;
|
||||||
|
logbook_default_path: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new DatabaseSettings(source);
|
return new DatabaseSettings(source);
|
||||||
@@ -2119,6 +2120,7 @@ export namespace main {
|
|||||||
this.path = source["path"];
|
this.path = source["path"];
|
||||||
this.default_path = source["default_path"];
|
this.default_path = source["default_path"];
|
||||||
this.is_custom = source["is_custom"];
|
this.is_custom = source["is_custom"];
|
||||||
|
this.logbook_default_path = source["logbook_default_path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class DuplicateGroup {
|
export class DuplicateGroup {
|
||||||
@@ -2315,6 +2317,7 @@ export namespace main {
|
|||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
database: string;
|
database: string;
|
||||||
|
sqlite_path?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new MySQLSettings(source);
|
return new MySQLSettings(source);
|
||||||
@@ -2328,6 +2331,7 @@ export namespace main {
|
|||||||
this.user = source["user"];
|
this.user = source["user"];
|
||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
this.database = source["database"];
|
this.database = source["database"];
|
||||||
|
this.sqlite_path = source["sqlite_path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class OfflineStatus {
|
export class OfflineStatus {
|
||||||
@@ -2935,6 +2939,22 @@ export namespace main {
|
|||||||
this.error = source["error"];
|
this.error = source["error"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class TunerGeniusSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
host: string;
|
||||||
|
password: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new TunerGeniusSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.password = source["password"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class ULSStatusResult {
|
export class ULSStatusResult {
|
||||||
count: number;
|
count: number;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -3065,6 +3085,7 @@ export namespace main {
|
|||||||
engine: string;
|
engine: string;
|
||||||
esc_clears_call: boolean;
|
esc_clears_call: boolean;
|
||||||
send_on_type: boolean;
|
send_on_type: boolean;
|
||||||
|
esm: boolean;
|
||||||
macros: WKMacro[];
|
macros: WKMacro[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -3094,6 +3115,7 @@ export namespace main {
|
|||||||
this.engine = source["engine"];
|
this.engine = source["engine"];
|
||||||
this.esc_clears_call = source["esc_clears_call"];
|
this.esc_clears_call = source["esc_clears_call"];
|
||||||
this.send_on_type = source["send_on_type"];
|
this.send_on_type = source["send_on_type"];
|
||||||
|
this.esm = source["esm"];
|
||||||
this.macros = this.convertValues(source["macros"], WKMacro);
|
this.macros = this.convertValues(source["macros"], WKMacro);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3365,6 +3387,7 @@ export namespace profile {
|
|||||||
user: string;
|
user: string;
|
||||||
password: string;
|
password: string;
|
||||||
database: string;
|
database: string;
|
||||||
|
path?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new ProfileDB(source);
|
return new ProfileDB(source);
|
||||||
@@ -3378,6 +3401,7 @@ export namespace profile {
|
|||||||
this.user = source["user"];
|
this.user = source["user"];
|
||||||
this.password = source["password"];
|
this.password = source["password"];
|
||||||
this.database = source["database"];
|
this.database = source["database"];
|
||||||
|
this.path = source["path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class Profile {
|
export class Profile {
|
||||||
@@ -4406,6 +4430,105 @@ export namespace spe {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace tunergenius {
|
||||||
|
|
||||||
|
export class Channel {
|
||||||
|
ptt: boolean;
|
||||||
|
band: number;
|
||||||
|
mode: number;
|
||||||
|
mode_str: string;
|
||||||
|
flex: string;
|
||||||
|
freq_mhz: number;
|
||||||
|
bypass: boolean;
|
||||||
|
antenna: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Channel(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.ptt = source["ptt"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.mode = source["mode"];
|
||||||
|
this.mode_str = source["mode_str"];
|
||||||
|
this.flex = source["flex"];
|
||||||
|
this.freq_mhz = source["freq_mhz"];
|
||||||
|
this.bypass = source["bypass"];
|
||||||
|
this.antenna = source["antenna"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
host?: string;
|
||||||
|
last_error?: string;
|
||||||
|
fwd_dbm: number;
|
||||||
|
fwd_w: number;
|
||||||
|
swr_db: number;
|
||||||
|
vswr: number;
|
||||||
|
operate: boolean;
|
||||||
|
bypass: boolean;
|
||||||
|
tuning: boolean;
|
||||||
|
active: number;
|
||||||
|
three_way: boolean;
|
||||||
|
a: Channel;
|
||||||
|
b: Channel;
|
||||||
|
relay_c1: number;
|
||||||
|
relay_l: number;
|
||||||
|
relay_c2: number;
|
||||||
|
freq_mhz: number;
|
||||||
|
antenna: number;
|
||||||
|
message?: 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.host = source["host"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.fwd_dbm = source["fwd_dbm"];
|
||||||
|
this.fwd_w = source["fwd_w"];
|
||||||
|
this.swr_db = source["swr_db"];
|
||||||
|
this.vswr = source["vswr"];
|
||||||
|
this.operate = source["operate"];
|
||||||
|
this.bypass = source["bypass"];
|
||||||
|
this.tuning = source["tuning"];
|
||||||
|
this.active = source["active"];
|
||||||
|
this.three_way = source["three_way"];
|
||||||
|
this.a = this.convertValues(source["a"], Channel);
|
||||||
|
this.b = this.convertValues(source["b"], Channel);
|
||||||
|
this.relay_c1 = source["relay_c1"];
|
||||||
|
this.relay_l = source["relay_l"];
|
||||||
|
this.relay_c2 = source["relay_c2"];
|
||||||
|
this.freq_mhz = source["freq_mhz"];
|
||||||
|
this.antenna = source["antenna"];
|
||||||
|
this.message = source["message"];
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace udp {
|
export namespace udp {
|
||||||
|
|
||||||
export class Config {
|
export class Config {
|
||||||
|
|||||||
@@ -45,7 +45,7 @@
|
|||||||
],
|
],
|
||||||
"total": 0,
|
"total": 0,
|
||||||
"builtin": true,
|
"builtin": true,
|
||||||
"version": 1
|
"version": 3
|
||||||
},
|
},
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
@@ -54,6 +54,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Aarau|Baden|Wettingen|Wohlen|Rheinfelden|Zofingen|Lenzburg|Brugg|Oftringen|Suhr|Spreitenbach)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -62,6 +63,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Appenzell|Oberegg)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -70,6 +72,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Herisau|Teufen|Speicher|Heiden|Gais|Urnäsch|Waldstatt)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -78,6 +81,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Bern|Berne|Thun|Biel|Bienne|Köniz|Burgdorf|Langenthal|Steffisburg|Münsingen|Spiez|Interlaken|Ostermundigen|Lyss|Zollikofen)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -86,6 +90,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Liestal|Allschwil|Reinach|Muttenz|Pratteln|Binningen|Münchenstein|Birsfelden|Aesch|Sissach|Oberwil)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -94,6 +99,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Basel|Bâle|Bale|Riehen|Bettingen)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,6 +108,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Fribourg|Freiburg|Bulle|Marly|Düdingen|Estavayer|Murten|Morat|Villars-sur-Glâne|Guin)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -110,6 +117,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Genève|Geneve|Sezenove|Chene-Bourg|Chene Bourg|Geneva|Genf|Carouge|Vernier|Lancy|Meyrin|Onex|Thônex|Thonex|Versoix|Bernex|Plan-les-Ouates)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -118,6 +126,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Glarus|Glaris|Näfels|Netstal|Ennenda|Mollis)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -126,6 +135,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Chur|Coire|Kueblis|Davos|Sankt Moritz|St\\.? Moritz|Landquart|Arosa|Klosters|Ilanz|Thusis|Poschiavo|Domat|Ems)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -134,6 +144,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Delémont|Delemont|Porrentruy|Bassecourt|Courroux|Saignelégier|Alle)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -142,6 +153,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Luzern|Lucerne|Emmen|Kriens|Horw|Ebikon|Sursee|Hochdorf|Willisau|Rothenburg)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -150,6 +162,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Neuchâtel|Neuchatel|Neuenburg|La Chaux-de-Fonds|Chaux-de-Fonds|Le Locle|Peseux|Boudry|Colombier|Marin)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -158,6 +171,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Stans|Hergiswil|Buochs|Stansstad|Beckenried|Ennetbürgen)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -166,6 +180,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Sarnen|Kerns|Alpnach|Engelberg|Sachseln|Giswil)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -174,6 +189,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Sankt Gallen|Saint-Gall|Rapperswil|Wil|Gossau|Rorschach|Uzwil|Flawil|Altstätten|Jona)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -182,6 +198,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Schaffhausen|Schaffhouse|Neuhausen|Stein am Rhein|Thayngen)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -190,6 +207,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Solothurn|Soleure|Olten|Grenchen|Zuchwil|Dornach|Oensingen|Balsthal|Biberist)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -198,6 +216,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Schwyz|Einsiedeln|Freienbach|Küssnacht|Arth|Lachen|Wollerau|Brunnen|Goldau)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -206,6 +225,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Frauenfeld|Kreuzlingen|Arbon|Amriswil|Weinfelden|Romanshorn|Sirnach|Aadorf|Münchwilen)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -214,6 +234,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Lugano|Bellinzona|Locarno|Mendrisio|Chiasso|Biasca|Ascona|Losone|Minusio|Giubiasco|Massagno)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -222,6 +243,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Altdorf|Schattdorf|Erstfeld|Andermatt|Flüelen|Bürglen)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -230,6 +252,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Lausanne|Chavornay|Blonay|Saint-Cergue|St-Cergue|St Cergue|Yverdon|Gingins|Montreux|Nyon|Vevey|Renens|Morges|Gland|Pully|Prilly|Ecublens|Aigle|Payerne|Rolle|Lutry|Bussigny)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -238,6 +261,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Sion|Sitten|Martigny|Monthey|Sierre|Brig|Brigue|Naters|Visp|Viège|Conthey|Fully|Verbier|Zermatt|Crans-Montana|Saas-Fee|Savièse|Bagnes)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -246,6 +270,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Zug|Zoug|Baar|Cham|Steinhausen|Risch|Rotkreuz|Hünenberg|Unterägeri|Oberägeri)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -254,6 +279,7 @@
|
|||||||
"dxcc": 287,
|
"dxcc": 287,
|
||||||
"group": "",
|
"group": "",
|
||||||
"subgrp": "",
|
"subgrp": "",
|
||||||
|
"pattern": "\\b(Zürich|Zurich|Winterthur|Uster|Dübendorf|Dietikon|Wetzikon|Kloten|Wädenswil|Horgen|Thalwil|Opfikon|Küsnacht|Meilen|Bülach|Regensdorf|Schlieren|Adliswil|Volketswil)\\b",
|
||||||
"valid": true
|
"valid": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -26,6 +26,13 @@ type ProfileDB struct {
|
|||||||
User string `json:"user"`
|
User string `json:"user"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Database string `json:"database"`
|
Database string `json:"database"`
|
||||||
|
// Path is a per-profile SQLite logbook FILE, distinct from the shared app
|
||||||
|
// database (opslog.db, which always holds settings + profiles). Empty =
|
||||||
|
// the shared database is used as the logbook (the historical default). Set =
|
||||||
|
// this profile's QSOs live in their own .db, so a visiting operator's contacts
|
||||||
|
// don't mix into yours and switching it never touches your config. Only used
|
||||||
|
// when Backend != "mysql".
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Profile is one operating configuration. A user typically keeps a few:
|
// Profile is one operating configuration. A user typically keeps a few:
|
||||||
|
|||||||
@@ -0,0 +1,496 @@
|
|||||||
|
// Package tunergenius drives a 4O3A Tuner Genius XL over its TCP text API
|
||||||
|
// (fixed port 9010 — the same port the device also uses for UDP discovery
|
||||||
|
// broadcasts). It's the same "Genius Series" line protocol as the PowerGenius
|
||||||
|
// XL / Antenna Genius: on connect the device sends a banner ("V1.1.8" or
|
||||||
|
// "V1.1.8 AUTH" when reached from outside the LAN); commands are
|
||||||
|
// "C<seq>|<command>\n" and replies are "R<seq>|<code>|<message>" (code 0 = OK).
|
||||||
|
// The status reply is pushed back as "S<seq>|status <k=v …>", and unsolicited
|
||||||
|
// "M|<message>" info/warning lines can arrive at any time.
|
||||||
|
//
|
||||||
|
// Protocol reference: 4O3A "TUNER GENIUS XL — PROTOCOL DESCRIPTION".
|
||||||
|
package tunergenius
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultPort is fixed on the device (both the TCP control channel and the
|
||||||
|
// UDP discovery broadcast use 9010).
|
||||||
|
DefaultPort = 9010
|
||||||
|
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
ioTimeout = 3 * time.Second
|
||||||
|
// Poll fast so the meters track TX like the amplifier does (the amp's numbers
|
||||||
|
// ride the real-time Flex UDP stream; the tuner is a synchronous TCP poll, so
|
||||||
|
// a slow interval made its SWR/power lag noticeably behind).
|
||||||
|
pollEvery = 400 * time.Millisecond
|
||||||
|
reconnectDelay = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Channel is the live state of one of the tuner's two RF channels (A / B). The
|
||||||
|
// Tuner Genius XL is a dual (SO2R) coupler, so each channel tracks its own
|
||||||
|
// source, band, frequency and antenna — mirroring the two rows the native 4O3A
|
||||||
|
// app shows.
|
||||||
|
type Channel struct {
|
||||||
|
PTT bool `json:"ptt"` // this channel is keyed
|
||||||
|
Band int `json:"band"` // band as reported by the device (0 = unknown)
|
||||||
|
Mode int `json:"mode"` // 0=RF Sense 1=FLEX 2=CAT 3=P2B 4=BCD
|
||||||
|
ModeStr string `json:"mode_str"` // human-readable mode/source
|
||||||
|
Flex string `json:"flex"` // bound Flex radio nickname (FLEX mode)
|
||||||
|
FreqMHz float64 `json:"freq_mhz"` // current frequency
|
||||||
|
Bypass bool `json:"bypass"` // this channel bypassed
|
||||||
|
Antenna int `json:"antenna"` // antenna in use (3WAY / SO2R-with-AG; 0 = n/a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is the snapshot the UI renders. Power/SWR come from the device's
|
||||||
|
// "status" reply; the booleans mirror the tuner's operating state.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Host string `json:"host,omitempty"`
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
|
||||||
|
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
|
||||||
|
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
|
||||||
|
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
|
||||||
|
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
|
||||||
|
|
||||||
|
Operate bool `json:"operate"` // state == OPERATE (1) vs STANDBY (0)
|
||||||
|
Bypass bool `json:"bypass"` // device global bypass engaged
|
||||||
|
Tuning bool `json:"tuning"` // autotune in progress
|
||||||
|
Active int `json:"active"` // active channel (1 = A, 2 = B)
|
||||||
|
ThreeWay bool `json:"three_way"` // 3-way (vs SO2R) hardware variant
|
||||||
|
|
||||||
|
A Channel `json:"a"` // channel A
|
||||||
|
B Channel `json:"b"` // channel B
|
||||||
|
|
||||||
|
RelayC1 int `json:"relay_c1"` // tuner network position (0–255)
|
||||||
|
RelayL int `json:"relay_l"`
|
||||||
|
RelayC2 int `json:"relay_c2"`
|
||||||
|
|
||||||
|
// FreqMHz / Antenna mirror the ACTIVE channel, kept for the compact docked
|
||||||
|
// widget that shows a single readout.
|
||||||
|
FreqMHz float64 `json:"freq_mhz"`
|
||||||
|
Antenna int `json:"antenna"`
|
||||||
|
|
||||||
|
Message string `json:"message,omitempty"` // last M| warning/info (empty = cleared)
|
||||||
|
}
|
||||||
|
|
||||||
|
// modeName maps the device's numeric mode/source to a label.
|
||||||
|
func modeName(m int) string {
|
||||||
|
switch m {
|
||||||
|
case 0:
|
||||||
|
return "RF Sense"
|
||||||
|
case 1:
|
||||||
|
return "Flex"
|
||||||
|
case 2:
|
||||||
|
return "CAT"
|
||||||
|
case 3:
|
||||||
|
return "P2B"
|
||||||
|
case 4:
|
||||||
|
return "BCD"
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
password string // remote-access code; sent as "auth <code>" when the banner announces AUTH
|
||||||
|
|
||||||
|
mu sync.Mutex // serialises command send/recv on the connection
|
||||||
|
conn net.Conn
|
||||||
|
reader *bufio.Reader
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
lastRaw string // last raw status payload — logged on change to map fields against real hardware
|
||||||
|
|
||||||
|
cmdID atomic.Int64
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int, password string) *Client {
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = DefaultPort
|
||||||
|
}
|
||||||
|
return &Client{
|
||||||
|
host: host,
|
||||||
|
port: port,
|
||||||
|
password: strings.TrimSpace(password),
|
||||||
|
stop: make(chan struct{}),
|
||||||
|
status: Status{Host: host},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stop)
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setStatus(fn func(*Status)) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
fn(&c.status)
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOperate puts the tuner in OPERATE (1) or STANDBY (0).
|
||||||
|
func (c *Client) SetOperate(on bool) error {
|
||||||
|
if _, err := c.command("operate set=" + boolNum(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.setStatus(func(s *Status) { s.Operate = on }) // optimistic; the poll confirms
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBypass engages (1) or clears (0) the device's global bypass (antenna
|
||||||
|
// connected straight through, tuner out of line).
|
||||||
|
func (c *Client) SetBypass(on bool) error {
|
||||||
|
if _, err := c.command("bypass set=" + boolNum(on)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.setStatus(func(s *Status) { s.Bypass = on })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Autotune starts an automatic tuning cycle on the active channel. The rig must
|
||||||
|
// be keyed into a carrier for the tuner to measure SWR — the device asserts its
|
||||||
|
// own PTT OUT if "tune PTT" is enabled in its setup.
|
||||||
|
func (c *Client) Autotune() error {
|
||||||
|
if _, err := c.command("autotune"); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.setStatus(func(s *Status) { s.Tuning = true }) // optimistic until the poll clears it
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate selects the active channel. On SO2R hardware ch is 1 (A) or 2 (B);
|
||||||
|
// on the 3-way variant it selects the antenna (1/2/3).
|
||||||
|
func (c *Client) Activate(ch int) error {
|
||||||
|
if ch < 1 {
|
||||||
|
return fmt.Errorf("tunergenius: invalid channel %d", ch)
|
||||||
|
}
|
||||||
|
key := "ch"
|
||||||
|
if c.GetStatus().ThreeWay {
|
||||||
|
key = "ant"
|
||||||
|
}
|
||||||
|
_, err := c.command(fmt.Sprintf("activate %s=%d", key, ch))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
t := time.NewTicker(pollEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
fresh := false
|
||||||
|
if c.needConnect() {
|
||||||
|
if err := c.ensureConnected(); err != nil {
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fresh = true
|
||||||
|
}
|
||||||
|
// One-shot on a fresh link: learn the hardware variant (3-way vs SO2R).
|
||||||
|
if fresh {
|
||||||
|
_, _ = c.command("info")
|
||||||
|
}
|
||||||
|
if _, err := c.command("status"); err != nil {
|
||||||
|
c.dropConn()
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = false; s.LastError = err.Error() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// needConnect reports whether the TCP link is currently down (so the poll loop
|
||||||
|
// knows a fresh connect + one-shot info query is needed).
|
||||||
|
func (c *Client) needConnect() bool {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
return c.conn == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConnected() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
c.reader = bufio.NewReader(conn)
|
||||||
|
// Banner: "V1.1.8" (LAN) or "V1.1.8 AUTH" (remote → authentication required).
|
||||||
|
_ = conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
banner, _ := c.reader.ReadString('\n')
|
||||||
|
banner = strings.TrimSpace(banner)
|
||||||
|
applog.Printf("tunergenius: connected %s → %s, banner=%q", conn.LocalAddr(), conn.RemoteAddr(), banner)
|
||||||
|
if strings.Contains(banner, "AUTH") {
|
||||||
|
if c.password == "" {
|
||||||
|
applog.Printf("tunergenius: device requires AUTH but no remote code set (Settings → Tuner Genius)")
|
||||||
|
} else if err := c.authLocked(); err != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn, c.reader = nil, nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host })
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// authLocked sends "auth <code>" and checks the reply. Must be called with c.mu
|
||||||
|
// held (during ensureConnected). Note the device replies R<seq>|0|... for BOTH
|
||||||
|
// success ("auth OK") and failure ("Unauthorized"), so the message text — not
|
||||||
|
// the response code — decides.
|
||||||
|
func (c *Client) authLocked() error {
|
||||||
|
id := c.cmdID.Add(1)
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
if _, err := fmt.Fprintf(c.conn, "C%d|auth %s\n", id, c.password); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.Contains(strings.ToLower(line), "unauthorized") {
|
||||||
|
return fmt.Errorf("tunergenius: authentication failed — check the remote code")
|
||||||
|
}
|
||||||
|
applog.Printf("tunergenius: authenticated")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// command sends "C<id>|<cmd>\n" and returns the matching reply line, updating
|
||||||
|
// the status snapshot from whatever status/message lines arrive. Unsolicited
|
||||||
|
// "M|" info lines that precede the reply are consumed (they update Message).
|
||||||
|
func (c *Client) command(cmd string) (string, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil || c.reader == nil {
|
||||||
|
return "", fmt.Errorf("tunergenius: not connected")
|
||||||
|
}
|
||||||
|
id := c.cmdID.Add(1)
|
||||||
|
_ = c.conn.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
if _, err := fmt.Fprintf(c.conn, "C%d|%s\n", id, cmd); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// Read until the command's reply (R…/S…); consume async M| lines along the way.
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
_ = c.conn.SetReadDeadline(time.Now().Add(ioTimeout))
|
||||||
|
line, err := c.reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.parse(line)
|
||||||
|
if line[0] == 'R' || line[0] == 'S' {
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("tunergenius: no reply to %q", cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dropConn() {
|
||||||
|
c.mu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
c.reader = nil
|
||||||
|
}
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// parse handles the three line shapes: "R<id>|<code>|<msg>", "S<id>|status …"
|
||||||
|
// and "M|<message>".
|
||||||
|
func (c *Client) parse(resp string) {
|
||||||
|
// Async info/warning: "M|<message>" (empty message = cleared).
|
||||||
|
if strings.HasPrefix(resp, "M|") {
|
||||||
|
msg := strings.TrimSpace(strings.TrimPrefix(resp, "M|"))
|
||||||
|
c.setStatus(func(s *Status) { s.Message = msg })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var data string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(resp, "R"):
|
||||||
|
p := strings.SplitN(resp, "|", 3)
|
||||||
|
if len(p) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = p[2]
|
||||||
|
case strings.HasPrefix(resp, "S"):
|
||||||
|
p := strings.SplitN(resp, "|", 2)
|
||||||
|
if len(p) < 2 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data = p[1]
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// "info …" carries the hardware variant (3way=1 on the 3-way model, absent on
|
||||||
|
// SO2R) — parsed once so the UI knows whether channels are A/B or antennas.
|
||||||
|
if strings.HasPrefix(data, "info") {
|
||||||
|
tw := strings.Contains(data, "3way=1")
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.ThreeWay = tw
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Only the "status …" payload carries the live state we render.
|
||||||
|
if !strings.HasPrefix(data, "status") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data != c.lastRaw {
|
||||||
|
c.lastRaw = data
|
||||||
|
applog.Printf("tunergenius: status raw=%q", data)
|
||||||
|
}
|
||||||
|
c.applyStatus(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyStatus maps the "status k=v …" fields onto the snapshot, filling both
|
||||||
|
// channels (A/B) plus the global power/SWR and operating state.
|
||||||
|
func (c *Client) applyStatus(data string) {
|
||||||
|
kv := map[string]string{}
|
||||||
|
for _, tok := range strings.Fields(data) {
|
||||||
|
if p := strings.SplitN(tok, "=", 2); len(p) == 2 {
|
||||||
|
kv[p[0]] = p[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
active := atoiDefault(kv["active"], 1)
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
c.status.Connected = true
|
||||||
|
c.status.LastError = ""
|
||||||
|
c.status.Active = active
|
||||||
|
c.status.Operate = kv["state"] == "1"
|
||||||
|
c.status.Bypass = kv["bypass"] == "1"
|
||||||
|
c.status.Tuning = kv["tuning"] == "1"
|
||||||
|
c.status.RelayC1 = atoiDefault(kv["relayC1"], 0)
|
||||||
|
c.status.RelayL = atoiDefault(kv["relayL"], 0)
|
||||||
|
c.status.RelayC2 = atoiDefault(kv["relayC2"], 0)
|
||||||
|
|
||||||
|
if v, ok := parseFloat(kv["fwd"]); ok {
|
||||||
|
c.status.FwdDbm = v
|
||||||
|
c.status.FwdW = dbmToWatts(v)
|
||||||
|
}
|
||||||
|
if v, ok := parseFloat(kv["swr"]); ok {
|
||||||
|
c.status.SwrDb = v
|
||||||
|
c.status.Vswr = returnLossToVswr(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.status.A = channelFrom(kv, "A")
|
||||||
|
c.status.B = channelFrom(kv, "B")
|
||||||
|
|
||||||
|
// Mirror the active channel into the flat fields the compact widget uses.
|
||||||
|
act := c.status.A
|
||||||
|
if active == 2 {
|
||||||
|
act = c.status.B
|
||||||
|
}
|
||||||
|
c.status.FreqMHz = act.FreqMHz
|
||||||
|
c.status.Antenna = act.Antenna
|
||||||
|
}
|
||||||
|
|
||||||
|
// channelFrom extracts one channel's fields (suffix "A" or "B") from the parsed
|
||||||
|
// status map.
|
||||||
|
func channelFrom(kv map[string]string, suffix string) Channel {
|
||||||
|
mode := atoiDefault(kv["mode"+suffix], 0)
|
||||||
|
freq, _ := parseFloat(kv["freq"+suffix])
|
||||||
|
return Channel{
|
||||||
|
PTT: kv["ptt"+suffix] == "1",
|
||||||
|
Band: atoiDefault(kv["band"+suffix], 0),
|
||||||
|
Mode: mode,
|
||||||
|
ModeStr: modeName(mode),
|
||||||
|
Flex: strings.TrimSpace(kv["flex"+suffix]),
|
||||||
|
FreqMHz: freq,
|
||||||
|
Bypass: kv["bypass"+suffix] == "1",
|
||||||
|
Antenna: atoiDefault(kv["ant"+suffix], 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolNum(on bool) string {
|
||||||
|
if on {
|
||||||
|
return "1"
|
||||||
|
}
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
|
||||||
|
func atoiDefault(s string, def int) int {
|
||||||
|
if n, err := strconv.Atoi(strings.TrimSpace(s)); err == nil {
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseFloat(s string) (float64, bool) {
|
||||||
|
if s == "" {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
|
||||||
|
return v, err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW).
|
||||||
|
func dbmToWatts(dbm float64) float64 {
|
||||||
|
return math.Pow(10, (dbm-30)/10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// returnLossToVswr converts the device's "swr" field — a return loss in dB,
|
||||||
|
// reported as a negative number (e.g. -60 = an excellent 60 dB match) — into a
|
||||||
|
// conventional VSWR ratio. A near-zero return loss (bad match) yields a large
|
||||||
|
// VSWR; a large negative one yields ~1.0.
|
||||||
|
func returnLossToVswr(swrDb float64) float64 {
|
||||||
|
rl := math.Abs(swrDb)
|
||||||
|
rho := math.Pow(10, -rl/20) // reflection coefficient magnitude
|
||||||
|
if rho >= 1 {
|
||||||
|
return 99.9
|
||||||
|
}
|
||||||
|
vswr := (1 + rho) / (1 - rho)
|
||||||
|
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
|
||||||
|
return 99.9
|
||||||
|
}
|
||||||
|
return vswr
|
||||||
|
}
|
||||||
+14
-2
@@ -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.12"
|
appVersion = "0.21.1"
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -85,14 +85,26 @@ func (a *App) sendTelemetryHeartbeat() {
|
|||||||
return // already counted today
|
return // already counted today
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// distinct_id identifies the USER. Prefer the station callsign — it's stable
|
||||||
|
// across every machine/reinstall the same operator runs, so one op counts as
|
||||||
|
// one user (a callsign is public in amateur radio). The random per-install ID
|
||||||
|
// is only a fallback until a callsign is configured; without it the same op on
|
||||||
|
// a laptop + desktop + Maestro showed up as three "users". install_id rides
|
||||||
|
// along as a property so multiple machines under one callsign stay visible.
|
||||||
|
installID := a.telemetryInstallID()
|
||||||
|
distinctID := installID
|
||||||
|
if call := a.activeCallsign(); call != "" {
|
||||||
|
distinctID = call
|
||||||
|
}
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"api_key": posthogAPIKey,
|
"api_key": posthogAPIKey,
|
||||||
"event": "app_opened",
|
"event": "app_opened",
|
||||||
"distinct_id": a.telemetryInstallID(),
|
"distinct_id": distinctID,
|
||||||
"properties": map[string]any{
|
"properties": map[string]any{
|
||||||
"version": appVersion,
|
"version": appVersion,
|
||||||
"os": runtime.GOOS,
|
"os": runtime.GOOS,
|
||||||
"arch": runtime.GOARCH,
|
"arch": runtime.GOARCH,
|
||||||
|
"install_id": installID,
|
||||||
"$lib": "opslog",
|
"$lib": "opslog",
|
||||||
},
|
},
|
||||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
|||||||
+17
-3
@@ -4,9 +4,23 @@ This walks you from a fresh install to your first logged QSO.
|
|||||||
|
|
||||||
## 1. Set your station
|
## 1. Set your station
|
||||||
|
|
||||||
**Settings → Station**: enter your **callsign**, **grid locator** and **name**.
|
**Settings → Station Information**: enter your **callsign**, **grid locator** and
|
||||||
These feed callsign lookups, the map, awards, the QSL card and the "my station"
|
**name**. These feed callsign lookups, the map, awards, the QSL card and the
|
||||||
ADIF fields on every QSO.
|
"my station" ADIF fields on every QSO.
|
||||||
|
|
||||||
|
For a **complete ADIF**, also fill your **address / city / state / county** here,
|
||||||
|
and set your **per-band rig and antenna** in **Settings → Operating conditions**
|
||||||
|
(tick the default antenna for each band). These populate the `MY_*` fields
|
||||||
|
(`MY_RIG`, `MY_ANTENNA`, `MY_CITY`, `MY_STREET`, `MY_STATE`…) on your QSOs.
|
||||||
|
|
||||||
|
> **Importing an existing log?** Fill the station info **and** your operating
|
||||||
|
> conditions **before** you import. In the ADIF import dialog, tick **"Fill my
|
||||||
|
> station fields from my profile"** — OpsLog then backfills the *empty* `MY_*`
|
||||||
|
> fields (grid, rig, antenna, address, city, state, county, SOTA/POTA…) plus
|
||||||
|
> **Operator** and **Owner callsign** from your active profile, so even a bare
|
||||||
|
> ADIF comes in fully described. Existing values are kept; only
|
||||||
|
> `STATION_CALLSIGN` is left untouched (so a mixed-call log isn't re-routed).
|
||||||
|
> See [[Import and Export ADIF]].
|
||||||
|
|
||||||
## 2. (Optional) Connect a radio
|
## 2. (Optional) Connect a radio
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user