Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da1d71323 | ||
|
|
72f692aa04 | ||
|
|
f6cd6e999a | ||
|
|
296a4a55c0 | ||
|
|
acf73c73c7 | ||
|
|
5777c119cb | ||
|
|
85bf0da006 | ||
|
|
b6ea07e3a3 |
@@ -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 radio’s own RJ45, which speaks Kenwood’s KNS/ARCP.
|
// adapter). NOT the radio’s own RJ45, which speaks Kenwood’s 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
|
||||||
@@ -3514,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
|
||||||
}
|
}
|
||||||
@@ -3533,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")
|
||||||
}
|
}
|
||||||
@@ -3540,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":
|
||||||
@@ -3590,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")
|
||||||
}
|
}
|
||||||
@@ -3608,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 {
|
||||||
@@ -6788,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
|
||||||
}
|
}
|
||||||
@@ -6809,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
|
||||||
@@ -6944,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"
|
||||||
@@ -6967,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),
|
||||||
@@ -12796,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).
|
||||||
@@ -12808,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).
|
||||||
@@ -13404,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 (0–9999)
|
||||||
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -13417,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)
|
||||||
@@ -13448,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))
|
||||||
@@ -13551,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"
|
||||||
@@ -14734,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")
|
||||||
@@ -15408,6 +15476,12 @@ type SpotStatus struct {
|
|||||||
// when that database has been downloaded); POTA from the spot's tagged park.
|
// when that database has been downloaded); POTA from the spot's tagged park.
|
||||||
NewCounty bool `json:"new_county"`
|
NewCounty bool `json:"new_county"`
|
||||||
NewPOTA bool `json:"new_pota"`
|
NewPOTA bool `json:"new_pota"`
|
||||||
|
// NewPfx flags a CQ WPX prefix never worked before, and Pfx is that prefix.
|
||||||
|
// Also orthogonal: a common entity on a worked band can still carry a prefix
|
||||||
|
// that has never been in the log, which is exactly what a WPX chaser is
|
||||||
|
// scanning the cluster for.
|
||||||
|
NewPfx bool `json:"new_pfx"`
|
||||||
|
Pfx string `json:"pfx,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClusterSpotStatuses takes a batch of spots and returns slot status for
|
// ClusterSpotStatuses takes a batch of spots and returns slot status for
|
||||||
@@ -15463,6 +15537,16 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
// lookup) and worked POTA parks. Both built once per batch.
|
// lookup) and worked POTA parks. Both built once per batch.
|
||||||
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
|
workedCounties, _ := a.qso.WorkedCountyKeys(a.ctx, award.USCountyKey)
|
||||||
workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx)
|
workedPOTA, _ := a.qso.WorkedPOTARefs(a.ctx)
|
||||||
|
// Worked WPX prefixes, derived from the callsigns we already loaded — no
|
||||||
|
// extra query. Derived rather than read from the stored PFX column: that
|
||||||
|
// column is only filled when an import supplied it, and deriving keeps this
|
||||||
|
// in step with the WPX award, which does the same thing.
|
||||||
|
workedPfx := make(map[string]struct{}, len(workedCalls))
|
||||||
|
for c := range workedCalls {
|
||||||
|
if p := award.WPXPrefix(c); p != "" {
|
||||||
|
workedPfx[p] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
for i, q := range spots {
|
for i, q := range spots {
|
||||||
out[i] = SpotStatus{
|
out[i] = SpotStatus{
|
||||||
Call: q.Call,
|
Call: q.Call,
|
||||||
@@ -15472,6 +15556,13 @@ func (a *App) ClusterSpotStatuses(spots []SpotQuery) []SpotStatus {
|
|||||||
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
|
if _, ok := workedCalls[strings.ToUpper(q.Call)]; ok {
|
||||||
out[i].WorkedCall = true
|
out[i].WorkedCall = true
|
||||||
}
|
}
|
||||||
|
// NEW PFX: the spot's CQ WPX prefix, never worked before.
|
||||||
|
if p := award.WPXPrefix(q.Call); p != "" {
|
||||||
|
out[i].Pfx = p
|
||||||
|
if _, done := workedPfx[p]; !done {
|
||||||
|
out[i].NewPfx = true
|
||||||
|
}
|
||||||
|
}
|
||||||
// NEW POTA: the spot's tagged park, never worked before.
|
// NEW POTA: the spot's tagged park, never worked before.
|
||||||
if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" {
|
if ref := strings.ToUpper(strings.TrimSpace(q.POTARef)); ref != "" {
|
||||||
if _, done := workedPOTA[ref]; !done {
|
if _, done := workedPOTA[ref]; !done {
|
||||||
|
|||||||
@@ -1,4 +1,60 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.23.1",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"DX cluster: NEW COUNTY is green, like NEW POTA.",
|
||||||
|
"CW: pressing ESC during a macro no longer turns the report from 599 into 59 when \"ESC clears the callsign too\" is on."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Cluster DX : NOUVEAU COMTÉ passe en vert, comme NOUVEAU POTA.",
|
||||||
|
"CW : appuyer sur Échap pendant une macro ne fait plus passer le report de 599 à 59 quand l'option « Échap efface aussi l'indicatif » est cochée."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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. (Thanks HB9HBY)",
|
||||||
|
"DX cluster: NEW PFX marks a spot whose CQ WPX prefix has never been worked, in the status column and as a filter."
|
||||||
|
],
|
||||||
|
"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. (Merci HB9HBY)",
|
||||||
|
"Cluster DX : NOUVEAU PFX signale un spot dont le préfixe CQ WPX n'a jamais été contacté, dans la colonne de statut et en filtre."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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 s’applique 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 qu’OpsLog est ouvert. Désactivée par défaut — les abaisser empêche d’autres interfaces d’émettre.",
|
||||||
|
"Amplificateurs SPE : les boutons ON et OFF allument et éteignent désormais l’amplificateur par l’USB.",
|
||||||
|
"Journal de diagnostic allégé : plus d’abonnements 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 s’alignent 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 l’icô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 qu’une 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 l’appareil lui-même, au lieu de l’instant capté par l’interrogation — une émission d’un 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",
|
"version": "0.22.8",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+124
-16
@@ -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';
|
||||||
@@ -491,6 +492,14 @@ export default function App() {
|
|||||||
const [bandRx, setBandRx] = useState('20m');
|
const [bandRx, setBandRx] = useState('20m');
|
||||||
const [countries, setCountries] = useState<string[]>([]);
|
const [countries, setCountries] = useState<string[]>([]);
|
||||||
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
const [rstLists, setRstLists] = useState<RSTLists>({ phone: [], cw: [], digital: [] });
|
||||||
|
// Refs for the report defaults. applyModePreset is called from long-lived
|
||||||
|
// closures — the global key handler is the one that bit us — whose dependency
|
||||||
|
// lists deliberately exclude the mode, so reading the state variables there
|
||||||
|
// gives whatever they were when that closure was built.
|
||||||
|
const modePresetsRef = useRef(modePresets);
|
||||||
|
useEffect(() => { modePresetsRef.current = modePresets; }, [modePresets]);
|
||||||
|
const rstListsRef = useRef(rstLists);
|
||||||
|
useEffect(() => { rstListsRef.current = rstLists; }, [rstLists]);
|
||||||
const [rstSent, setRstSent] = useState('59');
|
const [rstSent, setRstSent] = useState('59');
|
||||||
const [rstRcvd, setRstRcvd] = useState('59');
|
const [rstRcvd, setRstRcvd] = useState('59');
|
||||||
const [grid, setGrid] = useState('');
|
const [grid, setGrid] = useState('');
|
||||||
@@ -570,6 +579,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 +596,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 };
|
||||||
@@ -1371,7 +1390,7 @@ export default function App() {
|
|||||||
// county or a new park is not a DXCC state, it is another dimension of the
|
// county or a new park is not a DXCC state, it is another dimension of the
|
||||||
// same spot — which is why the grid shows them as separate badges. Filtering
|
// same spot — which is why the grid shows them as separate badges. Filtering
|
||||||
// is an OR across all of them, as it already was for a worked callsign.
|
// is an OR across all of them, as it already was for a worked callsign.
|
||||||
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county';
|
type SpotFilterKey = SpotStatusKey | 'new-pota' | 'new-county' | 'new-pfx';
|
||||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotFilterKey>>(() => lsSet<SpotFilterKey>('opslog.clusterStatusFilter'));
|
||||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||||
@@ -1467,7 +1486,7 @@ export default function App() {
|
|||||||
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
// Cached per-call slot status: "new" | "new-band" | "new-slot" | "worked".
|
||||||
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
// Keyed by `${call}|${band}|${mode}` so two spots of the same call on
|
||||||
// different slots don't share the same colour.
|
// different slots don't share the same colour.
|
||||||
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean }>>({});
|
const [spotStatus, setSpotStatus] = useState<Record<string, { status: string; country?: string; continent?: string; worked_call?: boolean; new_county?: boolean; new_pota?: boolean; new_pfx?: boolean; pfx?: string }>>({});
|
||||||
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
// Live mirror of spotStatus so the incoming-spot buffer can tell which slots
|
||||||
// still need resolving without re-subscribing the cluster:spot listener.
|
// still need resolving without re-subscribing the cluster:spot listener.
|
||||||
const spotStatusRef = useRef(spotStatus);
|
const spotStatusRef = useRef(spotStatus);
|
||||||
@@ -2101,11 +2120,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 {}
|
||||||
@@ -2165,8 +2195,9 @@ export default function App() {
|
|||||||
// Prefer the user's configured preset RST; otherwise fall back to the mode
|
// Prefer the user's configured preset RST; otherwise fall back to the mode
|
||||||
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
|
// category default (CW/RTTY/PSK → 599, phone → 59, digital → first option)
|
||||||
// so switching SSB→CW flips 59→599 even without a configured preset.
|
// so switching SSB→CW flips 59→599 even without a configured preset.
|
||||||
const p = modePresets.find((x) => x.name === m);
|
// Read through the refs, never the state: see their declaration.
|
||||||
const fallback = rstOptions(m, rstLists)[0] || '';
|
const p = modePresetsRef.current.find((x) => x.name === m);
|
||||||
|
const fallback = rstOptions(m, rstListsRef.current)[0] || '';
|
||||||
setRstSent(p?.default_rst_sent || fallback);
|
setRstSent(p?.default_rst_sent || fallback);
|
||||||
setRstRcvd(p?.default_rst_rcvd || fallback);
|
setRstRcvd(p?.default_rst_rcvd || fallback);
|
||||||
}
|
}
|
||||||
@@ -2439,6 +2470,8 @@ export default function App() {
|
|||||||
worked_call: !!(r as any).worked_call,
|
worked_call: !!(r as any).worked_call,
|
||||||
new_county: !!(r as any).new_county,
|
new_county: !!(r as any).new_county,
|
||||||
new_pota: !!(r as any).new_pota,
|
new_pota: !!(r as any).new_pota,
|
||||||
|
new_pfx: !!(r as any).new_pfx,
|
||||||
|
pfx: (r as any).pfx,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -2894,6 +2927,8 @@ export default function App() {
|
|||||||
worked_call: !!(r as any).worked_call,
|
worked_call: !!(r as any).worked_call,
|
||||||
new_county: !!(r as any).new_county,
|
new_county: !!(r as any).new_county,
|
||||||
new_pota: !!(r as any).new_pota,
|
new_pota: !!(r as any).new_pota,
|
||||||
|
new_pfx: !!(r as any).new_pfx,
|
||||||
|
pfx: (r as any).pfx,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return next;
|
return next;
|
||||||
@@ -3002,7 +3037,11 @@ export default function App() {
|
|||||||
setWb(null); // clear the Worked-before grid for the just-cleared callsign
|
setWb(null); // clear the Worked-before grid for the just-cleared callsign
|
||||||
setLookupError('');
|
setLookupError('');
|
||||||
rstUserEditedRef.current = false;
|
rstUserEditedRef.current = false;
|
||||||
applyModePreset(mode);
|
// modeRef, not `mode`: resetEntry is reached from the global key handler,
|
||||||
|
// whose effect does not re-run on a mode change. Pressing ESC during a CW
|
||||||
|
// macro therefore reset the report using the mode as it was when that
|
||||||
|
// handler was built — 'SSB' — and turned 599 into 59.
|
||||||
|
applyModePreset(modeRef.current);
|
||||||
setDetails((d) => ({
|
setDetails((d) => ({
|
||||||
...d,
|
...d,
|
||||||
state: '', cnty: '', address: '', lat: undefined, lon: undefined,
|
state: '', cnty: '', address: '', lat: undefined, lon: undefined,
|
||||||
@@ -4329,7 +4368,8 @@ export default function App() {
|
|||||||
const matches = clusterStatusFilter.has(st)
|
const matches = clusterStatusFilter.has(st)
|
||||||
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
|| (!!e?.worked_call && clusterStatusFilter.has('worked'))
|
||||||
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
|| (!!e?.new_pota && clusterStatusFilter.has('new-pota'))
|
||||||
|| (!!e?.new_county && clusterStatusFilter.has('new-county'));
|
|| (!!e?.new_county && clusterStatusFilter.has('new-county'))
|
||||||
|
|| (!!e?.new_pfx && clusterStatusFilter.has('new-pfx'));
|
||||||
if (!matches) return false;
|
if (!matches) return false;
|
||||||
}
|
}
|
||||||
if (clusterHideWorked) {
|
if (clusterHideWorked) {
|
||||||
@@ -4448,7 +4488,8 @@ export default function App() {
|
|||||||
// Same colours as the badges in the grid — a filter that does not
|
// Same colours as the badges in the grid — a filter that does not
|
||||||
// look like what it selects has to be learned twice.
|
// look like what it selects has to be learned twice.
|
||||||
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
{ k: 'new-pota' as SpotFilterKey, label: 'NEW POTA', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||||
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-info-muted text-info-muted-foreground border-info-border' },
|
{ k: 'new-county' as SpotFilterKey, label: 'NEW COUNTY', cls: 'bg-success-muted text-success-muted-foreground border-success-border' },
|
||||||
|
{ k: 'new-pfx' as SpotFilterKey, label: 'NEW PFX', cls: 'bg-caution-muted text-caution-muted-foreground border-caution-border' },
|
||||||
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
// (no WORKED chip — use the "Hide worked" checkbox to drop dupes.)
|
||||||
]).map((s) => {
|
]).map((s) => {
|
||||||
const on = clusterStatusFilter.has(s.k);
|
const on = clusterStatusFilter.has(s.k);
|
||||||
@@ -4849,6 +4890,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"
|
||||||
@@ -5365,7 +5460,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.
|
||||||
@@ -5452,6 +5547,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
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
// mode’s 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', ' '); };
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ export type SpotStatusEntry = {
|
|||||||
worked_call?: boolean;
|
worked_call?: boolean;
|
||||||
new_county?: boolean;
|
new_county?: boolean;
|
||||||
new_pota?: boolean;
|
new_pota?: boolean;
|
||||||
|
new_pfx?: boolean;
|
||||||
|
pfx?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -99,47 +101,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,
|
||||||
|
// POTA and prefix 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 +163,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 +179,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[] = [];
|
||||||
@@ -198,25 +193,31 @@ const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
else if (s?.worked_call) parts.push(t('clg2.wkdCall'));
|
||||||
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
if (s?.new_county) parts.push(t('clg2.newCounty'));
|
||||||
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
if (s?.new_pota) parts.push(t('clg2.newPota'));
|
||||||
|
if (s?.new_pfx) parts.push(t('clg2.newPfx'));
|
||||||
return parts.join(' ');
|
return parts.join(' ');
|
||||||
},
|
},
|
||||||
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(--success)' });
|
||||||
|
if (s?.new_pota) parts.push({ text: t('clg2.newPota'), color: 'var(--success)' });
|
||||||
|
if (s?.new_pfx) parts.push({ text: t('clg2.newPfx'), color: 'var(--caution)' });
|
||||||
|
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 +250,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 +264,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');
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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
|
||||||
|
|||||||
@@ -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.8';
|
export const APP_VERSION = '0.23.1';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+2
-2
@@ -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>>;
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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"];
|
||||||
@@ -2900,6 +2904,8 @@ export namespace main {
|
|||||||
worked_call: boolean;
|
worked_call: boolean;
|
||||||
new_county: boolean;
|
new_county: boolean;
|
||||||
new_pota: boolean;
|
new_pota: boolean;
|
||||||
|
new_pfx: boolean;
|
||||||
|
pfx?: string;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new SpotStatus(source);
|
return new SpotStatus(source);
|
||||||
@@ -2916,6 +2922,8 @@ export namespace main {
|
|||||||
this.worked_call = source["worked_call"];
|
this.worked_call = source["worked_call"];
|
||||||
this.new_county = source["new_county"];
|
this.new_county = source["new_county"];
|
||||||
this.new_pota = source["new_pota"];
|
this.new_pota = source["new_pota"];
|
||||||
|
this.new_pfx = source["new_pfx"];
|
||||||
|
this.pfx = source["pfx"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class StartupStatus {
|
export class StartupStatus {
|
||||||
@@ -3497,6 +3505,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 +3524,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 +4574,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 +4598,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 +4653,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 +4681,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"];
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -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 {
|
||||||
|
|||||||
+26
-12
@@ -88,6 +88,26 @@ type Kenwood struct {
|
|||||||
// Kept only to put it in the error message: "not answering" and "answering
|
// Kept only to put it in the error message: "not answering" and "answering
|
||||||
// something unreadable" are different faults with different fixes.
|
// something unreadable" are different faults with different fixes.
|
||||||
heard string
|
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"
|
// where names the link for a message, so an error does not read "COM @ 0 baud"
|
||||||
@@ -572,18 +592,12 @@ func (k *Kenwood) openPort() (serial.Port, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
// The modem lines are LEFT ALONE.
|
// The modem lines are only touched when the operator asks for it. See
|
||||||
//
|
// lowerLines: both defaults break somebody's station.
|
||||||
// They were briefly deasserted here, to stop an interface that reads them as
|
if k.lowerLines {
|
||||||
// PTT from keying the rig on connect. That silenced radios instead: a TS-990
|
_ = p.SetDTR(false)
|
||||||
// on COM3 opened fine and answered nothing, because a great many USB-serial
|
_ = p.SetRTS(false)
|
||||||
// interfaces will not transmit with RTS low — hardware flow control, or an
|
}
|
||||||
// output stage the line enables. "Opened but the rig sent nothing" was this,
|
|
||||||
// and it arrived as "CAT stopped working after the update".
|
|
||||||
//
|
|
||||||
// The fault that change was written for was a Xiegu G90 behind a DE-19, and
|
|
||||||
// that backend now has an explicit setting for which line keys it. A rig
|
|
||||||
// whose PTT is a CAT command has no business touching DTR or RTS at all.
|
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+20
-9
@@ -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,12 +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)
|
||||||
}
|
}
|
||||||
// The modem lines are LEFT ALONE — see the same note in kenwood.go.
|
// The modem lines are only touched when the operator asks for it — see the
|
||||||
//
|
// note on Kenwood.lowerLines. Both defaults break somebody's station.
|
||||||
// Deasserting them to keep an interface from keying the rig silenced radios
|
if y.lowerLines {
|
||||||
// instead: many USB-serial interfaces will not transmit with RTS low. The
|
_ = p.SetDTR(false)
|
||||||
// Xiegu fault that change was written for is handled in that backend, which
|
_ = p.SetRTS(false)
|
||||||
// now has an explicit setting for which line keys the rig.
|
}
|
||||||
p.SetReadTimeout(300 * time.Millisecond)
|
p.SetReadTimeout(300 * time.Millisecond)
|
||||||
y.port = p
|
y.port = p
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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, 0–9999; "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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.8"
|
appVersion = "0.23.1"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
@@ -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
@@ -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 (F1–F9; 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 **F1–F6** voice messages and transmit them. Set the audio devices, PTT
|
Record **F1–F6** 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 12–40 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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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]] |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
|
13–54 MHz): outside it OpsLog leaves the antenna and the TX interlock
|
||||||
|
completely alone, so tuning to 30 m on a 20 m–6 m SteppIR does nothing.
|
||||||
|
|
||||||
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch).
|
Related hardware: [[Amplifiers and Switches]] (Antenna Genius switch, Tuner
|
||||||
|
Genius, relays).
|
||||||
|
|||||||
@@ -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]].
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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, IPO–AMP1–AMP2, 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]].
|
||||||
@@ -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]]
|
||||||
|
|||||||
Reference in New Issue
Block a user