Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c91c8c3b47 | ||
|
|
968da5488c | ||
|
|
1b5b0c2e90 | ||
|
|
0385aed760 | ||
|
|
24a5a0480d | ||
|
|
828f99b8ac | ||
|
|
9e5868b839 | ||
|
|
40d0ca57c3 | ||
|
|
aa537f9eea | ||
|
|
ea1bd2a66d | ||
|
|
1f54656256 | ||
|
|
54dd109288 | ||
|
|
2c1d7235f0 | ||
|
|
adf9844d1b |
@@ -46,3 +46,4 @@ cat.log
|
|||||||
.env.local
|
.env.local
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
*.syso
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
|
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Discord-Rejoindre%20le%20serveur-5865F2?logo=discord&logoColor=white" alt="Rejoindre notre Discord" />
|
||||||
|
</a>
|
||||||
|
|
||||||
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
|
Un logiciel de log radioamateur moderne et rapide pour Windows — saisie façon
|
||||||
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
Log4OM, CAT en temps réel pour **OmniRig**, **FlexRadio/SmartSDR** natif,
|
||||||
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
**Icom CI-V** natif (USB **et** à distance par internet, en remplacement de
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# OpsLog
|
# OpsLog
|
||||||
|
|
||||||
|
<a href="https://discord.gg/FYM8yw5pT" target="_blank">
|
||||||
|
<img src="https://img.shields.io/badge/Discord-Join%20the%20server-5865F2?logo=discord&logoColor=white" alt="Join our Discord" />
|
||||||
|
</a>
|
||||||
|
|
||||||
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
A modern, fast ham-radio logger for Windows — Log4OM-style entry, real-time CAT
|
||||||
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB **and**
|
||||||
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert Electronics),
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"hamlog/internal/acom"
|
||||||
"hamlog/internal/adif"
|
"hamlog/internal/adif"
|
||||||
"hamlog/internal/alerts"
|
"hamlog/internal/alerts"
|
||||||
"hamlog/internal/antgenius"
|
"hamlog/internal/antgenius"
|
||||||
@@ -44,15 +45,16 @@ import (
|
|||||||
"hamlog/internal/operating"
|
"hamlog/internal/operating"
|
||||||
"hamlog/internal/pota"
|
"hamlog/internal/pota"
|
||||||
"hamlog/internal/powergenius"
|
"hamlog/internal/powergenius"
|
||||||
"hamlog/internal/spe"
|
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/rotator/pst"
|
|
||||||
"hamlog/internal/relaydev"
|
"hamlog/internal/relaydev"
|
||||||
|
"hamlog/internal/rotator/gs232"
|
||||||
|
"hamlog/internal/rotator/pst"
|
||||||
"hamlog/internal/rotgenius"
|
"hamlog/internal/rotgenius"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
"hamlog/internal/solar"
|
"hamlog/internal/solar"
|
||||||
|
"hamlog/internal/spe"
|
||||||
"hamlog/internal/steppir"
|
"hamlog/internal/steppir"
|
||||||
"hamlog/internal/uls"
|
"hamlog/internal/uls"
|
||||||
"hamlog/internal/ultrabeam"
|
"hamlog/internal/ultrabeam"
|
||||||
@@ -157,8 +159,10 @@ const (
|
|||||||
keyRotatorHost = "rotator.host"
|
keyRotatorHost = "rotator.host"
|
||||||
keyRotatorPort = "rotator.port"
|
keyRotatorPort = "rotator.port"
|
||||||
keyRotatorHasElevation = "rotator.has_elevation"
|
keyRotatorHasElevation = "rotator.has_elevation"
|
||||||
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
|
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, GS-232A)
|
||||||
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
|
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
|
||||||
|
keyRotatorTransport = "rotator.transport" // arco: "tcp" (LAN) | "serial" (USB virtual COM)
|
||||||
|
keyRotatorComPort = "rotator.com_port" // arco serial transport
|
||||||
|
|
||||||
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
||||||
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
||||||
@@ -446,8 +450,8 @@ type App struct {
|
|||||||
// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
|
// wcbm is an in-memory "CALL|BAND|MODE" worked-index so the alert engine never
|
||||||
// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
|
// queries the DB per cluster spot (an FT8 firehose would swamp a remote MySQL).
|
||||||
// Loaded once, appended to on each log, rebuilt after bulk changes.
|
// Loaded once, appended to on each log, rebuilt after bulk changes.
|
||||||
wcbm map[string]struct{}
|
wcbm map[string]struct{}
|
||||||
wcbmMu sync.RWMutex
|
wcbmMu sync.RWMutex
|
||||||
pota *pota.Cache
|
pota *pota.Cache
|
||||||
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
|
uls *uls.Store // US callsign→county/grid (offline FCC ULS), lazily opened
|
||||||
awardRefs *awardref.Repo
|
awardRefs *awardref.Repo
|
||||||
@@ -466,6 +470,7 @@ type App struct {
|
|||||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||||
spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE
|
spe *spe.Client // SPE Expert amplifier (serial/TCP); nil when disabled or not SPE
|
||||||
|
acom *acom.Client // ACOM 500S/600S/700S/1200S/2020S amplifier (serial/TCP); nil when disabled or not ACOM
|
||||||
audioMgr *audio.Manager
|
audioMgr *audio.Manager
|
||||||
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll)
|
||||||
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
solar *solar.Manager // live space-weather (SFI/SSN/A/K) for the header + QSO stamping
|
||||||
@@ -481,27 +486,27 @@ type App struct {
|
|||||||
|
|
||||||
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
|
alertStore *alerts.Store // DX-cluster spot alert rules (global JSON)
|
||||||
|
|
||||||
startupProfile string // --profile <name> from the command line (activate at startup)
|
startupProfile string // --profile <name> from the command line (activate at startup)
|
||||||
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
dvkRecSlot int // slot currently being recorded (DVKStartRecord → DVKStopRecord)
|
||||||
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
dvkPttKeyed bool // we keyed PTT for a voice message; unkey when it ends
|
||||||
pttMu sync.Mutex
|
pttMu sync.Mutex
|
||||||
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
udpLogMu sync.Mutex // serialises UDP auto-log so concurrent packets can't both pass the dedup check
|
||||||
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
adifMonMu sync.Mutex // guards the ADIF-monitor config (file list + per-file read offsets)
|
||||||
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
relayAutoMu sync.Mutex // serialises relay auto-control evaluation
|
||||||
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
|
relayAutoLast map[string]bool // deviceID|relay → last applied on/off, so we only switch on a real change
|
||||||
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
|
relayAutoOn atomic.Bool // cached "auto-control enabled" so the CAT hot path skips work when off
|
||||||
relayDrvMu sync.Mutex // guards the cached relay drivers below
|
relayDrvMu sync.Mutex // guards the cached relay drivers below
|
||||||
relayDrv map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call)
|
relayDrv map[string]cachedRelay // deviceID → live driver (reused across polls; stateful boards can't be reopened per call)
|
||||||
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
pttPort serial.Port // open serial port while PTT (RTS/DTR) is asserted
|
||||||
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)
|
dbPath string // active database file (may be a user-chosen location)
|
||||||
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
logDb *sql.DB // QSO logbook connection — MySQL when the shared backend is enabled, else == db (local SQLite)
|
||||||
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
|
||||||
offlineMode bool // last write failed because the DB was unreachable
|
offlineMode bool // last write failed because the DB was unreachable
|
||||||
|
|
||||||
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
catFlexSpots bool // push cluster spots to the FlexRadio panadapter
|
||||||
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
catFlexDecodeSpots bool // push WSJT-X decodes (heard stations) to the panadapter
|
||||||
@@ -511,11 +516,13 @@ type App struct {
|
|||||||
liveBand string
|
liveBand string
|
||||||
liveMode string
|
liveMode string
|
||||||
livePublishTimer *time.Timer // debounced live-status publish on activity change
|
livePublishTimer *time.Timer // debounced live-status publish on activity change
|
||||||
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
|
liveLastQSOAt time.Time // when this operator last logged a NEW contact — drives online/offline
|
||||||
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
liveTableMu sync.Mutex // guards liveTableFor
|
||||||
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
liveTableFor *sql.DB // logbook whose live_status DDL has been ensured (once per connection, not per call)
|
||||||
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
awardSnapMu sync.Mutex // guards the award QSO snapshot
|
||||||
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
|
awardSnap []qso.QSO // light-scanned + enriched logbook snapshot reused across award computations
|
||||||
|
awardSnapRev string // logbook revision the snapshot was built at ("" = none)
|
||||||
|
dataDir string // <exeDir>/data — holds config.json, logs, cty.dat
|
||||||
|
|
||||||
// shuttingDown gates beforeClose re-entry: the first user attempt to
|
// shuttingDown gates beforeClose re-entry: the first user attempt to
|
||||||
// close fires shutdown tasks (backup, future LoTW upload, ...) while
|
// close fires shutdown tasks (backup, future LoTW upload, ...) while
|
||||||
@@ -859,9 +866,9 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
applog.Printf("startup: logbook backend = %s", backend)
|
applog.Printf("startup: logbook backend = %s", backend)
|
||||||
a.logDb = logbookConn
|
a.logDb = logbookConn
|
||||||
a.qso = qso.NewRepo(logbookConn)
|
a.qso = qso.NewRepo(logbookConn)
|
||||||
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
|
a.backfillAwardRefsOnce() // one-time: materialise award_refs for pre-existing QSOs
|
||||||
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
go a.rebuildWorkedIndex() // in-memory worked-index for per-spot alert checks
|
||||||
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
go a.adifMonitorLoop() // watch external ADIF files (fldigi, N1MM…) for new QSOs
|
||||||
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
|
a.relayAutoOn.Store(a.GetRelayAuto().Enabled) // prime the relay auto-control hot-path flag
|
||||||
|
|
||||||
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
// cty.dat for offline DXCC / country resolution. Cached on disk; first
|
||||||
@@ -1934,7 +1941,13 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
// was redundant with the entry lookup and only slowed logging (a call not yet in
|
// was redundant with the entry lookup and only slowed logging (a call not yet in
|
||||||
// the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished
|
// the cache made AddQSO wait on QRZ/HamQTH). If the entry lookup hadn't finished
|
||||||
// when you logged (fast CW: type → Enter), the e-mail is simply blank — fine.
|
// when you logged (fast CW: type → Enter), the e-mail is simply blank — fine.
|
||||||
|
insT0 := time.Now()
|
||||||
id, err = a.qso.Add(a.ctx, q)
|
id, err = a.qso.Add(a.ctx, q)
|
||||||
|
if d := time.Since(insT0); d > 2*time.Second {
|
||||||
|
// Surface a genuinely slow INSERT (DB lock / overloaded server) — this is the
|
||||||
|
// value we need when someone reports "logging took N seconds".
|
||||||
|
applog.Printf("log: SLOW qso insert took %s (call=%s band=%s mode=%s)", d.Round(time.Millisecond), q.Callsign, q.Band, q.Mode)
|
||||||
|
}
|
||||||
if err != nil && db.IsConnLost(err) {
|
if err != nil && db.IsConnLost(err) {
|
||||||
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
// The database is UNREACHABLE (not a data error) — park the QSO in the
|
||||||
// offline outbox rather than lose it. Returns id = -1 so the UI can say
|
// offline outbox rather than lose it. Returns id = -1 so the UI can say
|
||||||
@@ -1948,25 +1961,36 @@ func (a *App) AddQSO(q qso.QSO) (id int64, err error) {
|
|||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
q.ID = id
|
q.ID = id
|
||||||
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh
|
a.noteWorked(q.Callsign, q.Band, q.Mode) // keep the alert worked-index fresh (in-memory)
|
||||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
|
||||||
a.materializeAwardRefs(q) // stamp award_refs so the grid columns show at once
|
// Announce the log RIGHT AWAY so the grid/UI refresh at once and the entry
|
||||||
// Announce the log so UI widgets can react (e.g. the Flex panel zeroing RIT).
|
// form clears immediately — the operator is not made to wait on the DB.
|
||||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||||
a.saveQSORecording(&q)
|
// Everything below writes to (or reads from) the shared DB. Run it OFF the
|
||||||
if a.extsvc != nil {
|
// critical path: on a busy multi-op MySQL the award_refs UPDATE alone could
|
||||||
a.extsvc.OnQSOLogged(id)
|
// block ~40s (and hit "Too many connections"), which used to freeze the log
|
||||||
}
|
// (form stuck, "…" on the button) until it finished. The QSO row is already
|
||||||
a.maybeAutoSendEQSL(q)
|
// safely inserted; these just enrich it, so they can lag a beat.
|
||||||
// Forward the ADIF of this QSO to any outbound "ADIF message" UDP rows.
|
qc := q
|
||||||
if a.udp != nil {
|
go func() {
|
||||||
go a.udp.EmitLoggedADIF(adif.SingleRecordADIF(q))
|
a.materializeAwardRefs(qc) // fills the award_refs column
|
||||||
}
|
a.saveQSORecording(&qc)
|
||||||
// A successful write means the link is healthy again — if QSOs are still
|
if a.extsvc != nil {
|
||||||
// parked, get them in now rather than waiting for the next tick.
|
a.extsvc.OnQSOLogged(id)
|
||||||
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
|
}
|
||||||
go func() { _, _ = a.replayOfflineQueue() }()
|
a.maybeAutoSendEQSL(qc)
|
||||||
}
|
if a.udp != nil {
|
||||||
|
a.udp.EmitLoggedADIF(adif.SingleRecordADIF(qc))
|
||||||
|
}
|
||||||
|
// Nudge the grid again so the award_refs columns appear once materialised.
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||||
|
}
|
||||||
|
// A successful write means the link is healthy — flush any parked QSOs.
|
||||||
|
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
|
||||||
|
_, _ = a.replayOfflineQueue()
|
||||||
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
return id, err
|
return id, err
|
||||||
}
|
}
|
||||||
@@ -4317,8 +4341,8 @@ func (a *App) ExportAwardForCatalog(code string, version int) (string, error) {
|
|||||||
return "", fmt.Errorf("load references: %w", err)
|
return "", fmt.Errorf("load references: %w", err)
|
||||||
}
|
}
|
||||||
entry := struct {
|
entry := struct {
|
||||||
Def award.Def `json:"def"`
|
Def award.Def `json:"def"`
|
||||||
References []awardref.Ref `json:"references,omitempty"`
|
References []awardref.Ref `json:"references,omitempty"`
|
||||||
}{Def: def, References: refs}
|
}{Def: def, References: refs}
|
||||||
b, err := json.MarshalIndent(entry, "", " ")
|
b, err := json.MarshalIndent(entry, "", " ")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -6292,7 +6316,7 @@ type clusterQueue struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cond *sync.Cond
|
cond *sync.Cond
|
||||||
buf []clusterEvent
|
buf []clusterEvent
|
||||||
head int // index of the next event to pop (waste reclaimed by compaction)
|
head int // index of the next event to pop (waste reclaimed by compaction)
|
||||||
closed bool
|
closed bool
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -9634,24 +9658,28 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
|
|||||||
return 0, fmt.Errorf("insert qso: %w", err)
|
return 0, fmt.Errorf("insert qso: %w", err)
|
||||||
}
|
}
|
||||||
q.ID = id
|
q.ID = id
|
||||||
a.noteLiveQSO() // multi-op: flip this operator back "online"
|
a.noteLiveQSO() // multi-op: flip this operator back "online" (publishes async)
|
||||||
a.materializeAwardRefs(q)
|
// Announce the log AT ONCE so the grid / ON-AIR badge / stations-on-air widget
|
||||||
// Announce the log so UI widgets refresh AT ONCE (the Recent-QSOs grid, the
|
// refresh immediately, then run the DB-heavy enrichment off the critical path
|
||||||
// ON-AIR badge and the "stations on air" widget). The manual log path always
|
// (same reasoning as the manual path — award_refs on a busy shared MySQL blocked).
|
||||||
// did this; the UDP/FT8 path did not, so a station running MSHV saw the top
|
|
||||||
// "on air" list lag ~15-45s behind the instant bottom badge.
|
|
||||||
if a.ctx != nil {
|
if a.ctx != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
wruntime.EventsEmit(a.ctx, "qso:logged", id)
|
||||||
}
|
}
|
||||||
a.saveQSORecording(&q)
|
qc := q
|
||||||
if a.extsvc != nil {
|
go func() {
|
||||||
a.extsvc.OnQSOLogged(id)
|
a.materializeAwardRefs(qc)
|
||||||
}
|
a.saveQSORecording(&qc)
|
||||||
a.maybeAutoSendEQSL(q)
|
if a.extsvc != nil {
|
||||||
// A successful write means the link is healthy again — flush anything parked.
|
a.extsvc.OnQSOLogged(id)
|
||||||
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
|
}
|
||||||
go func() { _, _ = a.replayOfflineQueue() }()
|
a.maybeAutoSendEQSL(qc)
|
||||||
}
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "qso:logged", id) // refresh again so award_refs show
|
||||||
|
}
|
||||||
|
if a.offlineMode && a.offlineQ != nil && a.offlineQ.Count() > 0 {
|
||||||
|
_, _ = a.replayOfflineQueue()
|
||||||
|
}
|
||||||
|
}()
|
||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11189,7 +11217,14 @@ func (a *App) ActivateProfile(id int64) error {
|
|||||||
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
|
// target (local SQLite or its own MySQL) so QSOs go to the right logbook.
|
||||||
if p, err := a.profiles.Get(a.ctx, id); err == nil {
|
if p, err := a.profiles.Get(a.ctx, id); err == nil {
|
||||||
if err := a.switchLogbook(p); err != nil {
|
if err := a.switchLogbook(p); err != nil {
|
||||||
|
// The switch failed (typically: this profile's MySQL is unreachable right
|
||||||
|
// now) and the PREVIOUS logbook stays live. Silently staying on the old
|
||||||
|
// database is how QSOs end up in the wrong log — tell the operator.
|
||||||
applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
|
applog.Printf("activate profile %d: logbook switch failed: %v", id, err)
|
||||||
|
if a.ctx != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "toast", fmt.Sprintf(
|
||||||
|
"Profil %q : connexion à sa base impossible — l'ancienne base reste active ! (%v)", p.Name, err))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Re-apply every settings-dependent subsystem for the new profile.
|
// Re-apply every settings-dependent subsystem for the new profile.
|
||||||
@@ -11251,11 +11286,13 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
|
|||||||
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
||||||
type RotatorSettings struct {
|
type RotatorSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
|
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, Yaesu GS-232A over TCP)
|
||||||
Host string `json:"host"` // default 127.0.0.1
|
Host string `json:"host"` // default 127.0.0.1
|
||||||
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius)
|
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco — must match the port set in ARCO's LAN CONTROL PROTOCOL)
|
||||||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
||||||
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
|
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
|
||||||
|
Transport string `json:"transport"` // arco: "tcp" (LAN) | "serial" (USB virtual COM)
|
||||||
|
ComPort string `json:"com_port"` // arco serial transport
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRotatorSettings returns the persisted rotator config with defaults.
|
// GetRotatorSettings returns the persisted rotator config with defaults.
|
||||||
@@ -11265,12 +11302,13 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
|||||||
return out, fmt.Errorf("db not initialized")
|
return out, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx,
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum)
|
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum,
|
||||||
|
keyRotatorTransport, keyRotatorComPort)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.Enabled = m[keyRotatorEnabled] == "1"
|
out.Enabled = m[keyRotatorEnabled] == "1"
|
||||||
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" {
|
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" || t == "arco" {
|
||||||
out.Type = t
|
out.Type = t
|
||||||
}
|
}
|
||||||
if h := m[keyRotatorHost]; h != "" {
|
if h := m[keyRotatorHost]; h != "" {
|
||||||
@@ -11280,11 +11318,18 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
|||||||
out.Port = p
|
out.Port = p
|
||||||
} else if out.Type == "rotgenius" {
|
} else if out.Type == "rotgenius" {
|
||||||
out.Port = 9006 // native default when no port stored yet
|
out.Port = 9006 // native default when no port stored yet
|
||||||
|
} else if out.Type == "arco" {
|
||||||
|
out.Port = 4001 // placeholder — the real number is set in ARCO's LAN menu
|
||||||
}
|
}
|
||||||
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
||||||
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
|
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
|
||||||
out.RotatorNum = 2
|
out.RotatorNum = 2
|
||||||
}
|
}
|
||||||
|
out.Transport = "tcp"
|
||||||
|
if m[keyRotatorTransport] == "serial" {
|
||||||
|
out.Transport = "serial"
|
||||||
|
}
|
||||||
|
out.ComPort = m[keyRotatorComPort]
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11294,18 +11339,21 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
if s.Type != "rotgenius" {
|
if s.Type != "rotgenius" && s.Type != "arco" {
|
||||||
s.Type = "pst"
|
s.Type = "pst"
|
||||||
}
|
}
|
||||||
if s.Host == "" {
|
if s.Host == "" {
|
||||||
s.Host = "127.0.0.1"
|
s.Host = "127.0.0.1"
|
||||||
}
|
}
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000}[s.Type]
|
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000, "arco": 4001}[s.Type]
|
||||||
}
|
}
|
||||||
if s.RotatorNum != 2 {
|
if s.RotatorNum != 2 {
|
||||||
s.RotatorNum = 1
|
s.RotatorNum = 1
|
||||||
}
|
}
|
||||||
|
if s.Transport != "serial" {
|
||||||
|
s.Transport = "tcp"
|
||||||
|
}
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyRotatorEnabled: boolStr(s.Enabled),
|
keyRotatorEnabled: boolStr(s.Enabled),
|
||||||
keyRotatorType: s.Type,
|
keyRotatorType: s.Type,
|
||||||
@@ -11313,6 +11361,8 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
|||||||
keyRotatorPort: strconv.Itoa(s.Port),
|
keyRotatorPort: strconv.Itoa(s.Port),
|
||||||
keyRotatorHasElevation: boolStr(s.HasElevation),
|
keyRotatorHasElevation: boolStr(s.HasElevation),
|
||||||
keyRotatorNum: strconv.Itoa(s.RotatorNum),
|
keyRotatorNum: strconv.Itoa(s.RotatorNum),
|
||||||
|
keyRotatorTransport: s.Transport,
|
||||||
|
keyRotatorComPort: strings.TrimSpace(s.ComPort),
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -11321,6 +11371,14 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// arcoClient builds the GS-232 client for the configured ARCO transport:
|
||||||
|
// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
|
||||||
|
func arcoClient(s RotatorSettings) *gs232.Client {
|
||||||
|
if s.Transport == "serial" {
|
||||||
|
return gs232.NewSerial(s.ComPort)
|
||||||
|
}
|
||||||
|
return gs232.New(s.Host, s.Port)
|
||||||
|
}
|
||||||
|
|
||||||
// RotatorHeading is the live antenna heading for the status bar.
|
// RotatorHeading is the live antenna heading for the status bar.
|
||||||
type RotatorHeading struct {
|
type RotatorHeading struct {
|
||||||
@@ -11347,6 +11405,13 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
|||||||
}
|
}
|
||||||
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
|
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
|
||||||
}
|
}
|
||||||
|
if s.Type == "arco" {
|
||||||
|
az, raw, herr := arcoClient(s).Heading()
|
||||||
|
if herr != nil {
|
||||||
|
return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
|
||||||
|
}
|
||||||
|
return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
|
||||||
|
}
|
||||||
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
||||||
@@ -11367,6 +11432,9 @@ func (a *App) RotatorGoTo(az int, el int) error {
|
|||||||
if s.Type == "rotgenius" {
|
if s.Type == "rotgenius" {
|
||||||
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
|
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
|
||||||
}
|
}
|
||||||
|
if s.Type == "arco" {
|
||||||
|
return arcoClient(s).GoTo(az)
|
||||||
|
}
|
||||||
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
|
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11382,6 +11450,9 @@ func (a *App) RotatorStop() error {
|
|||||||
if s.Type == "rotgenius" {
|
if s.Type == "rotgenius" {
|
||||||
return rotgenius.New(s.Host, s.Port).Stop()
|
return rotgenius.New(s.Host, s.Port).Stop()
|
||||||
}
|
}
|
||||||
|
if s.Type == "arco" {
|
||||||
|
return arcoClient(s).Stop()
|
||||||
|
}
|
||||||
return pst.New(s.Host, s.Port).Stop()
|
return pst.New(s.Host, s.Port).Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11399,6 +11470,9 @@ func (a *App) RotatorPark() error {
|
|||||||
if s.Type == "rotgenius" {
|
if s.Type == "rotgenius" {
|
||||||
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
||||||
}
|
}
|
||||||
|
if s.Type == "arco" {
|
||||||
|
return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
|
||||||
|
}
|
||||||
return pst.New(s.Host, s.Port).Park()
|
return pst.New(s.Host, s.Port).Park()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11423,6 +11497,18 @@ func (a *App) TestRotator(s RotatorSettings) error {
|
|||||||
// reply, so a rejected command surfaces as an error instead of a false OK.
|
// reply, so a rejected command surfaces as an error instead of a false OK.
|
||||||
return rotgenius.New(s.Host, s.Port).GoTo(rn, 0)
|
return rotgenius.New(s.Host, s.Port).GoTo(rn, 0)
|
||||||
}
|
}
|
||||||
|
if s.Type == "arco" {
|
||||||
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
|
s.Port = 4001
|
||||||
|
}
|
||||||
|
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
|
||||||
|
return fmt.Errorf("select the ARCO's COM port first")
|
||||||
|
}
|
||||||
|
// A heading query proves the link AND that the ARCO port really speaks
|
||||||
|
// GS-232 — without moving the antenna.
|
||||||
|
_, _, err := arcoClient(s).Heading()
|
||||||
|
return err
|
||||||
|
}
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
s.Port = 12000
|
s.Port = 12000
|
||||||
}
|
}
|
||||||
@@ -11440,14 +11526,14 @@ func boolStr(b bool) string {
|
|||||||
|
|
||||||
// StationDevice is one configured relay board for the Station Control tab.
|
// StationDevice is one configured relay board for the Station Control tab.
|
||||||
type StationDevice struct {
|
type StationDevice struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
|
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
|
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
|
||||||
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
|
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
|
||||||
Pass string `json:"pass,omitempty"`
|
Pass string `json:"pass,omitempty"`
|
||||||
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16)
|
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16)
|
||||||
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
|
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// deviceRelayCount is the relay count for a configured device — fixed by type,
|
// deviceRelayCount is the relay count for a configured device — fixed by type,
|
||||||
@@ -11720,12 +11806,12 @@ type motorAntenna interface {
|
|||||||
// convention, so commands pass straight through).
|
// convention, so commands pass straight through).
|
||||||
type ubAdapter struct{ c *ultrabeam.Client }
|
type ubAdapter struct{ c *ultrabeam.Client }
|
||||||
|
|
||||||
func (a ubAdapter) Start() error { return a.c.Start() }
|
func (a ubAdapter) Start() error { return a.c.Start() }
|
||||||
func (a ubAdapter) Stop() { a.c.Stop() }
|
func (a ubAdapter) Stop() { a.c.Stop() }
|
||||||
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
|
func (a ubAdapter) SetFrequency(k, d int) error { return a.c.SetFrequency(k, d) }
|
||||||
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
func (a ubAdapter) SetDirection(d int) error { return a.c.SetDirection(d) }
|
||||||
func (a ubAdapter) Retract() error { return a.c.Retract() }
|
func (a ubAdapter) Retract() error { return a.c.Retract() }
|
||||||
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
func (a ubAdapter) LastSetKHz() int { return a.c.LastSetKHz() }
|
||||||
func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) }
|
func (a ubAdapter) SetElement(n, mm int) error { return a.c.ModifyElement(n, mm) }
|
||||||
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
|
func (a ubAdapter) ReadElements() ([]int, error) { return a.c.ReadElements() }
|
||||||
func (a ubAdapter) Elements() []int {
|
func (a ubAdapter) Elements() []int {
|
||||||
@@ -12248,11 +12334,12 @@ func (a *App) AntGeniusDeselect(port int) error {
|
|||||||
// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ─────
|
// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ─────
|
||||||
|
|
||||||
// PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers
|
// PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers
|
||||||
// the 4O3A PowerGenius XL (TCP) and the SPE Expert amps (USB serial or an
|
// the 4O3A PowerGenius XL (TCP), the SPE Expert amps and the ACOM S-series amps
|
||||||
// RS232-to-Ethernet bridge). The type name is kept for binding compatibility.
|
// (both: USB serial or an RS232-to-Ethernet bridge). The type name is kept for
|
||||||
|
// binding compatibility.
|
||||||
type PGXLSettings struct {
|
type PGXLSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k"
|
Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k" | "acom500" | "acom600" | "acom700" | "acom1200" | "acom2020"
|
||||||
Transport string `json:"transport"` // "tcp" | "serial"
|
Transport string `json:"transport"` // "tcp" | "serial"
|
||||||
Host string `json:"host"` // TCP transport
|
Host string `json:"host"` // TCP transport
|
||||||
Port int `json:"port"` // TCP transport
|
Port int `json:"port"` // TCP transport
|
||||||
@@ -12333,6 +12420,10 @@ func (a *App) startPGXL() {
|
|||||||
go a.spe.Stop()
|
go a.spe.Stop()
|
||||||
a.spe = nil
|
a.spe = nil
|
||||||
}
|
}
|
||||||
|
if a.acom != nil {
|
||||||
|
go a.acom.Stop()
|
||||||
|
a.acom = nil
|
||||||
|
}
|
||||||
s, err := a.GetPGXLSettings()
|
s, err := a.GetPGXLSettings()
|
||||||
if err != nil || !s.Enabled {
|
if err != nil || !s.Enabled {
|
||||||
return
|
return
|
||||||
@@ -12345,13 +12436,18 @@ func (a *App) startPGXL() {
|
|||||||
_ = a.pgxl.Start()
|
_ = a.pgxl.Start()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// SPE Expert — USB serial or an RS232-to-Ethernet bridge.
|
// SPE Expert / ACOM — USB serial or an RS232-to-Ethernet bridge.
|
||||||
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
|
if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" {
|
if s.Transport == "tcp" && strings.TrimSpace(s.Host) == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if model, ok := strings.CutPrefix(s.Type, "acom"); ok {
|
||||||
|
a.acom = acom.New(acom.Config{Model: model + "S", Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
|
||||||
|
_ = a.acom.Start()
|
||||||
|
return
|
||||||
|
}
|
||||||
a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
|
a.spe = spe.New(spe.Config{Transport: s.Transport, ComPort: s.ComPort, Baud: s.Baud, Host: s.Host, Port: s.Port})
|
||||||
_ = a.spe.Start()
|
_ = a.spe.Start()
|
||||||
}
|
}
|
||||||
@@ -12392,6 +12488,35 @@ func (a *App) SPESetPowerLevel(level string) error {
|
|||||||
return a.spe.SetPowerLevel(level)
|
return a.spe.SetPowerLevel(level)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetACOMStatus returns the ACOM amplifier state for the UI poll.
|
||||||
|
func (a *App) GetACOMStatus() acom.Status {
|
||||||
|
if a.acom == nil {
|
||||||
|
return acom.Status{}
|
||||||
|
}
|
||||||
|
return a.acom.GetStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACOMSetOperate puts the ACOM amp in OPERATE (true) or STANDBY (false) — the
|
||||||
|
// protocol has explicit commands for each, unlike the SPE's toggle key.
|
||||||
|
func (a *App) ACOMSetOperate(on bool) error {
|
||||||
|
if a.acom == nil {
|
||||||
|
return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier")
|
||||||
|
}
|
||||||
|
return a.acom.Operate(on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACOMSetPower turns the ACOM amp on (true, a DTR/RTS pulse — serial only, needs
|
||||||
|
// the power-on pins wired in the cable) or off (false, the OFF data command).
|
||||||
|
func (a *App) ACOMSetPower(on bool) error {
|
||||||
|
if a.acom == nil {
|
||||||
|
return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier")
|
||||||
|
}
|
||||||
|
if on {
|
||||||
|
return a.acom.PowerOn()
|
||||||
|
}
|
||||||
|
return a.acom.PowerOff()
|
||||||
|
}
|
||||||
|
|
||||||
// GetPGXLStatus returns the amp's fan/connection state for the UI poll.
|
// GetPGXLStatus returns the amp's fan/connection state for the UI poll.
|
||||||
func (a *App) GetPGXLStatus() powergenius.Status {
|
func (a *App) GetPGXLStatus() powergenius.Status {
|
||||||
if a.pgxl == nil {
|
if a.pgxl == nil {
|
||||||
@@ -12400,6 +12525,15 @@ func (a *App) GetPGXLStatus() powergenius.Status {
|
|||||||
return a.pgxl.GetStatus()
|
return a.pgxl.GetStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PGXLSetOperate puts the PowerGenius XL in OPERATE (true) or STANDBY (false)
|
||||||
|
// over its direct TCP link (no Flex needed).
|
||||||
|
func (a *App) PGXLSetOperate(on bool) error {
|
||||||
|
if a.pgxl == nil {
|
||||||
|
return fmt.Errorf("PowerGenius not connected — enable it in Settings → Amplifier")
|
||||||
|
}
|
||||||
|
return a.pgxl.SetOperate(on)
|
||||||
|
}
|
||||||
|
|
||||||
// PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
|
// PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
|
||||||
func (a *App) PGXLSetFanMode(mode string) error {
|
func (a *App) PGXLSetFanMode(mode string) error {
|
||||||
if a.pgxl == nil {
|
if a.pgxl == nil {
|
||||||
|
|||||||
@@ -1,4 +1,38 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.20.8",
|
||||||
|
"date": "2026-07-21",
|
||||||
|
"en": [
|
||||||
|
"New: microHAM ARCO rotator controlled natively — no PstRotator needed. Over the LAN or over USB: set the ARCO's CONTROL PROTOCOL (LAN or USB) to 'Yaesu GS-232A' and pick 'microHAM ARCO' in Settings → Rotator.",
|
||||||
|
"Amplifier without a FlexRadio: the amplifier controls (OPERATE/STANDBY, ON/OFF, live power/SWR/temperature) now also live in the Station Control tab, and a status chip sits in the bottom bar next to Cluster/CAT/Rotator — green ON AIR = OPERATE, orange = STANDBY, red = offline. Clicking the chip toggles OPERATE/STANDBY on all three amps (PowerGenius XL included, over its direct network link).",
|
||||||
|
"NET Control: after logging a station, the next on-air station is selected automatically (chain log → log → log), and a new 'Log everyone' button logs every on-air station at once when the net ends.",
|
||||||
|
"Switching to a profile whose MySQL database is unreachable now shows a clear warning — before, OpsLog silently stayed on the previous logbook and QSOs could land in the wrong log.",
|
||||||
|
"The ON AIR badge is back for local (SQLite) logbooks — it had disappeared with the removal of the live-status option."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau : rotator microHAM ARCO contrôlé en natif — sans PstRotator. Par le réseau ou par USB : régler le CONTROL PROTOCOL de l'ARCO (LAN ou USB) sur « Yaesu GS-232A » et choisir « microHAM ARCO » dans Réglages → Rotator.",
|
||||||
|
"Amplificateur sans FlexRadio : les commandes d'ampli (OPERATE/STANDBY, ON/OFF, puissance/ROS/température en direct) sont maintenant aussi dans l'onglet Station Control, et une pastille de statut s'affiche dans la barre du bas à côté de Cluster/CAT/Rotator — vert = OPERATE, orange = STANDBY, rouge = hors ligne. Un clic sur la pastille bascule OPERATE/STANDBY sur les trois amplis (PowerGenius XL compris, via son lien réseau direct).",
|
||||||
|
"NET Control : après avoir loggé une station, la station on air suivante est sélectionnée automatiquement (enchaînement log → log → log), et un nouveau bouton « Logger tout le monde » enregistre toutes les stations on air d'un coup à la fin du net.",
|
||||||
|
"Passer sur un profil dont la base MySQL est injoignable affiche maintenant un avertissement clair — avant, OpsLog restait silencieusement sur l'ancien logbook et les QSO pouvaient partir dans le mauvais log.",
|
||||||
|
"Le badge ON AIR est de retour pour les logbooks locaux (SQLite) — il avait disparu avec la suppression de l'option de statut en direct."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.20.7",
|
||||||
|
"date": "2026-07-21",
|
||||||
|
"en": [
|
||||||
|
"New: ACOM amplifier support (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, live telemetry (power, SWR, PA temperature, band, fan) in Settings and the radio panel, over USB serial or an RS232-to-Ethernet bridge. Power-ON works over serial when the cable wires the DTR/RTS pins. The Amplifier settings now pick Brand then Model.",
|
||||||
|
"Multi-operator live sync fixed: the shared-MySQL connection pool was too small, so after a while the grid, chat and 'stations on air' silently stopped updating. The pool is now sized for real multi-op use (with 5 operators it stays well within a max_connections=300 server).",
|
||||||
|
"Live operator status is now always published on a shared MySQL logbook — the Settings toggle is gone. You still go off-air automatically 5 minutes after your last QSO, and the 'stations on air' panel refreshes noticeably faster (a redundant table check on every read was removed).",
|
||||||
|
"The By-Continent statistics donut now totals the same as your QSO count — QSOs whose continent couldn't be resolved are shown as an 'Unknown' slice instead of being dropped (the Continents count still lists only real continents)."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Nouveau : prise en charge des amplificateurs ACOM (500S / 600S / 700S / 1200S / 2020S) — OPERATE/STANDBY/OFF, télémétrie en direct (puissance, ROS, température PA, bande, ventilateur) dans les Réglages et le panneau radio, en USB série ou via un pont RS232-vers-Ethernet. La mise en marche fonctionne en série si le câble relie les broches DTR/RTS. Les réglages Amplificateur se font maintenant par Marque puis Modèle.",
|
||||||
|
"Synchronisation multi-opérateurs corrigée : le pool de connexions MySQL partagé était trop petit, et au bout d'un moment la grille, le chat et « stations on air » cessaient silencieusement de se mettre à jour. Le pool est maintenant dimensionné pour un vrai multi-op (à 5 opérateurs, on reste largement sous un serveur à max_connections=300).",
|
||||||
|
"Le statut opérateur en direct est maintenant toujours publié sur un logbook MySQL partagé — l'option des Réglages a disparu. Vous passez toujours hors antenne automatiquement 5 minutes après votre dernier QSO, et le panneau « stations on air » se rafraîchit nettement plus vite (une vérification de table redondante à chaque lecture a été supprimée).",
|
||||||
|
"La donut « Par continent » des statistiques totalise maintenant le même nombre que le total de QSO — les QSO dont le continent n'a pas pu être résolu apparaissent dans un segment « Unknown » au lieu d'être ignorés (le compteur Continents ne liste toujours que les vrais continents)."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.20.6",
|
"version": "0.20.6",
|
||||||
"date": "2026-07-21",
|
"date": "2026-07-21",
|
||||||
|
|||||||
+102
-40
@@ -42,7 +42,8 @@ import {
|
|||||||
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
QSOAudioBegin, QSOAudioCancel, QSOAudioRestart, QSOAudioResetClock,
|
||||||
GetAwardDefs,
|
GetAwardDefs,
|
||||||
GetUIPref, GetActiveProfile, QuitApp,
|
GetUIPref, GetActiveProfile, QuitApp,
|
||||||
ReportLiveActivity, GetLiveStatusEnabled, LiveLastQSOAgeSec,
|
ReportLiveActivity, LiveLastQSOAgeSec,
|
||||||
|
GetPGXLSettings, GetPGXLStatus, GetSPEStatus, GetACOMStatus, SPESetOperate, ACOMSetOperate, PGXLSetOperate,
|
||||||
} from '../wailsjs/go/main/App';
|
} from '../wailsjs/go/main/App';
|
||||||
import { Combobox } from '@/components/ui/combobox';
|
import { Combobox } from '@/components/ui/combobox';
|
||||||
import { applyAwardRefs } from '@/lib/awardRefs';
|
import { applyAwardRefs } from '@/lib/awardRefs';
|
||||||
@@ -436,6 +437,27 @@ export default function App() {
|
|||||||
// click reverts the UI and the click looks like it did nothing.
|
// click reverts the UI and the click looks like it did nothing.
|
||||||
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({});
|
||||||
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null);
|
||||||
|
// Amplifier chip in the status bar: name + green OPERATE / orange STANDBY /
|
||||||
|
// red offline. Config re-read every 5s (so enabling the amp in Settings makes
|
||||||
|
// the chip appear); status polled every 2s while enabled.
|
||||||
|
const [ampCfg, setAmpCfg] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
|
||||||
|
const [ampSt, setAmpSt] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmpCfg({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 5000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ampCfg.enabled) { setAmpSt({ connected: false }); return; }
|
||||||
|
let alive = true;
|
||||||
|
const get = ampCfg.type === 'pgxl' ? GetPGXLStatus : ampCfg.type.startsWith('acom') ? GetACOMStatus : GetSPEStatus;
|
||||||
|
const tick = () => get().then((s: any) => alive && setAmpSt(s || { connected: false })).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 2000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [ampCfg.enabled, ampCfg.type]);
|
||||||
// Multi-op "who's on air" widget: every operator's live status from the shared
|
// Multi-op "who's on air" widget: every operator's live status from the shared
|
||||||
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
// MySQL logbook (freq/mode/version). Only polled on a MySQL logbook.
|
||||||
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
type LiveStation = { operator: string; station: string; freq_hz: number; band: string; mode: string; online: boolean; version: string; age_sec: number };
|
||||||
@@ -1121,22 +1143,22 @@ export default function App() {
|
|||||||
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
useEffect(() => { if (!showSettings) setShowBeamOnMap(localStorage.getItem('opslog.showBeamOnMap') !== '0'); }, [showSettings]);
|
||||||
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
|
// "ON AIR" status-bar badge: mirrors the multi-op live status this operator
|
||||||
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
|
// publishes — online (blinking) when a QSO was logged in the last 5 min, else
|
||||||
// offline. Only shown when live-status publishing is enabled (Settings→General).
|
// offline. Publishing is always on for a shared MySQL logbook (no user toggle:
|
||||||
const [liveStatusOn, setLiveStatusOn] = useState(false);
|
// 5 min without a QSO removes the row automatically), so the badge simply
|
||||||
|
// follows the backend.
|
||||||
const [onAir, setOnAir] = useState(false);
|
const [onAir, setOnAir] = useState(false);
|
||||||
useEffect(() => { if (!showSettings) GetLiveStatusEnabled().then((v) => setLiveStatusOn(!!v)).catch(() => {}); }, [showSettings]);
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Read the ON-AIR state straight from the backend (single source of truth:
|
// Read the ON-AIR state straight from the backend (single source of truth:
|
||||||
// liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
|
// liveLastQSOAt, stamped on every log and seeded from the DB at launch). Poll
|
||||||
// it + refresh on each logged QSO — no fragile frontend timestamp to drift.
|
// it + refresh on each logged QSO — no fragile frontend timestamp to drift.
|
||||||
const refresh = () => LiveLastQSOAgeSec()
|
const refresh = () => LiveLastQSOAgeSec()
|
||||||
.then((sec: number) => setOnAir(liveStatusOn && typeof sec === 'number' && sec >= 0 && sec < 300))
|
.then((sec: number) => setOnAir(typeof sec === 'number' && sec >= 0 && sec < 300))
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
refresh();
|
refresh();
|
||||||
const off = EventsOn('qso:logged', refresh);
|
const off = EventsOn('qso:logged', refresh);
|
||||||
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (cheap 400-row scan)
|
const id = window.setInterval(refresh, 5 * 1000); // responsive without hammering (single indexed row)
|
||||||
return () => { off(); window.clearInterval(id); };
|
return () => { off(); window.clearInterval(id); };
|
||||||
}, [liveStatusOn]);
|
}, []);
|
||||||
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
// QSO-rate meter (10/60 min) in the header — opt-in via Settings→General.
|
||||||
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
const [showQsoRate, setShowQsoRate] = useState(() => localStorage.getItem('opslog.showQsoRate') === '1');
|
||||||
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
useEffect(() => { if (!showSettings) setShowQsoRate(localStorage.getItem('opslog.showQsoRate') === '1'); }, [showSettings]);
|
||||||
@@ -2147,39 +2169,45 @@ export default function App() {
|
|||||||
// Split the macro on <LOGQSO> so the log happens AT the token's position, not
|
// Split the macro on <LOGQSO> so the log happens AT the token's position, not
|
||||||
// only at the very end: "TU <LOGQSO> QRZ" sends "TU", logs, then sends "QRZ".
|
// only at the very end: "TU <LOGQSO> QRZ" sends "TU", logs, then sends "QRZ".
|
||||||
// Each split boundary (every part except the last) is one <LOGQSO>.
|
// Each split boundary (every part except the last) is one <LOGQSO>.
|
||||||
const parts = rawText.split(/<LOGQSO>/i);
|
// RESOLVE every segment up front — while the form still holds the callsign — so a
|
||||||
|
// segment AFTER the <LOGQSO> (which logs and clears the form) still expands its
|
||||||
|
// variables correctly.
|
||||||
|
const parts = rawText.split(/<LOGQSO>/i).map((pt) => resolveCW(pt));
|
||||||
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
|
const isRig = cwSourceRef.current === 'icom' || cwSourceRef.current === 'flex';
|
||||||
for (let p = 0; p < parts.length; p++) {
|
for (let p = 0; p < parts.length; p++) {
|
||||||
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
if (aborted()) return; // ESC / Stop before this segment → stop sending, don't log
|
||||||
const resolved = resolveCW(parts[p]);
|
const resolved = parts[p];
|
||||||
|
const isLast = p === parts.length - 1;
|
||||||
|
const logAfter = !isLast; // a <LOGQSO> follows this segment
|
||||||
if (resolved) {
|
if (resolved) {
|
||||||
// Trailing word space so back-to-back sends don't run together in the keyer
|
// Trailing word space so back-to-back sends don't run together in the keyer
|
||||||
// buffer ("CQ"+"TEST" → "CQTEST"); the keyer keys it as a word gap at the
|
// buffer ("CQ"+"TEST" → "CQTEST"); the keyer keys it as a word gap at the
|
||||||
// current WPM, so it scales automatically.
|
// current WPM, so it scales automatically.
|
||||||
const keyed = resolved + ' ';
|
const keyed = resolved + ' ';
|
||||||
setWkSent(resolved);
|
setWkSent(resolved);
|
||||||
if (isRig) {
|
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : cwSourceRef.current === 'icom' ? IcomSendCW : null;
|
||||||
// The rig keyer (Icom 0x17 / Flex CWX) gives no busy echo we track, so
|
if (sendFn) await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
// wait the ESTIMATED send duration before moving on / logging.
|
else await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
||||||
const sendFn = cwSourceRef.current === 'flex' ? FlexSendCW : IcomSendCW;
|
// Only WAIT for the CW to finish on the FINAL segment — auto-call needs the
|
||||||
await sendFn(keyed).catch((e) => setError(String(e?.message ?? e)));
|
// send done before its gap+resend. A segment a <LOGQSO> follows is NOT waited
|
||||||
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + (p < parts.length - 1 ? 200 : 600));
|
// on: the keyer has already buffered the CW, so logging happens AT ONCE and
|
||||||
} else {
|
// never interrupts what's being sent. (Gating the log on a CW-length ESTIMATE
|
||||||
await WinkeyerSend(keyed).catch((e) => setError(String(e?.message ?? e)));
|
// made <LOGQSO> log up to ~10s late when the estimate ran long.)
|
||||||
// Wait for the keyer to actually finish this segment before we log /
|
if (isLast) {
|
||||||
// continue. We'd like to watch busy rise then fall, but over a
|
if (isRig) {
|
||||||
// remote/serial-over-IP link that echo can lag tens of seconds, so cap
|
await waitMs(Math.round(estimateCwMs(resolved, wkWpm)) + 600);
|
||||||
// the wait at the ESTIMATED send duration + slack: proceed when busy
|
} else {
|
||||||
// clears OR the estimate elapses, whichever comes first.
|
// WinKeyer: watch the busy echo, capped by the estimate (+slack) since
|
||||||
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
|
// over a remote/serial-over-IP link that echo can lag tens of seconds.
|
||||||
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
|
const capMs = Math.round(estimateCwMs(resolved, wkWpm) * 1.4) + 2500;
|
||||||
const deadline = Date.now() + capMs;
|
for (let i = 0; i < 20 && !wkBusyRef.current && !aborted(); i++) await sleep(50); // ≤1s to start
|
||||||
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
const deadline = Date.now() + capMs;
|
||||||
|
while (wkBusyRef.current && !aborted() && Date.now() < deadline) await sleep(50);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (aborted()) return; // aborted while this segment was sending → don't log
|
if (aborted()) return; // aborted while this segment was sending → don't log
|
||||||
// A <LOGQSO> token follows every part except the last → log the contact here.
|
if (logAfter) void save(); // log NOW — the buffered CW keeps sending
|
||||||
if (p < parts.length - 1) void save();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// stopAutoCall cancels any running auto-call loop.
|
// stopAutoCall cancels any running auto-call loop.
|
||||||
@@ -3669,7 +3697,7 @@ export default function App() {
|
|||||||
case 'flex':
|
case 'flex':
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
|
||||||
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
|
<FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
|
||||||
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -4987,7 +5015,7 @@ export default function App() {
|
|||||||
backend is a FlexRadio. */}
|
backend is a FlexRadio. */}
|
||||||
{catState.backend === 'flex' && (
|
{catState.backend === 'flex' && (
|
||||||
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
|
<TabsContent value="flex" className="flex-1 min-h-0 p-0">
|
||||||
<FlexPanel onCWSpeed={(w) => { setWkWpm(w); WinkeyerSetSpeed(w).catch(() => {}); saveWk({ wpm: w }); }}
|
<FlexPanel onCWSpeed={(w) => { if (cwSourceRef.current === 'flex') setWkWpm(w); }}
|
||||||
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
@@ -5116,16 +5144,50 @@ export default function App() {
|
|||||||
disabled={!rotatorHeading.enabled}
|
disabled={!rotatorHeading.enabled}
|
||||||
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
|
||||||
/>
|
/>
|
||||||
{liveStatusOn && (
|
{/* Amplifier chip: green = OPERATE, orange = STANDBY, red = offline.
|
||||||
<div
|
CLICK toggles OPERATE ↔ STANDBY on every backend (PGXL included —
|
||||||
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
its direct TCP link takes operate=0/1); optimistic flip, the 2s
|
||||||
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
poll reconciles. Offline → click opens the settings instead. */}
|
||||||
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
{ampCfg.enabled && (() => {
|
||||||
>
|
const isPGXL = ampCfg.type === 'pgxl';
|
||||||
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
const name = isPGXL ? 'PGXL'
|
||||||
{onAir ? t('live.onAir') : t('live.offline')}
|
: ampCfg.type.startsWith('acom') ? `ACOM ${ampSt.model || ''}`.trim()
|
||||||
</div>
|
: `SPE ${ampSt.model || ''}`.trim();
|
||||||
)}
|
const dot = !ampSt.connected ? 'bg-danger' : ampSt.operate ? 'bg-success' : 'bg-warning';
|
||||||
|
const state = !ampSt.connected ? (ampSt.last_error || 'offline') : ampSt.operate ? 'OPERATE' : 'STANDBY';
|
||||||
|
const toggle = () => {
|
||||||
|
// Offline — nothing to command; open the settings to fix the link.
|
||||||
|
if (!ampSt.connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
|
||||||
|
const want = !ampSt.operate;
|
||||||
|
setAmpSt((s: any) => ({ ...s, operate: want }));
|
||||||
|
(isPGXL ? PGXLSetOperate(want)
|
||||||
|
: ampCfg.type.startsWith('acom') ? ACOMSetOperate(want)
|
||||||
|
: SPESetOperate(want)).catch(() => {});
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={`${name}: ${state}${ampSt.connected ? (ampSt.operate ? ' — click for STANDBY' : ' — click for OPERATE') : ' — click for settings'}`}
|
||||||
|
onClick={toggle}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2 h-5 rounded border text-[11px] transition-colors border-border hover:bg-muted cursor-pointer"
|
||||||
|
>
|
||||||
|
<span className={cn('size-2 rounded-full', dot)} />
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
{/* ON AIR badge: "did I log a QSO in the last 5 min" — meaningful on ANY
|
||||||
|
logbook backend (only the live_status PUBLISHING is MySQL-specific),
|
||||||
|
so it is always shown. Gating it on MySQL made it vanish for
|
||||||
|
local-SQLite operators when the Settings toggle was removed. */}
|
||||||
|
<div
|
||||||
|
className={cn('inline-flex items-center gap-1.5 rounded-md border px-2 py-0.5 text-[11px] font-bold uppercase tracking-wider shrink-0 transition-colors',
|
||||||
|
onAir ? 'border-danger-border bg-danger-muted text-danger-muted-foreground' : 'border-border text-muted-foreground')}
|
||||||
|
title={onAir ? t('live.onAirTip') : t('live.offlineTip')}
|
||||||
|
>
|
||||||
|
<span className={cn('size-2 rounded-full', onAir ? 'bg-danger animate-pulse' : 'bg-muted-foreground/40')} />
|
||||||
|
{onAir ? t('live.onAir') : t('live.offline')}
|
||||||
|
</div>
|
||||||
{/* Toasts / errors: the status bar's free space is far wider than the
|
{/* Toasts / errors: the status bar's free space is far wider than the
|
||||||
header band they used to sit in. Still one line (the bar is 28px),
|
header band they used to sit in. Still one line (the bar is 28px),
|
||||||
but CLICK opens the full text wrapped — long messages (a TQSL or
|
but CLICK opens the full text wrapped — long messages (a TQSL or
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
FlexSetProcessor, FlexSetProcessorLevel, FlexSetMon, FlexSetMonLevel, FlexSetMic,
|
||||||
FlexMox, FlexAmpOperate,
|
FlexMox, FlexAmpOperate,
|
||||||
GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
|
GetPGXLStatus, PGXLSetFanMode, GetPGXLSettings, GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
|
||||||
|
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
|
||||||
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,
|
||||||
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
FlexSetNB, FlexSetNBLevel, FlexSetNR, FlexSetNRLevel, FlexSetANF, FlexSetANFLevel,
|
||||||
@@ -288,6 +289,18 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [st, setSt] = useState<FlexState>(ZERO);
|
const [st, setSt] = useState<FlexState>(ZERO);
|
||||||
const hold = useRef<Record<string, number>>({});
|
const hold = useRef<Record<string, number>>({});
|
||||||
|
// Keep the host's keyer speed (used for CW-length estimates and the keyer panel)
|
||||||
|
// in step with the radio's ACTUAL CW speed — including when it's changed on the
|
||||||
|
// radio / in SmartSDR, not just via the slider below. Notify only on a real change.
|
||||||
|
const lastCwSpeed = useRef<number | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
const w = st.cw_speed;
|
||||||
|
if (typeof w === 'number' && w > 0 && w !== lastCwSpeed.current) {
|
||||||
|
lastCwSpeed.current = w;
|
||||||
|
onCWSpeed?.(w);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [st.cw_speed]);
|
||||||
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
// Peak-hold: keep the highest reading for ~2 s so the jittery VITA-49 meters
|
||||||
// read steadily instead of jumping every poll.
|
// read steadily instead of jumping every poll.
|
||||||
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
const peak = useRef<Record<string, { v: number; t: number }>>({});
|
||||||
@@ -340,7 +353,8 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
const id = window.setInterval(load, 5000);
|
const id = window.setInterval(load, 5000);
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
const isSPE = ampEnabled && ampType !== 'pgxl';
|
const isACOM = ampEnabled && ampType.startsWith('acom');
|
||||||
|
const isSPE = ampEnabled && !isACOM && ampType !== 'pgxl';
|
||||||
// SPE Expert live status (only polled when an SPE amp is configured).
|
// SPE Expert live status (only polled when an SPE amp is configured).
|
||||||
const [spe, setSpe] = useState<any>({ connected: false });
|
const [spe, setSpe] = useState<any>({ connected: false });
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -351,6 +365,16 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
const id = window.setInterval(tick, 1500);
|
const id = window.setInterval(tick, 1500);
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, [isSPE]);
|
}, [isSPE]);
|
||||||
|
// ACOM live status (only polled when an ACOM amp is configured).
|
||||||
|
const [acom, setAcom] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isACOM) return;
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetACOMStatus().then((s: any) => alive && setAcom(s || { connected: false })).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [isACOM]);
|
||||||
|
|
||||||
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
const change = (key: keyof FlexState, val: number | boolean | string, send: () => Promise<any>) => {
|
||||||
hold.current[key] = Date.now() + 900;
|
hold.current[key] = Date.now() + 900;
|
||||||
@@ -860,10 +884,55 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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). */}
|
||||||
|
{isACOM && (
|
||||||
|
<Card icon={Flame} title={`${t('flxp.amplifier')} · ACOM${acom.model ? ' ' + acom.model : ''}`} accent="#ea580c">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => ACOMSetOperate(!acom.operate).catch(() => {})}
|
||||||
|
className={cn('px-4 py-2 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
||||||
|
acom.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')}>
|
||||||
|
{acom.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
{/* Power ON is a DTR/RTS pulse — serial only, and it works while the amp
|
||||||
|
is off (the port stays open), so gate on port_open not connected. */}
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
|
||||||
|
<button type="button" disabled={!(acom.port_open && acom.transport === 'serial')}
|
||||||
|
onClick={() => ACOMSetPower(true).catch(() => {})}
|
||||||
|
title={acom.transport === 'serial' ? 'Power on (DTR/RTS pulse — needs the power-on pins wired)' : 'Power-on needs the serial DTR/RTS lines — not available over a network bridge'}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-success hover:bg-success/15 disabled:opacity-30">ON</button>
|
||||||
|
<button type="button" disabled={!acom.connected}
|
||||||
|
onClick={() => ACOMSetPower(false).catch(() => {})}
|
||||||
|
className="px-3 py-2 text-sm font-bold bg-card text-danger border-l-2 border-success/70 hover:bg-danger/15 disabled:opacity-30">OFF</button>
|
||||||
|
</div>
|
||||||
|
<span className={cn('inline-flex items-center gap-1.5 text-sm', acom.connected ? 'text-muted-foreground' : 'text-danger')}>
|
||||||
|
<span className={cn('size-2 rounded-full', acom.connected ? 'bg-success' : 'bg-danger')} />
|
||||||
|
{acom.connected ? acom.state : t('flxp.acomOffline')}
|
||||||
|
</span>
|
||||||
|
{acom.connected && (
|
||||||
|
<span className="text-sm font-mono text-muted-foreground tabular-nums">
|
||||||
|
{acom.band ? `${acom.band} · ` : ''}{acom.fwd_w}W · SWR {Number(acom.swr ?? 0).toFixed(1)} · {acom.temp_c}°C · Fan {acom.fan}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
{acom.err_text && (
|
||||||
|
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">⚠ {acom.err_text}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{acom.connected && (
|
||||||
|
<MeterBar label={t('flxp.outputPower')} value={Number(acom.fwd_w) || 0} unit="W"
|
||||||
|
lo={0} hi={Number(acom.max_w) || 800}
|
||||||
|
display={`${Number(acom.fwd_w) || 0} W`}
|
||||||
|
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* External amplifier (PowerGenius XL) — only when the Flex reports one AND
|
{/* External amplifier (PowerGenius XL) — only when the Flex reports one AND
|
||||||
PowerGenius is the selected amp type. Running an SPE Expert hides this
|
PowerGenius is the selected amp type. Running an SPE Expert or ACOM hides
|
||||||
Flex-reported card so the two amps don't both show. */}
|
this Flex-reported card so two amps don't both show. */}
|
||||||
{st.amp_available && !isSPE && (
|
{st.amp_available && !isSPE && !isACOM && (
|
||||||
<Card icon={Flame} title={`${t('flxp.amplifier')}${st.amp_model ? ' · ' + st.amp_model : ''}`} accent="#ea580c">
|
<Card icon={Flame} 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">
|
||||||
<button type="button" disabled={off}
|
<button type="button" disabled={off}
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
// Full-QSO edit modal for an on-air draft (same one Recent QSOs uses).
|
||||||
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
const [editingDraft, setEditingDraft] = useState<QSOForm | null>(null);
|
||||||
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
const [selectedActiveIds, setSelectedActiveIds] = useState<number[]>([]);
|
||||||
|
// Programmatic row selection in the on-air grid (auto-advance after logging).
|
||||||
|
const [selectRow, setSelectRow] = useState<{ id: number; seq: number }>({ id: 0, seq: 0 });
|
||||||
|
|
||||||
// Add/edit-contact dialog.
|
// Add/edit-contact dialog.
|
||||||
const [contactOpen, setContactOpen] = useState(false);
|
const [contactOpen, setContactOpen] = useState(false);
|
||||||
@@ -177,10 +179,34 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
}
|
}
|
||||||
// Active → logged (end QSO, removed from session, written to the logbook).
|
// Active → logged (end QSO, removed from session, written to the logbook).
|
||||||
|
// Then auto-select the FIRST remaining on-air station, so the operator can
|
||||||
|
// chain "log → log → log" without re-clicking a row each time.
|
||||||
async function deactivate(id?: number) {
|
async function deactivate(id?: number) {
|
||||||
if (id == null) return;
|
if (id == null) return;
|
||||||
try { await NetDeactivate(id); await refreshActive(); onLogged?.(); }
|
const next = active.find((a) => a.id !== id); // computed BEFORE the list shrinks
|
||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
try {
|
||||||
|
await NetDeactivate(id);
|
||||||
|
await refreshActive();
|
||||||
|
onLogged?.();
|
||||||
|
if (next?.id != null) {
|
||||||
|
setSelectRow((s) => ({ id: next.id as number, seq: s.seq + 1 }));
|
||||||
|
setSelectedActiveIds([next.id as number]);
|
||||||
|
showWorkedBefore(next.callsign, (next as any).dxcc ?? 0);
|
||||||
|
}
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
|
// Log EVERYONE still on air (end of net): each draft is written to the logbook
|
||||||
|
// exactly as the single-log button would.
|
||||||
|
async function logAll() {
|
||||||
|
if (active.length === 0) return;
|
||||||
|
if (!window.confirm(t('ncp.logAllConfirm', { n: active.length }))) return;
|
||||||
|
try {
|
||||||
|
for (const a of [...active]) {
|
||||||
|
if (a.id != null) await NetDeactivate(a.id as number);
|
||||||
|
}
|
||||||
|
await refreshActive();
|
||||||
|
onLogged?.();
|
||||||
|
} catch (e: any) { setError(String(e?.message ?? e)); await refreshActive(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
// Edit-modal handlers (operate on the in-memory draft, not the DB).
|
||||||
@@ -286,6 +312,7 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
rows={active}
|
rows={active}
|
||||||
total={active.length}
|
total={active.length}
|
||||||
storageKey="net.onair"
|
storageKey="net.onair"
|
||||||
|
selectRowSignal={selectRow}
|
||||||
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
onRowClicked={(q) => showWorkedBefore(q.callsign, (q as any).dxcc ?? 0)}
|
||||||
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
onRowDoubleClicked={(q) => setEditingDraft(q)}
|
||||||
onRowSelected={setSelectedActiveIds}
|
onRowSelected={setSelectedActiveIds}
|
||||||
@@ -297,6 +324,9 @@ export function NetControlPanel({ onLogged, countries, bands, modes }: Props) {
|
|||||||
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
onClick={() => { if (selectedActiveIds[0] != null) deactivate(selectedActiveIds[0]); }}>
|
||||||
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
<MinusCircle className="size-3.5" /> {t('ncp.logEndSelected')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" className="h-7 text-[11px] ml-auto" onClick={logAll}>
|
||||||
|
<MinusCircle className="size-3.5" /> {t('ncp.logAll', { n: active.length })}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ type Props = {
|
|||||||
// Bump this number to programmatically select every row (e.g. after a search
|
// Bump this number to programmatically select every row (e.g. after a search
|
||||||
// in the QSL Manager, where the default is "all selected").
|
// in the QSL Manager, where the default is "all selected").
|
||||||
selectAllSignal?: number;
|
selectAllSignal?: number;
|
||||||
|
// Bump `seq` to programmatically select the single row with this id (e.g. NET
|
||||||
|
// Control auto-advancing to the next on-air station after logging one).
|
||||||
|
selectRowSignal?: { id: number; seq: number };
|
||||||
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
// storageKey scopes the persisted column layout/visibility. Omit for the main
|
||||||
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
|
||||||
// reuse this grid elsewhere with its OWN independent column config.
|
// reuse this grid elsewhere with its OWN independent column config.
|
||||||
@@ -249,7 +252,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
|
|||||||
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
const stripAwardCols = (st: any[] | null | undefined): any[] =>
|
||||||
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -406,6 +409,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
|
|||||||
gridRef.current?.api?.selectAll();
|
gridRef.current?.api?.selectAll();
|
||||||
}, [selectAllSignal]);
|
}, [selectAllSignal]);
|
||||||
|
|
||||||
|
// Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
|
||||||
|
// tick so a row-data refresh in the same render settles first.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectRowSignal || selectRowSignal.seq === 0) return;
|
||||||
|
const t = window.setTimeout(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api) return;
|
||||||
|
api.deselectAll();
|
||||||
|
api.forEachNode((n: any) => {
|
||||||
|
if (n.data?.id === selectRowSignal.id) n.setSelected(true);
|
||||||
|
});
|
||||||
|
}, 0);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [selectRowSignal?.seq]);
|
||||||
|
|
||||||
// ── Column picker (visibility) ──
|
// ── Column picker (visibility) ──
|
||||||
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
|
||||||
// state for "which columns are visible" — AG Grid's column state is the
|
// state for "which columns are visible" — AG Grid's column state is the
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import {
|
|||||||
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop,
|
||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate,
|
GetPGXLSettings, SavePGXLSettings, GetSPEStatus, SPESetOperate, GetACOMStatus, ACOMSetOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty,
|
||||||
@@ -32,7 +32,6 @@ import {
|
|||||||
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
GetMySQLSettings, SaveMySQLSettings, TestMySQLConnection, GetDBBackendStatus,
|
||||||
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
GetAutostartPrograms, SaveAutostartPrograms, BrowseExecutable, LaunchAutostartProgram,
|
||||||
GetTelemetryEnabled, SetTelemetryEnabled,
|
GetTelemetryEnabled, SetTelemetryEnabled,
|
||||||
GetLiveStatusEnabled, SetLiveStatusEnabled,
|
|
||||||
GetQSLDefaults, SaveQSLDefaults,
|
GetQSLDefaults, SaveQSLDefaults,
|
||||||
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
GetExternalServices, SaveExternalServices, TestQRZUpload, TestClublogUpload, TestHRDLogUpload, TestEQSLUpload,
|
||||||
GetPOTAToken, SavePOTAToken,
|
GetPOTAToken, SavePOTAToken,
|
||||||
@@ -566,26 +565,6 @@ function TelemetryToggle() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// LiveStatusToggle publishes this operator's current activity (call + band +
|
|
||||||
// freq + mode) to the shared MySQL `live_status` table every ~15s, for multi-op
|
|
||||||
// events — a small web script on your server renders it for the QRZ page. Only
|
|
||||||
// useful on a MySQL logbook. Self-contained component (owns its async state).
|
|
||||||
function LiveStatusToggle() {
|
|
||||||
const { t } = useI18n();
|
|
||||||
const [on, setOn] = useState(false);
|
|
||||||
const [loaded, setLoaded] = useState(false);
|
|
||||||
useEffect(() => {
|
|
||||||
GetLiveStatusEnabled().then((v) => setOn(!!v)).catch(() => {}).finally(() => setLoaded(true));
|
|
||||||
}, []);
|
|
||||||
return (
|
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
|
||||||
<Checkbox checked={on} disabled={!loaded}
|
|
||||||
onCheckedChange={(c) => { const v = !!c; setOn(v); SetLiveStatusEnabled(v).catch(() => {}); }} />
|
|
||||||
{t('settings.liveStatus')} <span className="text-xs text-muted-foreground">({t('settings.liveStatusHint')})</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
|
// ADIFMonitorPanel watches a list of external ADIF files (fldigi RTTY, N1MM,
|
||||||
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
|
// VarAC…) and auto-imports newly appended QSOs — deliberately option-free: a
|
||||||
// contact that arrives is imported and uploaded automatically like any log entry.
|
// contact that arrives is imported and uploaded automatically like any log entry.
|
||||||
@@ -691,6 +670,47 @@ function SPEStatusCard() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Live ACOM amplifier status + OPERATE/STANDBY. Module-scoped for the same
|
||||||
|
// remount reason as SPEStatusCard. Polls once a second while shown.
|
||||||
|
function ACOMStatusCard() {
|
||||||
|
const [st, setSt] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const tick = () => GetACOMStatus().then((s) => alive && setSt(s || {})).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1000);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const operate = !!st.operate;
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border p-3 space-y-2 text-xs max-w-xl">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={cn('size-2 rounded-full', st.connected ? 'bg-success animate-pulse' : 'bg-danger')} />
|
||||||
|
<span className="font-semibold">{st.connected ? `ACOM ${st.model || ''}` : `ACOM ${st.model || ''} — not connected`}</span>
|
||||||
|
{!st.connected && st.last_error && <span className="text-danger truncate text-[10px]">{st.last_error}</span>}
|
||||||
|
<span className="flex-1" />
|
||||||
|
<Button size="sm" variant={operate ? 'default' : 'outline'} disabled={!st.connected}
|
||||||
|
onClick={() => ACOMSetOperate(!operate).catch(() => {})}
|
||||||
|
title="Toggle OPERATE / STANDBY">
|
||||||
|
{operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{st.connected && (
|
||||||
|
<div className="grid grid-cols-4 gap-x-3 gap-y-1 font-mono text-[11px]">
|
||||||
|
<div>{st.state}</div>
|
||||||
|
<div>Band {st.band || '—'}</div>
|
||||||
|
<div>{st.fwd_w} W</div>
|
||||||
|
<div>SWR {Number(st.swr ?? 0).toFixed(2)}</div>
|
||||||
|
<div>Refl {st.refl_w} W</div>
|
||||||
|
<div>Drive {st.drive_w} W</div>
|
||||||
|
<div>{st.temp_c}°C</div>
|
||||||
|
<div>Fan {st.fan}</div>
|
||||||
|
{st.err_text && <div className="col-span-4 text-warning">⚠ {st.err_text} ({st.err_code})</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
function RelayAutoPanel() {
|
function RelayAutoPanel() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [enabled, setEnabled] = useState(false);
|
const [enabled, setEnabled] = useState(false);
|
||||||
@@ -2690,7 +2710,32 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
function PGXLPanelSettings() {
|
function PGXLPanelSettings() {
|
||||||
const isPGXL = pgxl.type === 'pgxl';
|
const isPGXL = pgxl.type === 'pgxl';
|
||||||
|
const isACOM = (pgxl.type || '').startsWith('acom');
|
||||||
const isSerial = pgxl.transport === 'serial';
|
const isSerial = pgxl.transport === 'serial';
|
||||||
|
// The stored `type` stays the single flat value ("spe13", "acom700", "pgxl" —
|
||||||
|
// binding compatibility); the UI presents it as brand + model.
|
||||||
|
const brand = isPGXL ? 'pgxl' : isACOM ? 'acom' : 'spe';
|
||||||
|
const brandModels: Record<string, { value: string; label: string }[]> = {
|
||||||
|
spe: [
|
||||||
|
{ value: 'spe13', label: 'Expert 1.3K-FA' },
|
||||||
|
{ value: 'spe15', label: 'Expert 1.5K-FA' },
|
||||||
|
{ value: 'spe2k', label: 'Expert 2K-FA' },
|
||||||
|
],
|
||||||
|
acom: [
|
||||||
|
{ value: 'acom500', label: '500S' },
|
||||||
|
{ value: 'acom600', label: '600S' },
|
||||||
|
{ value: 'acom700', label: '700S' },
|
||||||
|
{ value: 'acom1200', label: '1200S' },
|
||||||
|
{ value: 'acom2020', label: '2020S' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
// Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is
|
||||||
|
// 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only.
|
||||||
|
const applyType = (v: string) => setPgxl((s) => ({
|
||||||
|
...s, type: v,
|
||||||
|
transport: v === 'pgxl' ? 'tcp' : s.transport,
|
||||||
|
baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : s.baud,
|
||||||
|
}));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader title="Amplifier" />
|
<SectionHeader title="Amplifier" />
|
||||||
@@ -2700,21 +2745,44 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
Enable amplifier control
|
Enable amplifier control
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3">
|
{/* Connection gets the widest column — "Network (RS232-to-Ethernet)" was
|
||||||
|
truncated with 3 equal columns. */}
|
||||||
|
<div className="grid grid-cols-[1fr_1fr_1.7fr] gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Amplifier</Label>
|
<Label>Brand</Label>
|
||||||
<Select value={pgxl.type} onValueChange={(v) => setPgxl((s) => ({ ...s, type: v, transport: v === 'pgxl' ? 'tcp' : s.transport }))}>
|
<Select value={brand} onValueChange={(b) => {
|
||||||
|
if (b === brand) return;
|
||||||
|
applyType(b === 'pgxl' ? 'pgxl' : brandModels[b][0].value);
|
||||||
|
}}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pgxl">4O3A PowerGenius XL</SelectItem>
|
<SelectItem value="pgxl">4O3A</SelectItem>
|
||||||
<SelectItem value="spe13">SPE Expert 1.3K-FA</SelectItem>
|
<SelectItem value="spe">SPE</SelectItem>
|
||||||
<SelectItem value="spe15">SPE Expert 1.5K-FA</SelectItem>
|
<SelectItem value="acom">ACOM</SelectItem>
|
||||||
<SelectItem value="spe2k">SPE Expert 2K-FA</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
{/* PowerGenius is TCP-only; SPE amps connect over USB serial or an
|
<div className="space-y-1">
|
||||||
RS232-to-Ethernet bridge, so they offer both. */}
|
<Label>Model</Label>
|
||||||
|
{isPGXL ? (
|
||||||
|
// The PowerGenius is the brand's single model — fixed, no choice.
|
||||||
|
<Select value="pgxl" disabled>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pgxl">PowerGenius XL</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Select value={pgxl.type} onValueChange={applyType}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{brandModels[brand].map((m) => <SelectItem key={m.value} value={m.value}>{m.label}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{/* PowerGenius is TCP-only; SPE and ACOM amps connect over USB serial or
|
||||||
|
an RS232-to-Ethernet bridge, so they offer both. */}
|
||||||
{!isPGXL && (
|
{!isPGXL && (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>Connection</Label>
|
<Label>Connection</Label>
|
||||||
@@ -2767,12 +2835,17 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isPGXL && pgxl.enabled && <SPEStatusCard />}
|
{!isPGXL && pgxl.enabled && (isACOM ? <ACOMStatusCard /> : <SPEStatusCard />)}
|
||||||
{!isPGXL && (
|
{!isPGXL && !isACOM && (
|
||||||
<p className="text-[10px] text-muted-foreground">
|
<p className="text-[10px] text-muted-foreground">
|
||||||
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
|
SPE control uses the amplifier's proprietary serial protocol (OPERATE toggle + live status). Save to (re)connect. Band / power-level / antenna are still managed on the amp from the transceiver CAT.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{isACOM && (
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
ACOM control uses the amplifier's serial protocol (9600 8N1): OPERATE / STANDBY / OFF + live telemetry. Save to (re)connect. Power-ON needs the serial DTR/RTS pins wired in the cable (not available over a network bridge). Band tracking stays on the amp itself.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -2780,11 +2853,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
|
|
||||||
function RotatorPanel() {
|
function RotatorPanel() {
|
||||||
const isRG = (rotator as any).type === 'rotgenius';
|
const isRG = (rotator as any).type === 'rotgenius';
|
||||||
|
const isARCO = (rotator as any).type === 'arco';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rotator"
|
title="Rotator"
|
||||||
hint={isRG ? undefined : t('rot.hint')}
|
hint={isRG || isARCO ? undefined : t('rot.hint')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
@@ -2794,14 +2868,15 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<div className="grid grid-cols-2 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('rot.type')}</Label>
|
<Label>{t('rot.type')}</Label>
|
||||||
{/* Switching to Rotator Genius moves the default port to its native
|
{/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001
|
||||||
9006; back to PstRotator restores 12000. */}
|
(placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
|
||||||
<Select value={(rotator as any).type ?? 'pst'}
|
<Select value={(rotator as any).type ?? 'pst'}
|
||||||
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
|
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : v === 'arco' ? 4001 : 12000 } as any))}>
|
||||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||||
|
<SelectItem value="arco">microHAM ARCO (LAN, GS-232A)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -2818,34 +2893,68 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */}
|
||||||
|
{isARCO && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Connection</Label>
|
||||||
|
<Select value={(rotator as any).transport ?? 'tcp'}
|
||||||
|
onValueChange={(v) => setRotator((s) => ({ ...s, transport: v } as any))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tcp">Network (LAN)</SelectItem>
|
||||||
|
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
{isARCO && (rotator as any).transport === 'serial' ? (
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 max-w-xs">
|
||||||
<Label>Host / IP</Label>
|
<Label>COM port</Label>
|
||||||
<Input
|
<div className="flex items-center gap-2">
|
||||||
value={rotator.host ?? ''}
|
<Select value={(rotator as any).com_port || '_'}
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
onValueChange={(v) => setRotator((s) => ({ ...s, com_port: v === '_' ? '' : v } as any))}>
|
||||||
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
className="font-mono"
|
<SelectContent>
|
||||||
/>
|
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||||
|
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
<ArrowDown className="size-3.5 rotate-90" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
) : (
|
||||||
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<Input
|
<div className="space-y-1 col-span-2">
|
||||||
type="number" min={1} max={65535}
|
<Label>Host / IP</Label>
|
||||||
value={rotator.port}
|
<Input
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
|
value={rotator.host ?? ''}
|
||||||
className="font-mono"
|
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
||||||
/>
|
placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={65535}
|
||||||
|
value={rotator.port}
|
||||||
|
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{!isRG && (
|
{!isRG && !isARCO && (
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||||
This rotator supports elevation (VHF / satellite)
|
This rotator supports elevation (VHF / satellite)
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||||
|
{isARCO && <p className="text-xs text-muted-foreground">{t('rot.arcoHint')}</p>}
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
||||||
@@ -4515,7 +4624,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
{t('gen.checkUpdates')} <span className="text-xs text-muted-foreground">{t('gen.checkUpdatesHint')}</span>
|
||||||
</label>
|
</label>
|
||||||
<TelemetryToggle />
|
<TelemetryToggle />
|
||||||
<LiveStatusToggle />
|
|
||||||
|
|
||||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
|
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw } from 'lucide-react';
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, Flame } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
@@ -13,6 +13,9 @@ import {
|
|||||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
|
GetPGXLSettings, GetPGXLStatus, PGXLSetFanMode, PGXLSetOperate,
|
||||||
|
GetSPEStatus, SPESetOperate, SPESetPower, SPESetPowerLevel,
|
||||||
|
GetACOMStatus, ACOMSetOperate, ACOMSetPower,
|
||||||
} 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 };
|
||||||
@@ -246,6 +249,116 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AmplifierWidget brings the amplifier controls (Settings → Amplifier) into the
|
||||||
|
// Station Control tab, so an operator WITHOUT a FlexRadio/Icom panel still has
|
||||||
|
// them (the FlexPanel card only exists when a Flex is the rig). Same backends:
|
||||||
|
// SPE Expert / ACOM (full control) and PowerGenius XL (fan mode + state).
|
||||||
|
function AmplifierWidget({ ampType, t }: { ampType: string; t: (k: string, v?: any) => string }) {
|
||||||
|
const isACOM = ampType.startsWith('acom');
|
||||||
|
const isPGXL = ampType === 'pgxl';
|
||||||
|
const [st, setSt] = useState<any>({ connected: false });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const get = isPGXL ? GetPGXLStatus : isACOM ? GetACOMStatus : GetSPEStatus;
|
||||||
|
const tick = () => get().then((s: any) => alive && setSt(s || { connected: false })).catch(() => {});
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, [ampType, isACOM, isPGXL]);
|
||||||
|
|
||||||
|
const title = isPGXL ? 'PowerGenius XL' : isACOM ? `ACOM ${st.model || ''}` : `SPE ${st.model || 'Expert'}`;
|
||||||
|
const maxW = isACOM ? (Number(st.max_w) || 800) : ({ '13K': 1300, '15K': 1500, '2K': 2000 } as Record<string, number>)[st.model] || 1500;
|
||||||
|
const outW = Number(isACOM ? st.fwd_w : st.output_w) || 0;
|
||||||
|
const frac = Math.min(1, outW / maxW);
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Flame className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-sm font-semibold truncate">{t('flxp.amplifier')}</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground font-mono truncate">{title}</div>
|
||||||
|
</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={st.connected ? t('station.online') : (st.last_error || t('station.offline'))} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{isPGXL ? (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button type="button" disabled={!st.connected}
|
||||||
|
onClick={() => { const want = !st.operate; setSt((s: any) => ({ ...s, operate: want })); PGXLSetOperate(want).catch(() => {}); }}
|
||||||
|
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
|
||||||
|
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{st.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (
|
||||||
|
<button key={m} type="button" disabled={!st.connected}
|
||||||
|
onClick={() => PGXLSetFanMode(m).catch(() => {})}
|
||||||
|
className={cn('px-2.5 py-1.5 rounded-md border text-xs font-semibold disabled:opacity-40',
|
||||||
|
st.fan_mode === m ? 'bg-primary text-primary-foreground border-primary' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||||
|
{m === 'STANDARD' ? t('flxp.fanStandard') : m === 'CONTEST' ? t('flxp.fanContest') : t('flxp.fanBroadcast')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<span className="text-xs text-muted-foreground font-mono">{st.state || ''}{st.temperature ? ` · ${Math.round(st.temperature)}°C` : ''}</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button type="button" disabled={!st.connected}
|
||||||
|
onClick={() => (isACOM ? ACOMSetOperate(!st.operate) : SPESetOperate(!st.operate)).catch(() => {})}
|
||||||
|
className={cn('px-3 py-1.5 rounded-lg text-xs font-extrabold tracking-wide border-2 transition-all disabled:opacity-40',
|
||||||
|
st.operate ? 'bg-warning text-warning-foreground border-warning' : 'bg-card text-warning border-warning hover:bg-warning-muted')}>
|
||||||
|
{st.operate ? 'OPERATE' : 'STANDBY'}
|
||||||
|
</button>
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border border-success/70">
|
||||||
|
<button type="button"
|
||||||
|
disabled={isACOM ? !(st.port_open && st.transport === 'serial') : !st.connected}
|
||||||
|
onClick={() => (isACOM ? ACOMSetPower(true) : SPESetPower(true)).catch(() => {})}
|
||||||
|
className="px-2.5 py-1.5 text-xs font-bold bg-card text-success hover:bg-success/15 disabled:opacity-40">ON</button>
|
||||||
|
<button type="button" disabled={!st.connected}
|
||||||
|
onClick={() => (isACOM ? ACOMSetPower(false) : SPESetPower(false)).catch(() => {})}
|
||||||
|
className="px-2.5 py-1.5 text-xs font-bold bg-card text-danger border-l border-success/70 hover:bg-danger/15 disabled:opacity-40">OFF</button>
|
||||||
|
</div>
|
||||||
|
{!isACOM && (
|
||||||
|
<div className="inline-flex rounded-lg overflow-hidden border border-border">
|
||||||
|
{(['L', 'M', 'H'] as const).map((lvl, i) => (
|
||||||
|
<button key={lvl} type="button" disabled={!st.connected}
|
||||||
|
onClick={() => SPESetPowerLevel(lvl).catch(() => {})}
|
||||||
|
className={cn('px-2.5 py-1.5 text-xs font-bold disabled:opacity-40', i > 0 && 'border-l border-border',
|
||||||
|
(st.power_level || '').trim().toUpperCase() === lvl ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||||
|
{lvl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs font-mono text-muted-foreground tabular-nums">
|
||||||
|
{st.connected
|
||||||
|
? <>
|
||||||
|
{isACOM ? (st.state || '') : (st.tx ? 'TX' : 'RX')}
|
||||||
|
{st.band ? ` · ${st.band}` : ''} · {outW}W · SWR {Number((isACOM ? st.swr : st.swr_ant) ?? 0).toFixed(1)} · {st.temp_c}°C
|
||||||
|
{isACOM && st.fan ? ` · Fan ${st.fan}` : ''}
|
||||||
|
</>
|
||||||
|
: (isACOM ? t('flxp.acomOffline') : t('flxp.speOffline'))}
|
||||||
|
</div>
|
||||||
|
{st.connected && (
|
||||||
|
<div>
|
||||||
|
<div className="h-2 rounded bg-muted overflow-hidden">
|
||||||
|
<div className={cn('h-full transition-all', frac > 0.9 ? 'bg-danger' : frac > 0.75 ? 'bg-warning' : 'bg-primary')}
|
||||||
|
style={{ width: `${Math.round(frac * 100)}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="text-[10px] text-muted-foreground mt-0.5">{t('flxp.outputPower')} — {outW} W / {maxW} W</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{(st.err_text || st.warnings || st.alarms) && (
|
||||||
|
<div className="text-[11px] font-bold text-danger">⚠ {st.err_text || `${st.warnings || ''} ${st.alarms || ''}`}</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const [devices, setDevices] = useState<Device[]>([]);
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
@@ -260,6 +373,17 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
});
|
});
|
||||||
const dragId = useRef<string | null>(null);
|
const dragId = useRef<string | null>(null);
|
||||||
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
|
||||||
|
// Amplifier (Settings → Amplifier): shown here so operators without a
|
||||||
|
// FlexRadio panel still get the controls. Re-read every 5s so enabling the
|
||||||
|
// amp in Settings makes the widget appear without reopening the tab.
|
||||||
|
const [amp, setAmp] = useState<{ enabled: boolean; type: string }>({ enabled: false, type: 'pgxl' });
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = () => GetPGXLSettings().then((s: any) => { if (alive) setAmp({ enabled: !!s?.enabled, type: s?.type || 'pgxl' }); }).catch(() => {});
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 5000);
|
||||||
|
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 */ }
|
||||||
@@ -380,13 +504,16 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
if (ant.enabled) {
|
if (ant.enabled) {
|
||||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||||
}
|
}
|
||||||
|
if (amp.enabled) {
|
||||||
|
widgets.push({ id: 'amplifier', node: <AmplifierWidget ampType={amp.type} 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;
|
const noDevices = devices.length === 0 && !rot.enabled && !ant.enabled && !amp.enabled;
|
||||||
|
|
||||||
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
// Column count controls the layout: with 4 widgets, choosing "2" lays them out
|
||||||
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
// 2×2 — which linear drag-reorder alone can't do, since the grid auto-flows to
|
||||||
|
|||||||
@@ -92,8 +92,6 @@ const en: Dict = {
|
|||||||
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
'settings.theme': 'Theme', 'settings.themeHint': 'Interface colour theme.',
|
||||||
'settings.telemetry': 'Send anonymous usage statistics',
|
'settings.telemetry': 'Send anonymous usage statistics',
|
||||||
'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data',
|
'settings.telemetryHint': 'install ID + version + OS, once a day — no callsign or QSO data',
|
||||||
'settings.liveStatus': 'Publish live operator status',
|
|
||||||
'settings.liveStatusHint': 'multi-op on shared MySQL — feeds a QRZ live page',
|
|
||||||
'settings.mainView': 'Main view',
|
'settings.mainView': 'Main view',
|
||||||
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
|
'settings.mainViewHint': 'Choose what the Main tab shows on each side (per profile).',
|
||||||
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
|
'settings.leftPane': 'Left pane', 'settings.rightPane': 'Right pane',
|
||||||
@@ -213,7 +211,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.',
|
||||||
'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.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.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.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||||
// CAT panel body
|
// CAT panel body
|
||||||
@@ -283,14 +281,14 @@ const en: Dict = {
|
|||||||
'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',
|
||||||
'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.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',
|
||||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
|
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.scopePanDown': 'Shift scope −50 kHz', 'icmp.scopePanUp': 'Shift scope +50 kHz', 'icmp.scopeCenterVfo': 'Center scope on the current frequency (±50 kHz)', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.apf': 'Audio peak filter (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?',
|
||||||
'rst.clickToFill': 'Click to set RST tx from the signal',
|
'rst.clickToFill': 'Click to set RST tx from the signal',
|
||||||
'qrz.openTitle': 'Open {call} on QRZ.com',
|
'qrz.openTitle': 'Open {call} on QRZ.com',
|
||||||
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
||||||
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
|
'altm.filterPh': 'Filter…', 'altm.noMatch': 'no match', 'altm.noneAll': 'none selected = ALL', 'altm.nSelected': '{n} selected', 'altm.giveName': 'Give the rule a name', 'altm.deleteConfirm': 'Delete alert "{name}"?', 'altm.title': 'Alert management', 'altm.desc': 'Alert when a spot matches a rule. Empty filters = ANY; the filters you set are ANDed (e.g. France + 20m = French stations on 20m).', 'altm.rules': 'Rules', 'altm.noRules': 'No rules yet — click +', 'altm.emailTo': 'Alert e-mail to', 'altm.selectOrCreate': 'Select or create a rule.', 'altm.tabDef': 'Definition', 'altm.tabCall': 'Call / DXCC', 'altm.tabBandMode': 'Band / Mode', 'altm.tabOrigin': 'Origin', 'altm.ruleName': 'Rule name', 'altm.alertEnabled': 'Alert enabled', 'altm.againAfter': 'Alert again after (min)', 'altm.againHint': '0 = once/session · -1 = always', 'altm.actions': 'Actions', 'altm.visual': 'Visual', 'altm.sound': 'Sound', 'altm.email': 'E-mail', 'altm.skipWorked': 'Skip calls already worked (same band + mode)', 'altm.callsigns': 'Callsigns (one per line, wildcards: IW3*, */P)', 'altm.countries': 'Countries (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bands', 'altm.modes': 'Modes', 'altm.spotterCall': 'Spotter callsign (wildcard)', 'altm.spotterCallPh': 'e.g. F* or DL1ABC', 'altm.spotterContinents': 'Spotter continents', 'altm.spotterCountries': 'Spotter countries', 'altm.delete': 'Delete', 'altm.saveRule': 'Save rule', 'altm.close': 'Close',
|
||||||
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
|
'spm.callRequired': 'Callsign required', 'spm.freqRequired': 'Frequency (kHz) required', 'spm.title': 'Send DX Spot', 'spm.callsign': 'Callsign', 'spm.callPh': 'DX call', 'spm.frequency': 'Frequency (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'e.g. CW · TNX QSO', 'spm.latestQsos': 'Latest QSOs', 'spm.spotSent': 'Spot sent ✓', 'spm.masterCluster': 'Master cluster', 'spm.cancel': 'Cancel', 'spm.sending': 'Sending…', 'spm.sendSpot': 'Send spot',
|
||||||
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
'ncp.newNetPrompt': 'New NET name:', 'ncp.renamePrompt': 'Rename NET:', 'ncp.deleteConfirm': 'Delete NET "{name}" and its roster? This cannot be undone.', 'ncp.closeConfirm': "{n} station(s) still on the air will be dropped WITHOUT logging. Close anyway?", 'ncp.removeConfirm': "Remove {n} station(s) from this NET's roster?", 'ncp.colCallsign': 'Callsign', 'ncp.colName': 'Name', 'ncp.colTimeOn': 'Time on', 'ncp.colBand': 'Band', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Comment', 'ncp.colCountry': 'Country', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Worked before', 'ncp.wbHint': 'Click a station (on air or roster) to see prior QSOs', 'ncp.wbNone': 'No prior QSO with', 'ncp.wbFirst': 'first', 'ncp.wbLast': 'last', 'ncp.wbResize': 'Drag to resize', 'ncp.newNet': 'New NET', 'ncp.closeToSwitch': 'Close the NET to switch', 'ncp.selectNetTitle': 'Select a NET', 'ncp.selectNetOption': '— select a NET —', 'ncp.closeNet': 'Close NET', 'ncp.openNet': 'Open NET', 'ncp.rename': 'Rename', 'ncp.delete': 'Delete', 'ncp.netOpenBadge': 'NET OPEN', 'ncp.onAir': 'On air:', 'ncp.roster': 'Roster:', 'ncp.onAirActive': 'On air — active QSOs', 'ncp.activeHint': 'double-click → edit all fields · "Log & end" to save', 'ncp.logEndSelected': 'Log & end selected', 'ncp.logAll': 'Log everyone ({n})', 'ncp.logAllConfirm': 'Log all {n} on-air station(s) to the logbook?', 'ncp.netUsersRoster': 'NET users — roster', 'ncp.rosterHint': 'double-click → put on air', 'ncp.addContact': 'Add contact', 'ncp.remove': 'Remove', 'ncp.putOnAir': 'Put selected on air', 'ncp.addContactTitle': 'Add contact to NET', 'ncp.addContactDesc': "Saved in this NET's roster (reused next time you open it).", 'ncp.callsign': 'Callsign', 'ncp.search': 'Search', 'ncp.name': 'Name', 'ncp.country': 'Country', 'ncp.cancel': 'Cancel', 'ncp.saveInNet': 'Save in NET',
|
||||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': 'Auto-logs FT8/FT4/etc. QSOs and fills the entry callsign live.', 'udpp.svcAdifLabel': 'ADIF message (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Receives a single ADIF record per packet and logs it.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (contest XML)', 'udpp.svcN1mmHint': 'Receives contest QSOs as XML messages.', 'udpp.svcRemoteLabel': 'Remote callsign (DXHunter, custom)', 'udpp.svcRemoteHint': 'A short text packet containing just a callsign — fills the entry field.', 'udpp.svcDbLabel': 'ADIF Message', 'udpp.svcDbHint': 'Sends the ADIF of every QSO you log to a remote listener (Cloudlog UDP, N1MM, …).', 'udpp.svcPstLabel': 'PstRotator frequency', 'udpp.svcPstHint': 'Sends the rig frequency as <PST><FREQUENCY> whenever it changes — set PstRotatorAz tracker to DXLog.net (default port 12040).', 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (freq + mode)', 'udpp.svcN1mmRadioHint': 'Broadcasts the rig frequency/mode as N1MM Logger+ RadioInfo XML on every change — consumed by PstRotator (N1MM tracker) and many other tools.', 'udpp.deleteConfirm': 'Delete this UDP connection?', 'udpp.loading': 'Loading…', 'udpp.intro': 'UDP connections let OpsLog talk to other ham radio software. Inbound connections receive QSOs or callsigns and update the logbook live; outbound connections notify other apps when you log a QSO locally. Enable multicast to share a port with another listener without conflict — required for the typical WSJT-X 2237 setup.', 'udpp.inboundTitle': 'Inbound — OpsLog listens', 'udpp.outboundTitle': 'Outbound — OpsLog sends', 'udpp.reloadAll': 'Reload all', 'udpp.reloadHint': 'Restarts every enabled listener after a manual change.', 'udpp.add': 'Add', 'udpp.noConnection': 'No connection.', 'udpp.unnamed': '(unnamed)', 'udpp.dialogTitle': '{action} {direction} connection', 'udpp.new': 'New', 'udpp.edit': 'Edit', 'udpp.directionInbound': 'inbound', 'udpp.directionOutbound': 'outbound', 'udpp.name': 'Name', 'udpp.namePhInbound': 'WSJT-X log', 'udpp.namePhOutbound': 'Cloudlog notify', 'udpp.serviceType': 'Service type', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Multicast group', 'udpp.multicastHint': 'Use the same group address as the sending app. WSJT-X default is 224.0.0.1.', 'udpp.destinationIp': 'Destination IP', 'udpp.enabled': 'Enabled', 'udpp.cancel': 'Cancel', 'udpp.save': 'Save',
|
||||||
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
'fltb.fCallsign': 'Callsign', 'fltb.fDate': 'Date / time (UTC)', 'fltb.fEndDate': 'End date / time', 'fltb.fBand': 'Band', 'fltb.fRxBand': 'RX band', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Submode', 'fltb.fFreq': 'Frequency (Hz)', 'fltb.fRxFreq': 'RX frequency (Hz)', 'fltb.fRstSent': 'RST sent', 'fltb.fRstRcvd': 'RST rcvd', 'fltb.fName': 'Name', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Address', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Grid', 'fltb.fCountry': 'Country', 'fltb.fState': 'State', 'fltb.fCounty': 'County', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'CQ zone', 'fltb.fItuz': 'ITU zone', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'SOTA ref', 'fltb.fPota': 'POTA ref', 'fltb.fWwff': 'WWFF ref', 'fltb.fRig': 'Rig', 'fltb.fAntenna': 'Antenna', 'fltb.fQslSent': 'QSL sent', 'fltb.fQslRcvd': 'QSL rcvd', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW sent', 'fltb.fLotwRcvd': 'LoTW rcvd', 'fltb.fEqslSent': 'eQSL sent', 'fltb.fEqslRcvd': 'eQSL rcvd', 'fltb.fQrzUpload': 'QRZ upload status', 'fltb.fClublogUpload': 'ClubLog upload status', 'fltb.fHrdlogUpload': 'HRDLog upload status', 'fltb.fContestId': 'Contest ID', 'fltb.fSerialRcvd': 'Serial rcvd', 'fltb.fSerialSent': 'Serial sent', 'fltb.fPropMode': 'Propagation mode', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'My callsign', 'fltb.fOperator': 'Operator', 'fltb.fOwnerCallsign': 'Owner callsign', 'fltb.fMyGrid': 'My grid', 'fltb.fMyCountry': 'My country', 'fltb.fMyState': 'My state', 'fltb.fMyCounty': 'My county', 'fltb.fMyIota': 'My IOTA', 'fltb.fMySota': 'My SOTA ref', 'fltb.fMyPota': 'My POTA ref', 'fltb.fMyWwff': 'My WWFF ref', 'fltb.fMyStreet': 'My street', 'fltb.fMyCity': 'My city', 'fltb.fMyPostal': 'My postal code', 'fltb.fMyRig': 'My rig', 'fltb.fMyAntenna': 'My antenna', 'fltb.fTxPower': 'TX power (W)', 'fltb.fComment': 'Comment', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'equals (=)', 'fltb.opNe': 'not equal (≠)', 'fltb.opContains': 'contains', 'fltb.opStartsWith': 'starts with', 'fltb.opEndsWith': 'ends with', 'fltb.opGt': 'greater than (>)', 'fltb.opLt': 'less than (<)', 'fltb.opGe': 'greater or equal (≥)', 'fltb.opLe': 'less or equal (≤)', 'fltb.opEmpty': 'is empty', 'fltb.opNotEmpty': 'is not empty', 'fltb.title': 'QSO filter', 'fltb.match': 'Match', 'fltb.all': 'ALL (AND)', 'fltb.any': 'ANY (OR)', 'fltb.loadPreset': 'Load preset…', 'fltb.noConditions': 'No conditions — the list shows all QSOs. Add one below.', 'fltb.where': 'WHERE', 'fltb.valuePh': 'value', 'fltb.remove': 'Remove', 'fltb.addCondition': 'Add condition', 'fltb.presetNamePh': 'Preset name…', 'fltb.savePreset': 'Save preset', 'fltb.clear': 'Clear', 'fltb.cancel': 'Cancel', 'fltb.applyClose': 'Apply & close',
|
||||||
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
'detp.propAS': 'Aircraft Scatter', 'detp.propAUR': 'Aurora', 'detp.propAUE': 'Aurora-E', 'detp.propBS': 'Back Scatter', 'detp.propEME': 'Earth-Moon-Earth', 'detp.propES': 'Sporadic E', 'detp.propFAI': 'Field Aligned Irregularities', 'detp.propF2': 'F2 Reflection', 'detp.propGWAVE': 'Ground Wave', 'detp.propINTERNET': 'Internet-assisted', 'detp.propION': 'Ionoscatter', 'detp.propLOS': 'Line of Sight', 'detp.propMS': 'Meteor Scatter', 'detp.propRPT': 'Terrestrial / atmospheric repeater', 'detp.propRS': 'Rain Scatter', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-Equatorial', 'detp.propTR': 'Tropospheric Ducting', 'detp.pathShort': 'Short Path', 'detp.pathLong': 'Long Path', 'detp.pathGrayline': 'Grayline', 'detp.pathOther': 'Other', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Awards', 'detp.tabMy': 'My', 'detp.tabExtended': 'Extended', 'detp.statePref': 'State / pref', 'detp.county': 'County', 'detp.prefix': 'Prefix', 'detp.cqZone': 'CQ zone', 'detp.ituZone': 'ITU zone', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimuth LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Address', 'detp.qslMessage': 'QSL message', 'detp.qslVia': 'QSL via', 'detp.detected': 'Detected — this contact will count for:', 'detp.azimuth': 'Azimuth (°)', 'detp.elevation': 'Elevation (°)', 'detp.txPower': 'TX power (W)', 'detp.satelliteMode': 'Satellite mode', 'detp.antPath': 'Ant. path', 'detp.propagation': 'Propagation', 'detp.rig': 'Rig', 'detp.antenna': 'Antenna', 'detp.satName': 'Satellite name', 'detp.contestId': 'Contest ID', 'detp.rcvdExchangePh': 'rcvd exchange', 'detp.sentExchangePh': 'sent exchange', 'detp.contactedEmail': 'Contacted email',
|
||||||
@@ -407,8 +405,6 @@ const fr: Dict = {
|
|||||||
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
'settings.theme': 'Thème', 'settings.themeHint': "Thème de couleur de l'interface.",
|
||||||
'settings.telemetry': "Envoyer des statistiques d'usage anonymes",
|
'settings.telemetry': "Envoyer des statistiques d'usage anonymes",
|
||||||
'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO",
|
'settings.telemetryHint': "ID d'installation + version + OS, une fois par jour — aucun indicatif ni donnée QSO",
|
||||||
'settings.liveStatus': 'Publier le statut opérateur en direct',
|
|
||||||
'settings.liveStatusHint': 'multi-op sur MySQL partagé — alimente une page live QRZ',
|
|
||||||
'settings.mainView': 'Vue principale',
|
'settings.mainView': 'Vue principale',
|
||||||
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
|
'settings.mainViewHint': "Choisis ce que l'onglet Principal affiche de chaque côté (par profil).",
|
||||||
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
|
'settings.leftPane': 'Volet gauche', 'settings.rightPane': 'Volet droit',
|
||||||
@@ -518,7 +514,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.",
|
||||||
'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.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.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.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||||
@@ -581,13 +577,13 @@ const fr: Dict = {
|
|||||||
'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',
|
||||||
'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.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',
|
||||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
|
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.scopePanDown': 'Décaler le scope −50 kHz', 'icmp.scopePanUp': 'Décaler le scope +50 kHz', 'icmp.scopeCenterVfo': 'Centrer le scope sur la fréquence actuelle (±50 kHz)', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.apf': 'Filtre de pic audio (CW)', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?',
|
||||||
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
||||||
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
||||||
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
|
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
|
||||||
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
|
'spm.callRequired': 'Indicatif requis', 'spm.freqRequired': 'Fréquence (kHz) requise', 'spm.title': 'Envoyer un spot DX', 'spm.callsign': 'Indicatif', 'spm.callPh': 'Indicatif DX', 'spm.frequency': 'Fréquence (kHz)', 'spm.message': 'Message', 'spm.messagePh': 'ex. CW · TNX QSO', 'spm.latestQsos': 'Derniers QSO', 'spm.spotSent': 'Spot envoyé ✓', 'spm.masterCluster': 'Cluster maître', 'spm.cancel': 'Annuler', 'spm.sending': 'Envoi…', 'spm.sendSpot': 'Envoyer le spot',
|
||||||
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
'ncp.newNetPrompt': 'Nom du nouveau NET :', 'ncp.renamePrompt': 'Renommer le NET :', 'ncp.deleteConfirm': 'Supprimer le NET « {name} » et son répertoire ? Cette action est irréversible.', 'ncp.closeConfirm': "{n} station(s) encore en l'air seront retirées SANS être enregistrées. Fermer quand même ?", 'ncp.removeConfirm': 'Retirer {n} station(s) du répertoire de ce NET ?', 'ncp.colCallsign': 'Indicatif', 'ncp.colName': 'Nom', 'ncp.colTimeOn': 'Heure début', 'ncp.colBand': 'Bande', 'ncp.colMode': 'Mode', 'ncp.colComment': 'Commentaire', 'ncp.colCountry': 'Pays', 'ncp.colDate': 'Date', 'ncp.workedBefore': 'Déjà contacté', 'ncp.wbHint': 'Cliquer une station (on air ou roster) pour voir les QSO précédents', 'ncp.wbNone': 'Aucun QSO précédent avec', 'ncp.wbFirst': 'premier', 'ncp.wbLast': 'dernier', 'ncp.wbResize': 'Glisser pour redimensionner', 'ncp.newNet': 'Nouveau NET', 'ncp.closeToSwitch': 'Ferme le NET pour changer', 'ncp.selectNetTitle': 'Sélectionne un NET', 'ncp.selectNetOption': '— sélectionner un NET —', 'ncp.closeNet': 'Fermer le NET', 'ncp.openNet': 'Ouvrir le NET', 'ncp.rename': 'Renommer', 'ncp.delete': 'Supprimer', 'ncp.netOpenBadge': 'NET OUVERT', 'ncp.onAir': "En l'air :", 'ncp.roster': 'Répertoire :', 'ncp.onAirActive': "En l'air — QSO actifs", 'ncp.activeHint': 'double-clic → éditer tous les champs · « Logger & terminer » pour enregistrer', 'ncp.logEndSelected': 'Logger & terminer la sélection', 'ncp.logAll': 'Logger tout le monde ({n})', 'ncp.logAllConfirm': 'Logger les {n} station(s) on air dans le logbook ?', 'ncp.netUsersRoster': 'Membres du NET — répertoire', 'ncp.rosterHint': "double-clic → mettre en l'air", 'ncp.addContact': 'Ajouter un contact', 'ncp.remove': 'Retirer', 'ncp.putOnAir': "Mettre la sélection en l'air", 'ncp.addContactTitle': 'Ajouter un contact au NET', 'ncp.addContactDesc': 'Enregistré dans le répertoire de ce NET (réutilisé à la prochaine ouverture).', 'ncp.callsign': 'Indicatif', 'ncp.search': 'Rechercher', 'ncp.name': 'Nom', 'ncp.country': 'Pays', 'ncp.cancel': 'Annuler', 'ncp.saveInNet': 'Enregistrer dans le NET',
|
||||||
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
'udpp.svcWsjtLabel': 'WSJT-X / JTDX / MSHV', 'udpp.svcWsjtHint': "Logue automatiquement les QSO FT8/FT4/etc. et remplit l'indicatif de saisie en direct.", 'udpp.svcAdifLabel': 'Message ADIF (JTAlert, GridTracker)', 'udpp.svcAdifHint': 'Reçoit un seul enregistrement ADIF par paquet et le logue.', 'udpp.svcN1mmLabel': 'N1MM Logger+ (XML contest)', 'udpp.svcN1mmHint': 'Reçoit les QSO de contest sous forme de messages XML.', 'udpp.svcRemoteLabel': 'Indicatif distant (DXHunter, personnalisé)', 'udpp.svcRemoteHint': 'Un court paquet texte contenant juste un indicatif — remplit le champ de saisie.', 'udpp.svcDbLabel': "ADIF Message", 'udpp.svcDbHint': "Envoie l'ADIF de chaque QSO enregistré vers un écouteur distant (Cloudlog UDP, N1MM…).", 'udpp.svcPstLabel': 'Fréquence PstRotator', 'udpp.svcPstHint': "Envoie la fréquence du poste en <PST><FREQUENCY> à chaque changement — règle le tracker de PstRotatorAz sur DXLog.net (port 12040 par défaut).", 'udpp.svcN1mmRadioLabel': 'N1MM RadioInfo (fréq + mode)', 'udpp.svcN1mmRadioHint': "Diffuse la fréquence/mode du poste en XML RadioInfo N1MM Logger+ à chaque changement — lu par PstRotator (tracker N1MM) et beaucoup d'autres outils.", 'udpp.deleteConfirm': 'Supprimer cette connexion UDP ?', 'udpp.loading': 'Chargement…', 'udpp.intro': "Les connexions UDP permettent à OpsLog de dialoguer avec d'autres logiciels radioamateurs. Les connexions entrantes reçoivent des QSO ou des indicatifs et mettent le journal à jour en direct ; les connexions sortantes notifient d'autres apps quand tu enregistres un QSO localement. Active le multicast pour partager un port avec un autre écouteur sans conflit — nécessaire pour la config WSJT-X 2237 classique.", 'udpp.inboundTitle': 'Entrant — OpsLog écoute', 'udpp.outboundTitle': 'Sortant — OpsLog envoie', 'udpp.reloadAll': 'Tout recharger', 'udpp.reloadHint': 'Redémarre chaque écouteur activé après une modification manuelle.', 'udpp.add': 'Ajouter', 'udpp.noConnection': 'Aucune connexion.', 'udpp.unnamed': '(sans nom)', 'udpp.dialogTitle': '{action} connexion {direction}', 'udpp.new': 'Nouvelle', 'udpp.edit': 'Modifier', 'udpp.directionInbound': 'entrante', 'udpp.directionOutbound': 'sortante', 'udpp.name': 'Nom', 'udpp.namePhInbound': 'Log WSJT-X', 'udpp.namePhOutbound': 'Notification Cloudlog', 'udpp.serviceType': 'Type de service', 'udpp.port': 'Port', 'udpp.multicast': 'Multicast', 'udpp.multicastGroup': 'Groupe multicast', 'udpp.multicastHint': "Utilise la même adresse de groupe que l'app émettrice. Le défaut WSJT-X est 224.0.0.1.", 'udpp.destinationIp': 'IP de destination', 'udpp.enabled': 'Activé', 'udpp.cancel': 'Annuler', 'udpp.save': 'Enregistrer',
|
||||||
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
'fltb.fCallsign': 'Indicatif', 'fltb.fDate': 'Date / heure (UTC)', 'fltb.fEndDate': 'Date / heure de fin', 'fltb.fBand': 'Bande', 'fltb.fRxBand': 'Bande RX', 'fltb.fMode': 'Mode', 'fltb.fSubmode': 'Sous-mode', 'fltb.fFreq': 'Fréquence (Hz)', 'fltb.fRxFreq': 'Fréquence RX (Hz)', 'fltb.fRstSent': 'RST envoyé', 'fltb.fRstRcvd': 'RST reçu', 'fltb.fName': 'Nom', 'fltb.fQth': 'QTH', 'fltb.fAddress': 'Adresse', 'fltb.fEmail': 'E-mail', 'fltb.fGrid': 'Locator', 'fltb.fCountry': 'Pays', 'fltb.fState': 'État', 'fltb.fCounty': 'Comté', 'fltb.fDxcc': 'DXCC #', 'fltb.fContinent': 'Continent', 'fltb.fCqz': 'Zone CQ', 'fltb.fItuz': 'Zone ITU', 'fltb.fIota': 'IOTA', 'fltb.fSota': 'Réf SOTA', 'fltb.fPota': 'Réf POTA', 'fltb.fWwff': 'Réf WWFF', 'fltb.fRig': 'Station', 'fltb.fAntenna': 'Antenne', 'fltb.fQslSent': 'QSL envoyée', 'fltb.fQslRcvd': 'QSL reçue', 'fltb.fQslVia': 'QSL via', 'fltb.fLotwSent': 'LoTW envoyé', 'fltb.fLotwRcvd': 'LoTW reçu', 'fltb.fEqslSent': 'eQSL envoyé', 'fltb.fEqslRcvd': 'eQSL reçu', 'fltb.fQrzUpload': 'Statut upload QRZ', 'fltb.fClublogUpload': 'Statut upload ClubLog', 'fltb.fHrdlogUpload': 'Statut upload HRDLog', 'fltb.fContestId': 'ID contest', 'fltb.fSerialRcvd': 'Numéro reçu', 'fltb.fSerialSent': 'Numéro envoyé', 'fltb.fPropMode': 'Mode de propagation', 'fltb.fSatellite': 'Satellite', 'fltb.fMyCallsign': 'Mon indicatif', 'fltb.fOperator': 'Opérateur', 'fltb.fOwnerCallsign': 'Indicatif propriétaire', 'fltb.fMyGrid': 'Mon locator', 'fltb.fMyCountry': 'Mon pays', 'fltb.fMyState': 'Mon état', 'fltb.fMyCounty': 'Mon comté', 'fltb.fMyIota': 'Mon IOTA', 'fltb.fMySota': 'Ma réf SOTA', 'fltb.fMyPota': 'Ma réf POTA', 'fltb.fMyWwff': 'Ma réf WWFF', 'fltb.fMyStreet': 'Ma rue', 'fltb.fMyCity': 'Ma ville', 'fltb.fMyPostal': 'Mon code postal', 'fltb.fMyRig': 'Ma station', 'fltb.fMyAntenna': 'Mon antenne', 'fltb.fTxPower': 'Puissance TX (W)', 'fltb.fComment': 'Commentaire', 'fltb.fNotes': 'Notes', 'fltb.opEq': 'égal (=)', 'fltb.opNe': 'différent (≠)', 'fltb.opContains': 'contient', 'fltb.opStartsWith': 'commence par', 'fltb.opEndsWith': 'finit par', 'fltb.opGt': 'supérieur à (>)', 'fltb.opLt': 'inférieur à (<)', 'fltb.opGe': 'supérieur ou égal (≥)', 'fltb.opLe': 'inférieur ou égal (≤)', 'fltb.opEmpty': 'est vide', 'fltb.opNotEmpty': "n'est pas vide", 'fltb.title': 'Filtre QSO', 'fltb.match': 'Correspondance', 'fltb.all': 'TOUS (ET)', 'fltb.any': 'AU MOINS UN (OU)', 'fltb.loadPreset': 'Charger un préréglage…', 'fltb.noConditions': 'Aucune condition — la liste affiche tous les QSO. Ajoutes-en une ci-dessous.', 'fltb.where': 'OÙ', 'fltb.valuePh': 'valeur', 'fltb.remove': 'Retirer', 'fltb.addCondition': 'Ajouter une condition', 'fltb.presetNamePh': 'Nom du préréglage…', 'fltb.savePreset': 'Enregistrer le préréglage', 'fltb.clear': 'Effacer', 'fltb.cancel': 'Annuler', 'fltb.applyClose': 'Appliquer & fermer',
|
||||||
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
'detp.propAS': 'Diffusion par avion', 'detp.propAUR': 'Aurore', 'detp.propAUE': 'Aurore-E', 'detp.propBS': 'Rétrodiffusion', 'detp.propEME': 'Terre-Lune-Terre', 'detp.propES': 'Sporadique E', 'detp.propFAI': 'Irrégularités alignées au champ', 'detp.propF2': 'Réflexion F2', 'detp.propGWAVE': 'Onde de sol', 'detp.propINTERNET': 'Assisté par Internet', 'detp.propION': 'Diffusion ionosphérique', 'detp.propLOS': 'Vue directe', 'detp.propMS': 'Diffusion météoritique', 'detp.propRPT': 'Répéteur terrestre / atmosphérique', 'detp.propRS': 'Diffusion par la pluie', 'detp.propSAT': 'Satellite', 'detp.propTEP': 'Trans-équatorial', 'detp.propTR': 'Conduit troposphérique', 'detp.pathShort': 'Chemin court', 'detp.pathLong': 'Chemin long', 'detp.pathGrayline': 'Ligne grise', 'detp.pathOther': 'Autre', 'detp.tabStats': 'Stats', 'detp.tabInfo': 'Info', 'detp.tabAwards': 'Diplômes', 'detp.tabMy': 'Moi', 'detp.tabExtended': 'Étendu', 'detp.statePref': 'État / préf', 'detp.county': 'Comté', 'detp.prefix': 'Préfixe', 'detp.cqZone': 'Zone CQ', 'detp.ituZone': 'Zone ITU', 'detp.dxcc': 'DXCC #', 'detp.azimuthLp': 'Azimut LP', 'detp.distanceSp': 'Distance SP', 'detp.distanceLp': 'Distance LP', 'detp.address': 'Adresse', 'detp.qslMessage': 'Message QSL', 'detp.qslVia': 'QSL via', 'detp.detected': 'Détecté — ce contact comptera pour :', 'detp.azimuth': 'Azimut (°)', 'detp.elevation': 'Élévation (°)', 'detp.txPower': 'Puissance TX (W)', 'detp.satelliteMode': 'Mode satellite', 'detp.antPath': 'Chemin ant.', 'detp.propagation': 'Propagation', 'detp.rig': 'Station', 'detp.antenna': 'Antenne', 'detp.satName': 'Nom du satellite', 'detp.contestId': 'ID contest', 'detp.rcvdExchangePh': 'échange reçu', 'detp.sentExchangePh': 'échange envoyé', 'detp.contactedEmail': 'E-mail du contact',
|
||||||
|
|||||||
@@ -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.6';
|
export const APP_VERSION = '0.20.8';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+9
-4
@@ -5,6 +5,7 @@ import {qso} from '../models';
|
|||||||
import {main} from '../models';
|
import {main} from '../models';
|
||||||
import {cat} from '../models';
|
import {cat} from '../models';
|
||||||
import {profile} from '../models';
|
import {profile} from '../models';
|
||||||
|
import {acom} from '../models';
|
||||||
import {antgenius} from '../models';
|
import {antgenius} from '../models';
|
||||||
import {award} from '../models';
|
import {award} from '../models';
|
||||||
import {awardref} from '../models';
|
import {awardref} from '../models';
|
||||||
@@ -23,6 +24,10 @@ import {lotwusers} from '../models';
|
|||||||
import {lookup} from '../models';
|
import {lookup} from '../models';
|
||||||
import {netctl} from '../models';
|
import {netctl} from '../models';
|
||||||
|
|
||||||
|
export function ACOMSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function ACOMSetPower(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
export function ADIFFields():Promise<Array<adif.FieldDef>>;
|
||||||
|
|
||||||
export function ADIFVersion():Promise<string>;
|
export function ADIFVersion():Promise<string>;
|
||||||
@@ -289,6 +294,8 @@ export function FlexStopCW():Promise<void>;
|
|||||||
|
|
||||||
export function FlexTune(arg1:boolean):Promise<void>;
|
export function FlexTune(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function GetACOMStatus():Promise<acom.Status>;
|
||||||
|
|
||||||
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
export function GetADIFMonitor():Promise<main.ADIFMonitorConfig>;
|
||||||
|
|
||||||
export function GetActiveProfile():Promise<profile.Profile>;
|
export function GetActiveProfile():Promise<profile.Profile>;
|
||||||
@@ -365,8 +372,6 @@ export function GetListsSettings():Promise<main.ListsSettings>;
|
|||||||
|
|
||||||
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
export function GetLiveStations():Promise<Array<main.LiveStation>>;
|
||||||
|
|
||||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
|
||||||
|
|
||||||
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
export function GetLoTWUsersStatus():Promise<main.LoTWUsersStatus>;
|
||||||
|
|
||||||
export function GetLogFilePath():Promise<string>;
|
export function GetLogFilePath():Promise<string>;
|
||||||
@@ -625,6 +630,8 @@ export function OperatingDefaultForBand(arg1:string):Promise<operating.BandDefau
|
|||||||
|
|
||||||
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
export function PGXLSetFanMode(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function PGXLSetOperate(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function PickADIFMonitorFile():Promise<string>;
|
export function PickADIFMonitorFile():Promise<string>;
|
||||||
|
|
||||||
export function PickAudioFolder():Promise<string>;
|
export function PickAudioFolder():Promise<string>;
|
||||||
@@ -813,8 +820,6 @@ export function SetCompactMode(arg1:boolean):Promise<void>;
|
|||||||
|
|
||||||
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
export function SetDVKLabel(arg1:number,arg2:string):Promise<void>;
|
||||||
|
|
||||||
export function SetLiveStatusEnabled(arg1:boolean):Promise<void>;
|
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
export function SetTelemetryEnabled(arg1:boolean):Promise<void>;
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
|
||||||
// This file is automatically generated. DO NOT EDIT
|
// This file is automatically generated. DO NOT EDIT
|
||||||
|
|
||||||
|
export function ACOMSetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['ACOMSetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ACOMSetPower(arg1) {
|
||||||
|
return window['go']['main']['App']['ACOMSetPower'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ADIFFields() {
|
export function ADIFFields() {
|
||||||
return window['go']['main']['App']['ADIFFields']();
|
return window['go']['main']['App']['ADIFFields']();
|
||||||
}
|
}
|
||||||
@@ -534,6 +542,10 @@ export function FlexTune(arg1) {
|
|||||||
return window['go']['main']['App']['FlexTune'](arg1);
|
return window['go']['main']['App']['FlexTune'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetACOMStatus() {
|
||||||
|
return window['go']['main']['App']['GetACOMStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetADIFMonitor() {
|
export function GetADIFMonitor() {
|
||||||
return window['go']['main']['App']['GetADIFMonitor']();
|
return window['go']['main']['App']['GetADIFMonitor']();
|
||||||
}
|
}
|
||||||
@@ -686,10 +698,6 @@ export function GetLiveStations() {
|
|||||||
return window['go']['main']['App']['GetLiveStations']();
|
return window['go']['main']['App']['GetLiveStations']();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetLiveStatusEnabled() {
|
|
||||||
return window['go']['main']['App']['GetLiveStatusEnabled']();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GetLoTWUsersStatus() {
|
export function GetLoTWUsersStatus() {
|
||||||
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
return window['go']['main']['App']['GetLoTWUsersStatus']();
|
||||||
}
|
}
|
||||||
@@ -1206,6 +1214,10 @@ export function PGXLSetFanMode(arg1) {
|
|||||||
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
return window['go']['main']['App']['PGXLSetFanMode'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function PGXLSetOperate(arg1) {
|
||||||
|
return window['go']['main']['App']['PGXLSetOperate'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function PickADIFMonitorFile() {
|
export function PickADIFMonitorFile() {
|
||||||
return window['go']['main']['App']['PickADIFMonitorFile']();
|
return window['go']['main']['App']['PickADIFMonitorFile']();
|
||||||
}
|
}
|
||||||
@@ -1582,10 +1594,6 @@ export function SetDVKLabel(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
return window['go']['main']['App']['SetDVKLabel'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SetLiveStatusEnabled(arg1) {
|
|
||||||
return window['go']['main']['App']['SetLiveStatusEnabled'](arg1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,58 @@
|
|||||||
|
export namespace acom {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
port_open: boolean;
|
||||||
|
transport: string;
|
||||||
|
last_error?: string;
|
||||||
|
model?: string;
|
||||||
|
state?: string;
|
||||||
|
operate: boolean;
|
||||||
|
tx: boolean;
|
||||||
|
fwd_w: number;
|
||||||
|
refl_w: number;
|
||||||
|
swr: number;
|
||||||
|
drive_w: number;
|
||||||
|
dc_w: number;
|
||||||
|
temp_c: number;
|
||||||
|
band?: string;
|
||||||
|
fan: number;
|
||||||
|
err_code: number;
|
||||||
|
err_text?: string;
|
||||||
|
nominal_w: number;
|
||||||
|
max_w: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.port_open = source["port_open"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.last_error = source["last_error"];
|
||||||
|
this.model = source["model"];
|
||||||
|
this.state = source["state"];
|
||||||
|
this.operate = source["operate"];
|
||||||
|
this.tx = source["tx"];
|
||||||
|
this.fwd_w = source["fwd_w"];
|
||||||
|
this.refl_w = source["refl_w"];
|
||||||
|
this.swr = source["swr"];
|
||||||
|
this.drive_w = source["drive_w"];
|
||||||
|
this.dc_w = source["dc_w"];
|
||||||
|
this.temp_c = source["temp_c"];
|
||||||
|
this.band = source["band"];
|
||||||
|
this.fan = source["fan"];
|
||||||
|
this.err_code = source["err_code"];
|
||||||
|
this.err_text = source["err_text"];
|
||||||
|
this.nominal_w = source["nominal_w"];
|
||||||
|
this.max_w = source["max_w"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace adif {
|
export namespace adif {
|
||||||
|
|
||||||
export class ExportResult {
|
export class ExportResult {
|
||||||
@@ -2528,6 +2583,8 @@ export namespace main {
|
|||||||
port: number;
|
port: number;
|
||||||
has_elevation: boolean;
|
has_elevation: boolean;
|
||||||
rotator_num: number;
|
rotator_num: number;
|
||||||
|
transport: string;
|
||||||
|
com_port: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorSettings(source);
|
return new RotatorSettings(source);
|
||||||
@@ -2541,6 +2598,8 @@ export namespace main {
|
|||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
this.has_elevation = source["has_elevation"];
|
this.has_elevation = source["has_elevation"];
|
||||||
this.rotator_num = source["rotator_num"];
|
this.rotator_num = source["rotator_num"];
|
||||||
|
this.transport = source["transport"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SecretStatus {
|
export class SecretStatus {
|
||||||
@@ -3153,6 +3212,7 @@ export namespace powergenius {
|
|||||||
state?: string;
|
state?: string;
|
||||||
fan_mode?: string;
|
fan_mode?: string;
|
||||||
temperature: number;
|
temperature: number;
|
||||||
|
operate: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Status(source);
|
return new Status(source);
|
||||||
@@ -3166,6 +3226,7 @@ export namespace powergenius {
|
|||||||
this.state = source["state"];
|
this.state = source["state"];
|
||||||
this.fan_mode = source["fan_mode"];
|
this.fan_mode = source["fan_mode"];
|
||||||
this.temperature = source["temperature"];
|
this.temperature = source["temperature"];
|
||||||
|
this.operate = source["operate"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,504 @@
|
|||||||
|
// Package acom drives the ACOM solid-state amplifiers (500S / 600S / 700S /
|
||||||
|
// 1200S / 2020S) over their (unpublished) RS-232 protocol, reverse-engineered by
|
||||||
|
// the ACOM-Controller project (bjornekelund, C#). The amp is reached either
|
||||||
|
// directly over a serial COM port (9600 8N1) or over TCP via an RS232-to-Ethernet
|
||||||
|
// bridge — both are just an io.ReadWriteCloser to this code, same as the SPE
|
||||||
|
// backend.
|
||||||
|
//
|
||||||
|
// Wire format (host → amp): fixed raw byte strings, validated by the same rule as
|
||||||
|
// telemetry (sum of ALL frame bytes ≡ 0 mod 256):
|
||||||
|
//
|
||||||
|
// enable telemetry: 55 92 04 15
|
||||||
|
// disable telemetry: 55 91 04 16
|
||||||
|
// OPERATE: 55 81 08 02 00 06 00 1A
|
||||||
|
// STANDBY: 55 81 08 02 00 05 00 1B
|
||||||
|
// OFF (power down): 55 81 08 02 00 0A 00 16
|
||||||
|
//
|
||||||
|
// Power ON is NOT a data command: it is a hardware pulse on the serial DTR/RTS
|
||||||
|
// lines (the amp's remote power-on pins) — serial transport only, and only if the
|
||||||
|
// cable wires those pins (a telemetry-only cable uses just RX/TX/GND).
|
||||||
|
//
|
||||||
|
// IMPORTANT: DTR and RTS must be held LOW at all times otherwise — asserting them
|
||||||
|
// permanently blocks the amplifier's front-panel power button.
|
||||||
|
//
|
||||||
|
// Telemetry (amp → host): once enabled, the amp streams 72-byte frames starting
|
||||||
|
// 0x55 0x2F, valid when the sum of all 72 bytes ≡ 0 (mod 256). Field offsets are
|
||||||
|
// decoded in decodeFrame below.
|
||||||
|
package acom
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
cmdEnableTelemetry = []byte{0x55, 0x92, 0x04, 0x15}
|
||||||
|
cmdDisableTelemetry = []byte{0x55, 0x91, 0x04, 0x16}
|
||||||
|
cmdOperate = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x06, 0x00, 0x1A}
|
||||||
|
cmdStandby = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x05, 0x00, 0x1B}
|
||||||
|
cmdOff = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x0A, 0x00, 0x16}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
frameLen = 72
|
||||||
|
sync0 = 0x55
|
||||||
|
sync1 = 0x2F
|
||||||
|
dialTimeout = 5 * time.Second
|
||||||
|
ioTimeout = 3 * time.Second
|
||||||
|
// The amp streams roughly 4-5 frames/s once telemetry is on; if nothing valid
|
||||||
|
// arrives for this long the link (or the amp) is considered down.
|
||||||
|
staleAfter = 5 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// model carries the per-model constants: the temperature word offset and the
|
||||||
|
// nominal/max forward power (for UI bar scaling).
|
||||||
|
type model struct {
|
||||||
|
Name string
|
||||||
|
TempOffset int
|
||||||
|
NominalW int
|
||||||
|
MaxW int
|
||||||
|
}
|
||||||
|
|
||||||
|
var models = map[string]model{
|
||||||
|
"500S": {"500S", 282, 500, 600},
|
||||||
|
"600S": {"600S", 273, 600, 700},
|
||||||
|
"700S": {"700S", 282, 700, 800},
|
||||||
|
"1200S": {"1200S", 281, 1200, 1400},
|
||||||
|
"2020S": {"2020S", 282, 1800, 2000},
|
||||||
|
}
|
||||||
|
|
||||||
|
// paStatusNames maps the PAstatus nibble to a display string.
|
||||||
|
var paStatusNames = map[int]string{
|
||||||
|
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
|
||||||
|
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
|
||||||
|
}
|
||||||
|
|
||||||
|
// acomBands maps the band nibble to a band label.
|
||||||
|
var acomBands = []string{"?", "160m", "80m", "40/60m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m"}
|
||||||
|
|
||||||
|
// errText translates the error code (frame byte 66) shown on the amp's display.
|
||||||
|
// 0xFF means NO error — everything else, including 0x00, is a fault/warning.
|
||||||
|
func errText(code int) string {
|
||||||
|
switch code {
|
||||||
|
case 0xFF:
|
||||||
|
return ""
|
||||||
|
case 0x00, 0x08:
|
||||||
|
return "Hot switching"
|
||||||
|
case 0x03:
|
||||||
|
return "Drive power at wrong time"
|
||||||
|
case 0x04, 0x05:
|
||||||
|
return "Reflected power warning"
|
||||||
|
case 0x06, 0x07:
|
||||||
|
return "Drive power too high"
|
||||||
|
case 0x0C:
|
||||||
|
return "RF power at wrong time"
|
||||||
|
case 0x0E:
|
||||||
|
return "Stop transmission first"
|
||||||
|
case 0x0F:
|
||||||
|
return "Remove drive power"
|
||||||
|
case 0x24, 0x25, 0x39, 0x44, 0x45, 0x59:
|
||||||
|
return "Excessive PAM current"
|
||||||
|
case 0x70:
|
||||||
|
return "CAT error"
|
||||||
|
default:
|
||||||
|
return "ERROR — see display"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is the decoded amplifier state for the UI.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"` // valid telemetry is flowing
|
||||||
|
PortOpen bool `json:"port_open"` // transport is open (serial port / TCP socket) — power-on possible even when the amp itself is off
|
||||||
|
Transport string `json:"transport"` // "serial" | "tcp" — the UI disables power-ON over tcp (no DTR line)
|
||||||
|
LastError string `json:"last_error,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
|
State string `json:"state,omitempty"` // STANDBY / RECEIVE / TRANSMIT / OFF / …
|
||||||
|
Operate bool `json:"operate"` // RECEIVE or TRANSMIT (vs STANDBY)
|
||||||
|
TX bool `json:"tx"`
|
||||||
|
FwdW int `json:"fwd_w"` // forward/output power
|
||||||
|
ReflW int `json:"refl_w"` // reflected power
|
||||||
|
SWR float64 `json:"swr"`
|
||||||
|
DriveW int `json:"drive_w"`
|
||||||
|
DCPowerW int `json:"dc_w"`
|
||||||
|
TempC int `json:"temp_c"` // PA temperature
|
||||||
|
Band string `json:"band,omitempty"`
|
||||||
|
FanLevel int `json:"fan"` // 1..4
|
||||||
|
ErrCode int `json:"err_code"` // raw code from the frame; 0xFF = none
|
||||||
|
ErrText string `json:"err_text,omitempty"` // human message; empty = no error
|
||||||
|
NominalW int `json:"nominal_w"`
|
||||||
|
MaxW int `json:"max_w"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config selects the transport and amplifier model.
|
||||||
|
type Config struct {
|
||||||
|
Model string // "500S" | "600S" | "700S" | "1200S" | "2020S"
|
||||||
|
Transport string // "serial" | "tcp"
|
||||||
|
ComPort string // serial
|
||||||
|
Baud int // serial (the amp is fixed 9600 8N1)
|
||||||
|
Host string // tcp (RS232-to-Ethernet bridge)
|
||||||
|
Port int // tcp
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
mdl model
|
||||||
|
|
||||||
|
mu sync.Mutex // serialises access to the connection
|
||||||
|
conn io.ReadWriteCloser
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
status Status
|
||||||
|
|
||||||
|
// Diagnostics: raw byte count + first-bytes capture, so a hardware session log
|
||||||
|
// tells apart "nothing on the wire" (cable/COM/amp) from "bytes but no valid
|
||||||
|
// frame" (framing/checksum) without a serial sniffer.
|
||||||
|
rawSeen int64
|
||||||
|
dbgBytes []byte
|
||||||
|
dbgLogged bool
|
||||||
|
ckFails int
|
||||||
|
frameLogged bool
|
||||||
|
|
||||||
|
stop chan struct{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 9600
|
||||||
|
}
|
||||||
|
mdl, ok := models[strings.ToUpper(strings.TrimSpace(cfg.Model))]
|
||||||
|
if !ok {
|
||||||
|
mdl = models["700S"]
|
||||||
|
}
|
||||||
|
c := &Client{cfg: cfg, mdl: mdl, stop: make(chan struct{})}
|
||||||
|
c.status.Model = mdl.Name
|
||||||
|
c.status.Transport = cfg.Transport
|
||||||
|
c.status.NominalW = mdl.NominalW
|
||||||
|
c.status.MaxW = mdl.MaxW
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
if c.running {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
c.running = true
|
||||||
|
go c.readLoop()
|
||||||
|
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.Write(cmdDisableTelemetry) // best effort: stop the stream
|
||||||
|
}
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.status
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setErr(msg string) {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Connected = false
|
||||||
|
c.status.LastError = msg
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operate puts the amp in OPERATE (true) or STANDBY (false). Unlike the SPE's
|
||||||
|
// single toggle key, the ACOM protocol has explicit commands for each state.
|
||||||
|
func (c *Client) Operate(on bool) error {
|
||||||
|
if on {
|
||||||
|
return c.send(cmdOperate)
|
||||||
|
}
|
||||||
|
return c.send(cmdStandby)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PowerOff sends the power-down command (same as pressing OFF on the amp).
|
||||||
|
func (c *Client) PowerOff() error { return c.send(cmdOff) }
|
||||||
|
|
||||||
|
// PowerOn pulses the serial DTR/RTS lines — the amp's remote power-on pins, wired
|
||||||
|
// like a press of the front-panel power button. Hardware line ⇒ serial transport
|
||||||
|
// only (an RS232-to-Ethernet bridge doesn't forward DTR), and the cable must have
|
||||||
|
// those pins connected. Pulse length to be confirmed on real hardware.
|
||||||
|
func (c *Client) PowerOn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
sp, ok := c.conn.(serial.Port)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("power-on needs the serial DTR/RTS lines — not available over a network bridge")
|
||||||
|
}
|
||||||
|
if err := sp.SetDTR(true); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := sp.SetRTS(true); err != nil {
|
||||||
|
_ = sp.SetDTR(false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
time.Sleep(2500 * time.Millisecond)
|
||||||
|
err1 := sp.SetDTR(false)
|
||||||
|
err2 := sp.SetRTS(false)
|
||||||
|
if err1 != nil {
|
||||||
|
return err1
|
||||||
|
}
|
||||||
|
return err2
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) send(cmd []byte) error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn == nil {
|
||||||
|
return fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
if nc, ok := c.conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
|
||||||
|
}
|
||||||
|
_, err := c.conn.Write(cmd)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readLoop keeps the link up and consumes the telemetry stream. When no valid
|
||||||
|
// frame arrives for a while it re-arms telemetry (the amp forgets the enable
|
||||||
|
// across a power cycle) and, on TCP, reconnects. A serial port is kept open even
|
||||||
|
// with the amp off, so the DTR power-on pulse stays available.
|
||||||
|
func (c *Client) readLoop() {
|
||||||
|
var lastFrame time.Time
|
||||||
|
var lastEnable time.Time
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
c.setErr("connect: " + err.Error())
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
frame, err := c.readFrame()
|
||||||
|
now := time.Now()
|
||||||
|
if err == nil {
|
||||||
|
lastFrame = now
|
||||||
|
if !c.frameLogged {
|
||||||
|
c.frameLogged = true
|
||||||
|
applog.Printf("acom: telemetry up — first valid frame received")
|
||||||
|
}
|
||||||
|
c.decodeFrame(frame)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// No valid frame. Re-send the telemetry enable briskly — there is no way to
|
||||||
|
// know whether the amp has it on (the reference controller re-sends every
|
||||||
|
// 200ms until frames flow), and it revives the stream after a power cycle.
|
||||||
|
if now.Sub(lastEnable) >= 500*time.Millisecond {
|
||||||
|
lastEnable = now
|
||||||
|
_ = c.send(cmdEnableTelemetry)
|
||||||
|
}
|
||||||
|
if lastFrame.IsZero() || now.Sub(lastFrame) > staleAfter {
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
// The bridge socket may be dead — force a reconnect.
|
||||||
|
c.mu.Lock()
|
||||||
|
c.dropLocked()
|
||||||
|
c.mu.Unlock()
|
||||||
|
}
|
||||||
|
if prev := c.GetStatus(); prev.Connected || prev.LastError == "" {
|
||||||
|
applog.Printf("acom: no telemetry (raw bytes seen so far: %d, checksum fails: %d)", c.rawSeen, c.ckFails)
|
||||||
|
}
|
||||||
|
c.setErr("no telemetry — amplifier off or cable/bridge issue")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConn() error {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if c.cfg.Transport == "tcp" {
|
||||||
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.conn = nc
|
||||||
|
} else {
|
||||||
|
sp, err := serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// CRITICAL: hold DTR/RTS LOW. Windows may assert them on open, and while
|
||||||
|
// asserted the amp's front-panel power button is blocked (they are its
|
||||||
|
// remote power-on lines).
|
||||||
|
_ = sp.SetDTR(false)
|
||||||
|
_ = sp.SetRTS(false)
|
||||||
|
// Short read timeout so the frame reader can poll the stop channel and
|
||||||
|
// detect staleness rather than blocking forever.
|
||||||
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
c.conn = sp
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.PortOpen = true
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
applog.Printf("acom: %s link open (%s)", c.mdl.Name, c.cfg.Transport)
|
||||||
|
_, _ = c.conn.Write(cmdEnableTelemetry)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dropLocked() {
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.PortOpen = false
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// readByte reads a single byte, honouring the transport's short timeout. A serial
|
||||||
|
// read that times out returns (0, nil) with go.bug.st/serial — mapped to an error
|
||||||
|
// here so callers can distinguish "no data yet".
|
||||||
|
var errNoData = fmt.Errorf("no data")
|
||||||
|
|
||||||
|
func (c *Client) readByte(deadline time.Time) (byte, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.mu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return 0, fmt.Errorf("not connected")
|
||||||
|
}
|
||||||
|
buf := make([]byte, 1)
|
||||||
|
for {
|
||||||
|
if nc, ok := conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
|
||||||
|
}
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n == 1 {
|
||||||
|
c.rawSeen++
|
||||||
|
// Capture the first 144 raw bytes (~2 frames) once, so a session log shows
|
||||||
|
// what the wire actually carries when frames won't validate.
|
||||||
|
if !c.dbgLogged {
|
||||||
|
c.dbgBytes = append(c.dbgBytes, buf[0])
|
||||||
|
if len(c.dbgBytes) >= 144 {
|
||||||
|
c.dbgLogged = true
|
||||||
|
applog.Printf("acom: first raw bytes: % X", c.dbgBytes)
|
||||||
|
c.dbgBytes = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf[0], nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||||
|
err = nil // treat like the serial short-timeout: just no data yet
|
||||||
|
} else {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-c.stop:
|
||||||
|
return 0, fmt.Errorf("stopped")
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return 0, errNoData
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFrame syncs on 0x55 0x2F, reads the rest of the 72-byte frame and verifies
|
||||||
|
// the mod-256 checksum (sum of ALL 72 bytes ≡ 0).
|
||||||
|
func (c *Client) readFrame() ([]byte, error) {
|
||||||
|
deadline := time.Now().Add(ioTimeout)
|
||||||
|
// Sync: hunt for 0x55 followed by 0x2F.
|
||||||
|
for {
|
||||||
|
b, err := c.readByte(deadline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if b != sync0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b2, err := c.readByte(deadline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if b2 == sync1 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
frame := make([]byte, frameLen)
|
||||||
|
frame[0], frame[1] = sync0, sync1
|
||||||
|
for i := 2; i < frameLen; i++ {
|
||||||
|
b, err := c.readByte(deadline)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
frame[i] = b
|
||||||
|
}
|
||||||
|
var sum int
|
||||||
|
for _, b := range frame {
|
||||||
|
sum += int(b)
|
||||||
|
}
|
||||||
|
if sum%256 != 0 {
|
||||||
|
c.ckFails++
|
||||||
|
if c.ckFails <= 3 {
|
||||||
|
applog.Printf("acom: bad checksum (fail #%d): % X", c.ckFails, frame)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("bad checksum")
|
||||||
|
}
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeFrame extracts the telemetry fields (see the package comment for the
|
||||||
|
// reverse-engineered layout; 16-bit values are little-endian lo + hi*256).
|
||||||
|
func (c *Client) decodeFrame(f []byte) {
|
||||||
|
u16 := func(i int) int { return int(f[i]) + int(f[i+1])*256 }
|
||||||
|
paStatus := int(f[3]&0xF0) >> 4
|
||||||
|
state := paStatusNames[paStatus]
|
||||||
|
if state == "" {
|
||||||
|
state = fmt.Sprintf("?%d", paStatus)
|
||||||
|
}
|
||||||
|
bandIdx := int(f[69] & 0x0F)
|
||||||
|
band := ""
|
||||||
|
if bandIdx > 0 && bandIdx < len(acomBands) {
|
||||||
|
band = acomBands[bandIdx]
|
||||||
|
}
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
c.status.Connected = true
|
||||||
|
c.status.LastError = ""
|
||||||
|
c.status.State = state
|
||||||
|
c.status.Operate = paStatus == 6 || paStatus == 7
|
||||||
|
c.status.TX = paStatus == 7
|
||||||
|
c.status.DCPowerW = u16(8) / 10
|
||||||
|
c.status.TempC = u16(16) - c.mdl.TempOffset
|
||||||
|
c.status.DriveW = u16(20)
|
||||||
|
c.status.FwdW = u16(22)
|
||||||
|
c.status.ReflW = u16(24)
|
||||||
|
c.status.SWR = float64(u16(26)) / 100
|
||||||
|
c.status.ErrCode = int(f[66])
|
||||||
|
c.status.ErrText = errText(int(f[66]))
|
||||||
|
c.status.Band = band
|
||||||
|
c.status.FanLevel = int(f[69]&0xF0) >> 4
|
||||||
|
}
|
||||||
@@ -47,10 +47,15 @@ func Init(dataDir string) (string, error) {
|
|||||||
_ = os.Rename(oldLog, logPath)
|
_ = os.Rename(oldLog, logPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Truncate if the file grew past ~5MB so we don't accumulate logs
|
// Rotate (don't delete) once the file grows past ~10MB: rename it to
|
||||||
// forever. We keep one file — simple and adequate for diagnostics.
|
// opslog.log.1 so the PREVIOUS session's log survives. Deleting it outright used
|
||||||
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 5*1024*1024 {
|
// to erase exactly the diagnostics we needed when a user reported an issue from
|
||||||
_ = os.Remove(logPath)
|
// the session that just ended. One generation kept — enough, without unbounded growth.
|
||||||
|
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 10*1024*1024 {
|
||||||
|
_ = os.Remove(logPath + ".1") // drop the older generation
|
||||||
|
if err := os.Rename(logPath, logPath+".1"); err != nil {
|
||||||
|
_ = os.Remove(logPath) // rename failed (locked?) → fall back to the old behaviour
|
||||||
|
}
|
||||||
}
|
}
|
||||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+24
-16
@@ -129,6 +129,28 @@ func translateTextColumns(s string) string {
|
|||||||
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
|
// OpenMySQL opens the shared MySQL logbook, creating the database if needed,
|
||||||
// then applies the (translated) embedded migrations. multiStatements is enabled
|
// then applies the (translated) embedded migrations. multiStatements is enabled
|
||||||
// so multi-statement migration files run in a single Exec.
|
// so multi-statement migration files run in a single Exec.
|
||||||
|
// tuneMySQLPool sizes the connection pool for a SHARED server. Two opposing needs:
|
||||||
|
// - A big per-instance pool (we shipped 50) let a handful of ops exhaust the
|
||||||
|
// server's global max_connections — "Error 1040: Too many connections".
|
||||||
|
// - Too SMALL a pool starves this instance's OWN concurrent UI queries — the live
|
||||||
|
// multi-op sync (revision poll every 2s + a 3-query grid refresh), live_status,
|
||||||
|
// chat, stats, cluster. Under real multi-op load, 8 and even 16 stalled: hot
|
||||||
|
// paths (WorkedBefore fires ~10 sequential round-trips; every poll uses the
|
||||||
|
// app-lifetime ctx with no per-query timeout) hold a connection for the whole
|
||||||
|
// chain of remote-MySQL latency, so a handful of concurrent UI bursts drained
|
||||||
|
// the pool and "after a while nothing updated" — grid, chat, live_status froze.
|
||||||
|
// 40 fits the actual deployment (max 5 ops on a server with max_connections=300 →
|
||||||
|
// 40*5 = 200, leaving ~100 for phpMyAdmin/server overhead). This is the per-INSTANCE
|
||||||
|
// pool, so keep max_connections >= pool*ops + headroom.
|
||||||
|
// Brisk idle reaping returns slots to the server; no max lifetime so a slow first
|
||||||
|
// migration on one connection isn't reaped mid-run (drops the selected database).
|
||||||
|
func tuneMySQLPool(conn *sql.DB) {
|
||||||
|
conn.SetMaxOpenConns(40)
|
||||||
|
conn.SetMaxIdleConns(8)
|
||||||
|
conn.SetConnMaxIdleTime(30 * time.Second)
|
||||||
|
conn.SetConnMaxLifetime(0)
|
||||||
|
}
|
||||||
|
|
||||||
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
||||||
if strings.TrimSpace(c.Host) == "" {
|
if strings.TrimSpace(c.Host) == "" {
|
||||||
return nil, fmt.Errorf("host is required")
|
return nil, fmt.Errorf("host is required")
|
||||||
@@ -141,18 +163,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open mysql: %w", err)
|
return nil, fmt.Errorf("open mysql: %w", err)
|
||||||
}
|
}
|
||||||
// The UI fires bursts of concurrent queries (the Preferences dialog alone
|
tuneMySQLPool(conn)
|
||||||
// loads ~8 settings in parallel, plus the grid and live cluster status). A
|
|
||||||
// low cap turns such a burst into a deadlock-like stall against a remote
|
|
||||||
// server, so keep a generous pool with idle reaping. SQLite ran uncapped.
|
|
||||||
conn.SetMaxOpenConns(50)
|
|
||||||
conn.SetMaxIdleConns(10)
|
|
||||||
conn.SetConnMaxIdleTime(90 * time.Second)
|
|
||||||
// No max lifetime: a slow server's first migration can run for minutes on a
|
|
||||||
// single connection, and reaping it mid-migration drops the selected database
|
|
||||||
// (surfacing as "Unknown database"). Idle connections are still recycled
|
|
||||||
// after 90s, and the driver retries stale pooled connections.
|
|
||||||
conn.SetConnMaxLifetime(0)
|
|
||||||
// Connect DIRECTLY to the database first. Only if that fails — the DB doesn't
|
// Connect DIRECTLY to the database first. Only if that fails — the DB doesn't
|
||||||
// exist yet (first-time setup) or the server is unreachable — do we pay for the
|
// exist yet (first-time setup) or the server is unreachable — do we pay for the
|
||||||
// server-level connect + CREATE DATABASE (two more handshakes). On a normal
|
// server-level connect + CREATE DATABASE (two more handshakes). On a normal
|
||||||
@@ -167,10 +178,7 @@ func OpenMySQL(c MySQLConfig) (*sql.DB, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open mysql: %w", err)
|
return nil, fmt.Errorf("open mysql: %w", err)
|
||||||
}
|
}
|
||||||
conn.SetMaxOpenConns(50)
|
tuneMySQLPool(conn)
|
||||||
conn.SetMaxIdleConns(10)
|
|
||||||
conn.SetConnMaxIdleTime(90 * time.Second)
|
|
||||||
conn.SetConnMaxLifetime(0)
|
|
||||||
if err := conn.Ping(); err != nil {
|
if err := conn.Ping(); err != nil {
|
||||||
_ = conn.Close()
|
_ = conn.Close()
|
||||||
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
return nil, fmt.Errorf("connect to %s: %w", name, err)
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type Status struct {
|
|||||||
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
|
State string `json:"state,omitempty"` // IDLE / TRANSMIT_A …
|
||||||
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
FanMode string `json:"fan_mode,omitempty"` // STANDARD / CONTEST / BROADCAST
|
||||||
Temperature float64 `json:"temperature"`
|
Temperature float64 `json:"temperature"`
|
||||||
|
Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
|
||||||
}
|
}
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
@@ -119,8 +120,15 @@ func (c *Client) SetOperate(on bool) error {
|
|||||||
if on {
|
if on {
|
||||||
v = "1"
|
v = "1"
|
||||||
}
|
}
|
||||||
_, err := c.command("operate=" + v)
|
if _, err := c.command("operate=" + v); err != nil {
|
||||||
return err
|
return err
|
||||||
|
}
|
||||||
|
// Optimistic: the status poll's "operate" field (when the firmware reports
|
||||||
|
// one) confirms or corrects this.
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.status.Operate = on
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) pollLoop() {
|
func (c *Client) pollLoop() {
|
||||||
@@ -223,6 +231,8 @@ func (c *Client) parse(resp string) {
|
|||||||
switch kv[0] {
|
switch kv[0] {
|
||||||
case "state":
|
case "state":
|
||||||
c.status.State = kv[1]
|
c.status.State = kv[1]
|
||||||
|
case "operate":
|
||||||
|
c.status.Operate = kv[1] == "1"
|
||||||
case "fanmode":
|
case "fanmode":
|
||||||
dev := strings.ToUpper(kv[1])
|
dev := strings.ToUpper(kv[1])
|
||||||
// Honour a recent optimistic change until the amp confirms it.
|
// Honour a recent optimistic change until the amp confirms it.
|
||||||
|
|||||||
+14
-16
@@ -1954,23 +1954,21 @@ func (r *Repo) Count(ctx context.Context) (int64, error) {
|
|||||||
// operator who just worked someone before (re)starting OpsLog shows online right
|
// operator who just worked someone before (re)starting OpsLog shows online right
|
||||||
// away instead of waiting for their next QSO. Empty operator matches every QSO.
|
// away instead of waiting for their next QSO. Empty operator matches every QSO.
|
||||||
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
|
func (r *Repo) LastQSOTime(ctx context.Context, operator string) (time.Time, bool) {
|
||||||
rows, err := r.db.QueryContext(ctx, `SELECT operator, qso_date FROM qso ORDER BY id DESC LIMIT 400`)
|
// ONE indexed row, not 400 filtered in Go: this runs every ~5 s (the ON-AIR
|
||||||
if err != nil {
|
// badge) and ~15 s (live-status publish), so pulling 400 rows each time over a
|
||||||
return time.Time{}, false
|
// remote MySQL hammered the connection pool and, on a busy multi-op event, was a
|
||||||
}
|
// big part of "after a while nothing updates". Match the operator the same
|
||||||
defer rows.Close()
|
// (case/space-insensitive) way, newest first.
|
||||||
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
opFilter := strings.ToUpper(strings.TrimSpace(operator))
|
||||||
for rows.Next() {
|
var dateStr sql.NullString
|
||||||
var oper, dateStr sql.NullString
|
err := r.db.QueryRowContext(ctx,
|
||||||
if err := rows.Scan(&oper, &dateStr); err != nil {
|
`SELECT qso_date FROM qso WHERE UPPER(TRIM(operator)) = ? AND qso_date IS NOT NULL AND qso_date <> '' ORDER BY id DESC LIMIT 1`,
|
||||||
return time.Time{}, false
|
opFilter).Scan(&dateStr)
|
||||||
}
|
if err != nil {
|
||||||
if strings.ToUpper(strings.TrimSpace(oper.String)) != opFilter {
|
return time.Time{}, false // ErrNoRows or a real error — no known last QSO
|
||||||
continue
|
}
|
||||||
}
|
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
|
||||||
if t := parseTimeLoose(dateStr.String).UTC(); !t.IsZero() {
|
return t, true
|
||||||
return t, true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return time.Time{}, false
|
return time.Time{}, false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -392,8 +392,13 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
if st := strings.ToUpper(strings.TrimSpace(station.String)); st != "" {
|
||||||
stationC[st]++
|
stationC[st]++
|
||||||
}
|
}
|
||||||
|
// Bucket a blank/unknown continent under "Unknown" rather than dropping it,
|
||||||
|
// so the continent breakdown totals the same as the QSO count (a handful of
|
||||||
|
// QSOs with no resolved continent made the donut read a few short).
|
||||||
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
|
if c := strings.ToUpper(strings.TrimSpace(cont.String)); c != "" {
|
||||||
contC[c]++
|
contC[c]++
|
||||||
|
} else {
|
||||||
|
contC["Unknown"]++
|
||||||
}
|
}
|
||||||
if c := strings.TrimSpace(country.String); c != "" {
|
if c := strings.TrimSpace(country.String); c != "" {
|
||||||
entityC[c]++
|
entityC[c]++
|
||||||
@@ -436,6 +441,9 @@ func (r *Repo) Stats(ctx context.Context, from, to time.Time, contestID string,
|
|||||||
s.UniqueCalls = len(calls)
|
s.UniqueCalls = len(calls)
|
||||||
s.Entities = len(entities)
|
s.Entities = len(entities)
|
||||||
s.Continents = len(contC)
|
s.Continents = len(contC)
|
||||||
|
if _, ok := contC["Unknown"]; ok {
|
||||||
|
s.Continents-- // "Unknown" is a catch-all bucket, not a real continent
|
||||||
|
}
|
||||||
if !first.IsZero() {
|
if !first.IsZero() {
|
||||||
s.FirstQSO = first.UTC().Format(time.RFC3339)
|
s.FirstQSO = first.UTC().Format(time.RFC3339)
|
||||||
s.LastQSO = last.UTC().Format(time.RFC3339)
|
s.LastQSO = last.UTC().Format(time.RFC3339)
|
||||||
|
|||||||
@@ -0,0 +1,143 @@
|
|||||||
|
// Package gs232 drives rotator controllers that speak the Yaesu GS-232A
|
||||||
|
// protocol, over a raw TCP socket or a serial COM port. The target device is
|
||||||
|
// the microHAM ARCO: both its LAN "CONTROL PROTOCOL" setting (a TCP port) and
|
||||||
|
// its USB port ("USB CONTROL PROTOCOL", a virtual COM where the baud rate is
|
||||||
|
// irrelevant) can be set to speak Yaesu GS-232A — so OpsLog controls it
|
||||||
|
// directly, no PstRotator in between. ARCO accepts up to four parallel LAN
|
||||||
|
// connections, and commands are single CR-terminated lines, so short
|
||||||
|
// per-call connections (same idiom as the other rotator backends) work fine.
|
||||||
|
//
|
||||||
|
// GS-232A subset used:
|
||||||
|
//
|
||||||
|
// Maaa<CR> move to azimuth aaa (000-450)
|
||||||
|
// S<CR> stop rotation
|
||||||
|
// C<CR> query azimuth — replies "+0aaa" (GS-232A) or "AZ=aaa" (GS-232B
|
||||||
|
// flavour); both are parsed.
|
||||||
|
package gs232
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
dialTimeout = 3 * time.Second
|
||||||
|
ioTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client is a stateless per-call sender, mirroring the pst/rotgenius idiom.
|
||||||
|
// Exactly one of (Host, Port) or ComPort is used, per Transport.
|
||||||
|
type Client struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
ComPort string // serial transport: "COM5" etc. (ARCO USB virtual COM — any baud)
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a TCP Client with sane defaults applied for empty fields. There
|
||||||
|
// is no standard port: the number is whatever the user typed into the ARCO's
|
||||||
|
// LAN CONTROL PROTOCOL setting — 4001 is only a placeholder.
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
if host == "" {
|
||||||
|
host = "127.0.0.1"
|
||||||
|
}
|
||||||
|
if port <= 0 || port > 65535 {
|
||||||
|
port = 4001
|
||||||
|
}
|
||||||
|
return &Client{Host: host, Port: port}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSerial returns a Client talking over the ARCO's USB virtual COM port. The
|
||||||
|
// baud rate is irrelevant on USB per the ARCO manual (8N1 framing matters); we
|
||||||
|
// open at 9600 which also suits a real RS-232 hookup left at its default.
|
||||||
|
func NewSerial(comPort string) *Client {
|
||||||
|
return &Client{ComPort: comPort}
|
||||||
|
}
|
||||||
|
|
||||||
|
// roundTrip opens a connection (TCP or serial per the client's config), sends
|
||||||
|
// one CR-terminated command and (when wantReply) reads one CR/LF-terminated
|
||||||
|
// reply line.
|
||||||
|
func (c *Client) roundTrip(cmd string, wantReply bool) (string, error) {
|
||||||
|
var conn io.ReadWriteCloser
|
||||||
|
if c.ComPort != "" {
|
||||||
|
sp, err := serial.Open(c.ComPort, &serial.Mode{BaudRate: 9600})
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("open ARCO %s: %w", c.ComPort, err)
|
||||||
|
}
|
||||||
|
_ = sp.SetReadTimeout(200 * time.Millisecond)
|
||||||
|
conn = sp
|
||||||
|
} else {
|
||||||
|
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.Host, strconv.Itoa(c.Port)), dialTimeout)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("connect ARCO %s:%d: %w", c.Host, c.Port, err)
|
||||||
|
}
|
||||||
|
_ = nc.SetDeadline(time.Now().Add(ioTimeout))
|
||||||
|
conn = nc
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if _, err := conn.Write([]byte(cmd + "\r")); err != nil {
|
||||||
|
return "", fmt.Errorf("send %q: %w", cmd, err)
|
||||||
|
}
|
||||||
|
if !wantReply {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
buf := make([]byte, 64)
|
||||||
|
var sb strings.Builder
|
||||||
|
deadline := time.Now().Add(ioTimeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
n, err := conn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
sb.Write(buf[:n])
|
||||||
|
if strings.ContainsAny(sb.String(), "\r\n") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A serial read that times out returns (0, nil) — keep polling until the
|
||||||
|
// overall deadline; a real error ends the read.
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
line := strings.TrimSpace(sb.String())
|
||||||
|
if line == "" {
|
||||||
|
return "", fmt.Errorf("no reply to %q", cmd)
|
||||||
|
}
|
||||||
|
return line, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoTo points the antenna at the given azimuth (0-359). GS-232A takes M000-M450
|
||||||
|
// (overlap rotators accept >360); we normalise to [0,360).
|
||||||
|
func (c *Client) GoTo(az int) error {
|
||||||
|
az = ((az % 360) + 360) % 360
|
||||||
|
_, err := c.roundTrip(fmt.Sprintf("M%03d", az), false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop interrupts any in-progress rotation.
|
||||||
|
func (c *Client) Stop() error {
|
||||||
|
_, err := c.roundTrip("S", false)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// azRe matches both reply flavours: "+0aaa" (GS-232A) and "AZ=aaa" (GS-232B).
|
||||||
|
var azRe = regexp.MustCompile(`(?:\+0|AZ=)(\d{3})`)
|
||||||
|
|
||||||
|
// Heading queries the current azimuth. Returns the raw reply for diagnostics.
|
||||||
|
func (c *Client) Heading() (az int, raw string, err error) {
|
||||||
|
raw, err = c.roundTrip("C", true)
|
||||||
|
if err != nil {
|
||||||
|
return 0, raw, err
|
||||||
|
}
|
||||||
|
m := azRe.FindStringSubmatch(raw)
|
||||||
|
if m == nil {
|
||||||
|
return 0, raw, fmt.Errorf("unrecognised azimuth reply %q", raw)
|
||||||
|
}
|
||||||
|
az, _ = strconv.Atoi(m[1])
|
||||||
|
return az % 360, raw, nil
|
||||||
|
}
|
||||||
+26
-39
@@ -2,7 +2,6 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -30,8 +29,6 @@ func (a *App) emitLiveStatusChanged() {
|
|||||||
// to the DB — it is not a web server. Rows older than a couple of minutes are
|
// to the DB — it is not a web server. Rows older than a couple of minutes are
|
||||||
// "stale" (operator went offline); the web side ignores them.
|
// "stale" (operator went offline); the web side ignores them.
|
||||||
|
|
||||||
const keyLiveStatusEnabled = "livestatus.enabled"
|
|
||||||
|
|
||||||
// liveOnlineWindow is how long after the last logged contact an operator still
|
// liveOnlineWindow is how long after the last logged contact an operator still
|
||||||
// counts as "on air". Leaving the log open without working anyone flips them
|
// counts as "on air". Leaving the log open without working anyone flips them
|
||||||
// offline once this elapses; logging a new QSO flips them back online.
|
// offline once this elapses; logging a new QSO flips them back online.
|
||||||
@@ -49,37 +46,6 @@ func (a *App) noteLiveQSO() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLiveStatusEnabled reports whether this operator publishes live status.
|
|
||||||
func (a *App) GetLiveStatusEnabled() bool {
|
|
||||||
if a.settings == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
v, _ := a.settings.Get(a.ctx, keyLiveStatusEnabled)
|
|
||||||
return strings.TrimSpace(v) == "1"
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetLiveStatusEnabled turns live-status publishing on or off (off also removes
|
|
||||||
// this operator's row immediately).
|
|
||||||
func (a *App) SetLiveStatusEnabled(on bool) error {
|
|
||||||
if a.settings == nil {
|
|
||||||
return fmt.Errorf("db not initialized")
|
|
||||||
}
|
|
||||||
val := "0"
|
|
||||||
if on {
|
|
||||||
val = "1"
|
|
||||||
}
|
|
||||||
if err := a.settings.Set(a.ctx, keyLiveStatusEnabled, val); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if on {
|
|
||||||
applog.Printf("livestatus: enabled (logbook backend=%q, mysql conn=%v)", a.dbBackend, a.logDb != nil)
|
|
||||||
go a.publishLiveStatus() // show up right away
|
|
||||||
} else {
|
|
||||||
a.clearLiveStatus()
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
|
// seedLiveLastQSO primes liveLastQSOAt from the DB at launch, so an operator who
|
||||||
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
|
// worked someone shortly before (re)starting OpsLog is shown "on air" right away
|
||||||
// instead of offline until their next QSO.
|
// instead of offline until their next QSO.
|
||||||
@@ -143,9 +109,11 @@ func (a *App) liveStatusLoop() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// liveStatusActive reports whether publishing should run (MySQL logbook + on).
|
// liveStatusActive reports whether publishing should run. Always on for a shared
|
||||||
|
// MySQL logbook (no user toggle: going offline is automatic — no QSO for 5 min
|
||||||
|
// removes the row — so there is nothing to opt out of).
|
||||||
func (a *App) liveStatusActive() bool {
|
func (a *App) liveStatusActive() bool {
|
||||||
return a.logDb != nil && a.dbBackend == "mysql" && a.GetLiveStatusEnabled()
|
return a.logDb != nil && a.dbBackend == "mysql"
|
||||||
}
|
}
|
||||||
|
|
||||||
// liveStatusOperator returns this instance's operator id (the operator callsign,
|
// liveStatusOperator returns this instance's operator id (the operator callsign,
|
||||||
@@ -197,9 +165,6 @@ func (a *App) publishLiveStatus() {
|
|||||||
if a.logDb == nil || a.dbBackend != "mysql" {
|
if a.logDb == nil || a.dbBackend != "mysql" {
|
||||||
return // not a MySQL logbook — nothing to do (silent, runs every 15s)
|
return // not a MySQL logbook — nothing to do (silent, runs every 15s)
|
||||||
}
|
}
|
||||||
if !a.GetLiveStatusEnabled() {
|
|
||||||
return // disabled (silent)
|
|
||||||
}
|
|
||||||
op, station := a.liveStatusOperator()
|
op, station := a.liveStatusOperator()
|
||||||
if op == "" {
|
if op == "" {
|
||||||
applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)")
|
applog.Printf("livestatus: nothing published — no operator/callsign set (Settings → Station)")
|
||||||
@@ -316,7 +281,29 @@ func (a *App) GetLiveStations() []LiveStation {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ensureLiveStatusTable creates/upgrades the live_status table ONCE per logbook
|
||||||
|
// connection. It used to run the CREATE + 3 ALTERs on every call — and it is
|
||||||
|
// called from every publish (15s heartbeat) AND every GetLiveStations poll (5s),
|
||||||
|
// so that was 4 wasted round-trips per call on a remote MySQL, adding latency to
|
||||||
|
// the "who's on air" widget and holding pool connections for nothing.
|
||||||
func (a *App) ensureLiveStatusTable() error {
|
func (a *App) ensureLiveStatusTable() error {
|
||||||
|
db := a.logDb
|
||||||
|
a.liveTableMu.Lock()
|
||||||
|
done := a.liveTableFor == db // re-ensure after a profile switch swaps the logbook
|
||||||
|
a.liveTableMu.Unlock()
|
||||||
|
if done {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := a.ensureLiveStatusTableDDL(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.liveTableMu.Lock()
|
||||||
|
a.liveTableFor = db
|
||||||
|
a.liveTableMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) ensureLiveStatusTableDDL() error {
|
||||||
if _, err := a.logDb.ExecContext(a.ctx,
|
if _, err := a.logDb.ExecContext(a.ctx,
|
||||||
"CREATE TABLE IF NOT EXISTS live_status ("+
|
"CREATE TABLE IF NOT EXISTS live_status ("+
|
||||||
"operator VARCHAR(32) PRIMARY KEY, "+
|
"operator VARCHAR(32) PRIMARY KEY, "+
|
||||||
|
|||||||
+1
-1
@@ -21,7 +21,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// appVersion is stamped on every heartbeat (and could feed the About box).
|
// appVersion is stamped on every heartbeat (and could feed the About box).
|
||||||
appVersion = "0.20.6"
|
appVersion = "0.20.8"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user