Compare commits

...
12 Commits
Author SHA1 Message Date
rouggy 72f692aa04 chore: release v0.23.0 2026-08-02 20:03:16 +02:00
rouggy f6cd6e999a chore: release v0.22.9 2026-08-02 06:40:10 +02:00
rouggy 296a4a55c0 refactor(cat): one DTR/RTS setting per backend, none shared
The 0.22.7/0.22.8 breakage came from a Xiegu-reported fault being fixed
inside the Yaesu and Kenwood backends. The lesson is structural: nothing
a backend does may be steered by another backend's report or another
backend's setting.

The lower-lines checkbox introduced for 0.22.9 was still one shared key
applied to both Yaesu and Kenwood — the same reflex in miniature. It is
now cat.yaesu.low_dtr_rts and cat.kenwood.low_dtr_rts, each checkbox in
its backend's own settings block, each wired only to its backend. The
shared key never shipped, so nothing needs migrating.
2026-08-01 20:35:57 +02:00
rouggy acf73c73c7 fix(panels): the wheel moved whatever slider the pointer was over
The rig-panel sliders take a non-passive wheel listener and acted on
hover, so scrolling the panel fed the gesture to whichever control sat
under the cursor — RF power included, mid-QSO. An operator scrolling to
read the bottom of the panel could change his transmit power without
touching anything.

They now require focus, i.e. a deliberate click on that slider first.
Without it the event is left alone and the panel scrolls, which is what
the operator meant. Same fix on the Flex and Yaesu panels, which share
the pattern.

This is a fault in its own right. It is NOT an explanation for the
"transmit power moves since 0.22.8" report: that release changed nothing
in the Icom path, and this behaviour is older than it.
2026-08-01 20:00:03 +02:00
rouggy 5777c119cb feat(cat): make the DTR/RTS lowering a setting
Both defaults break someone. 0.22.7 lowered the lines on Yaesu and
Kenwood and silenced a TS-990 whose interface needs RTS raised to
transmit; 0.22.8 stopped lowering them and an FT8 station reported its
output power wandering, cured by closing OpsLog — Windows raises both on
open and the interface reads one as PTT.

So it is now a checkbox on those two backends, off by default: that is
how they behaved before any of this. Xiegu keeps lowering them
unconditionally (its own report, its own explicit keying setting), and
the CI-V backend keeps the behaviour it has always had.
2026-08-01 19:25:04 +02:00
rouggy 85bf0da006 fix(awards): the mode filter only hid rows, it did not filter the award
Selecting CW showed FT8 and SSB contacts. The filter ran client-side over
the reference list and nothing else, so a reference kept because it had
one CW contact still displayed the band cells it earned on SSB, still
counted its SSB confirmations, and opening it listed every contact
regardless of mode.

The class now narrows the log BEFORE the engine runs, so the bands, the
totals and the confirmations all describe the selected mode, and
AwardCellQSOs takes the same class. The panel cache is keyed by
"CODE|MODECLASS" — one key per award showed the previous mode's numbers
after switching.

The snapshot is cached by log revision, so this costs a matching pass and
no database read.
2026-08-01 16:10:22 +02:00
rouggy b6ea07e3a3 chore(changelog): open the 0.22.9 block 2026-08-01 13:20:16 +02:00
rouggy 549adc8c0e chore: release v0.22.8 2026-08-01 13:19:49 +02:00
rouggy 0e1e7d9f3c feat(cat): ID 022 is a TS-990S
Reported by one in the field. Kenwood's documentation gives 024 for that
radio; both are mapped rather than betting on which is right, and the log
no longer reads "Kenwood (022)".
2026-08-01 12:09:57 +02:00
rouggy 3dd428d748 fix(cat): stop deasserting DTR/RTS on Kenwood and Yaesu
d4bfd30 fixed a Xiegu G90 behind a DE-19 that keyed on connect, and I
applied the same line-lowering to Kenwood and Yaesu with no evidence that
either needed it. It silenced them: many USB-serial interfaces will not
transmit with RTS low — hardware flow control, or an output stage the
line enables. A TS-990 on COM3 opened cleanly and answered nothing, which
reached us as "CAT stopped working after the update".

Xiegu keeps the behaviour: that is where the fault was reported, and that
backend now has an explicit setting for which line keys the rig. The CI-V
backend keeps its own, which predates all of this.

Kenwood also distinguishes a silent port from one sending data that never
answers, and quotes what arrived — "the rig is not answering" sent an
operator checking the power switch on a radio whose frames were visibly
on the wire.
2026-08-01 12:09:11 +02:00
rouggy c62d992ad6 fix(cat): keep the Kenwood read buffer across commands; log the CAT error
A TS-480 connected, answered ID, then never reported a state:

  discarding " 000000000010000000;" while waiting for IF
  connected on COM3 @ 115200 baud
  cat:state → connected=false freq=0

A headless frame tail can only come from bytes that were thrown away.
ask() buffered into a local slice, so everything left after the matched
frame — including the head of a frame still arriving — went with it, and
the link could sit one answer behind its questions. The buffer now lives
on the backend, and Connect drains whatever the rig said before AI0 took
effect rather than inheriting it.

Also: the cat:state diagnostic prints RigState.Error. It read
"connected=false freq=0" and said nothing about why, so every report of a
CAT failure arrived without the one fact that explains it — the reason
only ever reached a tooltip.

New work moves to 0.22.8; 0.22.7 is released.
2026-08-01 12:04:32 +02:00
rouggy a83acb0f9a feat(main): draggable divider between the two panes
The Main tab was a fixed 50/50 grid, but a map and a cluster list want
very different widths and which one deserves the room changes with what
the operator is doing.

The share is clamped to 15..85: a pane can be made small but never
dragged out of existence, because recovering from that would mean
grabbing a divider no longer on screen. Double-click restores even.

The drag uses pointer capture on the divider — without it Leaflet
swallows the moves as soon as the cursor crosses the map. Persisted
through writeUiPref, so it travels with data/ like the other UI prefs.
2026-08-01 11:11:32 +02:00
48 changed files with 2122 additions and 462 deletions
+121 -43
View File
@@ -120,16 +120,22 @@ const (
keyCATKenwoodHost = "cat.kenwood.host" // Kenwood CAT over a network serial bridge (ser2net), "host:port" keyCATKenwoodHost = "cat.kenwood.host" // Kenwood CAT over a network serial bridge (ser2net), "host:port"
keyCATKenwoodPort = "cat.kenwood.port" // Kenwood CAT serial port (TS-590/890/2000, Elecraft) keyCATKenwoodPort = "cat.kenwood.port" // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
keyCATKenwoodBaud = "cat.kenwood.baud" // Kenwood CAT baud (TS-590 default 9600, TS-890 115200) keyCATKenwoodBaud = "cat.kenwood.baud" // Kenwood CAT baud (TS-590 default 9600, TS-890 115200)
keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) // One key PER BACKEND, deliberately not shared. A Xiegu fix that reached
keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200) // into the Yaesu and Kenwood backends is what broke them in 0.22.7/0.22.8;
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98) // the rule since is that nothing a backend does may be steered by another
keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname // backend's setting.
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID keyCATYaesuLowLines = "cat.yaesu.low_dtr_rts" // deassert DTR/RTS on connect
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password keyCATKenwoodLowLines = "cat.kenwood.low_dtr_rts" // deassert DTR/RTS on connect
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental) keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5)
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2) keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001) keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama keyCATIcomNetHost = "cat.icom.net.host" // Icom network remote: rig IP/hostname
keyCATIcomNetUser = "cat.icom.net.user" // Icom network: Network User1 ID
keyCATIcomNetPass = "cat.icom.net.pass" // Icom network: Network User1 password
keyCATIcomNetAudio = "cat.icom.net.audio" // Icom network: stream RX audio on 50003 (experimental)
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so // Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
// global (not per-profile) like CAT/rotator. Device fields store the // global (not per-profile) like CAT/rotator. Device fields store the
@@ -376,23 +382,28 @@ type CATSettings struct {
YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400) YaesuBaud int `json:"yaesu_baud"` // Yaesu CAT baud (FTDX10/101 default 38400)
KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial KenwoodHost string `json:"kenwood_host"` // "host:port" of a serial-over-network bridge (ser2net, Ethernet-serial
// adapter). NOT the radios own RJ45, which speaks Kenwoods KNS/ARCP. // adapter). NOT the radios own RJ45, which speaks Kenwoods KNS/ARCP.
KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft) KenwoodPort string `json:"kenwood_port"` // Kenwood CAT serial port (TS-590/890/2000, Elecraft)
KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600) KenwoodBaud int `json:"kenwood_baud"` // Kenwood CAT baud (TS-590 default 9600)
IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) // Per-backend: deassert DTR and RTS after opening the CAT port, for
IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) // interfaces that read either line as PTT. Off by default: lowering them
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) // stops some USB-serial interfaces transmitting at all.
IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server) YaesuLowLines bool `json:"yaesu_low_lines"`
IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID KenwoodLowLines bool `json:"kenwood_low_lines"`
IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5)
IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200)
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR) IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001) IcomNetHost string `json:"icom_net_host"` // Icom network remote: rig IP/hostname (built-in LAN server)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama IcomNetUser string `json:"icom_net_user"` // Icom network Network User1 ID
PollMs int `json:"poll_ms"` // poll interval in ms (default 250) IcomNetPass string `json:"icom_net_pass"` // Icom network Network User1 password
DelayMs int `json:"delay_ms"` // pause between commands (default 0) IcomNetAudio bool `json:"icom_net_audio"` // Icom network: stream RX audio (50003) — experimental, needs on-rig verification
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl) TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
SharePort int `json:"share_port"` // TCP port for it (default 4532) TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…)
ShareEnabled bool `json:"share_enabled"` // serve CAT to other programs (Hamlib NET rigctl)
SharePort int `json:"share_port"` // TCP port for it (default 4532)
} }
// ModePreset is a mode entry with default RST values to auto-populate // ModePreset is a mode entry with default RST values to auto-populate
@@ -971,8 +982,18 @@ func (a *App) startup(ctx context.Context) {
// burst of these lines = the frontend is being re-rendered rapidly (the // burst of these lines = the frontend is being re-rendered rapidly (the
// "screen flickers" symptom). Shows WHAT is churning — connection flap, // "screen flickers" symptom). Shows WHAT is churning — connection flap,
// or freq/split/mode oscillating between slices during FT8. // or freq/split/mode oscillating between slices during FT8.
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s", // The error goes in the line too. Without it a disconnect read
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band) // "connected=false freq=0" and said nothing about WHY — the reason was in
// RigState.Error, which only ever reached a tooltip, so every report of
// "it stopped connecting" arrived without the one fact that explains it.
applog.Printf("cat:state → connected=%v freq=%d rx=%d split=%v mode=%s band=%s%s",
s.Connected, s.FreqHz, s.RxFreqHz, s.Split, s.Mode, s.Band,
func() string {
if s.Error == "" {
return ""
}
return " err=" + s.Error
}())
if a.ctx != nil { if a.ctx != nil {
wruntime.EventsEmit(a.ctx, "cat:state", s) wruntime.EventsEmit(a.ctx, "cat:state", s)
} }
@@ -3504,10 +3525,17 @@ func (a *App) GetAwards() ([]award.Result, error) {
// GetAward computes progress for a single award by code (one whole-log scan, // GetAward computes progress for a single award by code (one whole-log scan,
// matching only that award). This is what the awards UI calls when an award is // matching only that award). This is what the awards UI calls when an award is
// selected, so opening the panel doesn't scan every award up front. // selected, so opening the panel doesn't scan every award up front.
func (a *App) GetAward(code string) (award.Result, error) { // GetAward computes one award. modeClass ("CW", "PHONE", "DIGI", or "" for all)
// narrows it to contacts of that class BEFORE the engine runs, so the bands, the
// counts and the confirmations all describe that mode.
//
// Filtering only the reference list was not enough: a reference kept because it
// has one CW contact still showed the band cells it earned on SSB, which is
// precisely what an operator chasing a CW award must not be told.
func (a *App) GetAward(code, modeClass string) (award.Result, error) {
for _, d := range a.awardDefs() { for _, d := range a.awardDefs() {
if strings.EqualFold(d.Code, code) { if strings.EqualFold(d.Code, code) {
results, err := a.computeAwards([]award.Def{d}) results, err := a.computeAwardsForMode([]award.Def{d}, modeClass)
if err != nil { if err != nil {
return award.Result{}, err return award.Result{}, err
} }
@@ -3523,6 +3551,13 @@ func (a *App) GetAward(code string) (award.Result, error) {
// computeAwards runs the engine for the given award definitions over the whole // computeAwards runs the engine for the given award definitions over the whole
// log and enriches dynamic awards (totals + worked-reference names). // log and enriches dynamic awards (totals + worked-reference names).
func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) { func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) {
return a.computeAwardsForMode(defs, "")
}
// computeAwardsForMode is computeAwards restricted to one mode class. The
// snapshot is shared and cached by log revision, so this costs a matching pass,
// not a database read.
func (a *App) computeAwardsForMode(defs []award.Def, modeClass string) ([]award.Result, error) {
if a.qso == nil { if a.qso == nil {
return nil, fmt.Errorf("db not initialized") return nil, fmt.Errorf("db not initialized")
} }
@@ -3530,6 +3565,15 @@ func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if want := strings.ToUpper(strings.TrimSpace(modeClass)); want != "" && want != "ALL" {
kept := make([]qso.QSO, 0, len(all))
for _, q := range all {
if award.ModeClass(q.Mode) == want {
kept = append(kept, q)
}
}
all = kept
}
nameOf := func(field, ref string) string { nameOf := func(field, ref string) string {
switch field { switch field {
case "dxcc": case "dxcc":
@@ -3580,7 +3624,11 @@ func (a *App) computeAwards(defs []award.Def) ([]award.Result, error) {
// AwardCellQSOs returns the QSOs that contribute to one award reference, // AwardCellQSOs returns the QSOs that contribute to one award reference,
// optionally on a single band (band="" = all bands). Powers the award-grid // optionally on a single band (band="" = all bands). Powers the award-grid
// cell drill-down ("show me every Canada contact on 20m"). // cell drill-down ("show me every Canada contact on 20m").
func (a *App) AwardCellQSOs(code, ref, band string) ([]qso.QSO, error) { // AwardCellQSOs lists the contacts behind one cell. modeClass ("CW", "PHONE",
// "DIGI", or "" for all) applies the SAME filter as the reference list above it
// — without it, filtering the list to CW and then opening a reference showed the
// FT8 and SSB contacts that filter exists to hide.
func (a *App) AwardCellQSOs(code, ref, band, modeClass string) ([]qso.QSO, error) {
if a.qso == nil { if a.qso == nil {
return nil, fmt.Errorf("db not initialized") return nil, fmt.Errorf("db not initialized")
} }
@@ -3598,12 +3646,16 @@ func (a *App) AwardCellQSOs(code, ref, band string) ([]qso.QSO, error) {
metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)] metas := a.awardRefMetas([]award.Def{*def})[strings.ToUpper(def.Code)]
wantRef := strings.ToUpper(strings.TrimSpace(ref)) wantRef := strings.ToUpper(strings.TrimSpace(ref))
wantBand := strings.ToLower(strings.TrimSpace(band)) wantBand := strings.ToLower(strings.TrimSpace(band))
wantMode := strings.ToUpper(strings.TrimSpace(modeClass))
var out []qso.QSO var out []qso.QSO
err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error { err := a.qso.IterateAll(a.ctx, func(q qso.QSO) error {
if wantBand != "" && strings.ToLower(strings.TrimSpace(q.Band)) != wantBand { if wantBand != "" && strings.ToLower(strings.TrimSpace(q.Band)) != wantBand {
return nil return nil
} }
if wantMode != "" && wantMode != "ALL" && award.ModeClass(q.Mode) != wantMode {
return nil
}
a.enrichQSOForAwards(&q) a.enrichQSOForAwards(&q)
for _, c := range award.MatchQSO(*def, metas, &q) { for _, c := range award.MatchQSO(*def, metas, &q) {
if strings.ToUpper(c) == wantRef { if strings.ToUpper(c) == wantRef {
@@ -6778,7 +6830,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
if a.settings == nil { if a.settings == nil {
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
} }
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort) m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATOmniRigVFO, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATFlexDecodeSpots, keyCATFlexDecodeSecs, keyCATXieguPort, keyCATXieguBaud, keyCATXieguAddr, keyCATXieguPTTLine, keyCATYaesuPort, keyCATYaesuBaud, keyCATKenwoodPort, keyCATKenwoodBaud, keyCATKenwoodHost, keyCATYaesuLowLines, keyCATKenwoodLowLines, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATIcomNetHost, keyCATIcomNetUser, keyCATIcomNetPass, keyCATIcomNetAudio, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault, keyCATShareEnabled, keyCATSharePort)
if err != nil { if err != nil {
return CATSettings{}, err return CATSettings{}, err
} }
@@ -6799,6 +6851,8 @@ func (a *App) GetCATSettings() (CATSettings, error) {
KenwoodPort: m[keyCATKenwoodPort], KenwoodPort: m[keyCATKenwoodPort],
KenwoodHost: m[keyCATKenwoodHost], KenwoodHost: m[keyCATKenwoodHost],
KenwoodBaud: 9600, KenwoodBaud: 9600,
YaesuLowLines: m[keyCATYaesuLowLines] == "1",
KenwoodLowLines: m[keyCATKenwoodLowLines] == "1",
IcomPort: m[keyCATIcomPort], IcomPort: m[keyCATIcomPort],
IcomBaud: 115200, IcomBaud: 115200,
IcomAddr: 0x98, // IC-7610 default IcomAddr: 0x98, // IC-7610 default
@@ -6934,6 +6988,12 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.DigitalDefault == "" { if s.DigitalDefault == "" {
s.DigitalDefault = "FT8" s.DigitalDefault = "FT8"
} }
b01 := func(v bool) string {
if v {
return "1"
}
return "0"
}
shareEnabled := "0" shareEnabled := "0"
if s.ShareEnabled { if s.ShareEnabled {
shareEnabled = "1" shareEnabled = "1"
@@ -6957,6 +7017,8 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort), keyCATKenwoodPort: strings.TrimSpace(s.KenwoodPort),
keyCATKenwoodHost: strings.TrimSpace(s.KenwoodHost), keyCATKenwoodHost: strings.TrimSpace(s.KenwoodHost),
keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud), keyCATKenwoodBaud: strconv.Itoa(s.KenwoodBaud),
keyCATYaesuLowLines: b01(s.YaesuLowLines),
keyCATKenwoodLowLines: b01(s.KenwoodLowLines),
keyCATIcomPort: strings.TrimSpace(s.IcomPort), keyCATIcomPort: strings.TrimSpace(s.IcomPort),
keyCATIcomBaud: strconv.Itoa(s.IcomBaud), keyCATIcomBaud: strconv.Itoa(s.IcomBaud),
keyCATIcomAddr: strconv.Itoa(s.IcomAddr), keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
@@ -12786,7 +12848,9 @@ func (a *App) reloadCAT() {
// Yaesu fault reported so far came from OmniRig's interpretation layer // Yaesu fault reported so far came from OmniRig's interpretation layer
// (a rig file that hides the VFO, a Freq property meaning A on one model // (a rig file that hides the VFO, a Freq property meaning A on one model
// and B on another); talking to the radio directly removes it. // and B on another); talking to the radio directly removes it.
a.cat.Start(cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault)) yz := cat.NewYaesu(s.YaesuPort, s.YaesuBaud, s.DigitalDefault)
yz.SetLowerLines(s.YaesuLowLines)
a.cat.Start(yz)
case "kenwood": case "kenwood":
// Native Kenwood CAT — TS-590/890/990/2000 and everything that speaks the // Native Kenwood CAT — TS-590/890/990/2000 and everything that speaks the
// same dialect (Elecraft K3/K4, and the "Kenwood" setting on other rigs). // same dialect (Elecraft K3/K4, and the "Kenwood" setting on other rigs).
@@ -12798,7 +12862,9 @@ func (a *App) reloadCAT() {
if h := strings.TrimSpace(s.KenwoodHost); h != "" { if h := strings.TrimSpace(s.KenwoodHost); h != "" {
a.cat.Start(cat.NewKenwoodTCP(h, s.DigitalDefault)) a.cat.Start(cat.NewKenwoodTCP(h, s.DigitalDefault))
} else { } else {
a.cat.Start(cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault)) kw := cat.NewKenwood(s.KenwoodPort, s.KenwoodBaud, s.DigitalDefault)
kw.SetLowerLines(s.KenwoodLowLines)
a.cat.Start(kw)
} }
case "icom": case "icom":
// Native Icom CI-V over the radio's USB serial port (local control). // Native Icom CI-V over the radio's USB serial port (local control).
@@ -13394,12 +13460,12 @@ 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" | "dingtian" | "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; Dingtian HTTP session ID (blank = none)
Pass string `json:"pass,omitempty"` Pass string `json:"pass,omitempty"` // KMTronic HTTP auth; Dingtian relay password (09999)
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16) Channels int `json:"channels,omitempty"` // usbrelay/denkovi/dingtian: number of relays
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1) Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
} }
@@ -13407,10 +13473,13 @@ type StationDevice struct {
// except the Denkovi (4 or 8) and the generic USB-serial board, whose channel // except the Denkovi (4 or 8) and the generic USB-serial board, whose channel
// count the user picks. // count the user picks.
func deviceRelayCount(d StationDevice) int { func deviceRelayCount(d StationDevice) int {
if d.Type == "usbrelay" || d.Type == "denkovi" { if d.Type == "usbrelay" || d.Type == "denkovi" || d.Type == "dingtian" {
if d.Channels >= 1 { if d.Channels >= 1 {
return d.Channels return d.Channels
} }
if d.Type == "dingtian" {
return 2 // the smallest board; the real count comes from its status reply
}
return 8 return 8
} }
return relayCountFor(d.Type) return relayCountFor(d.Type)
@@ -13438,6 +13507,9 @@ func buildDeviceDriver(d StationDevice) relaydev.Device {
switch d.Type { switch d.Type {
case "kmtronic": case "kmtronic":
return relaydev.NewKMTronic(d.Host, d.User, d.Pass) return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
case "dingtian":
// User carries the optional HTTP session ID, Pass the relay password.
return relaydev.NewDingtian(d.Host, d.User, d.Pass, deviceRelayCount(d))
case "denkovi": case "denkovi":
// Host carries the FTDI serial number (e.g. "DAE0006K"), not a hostname. // Host carries the FTDI serial number (e.g. "DAE0006K"), not a hostname.
return relaydev.NewDenkovi(d.Host, deviceRelayCount(d)) return relaydev.NewDenkovi(d.Host, deviceRelayCount(d))
@@ -13541,7 +13613,7 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
} }
for i := range devs { for i := range devs {
switch devs[i].Type { switch devs[i].Type {
case "kmtronic", "denkovi", "usbrelay", "webswitch": case "kmtronic", "dingtian", "denkovi", "usbrelay", "webswitch":
// known type — keep // known type — keep
default: default:
devs[i].Type = "webswitch" devs[i].Type = "webswitch"
@@ -14724,8 +14796,14 @@ func (a *App) AmpOperate(id string, on bool) error {
} }
// AmpPower turns the given amp on/off (SPE and ACOM; the PGXL has no power // AmpPower turns the given amp on/off (SPE and ACOM; the PGXL has no power
// command on its direct link). // command on its direct link). Logged here because the UI buttons swallow the
func (a *App) AmpPower(id string, on bool) error { // returned error — a failed power-on otherwise looks like a dead button.
func (a *App) AmpPower(id string, on bool) (err error) {
defer func() {
if err != nil {
applog.Printf("amp %s: power %v failed: %v", id, on, err)
}
}()
inst := a.ampInstByID(id) inst := a.ampInstByID(id)
if inst == nil { if inst == nil {
return fmt.Errorf("amplifier not running — check Settings → Amplifier") return fmt.Errorf("amplifier not running — check Settings → Amplifier")
+60
View File
@@ -1,4 +1,64 @@
[ [
{
"version": "0.23.0",
"date": "",
"en": [
"Station Control: Dingtian IOT relay boards (2 to 32 relays, LAN or WiFi) can now be added.",
"ACOM amplifiers: the card now shows TUNE while the tuner is running, instead of an unknown state.",
"DX cluster: the status pills are gone. The call, band and mode turn yellow when that one is new, and a callsign already in the log turns blue.",
"Reports: starting OpsLog with the rig already in CW no longer leaves the RST fields on 59 — they now follow the mode you are actually on."
],
"fr": [
"Contrôle station : les cartes relais Dingtian IOT (2 à 32 relais, LAN ou WiFi) peuvent désormais être ajoutées.",
"Amplificateurs ACOM : la carte affiche TUNE pendant l'accord, au lieu d'un état inconnu.",
"Cluster DX : les pastilles de statut disparaissent. L'indicatif, la bande et le mode passent en jaune quand celui-ci est nouveau, et un indicatif déjà dans le log passe en bleu.",
"Reports : lancer OpsLog avec le poste déjà en CW ne laisse plus les RST à 59 — ils suivent maintenant le mode réellement en cours."
]
},
{
"version": "0.22.9",
"date": "",
"en": [
"Awards: the CW / Phone / Digital filter now applies to the whole award — bands, counts, confirmations and the contacts behind a cell — instead of only hiding rows from the reference list.",
"Yaesu and Kenwood: a new option lowers the DTR and RTS lines on connect, for interfaces that read either as PTT and keep the radio transmitting while OpsLog is open. Off by default — lowering them stops other interfaces transmitting at all.",
"SPE amplifiers: the ON and OFF buttons now switch the amplifier on and off over USB.",
"Quieter diagnostic log: no more FlexRadio meter subscriptions, per-packet WSJT-X/MSHV lines, or repeated Tuner Genius / PGXL / SPE status frames.",
"Station Control: the cards now line up on a grid — same width, and same height across a row — and the column selector goes up to 6.",
"New amplifier widget beside the keyers (flame icon): Operate, power ON/OFF, the SPE power level and live watts / SWR / temperature. With several amplifiers a dropdown on the icon picks the one to watch, or all of them.",
"Icom: the meters and front-panel readings are polled only while the Icom console is on screen. They were half of the CI-V traffic at all times, which matters when the link is shared — a microHAM Router splitting one CI-V port between OpsLog and WSJT-X, for instance.",
"Icom: the power-ON button sends a wake preamble sized for the CI-V speed. It was a fixed length that only suited 19200 baud, so a radio set faster — 115200 is the default — ignored the command.",
"The Tuner Genius power meter now shows the peak the device itself measured, instead of whatever instant the poll happened to catch — a kilowatt transmission could read a few hundred watts. Every power meter also clears the moment the carrier drops, instead of trailing several seconds behind it."
],
"fr": [
"Diplômes : le filtre CW / Phonie / Numérique sapplique désormais à tout le diplôme — bandes, comptes, confirmations et QSO derrière une case — au lieu de masquer seulement des lignes de la liste des références.",
"Yaesu et Kenwood : une option abaisse les lignes DTR et RTS à la connexion, pour les interfaces qui les lisent comme PTT et laissent la radio en émission tant quOpsLog est ouvert. Désactivée par défaut — les abaisser empêche dautres interfaces d’émettre.",
"Amplificateurs SPE : les boutons ON et OFF allument et éteignent désormais lamplificateur par lUSB.",
"Journal de diagnostic allégé : plus dabonnements aux mètres FlexRadio, de ligne par paquet WSJT-X/MSHV, ni de trames statut Tuner Genius / PGXL / SPE répétées.",
"Contrôle station : les cartes salignent désormais sur une grille — même largeur, et même hauteur sur une ligne — et le sélecteur de colonnes va jusqu’à 6.",
"Nouveau widget amplificateur à côté des manipulateurs (icône flamme) : Operate, marche/arrêt, le niveau de puissance SPE et les watts / ROS / température en direct. Avec plusieurs amplis, une liste déroulante sur licône choisit celui à surveiller, ou tous.",
"Icom : les mesures et les réglages de façade ne sont interrogés que lorsque la console Icom est affichée. Ils représentaient la moitié du trafic CI-V en permanence, ce qui compte quand la liaison est partagée — un Router microHAM qui répartit un port CI-V entre OpsLog et WSJT-X, par exemple.",
"Icom : le bouton de mise sous tension envoie un préambule de réveil dimensionné pour la vitesse CI-V. Sa longueur était fixe et ne convenait qu’à 19200 bauds, si bien quune radio réglée plus vite — 115200 par défaut — ignorait la commande.",
"Le mètre de puissance du Tuner Genius affiche désormais la crête mesurée par lappareil lui-même, au lieu de linstant capté par linterrogation — une émission dun kilowatt pouvait afficher quelques centaines de watts. Tous les mètres de puissance retombent aussi dès la fin de l’émission, au lieu de traîner plusieurs secondes."
]
},
{
"version": "0.22.8",
"date": "",
"en": [
"Main tab: drag the divider between the two panes to resize them. The setting is remembered and travels with the data folder; double-click the divider to even them out.",
"Kenwood: bytes arriving glued to the previous answer are no longer discarded, and the link starts from a clean buffer — a rig whose replies ran together could end up one answer behind and never report connected.",
"The CAT log line now carries the error explaining a disconnection.",
"Kenwood and Yaesu CAT connect again: 0.22.7 lowered the DTR and RTS lines on those ports, which stops many USB-serial interfaces from transmitting. Only Xiegu, where the problem was reported, still does it.",
"Kenwood: a port that opens but sends nothing is now reported differently from one sending data that does not answer, and the error quotes what arrived."
],
"fr": [
"Onglet Principal : faites glisser le séparateur entre les deux volets pour les redimensionner. Le réglage est mémorisé et suit le dossier de données ; double-clic pour les égaliser.",
"Kenwood : les octets reçus collés à la réponse précédente ne sont plus jetés, et la liaison démarre sur un tampon vide — une radio dont les réponses senchaînent pouvait rester une réponse en retard et ne jamais apparaître connectée.",
"La ligne de journal CAT indique désormais lerreur expliquant une déconnexion.",
"Le CAT Kenwood et Yaesu se connecte de nouveau : la 0.22.7 abaissait les lignes DTR et RTS sur ces ports, ce qui empêche beaucoup dinterfaces USB-série d’émettre. Seul Xiegu, où le problème avait été signalé, le fait encore.",
"Kenwood : un port qui souvre sans rien émettre est désormais distingué dun port qui émet sans répondre, et lerreur cite ce qui est arrivé."
]
},
{ {
"version": "0.22.7", "version": "0.22.7",
"date": "", "date": "",
+144 -10
View File
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Gauge, Hash, Loader2, Lock, Activity, AlertCircle, Antenna, Bell, Check, CheckCircle2, ChevronDown, Clock, CloudOff, Compass, Database, Ear, Eraser, Flame, Gauge, Hash, Loader2, Lock,
Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap, Maximize2, Minimize2, Mic, MessageSquare, Pencil, Radio, RadioTower, RefreshCw, Satellite, Send, Settings, SlidersHorizontal, SpellCheck, Square, Terminal, Trash2, Unlock, X, Zap,
} from 'lucide-react'; } from 'lucide-react';
@@ -77,6 +77,7 @@ import { IcomPanel } from '@/components/IcomPanel';
import { YaesuPanel } from '@/components/YaesuPanel'; import { YaesuPanel } from '@/components/YaesuPanel';
import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel'; import { TunerGeniusPanel, type TGStatus } from '@/components/TunerGeniusPanel';
import { AmpWidget } from '@/components/AmpWidget';
import { ScpPanel, type ScpResult } from '@/components/ScpPanel'; import { ScpPanel, type ScpResult } from '@/components/ScpPanel';
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
import { AwardsPanel } from '@/components/AwardsPanel'; import { AwardsPanel } from '@/components/AwardsPanel';
@@ -570,6 +571,16 @@ export default function App() {
// PGXL's OPERATE state comes from the radio when it reports the amp. // PGXL's OPERATE state comes from the radio when it reports the amp.
const [ampSts, setAmpSts] = useState<any[]>([]); const [ampSts, setAmpSts] = useState<any[]>([]);
const [flexAmp, setFlexAmp] = useState<any>(null); const [flexAmp, setFlexAmp] = useState<any>(null);
// Amplifier widget: whether it is shown, and which amp — "all", or one amp id.
// Several amps side by side make a wide widget, so an operator running two
// picks the one he watches while transmitting. Declared here because the poll
// below runs faster while the widget is open.
const [showAmpWidget, setShowAmpWidget] = useState(() => localStorage.getItem('opslog.showAmpWidget') !== '0');
const [ampWidgetSel, setAmpWidgetSel] = useState(() => localStorage.getItem('opslog.ampSel.widget') || 'all');
// Poll fast only while the amplifier widget is open: its meters must track TX
// the way the FlexRadio panel's do (400 ms), or the power reads seconds behind
// the transmission and looks like the app is struggling. The status-bar chips
// alone need nothing like that rate.
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const tick = () => Promise.all([ const tick = () => Promise.all([
@@ -577,9 +588,9 @@ export default function App() {
GetFlexState().catch(() => null), GetFlexState().catch(() => null),
]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } }); ]).then(([l, fx]: any[]) => { if (alive) { setAmpSts((l ?? []) as any[]); setFlexAmp(fx); } });
tick(); tick();
const id = window.setInterval(tick, 2000); const id = window.setInterval(tick, showAmpWidget ? 400 : 2000);
return () => { alive = false; window.clearInterval(id); }; return () => { alive = false; window.clearInterval(id); };
}, []); }, [showAmpWidget]);
// 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 };
@@ -871,6 +882,36 @@ export default function App() {
return ALWAYS_TABS.includes(saved) ? saved : 'recent'; return ALWAYS_TABS.includes(saved) ? saved : 'recent';
}); });
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]); useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
// Main tab: how much width the LEFT pane gets, in percent. Clamped to 15..85
// so a pane can be made small but never dragged out of existence — recovering
// from that means finding a divider that is no longer on screen.
const [mainSplit, setMainSplit] = useState<number>(() => {
const n = parseFloat(localStorage.getItem('opslog.mainSplit') || '');
return Number.isFinite(n) && n >= 15 && n <= 85 ? n : 50;
});
useEffect(() => { writeUiPref('opslog.mainSplit', String(Math.round(mainSplit))); }, [mainSplit]);
const mainSplitRef = useRef<HTMLDivElement | null>(null);
const startMainSplitDrag = (e: React.PointerEvent) => {
e.preventDefault();
const host = mainSplitRef.current;
if (!host) return;
// Pointer capture on the divider, so dragging over a map keeps working —
// Leaflet would otherwise swallow the moves the moment the cursor entered it.
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
const onMove = (ev: PointerEvent) => {
const r = host.getBoundingClientRect();
if (r.width <= 0) return;
const pct = ((ev.clientX - r.left) / r.width) * 100;
setMainSplit(Math.min(85, Math.max(15, pct)));
};
const onUp = () => {
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
};
window.addEventListener('pointermove', onMove);
window.addEventListener('pointerup', onUp);
};
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager. // QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
const [qslTabOpen, setQslTabOpen] = useState(false); const [qslTabOpen, setQslTabOpen] = useState(false);
const [qslDesignerOpen, setQslDesignerOpen] = useState(false); const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
@@ -2071,11 +2112,22 @@ export default function App() {
setModePresets(l.modes); setModePresets(l.modes);
const names = l.modes.map((m) => m.name); const names = l.modes.map((m) => m.name);
setModes(names); setModes(names);
setMode((cur) => names.includes(cur) ? cur : names[0]); // Resolve the mode we are ACTUALLY on, then take that mode's report.
const preset = l.modes.find((m) => m.name === mode) ?? l.modes[0]; //
if (preset && !rstUserEditedRef.current) { // This used to read the `mode` state variable, which this callback
if (preset.default_rst_sent) setRstSent(preset.default_rst_sent); // captures once (its dep list is empty) — so it was still the initial
if (preset.default_rst_rcvd) setRstRcvd(preset.default_rst_rcvd); // 'SSB' — and fell back to l.modes[0], SSB again, when the lookup
// missed. An operator who launched OpsLog with the rig already in CW
// and did not touch the VFO got 59/59 and kept it: nothing else runs
// until the CAT state actually changes.
const cur = names.includes(modeRef.current) ? modeRef.current : names[0];
setMode(cur);
if (!rstUserEditedRef.current) {
const lists: RSTLists = { phone: (l as any).rst_phone ?? [], cw: (l as any).rst_cw ?? [], digital: (l as any).rst_digital ?? [] };
const p = l.modes.find((m) => m.name === cur);
const fallback = rstOptions(cur, lists)[0] || '';
setRstSent(p?.default_rst_sent || fallback);
setRstRcvd(p?.default_rst_rcvd || fallback);
} }
} }
} catch {} } catch {}
@@ -4819,6 +4871,60 @@ export default function App() {
{showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />} {showTuner && tgStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />}
</button> </button>
)} )}
{/* Amplifier widget. With one amp the icon is a plain toggle; with
several it also carries a dropdown to choose which one the widget
shows (or All), since two amps stacked make a tall widget. */}
{ampSts.length > 0 && (
<div className="relative inline-flex">
<button
type="button"
onClick={() => { const v = !showAmpWidget; setShowAmpWidget(v); writeUiPref('opslog.showAmpWidget', v ? '1' : '0'); }}
title={showAmpWidget ? t('ampw.hideHint') : t('ampw.showHint')}
className={cn(
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
ampSts.length > 1 && 'rounded-r-none border-r-0',
showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted',
)}
>
<Flame className="size-4" />
{showAmpWidget && ampSts.some((a: any) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flexAmp?.amp_available) && (
<span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-success" />
)}
</button>
{ampSts.length > 1 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button type="button" title={t('ampw.pick')}
className={cn('inline-flex items-center justify-center h-7 w-4 rounded-md rounded-l-none border transition-colors',
showAmpWidget ? 'border-success-border bg-success-muted text-success-muted-foreground hover:bg-success-muted'
: 'border-border text-muted-foreground hover:bg-muted')}>
<ChevronDown className="size-3" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-44">
{[{ id: 'all', name: t('ampw.all') }, ...ampSts.map((a: any) => ({ id: a.id, name: a.name || a.id }))].map((o) => (
<DropdownMenuItem key={o.id}
onSelect={() => {
setAmpWidgetSel(o.id);
writeUiPref('opslog.ampSel.widget', o.id);
// Picking an amp means "show me this one" — opening the
// widget too saves a second click on a hidden one.
if (!showAmpWidget) { setShowAmpWidget(true); writeUiPref('opslog.showAmpWidget', '1'); }
}}>
<span className="flex items-center gap-2 min-w-0">
{ampWidgetSel === o.id
? <Check className="size-3.5 shrink-0 text-primary" />
: <span className="size-3.5 shrink-0" />}
<span className="truncate">{o.name}</span>
</span>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)}
{scpEnabled && ( {scpEnabled && (
<button <button
type="button" type="button"
@@ -5335,7 +5441,7 @@ export default function App() {
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the {/* Reserved free space to the right. The WinKeyer CW keyer and/or the
Digital Voice Keyer take this slot when enabled (Log4OM-style); Digital Voice Keyer take this slot when enabled (Log4OM-style);
otherwise it shows the QRZ profile photo. */} otherwise it shows the QRZ profile photo. */}
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && ( {!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled) || (showTuner && tgEnabled) || (showAmpWidget && ampSts.length > 0) || (showScp && scpEnabled) || (showLiveStations && dbConn?.backend === 'mysql')) && (
// relative + absolute inner (like the F1-F5 panel): a taller widget (e.g. // relative + absolute inner (like the F1-F5 panel): a taller widget (e.g.
// the DVK with Auto CQ) can't grow the row — the row height stays set by // the DVK with Auto CQ) can't grow the row — the row height stays set by
// the entry strip and each widget fills that height, scrolling inside. // the entry strip and each widget fills that height, scrolling inside.
@@ -5422,6 +5528,19 @@ export default function App() {
/> />
</div> </div>
)} )}
{showAmpWidget && ampSts.length > 0 && (
// One column per amplifier shown, so two amps stand side by side
// rather than making the widget twice as tall as the dock row.
<div className="shrink-0 min-h-0"
style={{ width: `${Math.min(ampWidgetSel === 'all' ? ampSts.length : 1, 3) * 250 + 20}px` }}>
<AmpWidget
amps={ampSts}
flex={flexAmp}
sel={ampWidgetSel}
onClose={() => { setShowAmpWidget(false); writeUiPref('opslog.showAmpWidget', '0'); }}
/>
</div>
)}
{showTuner && tgEnabled && ( {showTuner && tgEnabled && (
<div className="w-[230px] shrink-0 min-h-0"> <div className="w-[230px] shrink-0 min-h-0">
<TunerGeniusPanel <TunerGeniusPanel
@@ -6018,8 +6137,23 @@ export default function App() {
{/* Two configurable panes (per-profile, Settings Main view). {/* Two configurable panes (per-profile, Settings Main view).
Each side shows one of: great-circle map, locator map, cluster Each side shows one of: great-circle map, locator map, cluster
or worked-before. */} or worked-before. */}
<div className="grid grid-cols-2 grid-rows-1 gap-2 h-full min-h-0 p-2"> {/* The divider is draggable: a map and a cluster list want very
different widths, and which one deserves the room changes with
what the operator is doing. The share is persisted (portable),
and a double-click puts it back to even. */}
<div ref={mainSplitRef} className="grid grid-rows-1 gap-2 h-full min-h-0 p-2"
style={{ gridTemplateColumns: `${mainSplit}fr 6px ${100 - mainSplit}fr` }}>
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div> <div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneLeft)}</div>
<div
role="separator"
aria-orientation="vertical"
title={t('main.splitTip')}
onPointerDown={startMainSplitDrag}
onDoubleClick={() => setMainSplit(50)}
className="group relative cursor-col-resize flex items-center justify-center -mx-1 px-1"
>
<span className="h-10 w-1 rounded-full bg-border group-hover:bg-primary transition-colors" />
</div>
<div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div> <div className="min-h-0 min-w-0 flex">{renderMainPane(mainPaneRight)}</div>
</div> </div>
</TabsContent> </TabsContent>
+13 -5
View File
@@ -51,11 +51,14 @@ type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgx
export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) { export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) {
// Peak-hold so the jittery VITA-49 meters read steadily (own ref per card). // Peak-hold so the jittery VITA-49 meters read steadily (own ref per card).
// One second: the same window as the docked widget, so the two never show
// different figures for the same amplifier, and neither trails the end of a
// transmission long enough to look like lag.
const peak = useRef<Record<string, { v: number; t: number }>>({}); const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => { const peakHold = (key: string, val: number) => {
const now = Date.now(); const now = Date.now();
const p = peak.current[key]; const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; } if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v; return p.v;
}; };
@@ -73,9 +76,12 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
spe.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')}> spe.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')}>
{spe.operate ? 'OPERATE' : 'STANDBY'} {spe.operate ? 'OPERATE' : 'STANDBY'}
</button> </button>
{/* Power ON pulses RTS then DTR — it must stay clickable while the amp
is off (no status = not "connected"), and serial only. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70"> <div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected} <button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
onClick={() => AmpPower(amp.id, true).catch(() => {})} onClick={() => AmpPower(amp.id, true).catch(() => {})}
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR 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> 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={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})} onClick={() => AmpPower(amp.id, false).catch(() => {})}
@@ -177,9 +183,6 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
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')}> 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')}>
{operate ? 'OPERATE' : 'STANDBY'} {operate ? 'OPERATE' : 'STANDBY'}
</button> </button>
<span className="text-xs text-muted-foreground">
{operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
</span>
{(pg.host || pg.connected) && ( {(pg.host || pg.connected) && (
<label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}> <label className="flex items-center gap-1.5 text-xs" title={pg.connected ? t('flxp.pgConnected') : (pg.last_error || t('flxp.pgOffline'))}>
<span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} /> <span className={cn('size-1.5 rounded-full', pg.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
@@ -208,6 +211,11 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP') const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim())); && !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (amps.length === 0) return null; if (amps.length === 0) return null;
// Power comes from the radio's meter stream and nothing else. The
// amplifier also reports a "peakfwd", and using it was a mistake twice
// over: it is a latched maximum that is never reset, and it survives in
// the last-known status after the amp disconnects — so it claimed
// 1350 W from an old transmission while the radio was putting out 10.
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amps.map((m) => { {amps.map((m) => {
+281
View File
@@ -0,0 +1,281 @@
import { useRef } from 'react';
import { Flame, X, Power } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { MeterBar } from '@/components/MeterBar';
import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from '../../wailsjs/go/main/App';
// AmpWidget — the docked amplifier widget, the compact counterpart of AmpCard
// (which is the full-size card shown in the FlexRadio panel and Station
// Control). It covers all three families, since an operator running an SPE and
// a PowerGenius wants one widget, not two:
// • SPE Expert — OPERATE, power ON/OFF, L/M/H, live power / SWR / temp
// • ACOM — OPERATE, power ON/OFF, live power / SWR / temp
// • PowerGenius — OPERATE and meters ride on the FlexRadio, fan mode on GSCP
// With several amplifiers configured the caller passes a selection ("all" or an
// amp id) chosen from the toolbar icon's dropdown.
type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any };
// Full-scale watts for the output bar, from the model string.
function maxW(model?: string, fallback = 1300): number {
const m = (model || '').toUpperCase();
if (m.includes('20K') || m.includes('2K')) return 2000;
if (m.includes('15K')) return 1500;
if (m.includes('13K')) return 1300;
return fallback;
}
function swrColour(swr?: number): string {
if (!swr || swr <= 0) return 'text-muted-foreground';
if (swr <= 1.5) return 'text-success';
if (swr <= 2.0) return 'text-warning';
return 'text-danger';
}
// PowerMeter — the output-power LED bar that closes each column, straight under
// the controls (no filler above it: pushed to the bottom it left a band of dead
// space in the middle of the column). Uses the app-wide MeterBar, compact size,
// so it reads exactly like the meters on the rig panels.
function PowerMeter({ label, watts, maxWatts }: { label: string; watts: number; maxWatts: number }) {
return (
<MeterBar compact label={label} value={watts} unit="W" lo={0} hi={maxWatts}
display={`${Math.round(watts)} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
);
}
// usePeakHold keeps the highest reading seen for a short window, so a reading
// sampled between two syllables doesn't collapse the meter. `live` is the key
// part: pass false the moment the carrier drops and the held value is thrown
// away at once — holding it there made releasing the PTT look like a five
// second lag, which read as the whole app being slow.
function usePeakHold(windowMs = 1000) {
const peak = useRef<Record<string, { v: number; t: number }>>({});
return (key: string, val: number, live = true) => {
if (!live) { delete peak.current[key]; return val; }
const now = Date.now();
const p = peak.current[key];
if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
return p.v;
};
}
// Stat — one small labelled figure (power, SWR, temperature).
function Stat({ label, value, className }: { label: string; value: string; className?: string }) {
return (
<div className="rounded-lg border border-border bg-card/70 px-1.5 py-1 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground leading-tight">{label}</div>
<div className={cn('text-sm font-bold font-mono leading-tight', className ?? 'text-foreground/80')}>{value}</div>
</div>
);
}
// OperateButton — the STANDBY/OPERATE toggle, identical in behaviour across the
// three families (only where the state comes from differs).
function OperateButton({ operate, disabled, onClick, t }: {
operate: boolean; disabled: boolean; onClick: () => void; t: (k: string, v?: any) => string;
}) {
return (
<button type="button" disabled={disabled} onClick={onClick}
className={cn('w-full inline-flex items-center justify-center gap-1.5 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-[0.98] disabled:opacity-40',
operate
? 'bg-gradient-to-b from-amber-400 to-amber-600 text-white border-amber-300/60 shadow-[0_0_9px_rgba(245,158,11,0.45)]'
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground')}>
<Power className="size-3.5" />
{operate ? t('ampw.operate') : t('ampw.standby')}
</button>
);
}
// AmpBlock — one amplifier inside the widget.
function AmpBlock({ amp, flex, showName, t }: {
amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string;
}) {
const spe = amp.spe, acom = amp.acom;
const hold = usePeakHold();
if (spe || acom) {
const s = spe || acom;
// The amp reports zero watts on receive, so its TX flag clears the meter as
// soon as the operator lets go — and the radio's own flag, when we have one,
// gets there first (the amp is polled on its own slower cycle).
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false;
const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing);
const swr = Number(spe ? s.swr_ant : s.swr) || 0;
const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800);
// Power ON drives the remote-on control lines, so it stays available while
// the amplifier is off and reporting nothing — but only over a serial link.
const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial');
return (
<div className="h-full flex flex-col gap-1.5">
{showName && (
<div className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full shrink-0', s.connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || (spe ? 'SPE' : 'ACOM')}</span>
{s.connected && s.band && <span className="ml-auto text-[9px] text-muted-foreground shrink-0">{s.band}</span>}
</div>
)}
<OperateButton operate={!!s.operate} disabled={!s.connected} t={t}
onClick={() => AmpOperate(amp.id, !s.operate).catch(() => {})} />
<div className="flex gap-1.5">
<button type="button" disabled={!canPowerOn}
onClick={() => AmpPower(amp.id, true).catch(() => {})}
className="flex-1 rounded-lg border border-success/60 bg-card py-1 text-[11px] font-bold text-success hover:bg-success/15 disabled:opacity-30">
{t('ampw.on')}
</button>
<button type="button" disabled={!s.connected}
onClick={() => AmpPower(amp.id, false).catch(() => {})}
className="flex-1 rounded-lg border border-danger/60 bg-card py-1 text-[11px] font-bold text-danger hover:bg-danger/15 disabled:opacity-30">
{t('ampw.off')}
</button>
</div>
{/* Output level — SPE only; an ACOM has no L/M/H over CAT. */}
{spe && (
<div className="grid grid-cols-3 gap-1">
{(['L', 'M', 'H'] as const).map((lvl) => {
const active = (s.power_level || '').trim().toUpperCase() === lvl;
return (
<button key={lvl} type="button" disabled={!s.connected}
onClick={() => AmpPowerLevel(amp.id, lvl).catch(() => {})}
className={cn('rounded-md border py-0.5 text-[10px] font-bold transition-colors disabled:opacity-30',
active ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
{t(`ampw.lvl${lvl}`)}
</button>
);
})}
</div>
)}
{s.connected ? (
<div className="grid grid-cols-3 gap-1">
<Stat label={t('ampw.pwr')} value={`${Math.round(w)}`} />
<Stat label={t('ampw.swr')} value={swr > 0 ? swr.toFixed(1) : '—'} className={swrColour(swr)} />
<Stat label={t('ampw.temp')} value={`${Number(s.temp_c) || 0}°`} />
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
)}
{(s.warnings || s.alarms || s.err_text) && (
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
{s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
</div>
)}
<PowerMeter label={t('flxp.outputPower')} watts={s.connected ? w : 0} maxWatts={hi} />
</div>
);
}
// PowerGenius XL: OPERATE and the meters come from the radio when the Flex
// reports the amp; the fan mode rides on our own GSCP link.
const pg = amp.pgxl || {};
const viaFlex = !!flex?.amp_available;
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
const connected = !!pg.connected || viaFlex;
const fault = flex?.amp_fault;
const meters = ((flex?.meters as any[]) || []).filter((m) => (m.src || '').toUpperCase().includes('AMP'));
const meter = (re: RegExp) => meters.find((m) => re.test((m.name || '').trim()));
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const fwd = meter(/^fwd|pwr/i);
const flexW = fwd ? (/dbm/i.test(fwd.unit || '') ? dbmToW(fwd.value) : fwd.value) : 0;
// Whether there is power is the RADIO's transmit flag: it flips the instant
// the PTT does, where the amplifier's own status poll answers a second and a
// half late and holds TRANSMIT past the carrier.
//
// How MUCH is the live reading — the radio's meter stream, or the amp's own
// forward figure when there is no Flex. NOT the amp's "peakfwd": that is a
// latched maximum which is never reset AND survives in the last-known status
// after the amp disconnects, so it reported 1350 W from an old transmission
// while the radio was putting out 10 W into a disconnected amplifier.
const txing = typeof flex?.transmitting === 'boolean'
? flex.transmitting
: fwd ? flexW >= 1 : /TRANSMIT/i.test(pg.state || '');
const liveW = flexW > 0 ? flexW : (pg.connected ? Number(pg.fwd_w) || 0 : 0);
const fwdW = hold('fwd', txing ? liveW : 0, txing);
const id = meter(/^ID$|current/i);
// Live drain current, for the same reason: peak_id latches like peak_w.
const idA = id ? Number(id.value) : (txing ? Number(pg.id) || undefined : 0);
const temp = meter(/temp/i);
const tempC = pg.temperature > 0 ? pg.temperature : (temp ? temp.value : undefined);
return (
<div className="h-full flex flex-col gap-1.5">
{showName && (
<div className="flex items-center gap-1.5">
<span className={cn('size-1.5 rounded-full shrink-0', connected ? 'bg-success shadow-[0_0_6px_rgba(16,185,129,0.8)]' : 'bg-danger')} />
<span className="text-[10px] font-bold uppercase tracking-wider truncate">{amp.name || 'PGXL'}</span>
</div>
)}
<OperateButton operate={operate} disabled={!connected} t={t}
onClick={() => (viaFlex ? FlexAmpOperate(!operate) : AmpOperate(amp.id, !operate)).catch(() => {})} />
{connected ? (
<div className="grid grid-cols-3 gap-1">
<Stat label={t('ampw.pwr')} value={`${Math.round(fwdW)}`} />
<Stat label={t('ampw.id')} value={idA != null ? idA.toFixed(1) : '—'} />
<Stat label={t('ampw.temp')} value={tempC != null ? `${Math.round(tempC)}°` : '—'} />
</div>
) : (
<div className="text-[10px] text-center text-muted-foreground italic py-1">{t('ampw.offline')}</div>
)}
{(pg.host || pg.connected) && (
<select
disabled={!pg.connected}
value={(pg.fan_mode || 'CONTEST').toUpperCase()}
onChange={(e) => AmpFanMode(amp.id, e.target.value).catch(() => {})}
title={t('ampw.fan')}
className="w-full h-7 rounded-md border border-border bg-card px-1.5 text-[10px] font-semibold outline-none focus:border-warning disabled:opacity-40">
<option value="STANDARD">{t('flxp.fanStandard')}</option>
<option value="CONTEST">{t('flxp.fanContest')}</option>
<option value="BROADCAST">{t('flxp.fanBroadcast')}</option>
</select>
)}
{fault && fault !== 'NONE' && (
<div className="rounded-md border border-danger-border bg-danger-muted text-danger-muted-foreground text-[9px] px-1.5 py-0.5 text-center break-words">
{t('flxp.fault')}: {fault}
</div>
)}
<PowerMeter label={t('flxp.outputPower')} watts={connected ? fwdW : 0} maxWatts={2000} />
</div>
);
}
export function AmpWidget({ amps, flex, sel, onClose }: {
amps: Amp[];
flex: any;
sel: string; // "all" or an amp id
onClose: () => void;
}) {
const { t } = useI18n();
// An id that no longer matches (the amp was removed in Settings) falls back to
// showing everything rather than an empty widget.
const shown = sel === 'all' ? amps : (amps.filter((a) => a.id === sel).length ? amps.filter((a) => a.id === sel) : amps);
const anyOnline = shown.some((a) => a.spe?.connected || a.acom?.connected || a.pgxl?.connected || flex?.amp_available);
const title = shown.length === 1 ? (shown[0].name || t('ampw.title')) : t('ampw.title');
return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
<Flame className={cn('size-4', anyOnline ? 'text-warning drop-shadow-[0_0_3px_rgba(245,158,11,0.55)]' : 'text-muted-foreground')} />
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80 truncate">{title}</span>
<span className="flex-1" />
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title={t('ampw.close')}>
<X className="size-3.5" />
</button>
</div>
{/* Several amplifiers sit SIDE BY SIDE, one column each: stacked, two amps
made a widget taller than the dock row and both ended up half hidden. */}
<div className="flex-1 min-h-0 overflow-y-auto p-2.5">
{shown.length === 0 ? (
<div className="text-[11px] text-center text-muted-foreground italic py-2">{t('ampw.none')}</div>
) : (
<div className="grid gap-2.5 h-full" style={{ gridTemplateColumns: `repeat(${shown.length}, minmax(0, 1fr))` }}>
{shown.map((a, i) => (
<div key={a.id} className={cn(i > 0 && 'pl-2.5 border-l border-border/50')}>
<AmpBlock amp={a} flex={flex} showName={shown.length > 1} t={t} />
</div>
))}
</div>
)}
</div>
</div>
);
}
+16 -9
View File
@@ -69,6 +69,9 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
const [awardList, setAwardList] = useState<AwardListItem[]>([]); const [awardList, setAwardList] = useState<AwardListItem[]>([]);
// Computed results are cached per award code — each award is scanned only the // Computed results are cached per award code — each award is scanned only the
// first time it's selected (or when explicitly rescanned). // first time it's selected (or when explicitly rescanned).
// Keyed by "CODE|MODECLASS": the same award computed for CW and for all modes
// are different results, and caching them under one key showed the previous
// modes numbers after switching.
const [byCode, setByCode] = useState<Record<string, AwardResult>>({}); const [byCode, setByCode] = useState<Record<string, AwardResult>>({});
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [err, setErr] = useState(''); const [err, setErr] = useState('');
@@ -119,13 +122,14 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
// Compute one award (cached). force=true bypasses the cache (Rescan). // Compute one award (cached). force=true bypasses the cache (Rescan).
async function compute(code: string, force = false) { async function compute(code: string, force = false) {
if (!code) return; if (!code) return;
if (!force && byCode[code]) { setSelected(code); return; } const key = `${code}|${modeFilter}`;
if (!force && byCode[key]) { setSelected(code); return; }
setSelected(code); setSelected(code);
setLoading(true); setLoading(true);
setErr(''); setErr('');
try { try {
const r = (await GetAward(code)) as any as AwardResult; const r = (await GetAward(code, modeFilter === 'all' ? '' : modeFilter)) as any as AwardResult;
setByCode((m) => ({ ...m, [code]: r })); setByCode((m) => ({ ...m, [key]: r }));
} catch (e: any) { } catch (e: any) {
setErr(String(e?.message ?? e)); setErr(String(e?.message ?? e));
} finally { } finally {
@@ -149,7 +153,10 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
} }
useEffect(() => { loadList(); }, []); useEffect(() => { loadList(); }, []);
const current = byCode[selected]; const current = byCode[`${selected}|${modeFilter}`];
// Recompute when the mode class changes: the bands, counts and confirmations
// all describe the selected mode, so they cannot be filtered client-side.
useEffect(() => { if (selected) compute(selected); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [modeFilter]);
// Bands relevant to the selected award, used by BOTH the grid and the stats // Bands relevant to the selected award, used by BOTH the grid and the stats
// matrix so neither is padded with bands the award doesn't use. Rule: the // matrix so neither is padded with bands the award doesn't use. Rule: the
@@ -292,7 +299,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-auto">
{err && <div className="p-3 text-xs text-destructive">{err}</div>} {err && <div className="p-3 text-xs text-destructive">{err}</div>}
{awardList.map((a) => { {awardList.map((a) => {
const r = byCode[a.code]; const r = byCode[`${a.code}|${modeFilter}`];
return ( return (
<button <button
key={a.code} key={a.code}
@@ -539,7 +546,7 @@ export function AwardsPanel({ onEditQSO, onAwardsChanged }: { onEditQSO?: (id: n
</div> </div>
{cell && current && ( {cell && current && (
<CellQSOModal code={current.code} cell={cell} onClose={() => setCell(null)} /> <CellQSOModal code={current.code} cell={cell} modeClass={modeFilter === 'all' ? '' : modeFilter} onClose={() => setCell(null)} />
)} )}
{showMissing && current && ( {showMissing && current && (
<MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} /> <MissingQSOModal code={current.code} name={current.name} onClose={() => setShowMissing(false)} onEditQSO={onEditQSO} />
@@ -724,17 +731,17 @@ function MissingQSOModal({ code, name, onClose, onEditQSO }: { code: string; nam
} }
// CellQSOModal lists the QSOs behind one award-grid cell (reference × band). // CellQSOModal lists the QSOs behind one award-grid cell (reference × band).
function CellQSOModal({ code, cell, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; onClose: () => void }) { function CellQSOModal({ code, cell, modeClass, onClose }: { code: string; cell: { ref: string; band: string; name?: string }; modeClass: string; onClose: () => void }) {
const { t } = useI18n(); const { t } = useI18n();
const [qsos, setQsos] = useState<any[]>([]); const [qsos, setQsos] = useState<any[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
setLoading(true); setLoading(true);
AwardCellQSOs(code, cell.ref, cell.band) AwardCellQSOs(code, cell.ref, cell.band, modeClass)
.then((r) => setQsos((r ?? []) as any)) .then((r) => setQsos((r ?? []) as any))
.catch(() => setQsos([])) .catch(() => setQsos([]))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [code, cell.ref, cell.band]); }, [code, cell.ref, cell.band, modeClass]);
const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); }; const fmt = (s: any) => { const d = new Date(s); return isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 16).replace('T', ' '); };
+60 -63
View File
@@ -99,47 +99,42 @@ function statusFor(p: any): SpotStatusEntry | undefined {
]; ];
} }
// statusBadge maps a resolved spot status to a short labelled badge for the // Spot status is shown by COLOURING THE TEXT, never by a pill or a badge.
// Status column, using the same colours as the per-cell fills (NEW DXCC = //
// call cell, NEW BAND = band cell, NEW SLOT = mode cell). Returns null when // The pills were dropped: a rounded box has its own height and padding, so it
// there's nothing notable to show. // sat off the row's baseline and the callsign inside it no longer lined up with
type Badge = { text: string; fg: string; bg: string; bd: string }; // the plain callsigns above and below. A whole column of them read as chrome
// rather than as data. Same reasoning — and the same semantic tokens — as the
// Y/N/R QSL columns in lib/qslStatus.ts.
//
// Which cell is coloured IS the message, so one colour is enough for all three:
//
// yellow call → new DXCC yellow band → new band yellow mode → new mode
// blue call → already worked
const NEW = 'var(--warning)'; // yellow: something here is new
const WKD = 'var(--info)'; // blue: this callsign is already in the log
// tok builds a badge from a semantic status token (success/warning/caution/ // cellText renders a cell value in an optional colour. An empty value keeps the
// danger/info/neutral) so the colours adapt to every theme via CSS variables, // muted dash the grid used before, so blank cells still read as "nothing here"
// instead of the hard-coded light-theme pastels that looked wrong in dark mode. // rather than as a gap.
function tok(name: string, text: string): Badge { function cellText(value: any, color: string | null): any {
if (name === 'neutral') return { text, fg: 'var(--muted-foreground)', bg: 'var(--muted)', bd: 'var(--border)' };
return { text, fg: `var(--${name}-muted-foreground)`, bg: `var(--${name}-muted)`, bd: `var(--${name}-border)` };
}
// cellChip wraps a Band/Mode cell value in a small rounded pill (same look as the
// Status badges) when the slot is notable, instead of flooding the whole cell with
// a heavy muted fill that turned into an ugly olive block on dark themes. `name` is
// null → plain text, no pill.
function cellChip(value: any, name: string | null): any {
const txt = value === undefined || value === null || value === '' ? '' : String(value); const txt = value === undefined || value === null || value === '' ? '' : String(value);
if (!name) return txt || <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>; if (!txt) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
// Inherit the column's font size (no fixed 9px / height) so a pill around a return <span style={color ? { color, fontWeight: 700 } : undefined}>{txt}</span>;
// callsign stays the same size as the plain callsigns next to it — just tinted
// and rounded, not shrunk.
return (
<span style={{
display: 'inline-flex', alignItems: 'center', lineHeight: 1.1,
backgroundColor: `var(--${name}-muted)`, color: `var(--${name}-muted-foreground)`,
border: `1px solid var(--${name}-border)`, fontWeight: 700,
padding: '1px 6px', borderRadius: 999, whiteSpace: 'nowrap',
}}>{txt}</span>
);
} }
function statusBadge(t: TFn, s: SpotStatusEntry | undefined): Badge | null { // statusColor is the colour for a resolved status in the Status column. County
// and POTA keep their own tokens: they are orthogonal to the band/mode/DXCC
// story and an operator filters on them separately.
function statusColor(s: SpotStatusEntry | undefined): string | null {
switch (s?.status) { switch (s?.status) {
case 'new': return tok('danger', t('clg2.newDxcc')); case 'new':
case 'new-band': return tok('warning', t('clg2.newBand')); case 'new-band':
case 'new-mode': return tok('caution', t('clg2.newMode')); case 'new-mode':
case 'new-slot': return tok('caution', t('clg2.newSlot')); case 'new-slot':
default: return s?.worked_call ? tok('neutral', t('clg2.wkdCall')) : null; return NEW;
default:
return s?.worked_call ? WKD : null;
} }
} }
@@ -166,15 +161,12 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120, headerName: t('clg2.c.call'), field: 'dx_call' as any, width: 120,
defaultVisible: true, defaultVisible: true,
cellClass: 'font-mono', cellClass: 'font-mono',
// NEW DXCC → a danger pill around the call, consistent with the NEW BAND / // NEW DXCC → yellow call. Already worked → blue call. Anything else keeps
// NEW MODE pills (same look, same tokens). Worked-call stays blue bold text; // the theme's normal ink so ordinary callsigns don't shout.
// a plain spot inherits the theme's normal text colour so callsigns blend in
// with the rest of the row instead of always shouting a colour.
cellRenderer: (p: any) => { cellRenderer: (p: any) => {
const s = statusFor(p); const s = statusFor(p);
if (s?.status === 'new') return cellChip(p.value, 'danger'); const color = s?.status === 'new' ? NEW : s?.worked_call ? WKD : null;
const color = s?.worked_call ? 'var(--info)' : undefined; return <span style={{ color: color ?? undefined, fontWeight: 700 }}>{p.value ?? ''}</span>;
return <span style={{ color, fontWeight: 700 }}>{p.value ?? ''}</span>;
}, },
tooltipValueGetter: (p: any) => { tooltipValueGetter: (p: any) => {
const s = statusFor(p); const s = statusFor(p);
@@ -185,9 +177,10 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
group: 'Spot', label: t('clg2.c.status'), colId: 'status', group: 'Spot', label: t('clg2.c.status'), colId: 'status',
headerName: t('clg2.c.status'), width: 120, sortable: true, headerName: t('clg2.c.status'), width: 120, sortable: true,
defaultVisible: true, defaultVisible: true,
// Spells out the slot status as a text badge so NEW SLOT (and the others) // Spells the status out in words so NEW SLOT (and the others) is obvious at
// is obvious at the row level, not just a single coloured cell. NEW COUNTY // the row level, not just a single coloured cell NEW SLOT in particular
// and NEW POTA are orthogonal, so they stack as extra badges. // colours no cell at all, since neither the band nor the mode is new on its
// own. NEW COUNTY and NEW POTA are orthogonal, so they stack after it.
valueGetter: (p: any) => { valueGetter: (p: any) => {
const s = statusFor(p); const s = statusFor(p);
const parts: string[] = []; const parts: string[] = [];
@@ -202,21 +195,25 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
}, },
cellRenderer: (p: any) => { cellRenderer: (p: any) => {
const s = statusFor(p); const s = statusFor(p);
const badges: Badge[] = []; const parts: { text: string; color: string }[] = [];
const b = statusBadge(t, s); const main = statusColor(s);
if (b) badges.push(b); if (main) {
if (s?.new_county) badges.push(tok('info', t('clg2.newCounty'))); const label = s?.status === 'new' ? t('clg2.newDxcc')
if (s?.new_pota) badges.push(tok('success', t('clg2.newPota'))); : s?.status === 'new-band' ? t('clg2.newBand')
if (badges.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>; : s?.status === 'new-mode' ? t('clg2.newMode')
: s?.status === 'new-slot' ? t('clg2.newSlot')
: t('clg2.wkdCall');
parts.push({ text: label, color: main });
}
if (s?.new_county) parts.push({ text: t('clg2.newCounty'), color: 'var(--info)' });
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
if (parts.length === 0) return <span style={{ color: 'var(--muted-foreground)', fontSize: 10 }}></span>;
return ( return (
<span style={{ display: 'flex', height: '100%', flexWrap: 'wrap', gap: 2, alignItems: 'center' }}> <span style={{ whiteSpace: 'nowrap' }}>
{badges.map((bd, i) => ( {parts.map((pt, i) => (
<span key={i} style={{ <span key={i} style={{ color: pt.color, fontWeight: 700 }}>
display: 'inline-flex', alignItems: 'center', height: 15, lineHeight: 1, {i > 0 ? ' · ' : ''}{pt.text}
backgroundColor: bd.bg, color: bd.fg, border: `1px solid ${bd.bd}`, </span>
fontWeight: 700, fontSize: 9,
padding: '0 5px', borderRadius: 999, letterSpacing: 0.3, whiteSpace: 'nowrap',
}}>{bd.text}</span>
))} ))}
</span> </span>
); );
@@ -249,8 +246,8 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
headerName: t('clg2.c.band'), field: 'band' as any, width: 75, headerName: t('clg2.c.band'), field: 'band' as any, width: 75,
defaultVisible: true, defaultVisible: true,
cellClass: 'font-mono', cellClass: 'font-mono',
// NEW BAND for this entity → small warning pill around the band text. // NEW BAND for this entity → the band text turns yellow.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-band' ? 'warning' : null), cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-band' ? NEW : null),
tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined), tooltipValueGetter: (p: any) => (statusFor(p)?.status === 'new-band' ? t('clg2.tipNewBand') : undefined),
}, },
{ {
@@ -263,7 +260,7 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
// for the entity. NEW SLOT means band AND mode were each worked before (just // for the entity. NEW SLOT means band AND mode were each worked before (just
// not together), so highlighting the mode cell would wrongly imply "CW is new"; // not together), so highlighting the mode cell would wrongly imply "CW is new";
// that case is signalled by the Status badge alone. // that case is signalled by the Status badge alone.
cellRenderer: (p: any) => cellChip(p.value, statusFor(p)?.status === 'new-mode' ? 'caution' : null), cellRenderer: (p: any) => cellText(p.value, statusFor(p)?.status === 'new-mode' ? NEW : null),
tooltipValueGetter: (p: any) => { tooltipValueGetter: (p: any) => {
const st = statusFor(p)?.status; const st = statusFor(p)?.status;
if (st === 'new-mode') return t('clg2.tipNewMode'); if (st === 'new-mode') return t('clg2.tipNewMode');
+17 -10
View File
@@ -323,13 +323,15 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [st.cw_speed]); }, [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 a second so the jittery VITA-49
// read steadily instead of jumping every poll. // meters read steadily instead of jumping every poll. One second, not two —
// longer and the meters visibly trail the end of a transmission, which reads
// as the app lagging rather than as a peak being held.
const peak = useRef<Record<string, { v: number; t: number }>>({}); const peak = useRef<Record<string, { v: number; t: number }>>({});
const peakHold = (key: string, val: number) => { const peakHold = (key: string, val: number, windowMs = 1000) => {
const now = Date.now(); const now = Date.now();
const p = peak.current[key]; const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; } if (!p || val >= p.v || now - p.t > windowMs) { peak.current[key] = { v: val, t: now }; return val; }
return p.v; return p.v;
}; };
@@ -355,7 +357,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
}, []); }, []);
// PowerGenius XL direct connection (fan mode), independent of the Flex link. // PowerGenius XL direct connection (fan mode), independent of the Flex link.
const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string }>({ connected: false }); const [pg, setPg] = useState<{ connected: boolean; fan_mode?: string; host?: string; last_error?: string; state?: string; peak_w?: number }>({ connected: false });
useEffect(() => { useEffect(() => {
let alive = true; let alive = true;
const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} }; const tick = async () => { try { const s: any = await GetPGXLStatus(); if (alive) setPg(s || { connected: false }); } catch {} };
@@ -978,10 +980,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
spe.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')}> spe.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')}>
{spe.operate ? 'OPERATE' : 'STANDBY'} {spe.operate ? 'OPERATE' : 'STANDBY'}
</button> </button>
{/* Power ON / OFF (best-guess keystrokes from the APG — verify on hw). */} {/* Power ON pulses RTS then DTR — it must stay clickable while the amp
is off (no status = not "connected"), and serial only. */}
<div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70"> <div className="inline-flex rounded-lg overflow-hidden border-2 border-success/70">
<button type="button" disabled={!spe.connected} <button type="button" disabled={!(spe.connected || spe.transport === 'serial')}
onClick={() => AmpPower(selAmp.id, true).catch(() => {})} onClick={() => AmpPower(selAmp.id, true).catch(() => {})}
title={spe.transport === 'serial' ? 'Power on (RTS then DTR pulse)' : 'Power-on needs the serial RTS/DTR 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> 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={!spe.connected} <button type="button" disabled={!spe.connected}
onClick={() => AmpPower(selAmp.id, false).catch(() => {})} onClick={() => AmpPower(selAmp.id, false).catch(() => {})}
@@ -1083,9 +1087,6 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
st.amp_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')}> st.amp_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')}>
{st.amp_operate ? 'OPERATE' : 'STANDBY'} {st.amp_operate ? 'OPERATE' : 'STANDBY'}
</button> </button>
<span className="text-xs text-muted-foreground">
{st.amp_operate ? t('flxp.ampInLine') : t('flxp.ampBypassed')}
</span>
{/* Fan mode — shown when the PowerGenius is configured (Settings → {/* Fan mode — shown when the PowerGenius is configured (Settings →
PowerGenius). The dot shows the direct-connection state; the PowerGenius). The dot shows the direct-connection state; the
selector is disabled until connected (hover it for the error). */} selector is disabled until connected (hover it for the error). */}
@@ -1118,6 +1119,12 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP') const amp = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim())); && !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (off || amp.length === 0) return null; if (off || amp.length === 0) return null;
// Power comes from the radio's meter stream and nothing else. The
// amplifier also reports a "peakfwd", and using it was a mistake
// twice over: it is a latched maximum that is never reset, and it
// survives in the last-known status after the amp disconnects — so
// it claimed 1350 W from an old transmission while the radio was
// putting out 10.
return ( return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50"> <div className="grid grid-cols-2 sm:grid-cols-3 gap-2 mt-2 pt-2 border-t border-border/50">
{amp.map((m) => { {amp.map((m) => {
+16 -1
View File
@@ -1136,7 +1136,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [modeDraft, setModeDraft] = useState(''); const [modeDraft, setModeDraft] = useState('');
const [catCfg, setCatCfg] = useState<CATSettings>({ const [catCfg, setCatCfg] = useState<CATSettings>({
enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120, enabled: false, backend: 'omnirig', omnirig_rig: 1, omnirig_vfo: '', flex_host: '', flex_port: 4992, flex_spots: false, flex_decode_spots: false, flex_decode_secs: 120,
yaesu_port: '', yaesu_baud: 38400, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '', yaesu_port: '', yaesu_baud: 38400, yaesu_low_lines: false, kenwood_low_lines: false, kenwood_port: '', kenwood_baud: 9600, kenwood_host: '', xiegu_port: '', xiegu_baud: 19200, xiegu_addr: 0x70, xiegu_ptt_line: '',
icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false, icom_port: '', icom_baud: 115200, icom_addr: 0x98, icom_net_host: '', icom_net_user: '', icom_net_pass: '', icom_net_audio: false,
tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8', share_enabled: false, share_port: 4532, digital_default: 'FT8', share_enabled: false, share_port: 4532,
@@ -2580,6 +2580,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</Select> </Select>
<span className="text-xs text-muted-foreground">{t('cat.yaesuBaudHint')}</span> <span className="text-xs text-muted-foreground">{t('cat.yaesuBaudHint')}</span>
</div> </div>
<div className="space-y-1">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={!!catCfg.yaesu_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, yaesu_low_lines: !!c }))} />
{t('cat.lowerLines')}
</label>
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
</div>
</> </>
)} )}
{['icom', 'xiegu', 'kenwood'].includes(catCfg.backend) && ( {['icom', 'xiegu', 'kenwood'].includes(catCfg.backend) && (
@@ -2627,6 +2634,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
/> />
<span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span> <span className="text-xs text-muted-foreground">{t('cat.kenwoodHostHint')}</span>
</div> </div>
<div className="space-y-1">
<label className="flex items-center gap-2 text-xs cursor-pointer">
<Checkbox checked={!!catCfg.kenwood_low_lines} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, kenwood_low_lines: !!c }))} />
{t('cat.lowerLines')}
</label>
<span className="text-xs text-muted-foreground">{t('cat.lowerLinesHint')}</span>
</div>
</> </>
)} )}
{catCfg.backend === 'icom' && ( {catCfg.backend === 'icom' && (
@@ -3284,6 +3298,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Button variant="outline" size="sm" onClick={addAmp}> <Button variant="outline" size="sm" onClick={addAmp}>
<Plus className="size-3.5 mr-1" /> {t('amp.add')} <Plus className="size-3.5 mr-1" /> {t('amp.add')}
</Button> </Button>
</div> </div>
</> </>
); );
+84 -34
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react'; import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } 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';
@@ -26,13 +26,22 @@ type Device = { id: string; type: string; name: string; host: string; user?: str
type Relay = { number: number; label: string; on: boolean }; type Relay = { number: number; label: string; on: boolean };
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] }; type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 }; // Dashboard geometry: a grid of EQUAL columns whose cards also share a common
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' }; // HEIGHT per row — every card stretches to the tallest one beside it. Masonry
// packed tighter but left the row edges ragged, which read as untidy on a wide
// screen; aligned rows are what an operator expects from a dashboard.
const CARD_MIN = 430; // narrowest a card may get before "Auto" drops a column
const GRID_GAP = 16; // px between cards, both axes
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8, dingtian: 2 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)', dingtian: 'Dingtian IOT relay' };
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and // Relay count for a configured device: fixed by type, except Denkovi (4/8) and
// the generic USB-serial board, whose channel count the user picks. // the generic USB-serial board, whose channel count the user picks.
const chanCount = (d: Device): number => const chanCount = (d: Device): number =>
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5); (d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8)
: d.type === 'dingtian' ? (d.channels && d.channels >= 1 ? d.channels : 2)
: (RELAY_COUNT[d.type] ?? 5);
function blankDevice(): Device { function blankDevice(): Device {
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') }; return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
@@ -269,6 +278,23 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
// Max columns per row (persisted). "Auto" fills the window; a number caps the // Max columns per row (persisted). "Auto" fills the window; a number caps the
// row so cards wrap onto further lines even when there's horizontal room. // row so cards wrap onto further lines even when there's horizontal room.
const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto'); const [cols, setCols] = useState<string>(() => localStorage.getItem('opslog.stationCols') || 'auto');
// How many columns the dashboard shows. "Auto" fits as many CARD_MIN-wide
// cards as the window allows; an explicit count is honoured as asked, even
// past that width — the operator can see his own screen.
const gridRef = useRef<HTMLDivElement | null>(null);
const [colCount, setColCount] = useState(1);
useLayoutEffect(() => {
if (cols !== 'auto') { setColCount(Number(cols) || 1); return; }
const el = gridRef.current;
if (!el) return;
const measure = () => setColCount(Math.max(1, Math.floor((el.clientWidth + GRID_GAP) / (CARD_MIN + GRID_GAP))));
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [cols]);
// Amplifiers (Settings → Amplifier): shown here so operators without a // Amplifiers (Settings → Amplifier): shown here so operators without a
// FlexRadio panel still get the controls. EVERY configured amp gets its own // FlexRadio panel still get the controls. EVERY configured amp gets its own
// card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the // card (identical to the Flex panel's) — no dropdown. Polled fast (1.5s) so the
@@ -415,10 +441,10 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
); );
}; };
// `wide` cards span the whole dashboard instead of sitting in one masonry // `wide` cards take two grid columns instead of one. The amplifier and tuner
// column. The amplifier and tuner carry meter rows and a channel selector that // carry meter rows and a channel selector that are unreadable squeezed into a
// are unreadable squeezed into a ~430px column — they are the same cards the // single ~430px column — they are the same cards the FlexRadio panel shows
// FlexRadio panel shows full-width, and they need that room here too. // full-width, and they need that room here too.
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = []; const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
if (rot.enabled) { if (rot.enabled) {
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> }); widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
@@ -446,7 +472,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
{/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a {/* Max columns per row: Auto fills the window; 1/2/3/4 cap the row so a
card can sit on a further line even with horizontal room to spare. */} card can sit on a further line even with horizontal room to spare. */}
<div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]"> <div className="flex items-center rounded-md border border-border overflow-hidden text-[11px]">
{(['auto', '1', '2', '3', '4'] as const).map((c) => ( {(['auto', '1', '2', '3', '4', '5', '6'] as const).map((c) => (
<button key={c} type="button" <button key={c} type="button"
onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }} onClick={() => { setCols(c); writeUiPref('opslog.stationCols', c); }}
className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}> className={cn('px-2 py-1 font-medium', cols === c ? 'bg-primary text-primary-foreground' : 'text-muted-foreground hover:bg-muted')}>
@@ -466,31 +492,36 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
</div> </div>
)} )}
{/* Masonry dashboard: fixed-width cards flow into balanced CSS columns so {/* Dashboard grid: equal columns, and every card stretches to the height of
they pack tightly by height (no ragged gaps under short cards). "Auto" the tallest one on its row, so the rows line up instead of ending
fits as many ~430px columns as the window allows; a fixed count caps the ragged. One column each, two for the amplifier/tuner whose meter rows
container width to that many columns. Each card has a grip handle (left need the room. Each card has a grip handle (left rail) as the drag
rail) as the drag initiator (the body is full of buttons). */} initiator, since the body is full of buttons. */}
<div style={{ columnWidth: '430px', columnGap: '1rem', columnFill: 'balance', <div ref={gridRef} style={{
...(cols !== 'auto' ? { maxWidth: `${Number(cols) * 446}px` } : {}) }}> display: 'grid',
gridTemplateColumns: `repeat(${colCount}, minmax(0, 1fr))`,
gap: `${GRID_GAP}px`,
}}>
{ordered.map((w) => ( {ordered.map((w) => (
// column-span:all lifts a wide card out of the columns and across the <div key={w.id}
// full container, while keeping it in document order — so drag-reorder // A wide card takes two columns — but never more than there are, or
// still works between wide and normal cards. Capped so it stays a card // the grid would grow a phantom column on a narrow window.
// and not a banner on an ultra-wide window. style={{ gridColumn: w.wide ? `span ${Math.min(2, colCount)}` : undefined }}
<div key={w.id} className="flex items-stretch break-inside-avoid mb-4"
style={w.wide ? { columnSpan: 'all', maxWidth: '900px' } : undefined}
onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }} onDragOver={(e) => { if (dragId.current) { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; } }}
onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}> onDrop={(e) => { if (dragId.current) { e.preventDefault(); onDrop(w.id); } }}>
<div draggable {/* h-full down the chain is what makes the card fill the row height
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }} rather than sit at its natural size in a taller cell. */}
onDragEnd={() => { dragId.current = null; }} <div className="flex items-stretch h-full">
title={t('station.dragMove')} <div draggable
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors', onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
dragId.current === w.id && 'opacity-60')}> onDragEnd={() => { dragId.current = null; }}
<GripVertical className="size-4" /> title={t('station.dragMove')}
className={cn('flex items-center shrink-0 px-0.5 rounded-l-xl cursor-grab active:cursor-grabbing text-muted-foreground/30 hover:text-muted-foreground hover:bg-muted/40 transition-colors',
dragId.current === w.id && 'opacity-60')}>
<GripVertical className="size-4" />
</div>
<div className="flex-1 min-w-0 [&>*]:h-full">{w.node}</div>
</div> </div>
<div className="flex-1 min-w-0">{w.node}</div>
</div> </div>
))} ))}
</div> </div>
@@ -523,6 +554,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
onChange({ ...device, channels, labels }); onChange({ ...device, channels, labels });
}; };
const isKM = device.type === 'kmtronic'; const isKM = device.type === 'kmtronic';
const isDingtian = device.type === 'dingtian';
const isDenkovi = device.type === 'denkovi'; const isDenkovi = device.type === 'denkovi';
const isUsbRelay = device.type === 'usbrelay'; const isUsbRelay = device.type === 'usbrelay';
// COM ports for the generic USB-serial relay picker. // COM ports for the generic USB-serial relay picker.
@@ -573,6 +605,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<SelectContent> <SelectContent>
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem> <SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem> <SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
<SelectItem value="dingtian">Dingtian IOT relay (LAN / WiFi)</SelectItem>
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem> <SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem> <SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
</SelectContent> </SelectContent>
@@ -583,13 +616,13 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} /> <Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
</div> </div>
</div> </div>
{(isDenkovi || isUsbRelay) && ( {(isDenkovi || isUsbRelay || isDingtian) && (
<div className="space-y-1 max-w-[10rem]"> <div className="space-y-1 max-w-[10rem]">
<Label>{t('station.channels')}</Label> <Label>{t('station.channels')}</Label>
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}> <Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger> <SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => ( {(isDenkovi ? [4, 8] : isDingtian ? [2, 4, 8, 16, 24, 32] : [1, 2, 4, 8, 16]).map((n) => (
<SelectItem key={n} value={String(n)}>{n}</SelectItem> <SelectItem key={n} value={String(n)}>{n}</SelectItem>
))} ))}
</SelectContent> </SelectContent>
@@ -632,11 +665,28 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p> <p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
</div> </div>
) : ( ) : (
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}> <div className={cn('grid gap-3', (isKM || isDingtian) ? 'grid-cols-3' : 'grid-cols-1')}>
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}> <div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
<Label>{t('station.host')}</Label> <Label>{t('station.host')}</Label>
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} /> <Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
</div> </div>
{/* Dingtian: both are OFF on a factory board — the session ID only when
"HTTP Session" is enabled in its web page, the password only when a
relay password is set. */}
{isDingtian && (
<>
<div className="space-y-1">
<Label>{t('station.dtSession')}</Label>
<Input className="font-mono" value={device.user ?? ''} placeholder={t('station.optional')}
onChange={(e) => onChange({ ...device, user: e.target.value })} />
</div>
<div className="space-y-1">
<Label>{t('station.dtPwd')}</Label>
<Input className="font-mono" value={device.pass ?? ''} placeholder="0"
onChange={(e) => onChange({ ...device, pass: e.target.value.replace(/[^0-9]/g, '') })} />
</div>
</>
)}
{isKM && ( {isKM && (
<> <>
<div className="space-y-1"> <div className="space-y-1">
+8 -4
View File
@@ -76,13 +76,16 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
const peakHold = (key: string, val: number) => { const peakHold = (key: string, val: number) => {
const now = Date.now(); const now = Date.now();
const p = peak.current[key]; const p = peak.current[key];
if (!p || val >= p.v || now - p.t > 2000) { peak.current[key] = { v: val, t: now }; return val; } if (!p || val >= p.v || now - p.t > 1000) { peak.current[key] = { v: val, t: now }; return val; }
return p.v; return p.v;
}; };
const connected = !!status.connected; const connected = !!status.connected;
const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined; const rawVswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const rawFwdW = status.fwd_w && status.fwd_w >= 1 ? status.fwd_w : 0; // Prefer the device's own peak detector over the sampled reading; the JS hold
// below still smooths what is left between polls.
const rawPeak = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
const rawFwdW = rawPeak >= 1 ? rawPeak : 0;
const fwdW = peakHold('fwd', rawFwdW); const fwdW = peakHold('fwd', rawFwdW);
// SWR needs RF to mean anything: sampled on receive the device reports a // SWR needs RF to mean anything: sampled on receive the device reports a
@@ -93,13 +96,14 @@ export function TunerCard({ status, t }: { status: TGStatus; t: (k: string, v?:
// That expiry is the point: the first version of this held the last TX value // That expiry is the point: the first version of this held the last TX value
// with no timeout at all, so the meter sat frozen on the previous transmission // with no timeout at all, so the meter sat frozen on the previous transmission
// indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward. // indefinitely — reading 1.39:1 with the radio plainly in RX and 0 W forward.
// Same 2 s window as the power peak above, so the two meters clear together. // Same one-second window as the power peak above, so the two meters clear
// together.
const swrHold = useRef<{ v: number; t: number } | null>(null); const swrHold = useRef<{ v: number; t: number } | null>(null);
if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() }; if (rawFwdW >= 1 && rawVswr) swrHold.current = { v: rawVswr, t: Date.now() };
const heldSwr = swrHold.current; const heldSwr = swrHold.current;
const vswr = rawFwdW >= 1 const vswr = rawFwdW >= 1
? rawVswr ? rawVswr
: heldSwr && Date.now() - heldSwr.t < 2000 : heldSwr && Date.now() - heldSwr.t < 1000
? heldSwr.v ? heldSwr.v
: undefined; : undefined;
const active = status.active ?? 1; const active = status.active ?? 1;
+6 -2
View File
@@ -8,7 +8,7 @@ export type TGChannel = {
}; };
export type TGStatus = { export type TGStatus = {
connected: boolean; host?: string; last_error?: string; connected: boolean; host?: string; last_error?: string;
fwd_dbm?: number; fwd_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number; fwd_dbm?: number; fwd_w?: number; peak_w?: number; swr_db?: number; vswr?: number; freq_mhz?: number;
operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number; operate?: boolean; bypass?: boolean; tuning?: boolean; active?: number;
antenna?: number; three_way?: boolean; message?: string; antenna?: number; three_way?: boolean; message?: string;
a?: TGChannel; b?: TGChannel; a?: TGChannel; b?: TGChannel;
@@ -69,6 +69,10 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
const { t } = useI18n(); const { t } = useI18n();
const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined; const vswr = status.vswr && status.vswr > 0 ? status.vswr : undefined;
const active = status.active ?? 1; const active = status.active ?? 1;
// The device's own peak, not the instantaneous reading: a 400 ms poll lands
// between syllables as often as on a peak, so the plain figure showed a few
// hundred watts on a kilowatt transmission.
const fwdW = Math.max(status.peak_w ?? 0, status.fwd_w ?? 0);
return ( return (
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden"> <div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
@@ -103,7 +107,7 @@ export function TunerGeniusPanel({ status, onTune, onBypass, onOperate, onActiva
<div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center"> <div className="rounded-lg border border-border bg-card/70 px-2 py-1.5 text-center">
<div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div> <div className="text-[9px] uppercase tracking-wider text-muted-foreground">{t('tgp.power')}</div>
<div className="text-lg font-bold font-mono leading-tight text-foreground/80"> <div className="text-lg font-bold font-mono leading-tight text-foreground/80">
{status.fwd_w && status.fwd_w >= 1 ? `${Math.round(status.fwd_w)} W` : '—'} {fwdW >= 1 ? `${Math.round(fwdW)} W` : '—'}
</div> </div>
</div> </div>
</div> </div>
+16 -4
View File
@@ -51,7 +51,7 @@ const en: Dict = {
'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name', 'dup.colDate': 'Date / time (UTC)', 'dup.colFreq': 'Freq', 'dup.colRst': 'RST s/r', 'dup.colName': 'Name',
'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.', 'profileScope.saved': 'Saved for profile', 'profileScope.switch': '— switch profiles to edit another identity.',
// Tabs // Tabs
'tab.main': 'Main', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before', 'tab.main': 'Main', 'main.splitTip': 'Drag to resize the panes — double-click to even them out', 'tab.recent': 'Recent QSOs', 'tab.cluster': 'Cluster', 'tab.worked': 'Worked before',
'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net', 'tab.awards': 'Awards', 'tab.bandmap': 'Band Map', 'tab.contest': 'Contest', 'tab.net': 'Net',
// Entry form // Entry form
'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid', 'field.callsign': 'Callsign', 'field.name': 'Name', 'field.qth': 'QTH', 'field.grid': 'Grid',
@@ -277,7 +277,7 @@ const en: Dict = {
'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 12 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 12 min delay so a mis-logged QSO can still be fixed first).',
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer', 'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', 'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.steppirRange': 'Tunable range', 'hw.steppirRangeHint': "The SteppIR's frequency coverage. On a band outside this range (e.g. 30 m on a 20 m6 m SteppIR) OpsLog won't try to tune the antenna and won't inhibit transmission. Default 1354 MHz (20 m6 m); widen the low edge (e.g. 6) for a 40 m-equipped SteppIR.", 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
// CAT panel body // CAT panel body
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved', 'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Share CAT with other programs', 'cat.shareHint': 'A native CAT backend owns the radio serial port, so no other program can reach it. This lets WSJT-X, JTDX, MSHV or Log4OM talk to the rig THROUGH OpsLog: in the other program pick the rig model "Hamlib NET rigctl" and enter 127.0.0.1:4532. Works with every backend, not only the native ones.', 'cat.sharePort': 'Sharing port', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Xiegu CAT port', 'cat.xieguBaudHint': 'Must match the radio menu (G90 default: 19200).', 'cat.xieguAddrHint': 'Factory CI-V address of the G90/X6100 family: 0x70.', 'cat.xieguPTTLine': 'How the rig is keyed', 'cat.xieguPTTCiv': 'CI-V command', 'cat.xieguPTTHint': 'A G90 does not transmit on the CI-V command: interfaces like the DE-19 key it on RTS or DTR. Pick the line yours uses \u2014 it is also what lets WSJT-X transmit through the shared CAT link.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, network)', 'cat.civTrace': 'Log the CAT protocol', 'cat.civTraceHint': 'Writes every CAT frame to and from the radio into the log \u2014 CI-V as hex, Kenwood as the text it exchanges. For reporting a rig that answers oddly \u2014 a button that does the wrong thing, a frequency that jumps. Session only: it is a diagnostic, and it makes the log large.', 'cat.kenwoodPort': 'Kenwood COM port', 'cat.kenwoodPortHint': 'The rig\u2019s CAT/USB serial port (TS-590, TS-890, TS-990, TS-2000, and Elecraft K3/K4 which speak the same dialect).', 'cat.kenwoodBaudHint': 'Must match MENU on the radio: a TS-590 leaves the factory at 9600, a TS-890 at 115200.', 'cat.kenwoodHost': 'Or over the network (host:port)', 'cat.kenwoodHostHint': 'A serial-over-network bridge \u2014 ser2net, an Ethernet-serial adapter, a Raspberry Pi at the radio. Filled in, it is used INSTEAD of the COM port above. This is not the radio\u2019s own RJ45 socket, which speaks Kenwood\u2019s KNS protocol and is not supported.', 'cat.yaesuPort': 'Yaesu CAT port', 'cat.yaesuPortHint': 'The rig CAT port — on an FTDX10/FTDX101 over USB this is the ENHANCED COM port, not the standard one.', 'cat.yaesuBaudHint': 'Must match the radio menu (FTDX10/FTDX101 default: 38400).', 'cat.lowerLines': 'Lower the DTR and RTS lines on connect', 'cat.lowerLinesHint': 'Windows raises both when the port opens, and some interfaces read either line as PTT — the radio then transmits for as long as OpsLog runs. Tick this only if that happens: other interfaces need RTS raised to transmit at all, and lowering it makes the rig go silent.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V network)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 power per band and mode', 'flxpw.hint': 'TX power applied when the band or mode changes. Leave a box empty to leave the power alone.', 'flxpw.band': 'Band', 'flxb.title': 'FlexRadio \u2014 per-band antennas and power', 'flxb.hint': 'Antennas are applied when the band changes; power when the band or the mode changes. Leave a power box empty to leave it alone.', 'flxb.rxAnt': 'RX antenna', 'flxb.txAnt': 'TX antenna', 'flxb.noRadio': 'No antennas reported yet \u2014 connect the FlexRadio, then reopen this panel.', 'flxpw.phone': 'Phone', 'flxpw.digi': 'Digital', 'flxpw.noBands': 'No bands configured \u2014 add them in Lists \u2192 Bands.', 'flxpw.saved': 'Saved',
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password', 'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.", 'cat.icomNetHint': "Connects to the rig's built-in LAN server directly — no RS-BA1 or Remote Utility needed (close them first). Use the Network User1 ID/Password set in the rig's Network menu. A rig in standby is powered on automatically.",
'cat.icomNetAudio': 'Stream RX audio over the network (experimental)', 'cat.icomNetAudio': 'Stream RX audio over the network (experimental)',
@@ -352,6 +352,12 @@ const en: Dict = {
'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': 'Click a CQ macro (one whose text contains CQ) to resend it on a loop — message, gap, repeat — until you send another macro (e.g. a report), press Stop, or hit ESC. Non-CQ macros send once.', 'wkp.autoCall': 'Auto-call', 'wkp.gap': 'gap', 'wkp.gapHint': 'Seconds to wait after the message before resending', 'wkp.loopHint': 'click a CQ macro to loop it', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Voice keyer', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Repeat a CQ-labelled message on a timer until you stop it or play another slot', 'dvkp.gap': 'Gap', 'dvkp.notPhone': 'The voice keyer only transmits on a phone mode (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Disable voice keyer', 'dvkp.noMsgPre': 'No messages recorded yet. Open', 'dvkp.settingsPath': 'Settings → Audio devices & voice keyer', 'dvkp.noMsgPost': 'to record F1F6.', 'dvkp.transmit': 'Transmit F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — empty', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band', 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band',
'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows',
'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide',
'ampw.close': 'Close', 'ampw.offline': 'offline', 'ampw.none': 'No amplifier configured',
'ampw.operate': 'Operate', 'ampw.standby': 'Standby', 'ampw.on': 'ON', 'ampw.off': 'OFF',
'ampw.lvlL': 'Low', 'ampw.lvlM': 'Mid', 'ampw.lvlH': 'High',
'ampw.pwr': 'Watts', 'ampw.swr': 'SWR', 'ampw.temp': 'Temp', 'ampw.id': 'Id (A)', 'ampw.fan': 'Fan mode',
'tgp.online': 'online', 'tgp.offline': 'offline', 'tgp.close': 'Close', 'tgp.connecting': 'Connecting…', 'tgp.swr': 'SWR', 'tgp.power': 'Fwd power', 'tgp.tune': 'Tune', 'tgp.tuning': 'Tuning…', 'tgp.tuneHint': 'Start an automatic tuning cycle on the active channel — key the rig into a carrier so the tuner can measure SWR', 'tgp.bypass': 'Bypass', 'tgp.bypassHint': 'Toggle global bypass — route the antenna straight through, tuner out of line', 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Toggle Operate / Standby', 'tgp.online': 'online', 'tgp.offline': 'offline', 'tgp.close': 'Close', 'tgp.connecting': 'Connecting…', 'tgp.swr': 'SWR', 'tgp.power': 'Fwd power', 'tgp.tune': 'Tune', 'tgp.tuning': 'Tuning…', 'tgp.tuneHint': 'Start an automatic tuning cycle on the active channel — key the rig into a carrier so the tuner can measure SWR', 'tgp.bypass': 'Bypass', 'tgp.bypassHint': 'Toggle global bypass — route the antenna straight through, tuner out of line', 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Toggle Operate / Standby',
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line', 'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Channel {letter} — active', 'tgp.chSelect': 'Make channel {letter} active', 'tgp.chActiveTag': 'active', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypassed', 'tgp.inLine': 'In line',
'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.ritHint': 'RIT — shifts your RECEIVE frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.', 'flxp.xitHint': 'XIT — shifts your TRANSMIT frequency only. Wheel, ± or arrow keys to adjust (Ctrl = 100 Hz). The offset is kept when you switch it off.',
@@ -464,7 +470,7 @@ const fr: Dict = {
'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer', 'dup.selectExtras': 'Tout sélectionner sauf le premier', 'dup.deselectAll': 'Tout désélectionner', 'dup.deleteSel': 'Supprimer {n} sélectionné(s)', 'dup.deleteConfirm': 'Supprimer {n} QSO ? Action irréversible.', 'dup.deleted': '{n} doublon(s) supprimé(s)', 'dup.keep': 'le plus ancien', 'dup.close': 'Fermer',
'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom', 'dup.colDate': 'Date / heure (UTC)', 'dup.colFreq': 'Fréq', 'dup.colRst': 'RST e/r', 'dup.colName': 'Nom',
'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.", 'profileScope.saved': 'Enregistré pour le profil', 'profileScope.switch': "— change de profil pour éditer une autre identité.",
'tab.main': 'Principal', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté', 'tab.main': 'Principal', 'main.splitTip': 'Faites glisser pour redimensionner les volets — double-clic pour les égaliser', 'tab.recent': 'QSO récents', 'tab.cluster': 'Cluster', 'tab.worked': 'Déjà contacté',
'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET', 'tab.awards': 'Diplômes', 'tab.bandmap': 'Carte de bande', 'tab.contest': 'Contest', 'tab.net': 'NET',
'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator', 'field.callsign': 'Indicatif', 'field.name': 'Nom', 'field.qth': 'QTH', 'field.grid': 'Locator',
'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire', 'field.band': 'Bande', 'field.mode': 'Mode', 'field.country': 'Pays', 'field.comment': 'Commentaire',
@@ -678,7 +684,7 @@ const fr: Dict = {
'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 à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", '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 à tout contrôleur réglé sur Yaesu GS-232A — sans PstRotator. microHAM ARCO : régler Config → LAN → CONTROL PROTOCOL (ou USB CONTROL PROTOCOL) sur « Yaesu GS-232A » ; en USB la vitesse est sans importance. ERC (Easy Rotor Control, ERC Mini compris) : son émulation DOIT être réglée sur GS-232 — un ERC laissé en Hy-Gain DCU-1 parle un autre jeu de commandes et ne répondra pas — puis choisir son port COM et la même vitesse que dans la configuration de l'ERC.", '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 12 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 12 min pour corriger un QSO mal saisi avant).",
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal', 'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", 'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.steppirRange': 'Plage accordable', 'hw.steppirRangeHint': "La couverture en fréquence de la SteppIR. Sur une bande hors de cette plage (p. ex. 30 m avec une SteppIR 20 m-6 m), OpsLog n'essaie pas d'accorder l'antenne et n'inhibe pas l'émission. Défaut 13-54 MHz (20 m-6 m) ; abaisse la borne basse (p. ex. 6) pour une SteppIR équipée 40 m.", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9', 'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig', 'cat.optFlex': 'FlexRadio (API)', 'cat.share': 'Partager le CAT avec les autres logiciels', 'cat.shareHint': "Un backend CAT natif occupe le port série de la radio, aucun autre logiciel ne peut donc y accéder. Ceci permet à WSJT-X, JTDX, MSHV ou Log4OM de dialoguer avec la radio À TRAVERS OpsLog : dans l'autre logiciel, choisissez le modèle « Hamlib NET rigctl » et saisissez 127.0.0.1:4532. Fonctionne avec tous les backends, pas seulement les natifs.", 'cat.sharePort': 'Port de partage', 'cat.optXiegu': 'Xiegu (USB)', 'cat.xieguPort': 'Port CAT Xiegu', 'cat.xieguBaudHint': 'Doit correspondre au menu de la radio (G90 par défaut : 19200).', 'cat.xieguAddrHint': "Adresse CI-V d'usine de la famille G90/X6100 : 0x70.", 'cat.xieguPTTLine': 'Passage en \u00e9mission', 'cat.xieguPTTCiv': 'Commande CI-V', 'cat.xieguPTTHint': 'Un G90 ne passe pas en \u00e9mission sur la commande CI-V : les interfaces comme le DE-19 le pilotent par RTS ou DTR. Choisissez la ligne de la v\u00f4tre \u2014 c\u2019est aussi ce qui permet \u00e0 WSJT-X d\u2019\u00e9mettre via le partage CAT.', 'cat.optYaesu': 'Yaesu (USB)', 'cat.optKenwood': 'Kenwood (USB, réseau)', 'cat.civTrace': 'Journaliser le protocole CAT', 'cat.civTraceHint': '\u00c9crit dans le journal chaque trame CAT \u00e9chang\u00e9e avec la radio \u2014 CI-V en hexad\u00e9cimal, Kenwood en texte. Pour signaler une radio qui r\u00e9pond de travers \u2014 un bouton qui fait autre chose, une fr\u00e9quence qui saute. Valable pour la session seulement : c\u2019est un diagnostic, et le journal grossit vite.', 'cat.kenwoodPort': 'Port COM Kenwood', 'cat.kenwoodPortHint': 'Le port s\u00e9rie CAT/USB de la radio (TS-590, TS-890, TS-990, TS-2000, ainsi que les Elecraft K3/K4 qui parlent le m\u00eame dialecte).', 'cat.kenwoodBaudHint': 'Doit correspondre au MENU de la radio : un TS-590 sort d\u2019usine \u00e0 9600, un TS-890 \u00e0 115200.', 'cat.kenwoodHost': 'Ou par le r\u00e9seau (h\u00f4te:port)', 'cat.kenwoodHostHint': 'Un pont s\u00e9rie-r\u00e9seau \u2014 ser2net, un bo\u00eetier Ethernet-s\u00e9rie, un Raspberry Pi pr\u00e8s de la radio. S\u2019il est rempli, il est utilis\u00e9 \u00c0 LA PLACE du port COM ci-dessus. Il ne s\u2019agit pas de la prise RJ45 de la radio, qui parle le protocole KNS de Kenwood et n\u2019est pas prise en charge.', 'cat.yaesuPort': 'Port CAT Yaesu', 'cat.yaesuPortHint': "Le port CAT de la radio — sur un FTDX10/FTDX101 en USB c'est le port COM ENHANCED, pas le standard.", 'cat.yaesuBaudHint': 'Doit correspondre au menu de la radio (FTDX10/FTDX101 par défaut : 38400).', 'cat.lowerLines': 'Abaisser les lignes DTR et RTS à la connexion', 'cat.lowerLinesHint': 'Windows les lève à louverture du port, et certaines interfaces lisent lune ou lautre comme PTT — la radio émet alors tant quOpsLog tourne. À cocher seulement dans ce cas : dautres interfaces ont besoin de RTS haut pour émettre, et labaisser rend la radio muette.', 'cat.optIcom': 'Icom (CI-V USB)', 'cat.optIcomNet': 'Icom (CI-V réseau)', 'cat.optTci': 'TCI', 'flxpw.title': 'FlexRadio \u2014 puissance par bande et par mode', 'flxpw.hint': 'Puissance d\u2019\u00e9mission appliqu\u00e9e au changement de bande ou de mode. Laissez une case vide pour ne pas toucher \u00e0 la puissance.', 'flxpw.band': 'Bande', 'flxb.title': 'FlexRadio \u2014 antennes et puissance par bande', 'flxb.hint': 'Les antennes sont appliqu\u00e9es au changement de bande ; la puissance au changement de bande ou de mode. Laissez une case de puissance vide pour ne pas y toucher.', 'flxb.rxAnt': 'Antenne RX', 'flxb.txAnt': 'Antenne TX', 'flxb.noRadio': 'Aucune antenne remont\u00e9e \u2014 connectez le FlexRadio, puis rouvrez ce panneau.', 'flxpw.phone': 'Phonie', 'flxpw.digi': 'Num\u00e9rique', 'flxpw.noBands': 'Aucune bande configur\u00e9e \u2014 ajoutez-les dans Listes \u2192 Bandes.', 'flxpw.saved': 'Enregistr\u00e9',
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau', 'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.", 'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)', 'cat.icomNetAudio': 'Diffuser laudio RX par le réseau (expérimental)',
@@ -747,6 +753,12 @@ const fr: Dict = {
'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}', 'wkp.autoCallHint': "Clique une macro CQ (dont le texte contient CQ) pour la réémettre en boucle — message, pause, répétition — jusqu'à envoyer une autre macro (ex. un report), appuyer sur Stop ou ESC. Les macros non-CQ ne sont émises qu'une fois.", 'wkp.autoCall': 'Appel auto', 'wkp.gap': 'pause', 'wkp.gapHint': 'Secondes à attendre après le message avant de réémettre', 'wkp.loopHint': 'clique une macro CQ pour la boucler', 'wkp.macroN': 'Macro {n}',
'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message', 'dvkp.voiceKeyer': 'Manipulateur vocal', 'dvkp.autoCq': 'Auto CQ', 'dvkp.autoCqHint': 'Répète un message libellé CQ à intervalle régulier jusqu\'à l\'arrêt ou la lecture d\'un autre slot', 'dvkp.gap': 'Intervalle', 'dvkp.notPhone': 'Le manipulateur vocal n\'émet qu\'en mode phonie (SSB/AM/FM)', 'dvkp.stop': 'Stop', 'dvkp.disable': 'Désactiver le manipulateur vocal', 'dvkp.noMsgPre': 'Aucun message enregistré. Ouvre', 'dvkp.settingsPath': 'Réglages → Périphériques audio & manipulateur vocal', 'dvkp.noMsgPost': 'pour enregistrer F1F6.', 'dvkp.transmit': 'Émettre F{slot}{label} ({dur}s)', 'dvkp.empty': 'F{slot} — vide', 'dvkp.message': 'message',
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour nafficher 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 nafficher que la bande courante',
'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget',
'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer',
'ampw.close': 'Fermer', 'ampw.offline': 'hors ligne', 'ampw.none': 'Aucun amplificateur configuré',
'ampw.operate': 'Operate', 'ampw.standby': 'Standby', 'ampw.on': 'ON', 'ampw.off': 'OFF',
'ampw.lvlL': 'Low', 'ampw.lvlM': 'Mid', 'ampw.lvlH': 'High',
'ampw.pwr': 'Watts', 'ampw.swr': 'ROS', 'ampw.temp': 'Temp', 'ampw.id': 'Id (A)', 'ampw.fan': 'Mode ventil.',
'tgp.online': 'en ligne', 'tgp.offline': 'hors ligne', 'tgp.close': 'Fermer', 'tgp.connecting': 'Connexion…', 'tgp.swr': 'ROS', 'tgp.power': 'Puiss. directe', 'tgp.tune': 'Accord', 'tgp.tuning': 'Accord…', 'tgp.tuneHint': "Lancer un cycle d'accord automatique sur le canal actif — passe la radio en porteuse pour que le coupleur mesure le ROS", 'tgp.bypass': 'Bypass', 'tgp.bypassHint': "Basculer le bypass global — antenne en direct, coupleur hors ligne", 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Basculer Operate / Standby', 'tgp.online': 'en ligne', 'tgp.offline': 'hors ligne', 'tgp.close': 'Fermer', 'tgp.connecting': 'Connexion…', 'tgp.swr': 'ROS', 'tgp.power': 'Puiss. directe', 'tgp.tune': 'Accord', 'tgp.tuning': 'Accord…', 'tgp.tuneHint': "Lancer un cycle d'accord automatique sur le canal actif — passe la radio en porteuse pour que le coupleur mesure le ROS", 'tgp.bypass': 'Bypass', 'tgp.bypassHint': "Basculer le bypass global — antenne en direct, coupleur hors ligne", 'tgp.operate': 'Operate', 'tgp.standby': 'Standby', 'tgp.operateHint': 'Basculer Operate / Standby',
'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne', 'tgp.title': 'Tuner Genius', 'tgp.chActive': 'Canal {letter} — actif', 'tgp.chSelect': 'Activer le canal {letter}', 'tgp.chActiveTag': 'actif', 'tgp.ant': 'Ant', 'tgp.bypassed': 'Bypass', 'tgp.inLine': 'En ligne',
'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.ritHint': "RIT — décale uniquement ta fréquence de RÉCEPTION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.", 'flxp.xitHint': "XIT — décale uniquement ta fréquence d'ÉMISSION. Molette, ± ou flèches pour ajuster (Ctrl = 100 Hz). Le décalage est conservé quand tu l'éteins.",
+3
View File
@@ -17,6 +17,8 @@ const PORTABLE_KEYS = [
'opslog.autofocusWB', // auto-focus Worked-before 'opslog.autofocusWB', // auto-focus Worked-before
'hamlog.filterPresets', // Filter Builder saved presets 'hamlog.filterPresets', // Filter Builder saved presets
'opslog.showRotor', // rotor compass shown next to the keyers 'opslog.showRotor', // rotor compass shown next to the keyers
'opslog.showAmpWidget', // amplifier widget shown next to the keyers
'opslog.ampSel.widget', // which amplifier that widget shows ("all" or an amp id)
'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map 'opslog.showBeamOnMap', // antenna beam lobe drawn on the Main map
'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time) 'opslog.startEqualsEnd',// log TIME_ON = TIME_OFF (QSO time = completion time)
'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header 'opslog.showQsoRate', // QSO-rate meter (10/60 min) shown in the header
@@ -36,6 +38,7 @@ const PORTABLE_KEYS = [
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter', 'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked', 'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
'opslog.activeTab', // last selected tab 'opslog.activeTab', // last selected tab
'opslog.mainSplit', // Main tab: width share of the left pane (percent)
// NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here. // NOTE: 'hamlog.awardColsShown' and the grid column layouts are NOT listed here.
// They are handled by lib/gridPrefs, which scopes the localStorage cache PER // They are handled by lib/gridPrefs, which scopes the localStorage cache PER
// PROFILE and mirrors to the DB (already per-profile) itself — mirroring them // PROFILE and mirrors to the DB (already per-profile) itself — mirroring them
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.22.7'; export const APP_VERSION = '0.23.0';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2 -2
View File
@@ -75,7 +75,7 @@ export function AudioStopTX():Promise<void>;
export function AudioTXActive():Promise<boolean>; export function AudioTXActive():Promise<boolean>;
export function AwardCellQSOs(arg1:string,arg2:string,arg3:string):Promise<Array<qso.QSO>>; export function AwardCellQSOs(arg1:string,arg2:string,arg3:string,arg4:string):Promise<Array<qso.QSO>>;
export function AwardFields():Promise<Array<string>>; export function AwardFields():Promise<Array<string>>;
@@ -365,7 +365,7 @@ export function GetAudioSettings():Promise<main.AudioSettings>;
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>; export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
export function GetAward(arg1:string):Promise<award.Result>; export function GetAward(arg1:string,arg2:string):Promise<award.Result>;
export function GetAwardDefs():Promise<Array<award.Def>>; export function GetAwardDefs():Promise<Array<award.Def>>;
+4 -4
View File
@@ -98,8 +98,8 @@ export function AudioTXActive() {
return window['go']['main']['App']['AudioTXActive'](); return window['go']['main']['App']['AudioTXActive']();
} }
export function AwardCellQSOs(arg1, arg2, arg3) { export function AwardCellQSOs(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3); return window['go']['main']['App']['AwardCellQSOs'](arg1, arg2, arg3, arg4);
} }
export function AwardFields() { export function AwardFields() {
@@ -678,8 +678,8 @@ export function GetAutostartPrograms() {
return window['go']['main']['App']['GetAutostartPrograms'](); return window['go']['main']['App']['GetAutostartPrograms']();
} }
export function GetAward(arg1) { export function GetAward(arg1, arg2) {
return window['go']['main']['App']['GetAward'](arg1); return window['go']['main']['App']['GetAward'](arg1, arg2);
} }
export function GetAwardDefs() { export function GetAwardDefs() {
+18
View File
@@ -1924,6 +1924,8 @@ export namespace main {
kenwood_host: string; kenwood_host: string;
kenwood_port: string; kenwood_port: string;
kenwood_baud: number; kenwood_baud: number;
yaesu_low_lines: boolean;
kenwood_low_lines: boolean;
icom_port: string; icom_port: string;
icom_baud: number; icom_baud: number;
icom_addr: number; icom_addr: number;
@@ -1964,6 +1966,8 @@ export namespace main {
this.kenwood_host = source["kenwood_host"]; this.kenwood_host = source["kenwood_host"];
this.kenwood_port = source["kenwood_port"]; this.kenwood_port = source["kenwood_port"];
this.kenwood_baud = source["kenwood_baud"]; this.kenwood_baud = source["kenwood_baud"];
this.yaesu_low_lines = source["yaesu_low_lines"];
this.kenwood_low_lines = source["kenwood_low_lines"];
this.icom_port = source["icom_port"]; this.icom_port = source["icom_port"];
this.icom_baud = source["icom_baud"]; this.icom_baud = source["icom_baud"];
this.icom_addr = source["icom_addr"]; this.icom_addr = source["icom_addr"];
@@ -3497,6 +3501,11 @@ export namespace powergenius {
fan_mode?: string; fan_mode?: string;
temperature: number; temperature: number;
operate: boolean; operate: boolean;
fwd_w: number;
peak_w: number;
vswr: number;
id: number;
peak_id: number;
static createFrom(source: any = {}) { static createFrom(source: any = {}) {
return new Status(source); return new Status(source);
@@ -3511,6 +3520,11 @@ export namespace powergenius {
this.fan_mode = source["fan_mode"]; this.fan_mode = source["fan_mode"];
this.temperature = source["temperature"]; this.temperature = source["temperature"];
this.operate = source["operate"]; this.operate = source["operate"];
this.fwd_w = source["fwd_w"];
this.peak_w = source["peak_w"];
this.vswr = source["vswr"];
this.id = source["id"];
this.peak_id = source["peak_id"];
} }
} }
@@ -4556,6 +4570,7 @@ export namespace spe {
export class Status { export class Status {
connected: boolean; connected: boolean;
transport: string;
last_error?: string; last_error?: string;
model?: string; model?: string;
operate: boolean; operate: boolean;
@@ -4579,6 +4594,7 @@ export namespace spe {
constructor(source: any = {}) { constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source); if ('string' === typeof source) source = JSON.parse(source);
this.connected = source["connected"]; this.connected = source["connected"];
this.transport = source["transport"];
this.last_error = source["last_error"]; this.last_error = source["last_error"];
this.model = source["model"]; this.model = source["model"];
this.operate = source["operate"]; this.operate = source["operate"];
@@ -4633,6 +4649,7 @@ export namespace tunergenius {
last_error?: string; last_error?: string;
fwd_dbm: number; fwd_dbm: number;
fwd_w: number; fwd_w: number;
peak_w: number;
swr_db: number; swr_db: number;
vswr: number; vswr: number;
operate: boolean; operate: boolean;
@@ -4660,6 +4677,7 @@ export namespace tunergenius {
this.last_error = source["last_error"]; this.last_error = source["last_error"];
this.fwd_dbm = source["fwd_dbm"]; this.fwd_dbm = source["fwd_dbm"];
this.fwd_w = source["fwd_w"]; this.fwd_w = source["fwd_w"];
this.peak_w = source["peak_w"];
this.swr_db = source["swr_db"]; this.swr_db = source["swr_db"];
this.vswr = source["vswr"]; this.vswr = source["vswr"];
this.operate = source["operate"]; this.operate = source["operate"];
+86
View File
@@ -0,0 +1,86 @@
# ACOM built-in ATU — what we know, and why OpsLog does not drive it
Session of 2026-08-02 on F4BPO's friend's **ACOM 500S**, serial link, telemetry
trace. Written down so nobody repeats the work. **Conclusion first: the tuner
cannot be started over the serial link, and the attempt to work around that was
abandoned.**
## The tuner is PA state 8
Frame byte 3's high nibble is the PA status. ACOM's own list (recovered by the
ACOM-Controller project) has no entry for 8; it is the antenna tuner. Confirmed
twice, front-panel TUNE, with the byte-level trace running:
```
14:47:20.745 state 8 step 0 ← TUNE pressed. err=FF
14:47:22.588 operator keys a carrier (FT-891, FM, 31 W, 3.6358 MHz)
14:47:24.006 state 8 step 2
14:47:25.201 state 8 step 4
14:47:27.457 state 8 step 5
14:47:30.092 state 5 STANDBY ← accord finished
```
Byte 3's **low** nibble is a step counter through the accord: `0 → 2 → 4 → 5`.
Step 0 means *armed, waiting for RF* — in the first of the two runs the
amplifier sat there **30 seconds** untouched before the operator transmitted.
`paStatusNames[8] = "TUNE"` in acom.go is the only thing kept from all of this.
## The command does not exist in the state-command family
Every action byte of `55 81 08 02 00 XX 00 CHK` was tried, each one from a
**verified** STANDBY (an earlier sweep was worthless because its second code
parked the amp in SERVICE, where it silently refuses everything — 17 false
negatives). Result:
| byte | effect |
|------|--------|
| 0x01, 0x02 | **reboot the amplifier** — it prints `AMPLIFIER: ACOM 500S / HELLO ME…` |
| 0x04 | → SERVICE (state 4) |
| 0x05 | → STANDBY (state 5) |
| 0x06 | → OPERATE (state 6) |
| 0x0A | → OFF (state 10) |
| 0x03, 0x07..0x09, 0x0B..0x18 | nothing, state unchanged |
The action byte **is** the target PA state — four independent confirmations.
Which makes 0x08 the obvious candidate for TUNE. **It was tried, from a verified
STANDBY, and does nothing.** State 8 is reachable from the front panel only.
Going further would mean varying the other bytes of the frame. That is a large
space, and 0x01/0x02 prove that bad values reboot a 500 W amplifier. Not worth
it.
## The workaround that was built and removed
Since the amp waits, armed, for a carrier, OpsLog could watch for state 8 and
supply one: memorise the mode, drop the power, switch to FM, key the PTT, wait
for state 8 to clear, unkey, restore. It was implemented (`acomtune.go`, opt-in,
Settings → Amplifier) and **removed** after the first hardware test:
- the power change did not take effect, and
- **the PTT was not released when the tune finished.**
A logger that can leave a transceiver keyed is not acceptable, and the whole
thing was scaffolding around a protocol we do not actually control. Removed at
the operator's request: *"je veux qu'OpsLog soit solide et ça c'est de la
bidouille."*
If it is ever revisited, the unexplained part is why the tune-finished detection
did not fire — `anyAcomTuning()` polled `StateRaw == 8` every 200 ms and should
have seen the drop to STANDBY. Suspect the status snapshot, not the amplifier.
## Loose ends worth knowing
- **Frame byte 6 is a multiplexer.** The tail of the frame changes meaning with
it: `0x93` = bytes 48/49 carry the TX frequency in kHz (`62 1B` = 7010 while
the rig was on 7.010 MHz), `0x9D` = something else. Byte 70 looked like a
tuner flag for a while purely because of this — it toggles on its own several
times a minute. Do not read tail bytes without checking byte 6.
- **Byte 5 bit 0x80 alternates frame to frame.** It is a sequence/parity bit,
not a command acknowledgement.
- **Byte 66 (the error code) shows 0x14 / 0x8E / 0x69 / 0x0F during a normal
tune and during transmission.** `errText` renders those as "ERROR — see
display" and "Remove drive power", so an ACOM operator probably sees a false
alarm. This was never confirmed with the amplifier in OPERATE (it was in
STANDBY throughout, where drive genuinely is an error) — worth a look with a
clean log before touching the table.
+6 -1
View File
@@ -77,9 +77,14 @@ var models = map[string]model{
} }
// paStatusNames maps the PAstatus nibble to a display string. // paStatusNames maps the PAstatus nibble to a display string.
// 8 is absent from the state list the ACOM-Controller project recovered; it is
// the antenna tuner, read off F4BPO's 500S on 2026-08-02. Pressing TUNE on the
// front panel took byte 3 from 0x51 to 0x80, the amp then WAITED 19 s doing
// nothing until the operator keyed a carrier, ran the tune over ~8 s while the
// low nibble stepped 0→2→4→5, and dropped back to 0x51 (STANDBY).
var paStatusNames = map[int]string{ var paStatusNames = map[int]string{
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE", 1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF", 5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 8: "TUNE", 9: "SYSTEM", 10: "OFF",
} }
// acomBands maps the band nibble to a band label. // acomBands maps the band nibble to a band label.
+8 -16
View File
@@ -314,7 +314,12 @@ func (f *Flex) send(cmd string) int {
debugLog.Printf("Flex: send %q failed: %v", cmd, err) debugLog.Printf("Flex: send %q failed: %v", cmd, err)
return 0 return 0
} }
debugLog.Printf("Flex: → %s", cmd) // Meter subscriptions are pure bookkeeping and a Flex announces meters one
// status line at a time — tracing each "sub meter N" wrote dozens of lines
// at every connect.
if !strings.HasPrefix(cmd, "sub meter ") {
debugLog.Printf("Flex: → %s", cmd)
}
return seq return seq
} }
@@ -773,17 +778,11 @@ func (f *Flex) handleStatus(payload string) {
f.meterMeta[num] = old f.meterMeta[num] = old
} }
f.mu.Unlock() f.mu.Unlock()
// One line for the whole batch, not one per meter: a Flex announces // Not logged: the radio announces meters ONE per status line, so even
// dozens at connect and that was a wall of text every time. // a batched line meant dozens of them at every connect.
var names []string
for _, id := range newIDs { for _, id := range newIDs {
mi := f.meterMeta[id]
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
f.subscribeMeter(id) f.subscribeMeter(id)
} }
if len(names) > 0 {
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
}
} }
// Spot status: "spot <index> …". Track the index so we can clear the // Spot status: "spot <index> …". Track the index so we can clear the
// panadapter, and log it verbatim — a click on a panadapter spot pushes a // panadapter, and log it verbatim — a click on a panadapter spot pushes a
@@ -2261,13 +2260,6 @@ func (f *Flex) subscribeMeter(id int) {
f.send(fmt.Sprintf("sub meter %d", id)) f.send(fmt.Sprintf("sub meter %d", id))
} }
func nonEmpty(s, def string) string {
if s == "" {
return def
}
return s
}
func parseFloatDefault(s string, def float64) float64 { func parseFloatDefault(s string, def float64) float64 {
if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil { if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil {
return v return v
+66 -15
View File
@@ -114,6 +114,15 @@ type IcomSerial struct {
silentGrace time.Duration // current width of that tolerance (backs off, see ReadState) silentGrace time.Duration // current width of that tolerance (backs off, see ReadState)
dspLoaded bool // readDSP has run since the rig became responsive (loads all dspLoaded bool // readDSP has run since the rig became responsive (loads all
// the panel's set-once controls once the rig actually answers) // the panel's set-once controls once the rig actually answers)
// When the console last asked for the DSP snapshot. The meters and the
// front-panel rotation are polled ONLY while something is displaying them:
// they are half of OpsLog's CI-V traffic, and on a shared bus — a microHAM
// Router splitting one CI-V link into two virtual ports, OpsLog on one and
// WSJT-X/MSHV on the other — every frame we don't need is a frame that can
// collide with the other program's.
dspWantMu sync.Mutex
dspWantAt time.Time
lastSetFreq int64 // last frequency commanded (spot click: freq then mode) lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time lastSetFreqAt time.Time
@@ -409,15 +418,25 @@ func (b *IcomSerial) ReadState() (RigState, error) {
} }
// Live meters + TX state for the Icom panel (the rig doesn't push these). // Live meters + TX state for the Icom panel (the rig doesn't push these).
// The meters are polled only while the console is actually on screen: they
// were three CI-V round trips per cycle, every cycle, whether or not anyone
// could see them. The previous readings are kept so the panel opens on the
// last known values rather than on zeros.
tx := b.readTX() tx := b.readTX()
sm, _ := b.readMeter(civ.SubMeterS) watched := b.panelWatched()
po, swr := 0, 0 b.dspMu.Lock()
if tx { sm, po, swr := b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter
if v, ok := b.readMeter(civ.SubMeterPo); ok { b.dspMu.Unlock()
po = v if watched {
} sm, _ = b.readMeter(civ.SubMeterS)
if v, ok := b.readMeter(civ.SubMeterSWR); ok { po, swr = 0, 0
swr = v if tx {
if v, ok := b.readMeter(civ.SubMeterPo); ok {
po = v
}
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
swr = v
}
} }
} }
b.dspMu.Lock() b.dspMu.Lock()
@@ -434,11 +453,15 @@ func (b *IcomSerial) ReadState() (RigState, error) {
// snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig // snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig
// instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on // instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on
// demand, and a reconnect re-arms it (Connect clears dspLoaded). // demand, and a reconnect re-arms it (Connect clears dspLoaded).
if !b.dspLoaded { // Both of these only make sense for the console — don't spend the bus on them
b.readDSP() // while it is closed. The snapshot then loads the first time it is opened.
b.dspLoaded = true if watched {
} else { if !b.dspLoaded {
b.refreshFrontPanel() b.readDSP()
b.dspLoaded = true
} else {
b.refreshFrontPanel()
}
} }
return s, nil return s, nil
} }
@@ -532,12 +555,25 @@ func (b *IcomSerial) SetPower(on bool) error {
return fmt.Errorf("icom: not connected") return fmt.Errorf("icom: not connected")
} }
if on { if on {
buf := make([]byte, 0, 32) // The preamble has to last long enough IN TIME for the sleeping rig to
for i := 0; i < 25; i++ { // notice it, so its length scales with the baud rate — Icom's own table
// asks for 7 bytes at 4800 baud and 150 at 115200, which is a fixed
// ~25 ms of carrier. A flat 25 bytes (what this sent before) is right at
// 19200 and far too short above it, so a rig configured for 115200 simply
// ignored the command.
n := b.baud / 768
if n < 7 {
n = 7
} else if n > 150 {
n = 150
}
buf := make([]byte, 0, n+8)
for i := 0; i < n; i++ {
buf = append(buf, 0xFE) buf = append(buf, 0xFE)
} }
buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD) buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD)
_, err := b.port.Write(buf) _, err := b.port.Write(buf)
applog.Printf("icom: power ON — %d-byte wake preamble at %d baud, addr 0x%02X (err=%v)", n, b.baud, b.rigAddr, err)
return err return err
} }
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00)) _, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00))
@@ -1289,11 +1325,26 @@ func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) {
// ── IcomController: receive-DSP controls for the Icom tab ─────────────────── // ── IcomController: receive-DSP controls for the Icom tab ───────────────────
func (b *IcomSerial) IcomState() IcomTXState { func (b *IcomSerial) IcomState() IcomTXState {
// Asking for the snapshot is what marks the console as being watched, which
// is what re-enables the meter polling in ReadState.
b.dspWantMu.Lock()
b.dspWantAt = time.Now()
b.dspWantMu.Unlock()
b.dspMu.Lock() b.dspMu.Lock()
defer b.dspMu.Unlock() defer b.dspMu.Unlock()
return b.dsp return b.dsp
} }
// panelWatched reports whether the Icom console asked for the DSP snapshot
// recently — i.e. whether anything is on screen to read the meters.
func (b *IcomSerial) panelWatched() bool {
b.dspWantMu.Lock()
at := b.dspWantAt
b.dspWantMu.Unlock()
return !at.IsZero() && time.Since(at) < 3*time.Second
}
// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT // RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT
// goroutine (dispatched via IcomDo). // goroutine (dispatched via IcomDo).
func (b *IcomSerial) RefreshIcom() error { func (b *IcomSerial) RefreshIcom() error {
+119 -31
View File
@@ -29,6 +29,7 @@ package cat
// from the radio, and the rig file decides what a "Freq" property means. // from the radio, and the rig file decides what a "Freq" property means.
import ( import (
"bytes"
"errors" "errors"
"fmt" "fmt"
"net" "net"
@@ -71,6 +72,51 @@ type Kenwood struct {
curVFO string // "A" or "B" curVFO string // "A" or "B"
// Commands this rig answered "?;" to — asked once, then never again. // Commands this rig answered "?;" to — asked once, then never again.
unsupported map[string]bool unsupported map[string]bool
// rx holds bytes read but not yet consumed, ACROSS calls to ask.
//
// It has to survive: a rig answers faster than we ask, so one Read often
// returns a whole reply plus the start of the next frame. When this buffer
// was local to ask, everything after the matched frame was dropped — half a
// frame included — and the link desynchronised permanently: every ask then
// found the PREVIOUS command's answer and timed out waiting for its own.
// That is the "discarding \" 000000000010000000;\" while waiting for IF"
// a TS-480 reported, followed by connected=false for ever.
rx []byte
// heard is whatever arrived during Connect that was not a reply we wanted.
// Kept only to put it in the error message: "not answering" and "answering
// something unreadable" are different faults with different fixes.
heard string
// lowerLines deasserts DTR and RTS after opening the port.
//
// Neither default is safe for everyone, which is why this is a setting and
// not a decision. Windows raises both lines on open: an interface that reads
// them as PTT then keys the rig for as long as OpsLog is running — reported
// as FT8 output power wandering, and gone the moment OpsLog was closed.
// Lowering them instead silences the radios whose USB-serial interface needs
// RTS asserted to transmit at all — a TS-990 that opened cleanly and answered
// nothing. Off by default: that is how this backend behaved for its whole
// life before the question came up.
lowerLines bool
}
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
// before Connect.
func (k *Kenwood) SetLowerLines(v bool) {
k.mu.Lock()
k.lowerLines = v
k.mu.Unlock()
}
// where names the link for a message, so an error does not read "COM @ 0 baud"
// after a network connect.
func (k *Kenwood) where() string {
if k.host != "" {
return k.host
}
return k.portName
} }
// NewKenwoodTCP builds a backend that reaches the rig over a socket instead of // NewKenwoodTCP builds a backend that reaches the rig over a socket instead of
@@ -122,6 +168,14 @@ func (k *Kenwood) Connect() error {
// request/response pairs and make a reply impossible to attribute. We poll. // request/response pairs and make a reply impossible to attribute. We poll.
_ = k.write("AI0;") _ = k.write("AI0;")
// Start from silence. A reconnect inherits whatever the rig said last —
// unsolicited frames sent before AI0 landed, the tail of an answer nobody
// read — and one stale frame is enough to leave every ask one reply behind
// its question for the rest of the session.
k.rx = nil
k.drain(250 * time.Millisecond)
k.heard = ""
answered := false answered := false
if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") { if id, err := k.ask("ID;"); err == nil && strings.HasPrefix(id, "ID") {
answered = true answered = true
@@ -140,10 +194,22 @@ func (k *Kenwood) Connect() error {
} }
if !answered { if !answered {
k.model = "" k.model = ""
if k.host != "" { if k.heard == "" && len(k.rx) > 0 {
return fmt.Errorf("kenwood: %s accepted the connection but the rig is not answering — check that the serial bridge points at the radio and that the radio is switched on", k.host) k.heard = string(k.rx) // an unterminated fragment is evidence too
} }
return fmt.Errorf("kenwood: %s opened but the rig is not answering — check that it is switched on and set to %d baud", k.portName, k.baud) // Distinguish silence from noise. "The rig is not answering" sent an
// operator checking the power switch and the baud rate on a radio that was
// visibly talking — its frames were arriving, they just did not match what
// was asked (wrong baud garbles them; an interface echoing our own
// commands back produces the same). Say which of the two it is, and quote
// what came back, because that is the fact that decides where to look.
if seen := k.heard; seen != "" {
return fmt.Errorf("kenwood: %s is sending data but no reply to ID; or IF; — got %q. Check the baud rate (set to %d here) and that nothing else is echoing the port", k.where(), seen, k.baud)
}
if k.host != "" {
return fmt.Errorf("kenwood: %s accepted the connection but the rig sent nothing — check that the serial bridge points at the radio and that the radio is switched on", k.host)
}
return fmt.Errorf("kenwood: %s opened but the rig sent nothing — check that it is switched on and set to %d baud", k.portName, k.baud)
} }
// Name what was actually connected to. A log reading "connected on @ 0 baud" // Name what was actually connected to. A log reading "connected on @ 0 baud"
// after a network connect is the kind of line that sends someone hunting a // after a network connect is the kind of line that sends someone hunting a
@@ -300,6 +366,23 @@ func (k *Kenwood) write(cmd string) error {
return err return err
} }
// drain reads and throws away whatever the rig has already sent, until it stays
// quiet for one read timeout or the budget runs out.
func (k *Kenwood) drain(budget time.Duration) {
if k.port == nil {
return
}
tmp := make([]byte, 256)
deadline := time.Now().Add(budget)
for time.Now().Before(deadline) {
n, err := k.port.Read(tmp)
if err != nil || n == 0 {
return // an error here is not interesting: we are throwing this away
}
traceText("kenwood", "RX-drop", string(tmp[:n]))
}
}
// ask sends a query and returns the reply belonging to THAT command. Anything // ask sends a query and returns the reply belonging to THAT command. Anything
// else on the wire is discarded: a stray frame parsed as a frequency reads as // else on the wire is discarded: a stray frame parsed as a frequency reads as
// "lost the rig" to the Manager, which then reconnects — the CAT link dropping // "lost the rig" to the Manager, which then reconnects — the CAT link dropping
@@ -312,25 +395,18 @@ func (k *Kenwood) ask(cmd string) (string, error) {
if err := k.write(cmd); err != nil { if err := k.write(cmd); err != nil {
return "", err return "", err
} }
buf := make([]byte, 0, 64)
tmp := make([]byte, 64) tmp := make([]byte, 64)
deadline := time.Now().Add(600 * time.Millisecond) deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) { for {
n, err := k.port.Read(tmp) // Consume whatever is already buffered BEFORE reading more: the answer
if err != nil { // may have arrived attached to the previous one.
return "", err
}
if n == 0 {
continue // read timeout — the rig may still be composing its answer
}
buf = append(buf, tmp[:n]...)
for { for {
i := strings.IndexByte(string(buf), ';') i := bytes.IndexByte(k.rx, ';')
if i < 0 { if i < 0 {
break break
} }
frame := string(buf[:i+1]) frame := string(k.rx[:i+1])
buf = buf[i+1:] k.rx = k.rx[i+1:]
traceText("kenwood", "RX", frame) traceText("kenwood", "RX", frame)
if frame == "?;" { if frame == "?;" {
// The rig rejected the command. Remember it so the poll loop stops // The rig rejected the command. Remember it so the poll loop stops
@@ -343,9 +419,25 @@ func (k *Kenwood) ask(cmd string) (string, error) {
return frame, nil return frame, nil
} }
debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want) debugLog.Printf("kenwood: discarding %q while waiting for %s", frame, want)
// Remember the first unexpected frame: if the whole handshake fails, this
// is what tells the operator the radio was talking after all.
if k.heard == "" {
k.heard = frame
}
} }
if !time.Now().Before(deadline) {
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
}
n, err := k.port.Read(tmp)
if err != nil {
return "", err
}
if n > 0 {
k.rx = append(k.rx, tmp[:n]...)
}
// n == 0 is a read timeout, not silence for good: the rig may still be
// composing its answer.
} }
return "", fmt.Errorf("kenwood: timeout answering %q", cmd)
} }
// ── Frame parsing ────────────────────────────────────────────────────────── // ── Frame parsing ──────────────────────────────────────────────────────────
@@ -475,6 +567,10 @@ var kenwoodModels = map[string]string{
"019": "TS-2000", "019": "TS-2000",
"020": "TS-480", "020": "TS-480",
"021": "TS-590S", "021": "TS-590S",
// 022 reported by a real TS-990S in the field. Kenwood's documentation gives
// 024 for that radio, so both are kept: the observed value wins where they
// disagree, and neither maps to anything else.
"022": "TS-990S",
"023": "TS-590SG", "023": "TS-590SG",
"024": "TS-990S", "024": "TS-990S",
"025": "TS-890S", "025": "TS-890S",
@@ -496,20 +592,12 @@ func (k *Kenwood) openPort() (serial.Port, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Deassert DTR and RTS. // The modem lines are only touched when the operator asks for it. See
// // lowerLines: both defaults break somebody's station.
// Windows raises both when a serial port is opened, and a great many if k.lowerLines {
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into _ = p.SetDTR(false)
// transmit the moment OpsLog connects and STAYS there — Xiegu's own _ = p.SetRTS(false)
// documentation asks for RTS and DTR low. The same applies to an Icom with }
// USB SEND mapped to a line (which is why the CI-V backend has done this
// from the start) and to any rig keyed by a home-made cable.
//
// PTT on this backend is a CAT command, so neither line should ever be
// asserted here. A station keying by RTS/DTR configures that separately,
// on its own port.
_ = p.SetDTR(false)
_ = p.SetRTS(false)
return p, nil return p, nil
} }
+25 -2
View File
@@ -265,8 +265,8 @@ func TestKenwoodSilentRigIsNotConnected(t *testing.T) {
if err == nil { if err == nil {
t.Fatal("a silent port was reported as a connected rig") t.Fatal("a silent port was reported as a connected rig")
} }
if !strings.Contains(err.Error(), "not answering") { if !strings.Contains(err.Error(), "sent nothing") {
t.Errorf("error was %q — it should say the rig is not answering", err) t.Errorf("error was %q — a silent port should be reported as silence", err)
} }
if k.model != "" { if k.model != "" {
t.Errorf("a stale model survived a failed connect: %q", k.model) t.Errorf("a stale model survived a failed connect: %q", k.model)
@@ -355,3 +355,26 @@ func TestKenwoodSplitWhenFRFTRejected(t *testing.T) {
t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz) t.Errorf("IF-reported split broke: split=%v tx=%d rx=%d", s.Split, s.FreqHz, s.RxFreqHz)
} }
} }
// A rig that talks but never answers what was asked must not be reported as a
// silent one.
//
// The two faults need opposite responses: silence means the radio is off, the
// wrong port, or a dead cable; noise means the baud rate is wrong or something
// is echoing the line. "The rig is not answering" sent an operator checking the
// power switch on a radio whose frames were visibly arriving.
func TestKenwoodNoisyRigIsReportedAsNoiseNotSilence(t *testing.T) {
k := NewKenwood("COM-TEST", 9600, "FT8")
k.dialPort = func() (serial.Port, error) {
return &fakeSerial{toRig: &strings.Builder{}, answer: func(cmd string) string {
return "XX9999;" // something, but never the reply to ID; or IF;
}}, nil
}
err := k.Connect()
if err == nil {
t.Fatal("a rig answering gibberish was reported as connected")
}
if !strings.Contains(err.Error(), "sending data") || !strings.Contains(err.Error(), "XX9999;") {
t.Errorf("error was %q — it should say data arrived, and quote it", err)
}
}
+20 -17
View File
@@ -82,9 +82,12 @@ var yaesuModeToADIF = map[byte]string{
} }
type Yaesu struct { type Yaesu struct {
portName string // lowerLines deasserts DTR and RTS on connect — see Kenwood.lowerLines for
baud int // why neither default is safe for every station.
digital string // ADIF mode reported for DATA (FT8, RTTY…) lowerLines bool
portName string
baud int
digital string // ADIF mode reported for DATA (FT8, RTTY…)
mu sync.Mutex mu sync.Mutex
port serial.Port port serial.Port
@@ -127,6 +130,14 @@ func NewYaesu(portName string, baud int, digital string) *Yaesu {
return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"} return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"}
} }
// SetLowerLines chooses whether DTR and RTS are deasserted on connect. Set
// before Connect.
func (y *Yaesu) SetLowerLines(v bool) {
y.mu.Lock()
y.lowerLines = v
y.mu.Unlock()
}
func (y *Yaesu) Name() string { return "yaesu" } func (y *Yaesu) Name() string { return "yaesu" }
func (y *Yaesu) Connect() error { func (y *Yaesu) Connect() error {
@@ -150,20 +161,12 @@ func (y *Yaesu) Connect() error {
if err != nil { if err != nil {
return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err) return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err)
} }
// Deassert DTR and RTS. // The modem lines are only touched when the operator asks for it — see the
// // note on Kenwood.lowerLines. Both defaults break somebody's station.
// Windows raises both when a serial port is opened, and a great many if y.lowerLines {
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into _ = p.SetDTR(false)
// transmit the moment OpsLog connects and STAYS there — Xiegu's own _ = p.SetRTS(false)
// documentation asks for RTS and DTR low. The same applies to an Icom with }
// USB SEND mapped to a line (which is why the CI-V backend has done this
// from the start) and to any rig keyed by a home-made cable.
//
// PTT on this backend is a CAT command, so neither line should ever be
// asserted here. A station keying by RTS/DTR configures that separately,
// on its own port.
_ = p.SetDTR(false)
_ = p.SetRTS(false)
p.SetReadTimeout(300 * time.Millisecond) p.SetReadTimeout(300 * time.Millisecond)
y.port = p y.port = p
+6 -3
View File
@@ -196,7 +196,6 @@ func (s *Server) run() {
} }
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) { func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
applog.Printf("udp: [%s] rx %d bytes from %s\n", s.cfg.Name, len(pkt), remote)
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()} ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
switch s.cfg.ServiceType { switch s.cfg.ServiceType {
case ServiceWSJT: case ServiceWSJT:
@@ -229,8 +228,12 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.Mode = w.Mode ev.Mode = w.Mode
break break
} }
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n", // Only a logged QSO is worth a line — WSJT-X/MSHV send a Status packet
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF)) // every second, and logging each one buried the rest of the file.
if len(w.LoggedADIF) > 0 {
applog.Printf("udp: [%s] WSJT QSO logged: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
}
ev.DXCall = w.DXCall ev.DXCall = w.DXCall
ev.DXGrid = w.DXGrid ev.DXGrid = w.DXGrid
ev.Mode = w.Mode ev.Mode = w.Mode
+57 -3
View File
@@ -9,6 +9,7 @@ package powergenius
import ( import (
"bufio" "bufio"
"fmt" "fmt"
"math"
"net" "net"
"strconv" "strconv"
"strings" "strings"
@@ -36,6 +37,16 @@ type Status struct {
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) Operate bool `json:"operate"` // OPERATE vs STANDBY (optimistic until the amp reports it)
// Live power, read straight from the amplifier's own status frame rather
// than sampled off the FlexRadio meter stream. PeakW is the amp's own peak
// detector: a poll catches one instant of the envelope, so the plain forward
// figure lands between syllables as often as on a peak.
FwdW float64 `json:"fwd_w"` // forward power [W] (the frame reports dBm)
PeakW float64 `json:"peak_w"` // peak forward power [W]
Vswr float64 `json:"vswr"` // VSWR, derived from the frame's return loss in dB
Id float64 `json:"id"` // drain current [A]
PeakId float64 `json:"peak_id"` // peak drain current [A]
} }
type Client struct { type Client struct {
@@ -226,9 +237,9 @@ func (c *Client) parse(resp string) {
c.statusMu.Lock() c.statusMu.Lock()
c.status.Connected = true c.status.Connected = true
c.status.LastError = "" c.status.LastError = ""
// Log each DISTINCT status payload once: the PGXL's field set isn't fully // One raw frame per session is enough to learn the field set — the frames
// documented, so this is how we learn the real key for e.g. operate/standby. // carry live meter values, so "log on change" logged every frame.
if data != c.lastRaw { if c.lastRaw == "" {
c.lastRaw = data c.lastRaw = data
applog.Printf("pgxl: status raw=%q", data) applog.Printf("pgxl: status raw=%q", data)
} }
@@ -252,7 +263,50 @@ func (c *Client) parse(resp string) {
c.status.FanMode = dev c.status.FanMode = dev
case "temp": case "temp":
c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64) c.status.Temperature, _ = strconv.ParseFloat(kv[1], 64)
case "fwd":
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
c.status.FwdW = dbmToWatts(v)
}
case "peakfwd":
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
c.status.PeakW = dbmToWatts(v)
}
case "swr":
if v, err := strconv.ParseFloat(kv[1], 64); err == nil {
c.status.Vswr = returnLossToVswr(v)
}
case "id":
c.status.Id, _ = strconv.ParseFloat(kv[1], 64)
case "peakid":
c.status.PeakId, _ = strconv.ParseFloat(kv[1], 64)
} }
} }
c.statusMu.Unlock() c.statusMu.Unlock()
} }
// dbmToWatts converts a power reading in dBm to watts (0 dBm = 1 mW). The amp
// reports power that way — "fwd=60.5" is 1122 W, not 60 W.
func dbmToWatts(dbm float64) float64 {
if dbm <= 0 {
return 0
}
return math.Pow(10, (dbm-30)/10)
}
// returnLossToVswr converts the amp's "swr" field — a return loss in dB, sent
// negative for a good match — into the VSWR ratio an operator reads.
func returnLossToVswr(swrDb float64) float64 {
rl := math.Abs(swrDb)
if rl <= 0 {
return 0
}
rho := math.Pow(10, -rl/20)
if rho >= 1 {
return 0
}
vswr := (1 + rho) / (1 - rho)
if vswr > 99.9 || math.IsInf(vswr, 0) || math.IsNaN(vswr) {
return 0
}
return vswr
}
+120
View File
@@ -7,6 +7,10 @@
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss} // - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with // (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth. // <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
// - Dingtian IOT relay (DTWONDER) — 2/4/8/16/24/32 relays over its HTTP GET
// CGI. Control: GET /relay_cgi.cgi?type=0&relay=N&on=1&time=0&pwd=P&;
// status: GET /relay_cgi_load.cgi. Both answer &-separated fields.
// (IOT Relay Programming Manual V1.9.8.1, §3.)
// //
// A Device presents the same surface to the app regardless of wire protocol. // A Device presents the same surface to the app regardless of wire protocol.
package relaydev package relaydev
@@ -173,3 +177,119 @@ func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
} }
return out, nil return out, nil
} }
// ── Dingtian IOT relay (DTWONDER) ──────────────────────────────────────
//
// The board speaks several protocols (Modbus, MQTT, CoAP, its own binary); we
// use the HTTP GET CGI, which needs no connection state and matches how the
// other network boards here are driven.
//
// Both endpoints answer &-separated fields, first one 0 on success:
//
// /relay_cgi_load.cgi → &0&4&1&0&1&0&
// result, count, r1…rN
// /relay_cgi.cgi?type=0&relay=0&on=1&time=0&pwd=0& → &0&0&0&1&0&
// result, type, relay, on, time
//
// NOTE the relay index in the URL is ZERO-based (relay=0 is relay 1), while the
// Device interface is 1-based like every other board here.
type dingtian struct {
host string
session string // optional: the board can require "Cookie: session=<id>"
pwd string // CGI password, 09999; "0" (or blank) when none is set
count int
}
// NewDingtian builds a Dingtian IOT relay client. session is the HTTP session ID
// when the board has "HTTP Session" enabled (blank otherwise); pwd is its relay
// password (blank or "0" when none).
func NewDingtian(host, session, pwd string, count int) Device {
if count <= 0 {
count = 2
}
if strings.TrimSpace(pwd) == "" {
pwd = "0"
}
return &dingtian{host: host, session: strings.TrimSpace(session), pwd: strings.TrimSpace(pwd), count: count}
}
func (d *dingtian) Count() int { return d.count }
func (d *dingtian) Close() error { return nil } // stateless HTTP, nothing to release
// getCGI issues the GET with the session cookie the board may require.
func (d *dingtian) getCGI(ctx context.Context, url string) ([]byte, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
if d.session != "" {
req.Header.Set("Cookie", "session="+d.session)
}
resp, err := httpClient().Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return body, nil
}
// dtFields splits an &-separated CGI reply into its fields, dropping the empty
// ones the leading and trailing "&" produce.
func dtFields(body []byte) []string {
var out []string
for _, f := range strings.Split(strings.TrimSpace(string(body)), "&") {
if f = strings.TrimSpace(f); f != "" {
out = append(out, f)
}
}
return out
}
func (d *dingtian) Set(ctx context.Context, relay int, on bool) error {
if relay < 1 || relay > d.count {
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
}
state := 0
if on {
state = 1
}
// type=0 is plain ON/OFF (1 = jogging, 2 = delay, 3 = flash, 4 = toggle),
// and time is then unused.
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi.cgi?type=0&relay=%d&on=%d&time=0&pwd=%s&",
d.host, relay-1, state, d.pwd))
if err != nil {
return err
}
// A wrong password or session answers 200 with a non-zero result (e.g.
// "&302&/&"), so the HTTP status alone does not tell us it worked.
if f := dtFields(body); len(f) == 0 || f[0] != "0" {
return fmt.Errorf("dingtian: refused (%s) — check the relay password and the HTTP session ID",
strings.TrimSpace(string(body)))
}
return nil
}
func (d *dingtian) Status(ctx context.Context) ([]bool, error) {
body, err := d.getCGI(ctx, fmt.Sprintf("http://%s/relay_cgi_load.cgi", d.host))
if err != nil {
return nil, err
}
f := dtFields(body)
if len(f) < 2 || f[0] != "0" {
return nil, fmt.Errorf("dingtian: bad status reply %q", strings.TrimSpace(string(body)))
}
// The board reports its own relay count; trust it over the configured one so
// a mis-set channel count doesn't silently hide relays.
if n, e := strconv.Atoi(f[1]); e == nil && n > 0 && n <= 32 {
d.count = n
}
out := make([]bool, d.count)
for i := 0; i < d.count && i+2 < len(f); i++ {
out[i] = f[i+2] == "1"
}
return out, nil
}
+73
View File
@@ -99,3 +99,76 @@ func TestKMTronicSetURL(t *testing.T) {
} }
_ = d.Set(context.Background(), 1, false) // → /FF0100 _ = d.Set(context.Background(), 1, false) // → /FF0100
} }
// Dingtian — the wire examples come straight from the IOT Relay Programming
// Manual V1.9.8.1 §3.1/§3.2. The two traps pinned here: the URL relay index is
// ZERO-based while the Device interface is 1-based, and a refusal (bad password
// or session) answers HTTP 200 with a non-zero first field.
func TestDingtianStatus(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/relay_cgi_load.cgi" {
t.Errorf("unexpected status path %q", r.URL.Path)
}
// Manual's own example: ok, 4 relays, 1 on, 2 off, 3 on, 4 off.
_, _ = w.Write([]byte("&0&4&1&0&1&0&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
st, err := d.Status(context.Background())
if err != nil {
t.Fatal(err)
}
// The board said 4 relays even though 2 were configured — its count wins.
want := []bool{true, false, true, false}
if len(st) != len(want) {
t.Fatalf("got %d relays, want %d", len(st), len(want))
}
for i := range want {
if st[i] != want[i] {
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
}
}
if d.Count() != 4 {
t.Errorf("Count() = %d, want 4 (taken from the board)", d.Count())
}
}
func TestDingtianSetUsesZeroBasedIndexAndSession(t *testing.T) {
var gotQuery, gotCookie string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotQuery = r.URL.RawQuery
gotCookie = r.Header.Get("Cookie")
_, _ = w.Write([]byte("&0&0&2&1&0&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "12345678", "4660", 4)
if err := d.Set(context.Background(), 3, true); err != nil {
t.Fatal(err)
}
if !strings.Contains(gotQuery, "relay=2") {
t.Errorf("relay 3 must go out as relay=2 (zero-based); query was %q", gotQuery)
}
if !strings.Contains(gotQuery, "on=1") || !strings.Contains(gotQuery, "type=0") ||
!strings.Contains(gotQuery, "pwd=4660") {
t.Errorf("unexpected query %q", gotQuery)
}
if gotCookie != "session=12345678" {
t.Errorf("session cookie = %q, want session=12345678", gotCookie)
}
}
func TestDingtianSetRefusalIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
// Manual §3.4.4: a bad session still answers 200 OK.
_, _ = w.Write([]byte("&302&/&"))
}))
defer srv.Close()
d := NewDingtian(strings.TrimPrefix(srv.URL, "http://"), "", "", 2)
if err := d.Set(context.Background(), 1, true); err == nil {
t.Fatal("a refused command answered HTTP 200 and was reported as success")
}
}
+129 -26
View File
@@ -33,13 +33,11 @@ const (
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw) cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
cmdStatus byte = 0x90 // request the status string cmdStatus byte = 0x90 // request the status string
// Best-guess keystroke codes reconstructed from the APG command table after // From the official APG rev 1.1 command table. Note POWER does NOT switch a
// correcting the PDF's note-wrap misalignment (anchored to the confirmed // sleeping amp on (that is the RTS/DTR wake, see PowerOn) — on a running amp
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when // it cycles the output level L→M→H, which is all we use it for.
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the cmdOff byte = 0x0A // SWITCH OFF key — how PowerOff switches the amp off
// single physical POWER button. To be verified on hardware. cmdPower byte = 0x0B // POWER key — cycles the output power level
cmdOff byte = 0x0A // OFF key — switch the amplifier off
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
syncHost = 0x55 syncHost = 0x55
syncAmp = 0xAA syncAmp = 0xAA
@@ -47,11 +45,16 @@ const (
dialTimeout = 5 * time.Second dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second ioTimeout = 3 * time.Second
pollEvery = 800 * time.Millisecond pollEvery = 800 * time.Millisecond
// How long a modem line is held LOW before being raised again, to give the
// amp's edge-triggered remote-on input a transition it can see.
wakePulse = time.Second
) )
// Status is the decoded amplifier state for the UI. // Status is the decoded amplifier state for the UI.
type Status struct { type Status struct {
Connected bool `json:"connected"` Connected bool `json:"connected"`
Transport string `json:"transport"` // "serial" | "tcp" — the UI enables power-ON on serial even when asleep
LastError string `json:"last_error,omitempty"` LastError string `json:"last_error,omitempty"`
Model string `json:"model,omitempty"` // "20K" / "13K" Model string `json:"model,omitempty"` // "20K" / "13K"
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
@@ -91,6 +94,8 @@ type Client struct {
stop chan struct{} stop chan struct{}
running bool running bool
lastConnErr string // last connect failure logged (log only on change)
} }
func New(cfg Config) *Client { func New(cfg Config) *Client {
@@ -123,7 +128,13 @@ func (c *Client) Stop() {
func (c *Client) GetStatus() Status { func (c *Client) GetStatus() Status {
c.statusMu.RLock() c.statusMu.RLock()
defer c.statusMu.RUnlock() defer c.statusMu.RUnlock()
return c.status s := c.status
if c.cfg.Transport == "tcp" {
s.Transport = "tcp"
} else {
s.Transport = "serial"
}
return s
} }
func (c *Client) setErr(err error) { func (c *Client) setErr(err error) {
@@ -146,27 +157,79 @@ func (c *Client) Operate(on bool) error {
// ToggleOperate flips STANDBY/OPERATE unconditionally. // ToggleOperate flips STANDBY/OPERATE unconditionally.
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) } func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command // PowerOn switches the amplifier on with a RISING EDGE on RTS, then on DTR.
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs //
// the special SPE cable — so it only applies to the serial transport. Over TCP // Confirmed on a live 1.3K-FA. The remote-on input is EDGE-triggered, which is
// there is no DTR line and power-on isn't possible remotely. // why holding the lines high does nothing: a plain port open already leaves both
// high (the serial library's default), so there is no transition left to give.
// Hence low → pause → high on each line, in that order. The keystroke route is a
// dead end — a switched-off amp does not answer its UART at all, and the POWER
// key (0x0B) merely cycles L/M/H on an amp that is already running.
//
// Serial only: over TCP there are no modem lines. The port is (re)opened first,
// since the poll loop has dropped the connection to a sleeping amp long before
// the user clicks ON.
func (c *Client) PowerOn() error { func (c *Client) PowerOn() error {
if c.cfg.Transport == "tcp" {
return fmt.Errorf("power-on needs the serial RTS/DTR lines; not available over a network bridge")
}
// On an amp that is already awake there is nothing to wake.
if c.GetStatus().Connected {
return nil
}
// The poll goroutine may still be inside a blocking read on the old handle;
// Windows keeps the port "busy" until that read times out (ioTimeout). Retry
// the open for a little longer than that.
var err error
deadline := time.Now().Add(ioTimeout + 3*time.Second)
for {
if err = c.ensureConn(); err == nil || time.Now().After(deadline) {
break
}
time.Sleep(200 * time.Millisecond)
}
if err != nil {
applog.Printf("spe: power ON failed — cannot open %s: %v", c.cfg.ComPort, err)
return fmt.Errorf("power-on: %w", err)
}
c.mu.Lock() c.mu.Lock()
defer c.mu.Unlock()
sp, ok := c.conn.(serial.Port) sp, ok := c.conn.(serial.Port)
c.mu.Unlock()
if !ok { if !ok {
return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network") return fmt.Errorf("power-on needs a serial connection")
} }
// A single positive pulse (~1.2s) on DTR, as the manual describes. pulse := func(set func(bool) error) error {
if err := sp.SetDTR(true); err != nil { if err := set(false); err != nil {
return err return err
}
time.Sleep(wakePulse)
return set(true)
} }
time.Sleep(1200 * time.Millisecond) if err = pulse(sp.SetRTS); err == nil {
return sp.SetDTR(false) err = pulse(sp.SetDTR)
}
// Booting, the amp re-enumerates its USB interface, which leaves this handle
// pointing at a device that no longer exists — the amp came up and OpsLog
// still read "offline". Drop it; the poll loop reopens a fresh one as soon as
// the port is back.
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err)
return err
} }
// PowerOff presses the OFF key (switches the amp off). // PowerOff presses the SWITCH OFF key (0x0A) — the amp is running, so it hears
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) } // its UART. Dropping DTR then RTS switches it off too (it is the mirror of the
// wake, and does work when done by hand), but back-to-back from one click it
// did not, while this keystroke has never once failed. The amp then stays off:
// the poll loop keeps reopening the port with both lines high and that has
// never woken it — only the deliberate low→high sequence in PowerOn does.
func (c *Client) PowerOff() error {
err := c.sendCmd(cmdOff)
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
return err
}
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by // SetPowerLevel cycles the output power level (L/M/H) to the requested one by
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new // tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
@@ -206,9 +269,16 @@ func (c *Client) pollLoop() {
return return
case <-t.C: case <-t.C:
if err := c.ensureConn(); err != nil { if err := c.ensureConn(); err != nil {
// Logged once per distinct message: "the amp is on but OpsLog
// says offline" was impossible to diagnose without the reason.
if msg := err.Error(); msg != c.lastConnErr {
c.lastConnErr = msg
applog.Printf("spe: connect %s failed: %s", c.cfg.ComPort, msg)
}
c.setErr(fmt.Errorf("connect: %w", err)) c.setErr(fmt.Errorf("connect: %w", err))
continue continue
} }
c.lastConnErr = ""
// The amp streams status frames faster than one per poll, so a backlog // The amp streams status frames faster than one per poll, so a backlog
// builds up and we'd read stale frames (the display lagged ~15s behind // builds up and we'd read stale frames (the display lagged ~15s behind
// the real amp). Flush anything pending, THEN request + read exactly one // the real amp). Flush anything pending, THEN request + read exactly one
@@ -239,16 +309,48 @@ func (c *Client) ensureConn() error {
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout) nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
rwc = nc rwc = nc
} else { } else {
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud}) var sp serial.Port
// Plain open: both modem lines come up high (the library's default), which
// is what the interface needs to talk. That is not enough to switch the amp
// on — the remote-on input wants the deliberate low→high sequence PowerOn
// sends — so reconnecting never wakes an amplifier the operator switched
// off, and forcing either line low here would switch a running one off.
sp, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
if err == nil {
// Without this, a read on a sleeping amp blocks forever inside the
// Windows serial driver — the poll goroutine survived the window and
// the process took ~a minute to die.
_ = sp.SetReadTimeout(ioTimeout)
rwc = sp
}
} }
if err != nil { if err != nil {
return err return err
} }
c.conn = rwc c.conn = rwc
c.r = bufio.NewReader(rwc) if sp, ok := rwc.(serial.Port); ok {
// The driver reports a read timeout as "0 bytes, no error", which bufio
// spins on; surface it as a real error so the poll loop drops and retries.
c.r = bufio.NewReader(serialTimeoutReader{sp})
} else {
c.r = bufio.NewReader(rwc)
}
return nil return nil
} }
// serialTimeoutReader converts the serial driver's timeout convention (n=0,
// err=nil once SetReadTimeout expires) into an explicit error, so a bufio
// reader fails fast instead of retrying an amp that is asleep.
type serialTimeoutReader struct{ p serial.Port }
func (s serialTimeoutReader) Read(b []byte) (int, error) {
n, err := s.p.Read(b)
if n == 0 && err == nil {
return 0, fmt.Errorf("spe: read timeout")
}
return n, err
}
func (c *Client) dropLocked() { func (c *Client) dropLocked() {
if c.conn != nil { if c.conn != nil {
c.conn.Close() c.conn.Close()
@@ -361,9 +463,10 @@ func (c *Client) decodeCSV(payload string) {
c.statusMu.Lock() c.statusMu.Lock()
defer c.statusMu.Unlock() defer c.statusMu.Unlock()
// Log the raw status frame ONCE per change, so field alignment can be verified // One raw frame per session is enough to verify field alignment against real
// against real hardware (the doc's 19-field layout may differ per firmware). // hardware the frame carries live meter values, so logging each change
if payload != c.lastRaw { // meant logging nearly every frame.
if c.lastRaw == "" {
c.lastRaw = payload c.lastRaw = payload
applog.Printf("spe: status raw=%q fields=%d", payload, len(f)) applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
} }
+11 -1
View File
@@ -61,6 +61,7 @@ type Status struct {
FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported FwdDbm float64 `json:"fwd_dbm"` // forward power [dBm], as reported
FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm FwdW float64 `json:"fwd_w"` // forward power [W], derived from dBm
PeakW float64 `json:"peak_w"` // the device's own peak forward power [W] — what the meters show
SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match) SwrDb float64 `json:"swr_db"` // return loss [dB] as reported (negative = good match)
Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect) Vswr float64 `json:"vswr"` // VSWR ratio, derived from swr_db (1.0 = perfect)
@@ -405,7 +406,9 @@ func (c *Client) parse(resp string) {
if !strings.HasPrefix(data, "status") { if !strings.HasPrefix(data, "status") {
return return
} }
if data != c.lastRaw { // One raw frame per session is enough to verify field alignment — the
// frames carry live meter values, so "log on change" logged every frame.
if c.lastRaw == "" {
c.lastRaw = data c.lastRaw = data
applog.Printf("tunergenius: status raw=%q", data) applog.Printf("tunergenius: status raw=%q", data)
} }
@@ -439,6 +442,13 @@ func (c *Client) applyStatus(data string) {
c.status.FwdDbm = v c.status.FwdDbm = v
c.status.FwdW = dbmToWatts(v) c.status.FwdW = dbmToWatts(v)
} }
// The device's OWN peak detector. A poll samples one instant of the envelope,
// so on SSB the plain "fwd" figure lands between syllables as often as on a
// peak — the widget read a few hundred watts on a kilowatt transmission. This
// is the number to display.
if v, ok := parseFloat(kv["peak"]); ok {
c.status.PeakW = dbmToWatts(v)
}
if v, ok := parseFloat(kv["swr"]); ok { if v, ok := parseFloat(kv["swr"]); ok {
c.status.SwrDb = v c.status.SwrDb = v
c.status.Vswr = returnLossToVswr(v) c.status.Vswr = returnLossToVswr(v)
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.22.7" appVersion = "0.23.0"
// 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.
+64 -9
View File
@@ -1,13 +1,47 @@
# Amplifiers and Switches # Amplifiers and Switches
## PowerGenius XL (4O3A) amplifier ## Amplifiers
Direct TCP connection (Settings → PowerGenius): Settings → **Amplifier** is a **list**: configure several amps (e.g. two SPEs
run in parallel), each with its own name and connection. Brands:
- **Operate / Standby** and **fault** display. - **SPE Expert** (1.3K / 1.5K / 2K) — USB serial or an RS232-to-Ethernet
- **Fan-mode** selector (Standard / Contest / Broadcast). bridge. OPERATE/STANDBY, ON/OFF, Low/Mid/High power level, live status.
- When a Flex is connected, the amp controls + its **FWD / ID / TEMP** meters Switching the amplifier **on and off** uses the remote-on control lines, so
appear in the FlexRadio panel's Amplifier card. See [[FlexRadio]]. it needs the **USB (or RS-232) connection**, not a network bridge — the ON
button works even while the amplifier is off and reporting nothing.
- **ACOM** (500S / 600S / 700S / 1200S / 2020S) — serial or bridge.
OPERATE/STANDBY/OFF, live power / SWR / PA temperature / band / fan.
Power-ON works over serial when the cable wires the DTR/RTS pins.
- **PowerGenius XL** (4O3A) — direct TCP. Operate/Standby, fault display, and a
**fan-mode** selector (Standard / Contest / Broadcast).
Where the controls appear:
- An **amplifier card** in the FlexRadio panel and in **Station Control**:
OPERATE/STANDBY, ON/OFF, power level, output-power bar and live FWD /
drain-current / temperature meters. Several amps → one card each.
- A **status chip per amp** in the bottom bar: green ON AIR = OPERATE, orange =
STANDBY, red = offline. Clicking it toggles OPERATE/STANDBY.
- A **docked widget** (flame icon, next to the keyers): Operate, power ON/OFF,
the SPE power level and live watts / SWR / temperature, in the narrow column
format of the Tuner Genius widget. With several amplifiers configured, a
dropdown on the icon picks which one it shows — or **All**.
### Band-follow (amp on a second COM port)
An ACOM or SPE that normally polls the radio for its frequency can follow
OpsLog instead: OpsLog answers the amp's polls **as a Kenwood rig** on a second
COM port (Settings → Amplifier → frequency port). Useful when the real radio's
CAT port is already taken by OpsLog itself.
## Tuner Genius XL (4O3A)
Settings → Tuner Genius (IP only; port fixed at 9010). Live **SWR and forward
power** with peak-hold, **Tune / Bypass / Operate-Standby**, and the two
channels A/B (source, frequency, antenna) shown and click-selectable. Available
as a docked widget, a card in the FlexRadio panel and a card in Station
Control. Direct TCP — uses one of the box's connection slots.
## Antenna Genius (4O3A) antenna switch ## Antenna Genius (4O3A) antenna switch
@@ -16,8 +50,29 @@ Over TCP / GSCP — a docked **A/B antenna-switch** widget:
- One row per configured antenna, with a **Port A** and **Port B** button. - One row per configured antenna, with a **Port A** and **Port B** button.
- Colours: green = selected on port A, blue = selected on port B, red (pulsing) = - Colours: green = selected on port A, blue = selected on port B, red (pulsing) =
that port is transmitting. Clicking a selected port deselects it (→ None). that port is transmitting. Clicking a selected port deselects it (→ None).
- **Band filter** (funnel icon in the header): shows only the antennas configured - **Band filter** (funnel icon): shows only the antennas configured for the
for the current band, like the native app. Toggle it off to see all antennas. current band. Antennas with an unknown band mask are always shown.
Antennas with an unknown band mask are always shown.
Configure the host / password in Settings → Antenna Genius. Configure the host / password in Settings → Antenna Genius.
## Relay boards (Station Control)
Relay boards for antenna/accessory switching, with named buttons in Station
Control:
- **WebSwitch 1216H** (5 relays, LAN) and **KMTronic** 8-relay WEB (LAN, with
optional HTTP authentication).
- **Dingtian IOT relay** (2 to 32 relays, LAN or WiFi), driven over its HTTP
CGI. Give its IP address and pick the relay count; the **relay password** and
**session ID** fields are only needed if you enabled them on the board's own
web page — a factory board needs neither.
- **Denkovi** USB (4/8 relays, FT245) and generic **CH340 / LCUS** USB-serial
boards.
A Test-connection button and detection feedback live in the setup panel.
## Station Control
The Station Control tab is a dashboard of fixed-width panels that wrap to fill
the window — amplifier(s), tuner, relays, rotator, Ultrabeam… Drag a panel by
the grip on its left edge to reorder; the order is remembered.
+53 -16
View File
@@ -2,35 +2,72 @@
## CW keyer ## CW keyer
A CW keyer with **macros** and F-key macros. The keyer **engine** is selectable A CW keyer with **macros** on F-keys (F1F9; empty slots are hidden). The keyer
(Settings → CW Keyer): **engine** is selectable (Settings → CW Keyer):
- **WinKeyer** — K1EL WK1/2/3 over a COM port. - **WinKeyer** — K1EL WK1/2/3 over a COM port. The log names the generation
detected, and a byte-level trace can be enabled for bug reports.
- **Serial port (DTR=CW / RTS=PTT)** — OpsLog keys CW by toggling a serial
control line itself, the hardware method N1MM/WSJT-X use with a Yaesu SCU-17
or a generic keying interface. No WinKeyer needed. Choose the COM port, which
line carries CW, and *Invert keying* for interfaces that key backwards. This
is also the engine for an **FTDX10** (whose CAT refuses `KY`), on the rig's
second COM port.
- **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the - **Icom** — the radio's own keyer over CI-V, no extra hardware. Works over the
remote link too. (Requires the Icom CAT backend; set break-in to SEMI/FULL or remote link too. (Set break-in to SEMI/FULL or the rig keys the sidetone but
the rig keys the sidetone but stays in RX.) stays in RX.)
- **FlexRadio (CWX)** — keys CW straight over the SmartSDR connection, with
type-ahead and mid-send backspace.
- **Yaesu (rig keyer)** — the radio's internal keyer over CAT (`KY`), for the
FTDX101 / FT-991A / FT-710 family. See [[Yaesu]].
- **TCI** — for TCI backends. - **TCI** — for TCI backends.
**ESC** stops CW on whichever engine is active; typing a callsign aborts a
running macro. `<LOGQSO>` inside a macro logs the contact at that exact point.
**Auto-call** repeats a CQ with the gap you set measured *after* the message.
### ESM — Enter Sends Message
N1MM-style, for CW (Settings → CW Keyer). With the keyer on, **Enter** fires a
macro by QSO stage instead of logging: empty callsign → F1 (CQ); callsign
entered → F2 (report), focus jumps to RST; Enter in RST → F3 (TU), which logs
if the macro contains `<LOGQSO>`.
## Digital Voice Keyer (DVK) ## Digital Voice Keyer (DVK)
Record **F1F6** voice messages and transmit them. Set the audio devices, PTT Record **F1F6** voice messages and transmit them. Set the audio devices, PTT
method and gains in Settings → Audio. method and gains in Settings → Audio.
- **PTT** method: CAT, RTS, DTR, or none (VOX). - **PTT** method: CAT (whichever backend is active — every one supports PTT),
- **Test PTT** keys the rig briefly. On CAT/OmniRig the key is held ~1.5 s so the RTS, DTR, or none (VOX).
transmit command is actually sent (OmniRig is asynchronous and coalesces rapid - **Auto CQ** repeats a CQ-labelled message on a timer until you stop it.
writes). - The DVK refuses to transmit on non-phone modes — no speech on FT8.
- A **message level** control (Settings → Audio) drives the rig at the right
level even from a quietly recorded microphone. If the rig transmits almost
nothing, check its rear-input setting (on an FTDX10: SSB MOD SOURCE = REAR).
## QSO audio recording ## QSO audio recording
Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO WAV Continuous rolling capture. On **Log QSO** the contact is saved to a per-QSO
(`CALL_YYYYMMDD_HHMMSS.wav`), mixing RX + mic. Configure the recordings folder, WAV/MP3 (`CALL_YYYYMMDD_HHMMSS`), with pre-roll. With automatic recording off, a
format (WAV / MP3), pre-roll and gains in Settings → Audio. **red dot** beside the callsign records the contact by hand.
A recording can be **replayed on the air** to the station being worked, like a
voice-keyer message — with its **own level**, separate from the voice keyer (a
receiver line-out needs far less gain than a microphone). The play button
becomes a **stop** button while it transmits. Hidden on CW.
## CW decoder (RX audio → text)
**Tools → CW decoder** (or the ear button) decodes received CW while the mode
is CW: adaptive envelope with noise squelch, per-character dit/dah
classification, follows 1240 WPM and sloppy fists, rides QSB. Shows WPM /
pitch / level, locks onto the FlexRadio CW pitch automatically, and clicking a
decoded word fills the callsign. Clean-to-moderate signals decode solidly;
weak-signal pileups remain CW Skimmer territory.
## RX audio monitor (USB rigs) ## RX audio monitor (USB rigs)
Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB Settings → Audio → **Listen to radio** pipes the rig's received audio (its "USB
Audio CODEC" input) through your speakers, so you hear the radio inside OpsLog. Audio CODEC" input) through your speakers — the level applies live as you drag
**Talk to radio** keys PTT and pipes your mic to the rig's audio input. (Network it. **Talk to radio** keys PTT and pipes your mic to the rig's audio input.
audio for remote Icom is a work in progress — see
[[Remote Icom over the Internet]].)
+18 -4
View File
@@ -6,8 +6,15 @@ shared **globally** across profiles.
## Built-in awards ## Built-in awards
DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF, **DDFM** (French DXCC, WAS / WAZ / WAC, WPX, IOTA / POTA / SOTA / WWFF, **DDFM** (French
departments), and more — tracked **worked / confirmed / validated** by band and departments), **DLD** (DARC DOK), **H26** (Helvetia 26 cantons), the US
mode. counties (USA-CA), and more — tracked **worked / confirmed / validated** by
band and mode.
The awards panel has a **mode filter** (All / CW / Phone / Digital) that
applies to the whole award — bands, counts, confirmations and the contacts
behind a cell — and stacks with the worked/confirmed filter, so "worked on CW
but not confirmed on CW" is one click. Reference lists sort by reference or by
description.
## How matching works ## How matching works
@@ -121,13 +128,20 @@ drop off the list. This is how you close the gaps a matcher can't fill on its ow
## Live detection & manual refs ## Live detection & manual refs
- References are detected **live** as you enter a callsign. - References are detected **live** as you enter a callsign — from the looked-up
address *and* from the QTH, name, country, comment, note or grid you typed.
- An award can be marked **one reference per QSO** (used by DLD). When several
references then match the same contact, none is assigned silently — the
candidates are offered at log time and you pick. Off by default, so an n-fer
POTA activation still counts every park.
- You can **manually assign** a reference to a QSO (the *Award Refs* tab of the - You can **manually assign** a reference to a QSO (the *Award Refs* tab of the
QSO editor). It's stored in an ADIF extra (`APP_OPSLOG_AWARDREFS`) so it QSO editor). It's stored in an ADIF extra (`APP_OPSLOG_AWARDREFS`) so it
survives export/import — see [[Import and Export ADIF]] — and is honoured survives export/import — see [[Import and Export ADIF]] — and is honoured
everywhere (award panel, grid columns, totals). For a list-backed award the everywhere (award panel, grid columns, totals). For a list-backed award the
assigned reference must still be a **valid, listed** reference, and the QSO assigned reference must still be a **valid, listed** reference, and the QSO
must be **in scope** (rule 5) for it to count. must be **in scope** (rule 5) for it to count. A hand-assigned reference
**replaces** what the matcher found, so a correction survives the next
recompute.
## Reference lists & display ## Reference lists & display
+52 -14
View File
@@ -1,28 +1,46 @@
# CAT Control # CAT Control
OpsLog has **four native CAT backends** (Settings → CAT). Each auto-reconnects OpsLog has **eight CAT backends** (Settings → CAT). Each auto-reconnects and
and connects *non-blocking*, so a powered-off radio never freezes the app. connects *non-blocking*, so a powered-off radio never freezes the app. Backends
are named by **how the radio is reached**:
| Backend | Use it for | | Backend | Use it for |
|---------|-----------| |---------|-----------|
| **OmniRig** | Any OmniRig-supported rig (Rig 1 / Rig 2, hot-swap). | | **OmniRig** | Any OmniRig-supported rig (Rig 1 / Rig 2, hot-swap). |
| **FlexRadio (SmartSDR)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] | | **FlexRadio (API)** | FlexRadio 6000/8000 over the TCP API. → [[FlexRadio]] |
| **Icom CI-V** | Any Icom over **USB** *or* over the internet via its **LAN server**. → [[Icom]] · [[Remote Icom over the Internet]] | | **Yaesu (USB)** | FTDX10 / FTDX101 family, direct serial — no OmniRig. → [[Yaesu]] |
| **Kenwood (USB, network)** | TS-590 / TS-890 / TS-990 / TS-2000 (Elecraft K3/K4 speak the same dialect), serial or a `host:port` serial bridge. → [[Kenwood and Xiegu]] |
| **Xiegu (USB)** | G90 / X6100 / X6200 / X5105 — CI-V with their reduced command set. → [[Kenwood and Xiegu]] |
| **Icom (CI-V USB)** | Any Icom over USB. → [[Icom]] |
| **Icom (CI-V network)** | An Icom's built-in LAN/internet server, replacing RS-BA1. → [[Remote Icom over the Internet]] |
| **TCI** | SunSDR / ExpertSDR2 and any TCI (WebSocket) server. | | **TCI** | SunSDR / ExpertSDR2 and any TCI (WebSocket) server. |
Once connected, frequency / band / mode follow the radio, clicking a cluster spot Once connected, frequency / band / mode follow the radio, clicking a cluster
tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter. spot tunes the rig, and (for Flex) cluster spots can be pushed to the panadapter.
## Sharing the rig with WSJT-X / JTDX / MSHV (rigctld)
A native backend holds the radio's serial port **exclusively** — so OpsLog can
serve its rig connection to other programs instead: **Settings → CAT → Share
CAT**. In WSJT-X / JTDX / MSHV / Log4OM pick rig model **Hamlib NET rigctl** at
`127.0.0.1:4532`. Works with every backend and both Hamlib command dialects.
> Never point two programs at the same COM port. If another logger or WSJT-X
> opens the rig's port directly while OpsLog holds it, you get "port busy"
> errors, garbled CI-V frames and erratic rig behaviour. One master — OpsLog —
> and everyone else through the shared rigctl port.
## OmniRig ## OmniRig
Pick **Rig 1** or **Rig 2** (the two OmniRig slots). Configure the actual COM Pick **Rig 1** or **Rig 2** (the two OmniRig slots). Configure the actual COM
port / baud in OmniRig's own settings. Works with anything OmniRig supports; PTT port / baud in OmniRig's own settings.
keying via CAT is supported where the rig's OmniRig profile exposes it.
## FlexRadio - **VFO to read** — some OmniRig rig files declare the wrong active VFO (an
IC-7610 file declared VFO B). If the frequency looks frozen or follows the
Enter the radio's IP (UDP discovery assists). You get a full SmartSDR-style wrong VFO, force **A** or **B** here.
control tab — see **[[FlexRadio]]**. - OpsLog and OmniRig must run at the **same privilege level** — one elevated
("as administrator") and not the other cannot see each other; OpsLog detects
and names this case.
## Icom CI-V (USB) ## Icom CI-V (USB)
@@ -31,7 +49,7 @@ control tab — see **[[FlexRadio]]**.
- **COM port** + **baud** must match the radio (set *CI-V USB Echo Back* **OFF** - **COM port** + **baud** must match the radio (set *CI-V USB Echo Back* **OFF**
on the rig). on the rig).
- The model choice also tailors the console (e.g. attenuator steps: 20 dB on an - The model choice also tailors the console (e.g. attenuator steps: 20 dB on an
IC-7300, 6/12/18 dB on an IC-7610). IC-7300, 6/12/18 dB on an IC-7610/7800).
Full console details: **[[Icom]]**. Full console details: **[[Icom]]**.
@@ -40,10 +58,30 @@ Full console details: **[[Icom]]**.
Enter the TCI host + port (default 40001). Gives freq / mode / PTT / split and, Enter the TCI host + port (default 40001). Gives freq / mode / PTT / split and,
optionally, panorama spots. optionally, panorama spots.
## Serial lines (DTR / RTS)
Windows raises the DTR and RTS lines when a serial port opens, and some
interfaces read one of them as **PTT** — the radio goes into transmit the moment
OpsLog connects. Others need the lines **high** to work at all. So:
- **Xiegu** always lowers both on connect (a G90 behind a DE-19 keyed on open),
and has a setting for which line keys the rig.
- **Yaesu and Kenwood** have an option (off by default) to lower both lines on
connect — turn it on only if your interface transmits as soon as OpsLog opens
the port.
- **Icom CI-V** has always dropped them.
## Protocol trace
Settings → CAT can log the **raw protocol** (every CI-V frame, or every Kenwood
command, as hex/text) to the diagnostic log — invaluable when reporting a radio
that answers oddly. The checkbox shows whether the trace is really running.
## Notes ## Notes
- The **digital sub-mode** (FT4 vs FT8) is inferred from the frequency. - The **digital sub-mode** (FT4 vs FT8) is inferred from the frequency.
- A radio reporting LSB or USB selects **SSB** in the entry form.
- **Per-band Flex RX/TX antennas** can be configured and are applied - **Per-band Flex RX/TX antennas** can be configured and are applied
automatically on band change (Settings → FlexRadio). automatically on band change (Settings → CAT → FlexRadio).
- Split, RIT/XIT and other rig-specific features are exposed on each backend's - Split, RIT/XIT and other rig-specific features are exposed on each backend's
control tab where supported. control tab where supported.
+24 -6
View File
@@ -3,27 +3,39 @@
## Servers ## Servers
Add multiple cluster servers (Settings). They auto-reconnect; one is the Add multiple cluster servers (Settings). They auto-reconnect; one is the
**master** used for sending commands. The Cluster tab and the Main-view cluster **master** used for sending commands. A quiet node is not treated as a dead one
pane share the same live feed. — silence keeps its session, login and filters. The Cluster tab and the
Main-view cluster pane share the same live feed.
## Filter sidebar ## Filter sidebar
A show/hide **filter sidebar** (shared by the Cluster tab and the Main pane): A show/hide **filter sidebar** (shared by the Cluster tab and the Main pane):
- **Callsign** search, **hide worked**, **group duplicates**. - **Callsign** search, **hide worked**, **group duplicates**.
- Filter by **band / mode / status / source**. - Filter by **band / mode / status / source**, plus **NEW POTA** and
**NEW COUNTY** chips.
## Per-spot status and tuning ## Per-spot status and tuning
Each spot is coloured by **status** relative to your log: Each spot is coloured by **status** relative to your log:
- **new** (entity never worked), **new-band**, **new-slot**, **worked**. - **new** (entity never worked), **new-band**, **new-mode**, **new-slot**,
**worked**.
- Settings → General → **Group digital modes as one (DXCC-style)** makes
FT8/FT4/RTTY/PSK count as a single Digital mode for the colouring and the
matrix badges, matching how DXCC counts.
Click a spot to **tune the rig** (fills the callsign into the entry strip). A Click a spot to **tune the rig** (fills the callsign into the entry strip). A
multi-band **Band Map** shows panadapter-style strips per band. multi-band **Band Map** shows panadapter-style strips per band — its CW / data
/ phone sub-bands use their own colour-blind-checked hues, and **Ctrl+↑ /
Ctrl+↓** jumps to the next spot above/below the current frequency and tunes it.
**POTA** spots are tagged with their park reference (via `api.pota.app`). **POTA** spots are tagged with their park reference (via `api.pota.app`).
The entry strip's band matrix can also show the **ClubLog Most Wanted rank**
(Settings → General): an `MW #rank` pill next to the entity name, personalised
to your callsign, refreshed daily.
## Sending a spot ## Sending a spot
Use the **Send Spot** window to announce a DX spot on the master cluster: Use the **Send Spot** window to announce a DX spot on the master cluster:
@@ -35,6 +47,12 @@ frequency** (full resolution, including sub-kHz).
**Tools → Alert management** — Log4OM-style alert rules on **call / country / **Tools → Alert management** — Log4OM-style alert rules on **call / country /
band / mode / spotter**, with **sound**, **visual** and **e-mail** notification. band / mode / spotter**, with **sound**, **visual** and **e-mail** notification.
Active alerts appear as a discreet bell in the entry strip; click it for the Active alerts appear as a discreet bell in the entry strip; click it for the
list. Alerts are kept briefly and only shown when active. list.
## External spot sources
A **DXHunter** remote-call UDP packet fills the callsign, and when it carries
`<FREQ>` and `<MODE>` it also tunes whichever CAT backend is active. See
[[Settings and Data]] for the UDP configuration.
See also: [[Maps and Antennas]] for turning the beam to a spot. See also: [[Maps and Antennas]] for turning the beam to a spot.
+8 -6
View File
@@ -1,10 +1,11 @@
# OpsLog # OpsLog
A modern, fast ham-radio logger for **Windows** — Log4OM-style entry, real-time A modern, fast ham-radio logger for **Windows** — Log4OM-style entry, real-time
CAT for **OmniRig**, native **FlexRadio/SmartSDR**, native **Icom CI-V** (USB CAT for **OmniRig**, native **FlexRadio/SmartSDR**, **Icom CI-V** (USB *and*
*and* remote-over-internet, replacing RS-BA1) and **TCI** (SunSDR / Expert remote-over-internet, replacing RS-BA1), **Yaesu**, **Kenwood**, **Xiegu** and
Electronics), a DX cluster with spot alerts, awards tracking, maps, contest **TCI** (SunSDR / Expert Electronics) — with **CAT sharing** so WSJT-X & co use
logging, QSL management and a QSL-card designer. the same rig link — a DX cluster with spot alerts, awards tracking, maps,
contest logging, QSL management and a QSL-card designer.
Built with **Wails v2** (Go backend + React/TypeScript frontend), **pure Go** Built with **Wails v2** (Go backend + React/TypeScript frontend), **pure Go**
(no CGO). SQLite holds the configuration; the logbook can be the local SQLite (no CGO). SQLite holds the configuration; the logbook can be the local SQLite
@@ -18,14 +19,15 @@ Developed by **F4BPO**.
1. **[[Installation]]** — download / build and first launch. 1. **[[Installation]]** — download / build and first launch.
2. **[[Getting Started]]** — the entry strip, logging your first QSO, choosing a 2. **[[Getting Started]]** — the entry strip, logging your first QSO, choosing a
profile. profile.
3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, TCI). 3. **[[CAT Control]]** — connect your radio (OmniRig, FlexRadio, Icom, Yaesu,
Kenwood, Xiegu, TCI).
## What OpsLog does ## What OpsLog does
| Area | Pages | | Area | Pages |
|------|-------| |------|-------|
| Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] | | Enter and manage QSOs | [[Logging Basics]] · [[Recent QSOs and Filters]] · [[Import and Export ADIF]] |
| Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] | | Control your radio | [[CAT Control]] · [[FlexRadio]] · [[Icom]] · [[Remote Icom over the Internet]] · [[Yaesu]] · [[Kenwood and Xiegu]] |
| Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] | | Chase DX | [[DX Cluster and Spots]] · [[Maps and Antennas]] |
| Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] | | Station hardware | [[Amplifiers and Switches]] · [[Audio and Keyers]] |
| Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] | | Events & contests | [[Contest Logging]] · [[Net Control]] · [[Multi-Operator Live Status]] |
+18 -6
View File
@@ -8,22 +8,34 @@ export modes.
Import an `.adi` / `.adif` file into the active profile's logbook. On import, Import an `.adi` / `.adif` file into the active profile's logbook. On import,
OpsLog parses every field, maps standard fields to columns and keeps unknown but OpsLog parses every field, maps standard fields to columns and keeps unknown but
valid ADIF fields in a per-QSO **extras** store (editable later). Country / zones valid ADIF fields in a per-QSO **extras** store (editable later). Country /
are enriched from `cty.dat` where missing. zones are enriched from `cty.dat` where missing. A file **without a header**
(records pasted from another log) imports fine.
- **Field remapping**: the import dialog can move a field as it reads it —
e.g. the RSGB IOTA contest exports the island reference in `STATE`; add a
`STATE → IOTA` row and it lands where the award looks.
- **Fill from profile**: optionally stamps your station fields *and* your
default confirmation statuses (paper QSL, LoTW, eQSL, Club Log, HRDLog,
QRZ.com) on QSOs the file leaves empty — a WSJT-X log carries almost none.
Existing values are never overwritten.
## Export ## Export
- **Whole log**, **selected rows**, or the **filtered view** (no limit) — see - **Whole log**, **selected rows**, or the **filtered view** (no limit) — see
[[Recent QSOs and Filters]]. [[Recent QSOs and Filters]].
- **Standard** mode exports the common promoted fields; **All** mode also exports - **Standard** mode exports the common promoted fields; **All** mode also
every extra field. exports every extra field; **Choose fields…** opens a picker that separates
the official ADIF 3.1.7 fields (by category) from the OpsLog / non-standard
tags actually present in your log, with All/None per group. Your selection is
remembered.
- **Cabrillo** export for contest submission. - **Cabrillo** export for contest submission.
## Round-trip safety ## Round-trip safety
Award references you assign manually are stored in an ADIF extra field Award references you assign manually are stored in an ADIF extra field
(`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does not (`APP_OPSLOG_AWARDREFS`), so exporting your full log and re-importing it does
lose your award-reference assignments. not lose your award-reference assignments.
## Tips ## Tips
+49
View File
@@ -0,0 +1,49 @@
# Kenwood and Xiegu (native CAT)
## Kenwood (USB, network)
A native backend talks straight to a **TS-590 / TS-890 / TS-990 / TS-2000**
over its serial port — frequency, mode, VFO, split and PTT, no OmniRig in
between. **Elecraft K3/K4** speak the same dialect. Pick **Kenwood (USB,
network)** in Settings → CAT.
- **Serial**: COM port + baud.
- **Network**: give a `host:port` instead — for a serial bridge (ser2net, an
Ethernet-serial adapter). *Not* the radio's own RJ45, which speaks Kenwood's
KNS/ARCP protocol.
- Split is detected from FR/FT when the radio omits it from its status frame.
- The backend was tested end-to-end against a TS-2000 emulator and reports
useful errors: a port that opens but stays silent is distinguished from one
sending data that never answers (the error quotes what arrived).
## Xiegu (G90, X6100, X6200, X5105)
These radios speak CI-V but with a **reduced command set** (no scope, no DSP
block, no data mode), so they have their own backend — pick **Xiegu (USB)** in
Settings → CAT. Frequency, mode, split and PTT.
### PTT on a G90 — read this
A **G90 ignores the CI-V PTT command**. That has three consequences:
1. In the Xiegu settings, choose **how the rig is keyed**: CI-V command, RTS or
DTR — pick the hardware line your interface wires to PTT.
2. WSJT-X & co transmit through OpsLog's **shared CAT link** (Hamlib NET rigctl
`127.0.0.1:4532` — see [[CAT Control]]): OpsLog converts the PTT request
into the hardware line. This is the supported path and it works.
3. **WSJT-X + OmniRig will NOT transmit** a G90: OmniRig sends PTT as a CI-V
command, which the radio ignores. Either use the native Xiegu backend +
rigctl sharing, or configure the PTT line (RTS/DTR) inside OmniRig itself.
### Keying on connect
Behind an interface that carries PTT on a modem line (e.g. a DE-19), a G90 used
to go **straight into transmit when OpsLog connected** — Windows raises DTR and
RTS when a serial port opens. The Xiegu backend now drops both lines on
connect, then drives only the line you selected for keying.
## Notes
- Both backends refuse a CI-V/CAT frame read at the wrong offset, so a garbled
answer can no longer turn into a phantom frequency.
- The protocol trace (Settings → CAT) covers both — see [[CAT Control]].
+31 -8
View File
@@ -10,8 +10,24 @@ save. Fields: callsign, RST tx/rx, name, QTH, grid, band, mode, TX/RX frequency
CQ/ITU zones, continent) from `cty.dat` including `/MM` `/AM` `/B` and call-area CQ/ITU zones, continent) from `cty.dat` including `/MM` `/AM` `/B` and call-area
handling (`/8`, `/W6`), plus **ClubLog** DXpedition date overrides. handling (`/8`, `/W6`), plus **ClubLog** DXpedition date overrides.
- **Callsign lookup** (QRZ.com / HamQTH) fills name / QTH / grid and shows the - **Callsign lookup** (QRZ.com / HamQTH) fills name / QTH / grid and shows the
photo and a QRZ.com tab. Configure it in Settings → Lookup. photo and a QRZ.com tab. Configure it in Settings → Lookup. Portable calls
(`/M`, `/P`) are looked up on the home call too.
- **No lookup account?** The name / QTH / locator are recovered from the **last
time you worked the station**, and for US calls the offline **FCC (ULS)**
database fills state, county and a 6-character grid as you type. A real
QRZ/HamQTH hit still wins.
- A green **NEW** badge appears on the County field (Info / F2) when the US
county has never been worked.
- **ESC** clears the callsign (and stops CW). - **ESC** clears the callsign (and stops CW).
- The frequency readout is **scroll-tunable**: roll the wheel over the
hundreds / tens / units-of-kHz digit and the rig follows.
## Super Check Partial / N+1
Enable in Settings → General (downloads the community MASTER.SCP list). As you
type a call, a docked two-column widget shows known calls that **contain** what
you typed (Partial) and calls **one character away** (N+1) — click one to fix a
busted call.
## Editing a QSO ## Editing a QSO
@@ -22,14 +38,18 @@ fields** editor for any ADIF field not promoted to a column.
## Bulk operations ## Bulk operations
Select rows in Recent QSOs, right-click: Select rows in Recent QSOs (a **Select all** button takes every displayed row),
right-click:
- **Fix country & zones** from `cty.dat`, or **update from QRZ / ClubLog**. - **Fix country & zones** from `cty.dat`, or **update from the callsign
- **Bulk edit field** — set one field across many QSOs (QSL/upload status, my databases** (QRZ / ClubLog), with a progress bar.
station, contest, propagation, contacted-station refs, comment…). Great for - **Bulk edit field** — set one field across many QSOs: QSL/confirmation
flipping a batch to *Requested* before an upload. statuses *and dates*, my station, contest, propagation, contacted-station
refs, comment, **gridsquare**, even the **frequency** (band recomputed,
out-of-band values refused).
- **Send to** a QSL service, **export** selected / filtered to ADIF or Cabrillo, - **Send to** a QSL service, **export** selected / filtered to ADIF or Cabrillo,
**delete**. **delete** (optionally withdrawing the QSO from QRZ.com and Club Log —
Settings → External services, off by default).
## Find duplicates ## Find duplicates
@@ -40,6 +60,9 @@ same day or same minute) and lets you pick which to delete.
- **Worked-before matrix** shows prior contacts with the entity per band/mode - **Worked-before matrix** shows prior contacts with the entity per band/mode
slot as you type. slot as you type.
- Awards references are detected **live** as you enter a call (see [[Awards]]). - Award references are detected **live** as you enter a call — including from
the QTH, name, comment or grid you typed (see [[Awards]]).
- The callsign in the top bar is the **station switcher**: click it to change
profile.
See also: [[Recent QSOs and Filters]] · [[Import and Export ADIF]] See also: [[Recent QSOs and Filters]] · [[Import and Export ADIF]]
+27 -15
View File
@@ -3,36 +3,48 @@
## Main view ## Main view
The Main view is **two configurable panes** (per profile, Settings → General → The Main view is **two configurable panes** (per profile, Settings → General →
*Main view*). Choose from: great-circle map, locator (street) map, the cluster *Main view*), with a **draggable divider** between them (double-click it to
grid, the worked-before grid, recent QSOs, the **FlexRadio** controls, the even them out). Choose from: great-circle map, locator (street) map, the
**Icom** console, or the **Net control** panel. cluster grid, the worked-before grid, recent QSOs, the **FlexRadio** controls,
the **Icom** or **Yaesu** console, or the **Net control** panel.
## Great-circle map ## Great-circle map
- Short/long-path **distance & azimuth** to the worked station. - Short/long-path **distance & azimuth** to the worked station.
- Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite (all - Selectable, **key-free** basemaps: Light / Voyager / Street / Satellite.
labelled).
- The **antenna beam lobe(s)** are drawn from the rotor azimuth. - The **antenna beam lobe(s)** are drawn from the rotor azimuth.
- **Day/night terminator** and twilight band (button at the top right).
## Locator (street) map ## Locator (street) map
An OSM street map centred on the contacted grid square — handy for local / An OSM street map centred on the contacted grid square — handy for local /
portable contacts. No API key. portable contacts. No API key.
## Rotor compass ## Rotators
An azimuthal-equidistant **click-to-turn** compass driven by **PstRotator**. The An azimuthal-equidistant **click-to-turn** compass; the current heading and
current heading and target are shown; click a bearing to turn the rotor. target are shown. Three rotator types (Settings → Rotator):
## Ultrabeam - **PstRotator** — via its UDP interface.
- **microHAM ARCO** — native, over LAN or USB: set the ARCO's CONTROL PROTOCOL
to *Yaesu GS-232A*.
- **GS-232A (generic)** — any GS-232A controller, ERC (Easy Rotor Control)
included; serial speed selectable. Set an ERC to GS-232 emulation, not
Hy-Gain DCU-1.
Ultrabeam antennas are supported with three modes — **Normal**, **180° reverse**, ## Motorized antennas (Ultrabeam / SteppIR)
Both follow the **rig frequency** (band change → retune) and retune immediately
when you click a spot, with three modes — **Normal**, **180° reverse**,
**Bidirectional**: **Bidirectional**:
- The **radiating direction** is shown in green and the **mechanical boom** in - The **radiating direction** is shown in green and the **mechanical boom** in
grey, on both the compass and the map, so you always know where the antenna grey, on both the compass and the map.
actually points. - On a FlexRadio, transmit is **inhibited while the elements move**, and
- The antenna **follows the rig frequency** (band change → retune), and retunes released as soon as the antenna is tuned.
immediately when you click a spot. - **SteppIR — Tunable range** (Settings → Hardware → Antenna, default
1354 MHz): outside it OpsLog leaves the antenna and the TX interlock
completely alone, so tuning to 30 m on a 20 m6 m SteppIR does nothing.
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch). Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch, Tuner
Genius, relays).
+28 -10
View File
@@ -6,18 +6,29 @@ Every setting in OpsLog is **per-profile**: station identity, CAT, lookup, QSL
defaults, awards display, antennas, and which logbook to use. Create one profile defaults, awards display, antennas, and which logbook to use. Create one profile
per callsign / station / event. per callsign / station / event.
Switch or manage profiles in Settings. The active profile decides which logbook The **callsign in the top bar is the station switcher** — click it and pick a
your QSOs go to. profile. Managing profiles is at the bottom of that list (and in Settings).
## Config vs. logbook — two different stores ## Two separate databases
Since 0.21.0 the settings and the QSOs live in **separate files**:
| Store | What | Where | | Store | What | Where |
|-------|------|-------| |-------|------|-------|
| **Config** | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` | | **Settings database** (`settings.db`, or `opslog.db` on older installs) | settings, profiles, rigs/antennas, cluster nodes, lookup cache, award lists, QSL templates | always the **local SQLite** file under `data/` |
| **Logbook** | your QSOs | where the active profile points — local SQLite **or** shared MySQL | | **Logbook** (`logbook.db`) | your QSOs | where the active profile points — a local SQLite file **or** shared MySQL |
Keeping config local means the UI is instant even when the logbook is a far-away Keeping the settings local means the UI is instant even when the logbook is a
MySQL server. far-away MySQL server. Settings → Database shows the two as separate sections,
with an *Open folder* shortcut; a logbook file can be **renamed or relocated**
(its QSOs move with it), and the status bar names the **logbook** file — that
is the one to back up.
A profile can point at its **own logbook file** — ideal for a visiting
operator, whose contacts stay out of your log without touching your settings.
**Portable**: paths inside the OpsLog folder are stored relative to it, so
moving the whole folder to another drive or PC loses nothing.
## Shared MySQL logbook (multi-operator) ## Shared MySQL logbook (multi-operator)
@@ -25,11 +36,18 @@ Point a profile's logbook at a **MySQL** database so several operators run **one
log** — e.g. a multi-op special-event call. Configure the host / database / log** — e.g. a multi-op special-event call. Configure the host / database /
credentials in the profile's database settings. credentials in the profile's database settings.
This is also what powers [[Multi-Operator Live Status]] (each instance heartbeats - The database and tables are converted to **utf8mb4** on connect, so Cyrillic
its activity into a `live_status` table). or Polish characters from a QRZ lookup store correctly even when the hosting
panel created the database as latin1.
- An FT8/MSHV QSO that cannot reach a laggy shared database is **parked and
re-logged automatically** once it recovers — no lost QSOs.
- Switching to a profile whose MySQL is unreachable shows a clear warning
instead of silently staying on the previous logbook.
This is also what powers [[Multi-Operator Live Status]].
## Backups ## Backups
Optional **database + ADIF backup at shutdown** (Settings → Backup). Regardless, Optional **database + ADIF backup at shutdown** (Settings → Backup). Regardless,
copying the `data/` folder backs up your config and local logbook. See copying the `data/` folder backs up your settings and local logbook. See
[[Settings and Data]]. [[Settings and Data]].
+22 -8
View File
@@ -4,13 +4,23 @@
The main log table. Double-click a row to edit; right-click for bulk actions The main log table. Double-click a row to edit; right-click for bulk actions
(see [[Logging Basics]]). Columns are configurable — click **Columns** to choose (see [[Logging Basics]]). Columns are configurable — click **Columns** to choose
which are visible; your selection is saved (per profile). which are visible; widths, order and hidden columns are saved (per profile),
with a separate layout for the narrower Main-tab pane.
- The selected-row count is always visible at the top, and a **Select all /
Unselect all** button takes every displayed row.
- QSL and upload status columns are coloured: **Y** green, **N** red, **R**
blue.
- A **Distance (km)** column (also in Worked-before) is computed from the QSO's
own locator pair.
- There are **no per-column header filters** — they only ever searched the rows
on screen, which made a QSO further back in the log look missing. Use the
advanced filter, which queries the whole logbook.
### Award columns ### Award columns
Each defined award can show a column with the reference the QSO counts for. These Each defined award can show a column with the reference the QSO counts for.
columns are **hidden by default** and opt-in from the Columns picker — turn on These columns are **hidden by default** and opt-in from the Columns picker.
only the awards you're chasing. Your choice persists across restarts.
## Advanced filter builder ## Advanced filter builder
@@ -19,15 +29,19 @@ Click the filter button to open the **QSO filter builder** (Log4OM-style):
- Add one or more **conditions**: *field · operator · value*. - Add one or more **conditions**: *field · operator · value*.
- Operators: equals, not-equal, contains, starts/ends with, greater/less - Operators: equals, not-equal, contains, starts/ends with, greater/less
(or equal), is empty, is not empty. (or equal), is empty, is not empty.
- Fields include every confirmation **status and date** (sent / received,
before / after) for paper QSL, LoTW, eQSL, Club Log, HRDLog and QRZ.com.
Field names stay in English — they are ADIF names, a standard vocabulary.
- Join conditions with **ALL (AND)** or **ANY (OR)**. - Join conditions with **ALL (AND)** or **ANY (OR)**.
- Save named **presets** (they travel with the `data/` folder). - Save named **presets** (they travel with the `data/` folder).
- The field dropdown is sorted alphabetically to find things fast.
The filtered view can be exported: **selected rows** or the **whole filtered A filter shows **every** match (the on-screen row limit only applies to the
view** (no row limit) → ADIF or Cabrillo. unfiltered log; safety cap 10 000). The filtered view can be exported:
**selected rows** or the **whole filtered view** → ADIF or Cabrillo.
## Worked-before matrix ## Worked-before matrix
As you type a callsign, the worked-before grid shows prior contacts with that As you type a callsign, the worked-before grid shows prior contacts with that
**entity**, arranged by band/mode slot, so you instantly see what's new (new **entity**, arranged by band/mode slot, so you instantly see what's new (new
DXCC / new band / new slot) versus already worked. DXCC / new band / new slot) versus already worked. Its right-click menu offers
the same bulk edit and export as the main grid.
+32 -22
View File
@@ -4,48 +4,58 @@
A `data/` folder is created **next to `OpsLog.exe`**: A `data/` folder is created **next to `OpsLog.exe`**:
- **Config** (SQLite): settings, profiles, rigs/antennas, cluster nodes, lookup - **Settings database** (SQLite): settings, profiles, rigs/antennas, cluster
cache, award lists, QSL templates. Always local, so the UI is instant. nodes, lookup cache, award lists, QSL templates. Always local.
- **Local logbook** (SQLite): QSOs, when the profile logs locally. - **Logbook** (`logbook.db`, SQLite): QSOs, when the profile logs locally.
- `data/qsl/templates/…` — QSL card templates and their photos. - `data/qsl/templates/…` — QSL card templates and their photos.
- `data/qsl/outbox/…` — sent QSL card JPEGs. - `data/qsl/outbox/…` — sent QSL card JPEGs.
- Recordings folder (configurable) — per-QSO WAV/MP3. - Recordings folder (configurable) — per-QSO WAV/MP3.
- Backups. - Backups, diagnostic logs.
Back up your setup by copying `data/` (and keep it with the exe). See Paths are stored **relative to the folder**, so the whole OpsLog directory can
move to another drive or PC. Back up your setup by copying `data/`. See
[[Profiles and Databases]] for local-vs-MySQL logbooks. [[Profiles and Databases]] for local-vs-MySQL logbooks.
## Settings map ## Settings map
| Section | What | | Section | What |
|---------|------| |---------|------|
| **General** | language, theme, Main-view panes, live-status publish | | **General** | language, theme, date format (US / FR / Standard), Main-view panes, digital-modes grouping, Super Check Partial, ClubLog exceptions & Most Wanted |
| **Station** | your callsign, grid, name, address (my-station ADIF) | | **Station** | your callsign, grid, name, address (my-station ADIF) — hand-corrected CQ/ITU zones stay corrected |
| **CAT** | radio backend + connection ([[CAT Control]]) | | **CAT** | radio backend + connection, CAT sharing (rigctld), protocol trace ([[CAT Control]]) |
| **Lookup** | QRZ.com / HamQTH credentials | | **Lookup** | QRZ.com / HamQTH credentials |
| **FlexRadio** | per-band RX/TX antennas | | **Audio** | DVK / recorder devices, PTT, gains and levels, RX monitor |
| **Audio** | DVK / recorder devices, PTT, gains, RX monitor | | **CW Keyer** | engine (WinKeyer / Serial / Icom / Flex CWX / Yaesu / TCI), macros, ESM |
| **CW Keyer** | WinKeyer / Icom / TCI engine + macros | | **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA, Cloudlog/Wavelog |
| **External services** | LoTW (TQSL), QRZ, eQSL, ClubLog, HRDLog, POTA | | **Email** | SMTP for eQSL-by-email and *Send log to F4BPO* |
| **Email** | SMTP for eQSL-by-email | | **Amplifier / Tuner Genius / Antenna Genius / Rotator / Antenna** | station hardware ([[Amplifiers and Switches]] · [[Maps and Antennas]]) |
| **PowerGenius / Antenna Genius / Rotator / Ultrabeam** | station hardware | | **Database** | settings DB + this profile's logbook, rename/relocate |
| **Backup** | database + ADIF backup at shutdown | | **Backup** | database + ADIF backup at shutdown |
## Appearance & language ## Appearance & language
Four themes (Warm light, Warm dark, Graphite dark, High contrast) plus **Auto** Four themes (Warm light, Warm dark, Graphite dark, High contrast) plus **Auto**
(follows the OS). Full **English / French** UI with a first-run flag chooser and (follows the OS); fresh installs start dark. Full **English / French** UI with
a switcher in Settings → General. a first-run flag chooser and a switcher in Settings → General. **Ctrl + wheel**
zooms the whole UI (remembered; Ctrl+0 resets). The window title bar is drawn
by OpsLog — drag the top bar to move, double-click to maximise — and the window
reopens on the screen it was closed on.
## Integrations (outbound UDP) ## UDP integrations
Push the current frequency to **PstRotator**, radio info in **N1MM `RadioInfo`** - **Inbound**: WSJT-X / MSHV / JTDX QSO logging and status (unicast or
format, or an **ADIF record on each logged QSO** — so external tools stay in sync. multicast), and DXHunter remote-call packets (fills the callsign, tunes the
rig when the packet carries `<FREQ>`/`<MODE>`).
- **Outbound**: the current frequency to **PstRotator**, radio info in **N1MM
`RadioInfo`** format, or an **ADIF record on each logged QSO**.
## Other ## Other
- **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at launch, - **Autostart** external programs (WSJT-X, JTAlert, rotator control…) at
skipping any already running. launch, skipping any already running.
- **Update check** at startup (toggleable). - **Update check** at startup, when opening Help → About, and every 5 minutes.
A "What's new" summary appears on the first launch after each update.
- **Help → Send log to F4BPO** (shown when SMTP is configured) e-mails the
diagnostic logs straight to the developer.
- **Anonymous usage telemetry** — a once-a-day heartbeat (random install ID + - **Anonymous usage telemetry** — a once-a-day heartbeat (random install ID +
version + OS; no callsign or QSO data). Opt out in Preferences. version + OS; no callsign or QSO data). Opt out in Preferences.
+50 -30
View File
@@ -2,57 +2,77 @@
## Where's the log file? ## Where's the log file?
OpsLog writes a diagnostic log. The path is shown in Settings (and can be opened OpsLog writes a diagnostic log. The path is shown in Settings (and can be
from there). When reporting a problem, include the relevant lines — they're opened from there). When reporting a problem, include the relevant lines —
prefixed by subsystem (`Flex:`, `icom net:`, `icom scope`, `antgenius:`, they're prefixed by subsystem (`cat:`, `Flex:`, `icom net:`, `antgenius:`,
`cluster:`, `qslmgr:log`, `audio:`…). `cluster:`, `qslmgr:log`, `audio:`…). With an SMTP server configured, **Help →
Send log to F4BPO** e-mails the logs straight to the developer.
A crash no longer leaves a blank window: the error is shown on screen,
selectable, with a copy button.
## CAT ## CAT
- **Radio not connecting** — check the backend fields (COM port + baud for USB; - **Radio not connecting** — check the backend fields (COM port + baud for USB;
IP/credentials for network). OpsLog connects non-blocking, so it won't freeze — IP/credentials for network). The CAT log line carries the error explaining a
watch the CAT status. disconnection.
- **Icom: no CAT at all over USB**the CI-V address must match the radio; pick - **"Serial port busy" in the log** — another program holds the COM port
your **model** from the dropdown (it sets the address) and set *CI-V USB Echo (WSJT-X, MSHV, another logger configured for direct CAT). Only one program
Back* **OFF** on the rig. can own the port: let OpsLog hold it and point the others at **Hamlib NET
- **Icom scope blank on an IC-7300** — this was a bug where the scope decoder was rigctl 127.0.0.1:4532** (Settings → CAT → Share CAT). Two masters on one
chosen from the configured CI-V address; it now uses the model detected from the CI-V bus also cause garbled frames and erratic behaviour.
CI-V ID, so a single-scope rig run at address `0x98` decodes correctly. Update - **The radio transmits as soon as OpsLog connects** — your interface reads DTR
to the latest build. or RTS as PTT. Xiegu drops the lines automatically; on Yaesu and Kenwood
- **Icom attenuator button does nothing** — the dB steps are model-specific; make enable the *lower DTR/RTS on connect* option. (It is off by default because
sure the right model is selected (IC-7300 = 20 dB, IC-7610 = 6/12/18 dB). other interfaces stop transmitting with the lines low.)
- **WSJT-X won't key a Xiegu G90 through OmniRig** — a G90 ignores the CI-V PTT
command. Use the native Xiegu backend (which keys RTS/DTR) + rigctl sharing,
or set the PTT line inside OmniRig. See [[Kenwood and Xiegu]].
- **OmniRig frequency frozen / wrong VFO** — set **VFO to read** (A or B) in
the OmniRig backend settings; some rig files declare the wrong VFO.
- **"OmniRig not found" with OmniRig running** — one of the two runs as
administrator and the other doesn't. Start both the same way.
- **Icom: no CAT at all over USB** — the CI-V address must match the radio;
pick your **model** from the dropdown (it sets the address) and set *CI-V USB
Echo Back* **OFF** on the rig.
- **A radio that answers oddly** — enable the **protocol trace** (Settings →
CAT): every frame to and from the rig lands in the log as hex, which turns a
guess into a diagnosis.
## Remote Icom ## Remote Icom
- **Login rejected** → wrong Network User1 name/password. - **Login rejected** → wrong Network User1 name/password.
- **Can't bind local port** → the Icom **Remote Utility is still running** - **Can't bind local port** → the Icom **Remote Utility is still running**
close it. close it.
- **Link drops with the scope on** → receive-side retransmit should hold it; if - **Frequency frozen after another program took the CI-V session** — OpsLog
not, check network quality and the `icom net:` log lines. See reconnects on its own; if it doesn't, check the `icom net:` log lines. See
[[Remote Icom over the Internet]]. [[Remote Icom over the Internet]].
## FlexRadio ## FlexRadio
- **A control reads 0 / stale at startup** — some SmartSDR status fields use short - **A control reads 0 / stale at startup** — some SmartSDR status fields use
names (e.g. the TX filter is reported as `lo`/`hi`, not `filter_low/high`). If a short names. If a value never populates, capture the `Flex: … status` log
value never populates, capture the `Flex: … status` log line and report it. line and report it.
- **Antenna wrong band on a spot click** — fixed: `SetFrequency`/`SetMode` update - **SmartSDR CAT keeps disconnecting while OpsLog is open** — fixed: OpsLog now
the slice cache immediately so the panel and the Ultrabeam follow don't chase a binds only to the actual SmartSDR/Maestro GUI client. Update.
stale frequency. - **"Interlock is preventing transmission" with a SteppIR** — see the tunable
range setting in [[Maps and Antennas]].
## Multi-op live status PHP ## Multi-op live status PHP
`Connection refused` from the PHP page is almost always MySQL `bind-address = `Connection refused` from the PHP page is almost always MySQL `bind-address =
127.0.0.1` or a firewall/grant issue — see 127.0.0.1` or a firewall/grant issue — see [[Multi-Operator Live Status]].
[[Multi-Operator Live Status]].
## DVK / PTT ## DVK / PTT
- **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the async - **Test PTT does nothing on CAT/OmniRig** — the key is held ~1.5 s so the
OmniRig write is actually sent; if it still doesn't key, the rig's OmniRig async OmniRig write is actually sent; if it still doesn't key, the rig's
profile may not expose CAT TX keying — use RTS/DTR or VOX. OmniRig profile may not expose CAT TX keying — use RTS/DTR or VOX.
- **A voice message transmits almost nothing** — raise the message level
(Settings → Audio) and check the rig's rear-input setting (FTDX10: SSB MOD
SOURCE = REAR).
## Still stuck? ## Still stuck?
Open an issue with: what you did, what happened, your radio/model, and the Open an issue (or *Send log to F4BPO*) with: what you did, what happened, your
relevant log lines. radio/model, and the relevant log lines.
+47
View File
@@ -0,0 +1,47 @@
# Yaesu (native CAT)
A native backend talks to an **FTDX10 / FTDX101** (and close relatives) directly
over the rig's USB/serial port — no OmniRig. Pick **Yaesu (USB)** in
Settings → CAT: COM port + baud (FTDX10/101 default 38400).
Frequency, mode, VFO A/B (MAIN/SUB on the 101), split and PTT are covered, and
OpsLog **follows the VFO you are actually on** — the displayed frequency, the
logged frequency and the VFO a cluster spot tunes. Split is worked out from
which VFO transmits, so simplex on the SUB receiver is not shown as split.
## The Yaesu console
With the native backend connected, a console tab (and a Main-view pane) gives:
- **S / PO / SWR meters** — power in watts on a curve measured against the rig's
own display, SWR as the ratio itself, with peak-hold so they stay readable
between CW words or SSB syllables.
- **Band and mode buttons** — CW, RTTY, DIGI and PSK show their sideband; click
the active button again to switch it.
- AF / RF / squelch, AGC, IPOAMP1AMP2, 6/12/18 dB attenuator, NB, DNR, narrow
filter, power, mic gain, VOX, split and **ATU tune**.
- **SPLIT** places the transmit VFO up 1 kHz on CW/data, up 5 kHz on phone.
- In CW the mic gain and VOX give way to a **CW card**: keyer speed, break-in
and ZIN (zero-in).
Sliders only respond to the mouse wheel after you click them, so scrolling the
panel can't change the transmit power by accident.
## CW with the rig's own keyer
A CW engine sends your macros with the radio's internal keyer (CAT `KY`) — no
WinKeyer, no second cable. Pick **Yaesu (rig keyer)** in Settings → CW keyer.
- Works on the **FTDX101 / FT-991A / FT-710** family.
- An **FTDX10 does not accept it** (`KY` is refused, `DAKY` doesn't help) —
OpsLog says so and points to the **serial DTR keyer** on the rig's second COM
port (the *Serial port* CW engine), which works. See [[Audio and Keyers]].
- CW speed is one setting everywhere: the console slider, the CW panel and the
rig's front panel stay in agreement.
## Notes
- A Yaesu that is switched off is reported as such (opening the port is not
"connected").
- If your interface goes into transmit the moment OpsLog connects, enable the
**lower DTR/RTS on connect** option — see [[CAT Control]].
+2
View File
@@ -16,6 +16,8 @@
- [[FlexRadio]] - [[FlexRadio]]
- [[Icom]] - [[Icom]]
- [[Remote Icom over the Internet]] - [[Remote Icom over the Internet]]
- [[Yaesu]]
- [[Kenwood and Xiegu]]
**Operating** **Operating**
- [[DX Cluster and Spots]] - [[DX Cluster and Spots]]