Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
564d97b692 | ||
|
|
99599458ff | ||
|
|
191e92ad62 | ||
|
|
f405e71d7d | ||
|
|
0924062ced | ||
|
|
8683a450a7 | ||
|
|
9f8e3c73d9 | ||
|
|
423cf1f998 | ||
|
|
c0dc1bfcc4 | ||
|
|
46d6531cf5 | ||
|
|
3ed2d957e5 | ||
|
|
5770c40d89 | ||
|
|
5e2c452753 | ||
|
|
f3f966f5b7 | ||
|
|
38c838d232 | ||
|
|
7eb734d86f | ||
|
|
b49599150c | ||
|
|
331db58705 | ||
|
|
62e20de69c | ||
|
|
480d24384b | ||
|
|
c2ff0f714a | ||
|
|
fe2affc72f | ||
|
|
be0326c862 |
@@ -52,14 +52,15 @@ import (
|
|||||||
"hamlog/internal/powergenius"
|
"hamlog/internal/powergenius"
|
||||||
"hamlog/internal/profile"
|
"hamlog/internal/profile"
|
||||||
"hamlog/internal/pskr"
|
"hamlog/internal/pskr"
|
||||||
|
"hamlog/internal/psu"
|
||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/relaydev"
|
"hamlog/internal/relaydev"
|
||||||
"hamlog/internal/rigctld"
|
"hamlog/internal/rigctld"
|
||||||
"hamlog/internal/rotator/dcu1"
|
"hamlog/internal/rotator/dcu1"
|
||||||
"hamlog/internal/rotator/spid"
|
|
||||||
"hamlog/internal/rotator/gs232"
|
"hamlog/internal/rotator/gs232"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
|
"hamlog/internal/rotator/spid"
|
||||||
"hamlog/internal/rotgenius"
|
"hamlog/internal/rotgenius"
|
||||||
"hamlog/internal/scp"
|
"hamlog/internal/scp"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
@@ -690,6 +691,7 @@ type App struct {
|
|||||||
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
motorInhibited atomic.Bool // TX currently inhibited by the motor-antenna watcher
|
||||||
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled
|
||||||
tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled
|
tunergenius *tunergenius.Client // Tuner Genius XL (4O3A) ATU (TCP); nil when disabled
|
||||||
|
psu *psu.Client // bench power supply over Modbus RTU (serial); nil when disabled
|
||||||
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
pgxl *powergenius.Client // PowerGenius XL (4O3A) amp fan control (TCP); nil when disabled
|
||||||
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
|
spe *spe.Client // legacy pointer: FIRST enabled SPE amp (kept for the pre-multi bindings)
|
||||||
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
|
acom *acom.Client // legacy pointer: FIRST enabled ACOM amp
|
||||||
@@ -1415,6 +1417,8 @@ func (a *App) startup(ctx context.Context) {
|
|||||||
a.startTunerGenius()
|
a.startTunerGenius()
|
||||||
// PowerGenius XL amp fan control: connect in the background if enabled.
|
// PowerGenius XL amp fan control: connect in the background if enabled.
|
||||||
a.startAmps()
|
a.startAmps()
|
||||||
|
// Bench power supply (Modbus RTU): connect in the background if enabled.
|
||||||
|
a.startPSU()
|
||||||
|
|
||||||
// Autostart: launch the active profile's configured external programs that
|
// Autostart: launch the active profile's configured external programs that
|
||||||
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
|
// aren't already running (WSJT-X, JTAlert, rotator control, …). Background
|
||||||
@@ -8771,7 +8775,11 @@ func (a *App) NetDeactivate(id int64) (int64, error) {
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultEmailSubject = "Our QSO recording — {CALL}"
|
defaultEmailSubject = "Our QSO recording — {CALL}"
|
||||||
defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
// Same credit line as the QSL e-mail, and for the same reason it lives in the
|
||||||
|
// template rather than being appended at send time: it is a default, so an
|
||||||
|
// operator deletes it once, and one who already saved their own body never
|
||||||
|
// sees it — a stored template is returned verbatim.
|
||||||
|
defaultEmailBody = "Hi,\n\nGreat to work you! Please find attached the audio recording of our QSO.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}\n\n" + qslCredit
|
||||||
)
|
)
|
||||||
|
|
||||||
// EmailSettings is the user's SMTP config + auto-send + message templates.
|
// EmailSettings is the user's SMTP config + auto-send + message templates.
|
||||||
@@ -10549,6 +10557,66 @@ func (a *App) applyULSCounty(q *qso.QSO) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateQSOsCountyFromULS re-derives the county of the selected US contacts from
|
||||||
|
// the offline ULS database, and OVERWRITES what is there.
|
||||||
|
//
|
||||||
|
// That is the difference from applyULSCounty beside it, and it is deliberate.
|
||||||
|
// The automatic pass fills blanks only, because a value the operator or QRZ
|
||||||
|
// supplied is usually better than one derived from a ZIP code. This one is asked
|
||||||
|
// for by hand, on a chosen set of rows, precisely BECAUSE the stored county is
|
||||||
|
// believed wrong — the Connecticut planning regions are the case that prompted
|
||||||
|
// it: every CT contact logged before that correction holds a county the state
|
||||||
|
// abolished in 2022, and no amount of filling blanks reaches them.
|
||||||
|
//
|
||||||
|
// Only US entities are touched, only callsigns the database knows, and only when
|
||||||
|
// the answer actually differs — so re-running it on a mixed selection is safe
|
||||||
|
// and the count reported is the number of counties that really changed.
|
||||||
|
func (a *App) UpdateQSOsCountyFromULS(ids []int64) (int, error) {
|
||||||
|
if a.qso == nil {
|
||||||
|
return 0, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if a.uls == nil || a.uls.Count() == 0 {
|
||||||
|
return 0, fmt.Errorf("the US county database has not been downloaded yet — Settings › Awards › US counties")
|
||||||
|
}
|
||||||
|
changed := 0
|
||||||
|
for _, id := range ids {
|
||||||
|
q, err := a.qso.GetByID(a.ctx, id)
|
||||||
|
if err != nil || q.DXCC == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch *q.DXCC {
|
||||||
|
case 291, 110, 6: // United States, Hawaii, Alaska
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
loc, ok := a.uls.Resolve(q.Callsign)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cnty := loc.CNTY()
|
||||||
|
if cnty == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.County == cnty && (loc.State == "" || q.State == loc.State) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
q.County = cnty
|
||||||
|
if loc.State != "" {
|
||||||
|
q.State = loc.State
|
||||||
|
}
|
||||||
|
if a.qso.Update(a.ctx, q) == nil {
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if changed > 0 {
|
||||||
|
// The county IS the reference for CQ USA-CA and the state for WAS, so the
|
||||||
|
// stored award refs are stale the moment it changes.
|
||||||
|
a.invalidateAwardStats()
|
||||||
|
a.materializeAwardRefsForIDs(ids)
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ULSStatusResult reports whether the county database is loaded, and how fresh.
|
// ULSStatusResult reports whether the county database is loaded, and how fresh.
|
||||||
type ULSStatusResult struct {
|
type ULSStatusResult struct {
|
||||||
Count int `json:"count"`
|
Count int `json:"count"`
|
||||||
@@ -14120,8 +14188,9 @@ func (a *App) ActivateProfile(id int64) error {
|
|||||||
|
|
||||||
// reloadAfterProfileSwitch re-applies all settings-derived state for the newly
|
// reloadAfterProfileSwitch re-applies all settings-derived state for the newly
|
||||||
// active profile: lookup providers, upload-service accounts, CAT connection,
|
// active profile: lookup providers, upload-service accounts, CAT connection,
|
||||||
// and the QSO recorder (audio devices). The Winkeyer stays as-is (the operator
|
// the station hardware, and the QSO recorder (audio devices). The Winkeyer
|
||||||
// connects it explicitly). The frontend reloads its panels via profile:changed.
|
// stays as-is (the operator connects it explicitly). The frontend reloads its
|
||||||
|
// panels via profile:changed.
|
||||||
func (a *App) reloadAfterProfileSwitch() {
|
func (a *App) reloadAfterProfileSwitch() {
|
||||||
a.reloadLookupProviders()
|
a.reloadLookupProviders()
|
||||||
if a.extsvc != nil {
|
if a.extsvc != nil {
|
||||||
@@ -14131,6 +14200,22 @@ func (a *App) reloadAfterProfileSwitch() {
|
|||||||
// runs from ActivateProfile, a click, and a rig that is slow to release would
|
// runs from ActivateProfile, a click, and a rig that is slow to release would
|
||||||
// otherwise freeze the switch.
|
// otherwise freeze the switch.
|
||||||
a.restartAsync("cat", a.reloadCAT)
|
a.restartAsync("cat", a.reloadCAT)
|
||||||
|
// Every device below is configured PER PROFILE, and none of them used to
|
||||||
|
// follow the profile: they were built once at startup and again only when
|
||||||
|
// Settings was saved. An operator with an SPE on COM9 for HF and another on
|
||||||
|
// COM10 for 6 m — one amplifier per profile, which is the whole point of
|
||||||
|
// having two — switched profile and stayed connected to the previous port,
|
||||||
|
// then had to open Settings and press Save to get the right one. Save is not
|
||||||
|
// a connect button; switching profile is what asks for this hardware.
|
||||||
|
//
|
||||||
|
// Unconditional, exactly as a save is: the alternative is comparing every
|
||||||
|
// device's configuration between profiles, and a needless reconnect on a
|
||||||
|
// profile switch costs a second, while a missed one costs the amplifier.
|
||||||
|
a.restartAsync("amp", a.startAmps)
|
||||||
|
a.restartAsync("antenna", a.startUltrabeam)
|
||||||
|
a.restartAsync("antgenius", a.startAntGenius)
|
||||||
|
a.restartAsync("tuner", a.startTunerGenius)
|
||||||
|
a.restartAsync("psu", a.startPSU)
|
||||||
a.startQSORecorderIfEnabled()
|
a.startQSORecorderIfEnabled()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14757,6 +14842,13 @@ func buildDeviceDriver(d StationDevice) relaydev.Device {
|
|||||||
case "usbrelay":
|
case "usbrelay":
|
||||||
// Host carries the COM port (e.g. "COM5"); CH340/LCUS "A0" serial protocol.
|
// Host carries the COM port (e.g. "COM5"); CH340/LCUS "A0" serial protocol.
|
||||||
return relaydev.NewSerialRelay(d.Host, deviceRelayCount(d))
|
return relaydev.NewSerialRelay(d.Host, deviceRelayCount(d))
|
||||||
|
case "httpgen":
|
||||||
|
// The whole board lives in its URLs — Host is not used at all, which is
|
||||||
|
// why it may be left empty. Leaving this case out is what made the
|
||||||
|
// generic board fall through to the WebSwitch driver below: it answered
|
||||||
|
// the WebSwitch's own address, never sent one configured URL, and
|
||||||
|
// reported itself offline so every relay button stayed greyed out.
|
||||||
|
return relaydev.NewHTTPGeneric(d.OnURLs, d.OffURLs, d.OnPat, d.OffPat, d.User, d.Pass, deviceRelayCount(d), d.Labels)
|
||||||
default:
|
default:
|
||||||
return relaydev.NewWebswitch(d.Host)
|
return relaydev.NewWebswitch(d.Host)
|
||||||
}
|
}
|
||||||
@@ -14765,7 +14857,20 @@ func buildDeviceDriver(d StationDevice) relaydev.Device {
|
|||||||
// deviceKey is the config signature that, when unchanged, lets us reuse a device's
|
// deviceKey is the config signature that, when unchanged, lets us reuse a device's
|
||||||
// open driver (and its OS handle) instead of rebuilding it every poll.
|
// open driver (and its OS handle) instead of rebuilding it every poll.
|
||||||
func deviceKey(d StationDevice) string {
|
func deviceKey(d StationDevice) string {
|
||||||
return fmt.Sprintf("%s|%s|%s|%s|%d", d.Type, d.Host, d.User, d.Pass, deviceRelayCount(d))
|
k := fmt.Sprintf("%s|%s|%s|%s|%d", d.Type, d.Host, d.User, d.Pass, deviceRelayCount(d))
|
||||||
|
if d.Type == "httpgen" {
|
||||||
|
// The generic board's entire configuration is its URLs, and none of it is
|
||||||
|
// in the signature above. Correcting a typo in one of them would have
|
||||||
|
// handed back the cached driver still holding the wrong address, so the
|
||||||
|
// fix appeared to do nothing until OpsLog was restarted.
|
||||||
|
k += "|" + d.OnPat + "|" + d.OffPat +
|
||||||
|
"|" + strings.Join(d.OnURLs, "\x1f") + "|" + strings.Join(d.OffURLs, "\x1f") +
|
||||||
|
// The labels are part of the wire format here: {value} sends them.
|
||||||
|
// Renaming a relay re-addresses it, and the cached driver would keep
|
||||||
|
// commanding the old name.
|
||||||
|
"|" + strings.Join(d.Labels, "\x1f")
|
||||||
|
}
|
||||||
|
return k
|
||||||
}
|
}
|
||||||
|
|
||||||
// driverFor returns the cached, still-open driver for a device, building it once
|
// driverFor returns the cached, still-open driver for a device, building it once
|
||||||
@@ -15864,13 +15969,24 @@ func (a *App) restartMotorFollow(s UltrabeamSettings) {
|
|||||||
close(a.ubFollowStop)
|
close(a.ubFollowStop)
|
||||||
a.ubFollowStop = nil
|
a.ubFollowStop = nil
|
||||||
}
|
}
|
||||||
if !s.Follow || a.motorAnt == nil {
|
// Say WHICH of the two reasons it is. "follow loop stopped" covered both, and
|
||||||
applog.Printf("ultrabeam: follow loop stopped")
|
// at startup — where nothing was running to stop — it read as a fault. An
|
||||||
|
// operator whose antenna sat at 21050 while the rig worked 21074 sent a log
|
||||||
|
// that said the antenna had started, answered every poll, and had its follow
|
||||||
|
// loop "stopped": three lines that together looked like a link going dead,
|
||||||
|
// when tracking was simply switched off and the antenna was waiting to be
|
||||||
|
// tuned by hand.
|
||||||
|
if a.motorAnt == nil {
|
||||||
|
applog.Printf("antenna: tracking not started — no antenna connected")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.Follow {
|
||||||
|
applog.Printf("antenna: %s connected, but TRACKING IS OFF in Settings — it will not follow the rig, and only moves when you tune it by hand", s.Type)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
stop := make(chan struct{})
|
stop := make(chan struct{})
|
||||||
a.ubFollowStop = stop
|
a.ubFollowStop = stop
|
||||||
applog.Printf("ultrabeam: follow loop restarting — covered bands %v, mode %s, step %d kHz", s.Bands, normMotorTrackMode(s.TrackMode), s.StepKHz)
|
applog.Printf("antenna: %s tracking the rig — covered bands %v, mode %s, step %d kHz", s.Type, s.Bands, normMotorTrackMode(s.TrackMode), s.StepKHz)
|
||||||
go a.ultrabeamFollowLoop(a.motorAnt, s.TrackMode, s.StepKHz, s.Bands, stop)
|
go a.ultrabeamFollowLoop(a.motorAnt, s.TrackMode, s.StepKHz, s.Bands, stop)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+128
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"hamlog/internal/applog"
|
||||||
|
"hamlog/internal/psu"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── Bench power supply (Modbus RTU) ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A programmable supply feeding the shack, switched on and off from OpsLog so
|
||||||
|
// the station comes up and goes down with the logbook rather than by reaching
|
||||||
|
// behind the desk.
|
||||||
|
//
|
||||||
|
// OpsLog READS the supply's measurements and its set points, and WRITES exactly
|
||||||
|
// one thing: the output on/off. The register map has the voltage and current
|
||||||
|
// set points and the three protection trip levels as writable too, and none of
|
||||||
|
// them belong to a logbook — a wrong value there is 30 V where a radio expected
|
||||||
|
// 13.8. See internal/psu for the map and where it comes from.
|
||||||
|
|
||||||
|
const (
|
||||||
|
keyPSUEnabled = "psu.enabled"
|
||||||
|
keyPSUPort = "psu.com_port"
|
||||||
|
keyPSUBaud = "psu.baud"
|
||||||
|
keyPSUAddress = "psu.address" // Modbus slave address, 1…15 on this family
|
||||||
|
)
|
||||||
|
|
||||||
|
// PSUSettings is the JSON shape for the Hardware → Power supply panel.
|
||||||
|
type PSUSettings struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
ComPort string `json:"com_port"`
|
||||||
|
Baud int `json:"baud"` // 9600 from the factory
|
||||||
|
Address int `json:"address"` // 1 from the factory
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPSUSettings returns the persisted supply config.
|
||||||
|
func (a *App) GetPSUSettings() (PSUSettings, error) {
|
||||||
|
out := PSUSettings{Baud: 9600, Address: 1}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out, fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
m, err := a.settings.GetMany(a.ctx, keyPSUEnabled, keyPSUPort, keyPSUBaud, keyPSUAddress)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.Enabled = m[keyPSUEnabled] == "1"
|
||||||
|
out.ComPort = m[keyPSUPort]
|
||||||
|
if v, e := strconv.Atoi(m[keyPSUBaud]); e == nil && v > 0 {
|
||||||
|
out.Baud = v
|
||||||
|
}
|
||||||
|
if v, e := strconv.Atoi(m[keyPSUAddress]); e == nil && v >= 1 && v <= 250 {
|
||||||
|
out.Address = v
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SavePSUSettings persists the config and (re)starts or stops the client.
|
||||||
|
func (a *App) SavePSUSettings(s PSUSettings) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
if s.Baud <= 0 {
|
||||||
|
s.Baud = 9600
|
||||||
|
}
|
||||||
|
// The manual gives 1…15 for the address field and 1…250 for the address
|
||||||
|
// SETTING register. Clamped to the wider range and defaulted to 1: an
|
||||||
|
// address of 0 is the Modbus broadcast, which never answers, so accepting it
|
||||||
|
// would give a supply that is present and permanently "not responding".
|
||||||
|
if s.Address < 1 || s.Address > 250 {
|
||||||
|
s.Address = 1
|
||||||
|
}
|
||||||
|
for k, v := range map[string]string{
|
||||||
|
keyPSUEnabled: boolStr(s.Enabled),
|
||||||
|
keyPSUPort: strings.TrimSpace(s.ComPort),
|
||||||
|
keyPSUBaud: strconv.Itoa(s.Baud),
|
||||||
|
keyPSUAddress: strconv.Itoa(s.Address),
|
||||||
|
} {
|
||||||
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.restartAsync("psu", a.startPSU)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// startPSU stops any running client and starts a fresh one if the supply is
|
||||||
|
// enabled and has a port. Safe to call repeatedly (startup, settings save,
|
||||||
|
// profile switch).
|
||||||
|
func (a *App) startPSU() {
|
||||||
|
if a.psu != nil {
|
||||||
|
go a.psu.Stop()
|
||||||
|
a.psu = nil
|
||||||
|
}
|
||||||
|
s, err := a.GetPSUSettings()
|
||||||
|
if err != nil {
|
||||||
|
applog.Printf("psu: not started — settings unavailable: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !s.Enabled {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(s.ComPort) == "" {
|
||||||
|
applog.Printf("psu: not started — no serial port configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
applog.Printf("psu: starting on %s @ %d baud, Modbus address %d", s.ComPort, s.Baud, s.Address)
|
||||||
|
a.psu = psu.New(psu.Config{ComPort: s.ComPort, Baud: s.Baud, Address: byte(s.Address)})
|
||||||
|
_ = a.psu.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPSUStatus returns the supply's last polled state for the UI.
|
||||||
|
func (a *App) GetPSUStatus() psu.Status {
|
||||||
|
if a.psu == nil {
|
||||||
|
return psu.Status{}
|
||||||
|
}
|
||||||
|
return a.psu.GetStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetPSUOutput switches the supply's output. The only write OpsLog makes to it.
|
||||||
|
func (a *App) SetPSUOutput(on bool) error {
|
||||||
|
if a.psu == nil {
|
||||||
|
return fmt.Errorf("the power supply is not enabled in Settings")
|
||||||
|
}
|
||||||
|
return a.psu.SetOutput(on)
|
||||||
|
}
|
||||||
+12
-1
@@ -45,9 +45,20 @@ const appQSLCardSentField = "APP_OPSLOG_QSL_SENT"
|
|||||||
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
|
// (please send one). Independent of ADIF qsl_rcvd, like the sent field.
|
||||||
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
|
const appQSLCardRcvdField = "APP_OPSLOG_QSL_RCVD"
|
||||||
|
|
||||||
|
// qslCredit closes the default QSL e-mail: who made the card, and where the
|
||||||
|
// recipient can get the same program.
|
||||||
|
//
|
||||||
|
// A DEFAULT, not a signature. It lives in the body template like every other
|
||||||
|
// line, so an operator who does not want it deletes it once and it is gone —
|
||||||
|
// and one who has already written their own body never sees it appear, because
|
||||||
|
// a stored template is returned verbatim and the default is only the fallback.
|
||||||
|
// Appending it at send time instead would have made it unremovable, which is
|
||||||
|
// not a thing to do to someone's outgoing mail.
|
||||||
|
const qslCredit = "--\nDesigned & sent by OpsLog — " + releasesPageURL
|
||||||
|
|
||||||
const (
|
const (
|
||||||
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
defaultQSLEmailSubject = "eQSL — {CALL} de {MYCALL}"
|
||||||
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}"
|
defaultQSLEmailBody = "Hi,\n\nThank you for our QSO! Please find attached your eQSL card.\n\n{DATE} · {BAND} · {MODE}\n\n73,\n{MYCALL}\n\n" + qslCredit
|
||||||
)
|
)
|
||||||
|
|
||||||
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
|
// qslDir is the root of all designer artifacts: templates/<id>/ and outbox/.
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A band change now has two sources: the rig, and the entry strip on a station
|
||||||
|
// whose rig OpsLog does not control. They share one memory of the last band, so
|
||||||
|
// a station that has both commands its antenna switch once per QSY rather than
|
||||||
|
// twice — the entry selector pushes a QSY, the rig reports the same band back,
|
||||||
|
// and only the first of the two is a change.
|
||||||
|
func TestBandChangeIsNotedOncePerBand(t *testing.T) {
|
||||||
|
lastTriggerBandMu.Lock()
|
||||||
|
lastTriggerBand = ""
|
||||||
|
lastTriggerBandMu.Unlock()
|
||||||
|
|
||||||
|
if b, changed := noteBandChange("20m"); !changed || b != "20m" {
|
||||||
|
t.Fatalf("first 20m = (%q,%v), want (\"20m\",true)", b, changed)
|
||||||
|
}
|
||||||
|
// The same band from the other source — the rig echoing the QSY back.
|
||||||
|
if _, changed := noteBandChange("20M"); changed {
|
||||||
|
t.Error("the rig echoing the band back counted as a second change — the switch would be commanded twice")
|
||||||
|
}
|
||||||
|
if _, changed := noteBandChange(" 20m "); changed {
|
||||||
|
t.Error("whitespace made the same band look new")
|
||||||
|
}
|
||||||
|
if b, changed := noteBandChange("40m"); !changed || b != "40m" {
|
||||||
|
t.Errorf("40m = (%q,%v), want (\"40m\",true)", b, changed)
|
||||||
|
}
|
||||||
|
// An unknown band must not be recorded, or the next real one would look
|
||||||
|
// unchanged against it.
|
||||||
|
if _, changed := noteBandChange(""); changed {
|
||||||
|
t.Error("an empty band was treated as a change")
|
||||||
|
}
|
||||||
|
if _, changed := noteBandChange("40m"); changed {
|
||||||
|
t.Error("the empty band overwrote the last one")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,52 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.25.6",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"Right-click: update the US county of the selected contacts from the ULS database, replacing a county since renamed or abolished.",
|
||||||
|
"A QSO logged from WSJT-X, MSHV or a net now appears in Recent QSOs at once, instead of waiting for a delayed auto-upload to send it.",
|
||||||
|
"New installs: the default QSL and recording e-mails end with a credit line and a link to OpsLog. Part of the template, so delete it if unwanted.",
|
||||||
|
"Relay automatic control and band-change messages now follow the Band selector too, so a station without CAT switches its antenna when you change band.",
|
||||||
|
"Fixed OpsLog re-tuning its own rig from its own radio broadcasts, which dropped the CAT link on every JTDX or WSJT-X “Fake It” transmission.",
|
||||||
|
"New device: a bench power supply on Modbus RTU (BSIDE, Wanptek and kin) — its output switched from Station Control, with volts, amps and watts.",
|
||||||
|
"SPID rotator: a Rot1Prog controller turned nearly a full circle the wrong way for every heading — its commands take three digits, not four.",
|
||||||
|
"The compass sweeps with a turning antenna instead of jumping, and the controller is polled once for the whole app rather than by each panel.",
|
||||||
|
"A rotator test that only reads the heading now says so, instead of naming PstRotator for a move it never commanded."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Clic droit : mettre à jour le comté US des contacts sélectionnés depuis la base ULS, pour remplacer un comté renommé ou supprimé.",
|
||||||
|
"Un QSO logué depuis WSJT-X, MSHV ou un net apparaît aussitôt dans les QSO récents, sans attendre l’envoi d’un upload automatique différé.",
|
||||||
|
"Nouvelles installations : les mails QSL et enregistrement par défaut finissent par une ligne de crédit et un lien vers OpsLog. Dans le modèle, supprimable.",
|
||||||
|
"Le contrôle automatique des relais et les messages de changement de bande suivent aussi le champ Band : une station sans CAT commute enfin son antenne.",
|
||||||
|
"Corrigé : OpsLog réaccordait sa propre radio depuis ses propres diffusions, ce qui coupait le lien CAT à chaque émission JTDX ou WSJT-X en « Fake It ».",
|
||||||
|
"Nouvel appareil : alimentation de laboratoire en Modbus RTU (BSIDE, Wanptek et similaires) — sortie commutée depuis Contrôle station, avec V, A et W.",
|
||||||
|
"Rotator SPID : un contrôleur Rot1Prog tournait presque un tour complet à l’envers pour chaque azimut — ses commandes tiennent trois chiffres, pas quatre.",
|
||||||
|
"Le compas suit l’antenne en rotation au lieu de sauter, et le contrôleur est interrogé une fois pour toute l’application, plus par chaque panneau.",
|
||||||
|
"Un test de rotator qui ne fait que lire l’azimut le dit, au lieu de citer PstRotator pour un mouvement jamais commandé."
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "0.25.5",
|
||||||
|
"date": "",
|
||||||
|
"en": [
|
||||||
|
"DX cluster: when spots arrive and every one is filtered out, the panel says so, names the filters doing it and offers to clear them — it used to say “waiting for spots” beside a counter reading 76 live.",
|
||||||
|
"DX cluster: the log times the connection and the first spot, so a slow first launch can be told apart from a quiet node.",
|
||||||
|
"Generic HTTP relay: its URLs were never actually sent — fixed. {value} sends the relay's label, {relay-1} counts from zero, no host needed.",
|
||||||
|
"Switching profile now reconnects the amplifier, antenna, Antenna Genius and tuner — they stayed on the previous profile's ports until you saved Settings.",
|
||||||
|
"SteppIR: one missed reply used to wedge the link — the antenna stopped responding, with nothing at all in the log. It now recovers on its own.",
|
||||||
|
"The log now says when a motorized antenna is connected but tracking is off, instead of a line that read like a fault.",
|
||||||
|
"DXHunter spot clicks tune the rig again: the frequency it sends is read in whatever unit it uses, not always as MHz."
|
||||||
|
],
|
||||||
|
"fr": [
|
||||||
|
"Cluster DX : quand des spots arrivent et que tout est filtré, le panneau le dit, nomme les filtres responsables et propose de les effacer — il affichait « en attente de spots » à côté d’un compteur à 76 en direct.",
|
||||||
|
"Cluster DX : le journal chronomètre la connexion et le premier spot, pour distinguer un premier lancement lent d’un nœud silencieux.",
|
||||||
|
"Relais HTTP générique : ses URL n’étaient jamais envoyées — corrigé. {value} envoie le libellé du relais, {relay-1} compte de zéro, hôte inutile.",
|
||||||
|
"Changer de profil reconnecte l’ampli, l’antenne, l’Antenna Genius et le tuner — ils restaient sur les ports du profil précédent jusqu’à un Enregistrer.",
|
||||||
|
"SteppIR : une seule réponse manquée bloquait la liaison — l’antenne ne répondait plus, sans rien dans le journal. Elle se rétablit maintenant seule.",
|
||||||
|
"Le journal indique désormais qu’une antenne motorisée est connectée mais que le suivi est désactivé, au lieu d’une ligne qui ressemblait à une panne.",
|
||||||
|
"Les clics de spot DXHunter accordent à nouveau le rig : la fréquence envoyée est lue dans son unité réelle, plus toujours en MHz."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.25.4",
|
"version": "0.25.4",
|
||||||
"date": "",
|
"date": "",
|
||||||
|
|||||||
+135
-30
@@ -11,17 +11,17 @@ import {
|
|||||||
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
SaveCabrilloFile, ExportCabrillo, ExportCabrilloFiltered, ExportCabrilloSelected,
|
||||||
ContestDupe,
|
ContestDupe,
|
||||||
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
GetQSO, UpdateQSO, DeleteQSO, DeleteQSOs, DeleteAllQSO,
|
||||||
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UploadQSOsManual, SendQSORecordingEmail,
|
UpdateQSOsFromCty, UpdateQSOsFromQRZ, UpdateQSOsFromClublog, UpdateQSOsCountyFromULS, ULSStatus, UploadQSOsManual, SendQSORecordingEmail,
|
||||||
LookupCallsign, GetStationSettings, GetListsSettings,
|
LookupCallsign, GetStationSettings, GetListsSettings,
|
||||||
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
GetStartupStatus, CheckForUpdate, DownloadAndApplyUpdate, GetLiveStations, GetWhatsNew, GetChangelog,
|
||||||
SMTPConfigured, SendLogToDeveloper,
|
SMTPConfigured, SendLogToDeveloper,
|
||||||
WorkedBefore,
|
WorkedBefore,
|
||||||
SetCompactMode, SetCompactHeight,
|
SetCompactMode, SetCompactHeight,
|
||||||
RotatorGoToPath,
|
RotatorGoToPath,
|
||||||
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, FlexApplyBandAntenna, FlexApplyBandPower,
|
GetCATState, SetCATFrequency, SetCATMode, SwitchCATRig, EntryBandChanged, FlexApplyBandAntenna, FlexApplyBandPower,
|
||||||
GetSecretStatus, UnlockSecrets,
|
GetSecretStatus, UnlockSecrets,
|
||||||
RefreshCtyDat, DownloadAllReferenceLists,
|
RefreshCtyDat, DownloadAllReferenceLists,
|
||||||
RotatorGoTo, RotatorStop, GetRotatorHeading, SetActiveRotor,
|
RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||||
GetDBConnectionInfo, GetLogbookRevision,
|
GetDBConnectionInfo, GetLogbookRevision,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
GetUltrabeamStatus, SetUltrabeamDirection, UILog,
|
||||||
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate,
|
||||||
@@ -107,6 +107,7 @@ import { DetailsPanel, type DetailsState } from '@/components/DetailsPanel';
|
|||||||
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
import { SendSpotModal, type RecentSpotQSO } from '@/components/SendSpotModal';
|
||||||
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
import { WinkeyerPanel, type WKStatus, type WKMacro } from '@/components/WinkeyerPanel';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
|
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
import { formatDateTimeUTC } from '@/lib/dateFormat';
|
||||||
import { titleCase, sentenceCase } from '@/lib/textCase';
|
import { titleCase, sentenceCase } from '@/lib/textCase';
|
||||||
@@ -730,10 +731,19 @@ export default function App() {
|
|||||||
function onBandUserChange(v: string) {
|
function onBandUserChange(v: string) {
|
||||||
setBand(v);
|
setBand(v);
|
||||||
noteManualEdit();
|
noteManualEdit();
|
||||||
if (catState.enabled && catState.connected && !locks.band && !locks.freq) {
|
// A lock means this value is deliberately decoupled from the rig (logging an
|
||||||
|
// old contact off-frequency) — drive nothing, neither the radio nor an antenna.
|
||||||
|
if (locks.band || locks.freq) return;
|
||||||
|
if (catState.enabled && catState.connected) {
|
||||||
const hz = qsyFreqHz(v, mode);
|
const hz = qsyFreqHz(v, mode);
|
||||||
if (hz > 0) SetCATFrequency(hz).catch(() => {});
|
if (hz > 0) SetCATFrequency(hz).catch(() => {});
|
||||||
|
return; // the rig reports back, and the relays follow from its state
|
||||||
}
|
}
|
||||||
|
// No rig to push to, so THIS is the band change. Without it, relay automatic
|
||||||
|
// control and the band-change outbound rows never heard about it — they only
|
||||||
|
// ever listened to the CAT state, which on a station without rig control
|
||||||
|
// never says anything.
|
||||||
|
EntryBandChanged(v).catch(() => {});
|
||||||
}
|
}
|
||||||
function onModeUserChange(v: string) {
|
function onModeUserChange(v: string) {
|
||||||
setMode(v);
|
setMode(v);
|
||||||
@@ -1539,6 +1549,41 @@ export default function App() {
|
|||||||
// Hide spots already worked (exact call worked, or this band+mode slot done).
|
// Hide spots already worked (exact call worked, or this band+mode slot done).
|
||||||
const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false));
|
const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false));
|
||||||
|
|
||||||
|
// Everything currently narrowing the spot list, in words. Shown when spots
|
||||||
|
// arrived and none survived — the operator needs to know WHICH filter to
|
||||||
|
// loosen, and the two locks are the least memorable because they follow the
|
||||||
|
// rig rather than a click.
|
||||||
|
const clusterActiveFilterSummary = useMemo(() => {
|
||||||
|
const on: string[] = [];
|
||||||
|
if (clusterLockBand) on.push(t('clg2.fBandLock'));
|
||||||
|
else if (clusterBands.size > 0) on.push(t('clg2.fBands', { list: [...clusterBands].join(', ') }));
|
||||||
|
if (clusterLockMode) on.push(t('clg2.fModeLock'));
|
||||||
|
else if (clusterModeFilter.size > 0) on.push(t('clg2.fModes', { list: [...clusterModeFilter].join(', ') }));
|
||||||
|
if (clusterStatusFilter.size > 0) on.push(t('clg2.fStatus'));
|
||||||
|
if (clusterHideWorked) on.push(t('clg2.fHideWorked'));
|
||||||
|
if (clusterLotwOnly) on.push(t('clg2.fLotwOnly'));
|
||||||
|
if (clusterSpotterConts.size > 0) on.push(t('clg2.fSpotterCont'));
|
||||||
|
if (clusterFilterSource) on.push(t('clg2.fSource'));
|
||||||
|
if (clusterSearch.trim()) on.push(t('clg2.fSearch', { q: clusterSearch.trim() }));
|
||||||
|
return on.join(' · ');
|
||||||
|
}, [t, clusterLockBand, clusterBands, clusterLockMode, clusterModeFilter, clusterStatusFilter,
|
||||||
|
clusterHideWorked, clusterLotwOnly, clusterSpotterConts, clusterFilterSource, clusterSearch]);
|
||||||
|
|
||||||
|
// Undo every one of them at once. A list of ten switches spread down a panel
|
||||||
|
// is not something to walk back by hand when the answer is "show me anything".
|
||||||
|
const clearClusterFilters = useCallback(() => {
|
||||||
|
setClusterLockBand(false);
|
||||||
|
setClusterBands(new Set());
|
||||||
|
setClusterLockMode(false);
|
||||||
|
setClusterModeFilter(new Set());
|
||||||
|
setClusterStatusFilter(new Set());
|
||||||
|
setClusterHideWorked(false);
|
||||||
|
setClusterLotwOnly(false);
|
||||||
|
setClusterSpotterConts(new Set());
|
||||||
|
setClusterFilterSource('');
|
||||||
|
setClusterSearch('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Persist every cluster filter selection whenever it changes, so it is still
|
// Persist every cluster filter selection whenever it changes, so it is still
|
||||||
// set after a close/reopen.
|
// set after a close/reopen.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2192,16 +2237,34 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
}, [buildActiveFilter]);
|
}, [buildActiveFilter]);
|
||||||
|
|
||||||
// Refresh the Recent QSOs grid after external-service uploads stamp the
|
// Reload the Recent QSOs grid when the log changes.
|
||||||
// sent status (auto-upload via extsvc:uploaded, or manual QSL Manager via
|
//
|
||||||
// qslmgr:done). Debounced so a batch of per-QSO events triggers one reload.
|
// qso:logged is the important one, and it was missing. The entry form calls
|
||||||
|
// refresh() itself after AddQSO, so a QSO TYPED here always appeared — but a
|
||||||
|
// QSO logged from anywhere else (WSJT-X / MSHV over UDP, JTAlert, NET
|
||||||
|
// Control, an offline replay) only reached the grid when something else
|
||||||
|
// happened to reload it, and the something else was the upload: with
|
||||||
|
// auto-upload set to immediate, extsvc:uploaded arrived a second later and
|
||||||
|
// hid the gap entirely.
|
||||||
|
//
|
||||||
|
// Which is why this surfaced as "delayed upload breaks the recent list". It
|
||||||
|
// does not — the grid was waiting on the upload for its news. On a delayed
|
||||||
|
// service the QSO appeared one to two minutes late, and on close-of-session
|
||||||
|
// upload not until the next launch: the corrections that deferring the upload
|
||||||
|
// exists to allow could not be made, because there was nothing to correct on
|
||||||
|
// screen. The list follows the LOG now, and the upload only stamps a status.
|
||||||
|
//
|
||||||
|
// Debounced: qso:logged fires twice per contact (once on insert, once when
|
||||||
|
// the award refs are materialised), and a contest run fires it every few
|
||||||
|
// seconds.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let t: number | undefined;
|
let t: number | undefined;
|
||||||
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
|
const ping = () => { if (t) window.clearTimeout(t); t = window.setTimeout(() => { refresh(); }, 400); };
|
||||||
|
const offLogged = EventsOn('qso:logged', ping);
|
||||||
const offUploaded = EventsOn('extsvc:uploaded', ping);
|
const offUploaded = EventsOn('extsvc:uploaded', ping);
|
||||||
const offDone = EventsOn('qslmgr:done', ping);
|
const offDone = EventsOn('qslmgr:done', ping);
|
||||||
const offEqsl = EventsOn('qsl:sent', ping);
|
const offEqsl = EventsOn('qsl:sent', ping);
|
||||||
return () => { offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
return () => { offLogged(); offUploaded(); offDone(); offEqsl(); if (t) window.clearTimeout(t); };
|
||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
// The backend bulk-recomputed the materialised award_refs (an award definition
|
// The backend bulk-recomputed the materialised award_refs (an award definition
|
||||||
@@ -2328,17 +2391,12 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Poll PstRotator for the live antenna heading (status bar). Cheap when the
|
// The live antenna heading, from the shared poller in lib/rotorHeading — fast
|
||||||
// rotator is disabled (the backend just reads settings and returns).
|
// while the antenna turns, slow while it is parked, and ONE loop however many
|
||||||
useEffect(() => {
|
// panels are watching. This used to be its own interval alongside Station
|
||||||
let alive = true;
|
// Control's, so the controller was polled twice over whenever that tab was
|
||||||
const tick = async () => {
|
// open, and every poll opens and closes the port.
|
||||||
try { const h: any = await GetRotatorHeading(); if (alive) setRotatorHeading(h); } catch {}
|
useEffect(() => subscribeRotorHeading((h) => setRotatorHeading(h as any)), []);
|
||||||
};
|
|
||||||
tick();
|
|
||||||
const id = window.setInterval(tick, 3000);
|
|
||||||
return () => { alive = false; window.clearInterval(id); };
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Poll the Ultrabeam antenna for its connection + pattern direction.
|
// Poll the Ultrabeam antenna for its connection + pattern direction.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3548,6 +3606,26 @@ export default function App() {
|
|||||||
catch (e: any) { setError(String(e?.message ?? e)); }
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
finally { setBulkProgress(null); }
|
finally { setBulkProgress(null); }
|
||||||
}
|
}
|
||||||
|
// Whether the offline US county database holds anything. It gates the
|
||||||
|
// right-click entry below: an action that can only answer "no database" is
|
||||||
|
// not worth a line in a menu this long. Re-read when a download finishes, so
|
||||||
|
// the entry appears without a restart.
|
||||||
|
const [ulsReady, setUlsReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const read = () => ULSStatus().then((s: any) => setUlsReady((s?.count ?? 0) > 0)).catch(() => {});
|
||||||
|
read();
|
||||||
|
const off = EventsOn('uls:done', () => { read(); });
|
||||||
|
return () => { off(); };
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Re-derive the county of the selected US contacts from the offline ULS
|
||||||
|
// database, OVERWRITING what is stored. Offered only once that database has
|
||||||
|
// been downloaded — see ulsReady above.
|
||||||
|
async function bulkUpdateCountyFromULS(ids: number[]) {
|
||||||
|
if (ids.length === 0) return;
|
||||||
|
try { await afterBulkUpdate(await UpdateQSOsCountyFromULS(ids as any), t('qctx.fromUlsLabel')); }
|
||||||
|
catch (e: any) { setError(String(e?.message ?? e)); }
|
||||||
|
}
|
||||||
async function bulkUpdateFromClublog(ids: number[]) {
|
async function bulkUpdateFromClublog(ids: number[]) {
|
||||||
if (ids.length === 0) return;
|
if (ids.length === 0) return;
|
||||||
try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
|
try { await afterBulkUpdate(await UpdateQSOsFromClublog(ids as any), 'from ClubLog'); }
|
||||||
@@ -5217,7 +5295,7 @@ export default function App() {
|
|||||||
return (
|
return (
|
||||||
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
|
||||||
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog}
|
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined}
|
||||||
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendTo={bulkSendTo} onSendRecording={bulkSendRecording} onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||||
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
||||||
@@ -5247,7 +5325,7 @@ export default function App() {
|
|||||||
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
|
||||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||||
qsoMenuHandlers={{
|
qsoMenuHandlers={{
|
||||||
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog, onUpdateCountyFromULS: ulsReady ? bulkUpdateCountyFromULS : undefined,
|
||||||
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||||
onDelete: (ids: number[]) => setDeletingIds(ids),
|
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||||
}} />
|
}} />
|
||||||
@@ -5271,7 +5349,7 @@ export default function App() {
|
|||||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty}
|
onUpdateFromCty={bulkUpdateFromCty}
|
||||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||||
onUpdateFromClublog={bulkUpdateFromClublog}
|
onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined}
|
||||||
onSendTo={bulkSendTo}
|
onSendTo={bulkSendTo}
|
||||||
onSendRecording={bulkSendRecording}
|
onSendRecording={bulkSendRecording}
|
||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
@@ -5409,7 +5487,7 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => RotatorStop().catch((err) => setError(String(err?.message ?? err)))}
|
onClick={() => { pokeRotorHeading(); RotatorStop().catch((err) => setError(String(err?.message ?? err))); }}
|
||||||
title="Stop rotation"
|
title="Stop rotation"
|
||||||
className="px-1.5 py-0.5 border-l border-info-border text-danger hover:bg-danger-muted hover:text-danger-muted-foreground cursor-pointer transition-colors"
|
className="px-1.5 py-0.5 border-l border-info-border text-danger hover:bg-danger-muted hover:text-danger-muted-foreground cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
@@ -6199,8 +6277,8 @@ export default function App() {
|
|||||||
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
rotorEnabled={rotatorHeading.enabled && rotatorHeading.ok}
|
||||||
rotors={(rotatorHeading as any).rotors}
|
rotors={(rotatorHeading as any).rotors}
|
||||||
activeRotor={(rotatorHeading as any).active}
|
activeRotor={(rotatorHeading as any).active}
|
||||||
onSelectRotor={(i) => { SetActiveRotor(i).then(() => GetRotatorHeading()).then((h: any) => setRotatorHeading(h)).catch((err) => setError(String(err?.message ?? err))); }}
|
onSelectRotor={(i) => { SetActiveRotor(i).then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||||
onGoto={(az) => RotatorGoTo(Math.round(az), -1).catch((err) => setError(String(err?.message ?? err)))}
|
onGoto={(az) => { RotatorGoTo(Math.round(az), -1).then(pokeRotorHeading).catch((err) => setError(String(err?.message ?? err))); }}
|
||||||
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
onClose={() => { setShowRotor(false); writeUiPref('opslog.showRotor', '0'); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -6587,7 +6665,7 @@ export default function App() {
|
|||||||
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty}
|
onUpdateFromCty={bulkUpdateFromCty}
|
||||||
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
onUpdateFromQRZ={bulkUpdateFromQRZ}
|
||||||
onUpdateFromClublog={bulkUpdateFromClublog}
|
onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined}
|
||||||
onSendTo={bulkSendTo}
|
onSendTo={bulkSendTo}
|
||||||
onSendRecording={bulkSendRecording}
|
onSendRecording={bulkSendRecording}
|
||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
@@ -6738,14 +6816,41 @@ export default function App() {
|
|||||||
// pane). All the filter state lives in the right-side panel.
|
// pane). All the filter state lives in the right-side panel.
|
||||||
const rendered = clusterRenderedRows;
|
const rendered = clusterRenderedRows;
|
||||||
if (rendered.length === 0) {
|
if (rendered.length === 0) {
|
||||||
|
const connected = clusterServerStatuses.some((s) => s.state === 'connected');
|
||||||
|
// Spots HAVE arrived and the filters ate every one of them.
|
||||||
|
//
|
||||||
|
// This used to say "Waiting for spots…" regardless, which is a
|
||||||
|
// lie the moment the counter beside it reads 76 live: an
|
||||||
|
// operator reads the two together and goes looking for a
|
||||||
|
// connection fault instead of at the filter panel. The band and
|
||||||
|
// mode locks are the usual culprits and the least visible —
|
||||||
|
// they follow the rig, so nobody remembers switching them on.
|
||||||
|
if (connected && spots.length > 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12 px-6 text-center">
|
||||||
|
<SlidersHorizontal className="size-10 opacity-30" />
|
||||||
|
<div className="text-sm font-semibold text-foreground/70">
|
||||||
|
{t('clg2.allFiltered', { n: spots.length })}
|
||||||
|
</div>
|
||||||
|
{clusterActiveFilterSummary && (
|
||||||
|
<div className="text-xs max-w-md leading-relaxed">
|
||||||
|
{t('clg2.activeFilters')} <span className="font-medium text-foreground/80">{clusterActiveFilterSummary}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button size="sm" variant="outline" className="mt-1" onClick={clearClusterFilters}>
|
||||||
|
{t('clg2.clearAllFilters')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
<div className="flex-1 flex flex-col items-center justify-center text-muted-foreground gap-2 py-12">
|
||||||
<Hash className="size-10 opacity-30" />
|
<Hash className="size-10 opacity-30" />
|
||||||
<div className="text-sm font-semibold text-foreground/70">
|
<div className="text-sm font-semibold text-foreground/70">
|
||||||
{clusterServerStatuses.some((s) => s.state === 'connected') ? 'Waiting for spots…' : 'No active connection'}
|
{connected ? 'Waiting for spots…' : 'No active connection'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
{clusterServerStatuses.some((s) => s.state === 'connected')
|
{connected
|
||||||
? 'Spots will appear as the cluster sends them.'
|
? 'Spots will appear as the cluster sends them.'
|
||||||
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
|
: 'Use Connect all (or configure a cluster in Settings → DX Cluster).'}
|
||||||
</div>
|
</div>
|
||||||
@@ -6840,7 +6945,7 @@ export default function App() {
|
|||||||
|
|
||||||
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="worked" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
<WorkedBeforeGrid key={`wbg-${activeProfileId ?? 'x'}`} wb={wbWithAwards as any} myGrid={station.my_grid} awardCols={awardCols} busy={wbBusy} currentCall={callsign} onRowDoubleClicked={(q) => openEdit(q.id as number)}
|
||||||
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
onUpdateFromCty={bulkUpdateFromCty} onUpdateFromQRZ={bulkUpdateFromQRZ} onUpdateFromClublog={bulkUpdateFromClublog} onUpdateCountyFromULS={ulsReady ? bulkUpdateCountyFromULS : undefined} onSendTo={bulkSendTo} onSendRecording={bulkSendRecording}
|
||||||
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
onSendEQSL={(ids) => setEqslQsoId(ids[0] ?? null)}
|
||||||
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
onBulkEdit={openBulkEdit} onExportSelected={exportSelectedADIF} onExportSelectedFields={exportSelectedFields}
|
||||||
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
onExportCabrilloSelected={exportSelectedCabrillo} onDelete={(ids) => setDeletingIds(ids)} />
|
||||||
@@ -6937,7 +7042,7 @@ export default function App() {
|
|||||||
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
<TabsContent value="net" className="mt-0 flex flex-col min-h-0 flex-1">
|
||||||
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes}
|
||||||
qsoMenuHandlers={{
|
qsoMenuHandlers={{
|
||||||
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog,
|
onUpdateFromCty: bulkUpdateFromCty, onUpdateFromQRZ: bulkUpdateFromQRZ, onUpdateFromClublog: bulkUpdateFromClublog, onUpdateCountyFromULS: ulsReady ? bulkUpdateCountyFromULS : undefined,
|
||||||
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
onSendTo: bulkSendTo, onSendRecording: bulkSendRecording, onSendEQSL: (ids: number[]) => setEqslQsoId(ids[0] ?? null),
|
||||||
onDelete: (ids: number[]) => setDeletingIds(ids),
|
onDelete: (ids: number[]) => setDeletingIds(ids),
|
||||||
}} />
|
}} />
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ type QSOMenuHandlers = {
|
|||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||||
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2 } from 'lucide-react';
|
import { Globe2, RefreshCw, Upload, BadgeCheck, Mail, FileDown, PencilLine, Trash2, MapPin } from 'lucide-react';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
|
||||||
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
export type QSOMenuState = { x: number; y: number; ids: number[] } | null;
|
||||||
@@ -10,6 +10,9 @@ type Props = {
|
|||||||
onUpdateFromCty: (ids: number[]) => void;
|
onUpdateFromCty: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ: (ids: number[]) => void;
|
onUpdateFromQRZ: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
// Only passed once the offline US county database has been downloaded —
|
||||||
|
// an entry that can only ever answer "no database" is not worth a line here.
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
@@ -36,7 +39,7 @@ const UPLOAD_TARGETS: { service: string; name: string }[] = [
|
|||||||
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
// or picks a command. (We deliberately do NOT close on scroll/resize: the QSO
|
||||||
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
// list auto-refreshes and AG Grid fires internal scroll events on refresh,
|
||||||
// which used to dismiss the menu the instant it appeared.)
|
// which used to dismiss the menu the instant it appeared.)
|
||||||
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const boxRef = useRef<HTMLDivElement>(null);
|
const boxRef = useRef<HTMLDivElement>(null);
|
||||||
// Starts at the cursor; the layout effect corrects it once the real size is
|
// Starts at the cursor; the layout effect corrects it once the real size is
|
||||||
@@ -118,6 +121,16 @@ export function QSOContextMenu({ menu, onClose, onUpdateFromCty, onUpdateFromQRZ
|
|||||||
<span>{t('qctx.updateClublog')}</span>
|
<span>{t('qctx.updateClublog')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
{onUpdateCountyFromULS && (
|
||||||
|
<button
|
||||||
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left hover:bg-accent/50"
|
||||||
|
onClick={() => { onUpdateCountyFromULS(menu.ids); onClose(); }}
|
||||||
|
title={t('qctx.updateUlsCountyTitle')}
|
||||||
|
>
|
||||||
|
<MapPin className="size-4 text-info" />
|
||||||
|
<span>{t('qctx.updateUlsCounty')}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{(onSendRecording || onSendEQSL) && (
|
{(onSendRecording || onSendEQSL) && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ type Props = {
|
|||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
@@ -308,7 +309,7 @@ const sanitizeAwardCols = (st: any[] | null | undefined): any[] =>
|
|||||||
return rest;
|
return rest;
|
||||||
});
|
});
|
||||||
|
|
||||||
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal, rowDragCall, passOrder, onGridApi, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onRowSelectedQso, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols, rowColors }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -716,6 +717,7 @@ export function RecentQSOsGrid({ rows, myGrid, selectAllSignal, selectRowSignal,
|
|||||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||||
onUpdateFromClublog={onUpdateFromClublog}
|
onUpdateFromClublog={onUpdateFromClublog}
|
||||||
|
onUpdateCountyFromULS={onUpdateCountyFromULS}
|
||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam,
|
||||||
GetAntGeniusSettings, SaveAntGeniusSettings,
|
GetAntGeniusSettings, SaveAntGeniusSettings,
|
||||||
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
GetTunerGeniusSettings, SaveTunerGeniusSettings,
|
||||||
|
GetPSUSettings, SavePSUSettings,
|
||||||
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
GetAmplifiers, SaveAmplifiers, GetAmpStatuses, AmpOperate,
|
||||||
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts,
|
||||||
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
GetAudioSettings, SaveAudioSettings, AudioApplyLevels, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT,
|
||||||
@@ -203,6 +204,7 @@ type SectionId =
|
|||||||
| 'antenna'
|
| 'antenna'
|
||||||
| 'antgenius'
|
| 'antgenius'
|
||||||
| 'tunergenius'
|
| 'tunergenius'
|
||||||
|
| 'psu'
|
||||||
| 'pgxl'
|
| 'pgxl'
|
||||||
| 'flex'
|
| 'flex'
|
||||||
| 'relayauto'
|
| 'relayauto'
|
||||||
@@ -251,6 +253,7 @@ function buildTree(flexAvailable: boolean, t: (k: string) => string): TreeNode[]
|
|||||||
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
|
{ kind: 'item', label: t('sec.antgenius'), id: 'antgenius', vendor: 'o3a' },
|
||||||
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius', vendor: 'o3a' },
|
{ kind: 'item', label: t('sec.tunergenius'), id: 'tunergenius', vendor: 'o3a' },
|
||||||
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
{ kind: 'item', label: t('sec.pgxl'), id: 'pgxl' },
|
||||||
|
{ kind: 'item', label: t('sec.psu'), id: 'psu' },
|
||||||
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
...(flexAvailable ? [{ kind: 'item', label: t('sec.flex'), id: 'flex' } as TreeNode] : []),
|
||||||
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
{ kind: 'item', label: t('sec.relayauto'), id: 'relayauto' },
|
||||||
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
{ kind: 'item', label: t('sec.audio'), id: 'audio' },
|
||||||
@@ -1278,6 +1281,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||||||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
const [tunergenius, setTunergenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||||
|
const [psuCfg, setPsuCfg] = useState<{ enabled: boolean; com_port: string; baud: number; address: number }>({ enabled: false, com_port: '', baud: 9600, address: 1 });
|
||||||
|
|
||||||
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
// Amplifier list — operators can run SEVERAL amps (even two SPEs combined),
|
||||||
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
// each with its own connection. Saved as a whole via SaveAmplifiers.
|
||||||
@@ -1681,6 +1685,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
|
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
setBackupCfg(b as any);
|
setBackupCfg(b as any);
|
||||||
setQslDefaults(qd as any);
|
setQslDefaults(qd as any);
|
||||||
@@ -1723,6 +1728,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {}
|
||||||
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
try { setAntgenius(await GetAntGeniusSettings() as any); } catch {}
|
||||||
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
try { setTunergenius(await GetTunerGeniusSettings() as any); } catch {}
|
||||||
|
try { setPsuCfg(await GetPSUSettings() as any); } catch {}
|
||||||
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
try { setAmps(((await GetAmplifiers()) ?? []) as AmpUI[]); } catch {}
|
||||||
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
try { setBackupCfg(await GetBackupSettings() as any); } catch {}
|
||||||
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
try { setQslDefaults(await GetQSLDefaults() as any); } catch {}
|
||||||
@@ -1916,6 +1922,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
await SaveUltrabeamSettings(ultrabeam as any);
|
await SaveUltrabeamSettings(ultrabeam as any);
|
||||||
await SaveAntGeniusSettings(antgenius as any);
|
await SaveAntGeniusSettings(antgenius as any);
|
||||||
await SaveTunerGeniusSettings(tunergenius as any);
|
await SaveTunerGeniusSettings(tunergenius as any);
|
||||||
|
await SavePSUSettings(psuCfg as any);
|
||||||
await SaveAmplifiers(amps as any);
|
await SaveAmplifiers(amps as any);
|
||||||
await SaveWinkeyerSettings(wk as any);
|
await SaveWinkeyerSettings(wk as any);
|
||||||
await SaveAudioSettings(audioCfg as any);
|
await SaveAudioSettings(audioCfg as any);
|
||||||
@@ -3079,7 +3086,19 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setRotatorTest(null);
|
setRotatorTest(null);
|
||||||
try {
|
try {
|
||||||
await TestRotatorDevice(dev as any, sub);
|
await TestRotatorDevice(dev as any, sub);
|
||||||
setRotatorTest({ ok: true, msg: (dev as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
// Say what the test ACTUALLY did, which is not the same on every backend.
|
||||||
|
//
|
||||||
|
// PstRotator and a Rotator Genius are sent a move to 0°; a SPID, an ARCO
|
||||||
|
// and a DCU-1 are asked their heading and nothing turns. They all used to
|
||||||
|
// report "Packet sent — the antenna should swing to north. If it didn't,
|
||||||
|
// check PstRotator's UDP listener" — which named a program that is not in
|
||||||
|
// the path, for a move that was never commanded. An operator on a tower
|
||||||
|
// read that as the test having failed.
|
||||||
|
const type = (dev as any).type;
|
||||||
|
const msg = type === 'rotgenius' ? t('rot.testOkRG')
|
||||||
|
: (type === 'spid' || type === 'arco' || type === 'dcu1') ? t('rot.testOkRead')
|
||||||
|
: t('cat.rotatorOk');
|
||||||
|
setRotatorTest({ ok: true, msg });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -3383,6 +3402,62 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bench power supply over Modbus RTU. OpsLog reads everything and writes one
|
||||||
|
// register — the output on/off. The voltage and current SET points are shown
|
||||||
|
// because they are worth seeing, and are not editable here: they belong to the
|
||||||
|
// supply's front panel, and a logbook that can set them can set them wrong.
|
||||||
|
// NO HOOKS IN HERE. This panel is called as a plain function, like its
|
||||||
|
// neighbours — PANELS[x]() — so a useState of its own lands in SettingsModal's
|
||||||
|
// hook list and only while this section is open. React counts those, and it
|
||||||
|
// stopped drawing the moment the section was clicked (error #310, "rendered
|
||||||
|
// more hooks than during the previous render"). The COM ports come from the
|
||||||
|
// list SettingsModal already loads for the Winkeyer panel: one machine, one
|
||||||
|
// set of serial ports, loaded once.
|
||||||
|
function PSUPanelSettings() {
|
||||||
|
const ports = wkPorts;
|
||||||
|
const setPorts = setWkPorts;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SectionHeader title={t('psu.title')} hint={t('psu.hint')} />
|
||||||
|
<div className="space-y-4 max-w-xl">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={psuCfg.enabled} onCheckedChange={(c) => setPsuCfg((s) => ({ ...s, enabled: !!c }))} />
|
||||||
|
{t('psu.enable')}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('psu.port')}</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select value={psuCfg.com_port || '_'} onValueChange={(v) => setPsuCfg((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
|
||||||
|
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{ports.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||||
|
{ports.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setPorts((p ?? []) as string[])).catch(() => {})}>
|
||||||
|
↻
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('psu.baud')}</Label>
|
||||||
|
<Input className="font-mono" value={String(psuCfg.baud ?? 9600)}
|
||||||
|
onChange={(e) => setPsuCfg((s) => ({ ...s, baud: parseInt(e.target.value.replace(/[^0-9]/g, ''), 10) || 0 }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('psu.address')}</Label>
|
||||||
|
<Input className="font-mono" value={String(psuCfg.address ?? 1)}
|
||||||
|
onChange={(e) => setPsuCfg((s) => ({ ...s, address: parseInt(e.target.value.replace(/[^0-9]/g, ''), 10) || 0 }))} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('psu.wireHint')}</p>
|
||||||
|
<p className="text-xs text-warning">{t('psu.writeScope')}</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function PGXLPanelSettings() {
|
function PGXLPanelSettings() {
|
||||||
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
// The stored `type` stays a flat value ("spe13", "acom700", "pgxl"); the UI
|
||||||
// presents it as brand + model.
|
// presents it as brand + model.
|
||||||
@@ -6349,6 +6424,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
antenna: UltrabeamPanel,
|
antenna: UltrabeamPanel,
|
||||||
antgenius: AntGeniusPanelSettings,
|
antgenius: AntGeniusPanelSettings,
|
||||||
tunergenius: TunerGeniusPanelSettings,
|
tunergenius: TunerGeniusPanelSettings,
|
||||||
|
psu: PSUPanelSettings,
|
||||||
pgxl: PGXLPanelSettings,
|
pgxl: PGXLPanelSettings,
|
||||||
flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
|
flex: () => <FlexBandPanel bands={lists.bands ?? []} />,
|
||||||
audio: AudioPanel,
|
audio: AudioPanel,
|
||||||
|
|||||||
@@ -7,22 +7,77 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { useI18n } from '@/lib/i18n';
|
import { useI18n } from '@/lib/i18n';
|
||||||
import { writeUiPref } from '@/lib/uiPref';
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { subscribeRotorHeading, pokeRotorHeading } from '@/lib/rotorHeading';
|
||||||
import { RotorCompass } from '@/components/RotorCompass';
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
import { AmpCard } from '@/components/AmpCard';
|
import { AmpCard } from '@/components/AmpCard';
|
||||||
import { TunerCard } from '@/components/TunerCard';
|
import { TunerCard } from '@/components/TunerCard';
|
||||||
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
import type { TGStatus } from '@/components/TunerGeniusPanel';
|
||||||
import {
|
import {
|
||||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||||
GetAmpStatuses, GetFlexState,
|
GetAmpStatuses, GetFlexState,
|
||||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||||
|
GetPSUStatus, GetPSUSettings, SetPSUOutput,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||||
|
|
||||||
|
type PSUState = { connected: boolean; on: boolean; volts: number; amps: number; watts: number; set_volts: number; set_amps: number; protected: number; error?: string };
|
||||||
|
|
||||||
|
// The bench supply. One button — the output — and the three numbers that say
|
||||||
|
// what it is actually delivering.
|
||||||
|
//
|
||||||
|
// The SET points are shown beside them, small, because "13.8 V set" next to
|
||||||
|
// "0.02 A out" is how an operator sees at a glance that the supply is on but
|
||||||
|
// the radio is not drawing. They are not editable: OpsLog reads them and never
|
||||||
|
// writes them, which is the whole safety story of this device.
|
||||||
|
function PSUCard({ st, busy, onToggle, t }: {
|
||||||
|
st: PSUState; busy: boolean; onToggle: (on: boolean) => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const tripped = (st.protected ?? 0) !== 0;
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Power className="size-4 text-primary" />
|
||||||
|
<div className="text-sm font-semibold truncate">{t('psu.title')}</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full shrink-0', st.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={st.connected ? t('station.online') : (st.error || t('psu.offline'))} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<button type="button" disabled={!st.connected || busy}
|
||||||
|
onClick={() => onToggle(!st.on)}
|
||||||
|
className={cn('flex items-center gap-2 rounded-md border px-3 py-1.5 transition-colors disabled:opacity-40',
|
||||||
|
st.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||||
|
<span className={cn('flex items-center justify-center size-6 rounded shrink-0',
|
||||||
|
st.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{busy ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-semibold">{t('psu.output')}</span>
|
||||||
|
<span className={cn('text-[10px] font-bold', st.on ? 'text-success' : 'text-muted-foreground/60')}>
|
||||||
|
{st.on ? t('station.on') : t('station.off')}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0 font-mono tabular-nums text-right">
|
||||||
|
<span className="text-lg font-bold">{(st.volts ?? 0).toFixed(2)}</span><span className="text-xs text-muted-foreground"> V</span>
|
||||||
|
<span className="text-lg font-bold ml-3">{(st.amps ?? 0).toFixed(3)}</span><span className="text-xs text-muted-foreground"> A</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-[11px] text-muted-foreground font-mono">
|
||||||
|
<span>{(st.watts ?? 0).toFixed(1)} W</span>
|
||||||
|
<span>{t('psu.setTo')} {(st.set_volts ?? 0).toFixed(2)} V / {(st.set_amps ?? 0).toFixed(3)} A</span>
|
||||||
|
</div>
|
||||||
|
{tripped && (
|
||||||
|
<div className="text-[11px] font-bold text-danger">{t('psu.tripped')} (0x{(st.protected ?? 0).toString(16).toUpperCase()})</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type Device = {
|
type Device = {
|
||||||
id: string; type: string; name: string; host: string;
|
id: string; type: string; name: string; host: string;
|
||||||
user?: string; pass?: string; channels?: number; labels: string[];
|
user?: string; pass?: string; channels?: number; labels: string[];
|
||||||
@@ -457,6 +512,32 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
return () => { alive = false; window.clearInterval(id); };
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Bench power supply. Polled slower than the tuner: it has no meters that
|
||||||
|
// track a transmission, and every poll is three Modbus exchanges on a 9600
|
||||||
|
// baud line the operator may also be using to switch the output.
|
||||||
|
const [psu, setPsu] = useState<PSUState>({ connected: false, on: false, volts: 0, amps: 0, watts: 0, set_volts: 0, set_amps: 0, protected: 0 });
|
||||||
|
const [psuEnabled, setPsuEnabled] = useState(false);
|
||||||
|
const [psuBusy, setPsuBusy] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
let alive = true;
|
||||||
|
const load = async () => {
|
||||||
|
try { const en: any = await GetPSUSettings(); if (alive) setPsuEnabled(!!en?.enabled); } catch {}
|
||||||
|
try { const st: any = await GetPSUStatus(); if (alive && st) setPsu(st as PSUState); } catch {}
|
||||||
|
};
|
||||||
|
load();
|
||||||
|
const id = window.setInterval(load, 1500);
|
||||||
|
return () => { alive = false; window.clearInterval(id); };
|
||||||
|
}, []);
|
||||||
|
const togglePSU = useCallback(async (on: boolean) => {
|
||||||
|
setPsuBusy(true);
|
||||||
|
// No optimistic flip here, unlike the relays: the supply echoes the value it
|
||||||
|
// actually set, so showing ON before it confirms would be showing something
|
||||||
|
// OpsLog does not know. A power switch is the wrong place to guess.
|
||||||
|
try { await SetPSUOutput(on); const st: any = await GetPSUStatus(); if (st) setPsu(st as PSUState); }
|
||||||
|
catch { /* the poll will tell the truth */ }
|
||||||
|
finally { setPsuBusy(false); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
const loadDevices = useCallback(async () => {
|
const loadDevices = useCallback(async () => {
|
||||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
}, []);
|
}, []);
|
||||||
@@ -467,19 +548,21 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
||||||
} catch { /* ignore transient */ }
|
} catch { /* ignore transient */ }
|
||||||
}, []);
|
}, []);
|
||||||
const pollRot = useCallback(async () => {
|
|
||||||
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
|
||||||
}, []);
|
|
||||||
const pollAnt = useCallback(async () => {
|
const pollAnt = useCallback(async () => {
|
||||||
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
try { setAnt((await GetUltrabeamStatus()) as any); } catch { /* ignore */ }
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => { loadDevices(); }, [loadDevices]);
|
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||||
|
// The heading comes from the shared poller: one loop for the whole app, fast
|
||||||
|
// while the antenna turns and slow while it is parked. Every poll opens and
|
||||||
|
// closes the controller's port, and this panel used to run its own alongside
|
||||||
|
// the status bar's — the same controller asked twice over.
|
||||||
|
useEffect(() => subscribeRotorHeading((h) => setRot(h as any)), []);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
poll(); pollRot(); pollAnt();
|
poll(); pollAnt();
|
||||||
const id = window.setInterval(() => { poll(); pollRot(); pollAnt(); }, 3000);
|
const id = window.setInterval(() => { poll(); pollAnt(); }, 3000);
|
||||||
return () => window.clearInterval(id);
|
return () => window.clearInterval(id);
|
||||||
}, [poll, pollRot, pollAnt, devices.length]);
|
}, [poll, pollAnt, devices.length]);
|
||||||
|
|
||||||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||||
// Reorder so `dragged` lands just before `target`.
|
// Reorder so `dragged` lands just before `target`.
|
||||||
@@ -527,16 +610,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
const deviceCard = (dev: Device) => {
|
const deviceCard = (dev: Device) => {
|
||||||
const st = status[dev.id];
|
const st = status[dev.id];
|
||||||
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
||||||
|
// The generic HTTP board has no address of its own and nothing to poll: its
|
||||||
|
// relays can each live on a different box, and no status endpoint is read
|
||||||
|
// back. So no host under the name, no online dot, and the buttons are never
|
||||||
|
// greyed out waiting for a connection that is never made.
|
||||||
|
const fireAndForget = dev.type === 'httpgen';
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
<PlugZap className="size-4 text-primary" />
|
<PlugZap className="size-4 text-primary" />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
||||||
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
|
<div className="text-[10px] text-muted-foreground font-mono truncate">
|
||||||
|
{TYPE_LABEL[dev.type]}{fireAndForget || !dev.host ? '' : ` · ${dev.host}`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
{fireAndForget ? <span className="ml-auto" /> : (
|
||||||
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||||
|
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||||
|
)}
|
||||||
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
||||||
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||||||
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||||
@@ -549,7 +641,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
const key = `${dev.id}:${r.number}`;
|
const key = `${dev.id}:${r.number}`;
|
||||||
const label = r.label || `${t('station.relay')} ${r.number}`;
|
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||||
return (
|
return (
|
||||||
<button key={r.number} type="button" disabled={!st?.connected}
|
<button key={r.number} type="button" disabled={!fireAndForget && !st?.connected}
|
||||||
title={label}
|
title={label}
|
||||||
onClick={() => toggle(dev, r.number, !r.on)}
|
onClick={() => toggle(dev, r.number, !r.on)}
|
||||||
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
|
className={cn('w-[150px] flex items-center gap-1.5 rounded-md border px-2 py-1 text-left transition-colors disabled:opacity-40',
|
||||||
@@ -576,7 +668,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
// 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={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||||
}
|
}
|
||||||
if (ant.enabled) {
|
if (ant.enabled) {
|
||||||
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
widgets.push({ id: 'antenna', node: <MotorAntennaWidget ant={ant} refetch={pollAnt} t={t} /> });
|
||||||
@@ -585,6 +677,7 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
|||||||
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
for (const amp of amps) widgets.push({ id: `amp:${amp.id}`, node: <AmpCard amp={amp} flex={flexState} t={t} />, wide: true });
|
||||||
// Tuner Genius XL card (identical to the Flex panel's).
|
// Tuner Genius XL card (identical to the Flex panel's).
|
||||||
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
if (tgEnabled) widgets.push({ id: 'tuner', node: <TunerCard status={tg} t={t} />, wide: true });
|
||||||
|
if (psuEnabled) widgets.push({ id: 'psu', node: <PSUCard st={psu} busy={psuBusy} onToggle={togglePSU} t={t} /> });
|
||||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||||
|
|
||||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||||
@@ -687,6 +780,13 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
const isDenkovi = device.type === 'denkovi';
|
const isDenkovi = device.type === 'denkovi';
|
||||||
const isUsbRelay = device.type === 'usbrelay';
|
const isUsbRelay = device.type === 'usbrelay';
|
||||||
const isHTTPGen = device.type === 'httpgen';
|
const isHTTPGen = device.type === 'httpgen';
|
||||||
|
// {value} sends a relay's label, so a URL using it on an unnamed relay would
|
||||||
|
// go out with an empty parameter. Warn while it is being typed rather than at
|
||||||
|
// the moment an antenna fails to switch.
|
||||||
|
const valueNeedsLabels = isHTTPGen
|
||||||
|
&& [...(device.on_urls ?? []), ...(device.off_urls ?? []), device.on_pattern ?? '', device.off_pattern ?? '']
|
||||||
|
.some((s) => (s ?? '').includes('{value}'))
|
||||||
|
&& device.labels.some((l) => !l.trim());
|
||||||
// COM ports for the generic USB-serial relay picker.
|
// COM ports for the generic USB-serial relay picker.
|
||||||
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -795,7 +895,12 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : isHTTPGen ? null : (
|
||||||
|
/* No Host for the generic board: its driver never reads one. Each URL
|
||||||
|
below carries its own address, and they need not even share it — one
|
||||||
|
relay can sit on a different box from the next. A field that changes
|
||||||
|
nothing is worse than no field: it reads as the thing to fill in first,
|
||||||
|
and then the URLs look like they should be relative to it. */
|
||||||
<div className={cn('grid gap-3', (isKM || isDingtian) ? '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 || isDingtian) ? '' : 'max-w-xs')}>
|
<div className={cn('space-y-1', (isKM || isDingtian) ? '' : 'max-w-xs')}>
|
||||||
<Label>{t('station.host')}</Label>
|
<Label>{t('station.host')}</Label>
|
||||||
@@ -886,6 +991,10 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('station.labels')}</Label>
|
<Label>{t('station.labels')}</Label>
|
||||||
|
{/* {value} sends the label, so an unnamed relay would go out as "?on=".
|
||||||
|
Said here, beside the empty box, rather than when the antenna fails
|
||||||
|
to switch and the log is the only place that explains why. */}
|
||||||
|
{valueNeedsLabels && <p className="text-[10px] text-warning">{t('station.valueNeedsLabels')}</p>}
|
||||||
<div className="grid grid-cols-4 gap-2">
|
<div className="grid grid-cols-4 gap-2">
|
||||||
{device.labels.map((lab, i) => (
|
{device.labels.map((lab, i) => (
|
||||||
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||||||
@@ -901,12 +1010,20 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="ml-auto flex gap-2">
|
<div className="ml-auto flex gap-2">
|
||||||
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
{/* No connection test for the generic board, and no host required to
|
||||||
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
save it. There is nothing to test: it has no address of its own and
|
||||||
{t('station.test')}
|
no status to read — its URLs are fired and forgotten. A button that
|
||||||
</Button>
|
can only ever say "OK, 4 relays" tests nothing, and a Save greyed
|
||||||
|
out for a missing host made a perfectly complete configuration —
|
||||||
|
four full URLs — impossible to store. */}
|
||||||
|
{!isHTTPGen && (
|
||||||
|
<Button size="sm" variant="outline" onClick={testDevice} disabled={testing || !device.host.trim()}>
|
||||||
|
{testing ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <PlugZap className="size-3.5 mr-1" />}
|
||||||
|
{t('station.test')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
<Button size="sm" onClick={onSave} disabled={!isHTTPGen && !device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type Props = {
|
|||||||
onUpdateFromCty?: (ids: number[]) => void;
|
onUpdateFromCty?: (ids: number[]) => void;
|
||||||
onUpdateFromQRZ?: (ids: number[]) => void;
|
onUpdateFromQRZ?: (ids: number[]) => void;
|
||||||
onUpdateFromClublog?: (ids: number[]) => void;
|
onUpdateFromClublog?: (ids: number[]) => void;
|
||||||
|
onUpdateCountyFromULS?: (ids: number[]) => void;
|
||||||
onSendTo?: (service: string, ids: number[]) => void;
|
onSendTo?: (service: string, ids: number[]) => void;
|
||||||
onSendRecording?: (ids: number[]) => void;
|
onSendRecording?: (ids: number[]) => void;
|
||||||
onSendEQSL?: (ids: number[]) => void;
|
onSendEQSL?: (ids: number[]) => void;
|
||||||
@@ -57,7 +58,7 @@ function fmtDate(s: any): string {
|
|||||||
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleClicked, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onUpdateCountyFromULS, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportSelectedFields, onExportCabrilloSelected, onDelete, awardCols }: Props) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const gridRef = useRef<any>(null);
|
const gridRef = useRef<any>(null);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
@@ -272,6 +273,7 @@ export function WorkedBeforeGrid({ wb, myGrid, busy, currentCall, onRowDoubleCli
|
|||||||
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
onUpdateFromCty={(ids) => onUpdateFromCty?.(ids)}
|
||||||
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
onUpdateFromQRZ={(ids) => onUpdateFromQRZ?.(ids)}
|
||||||
onUpdateFromClublog={onUpdateFromClublog}
|
onUpdateFromClublog={onUpdateFromClublog}
|
||||||
|
onUpdateCountyFromULS={onUpdateCountyFromULS}
|
||||||
onSendTo={onSendTo}
|
onSendTo={onSendTo}
|
||||||
onSendRecording={onSendRecording}
|
onSendRecording={onSendRecording}
|
||||||
onSendEQSL={onSendEQSL}
|
onSendEQSL={onSendEQSL}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,87 @@
|
|||||||
|
import { GetRotatorHeading } from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
// One poll loop for the antenna heading, shared by everything that shows it.
|
||||||
|
//
|
||||||
|
// TWO PROBLEMS THIS FIXES, both of them invisible until you count.
|
||||||
|
//
|
||||||
|
// The status bar polled, and the Station Control compass polled, and they polled
|
||||||
|
// the SAME binding — so with that tab open the rotator was asked twice as often
|
||||||
|
// as either component believed. Every backend builds a fresh client per call
|
||||||
|
// (spid.New, gs232.NewSerial, dcu1.NewSerial, rotgenius.New…), so one poll is
|
||||||
|
// one OPEN and CLOSE of a serial port or a TCP connection, not a read on a link
|
||||||
|
// already up.
|
||||||
|
//
|
||||||
|
// And a fixed interval has no good value. Three seconds moved the needle in
|
||||||
|
// steps of about thirteen degrees on a turning antenna — a compass that jumps
|
||||||
|
// rather than sweeps. Seven hundred milliseconds sweeps beautifully and opens
|
||||||
|
// the controller's port about ten thousand times an hour to watch an antenna
|
||||||
|
// that has not moved since breakfast.
|
||||||
|
//
|
||||||
|
// So: fast while it is turning, slow while it is not. "Turning" is not something
|
||||||
|
// these controllers report — a SPID answers a position and nothing else — so it
|
||||||
|
// is inferred from the position changing, and held for a few seconds after the
|
||||||
|
// last change so the tail of a movement stays smooth.
|
||||||
|
|
||||||
|
export type RotorHeading = {
|
||||||
|
enabled: boolean; ok: boolean; azimuth: number;
|
||||||
|
rotors?: string[]; active?: number; motorized?: boolean; raw?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MOVING_MS = 500; // while the antenna is turning
|
||||||
|
const IDLE_MS = 3000; // while it is parked — the rate everything used before
|
||||||
|
const SETTLE_MS = 6000; // stay fast this long after the last movement
|
||||||
|
|
||||||
|
const subs = new Set<(h: RotorHeading) => void>();
|
||||||
|
let timer: number | undefined;
|
||||||
|
let inFlight = false;
|
||||||
|
let lastAz: number | null = null;
|
||||||
|
let lastMoveAt = 0;
|
||||||
|
|
||||||
|
function schedule(delay: number) {
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
timer = window.setTimeout(tick, delay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick() {
|
||||||
|
// A slow controller must not stack requests behind itself: at 600 baud a SPID
|
||||||
|
// reply takes a fifth of a second on the wire alone, and a port that is still
|
||||||
|
// open from the last poll cannot be opened again.
|
||||||
|
if (inFlight) { schedule(MOVING_MS); return; }
|
||||||
|
inFlight = true;
|
||||||
|
try {
|
||||||
|
const h = (await GetRotatorHeading()) as unknown as RotorHeading;
|
||||||
|
if (h?.ok && typeof h.azimuth === 'number') {
|
||||||
|
if (lastAz !== null && h.azimuth !== lastAz) lastMoveAt = Date.now();
|
||||||
|
lastAz = h.azimuth;
|
||||||
|
}
|
||||||
|
subs.forEach((fn) => { try { fn(h); } catch { /* a subscriber must not stop the loop */ } });
|
||||||
|
} catch {
|
||||||
|
// Leave the last heading alone: a single failed poll on a shared serial port
|
||||||
|
// is not news, and blanking the compass on one would make it flicker.
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
}
|
||||||
|
if (subs.size > 0) schedule(Date.now() - lastMoveAt < SETTLE_MS ? MOVING_MS : IDLE_MS);
|
||||||
|
else timer = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// subscribeRotorHeading starts the loop if it is not running and returns the
|
||||||
|
// unsubscribe. The loop stops when the last subscriber leaves.
|
||||||
|
export function subscribeRotorHeading(fn: (h: RotorHeading) => void): () => void {
|
||||||
|
subs.add(fn);
|
||||||
|
if (timer === undefined && !inFlight) void tick();
|
||||||
|
return () => {
|
||||||
|
subs.delete(fn);
|
||||||
|
if (subs.size === 0 && timer !== undefined) { window.clearTimeout(timer); timer = undefined; }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// pokeRotorHeading polls at once and switches to the fast rate — call it after
|
||||||
|
// commanding a move, so the needle starts sweeping on the click rather than on
|
||||||
|
// whatever was left of a three-second tick.
|
||||||
|
export function pokeRotorHeading(): void {
|
||||||
|
lastMoveAt = Date.now();
|
||||||
|
if (timer !== undefined) window.clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
void tick();
|
||||||
|
}
|
||||||
@@ -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.25.4';
|
export const APP_VERSION = '0.25.6';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+13
@@ -15,6 +15,7 @@ import {cluster} from '../models';
|
|||||||
import {extsvc} from '../models';
|
import {extsvc} from '../models';
|
||||||
import {powergenius} from '../models';
|
import {powergenius} from '../models';
|
||||||
import {pskr} from '../models';
|
import {pskr} from '../models';
|
||||||
|
import {psu} from '../models';
|
||||||
import {spe} from '../models';
|
import {spe} from '../models';
|
||||||
import {solar} from '../models';
|
import {solar} from '../models';
|
||||||
import {tunergenius} from '../models';
|
import {tunergenius} from '../models';
|
||||||
@@ -196,6 +197,8 @@ export function DownloadULSCounties():Promise<void>;
|
|||||||
|
|
||||||
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
export function DuplicateProfile(arg1:number,arg2:string):Promise<profile.Profile>;
|
||||||
|
|
||||||
|
export function EntryBandChanged(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
export function ExplainAward(arg1:string,arg2:string):Promise<Array<main.AwardExplain>>;
|
||||||
|
|
||||||
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
|
export function ExportADIF(arg1:string,arg2:boolean,arg3:Array<string>):Promise<adif.ExportResult>;
|
||||||
@@ -486,6 +489,10 @@ export function GetPOTAToken():Promise<string>;
|
|||||||
|
|
||||||
export function GetPSKReporterStatus():Promise<pskr.Status>;
|
export function GetPSKReporterStatus():Promise<pskr.Status>;
|
||||||
|
|
||||||
|
export function GetPSUSettings():Promise<main.PSUSettings>;
|
||||||
|
|
||||||
|
export function GetPSUStatus():Promise<psu.Status>;
|
||||||
|
|
||||||
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
export function GetPendingQSOs():Promise<Array<qso.QSO>>;
|
||||||
|
|
||||||
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
export function GetQSLDefaults():Promise<main.QSLDefaults>;
|
||||||
@@ -942,6 +949,8 @@ export function SavePGXLSettings(arg1:main.PGXLSettings):Promise<void>;
|
|||||||
|
|
||||||
export function SavePOTAToken(arg1:string):Promise<void>;
|
export function SavePOTAToken(arg1:string):Promise<void>;
|
||||||
|
|
||||||
|
export function SavePSUSettings(arg1:main.PSUSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
export function SaveProfile(arg1:profile.Profile):Promise<profile.Profile>;
|
||||||
|
|
||||||
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||||
@@ -1022,6 +1031,8 @@ export function SetMotorFollow(arg1:boolean,arg2:number,arg3:string):Promise<voi
|
|||||||
|
|
||||||
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
export function SetOpsLogQSLReceived(arg1:number,arg2:boolean):Promise<void>;
|
||||||
|
|
||||||
|
export function SetPSUOutput(arg1:boolean):Promise<void>;
|
||||||
|
|
||||||
export function SetPassphrase(arg1:string):Promise<void>;
|
export function SetPassphrase(arg1:string):Promise<void>;
|
||||||
|
|
||||||
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
export function SetScpEnabled(arg1:boolean):Promise<void>;
|
||||||
@@ -1138,6 +1149,8 @@ export function UpdateAwardReferenceList(arg1:string):Promise<main.AwardRefMeta>
|
|||||||
|
|
||||||
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
export function UpdateQSO(arg1:qso.QSO):Promise<void>;
|
||||||
|
|
||||||
|
export function UpdateQSOsCountyFromULS(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
|
export function UpdateQSOsFromClublog(arg1:Array<number>):Promise<number>;
|
||||||
|
|
||||||
export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
|
export function UpdateQSOsFromCty(arg1:Array<number>):Promise<number>;
|
||||||
|
|||||||
@@ -334,6 +334,10 @@ export function DuplicateProfile(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
return window['go']['main']['App']['DuplicateProfile'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function EntryBandChanged(arg1) {
|
||||||
|
return window['go']['main']['App']['EntryBandChanged'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ExplainAward(arg1, arg2) {
|
export function ExplainAward(arg1, arg2) {
|
||||||
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
return window['go']['main']['App']['ExplainAward'](arg1, arg2);
|
||||||
}
|
}
|
||||||
@@ -914,6 +918,14 @@ export function GetPSKReporterStatus() {
|
|||||||
return window['go']['main']['App']['GetPSKReporterStatus']();
|
return window['go']['main']['App']['GetPSKReporterStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetPSUSettings() {
|
||||||
|
return window['go']['main']['App']['GetPSUSettings']();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GetPSUStatus() {
|
||||||
|
return window['go']['main']['App']['GetPSUStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetPendingQSOs() {
|
export function GetPendingQSOs() {
|
||||||
return window['go']['main']['App']['GetPendingQSOs']();
|
return window['go']['main']['App']['GetPendingQSOs']();
|
||||||
}
|
}
|
||||||
@@ -1826,6 +1838,10 @@ export function SavePOTAToken(arg1) {
|
|||||||
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
return window['go']['main']['App']['SavePOTAToken'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SavePSUSettings(arg1) {
|
||||||
|
return window['go']['main']['App']['SavePSUSettings'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveProfile(arg1) {
|
export function SaveProfile(arg1) {
|
||||||
return window['go']['main']['App']['SaveProfile'](arg1);
|
return window['go']['main']['App']['SaveProfile'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1986,6 +2002,10 @@ export function SetOpsLogQSLReceived(arg1, arg2) {
|
|||||||
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
|
return window['go']['main']['App']['SetOpsLogQSLReceived'](arg1, arg2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SetPSUOutput(arg1) {
|
||||||
|
return window['go']['main']['App']['SetPSUOutput'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SetPassphrase(arg1) {
|
export function SetPassphrase(arg1) {
|
||||||
return window['go']['main']['App']['SetPassphrase'](arg1);
|
return window['go']['main']['App']['SetPassphrase'](arg1);
|
||||||
}
|
}
|
||||||
@@ -2218,6 +2238,10 @@ export function UpdateQSO(arg1) {
|
|||||||
return window['go']['main']['App']['UpdateQSO'](arg1);
|
return window['go']['main']['App']['UpdateQSO'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function UpdateQSOsCountyFromULS(arg1) {
|
||||||
|
return window['go']['main']['App']['UpdateQSOsCountyFromULS'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function UpdateQSOsFromClublog(arg1) {
|
export function UpdateQSOsFromClublog(arg1) {
|
||||||
return window['go']['main']['App']['UpdateQSOsFromClublog'](arg1);
|
return window['go']['main']['App']['UpdateQSOsFromClublog'](arg1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2733,6 +2733,24 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class PSUSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
com_port: string;
|
||||||
|
baud: number;
|
||||||
|
address: number;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new PSUSettings(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.enabled = source["enabled"];
|
||||||
|
this.com_port = source["com_port"];
|
||||||
|
this.baud = source["baud"];
|
||||||
|
this.address = source["address"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class QSLBulkUpdate {
|
export class QSLBulkUpdate {
|
||||||
sent_status: string;
|
sent_status: string;
|
||||||
rcvd_status: string;
|
rcvd_status: string;
|
||||||
@@ -4063,6 +4081,39 @@ export namespace pskr {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export namespace psu {
|
||||||
|
|
||||||
|
export class Status {
|
||||||
|
connected: boolean;
|
||||||
|
on: boolean;
|
||||||
|
volts: number;
|
||||||
|
amps: number;
|
||||||
|
watts: number;
|
||||||
|
set_volts: number;
|
||||||
|
set_amps: number;
|
||||||
|
protected: number;
|
||||||
|
error?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new Status(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.on = source["on"];
|
||||||
|
this.volts = source["volts"];
|
||||||
|
this.amps = source["amps"];
|
||||||
|
this.watts = source["watts"];
|
||||||
|
this.set_volts = source["set_volts"];
|
||||||
|
this.set_amps = source["set_amps"];
|
||||||
|
this.protected = source["protected"];
|
||||||
|
this.error = source["error"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
export namespace qslcard {
|
export namespace qslcard {
|
||||||
|
|
||||||
export class Bevel {
|
export class Bevel {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package adif
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// An import must never INVENT a routing method. The two "via" fields carry what
|
||||||
|
// the file carries, and nothing at all when it carries nothing.
|
||||||
|
//
|
||||||
|
// This is worth pinning because the opposite is easy to reach for: a card that
|
||||||
|
// was confirmed electronically is the common case, and defaulting to E would
|
||||||
|
// quietly rewrite the operator's own record of how their cards actually
|
||||||
|
// travelled. Where an E does show up after an import, it came from the source
|
||||||
|
// file — Log4OM writes QSL_SENT_VIA:1>E on every record whether or not a card
|
||||||
|
// was ever sent, which is pinned by TestQSLSentViaDoesNotBecomeManager.
|
||||||
|
func TestImportInventsNoQSLRouting(t *testing.T) {
|
||||||
|
for name, rec := range map[string]string{
|
||||||
|
"absent": "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW<EOR>\n",
|
||||||
|
"empty": "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW<QSL_SENT_VIA:0><QSL_RCVD_VIA:0><EOR>\n",
|
||||||
|
"whitespace": "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW<QSL_SENT_VIA:1> <QSL_RCVD_VIA:1> <EOR>\n",
|
||||||
|
// Not in the enumeration: dropped rather than passed through, or the
|
||||||
|
// export would write a value no other logger can read.
|
||||||
|
"unknown": "<CALL:5>OE6CLD<QSO_DATE:8>20260606<TIME_ON:4>1200<BAND:3>20m<MODE:2>CW<QSL_SENT_VIA:3>ZZZ<EOR>\n",
|
||||||
|
} {
|
||||||
|
var got Record
|
||||||
|
if err := Parse(strings.NewReader("<EOH>\n"+rec), func(r Record) error { got = r; return nil }); err != nil {
|
||||||
|
t.Fatalf("%s: parse: %v", name, err)
|
||||||
|
}
|
||||||
|
q, ok := recordToQSO(got)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("%s: recordToQSO returned !ok", name)
|
||||||
|
}
|
||||||
|
if q.QSLSentVia != "" {
|
||||||
|
t.Errorf("%s: QSL_SENT_VIA = %q, want empty — the import made up a routing method", name, q.QSLSentVia)
|
||||||
|
}
|
||||||
|
if q.QSLRcvdVia != "" {
|
||||||
|
t.Errorf("%s: QSL_RCVD_VIA = %q, want empty — the import made up a routing method", name, q.QSLRcvdVia)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -369,10 +369,21 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
// failure surfaces as an error on Read — which is the only thing that ends
|
// failure surfaces as an error on Read — which is the only thing that ends
|
||||||
// a session below.
|
// a session below.
|
||||||
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
||||||
|
// TIMED, and reported. An operator sees "connected" and no spots for a
|
||||||
|
// minute on the first launch, then an instant connection when the program is
|
||||||
|
// restarted — which is the signature of a slow name resolution rather than a
|
||||||
|
// slow cluster (the OS caches the answer, so the second run skips it). The
|
||||||
|
// only way to tell that from a node that simply had nothing to say is to
|
||||||
|
// know how long the dial itself took.
|
||||||
|
dialStart := time.Now()
|
||||||
conn, err := d.Dial("tcp", addr)
|
conn, err := d.Dial("tcp", addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
applog.Printf("cluster[%s] dial %s failed after %s: %v", s.cfg.Name, addr, time.Since(dialStart).Round(time.Millisecond), err)
|
||||||
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
||||||
}
|
}
|
||||||
|
applog.Printf("cluster[%s] connected to %s in %s", s.cfg.Name, addr, time.Since(dialStart).Round(time.Millisecond))
|
||||||
|
linkUpAt := time.Now()
|
||||||
|
firstSpotLogged := false
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
s.conn = conn
|
s.conn = conn
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
@@ -556,6 +567,13 @@ func (s *session) runOnce() (time.Time, error) {
|
|||||||
}
|
}
|
||||||
s.mu.Unlock()
|
s.mu.Unlock()
|
||||||
if s.onSpot != nil {
|
if s.onSpot != nil {
|
||||||
|
if !firstSpotLogged {
|
||||||
|
firstSpotLogged = true
|
||||||
|
// The gap between the socket opening and the first spot is the
|
||||||
|
// other half of the answer: a long dial is the network, a quick
|
||||||
|
// dial and a long silence is the node (or the login) instead.
|
||||||
|
applog.Printf("cluster[%s] first spot %s after connecting", s.cfg.Name, time.Since(linkUpAt).Round(time.Millisecond))
|
||||||
|
}
|
||||||
s.onSpot(spot)
|
s.onSpot(spot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// A remote-call <FREQ> arrives in whatever unit the sender happens to use, and
|
||||||
|
// the same sender uses more than one. Every form has to land on the same dial
|
||||||
|
// frequency, because the alternative is a rig sent to the wrong band.
|
||||||
|
func TestRemoteTuneUnits(t *testing.T) {
|
||||||
|
const m20 = 14_074_000
|
||||||
|
for _, c := range []struct {
|
||||||
|
in string
|
||||||
|
want int64
|
||||||
|
why string
|
||||||
|
}{
|
||||||
|
{"14.074", m20, "MHz, the documented DXHunter form"},
|
||||||
|
{"10.136", 10_136_000, "MHz, 30 m"},
|
||||||
|
{"14074", m20, "kHz"},
|
||||||
|
{"14074.0", m20, "kHz with a decimal point"},
|
||||||
|
{"1407400", m20, "tens of Hz — what N1MM RadioInfo publishes, echoed back"},
|
||||||
|
{"2107400", 21_074_000, "the value from the field log that failed every time"},
|
||||||
|
{"14074000", m20, "Hz"},
|
||||||
|
{"7000000", 7_000_000, "Hz on 40 m, not tens of Hz on 4 m"},
|
||||||
|
{"0", 0, "no frequency"},
|
||||||
|
{"999999999999", 0, "nothing plausible — tune nothing rather than guess"},
|
||||||
|
{"abc", 0, "not a number"},
|
||||||
|
} {
|
||||||
|
if got := remoteTuneHz(c.in); got != c.want {
|
||||||
|
t.Errorf("remoteTuneHz(%q) = %d, want %d (%s)", c.in, got, c.want, c.why)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package udp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// feed runs one datagram through a remote-call listener and returns the event
|
||||||
|
// it produced, or nil.
|
||||||
|
func feed(t *testing.T, pkt []byte) *Event {
|
||||||
|
t.Helper()
|
||||||
|
out := make(chan Event, 4)
|
||||||
|
s := &Server{
|
||||||
|
cfg: Config{ID: 1, Name: "DX HUNTER", ServiceType: ServiceRemoteCall, Port: 2241},
|
||||||
|
out: out,
|
||||||
|
}
|
||||||
|
s.handle(pkt, &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2241})
|
||||||
|
select {
|
||||||
|
case ev := <-out:
|
||||||
|
return &ev
|
||||||
|
case <-time.After(200 * time.Millisecond):
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A RadioInfo datagram must never be read as a remote-call request.
|
||||||
|
//
|
||||||
|
// This is the loop from a reported session. An inbound remote-call row and an
|
||||||
|
// outbound RadioInfo row shared port 2241, so every datagram OpsLog sent came
|
||||||
|
// straight back on the loopback. The tag-stripping heuristic read its last
|
||||||
|
// token — <ActiveRadioNr>1</ActiveRadioNr> — as the callsign "1", and <Freq> as
|
||||||
|
// a tune request for the frequency the rig was already on.
|
||||||
|
//
|
||||||
|
// It stayed harmless only while <Freq> was misread as MHz: the tune failed "out
|
||||||
|
// of the CAT range" and the loop died there. Reading the unit correctly closed
|
||||||
|
// it, and with JTDX "Fake It" — which shifts the dial for every over — each
|
||||||
|
// transmission set off a burst of sets echoing between OpsLog and itself until
|
||||||
|
// the rig stopped answering IF; and the shared CAT link dropped.
|
||||||
|
func TestRadioInfoIsNotARemoteCall(t *testing.T) {
|
||||||
|
pkt := BuildN1MMRadioInfo("F5PHW", 24_915_000, 24_915_000, "FT8", "F5PHW")
|
||||||
|
|
||||||
|
// The trap this closes: the payload really does parse as a plausible dial
|
||||||
|
// frequency, so nothing downstream would have questioned it.
|
||||||
|
m := remoteFreqRe.FindStringSubmatch(string(pkt))
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("the RadioInfo no longer carries a <Freq> — this test is checking nothing")
|
||||||
|
}
|
||||||
|
if hz := remoteTuneHz(m[1]); hz != 24_915_000 {
|
||||||
|
t.Fatalf("remoteTuneHz(%q) = %d — the dial frequency back is what made the loop live", m[1], hz)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ev := feed(t, pkt); ev != nil {
|
||||||
|
t.Errorf("a RadioInfo produced a remote-call event %+v — the rig would be re-tuned to where it already is", *ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A callsign has a letter in it. Refusing by shape as well as by name means the
|
||||||
|
// next program to broadcast its state on this port cannot drive the rig either.
|
||||||
|
func TestRemoteCallNeedsALetter(t *testing.T) {
|
||||||
|
for _, body := range []string{"<CALLSIGN>1</CALLSIGN>", "<CALLSIGN>0</CALLSIGN>", "12345"} {
|
||||||
|
if ev := feed(t, []byte(body)); ev != nil {
|
||||||
|
t.Errorf("%q was accepted as a callsign: %+v", body, *ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A genuine spot click still gets through, tune request and all.
|
||||||
|
ev := feed(t, []byte("<CALLSIGN>OJ0YL<FREQ>10.112<MODE>CW"))
|
||||||
|
if ev == nil {
|
||||||
|
t.Fatal("a genuine spot click produced no event")
|
||||||
|
}
|
||||||
|
if ev.DXCall != "OJ0YL" || ev.TuneFreqHz != 10_112_000 || ev.TuneMode != "CW" {
|
||||||
|
t.Errorf("spot click decoded as %+v, want OJ0YL / 10112000 Hz / CW", *ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,38 @@ import (
|
|||||||
"hamlog/internal/applog"
|
"hamlog/internal/applog"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// remoteTuneHz turns a <FREQ> value from a remote-call packet into Hz.
|
||||||
|
//
|
||||||
|
// The field has NO agreed unit, and the same sender uses two of them. DXHunter
|
||||||
|
// documents "<FREQ>10.136" — MHz — but a log from a working station showed it
|
||||||
|
// echoing "<FREQ>2107400" straight back: the frequency OpsLog had just
|
||||||
|
// published to it in the N1MM RadioInfo broadcast, whose <Freq> is in units of
|
||||||
|
// 10 Hz. Read as MHz, that asked the rig for 2 107 400 MHz, and every tune
|
||||||
|
// request failed with "out of the 11-digit CAT range" from the first second
|
||||||
|
// after launch.
|
||||||
|
//
|
||||||
|
// So the unit is inferred: try each one and keep the first that lands on an
|
||||||
|
// amateur band. Anything a station is asked to tune to is, by definition, in
|
||||||
|
// one. Hz is tried before 10 Hz because the one overlap between them — a 40 m
|
||||||
|
// frequency in Hz reads as a 4 m one in tens of Hz — is far more likely to be
|
||||||
|
// 40 m. Nothing plausible means nothing is tuned: a wrong band is worse than a
|
||||||
|
// request that visibly did nothing.
|
||||||
|
func remoteTuneHz(s string) int64 {
|
||||||
|
v, err := strconv.ParseFloat(s, 64)
|
||||||
|
if err != nil || v <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
for _, hz := range []int64{int64(v * 1e6), int64(v * 1e3), int64(v), int64(v * 10)} {
|
||||||
|
if bandFromHz(hz) != "" {
|
||||||
|
return hz
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Refusing in silence is how the previous version's failure looked from the
|
||||||
|
// outside: a spot clicked in another program, and nothing happening here.
|
||||||
|
applog.Printf("udp: remote_call <FREQ>%s is not a frequency in any amateur band in MHz, kHz or Hz — not tuning\n", s)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
// remoteFreqRe / remoteModeRe pull the optional tune request out of a
|
// remoteFreqRe / remoteModeRe pull the optional tune request out of a
|
||||||
// ServiceRemoteCall packet: "<FREQ>10.136" (MHz) and "<MODE>FT8". Both accept
|
// ServiceRemoteCall packet: "<FREQ>10.136" (MHz) and "<MODE>FT8". Both accept
|
||||||
// an optional closing tag for proper-XML senders.
|
// an optional closing tag for proper-XML senders.
|
||||||
@@ -391,15 +423,32 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
// Strip every angle-bracket tag, normalise whitespace, take the
|
// Strip every angle-bracket tag, normalise whitespace, take the
|
||||||
// last non-empty token. Upper-case for downstream consistency.
|
// last non-empty token. Upper-case for downstream consistency.
|
||||||
text := string(pkt)
|
text := string(pkt)
|
||||||
|
// NEVER act on an N1MM RadioInfo datagram.
|
||||||
|
//
|
||||||
|
// It is not a spot click, it is a radio TELLING the world where it is —
|
||||||
|
// and on a station where an inbound remote-call row and an outbound
|
||||||
|
// RadioInfo row share a port, the one OpsLog just sent arrives straight
|
||||||
|
// back on the loopback. The tag-stripping below then reads its last token,
|
||||||
|
// <ActiveRadioNr>1</ActiveRadioNr>, as the callsign "1", and <Freq> as a
|
||||||
|
// tune request — for the frequency the rig is already on.
|
||||||
|
//
|
||||||
|
// That was harmless only for as long as <Freq> was misread: in tens of Hz
|
||||||
|
// it looks like a wild number, every tune failed "out of the CAT range",
|
||||||
|
// and the loop died there. Reading the unit correctly closed it. With
|
||||||
|
// WSJT-X/JTDX "Fake It", which shifts the dial for each over, every
|
||||||
|
// transmission then produced a burst of sets echoing between OpsLog and
|
||||||
|
// itself until the rig stopped answering IF; and the shared CAT link
|
||||||
|
// dropped. Reported as Fake It causing CAT disconnections.
|
||||||
|
if low := strings.ToLower(text); strings.Contains(low, "<radioinfo") {
|
||||||
|
return
|
||||||
|
}
|
||||||
// Optional tune request: <FREQ>MHz and <MODE>str ride along with the
|
// Optional tune request: <FREQ>MHz and <MODE>str ride along with the
|
||||||
// callsign so a DXHunter spot click can drive OpsLog's CAT. Extract
|
// callsign so a DXHunter spot click can drive OpsLog's CAT. Extract
|
||||||
// (and cut) them BEFORE the generic tag-stripping below, which would
|
// (and cut) them BEFORE the generic tag-stripping below, which would
|
||||||
// otherwise leave their values as stray tokens and corrupt the
|
// otherwise leave their values as stray tokens and corrupt the
|
||||||
// "last token = callsign" heuristic.
|
// "last token = callsign" heuristic.
|
||||||
if m := remoteFreqRe.FindStringSubmatch(text); m != nil {
|
if m := remoteFreqRe.FindStringSubmatch(text); m != nil {
|
||||||
if mhz, err := strconv.ParseFloat(m[1], 64); err == nil && mhz > 0 {
|
ev.TuneFreqHz = remoteTuneHz(m[1])
|
||||||
ev.TuneFreqHz = int64(mhz * 1e6)
|
|
||||||
}
|
|
||||||
text = strings.Replace(text, m[0], " ", 1)
|
text = strings.Replace(text, m[0], " ", 1)
|
||||||
}
|
}
|
||||||
if m := remoteModeRe.FindStringSubmatch(text); m != nil {
|
if m := remoteModeRe.FindStringSubmatch(text); m != nil {
|
||||||
@@ -424,7 +473,16 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
if len(parts) == 0 {
|
if len(parts) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ev.DXCall = strings.ToUpper(parts[len(parts)-1])
|
call := strings.ToUpper(parts[len(parts)-1])
|
||||||
|
// A callsign has a letter in it. Without this, any status XML that ends
|
||||||
|
// in a number is read as a station — the RadioInfo above was exactly
|
||||||
|
// that, and refusing it by shape as well as by name means the next
|
||||||
|
// program to broadcast its state on this port cannot drive the rig
|
||||||
|
// either.
|
||||||
|
if !strings.ContainsAny(call, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ev.DXCall = call
|
||||||
case ServiceN1MM:
|
case ServiceN1MM:
|
||||||
adifText, ok, err := ParseN1MM(pkt)
|
adifText, ok, err := ParseN1MM(pkt)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -568,10 +626,36 @@ func (m *Manager) Reload(ctx context.Context) []string {
|
|||||||
m.inbound[c.ID] = srv
|
m.inbound[c.ID] = srv
|
||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
}
|
}
|
||||||
|
warnSharedPorts(cfgs)
|
||||||
applog.Printf("udp: Reload done — %d server(s) running, %d error(s)", len(m.inbound), len(errs))
|
applog.Printf("udp: Reload done — %d server(s) running, %d error(s)", len(m.inbound), len(errs))
|
||||||
return errs
|
return errs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// warnSharedPorts names an inbound and an outbound row sitting on the same
|
||||||
|
// port, because that is a loop: what OpsLog sends there, OpsLog receives.
|
||||||
|
//
|
||||||
|
// It is how a station ended up re-tuning its own rig from its own RadioInfo
|
||||||
|
// broadcasts. The parser refuses that particular payload now, but the
|
||||||
|
// arrangement stays wrong for anything else that lands on the port, and it is
|
||||||
|
// invisible in a settings panel that shows one row at a time.
|
||||||
|
func warnSharedPorts(cfgs []Config) {
|
||||||
|
in := map[int]string{}
|
||||||
|
for _, c := range cfgs {
|
||||||
|
if c.Enabled && c.Direction != Outbound {
|
||||||
|
in[c.Port] = c.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, c := range cfgs {
|
||||||
|
if !c.Enabled || c.Direction != Outbound {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if name, ok := in[c.Port]; ok {
|
||||||
|
applog.Printf("udp: %q sends on port %d and %q listens on it — OpsLog will receive its own messages there; give one of the two another port",
|
||||||
|
c.Name, c.Port, name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Outbound returns the active outbound configs matching a service type.
|
// Outbound returns the active outbound configs matching a service type.
|
||||||
// Used by the QSO save path to push notifications to listeners.
|
// Used by the QSO save path to push notifications to listeners.
|
||||||
func (m *Manager) Outbound(service ServiceType) []Config {
|
func (m *Manager) Outbound(service ServiceType) []Config {
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
// Package psu drives a bench power supply over Modbus RTU — the BSIDE / Wanptek
|
||||||
|
// family of programmable supplies that sit in a shack feeding the radios.
|
||||||
|
//
|
||||||
|
// The register map and the wire settings come from the manufacturer's own
|
||||||
|
// document ("This machine only support function code 03,06", version 20180611):
|
||||||
|
//
|
||||||
|
// 9600 baud, 8 data bits, no parity, 1 stop bit
|
||||||
|
// function 03 read holding registers
|
||||||
|
// function 06 write single register
|
||||||
|
// slave address 1…15, address 0 broadcast
|
||||||
|
//
|
||||||
|
// NOTHING ELSE IS WRITTEN. The map also carries the output voltage and current
|
||||||
|
// SET points, and the over-voltage, over-current and over-power trip levels, all
|
||||||
|
// read/write. This driver reads them and writes exactly one register: 0x0001,
|
||||||
|
// the output on/off. A wrong value in any of the others is not a wrong reading —
|
||||||
|
// it is 30 V where a radio expected 13.8, or a protection trip lifted on a
|
||||||
|
// supply feeding an amplifier. There is no reason for a logbook to set them, so
|
||||||
|
// it cannot.
|
||||||
|
package psu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registers, from the manufacturer's table. Addresses are as printed there.
|
||||||
|
const (
|
||||||
|
regOnOff = 0x0001 // output on/off — 1 or 0. The ONLY register written.
|
||||||
|
regProtect = 0x0002 // protection status word
|
||||||
|
regModel = 0x0003 // specification model
|
||||||
|
regDecimals = 0x0005 // "V_A_W number of digits" — see readDecimals
|
||||||
|
regVolts = 0x0010 // measured output voltage, 2 decimals
|
||||||
|
regAmps = 0x0011 // measured output current, 3 decimals
|
||||||
|
regWatts = 0x0012 // measured output power, 32-bit across 0x0012/0x0013, 3 decimals
|
||||||
|
regSetVolts = 0x0030 // voltage set point, 2 decimals
|
||||||
|
regSetAmps = 0x0031 // current set point, 3 decimals
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fnRead = 0x03
|
||||||
|
fnWrite = 0x06
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fixed scaling from the manufacturer's "Decimal place" column. The supply also
|
||||||
|
// reports its own digit counts in 0x0005, but the document's "Note 2" that
|
||||||
|
// explains how to decode that word is not in the manual we have — so the
|
||||||
|
// documented per-register values are used, and the raw word is logged once at
|
||||||
|
// connect. If an operator ever reports readings out by a factor of ten, that
|
||||||
|
// line is what says how to decode it properly.
|
||||||
|
const (
|
||||||
|
voltScale = 100.0 // 2 decimals
|
||||||
|
ampScale = 1000.0 // 3 decimals
|
||||||
|
wattScale = 1000.0 // 3 decimals
|
||||||
|
)
|
||||||
|
|
||||||
|
// crc16 is the Modbus RTU frame check: CRC-16/MODBUS — reflected, polynomial
|
||||||
|
// 0xA001, initial value 0xFFFF, no final xor. Transmitted low byte first.
|
||||||
|
func crc16(b []byte) uint16 {
|
||||||
|
crc := uint16(0xFFFF)
|
||||||
|
for _, c := range b {
|
||||||
|
crc ^= uint16(c)
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
if crc&1 != 0 {
|
||||||
|
crc = (crc >> 1) ^ 0xA001
|
||||||
|
} else {
|
||||||
|
crc >>= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return crc
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendCRC closes a frame: low byte first, as the manual states.
|
||||||
|
func appendCRC(f []byte) []byte {
|
||||||
|
c := crc16(f)
|
||||||
|
return append(f, byte(c&0xFF), byte(c>>8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRead frames a function 03 "read holding registers".
|
||||||
|
func buildRead(addr byte, reg uint16, count uint16) []byte {
|
||||||
|
f := []byte{addr, fnRead, byte(reg >> 8), byte(reg), byte(count >> 8), byte(count)}
|
||||||
|
return appendCRC(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildWrite frames a function 06 "write single register".
|
||||||
|
func buildWrite(addr byte, reg, val uint16) []byte {
|
||||||
|
f := []byte{addr, fnWrite, byte(reg >> 8), byte(reg), byte(val >> 8), byte(val)}
|
||||||
|
return appendCRC(f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// modbusError is an exception response — the supply understood the frame and
|
||||||
|
// refused it. Kept distinct from a transport failure: one means "ask
|
||||||
|
// differently", the other means "the cable".
|
||||||
|
type modbusError struct {
|
||||||
|
fn byte
|
||||||
|
code byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e modbusError) Error() string {
|
||||||
|
what := map[byte]string{
|
||||||
|
1: "illegal function",
|
||||||
|
2: "illegal data address",
|
||||||
|
3: "illegal data value",
|
||||||
|
4: "slave device failure",
|
||||||
|
6: "device busy",
|
||||||
|
}[e.code]
|
||||||
|
if what == "" {
|
||||||
|
what = fmt.Sprintf("exception %d", e.code)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("supply refused function 0x%02X: %s", e.fn, what)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRead validates a function 03 reply and returns the register values.
|
||||||
|
func parseRead(addr byte, want uint16, frame []byte) ([]uint16, error) {
|
||||||
|
if err := checkFrame(addr, fnRead, frame, 5); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
n := int(frame[2])
|
||||||
|
if n != int(want)*2 {
|
||||||
|
return nil, fmt.Errorf("reply carries %d data byte(s), expected %d", n, want*2)
|
||||||
|
}
|
||||||
|
if len(frame) != 3+n+2 {
|
||||||
|
return nil, fmt.Errorf("reply is %d bytes, expected %d", len(frame), 3+n+2)
|
||||||
|
}
|
||||||
|
out := make([]uint16, want)
|
||||||
|
for i := range out {
|
||||||
|
out[i] = binary.BigEndian.Uint16(frame[3+i*2:])
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseWriteEcho validates a function 06 reply, which echoes the request.
|
||||||
|
func parseWriteEcho(addr byte, reg, val uint16, frame []byte) error {
|
||||||
|
if err := checkFrame(addr, fnWrite, frame, 8); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(frame) != 8 {
|
||||||
|
return fmt.Errorf("write reply is %d bytes, expected 8", len(frame))
|
||||||
|
}
|
||||||
|
if got := binary.BigEndian.Uint16(frame[2:]); got != reg {
|
||||||
|
return fmt.Errorf("write reply is for register 0x%04X, not 0x%04X", got, reg)
|
||||||
|
}
|
||||||
|
// The echoed VALUE is the confirmation that the output actually changed.
|
||||||
|
// Accepting the frame without checking it would report an on/off that the
|
||||||
|
// supply never made.
|
||||||
|
if got := binary.BigEndian.Uint16(frame[4:]); got != val {
|
||||||
|
return fmt.Errorf("supply echoed value %d, not the %d it was sent", got, val)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkFrame covers what every reply must satisfy: our address, our function
|
||||||
|
// (or its exception), and a good CRC.
|
||||||
|
func checkFrame(addr, fn byte, frame []byte, min int) error {
|
||||||
|
if len(frame) < 4 {
|
||||||
|
return fmt.Errorf("short reply (%d bytes)", len(frame))
|
||||||
|
}
|
||||||
|
if frame[0] != addr {
|
||||||
|
return fmt.Errorf("reply from address %d, expected %d", frame[0], addr)
|
||||||
|
}
|
||||||
|
if frame[1] == fn|0x80 {
|
||||||
|
if len(frame) < 5 {
|
||||||
|
return fmt.Errorf("short exception reply (%d bytes)", len(frame))
|
||||||
|
}
|
||||||
|
if !crcOK(frame[:5]) {
|
||||||
|
return fmt.Errorf("exception reply failed its CRC")
|
||||||
|
}
|
||||||
|
return modbusError{fn: fn, code: frame[2]}
|
||||||
|
}
|
||||||
|
if frame[1] != fn {
|
||||||
|
return fmt.Errorf("reply to function 0x%02X, expected 0x%02X", frame[1], fn)
|
||||||
|
}
|
||||||
|
if len(frame) < min {
|
||||||
|
return fmt.Errorf("short reply (%d bytes, expected at least %d)", len(frame), min)
|
||||||
|
}
|
||||||
|
if !crcOK(frame) {
|
||||||
|
return fmt.Errorf("reply failed its CRC")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// crcOK checks a whole frame, trailing CRC included: the CRC of the entire
|
||||||
|
// frame is zero when it is intact.
|
||||||
|
func crcOK(frame []byte) bool {
|
||||||
|
if len(frame) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body := frame[:len(frame)-2]
|
||||||
|
want := uint16(frame[len(frame)-2]) | uint16(frame[len(frame)-1])<<8
|
||||||
|
return crc16(body) == want
|
||||||
|
}
|
||||||
|
|
||||||
|
// frameGap is the silence that separates two Modbus RTU frames: 3.5 character
|
||||||
|
// times, which at 9600 baud 8N1 (10 bits per character) is 3.65 ms. Rounded up,
|
||||||
|
// because the cost of waiting is nothing and the cost of being early is a
|
||||||
|
// supply that treats our request as the tail of the previous one.
|
||||||
|
const frameGap = 4 * time.Millisecond
|
||||||
|
|
||||||
|
// replyWait is how long a reply may take. The manual promises under 5 ms at
|
||||||
|
// 9600 baud or better; this is generous by two orders of magnitude so a USB
|
||||||
|
// serial bridge that buffers cannot be mistaken for a supply that is not there.
|
||||||
|
const replyWait = 500 * time.Millisecond
|
||||||
|
|
||||||
|
// readFrame collects a reply until it stops arriving.
|
||||||
|
//
|
||||||
|
// A serial read that times out returns (0, nil) on Windows — a timeout is not
|
||||||
|
// an error on this transport — so a loop that trusts an error to end it never
|
||||||
|
// ends. Modbus RTU has no terminator either: a frame is over when the line has
|
||||||
|
// been quiet for 3.5 character times. Both facts point at the same shape, a
|
||||||
|
// deadline and a quiet-time.
|
||||||
|
func readFrame(conn io.Reader, d time.Duration) ([]byte, error) {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
buf := make([]byte, 0, 64)
|
||||||
|
tmp := make([]byte, 64)
|
||||||
|
lastByte := time.Time{}
|
||||||
|
for {
|
||||||
|
n, err := conn.Read(tmp)
|
||||||
|
if err != nil {
|
||||||
|
return buf, err
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
lastByte = time.Now()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Nothing this time: either the frame has ended, or it never started.
|
||||||
|
if len(buf) > 0 && time.Since(lastByte) >= frameGap {
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
if len(buf) > 0 {
|
||||||
|
return buf, nil // partial — let the parser say what is wrong with it
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("no reply after %s", d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package psu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The CRC is the one thing here that cannot be checked by inspection, and every
|
||||||
|
// frame depends on it. CRC-16/MODBUS has a published check value: the CRC of
|
||||||
|
// the ASCII digits "123456789" is 0x4B37. If this passes, the polynomial, the
|
||||||
|
// initial value, the reflection and the absence of a final xor are all right.
|
||||||
|
func TestCRCMatchesTheStandardCheckValue(t *testing.T) {
|
||||||
|
if got := crc16([]byte("123456789")); got != 0x4B37 {
|
||||||
|
t.Fatalf("crc16(\"123456789\") = 0x%04X, want 0x4B37 — this is not CRC-16/MODBUS", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A frame including its own CRC checks to zero. That property is what crcOK
|
||||||
|
// relies on, so it is worth pinning separately from the check value.
|
||||||
|
func TestAFrameVerifiesItself(t *testing.T) {
|
||||||
|
for _, f := range [][]byte{
|
||||||
|
buildRead(1, regVolts, 2),
|
||||||
|
buildWrite(1, regOnOff, 1),
|
||||||
|
buildWrite(15, regOnOff, 0),
|
||||||
|
} {
|
||||||
|
if !crcOK(f) {
|
||||||
|
t.Errorf("% X does not verify against its own CRC", f)
|
||||||
|
}
|
||||||
|
// And a single flipped bit must be caught.
|
||||||
|
bad := append([]byte(nil), f...)
|
||||||
|
bad[2] ^= 0x01
|
||||||
|
if crcOK(bad) {
|
||||||
|
t.Errorf("% X passed the CRC with a corrupted byte", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The frame layout, byte for byte against the manual: address, function,
|
||||||
|
// register high/low, count or value high/low, then CRC low byte first.
|
||||||
|
func TestFrameLayout(t *testing.T) {
|
||||||
|
r := buildRead(1, 0x0010, 2)
|
||||||
|
if len(r) != 8 {
|
||||||
|
t.Fatalf("read frame is %d bytes, want 8", len(r))
|
||||||
|
}
|
||||||
|
want := []byte{0x01, 0x03, 0x00, 0x10, 0x00, 0x02}
|
||||||
|
for i := range want {
|
||||||
|
if r[i] != want[i] {
|
||||||
|
t.Fatalf("read frame % X, want % X…", r, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w := buildWrite(1, regOnOff, 1)
|
||||||
|
want = []byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x01}
|
||||||
|
for i := range want {
|
||||||
|
if w[i] != want[i] {
|
||||||
|
t.Fatalf("write frame % X, want % X…", w, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The CRC goes out low byte first — the manual is explicit, and getting it
|
||||||
|
// backwards makes every frame be ignored in silence.
|
||||||
|
c := crc16(w[:6])
|
||||||
|
if w[6] != byte(c&0xFF) || w[7] != byte(c>>8) {
|
||||||
|
t.Errorf("CRC bytes % X, want %02X %02X (low first)", w[6:], byte(c&0xFF), byte(c>>8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRead(t *testing.T) {
|
||||||
|
// Two registers: 13.80 V (1380 at 2 decimals) and 2.500 A (2500 at 3).
|
||||||
|
frame := appendCRC([]byte{0x01, 0x03, 0x04, 0x05, 0x64, 0x09, 0xC4})
|
||||||
|
got, err := parseRead(1, 2, frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseRead: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != 2 || got[0] != 1380 || got[1] != 2500 {
|
||||||
|
t.Fatalf("got %v, want [1380 2500]", got)
|
||||||
|
}
|
||||||
|
if v := float64(got[0]) / voltScale; v != 13.80 {
|
||||||
|
t.Errorf("voltage scaled to %v, want 13.8", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A reply from another slave on the same bus must not be read as ours.
|
||||||
|
func TestParseRejectsAnotherSlave(t *testing.T) {
|
||||||
|
frame := appendCRC([]byte{0x02, 0x03, 0x02, 0x05, 0x64})
|
||||||
|
if _, err := parseRead(1, 1, frame); err == nil {
|
||||||
|
t.Error("a reply from address 2 was accepted as address 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseRejectsABadCRC(t *testing.T) {
|
||||||
|
frame := appendCRC([]byte{0x01, 0x03, 0x02, 0x05, 0x64})
|
||||||
|
frame[3] ^= 0xFF
|
||||||
|
if _, err := parseRead(1, 1, frame); err == nil || !strings.Contains(err.Error(), "CRC") {
|
||||||
|
t.Errorf("err = %v, want a CRC complaint", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An exception reply is the supply refusing, not the line failing, and the two
|
||||||
|
// need different answers from the operator.
|
||||||
|
func TestParseReportsAnException(t *testing.T) {
|
||||||
|
frame := appendCRC([]byte{0x01, 0x83, 0x02})
|
||||||
|
_, err := parseRead(1, 1, frame)
|
||||||
|
var me modbusError
|
||||||
|
if !errors.As(err, &me) {
|
||||||
|
t.Fatalf("err = %v, want a modbusError", err)
|
||||||
|
}
|
||||||
|
if me.code != 2 || !strings.Contains(err.Error(), "illegal data address") {
|
||||||
|
t.Errorf("exception decoded as %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The echo is the ONLY confirmation that the output actually switched. A reply
|
||||||
|
// echoing a different value means the supply did something else, and reporting
|
||||||
|
// that as success is how a radio ends up with no power and a green light.
|
||||||
|
func TestWriteEchoMustMatch(t *testing.T) {
|
||||||
|
ok := appendCRC([]byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x01})
|
||||||
|
if err := parseWriteEcho(1, regOnOff, 1, ok); err != nil {
|
||||||
|
t.Fatalf("a correct echo was rejected: %v", err)
|
||||||
|
}
|
||||||
|
wrongVal := appendCRC([]byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x00})
|
||||||
|
if err := parseWriteEcho(1, regOnOff, 1, wrongVal); err == nil {
|
||||||
|
t.Error("an echo of 0 was accepted for a command of 1 — the output never switched")
|
||||||
|
}
|
||||||
|
wrongReg := appendCRC([]byte{0x01, 0x06, 0x00, 0x30, 0x00, 0x01})
|
||||||
|
if err := parseWriteEcho(1, regOnOff, 1, wrongReg); err == nil {
|
||||||
|
t.Error("an echo for register 0x0030 was accepted for a write to 0x0001")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// quietPort delivers a frame in pieces, then goes quiet — a serial port that
|
||||||
|
// reports a timeout as (0, nil), which is what Windows does.
|
||||||
|
type quietPort struct {
|
||||||
|
chunks [][]byte
|
||||||
|
i int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *quietPort) Read(b []byte) (int, error) {
|
||||||
|
if p.i >= len(p.chunks) {
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
return 0, nil // timeout, not an error
|
||||||
|
}
|
||||||
|
n := copy(b, p.chunks[p.i])
|
||||||
|
p.i++
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Modbus RTU frame has no terminator: it ends when the line falls quiet. The
|
||||||
|
// reader must assemble a dribbled frame and then stop on its own.
|
||||||
|
func TestReadFrameAssemblesUntilQuiet(t *testing.T) {
|
||||||
|
want := appendCRC([]byte{0x01, 0x03, 0x04, 0x05, 0x64, 0x09, 0xC4})
|
||||||
|
p := &quietPort{chunks: [][]byte{want[:2], want[2:5], want[5:]}}
|
||||||
|
got, err := readFrame(p, time.Second)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("readFrame: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("read % X, want % X", got, want)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("read % X, want % X", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A supply that is switched off, or not on this port, must produce an error
|
||||||
|
// rather than a wait that never ends.
|
||||||
|
func TestReadFrameGivesUpOnSilence(t *testing.T) {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() { _, err := readFrame(&quietPort{}, 80*time.Millisecond); done <- err }()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil {
|
||||||
|
t.Error("silence was reported as a frame")
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("readFrame never returned")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package psu
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config is one supply's serial link.
|
||||||
|
type Config struct {
|
||||||
|
ComPort string
|
||||||
|
Baud int // 9600 unless the supply has been reconfigured
|
||||||
|
Address byte // Modbus slave address, 1…15 on this family
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is what the UI shows. Everything here is READ from the supply — the
|
||||||
|
// set points included, which the operator sets on the front panel and OpsLog
|
||||||
|
// only reports.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
On bool `json:"on"` // output enabled
|
||||||
|
Volts float64 `json:"volts"` // measured output
|
||||||
|
Amps float64 `json:"amps"` // measured output
|
||||||
|
Watts float64 `json:"watts"` // measured output
|
||||||
|
SetVolts float64 `json:"set_volts"` // the voltage the supply is set to
|
||||||
|
SetAmps float64 `json:"set_amps"` // the current limit it is set to
|
||||||
|
Protected uint16 `json:"protected"` // protection status word, non-zero = tripped
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const pollEvery = 1500 * time.Millisecond
|
||||||
|
|
||||||
|
// Client owns the serial link to one supply.
|
||||||
|
type Client struct {
|
||||||
|
cfg Config
|
||||||
|
|
||||||
|
connMu sync.Mutex
|
||||||
|
conn serial.Port
|
||||||
|
|
||||||
|
ioMu sync.Mutex // serialises a request/reply exchange on the shared port
|
||||||
|
|
||||||
|
statusMu sync.Mutex
|
||||||
|
last Status
|
||||||
|
|
||||||
|
stopChan chan struct{}
|
||||||
|
stopOnce sync.Once
|
||||||
|
lastErr string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New builds a client. Nothing is opened until Start.
|
||||||
|
func New(cfg Config) *Client {
|
||||||
|
if cfg.Baud <= 0 {
|
||||||
|
cfg.Baud = 9600
|
||||||
|
}
|
||||||
|
if cfg.Address == 0 {
|
||||||
|
cfg.Address = 1
|
||||||
|
}
|
||||||
|
return &Client{cfg: cfg, stopChan: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start begins the poll loop. It returns immediately: the supply may be off, and
|
||||||
|
// a shack comes up in whatever order it comes up in.
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
go c.loop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop closes the link.
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
c.stopOnce.Do(func() { close(c.stopChan) })
|
||||||
|
c.closeConn()
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStatus returns the last poll's answer.
|
||||||
|
func (c *Client) GetStatus() Status {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
defer c.statusMu.Unlock()
|
||||||
|
return c.last
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetOutput switches the supply's output on or off — the one thing this driver
|
||||||
|
// writes. The supply's echo is checked, so a false return of "done" is not
|
||||||
|
// possible: either it confirmed the value or this is an error.
|
||||||
|
func (c *Client) SetOutput(on bool) error {
|
||||||
|
val := uint16(0)
|
||||||
|
if on {
|
||||||
|
val = 1
|
||||||
|
}
|
||||||
|
if err := c.writeRegister(regOnOff, val); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Report it at once rather than waiting for the next poll: the operator
|
||||||
|
// pressed a button and is looking at it.
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.last.On = on
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
log.Printf("psu: output %s", map[bool]string{true: "ON", false: "OFF"}[on])
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) loop() {
|
||||||
|
t := time.NewTicker(pollEvery)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
if err := c.poll(); err != nil {
|
||||||
|
c.noteFailure(err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// poll reads everything the UI shows, in as few exchanges as the register map
|
||||||
|
// allows: the measurements are contiguous (0x0010…0x0013), the set points are
|
||||||
|
// contiguous (0x0030, 0x0031), and the on/off and protection words sit together
|
||||||
|
// at 0x0001/0x0002.
|
||||||
|
func (c *Client) poll() error {
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st := Status{Connected: true}
|
||||||
|
|
||||||
|
state, err := c.readRegisters(regOnOff, 2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.On = state[0] != 0
|
||||||
|
st.Protected = state[1]
|
||||||
|
|
||||||
|
meas, err := c.readRegisters(regVolts, 4) // U, I, P high, P low
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.Volts = float64(meas[0]) / voltScale
|
||||||
|
st.Amps = float64(meas[1]) / ampScale
|
||||||
|
st.Watts = float64(uint32(meas[2])<<16|uint32(meas[3])) / wattScale
|
||||||
|
|
||||||
|
set, err := c.readRegisters(regSetVolts, 2)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
st.SetVolts = float64(set[0]) / voltScale
|
||||||
|
st.SetAmps = float64(set[1]) / ampScale
|
||||||
|
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.last = st
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
if c.lastErr != "" {
|
||||||
|
log.Printf("psu: %s answering again", c.cfg.ComPort)
|
||||||
|
c.lastErr = ""
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) readRegisters(reg, count uint16) ([]uint16, error) {
|
||||||
|
frame, err := c.exchange(buildRead(c.cfg.Address, reg, count))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return parseRead(c.cfg.Address, count, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writeRegister(reg, val uint16) error {
|
||||||
|
if err := c.ensureConn(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
frame, err := c.exchange(buildWrite(c.cfg.Address, reg, val))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return parseWriteEcho(c.cfg.Address, reg, val, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange sends one frame and reads one reply, holding the port for the whole
|
||||||
|
// round trip. Modbus RTU has no way to match a reply to a request, so two
|
||||||
|
// exchanges in flight at once would read each other's answers.
|
||||||
|
func (c *Client) exchange(req []byte) ([]byte, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.connMu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return nil, fmt.Errorf("psu: not connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
c.ioMu.Lock()
|
||||||
|
defer c.ioMu.Unlock()
|
||||||
|
|
||||||
|
// The silence before a frame is part of the protocol, not politeness: it is
|
||||||
|
// how the supply knows this is a new message and not the tail of the last.
|
||||||
|
time.Sleep(frameGap)
|
||||||
|
if _, err := conn.Write(req); err != nil {
|
||||||
|
c.closeConn()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
frame, err := readFrame(conn, replyWait)
|
||||||
|
if err != nil {
|
||||||
|
c.closeConn()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return frame, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) ensureConn() error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
port := strings.TrimSpace(c.cfg.ComPort)
|
||||||
|
if port == "" {
|
||||||
|
return fmt.Errorf("psu: no serial port configured")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(port, &serial.Mode{
|
||||||
|
BaudRate: c.cfg.Baud,
|
||||||
|
DataBits: 8,
|
||||||
|
Parity: serial.NoParity,
|
||||||
|
StopBits: serial.OneStopBit,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("psu: cannot open %s: %w", port, err)
|
||||||
|
}
|
||||||
|
// Short per-read timeout: readFrame decides when a frame has ended by the
|
||||||
|
// quiet between bytes, so each Read must come back promptly with whatever
|
||||||
|
// has arrived.
|
||||||
|
_ = p.SetReadTimeout(2 * time.Millisecond)
|
||||||
|
c.conn = p
|
||||||
|
log.Printf("psu: %s open at %d baud, Modbus address %d", port, c.cfg.Baud, c.cfg.Address)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) closeConn() {
|
||||||
|
c.connMu.Lock()
|
||||||
|
defer c.connMu.Unlock()
|
||||||
|
if c.conn != nil {
|
||||||
|
_ = c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// noteFailure records a poll failure and says so ONCE per distinct message.
|
||||||
|
//
|
||||||
|
// A supply that is switched off at the mains fails every poll, and a line per
|
||||||
|
// second and a half would be the whole log file — but saying nothing at all is
|
||||||
|
// how "it stopped working" arrives with no evidence.
|
||||||
|
func (c *Client) noteFailure(err error) {
|
||||||
|
msg := err.Error()
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.last = Status{Connected: false, Error: msg}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
if msg != c.lastErr {
|
||||||
|
c.lastErr = msg
|
||||||
|
log.Printf("psu: %v — retrying every %s, and this will not be logged again until it changes", err, pollEvery)
|
||||||
|
}
|
||||||
|
}
|
||||||
+122
-22
@@ -3,6 +3,8 @@ package relaydev
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -20,13 +22,28 @@ import (
|
|||||||
//
|
//
|
||||||
// - one URL pair with {relay} in it, used for every relay:
|
// - one URL pair with {relay} in it, used for every relay:
|
||||||
// http://192.168.1.9/relay?n={relay}&state=on
|
// http://192.168.1.9/relay?n={relay}&state=on
|
||||||
// - or one pair per relay, when the box has no pattern to speak of:
|
// - one pair per relay, when the box has no pattern to speak of:
|
||||||
// relay 1 → http://192.168.1.9/FF0101 , relay 2 → .../FF0201
|
// relay 1 → http://192.168.1.9/FF0101 , relay 2 → .../FF0201
|
||||||
//
|
//
|
||||||
// The second is the reason this driver exists. A hand-made switch often has
|
// The second is the reason this driver exists. A hand-made switch often has
|
||||||
// URLs with nothing in common between channels, and a template with {relay}
|
// URLs with nothing in common between channels, and a template with {relay}
|
||||||
// cannot express that.
|
// cannot express that.
|
||||||
//
|
//
|
||||||
|
// TWO SUBSTITUTIONS are available in either form:
|
||||||
|
//
|
||||||
|
// {relay} the relay number, 1-based. {relay-1} for a board that counts its
|
||||||
|
// channels from zero — otherwise the whole pattern has to be given
|
||||||
|
// up for eight hand-typed URLs over one missing offset.
|
||||||
|
// {value} that relay's LABEL, the name given to it in Relay labels. A switch
|
||||||
|
// addressed by antenna name rather than by channel number
|
||||||
|
// (…/relay?on=Ant1) is then one pattern instead of eight URLs, and
|
||||||
|
// renaming the antenna re-addresses it — the name the operator reads
|
||||||
|
// on the button and the name on the wire cannot drift apart because
|
||||||
|
// they are the same string.
|
||||||
|
//
|
||||||
|
// The label is percent-encoded, so a name with a space or an accent goes out as
|
||||||
|
// a valid URL rather than a request the board rejects without saying why.
|
||||||
|
//
|
||||||
// STATE IS REMEMBERED, NOT READ. Most of these boxes have no status endpoint,
|
// STATE IS REMEMBERED, NOT READ. Most of these boxes have no status endpoint,
|
||||||
// or answer with a web page nobody can parse reliably. Status therefore returns
|
// or answer with a web page nobody can parse reliably. Status therefore returns
|
||||||
// what we last commanded — see the method for what that costs.
|
// what we last commanded — see the method for what that costs.
|
||||||
@@ -35,6 +52,7 @@ type httpGen struct {
|
|||||||
offURLs []string
|
offURLs []string
|
||||||
onPat string // pattern with {relay}, used when the per-relay URL is empty
|
onPat string // pattern with {relay}, used when the per-relay URL is empty
|
||||||
offPat string
|
offPat string
|
||||||
|
labels []string // index 0 = relay 1; what {value} resolves to
|
||||||
user string
|
user string
|
||||||
pass string
|
pass string
|
||||||
count int
|
count int
|
||||||
@@ -45,8 +63,8 @@ type httpGen struct {
|
|||||||
|
|
||||||
// NewHTTPGeneric builds the driver. onURLs/offURLs are per relay (index 0 =
|
// NewHTTPGeneric builds the driver. onURLs/offURLs are per relay (index 0 =
|
||||||
// relay 1) and may be short or hold empty entries; onPat/offPat are the
|
// relay 1) and may be short or hold empty entries; onPat/offPat are the
|
||||||
// fallback patterns.
|
// fallback patterns; labels are the relay names {value} substitutes.
|
||||||
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int) Device {
|
func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string, count int, labels []string) Device {
|
||||||
if count <= 0 {
|
if count <= 0 {
|
||||||
count = len(onURLs)
|
count = len(onURLs)
|
||||||
}
|
}
|
||||||
@@ -55,7 +73,7 @@ func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string,
|
|||||||
}
|
}
|
||||||
return &httpGen{
|
return &httpGen{
|
||||||
onURLs: onURLs, offURLs: offURLs,
|
onURLs: onURLs, offURLs: offURLs,
|
||||||
onPat: onPat, offPat: offPat,
|
onPat: onPat, offPat: offPat, labels: labels,
|
||||||
user: user, pass: pass, count: count,
|
user: user, pass: pass, count: count,
|
||||||
state: make([]bool, count),
|
state: make([]bool, count),
|
||||||
}
|
}
|
||||||
@@ -64,38 +82,120 @@ func NewHTTPGeneric(onURLs, offURLs []string, onPat, offPat, user, pass string,
|
|||||||
func (h *httpGen) Count() int { return h.count }
|
func (h *httpGen) Count() int { return h.count }
|
||||||
func (h *httpGen) Close() error { return nil } // stateless HTTP, nothing to release
|
func (h *httpGen) Close() error { return nil } // stateless HTTP, nothing to release
|
||||||
|
|
||||||
// urlFor picks the per-relay URL, falling back to the pattern.
|
// patFor returns the pattern for a direction, trimmed.
|
||||||
func (h *httpGen) urlFor(relay int, on bool) string {
|
func (h *httpGen) patFor(on bool) string {
|
||||||
list, pat := h.offURLs, h.offPat
|
|
||||||
if on {
|
if on {
|
||||||
list, pat = h.onURLs, h.onPat
|
return strings.TrimSpace(h.onPat)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(h.offPat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// entryFor returns what was typed in the per-relay box for a direction.
|
||||||
|
func (h *httpGen) entryFor(relay int, on bool) string {
|
||||||
|
list := h.offURLs
|
||||||
|
if on {
|
||||||
|
list = h.onURLs
|
||||||
}
|
}
|
||||||
if i := relay - 1; i >= 0 && i < len(list) {
|
if i := relay - 1; i >= 0 && i < len(list) {
|
||||||
if u := strings.TrimSpace(list[i]); u != "" {
|
return strings.TrimSpace(list[i])
|
||||||
return u
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if pat = strings.TrimSpace(pat); pat == "" {
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// labelFor returns the relay's name, as typed in Relay labels.
|
||||||
|
func (h *httpGen) labelFor(relay int) string {
|
||||||
|
if i := relay - 1; i >= 0 && i < len(h.labels) {
|
||||||
|
return strings.TrimSpace(h.labels[i])
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// urlFor builds the request for one relay in one direction: the per-relay URL
|
||||||
|
// if there is one, the pattern otherwise, with both substitutions applied.
|
||||||
|
func (h *httpGen) urlFor(relay int, on bool) string {
|
||||||
|
u := h.entryFor(relay, on)
|
||||||
|
if u == "" {
|
||||||
|
u = h.patFor(on)
|
||||||
|
}
|
||||||
|
if u == "" {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return strings.ReplaceAll(pat, "{relay}", strconv.Itoa(relay))
|
return expand(u, relay, h.labelFor(relay))
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeValue percent-encodes a relay label for use anywhere in a URL.
|
||||||
|
//
|
||||||
|
// url.QueryEscape alone is wrong: it writes a space as "+", which is a space
|
||||||
|
// only in a query string and a literal plus sign in a path. Encoding it as %20
|
||||||
|
// instead is correct in both, and {value} may land in either.
|
||||||
|
func escapeValue(s string) string {
|
||||||
|
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
||||||
|
}
|
||||||
|
|
||||||
|
// withScheme supplies http:// when none was typed, and leaves https:// alone.
|
||||||
|
//
|
||||||
|
// The same rule the named boards get from relayBase, and it has to be here too:
|
||||||
|
// this driver takes whole URLs rather than a host, and a line typed as
|
||||||
|
// "192.168.1.9/Set0/1" would otherwise fail with "unsupported protocol scheme"
|
||||||
|
// — an error about a scheme, for a field where nobody knew one was expected.
|
||||||
|
// An https:// board (a reverse proxy fronting the shack, most often) is passed
|
||||||
|
// through untouched and needs no other handling: it is the same HTTP client.
|
||||||
|
func withScheme(u string) string {
|
||||||
|
if u == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if l := strings.ToLower(u); strings.HasPrefix(l, "http://") || strings.HasPrefix(l, "https://") {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
return "http://" + u
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayToken matches {relay} and its offset forms, {relay-1} / {relay+2}.
|
||||||
|
var relayToken = regexp.MustCompile(`\{relay([+-]\d+)?\}`)
|
||||||
|
|
||||||
|
// expand substitutes {value} with the relay's label and {relay} with its
|
||||||
|
// number, honouring an offset. A board that numbers its channels from zero is
|
||||||
|
// written {relay-1}; without that the whole pattern has to be abandoned for
|
||||||
|
// four hand-typed URLs.
|
||||||
|
func expand(s string, relay int, label string) string {
|
||||||
|
s = strings.ReplaceAll(s, "{value}", escapeValue(label))
|
||||||
|
return relayToken.ReplaceAllStringFunc(s, func(m string) string {
|
||||||
|
n := relay
|
||||||
|
if i := strings.IndexAny(m, "+-"); i >= 0 {
|
||||||
|
if off, err := strconv.Atoi(m[i : len(m)-1]); err == nil {
|
||||||
|
n += off
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strconv.Itoa(n)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *httpGen) Set(ctx context.Context, relay int, on bool) error {
|
func (h *httpGen) Set(ctx context.Context, relay int, on bool) error {
|
||||||
if relay < 1 || relay > h.count {
|
if relay < 1 || relay > h.count {
|
||||||
return fmt.Errorf("relay %d out of range 1..%d", relay, h.count)
|
return fmt.Errorf("relay %d out of range 1..%d", relay, h.count)
|
||||||
}
|
}
|
||||||
u := h.urlFor(relay, on)
|
// Naming the direction matters: an operator who filled the ON URLs and left
|
||||||
if u == "" {
|
// OFF empty gets a switch that latches, and "no URL configured" alone would
|
||||||
// Naming the direction matters: an operator who filled the ON URLs and
|
// not say which half is missing.
|
||||||
// left OFF empty gets a switch that latches, and "no URL configured"
|
dir := "OFF"
|
||||||
// alone would not say which half is missing.
|
if on {
|
||||||
dir := "OFF"
|
dir = "ON"
|
||||||
if on {
|
}
|
||||||
dir = "ON"
|
tmpl := h.entryFor(relay, on)
|
||||||
}
|
if tmpl == "" {
|
||||||
|
tmpl = h.patFor(on)
|
||||||
|
}
|
||||||
|
if tmpl == "" {
|
||||||
return fmt.Errorf("no %s URL configured for relay %d", dir, relay)
|
return fmt.Errorf("no %s URL configured for relay %d", dir, relay)
|
||||||
}
|
}
|
||||||
|
// {value} with no label would send "?on=" — an empty parameter to an antenna
|
||||||
|
// switch, which most boards answer with a cheerful 200 and no movement. Say
|
||||||
|
// what is missing instead of firing it.
|
||||||
|
if strings.Contains(tmpl, "{value}") && h.labelFor(relay) == "" {
|
||||||
|
return fmt.Errorf("the %s URL for relay %d uses {value}, but relay %d has no label to put there", dir, relay, relay)
|
||||||
|
}
|
||||||
|
u := h.urlFor(relay, on)
|
||||||
|
u = withScheme(u)
|
||||||
if _, err := get(ctx, u, h.user, h.pass); err != nil {
|
if _, err := get(ctx, u, h.user, h.pass); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func TestHTTPGenericPattern(t *testing.T) {
|
|||||||
|
|
||||||
d := NewHTTPGeneric(nil, nil,
|
d := NewHTTPGeneric(nil, nil,
|
||||||
srv.URL+"/relay?n={relay}&state=on",
|
srv.URL+"/relay?n={relay}&state=on",
|
||||||
srv.URL+"/relay?n={relay}&state=off", "", "", 4)
|
srv.URL+"/relay?n={relay}&state=off", "", "", 4, nil)
|
||||||
if err := d.Set(context.Background(), 2, true); err != nil {
|
if err := d.Set(context.Background(), 2, true); err != nil {
|
||||||
t.Fatalf("Set on: %v", err)
|
t.Fatalf("Set on: %v", err)
|
||||||
}
|
}
|
||||||
@@ -53,7 +53,7 @@ func TestHTTPGenericPerRelayURLsWinOverThePattern(t *testing.T) {
|
|||||||
d := NewHTTPGeneric(
|
d := NewHTTPGeneric(
|
||||||
[]string{srv.URL + "/FF0101", "", srv.URL + "/weird/on"},
|
[]string{srv.URL + "/FF0101", "", srv.URL + "/weird/on"},
|
||||||
[]string{srv.URL + "/FF0100", "", ""},
|
[]string{srv.URL + "/FF0100", "", ""},
|
||||||
srv.URL+"/pattern/on/{relay}", srv.URL+"/pattern/off/{relay}", "", "", 3)
|
srv.URL+"/pattern/on/{relay}", srv.URL+"/pattern/off/{relay}", "", "", 3, nil)
|
||||||
|
|
||||||
_ = d.Set(context.Background(), 1, true) // its own URL
|
_ = d.Set(context.Background(), 1, true) // its own URL
|
||||||
_ = d.Set(context.Background(), 2, true) // empty → falls back to the pattern
|
_ = d.Set(context.Background(), 2, true) // empty → falls back to the pattern
|
||||||
@@ -66,10 +66,90 @@ func TestHTTPGenericPerRelayURLsWinOverThePattern(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// {value} is the relay's LABEL: a switch addressed by antenna name rather than
|
||||||
|
// by channel number is one pattern instead of eight URLs.
|
||||||
|
func TestHTTPGenericValueIsTheRelayLabel(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var got []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
got = append(got, r.URL.String())
|
||||||
|
mu.Unlock()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := NewHTTPGeneric(
|
||||||
|
[]string{srv.URL + "/relay?on={value}"}, // per-relay URL
|
||||||
|
nil,
|
||||||
|
"", srv.URL+"/relay?off={value}", // and the pattern, for the other direction
|
||||||
|
"", "", 3, []string{"Ant1", "Beam 20m", ""})
|
||||||
|
_ = d.Set(context.Background(), 1, true)
|
||||||
|
_ = d.Set(context.Background(), 2, false)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
// The space in "Beam 20m" must go out as %20 — a "+" would be a literal plus
|
||||||
|
// in a path, and this substitution can land in either half of a URL.
|
||||||
|
want := []string{"/relay?on=Ant1", "/relay?off=Beam%2020m"}
|
||||||
|
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||||
|
t.Errorf("requested %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// {relay-1} for a board whose channels are numbered from zero.
|
||||||
|
func TestHTTPGenericRelayOffset(t *testing.T) {
|
||||||
|
var mu sync.Mutex
|
||||||
|
var got []string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
mu.Lock()
|
||||||
|
got = append(got, r.URL.Path)
|
||||||
|
mu.Unlock()
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := NewHTTPGeneric(nil, nil,
|
||||||
|
srv.URL+"/set0/{relay-1}/1", srv.URL+"/set0/{relay-1}/0", "", "", 4, nil)
|
||||||
|
_ = d.Set(context.Background(), 1, true)
|
||||||
|
_ = d.Set(context.Background(), 4, false)
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
want := []string{"/set0/0/1", "/set0/3/0"}
|
||||||
|
if strings.Join(got, " ") != strings.Join(want, " ") {
|
||||||
|
t.Errorf("requested %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A URL that uses {value} on an unlabelled relay would go out as "?on=" — an
|
||||||
|
// empty parameter, which most boards answer with a cheerful 200 and no
|
||||||
|
// movement. It must be refused, and the message must say the label is what is
|
||||||
|
// missing.
|
||||||
|
func TestHTTPGenericRefusesValueWithoutALabel(t *testing.T) {
|
||||||
|
d := NewHTTPGeneric(nil, nil, "http://x/relay?on={value}", "", "", "", 2, []string{"", ""})
|
||||||
|
err := d.Set(context.Background(), 1, true)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "label") {
|
||||||
|
t.Errorf("err = %v, want it to name the missing label", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A URL typed without a scheme must still be sent — the named boards take a
|
||||||
|
// bare host and add http:// themselves, and this one has to behave the same.
|
||||||
|
// https:// is left exactly as typed.
|
||||||
|
func TestHTTPGenericSuppliesTheScheme(t *testing.T) {
|
||||||
|
for _, c := range []struct{ in, want string }{
|
||||||
|
{"192.168.1.9/Set0/1", "http://192.168.1.9/Set0/1"},
|
||||||
|
{"http://192.168.1.9/x", "http://192.168.1.9/x"},
|
||||||
|
{"https://relay.example.com/x", "https://relay.example.com/x"},
|
||||||
|
{"HTTPS://relay.example.com/x", "HTTPS://relay.example.com/x"},
|
||||||
|
} {
|
||||||
|
if got := withScheme(c.in); got != c.want {
|
||||||
|
t.Errorf("withScheme(%q) = %q, want %q", c.in, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// A switch with the ON URLs filled and OFF left empty latches. The error has to
|
// A switch with the ON URLs filled and OFF left empty latches. The error has to
|
||||||
// name the direction, or the operator cannot tell which half is missing.
|
// name the direction, or the operator cannot tell which half is missing.
|
||||||
func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
|
func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
|
||||||
d := NewHTTPGeneric([]string{"http://x/on"}, nil, "", "", "", "", 1)
|
d := NewHTTPGeneric([]string{"http://x/on"}, nil, "", "", "", "", 1, nil)
|
||||||
err := d.Set(context.Background(), 1, false)
|
err := d.Set(context.Background(), 1, false)
|
||||||
if err == nil || !strings.Contains(err.Error(), "OFF") {
|
if err == nil || !strings.Contains(err.Error(), "OFF") {
|
||||||
t.Errorf("err = %v, want it to name the OFF direction", err)
|
t.Errorf("err = %v, want it to name the OFF direction", err)
|
||||||
@@ -80,7 +160,7 @@ func TestHTTPGenericNamesTheMissingDirection(t *testing.T) {
|
|||||||
func TestHTTPGenericRemembersWhatItCommanded(t *testing.T) {
|
func TestHTTPGenericRemembersWhatItCommanded(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 3)
|
d := NewHTTPGeneric(nil, nil, srv.URL+"/on/{relay}", srv.URL+"/off/{relay}", "", "", 3, nil)
|
||||||
_ = d.Set(context.Background(), 2, true)
|
_ = d.Set(context.Background(), 2, true)
|
||||||
st, err := d.Status(context.Background())
|
st, err := d.Status(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// just to turn an antenna. Two towers with a controller each is the ordinary
|
// just to turn an antenna. Two towers with a controller each is the ordinary
|
||||||
// case; each one is a separate serial port and a separate rotor in OpsLog.
|
// case; each one is a separate serial port and a separate rotor in OpsLog.
|
||||||
//
|
//
|
||||||
// WIRE FORMAT
|
// # WIRE FORMAT
|
||||||
//
|
//
|
||||||
// Every command is 13 bytes:
|
// Every command is 13 bytes:
|
||||||
//
|
//
|
||||||
@@ -30,6 +30,13 @@
|
|||||||
//
|
//
|
||||||
// az = H1×100 + H2×10 + H3 + H4/10 − 360
|
// az = H1×100 + H2×10 + H3 + H4/10 − 360
|
||||||
//
|
//
|
||||||
|
// ROT1PROG IS THREE DIGITS IN BOTH DIRECTIONS. Its reply carries three, and so
|
||||||
|
// does its command — the controller reads the azimuth from offsets 1, 2 and 3,
|
||||||
|
// with no resolution scaling (it is one pulse per degree). Sending it the
|
||||||
|
// four-digit Rot2Prog form shifts every target by a decimal place: 90° goes out
|
||||||
|
// as "0450" and is read as 045, which is −315°, so every command turns the
|
||||||
|
// antenna nearly a full circle the wrong way. Found on a tower, not here.
|
||||||
|
//
|
||||||
// The 360 offset is what lets the controller report a rotator that has turned
|
// The 360 offset is what lets the controller report a rotator that has turned
|
||||||
// past north in either direction, which is the point of a pulse-counting
|
// past north in either direction, which is the point of a pulse-counting
|
||||||
// rotator: −180…540 rather than 0…359.
|
// rotator: −180…540 rather than 0…359.
|
||||||
@@ -111,7 +118,22 @@ func BuildStop() []byte { return buildCmd(0, 0, 0, 0, cmdStop) }
|
|||||||
// Azimuth is offset by 360 before scaling, so a target of −10° and one of 350°
|
// Azimuth is offset by 360 before scaling, so a target of −10° and one of 350°
|
||||||
// are different instructions: the first turns anticlockwise past north, the
|
// are different instructions: the first turns anticlockwise past north, the
|
||||||
// second does not. Feeding a 0…359 heading in is therefore always safe.
|
// second does not. Feeding a 0…359 heading in is therefore always safe.
|
||||||
func BuildSet(az, el float64, resolution byte) []byte {
|
//
|
||||||
|
// ROT1PROG SENDS THREE DIGITS, NOT FOUR, and that is the whole reason this
|
||||||
|
// takes a model. Its reply is three digits — a 5-byte frame — and its command
|
||||||
|
// field matches: the controller reads the azimuth from offsets 1, 2 and 3.
|
||||||
|
//
|
||||||
|
// Sending the four-digit Rot2Prog form to one shifts every target by a decimal
|
||||||
|
// place. "0450" for 90° was read as 045, i.e. 45 − 360 = −315°, so every
|
||||||
|
// command became a near-full turn ANTICLOCKWISE whatever was asked for — 0°,
|
||||||
|
// 90°, 180°, all of them. That is exactly how it was reported from a tower:
|
||||||
|
// every heading wanted to go the wrong way round, and a "point to 0°" test that
|
||||||
|
// did nothing useful.
|
||||||
|
func BuildSet(az, el float64, resolution byte, model Model) []byte {
|
||||||
|
if model == Rot1Prog {
|
||||||
|
// No scaling: a Rot1Prog is one pulse per degree and reports resolution 1.
|
||||||
|
return buildCmd3(int(360+az+0.5), int(360+el+0.5))
|
||||||
|
}
|
||||||
if resolution == 0 {
|
if resolution == 0 {
|
||||||
resolution = 1
|
resolution = 1
|
||||||
}
|
}
|
||||||
@@ -120,6 +142,26 @@ func BuildSet(az, el float64, resolution byte) []byte {
|
|||||||
return buildCmd(uaz, uel, resolution, resolution, cmdSet)
|
return buildCmd(uaz, uel, resolution, resolution, cmdSet)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// buildCmd3 frames a Rot1Prog target: three ASCII digits per axis at the same
|
||||||
|
// offsets its replies use, the fourth digit position left as '0'.
|
||||||
|
func buildCmd3(uaz, uel int) []byte {
|
||||||
|
c := make([]byte, 13)
|
||||||
|
c[0] = frameStart
|
||||||
|
c[1] = '0' + byte(uaz/100%10)
|
||||||
|
c[2] = '0' + byte(uaz/10%10)
|
||||||
|
c[3] = '0' + byte(uaz%10)
|
||||||
|
c[4] = '0'
|
||||||
|
c[5] = 0x01
|
||||||
|
c[6] = '0' + byte(uel/100%10)
|
||||||
|
c[7] = '0' + byte(uel/10%10)
|
||||||
|
c[8] = '0' + byte(uel%10)
|
||||||
|
c[9] = '0'
|
||||||
|
c[10] = 0x01
|
||||||
|
c[11] = cmdSet
|
||||||
|
c[12] = frameEnd
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
func buildCmd(uaz, uel int, ph, pv byte, k byte) []byte {
|
func buildCmd(uaz, uel int, ph, pv byte, k byte) []byte {
|
||||||
c := make([]byte, 13)
|
c := make([]byte, 13)
|
||||||
c[0] = frameStart
|
c[0] = frameStart
|
||||||
@@ -175,7 +217,7 @@ func (c *Client) GoTo(az int, el int) error {
|
|||||||
if el >= 0 && c.model == Rot2Prog {
|
if el >= 0 && c.model == Rot2Prog {
|
||||||
e = float64(el)
|
e = float64(el)
|
||||||
}
|
}
|
||||||
_, err := c.exchange(BuildSet(float64(az), e, res), 0)
|
_, err := c.exchange(BuildSet(float64(az), e, res, c.model), 0)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,21 +12,67 @@ import (
|
|||||||
func TestSetFrameMatchesTheReference(t *testing.T) {
|
func TestSetFrameMatchesTheReference(t *testing.T) {
|
||||||
// Hamlib: u_az = PH × (360 + az), then the four decimal digits as ASCII;
|
// Hamlib: u_az = PH × (360 + az), then the four decimal digits as ASCII;
|
||||||
// PH and PV raw; K = 0x2F.
|
// PH and PV raw; K = 0x2F.
|
||||||
got := BuildSet(0, 0, 1) // 360 → "0360"
|
got := BuildSet(0, 0, 1, Rot2Prog) // 360 → "0360"
|
||||||
want := []byte{0x57, '0', '3', '6', '0', 0x01, '0', '3', '6', '0', 0x01, 0x2F, 0x20}
|
want := []byte{0x57, '0', '3', '6', '0', 0x01, '0', '3', '6', '0', 0x01, 0x2F, 0x20}
|
||||||
assertBytes(t, "az 0 res 1", got, want)
|
assertBytes(t, "az 0 res 1", got, want)
|
||||||
|
|
||||||
// 90° at half-degree resolution: 2 × 450 = 900 → "0900".
|
// 90° at half-degree resolution: 2 × 450 = 900 → "0900".
|
||||||
got = BuildSet(90, 0, 2)
|
got = BuildSet(90, 0, 2, Rot2Prog)
|
||||||
want = []byte{0x57, '0', '9', '0', '0', 0x02, '0', '7', '2', '0', 0x02, 0x2F, 0x20}
|
want = []byte{0x57, '0', '9', '0', '0', 0x02, '0', '7', '2', '0', 0x02, 0x2F, 0x20}
|
||||||
assertBytes(t, "az 90 res 2", got, want)
|
assertBytes(t, "az 90 res 2", got, want)
|
||||||
|
|
||||||
// A quarter-degree controller, 359°: 4 × 719 = 2876.
|
// A quarter-degree controller, 359°: 4 × 719 = 2876.
|
||||||
got = BuildSet(359, 0, 4)
|
got = BuildSet(359, 0, 4, Rot2Prog)
|
||||||
want = []byte{0x57, '2', '8', '7', '6', 0x04, '1', '4', '4', '0', 0x04, 0x2F, 0x20}
|
want = []byte{0x57, '2', '8', '7', '6', 0x04, '1', '4', '4', '0', 0x04, 0x2F, 0x20}
|
||||||
assertBytes(t, "az 359 res 4", got, want)
|
assertBytes(t, "az 359 res 4", got, want)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A Rot1Prog reads its azimuth from offsets 1, 2 and 3 — three digits, the same
|
||||||
|
// field its five-byte replies use.
|
||||||
|
//
|
||||||
|
// This is field evidence, not a reading of the reference: sending the
|
||||||
|
// four-digit Rot2Prog form to a RAK/RAU made every command turn the antenna
|
||||||
|
// nearly a full circle ANTICLOCKWISE. "0450" for 90° was read as 045, which is
|
||||||
|
// 45 − 360 = −315°, and the same shift made 0°, 180° and every other target go
|
||||||
|
// the wrong way round too. The three cases below are the ones that were tried
|
||||||
|
// on the tower.
|
||||||
|
func TestRot1ProgSetFrameIsThreeDigits(t *testing.T) {
|
||||||
|
// 0° → 360 → "360". The "point to 0°" test that did nothing.
|
||||||
|
assertBytes(t, "rot1prog az 0", BuildSet(0, 0, 1, Rot1Prog),
|
||||||
|
[]byte{0x57, '3', '6', '0', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
|
||||||
|
|
||||||
|
// 90° → 450 → "450". Sent as "0450" it read as 45 − 360 = −315°, which from
|
||||||
|
// 45° is a full turn the wrong way.
|
||||||
|
assertBytes(t, "rot1prog az 90", BuildSet(90, 0, 1, Rot1Prog),
|
||||||
|
[]byte{0x57, '4', '5', '0', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
|
||||||
|
|
||||||
|
// 359° → 719 → "719": three digits still, at the top of the range.
|
||||||
|
assertBytes(t, "rot1prog az 359", BuildSet(359, 0, 1, Rot1Prog),
|
||||||
|
[]byte{0x57, '7', '1', '9', '0', 0x01, '3', '6', '0', '0', 0x01, 0x2F, 0x20})
|
||||||
|
}
|
||||||
|
|
||||||
|
// What a Rot1Prog is SENT and what it REPORTS have to be the same number, or
|
||||||
|
// the antenna goes somewhere nobody asked for. Round-tripping every degree of
|
||||||
|
// the circle through the command builder and the reply parser is the cheapest
|
||||||
|
// way to say that, and it is the check that would have caught the four-digit
|
||||||
|
// frame before it reached a tower.
|
||||||
|
func TestRot1ProgCommandAndReplyAgree(t *testing.T) {
|
||||||
|
for deg := 0; deg < 360; deg++ {
|
||||||
|
cmd := BuildSet(float64(deg), 0, 1, Rot1Prog)
|
||||||
|
// The controller reads three ASCII digits and answers with the same value
|
||||||
|
// in raw bytes — the asymmetry this protocol is built on.
|
||||||
|
reply := []byte{0x57, cmd[1] - '0', cmd[2] - '0', cmd[3] - '0', 0x20}
|
||||||
|
az, _, _, err := ParseStatus(reply, Rot1Prog)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%d°: %v", deg, err)
|
||||||
|
}
|
||||||
|
if int(az+0.5) != deg {
|
||||||
|
t.Fatalf("commanded %d°, the controller would report %v° — a %v° error",
|
||||||
|
deg, az, az-float64(deg))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Status and stop carry no position: every data byte is zero, only K differs.
|
// Status and stop carry no position: every data byte is zero, only K differs.
|
||||||
func TestStatusAndStopFrames(t *testing.T) {
|
func TestStatusAndStopFrames(t *testing.T) {
|
||||||
assertBytes(t, "status", BuildStatus(),
|
assertBytes(t, "status", BuildStatus(),
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package steppir
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// silentPort is a serial port that has stopped answering: every read times out.
|
||||||
|
// On Windows that is reported as (0, nil) — a timeout is not an error on this
|
||||||
|
// transport — which is precisely what io.ReadFull cannot survive.
|
||||||
|
type silentPort struct{ reads int }
|
||||||
|
|
||||||
|
func (p *silentPort) Read(b []byte) (int, error) {
|
||||||
|
p.reads++
|
||||||
|
time.Sleep(5 * time.Millisecond) // stand in for the port's read timeout
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
func (p *silentPort) Write(b []byte) (int, error) { return len(b), nil }
|
||||||
|
func (p *silentPort) Close() error { return nil }
|
||||||
|
|
||||||
|
// A controller that goes quiet must make the read FAIL, not hang. Hanging held
|
||||||
|
// the io mutex, so the poll loop never reported a fault and every operator
|
||||||
|
// command blocked behind it: the antenna stopped responding and the log had
|
||||||
|
// nothing in it.
|
||||||
|
func TestReadFrameGivesUpOnASilentController(t *testing.T) {
|
||||||
|
done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
done <- readFrame(&silentPort{}, make([]byte, 11), 100*time.Millisecond)
|
||||||
|
}()
|
||||||
|
select {
|
||||||
|
case err := <-done:
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a silent controller was reported as a good frame")
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("readFrame never returned — the driver is wedged exactly as it was in the field")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// dribblePort delivers the frame a few bytes at a time, with empty reads in
|
||||||
|
// between — a slow 4800-baud link, which must still assemble one frame.
|
||||||
|
type dribblePort struct {
|
||||||
|
data []byte
|
||||||
|
step int
|
||||||
|
idle int // empty reads before each chunk
|
||||||
|
n int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *dribblePort) Read(b []byte) (int, error) {
|
||||||
|
if p.n < p.idle {
|
||||||
|
p.n++
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
p.n = 0
|
||||||
|
if len(p.data) == 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
k := p.step
|
||||||
|
if k > len(p.data) {
|
||||||
|
k = len(p.data)
|
||||||
|
}
|
||||||
|
if k > len(b) {
|
||||||
|
k = len(b)
|
||||||
|
}
|
||||||
|
copy(b, p.data[:k])
|
||||||
|
p.data = p.data[k:]
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
|
func (p *dribblePort) Write(b []byte) (int, error) { return len(b), nil }
|
||||||
|
func (p *dribblePort) Close() error { return nil }
|
||||||
|
|
||||||
|
func TestReadFrameAssemblesASlowFrame(t *testing.T) {
|
||||||
|
want := []byte{'@', 'A', 0x00, 0x20, 0x1E, 0xA8, 0x00, 0x05, 0x30, 0x37, 0x0D}
|
||||||
|
p := &dribblePort{data: append([]byte(nil), want...), step: 3, idle: 2}
|
||||||
|
buf := make([]byte, 11)
|
||||||
|
if err := readFrame(p, buf, time.Second); err != nil {
|
||||||
|
t.Fatalf("readFrame: %v", err)
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if buf[i] != want[i] {
|
||||||
|
t.Fatalf("read % X, want % X", buf, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A truncated frame is a failure, not a frame. The controller sending 5 bytes
|
||||||
|
// and stopping used to spin forever on the missing 6.
|
||||||
|
func TestReadFrameRejectsATruncatedFrame(t *testing.T) {
|
||||||
|
p := &dribblePort{data: []byte{'@', 'A', 0x00, 0x20, 0x1E}, step: 5}
|
||||||
|
err := readFrame(p, make([]byte, 11), 100*time.Millisecond)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("a 5-byte frame was accepted as 11")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A real error still comes straight back.
|
||||||
|
func TestReadFrameReturnsPortErrors(t *testing.T) {
|
||||||
|
want := errors.New("port closed")
|
||||||
|
p := errPort{err: want}
|
||||||
|
if err := readFrame(p, make([]byte, 11), time.Second); !errors.Is(err, want) {
|
||||||
|
t.Fatalf("err = %v, want %v", err, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type errPort struct{ err error }
|
||||||
|
|
||||||
|
func (p errPort) Read([]byte) (int, error) { return 0, p.err }
|
||||||
|
func (p errPort) Write(b []byte) (int, error) { return len(b), nil }
|
||||||
|
func (p errPort) Close() error { return nil }
|
||||||
|
|
||||||
|
var _ io.ReadWriteCloser = errPort{}
|
||||||
@@ -419,6 +419,45 @@ func drain(conn io.ReadWriteCloser) int {
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// frameTimeout bounds the wait for one 11-byte status reply. At 4800 baud the
|
||||||
|
// frame itself takes ~23 ms; three seconds is a controller that is not going to
|
||||||
|
// answer this query.
|
||||||
|
const frameTimeout = 3 * time.Second
|
||||||
|
|
||||||
|
// readFrame reads exactly len(buf) bytes, or gives up.
|
||||||
|
//
|
||||||
|
// io.ReadFull CANNOT be used on a serial port, and using it here is what made an
|
||||||
|
// antenna "stop responding after a while" with nothing whatsoever in the log.
|
||||||
|
//
|
||||||
|
// On Windows a serial read that times out returns (0, nil) — a timeout is not an
|
||||||
|
// error on this transport. io.ReadFull loops while err == nil, so a controller
|
||||||
|
// that goes quiet, or sends a truncated frame, spins it forever. It holds ioMu
|
||||||
|
// the whole time, and that is the part the operator sees: the poll goroutine
|
||||||
|
// never returns to report a fault, so the last status stays on screen and the
|
||||||
|
// link still looks connected — while every command blocks on the same mutex.
|
||||||
|
// The trace line used to sit AFTER that lock, so even the attempt went unlogged.
|
||||||
|
// One dropped reply on a 4800-baud link wedged the driver until OpsLog restarted.
|
||||||
|
//
|
||||||
|
// Giving up returns an error, which the poll loop already knows how to handle:
|
||||||
|
// it says so in the log and reconnects.
|
||||||
|
func readFrame(conn io.ReadWriteCloser, buf []byte, d time.Duration) error {
|
||||||
|
deadline := time.Now().Add(d)
|
||||||
|
for n := 0; n < len(buf); {
|
||||||
|
m, err := conn.Read(buf[n:])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
n += m
|
||||||
|
if n >= len(buf) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
return fmt.Errorf("timed out after %s with %d of %d bytes", d, n, len(buf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *Client) queryStatus() (*Status, error) {
|
func (c *Client) queryStatus() (*Status, error) {
|
||||||
c.connMu.Lock()
|
c.connMu.Lock()
|
||||||
conn := c.conn
|
conn := c.conn
|
||||||
@@ -438,7 +477,7 @@ func (c *Client) queryStatus() (*Status, error) {
|
|||||||
return nil, fmt.Errorf("write status cmd: %w", err)
|
return nil, fmt.Errorf("write status cmd: %w", err)
|
||||||
}
|
}
|
||||||
buf := make([]byte, 11)
|
buf := make([]byte, 11)
|
||||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
if err := readFrame(conn, buf, frameTimeout); err != nil {
|
||||||
return nil, fmt.Errorf("read status: %w", err)
|
return nil, fmt.Errorf("read status: %w", err)
|
||||||
}
|
}
|
||||||
// Reject anything that isn't a framed reply rather than decoding garbage into
|
// Reject anything that isn't a framed reply rather than decoding garbage into
|
||||||
@@ -523,9 +562,14 @@ func (c *Client) writeCmd(pkt []byte) error {
|
|||||||
if conn == nil {
|
if conn == nil {
|
||||||
return fmt.Errorf("steppir: not connected")
|
return fmt.Errorf("steppir: not connected")
|
||||||
}
|
}
|
||||||
|
// Traced BEFORE taking the lock, not after. A command waits here for the poll
|
||||||
|
// in flight, and when that wait was unbounded the log showed no sign the
|
||||||
|
// operator had asked for anything at all — the one fact that would have named
|
||||||
|
// the fault. The line now means "asked for"; a failure to write is reported
|
||||||
|
// by the caller.
|
||||||
|
log.Printf("steppir: → % X", pkt)
|
||||||
c.ioMu.Lock()
|
c.ioMu.Lock()
|
||||||
defer c.ioMu.Unlock()
|
defer c.ioMu.Unlock()
|
||||||
log.Printf("steppir: → % X", pkt)
|
|
||||||
setDeadline(conn, 3*time.Second)
|
setDeadline(conn, 3*time.Second)
|
||||||
if _, err := conn.Write(pkt); err != nil {
|
if _, err := conn.Write(pkt); err != nil {
|
||||||
c.closeConn()
|
c.closeConn()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Every setting in OpsLog is per profile, station hardware included. A device
|
||||||
|
// started at boot and never again therefore stays on the PREVIOUS profile's
|
||||||
|
// port until Settings is opened and saved — which is how an operator running an
|
||||||
|
// SPE on COM9 for HF and another on COM10 for 6 m, one per profile, found the
|
||||||
|
// amplifier still on the old port after switching. Save is not a connect
|
||||||
|
// button.
|
||||||
|
//
|
||||||
|
// This test keeps startup and reloadAfterProfileSwitch in lockstep: anything
|
||||||
|
// started at boot must either be re-applied on a profile switch or be listed
|
||||||
|
// below with the reason it must not be. Adding a device makes the choice
|
||||||
|
// explicit instead of leaving the fifth one to be found by a user.
|
||||||
|
func TestProfileSwitchReappliesEveryStartupDevice(t *testing.T) {
|
||||||
|
src, err := os.ReadFile("app.go")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read app.go: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Started at boot but deliberately NOT re-run on a profile switch:
|
||||||
|
notPerProfile := map[string]string{
|
||||||
|
"startAllEnabledClusters": "the cluster panel reconnects itself; its servers are a global list",
|
||||||
|
"startGridCache": "a shared on-disk grid cache, not a profile's",
|
||||||
|
"startBandOpenFeed": "PSK Reporter, keyed on the operator grid it re-reads itself",
|
||||||
|
}
|
||||||
|
|
||||||
|
startup := body(t, string(src), "func (a *App) startup(ctx context.Context) {")
|
||||||
|
reload := body(t, string(src), "func (a *App) reloadAfterProfileSwitch() {")
|
||||||
|
|
||||||
|
call := regexp.MustCompile(`a\.(start[A-Z][A-Za-z]*)\b`)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, m := range call.FindAllStringSubmatch(startup, -1) {
|
||||||
|
name := m[1]
|
||||||
|
if seen[name] || notPerProfile[name] != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[name] = true
|
||||||
|
if !strings.Contains(reload, "a."+name) {
|
||||||
|
t.Errorf("%s runs at startup but not on a profile switch — the device stays on the previous profile's settings.\n"+
|
||||||
|
"Add it to reloadAfterProfileSwitch, or to notPerProfile here with the reason it must not follow the profile.", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(seen) == 0 {
|
||||||
|
t.Fatal("no startup device starters found — this test has stopped checking anything")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// body returns the source of the function opening with the given signature,
|
||||||
|
// up to the closing brace in column 0.
|
||||||
|
func body(t *testing.T, src, signature string) string {
|
||||||
|
t.Helper()
|
||||||
|
i := strings.Index(src, signature)
|
||||||
|
if i < 0 {
|
||||||
|
t.Fatalf("%q not found in app.go", signature)
|
||||||
|
}
|
||||||
|
rest := src[i+len(signature):]
|
||||||
|
if j := strings.Index(rest, "\n}"); j >= 0 {
|
||||||
|
return rest[:j]
|
||||||
|
}
|
||||||
|
return rest
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The default QSL e-mail closes with a credit line carrying the download page.
|
||||||
|
//
|
||||||
|
// It is part of the BODY TEMPLATE, deliberately: an operator who does not want
|
||||||
|
// it deletes it once, and one who already wrote their own body never sees it,
|
||||||
|
// because a stored template is returned verbatim and the default is only the
|
||||||
|
// fallback. Appending it at send time would have made it unremovable.
|
||||||
|
func TestQSLCreditIsInTheDefaultBody(t *testing.T) {
|
||||||
|
if !strings.HasSuffix(defaultQSLEmailBody, qslCredit) {
|
||||||
|
t.Errorf("the default QSL e-mail does not end with the credit line:\n%q", defaultQSLEmailBody)
|
||||||
|
}
|
||||||
|
// The body still has to be a usable template — the credit is added to it,
|
||||||
|
// not in place of it.
|
||||||
|
for _, v := range []string{"{DATE}", "{BAND}", "{MODE}", "{MYCALL}"} {
|
||||||
|
if !strings.Contains(defaultQSLEmailBody, v) {
|
||||||
|
t.Errorf("the default QSL e-mail lost %s", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The link must be the page a person can read. updateCheckURL sits beside it and
|
||||||
|
// answers JSON — sending a correspondent there is the easy mistake to make.
|
||||||
|
func TestQSLCreditLinksTheHumanPage(t *testing.T) {
|
||||||
|
if !strings.Contains(qslCredit, releasesPageURL) {
|
||||||
|
t.Errorf("credit %q does not carry the releases page", qslCredit)
|
||||||
|
}
|
||||||
|
if strings.Contains(qslCredit, "api.github.com") {
|
||||||
|
t.Errorf("credit %q points at the update API, which answers JSON to whoever clicks it", qslCredit)
|
||||||
|
}
|
||||||
|
if !strings.Contains(qslCredit, "OpsLog") {
|
||||||
|
t.Errorf("credit %q does not name OpsLog", qslCredit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -203,3 +203,34 @@ func (a *App) applyRelayAuto(freqHz int64, band string) {
|
|||||||
wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll
|
wruntime.EventsEmit(a.ctx, "station:relay_auto", nil) // nudge the Station Control UI to re-poll
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EntryBandChanged drives the band-following features from the QSO ENTRY band
|
||||||
|
// selector, for a station whose rig OpsLog does not control.
|
||||||
|
//
|
||||||
|
// Both of them — relay automatic control and the band-change outbound rows —
|
||||||
|
// hang off the rig state, and that is right when there IS a rig: changing Band
|
||||||
|
// in the entry strip pushes a QSY to it, the new state comes back through the
|
||||||
|
// CAT callback, and the relays follow from there. Without a CAT connection
|
||||||
|
// nothing is pushed and nothing comes back, so an operator with an antenna
|
||||||
|
// switch and no rig control changed band in OpsLog and watched the switch sit
|
||||||
|
// exactly where it was. Reported as automatic control not working; it was never
|
||||||
|
// told the band had changed.
|
||||||
|
//
|
||||||
|
// The frontend calls this ONLY when the CAT push did not happen, and never when
|
||||||
|
// the band or frequency lock is on — a lock means the entry is deliberately
|
||||||
|
// decoupled from the rig (logging an old contact off-frequency), and moving an
|
||||||
|
// antenna to match a QSO from last year is worse than doing nothing.
|
||||||
|
//
|
||||||
|
// The frequency is passed as unknown, deliberately: a band selector gives a
|
||||||
|
// band and nothing else, and rules written on a frequency RANGE must be left
|
||||||
|
// alone rather than evaluated against a made-up dial reading.
|
||||||
|
func (a *App) EntryBandChanged(band string) {
|
||||||
|
band = strings.TrimSpace(band)
|
||||||
|
if band == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.relayAutoOn.Load() {
|
||||||
|
go a.applyRelayAuto(0, band)
|
||||||
|
}
|
||||||
|
a.emitBandChangeTrigger(band, "", 0)
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The generic HTTP board must actually be built from its URLs.
|
||||||
|
//
|
||||||
|
// It was not: buildDeviceDriver had no case for it, so it fell through to the
|
||||||
|
// WebSwitch driver. The board was configured, saved, listed — and every command
|
||||||
|
// went to a WebSwitch address that did not exist, which also left the device
|
||||||
|
// reported as offline and every relay button on the panel greyed out. Nothing in
|
||||||
|
// the UI said so; the URLs were simply never sent.
|
||||||
|
func TestGenericHTTPBoardSendsItsConfiguredURL(t *testing.T) {
|
||||||
|
hit := make(chan string, 4)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
hit <- r.URL.Path
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// Host deliberately empty — this board's whole address is in the URLs.
|
||||||
|
d := StationDevice{
|
||||||
|
Type: "httpgen",
|
||||||
|
Channels: 2,
|
||||||
|
OnURLs: []string{srv.URL + "/relay1/on", srv.URL + "/relay2/on"},
|
||||||
|
OffURLs: []string{srv.URL + "/relay1/off", srv.URL + "/relay2/off"},
|
||||||
|
}
|
||||||
|
if err := buildDeviceDriver(d).Set(context.Background(), 2, true); err != nil {
|
||||||
|
t.Fatalf("Set: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case got := <-hit:
|
||||||
|
if got != "/relay2/on" {
|
||||||
|
t.Errorf("board was asked for %q, want /relay2/on", got)
|
||||||
|
}
|
||||||
|
case <-time.After(3 * time.Second):
|
||||||
|
t.Fatal("the configured URL was never requested — the board is not using its own driver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing a URL must rebuild the driver. The cached one is keyed by the device's
|
||||||
|
// configuration, and the URLs used not to be part of that key: correcting a typo
|
||||||
|
// handed back the driver still holding the old address, so the fix looked like it
|
||||||
|
// had done nothing until OpsLog was restarted.
|
||||||
|
func TestGenericHTTPBoardKeyCoversItsURLs(t *testing.T) {
|
||||||
|
a := StationDevice{Type: "httpgen", Channels: 2, OnURLs: []string{"http://box/a"}}
|
||||||
|
b := StationDevice{Type: "httpgen", Channels: 2, OnURLs: []string{"http://box/b"}}
|
||||||
|
if deviceKey(a) == deviceKey(b) {
|
||||||
|
t.Error("two boards with different URLs share a cache key — an edited URL would not take effect")
|
||||||
|
}
|
||||||
|
c := a
|
||||||
|
c.OnPat = "http://box/{relay}"
|
||||||
|
if deviceKey(a) == deviceKey(c) {
|
||||||
|
t.Error("changing the ON pattern left the cache key unchanged")
|
||||||
|
}
|
||||||
|
}
|
||||||
+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.25.4"
|
appVersion = "0.25.6"
|
||||||
|
|
||||||
// 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.
|
||||||
|
|||||||
+38
-11
@@ -102,27 +102,54 @@ func (a *App) udpTriggerBandChange(s cat.RigState) {
|
|||||||
if a.udp == nil {
|
if a.udp == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
band := strings.ToLower(strings.TrimSpace(s.Band))
|
if !s.Connected {
|
||||||
if band == "" || !s.Connected {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lastTriggerBandMu.Lock()
|
a.emitBandChangeTrigger(s.Band, s.Mode, s.FreqHz)
|
||||||
changed := band != lastTriggerBand
|
}
|
||||||
if changed {
|
|
||||||
lastTriggerBand = band
|
// noteBandChange normalises a band and reports whether it is a NEW one.
|
||||||
|
//
|
||||||
|
// The state is shared by both sources on purpose. The rig reports a band change
|
||||||
|
// and so does the entry strip, and on a station that has both, one QSY produces
|
||||||
|
// both — an antenna switch must be commanded once, not twice.
|
||||||
|
func noteBandChange(raw string) (string, bool) {
|
||||||
|
band := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
if band == "" {
|
||||||
|
return "", false
|
||||||
}
|
}
|
||||||
lastTriggerBandMu.Unlock()
|
lastTriggerBandMu.Lock()
|
||||||
|
defer lastTriggerBandMu.Unlock()
|
||||||
|
if band == lastTriggerBand {
|
||||||
|
return band, false
|
||||||
|
}
|
||||||
|
lastTriggerBand = band
|
||||||
|
return band, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitBandChangeTrigger fires the band-change rows, once per NEW band.
|
||||||
|
//
|
||||||
|
// Split out of the rig-state path because the rig is not the only thing that
|
||||||
|
// changes band: a station with no CAT link changes it in the QSO entry strip,
|
||||||
|
// and that is just as much a band change to the antenna switch on the other end
|
||||||
|
// of the message. The de-duplication is shared, so the two sources cannot
|
||||||
|
// double-fire between them.
|
||||||
|
func (a *App) emitBandChangeTrigger(rawBand, mode string, freqHz int64) {
|
||||||
|
if a.udp == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
band, changed := noteBandChange(rawBand)
|
||||||
if !changed {
|
if !changed {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
f := map[string]string{
|
f := map[string]string{
|
||||||
"band": band,
|
"band": band,
|
||||||
"band_m": bandMetres(band),
|
"band_m": bandMetres(band),
|
||||||
"mode": s.Mode,
|
"mode": mode,
|
||||||
}
|
}
|
||||||
if s.FreqHz > 0 {
|
if freqHz > 0 {
|
||||||
f["freq_hz"] = strconv.FormatInt(s.FreqHz, 10)
|
f["freq_hz"] = strconv.FormatInt(freqHz, 10)
|
||||||
f["freq_mhz"] = fmt.Sprintf("%.6f", float64(s.FreqHz)/1e6)
|
f["freq_mhz"] = fmt.Sprintf("%.6f", float64(freqHz)/1e6)
|
||||||
}
|
}
|
||||||
a.udp.EmitTrigger(udp.TriggerBandChange, f)
|
a.udp.EmitTrigger(udp.TriggerBandChange, f)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ import (
|
|||||||
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
// build (the exe lives there; source stays on Gitea). Adjust the repo if needed.
|
||||||
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
|
const updateCheckURL = "https://api.github.com/repos/GregTroar/OpsLog/releases/latest"
|
||||||
|
|
||||||
|
// releasesPageURL is the same release, for people rather than for the updater:
|
||||||
|
// the API address above answers JSON, so it is not something to put in front of
|
||||||
|
// an operator who followed a link out of a QSL e-mail.
|
||||||
|
const releasesPageURL = "https://github.com/GregTroar/OpsLog/releases/latest"
|
||||||
|
|
||||||
// UpdateInfo is the result of the version check.
|
// UpdateInfo is the result of the version check.
|
||||||
type UpdateInfo struct {
|
type UpdateInfo struct {
|
||||||
Current string `json:"current"` // this build's version (appVersion)
|
Current string `json:"current"` // this build's version (appVersion)
|
||||||
@@ -367,8 +372,8 @@ func cleanupOldUpdateBinary() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = os.Remove(exe + ".old") // the old fixed name
|
_ = os.Remove(exe + ".old") // the old fixed name
|
||||||
_ = os.Remove(exe + ".new") // a deferred swap that has been applied
|
_ = os.Remove(exe + ".new") // a deferred swap that has been applied
|
||||||
matches, err := filepath.Glob(exe + ".old-*")
|
matches, err := filepath.Glob(exe + ".old-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ Over TCP / GSCP — a docked **A/B antenna-switch** widget:
|
|||||||
|
|
||||||
Configure the host / password in Settings → Antenna Genius.
|
Configure the host / password in Settings → Antenna Genius.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Relay boards (Station Control)
|
## Relay boards (Station Control)
|
||||||
|
|
||||||
Relay boards for antenna/accessory switching, with named buttons in Station
|
Relay boards for antenna/accessory switching, with named buttons in Station
|
||||||
@@ -68,11 +70,139 @@ Control:
|
|||||||
web page — a factory board needs neither.
|
web page — a factory board needs neither.
|
||||||
- **Denkovi** USB (4/8 relays, FT245) and generic **CH340 / LCUS** USB-serial
|
- **Denkovi** USB (4/8 relays, FT245) and generic **CH340 / LCUS** USB-serial
|
||||||
boards.
|
boards.
|
||||||
|
- **HTTP relay (home-made / generic)** — see below.
|
||||||
|
|
||||||
A Test-connection button and detection feedback live in the setup panel.
|
### HTTP relay (home-made / generic)
|
||||||
|
|
||||||
|
For any box that switches on a plain HTTP GET: an ESP8266 with a web page, a
|
||||||
|
Shelly, a Sonoff on third-party firmware, a qro.cz board, a home-brew switch.
|
||||||
|
Pick the relay count, name the relays, and give the addresses.
|
||||||
|
|
||||||
|
There is **no Host field and no connection test**. This device type has no
|
||||||
|
address of its own — each relay carries its own full URL, and they need not
|
||||||
|
even be on the same box. Nothing is read back either: OpsLog remembers what it
|
||||||
|
commanded, so after a restart each managed relay is re-commanded once.
|
||||||
|
|
||||||
|
**Two ways to give the addresses**, and you can mix them.
|
||||||
|
|
||||||
|
**A — one URL per relay.** The reason this type exists: a hand-made switch
|
||||||
|
often has addresses with nothing in common between channels.
|
||||||
|
|
||||||
|
| Relay | ON URL | OFF URL |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | `http://192.168.1.9/FF0101` | `http://192.168.1.9/FF0100` |
|
||||||
|
| 2 | `http://192.168.1.9/relay2/on` | `http://192.168.1.9/relay2/off` |
|
||||||
|
|
||||||
|
**B — one pattern for the whole board**, with the relay number filled in:
|
||||||
|
|
||||||
|
```
|
||||||
|
ON pattern: http://192.168.1.9/relay?n={relay}&state=on
|
||||||
|
OFF pattern: http://192.168.1.9/relay?n={relay}&state=off
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Substitutions
|
||||||
|
|
||||||
|
| In a URL or a pattern | Becomes |
|
||||||
|
|---|---|
|
||||||
|
| `{relay}` | the relay number, 1-based |
|
||||||
|
| `{relay-1}` | the relay number counting **from zero** — for boards whose channels are 0, 1, 2… |
|
||||||
|
| `{value}` | **that relay's label**, from the *Relay labels* row below |
|
||||||
|
|
||||||
|
`http` may be left off — it is assumed. `https` works and is used as typed.
|
||||||
|
|
||||||
|
`{value}` is for a switch addressed by antenna name rather than by channel
|
||||||
|
number. Name relay 1 **Ant1**, and:
|
||||||
|
|
||||||
|
```
|
||||||
|
Per-relay ON URL for relay 1: http://10.10.10.100/relay?on={value}
|
||||||
|
Sent: http://10.10.10.100/relay?on=Ant1
|
||||||
|
```
|
||||||
|
|
||||||
|
Rename the antenna and the address follows it — the name on the button and the
|
||||||
|
name on the wire cannot drift apart, because they are the same text. A label
|
||||||
|
with a space or an accent is percent-encoded automatically. A relay whose URL
|
||||||
|
uses `{value}` **must have a label**; the editor warns you while you type.
|
||||||
|
|
||||||
|
A per-relay URL always wins over the pattern, so you can write a pattern for
|
||||||
|
the seven ordinary channels and one full URL for the odd one out.
|
||||||
|
|
||||||
|
### Manual switching
|
||||||
|
|
||||||
|
Every relay is a button in **Station Control** — click to toggle. The generic
|
||||||
|
HTTP board is never greyed out (there is no connection to wait for); the other
|
||||||
|
types are enabled once the board answers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Relay automatic control
|
||||||
|
|
||||||
|
Settings → **Relay automatic control** turns relays by frequency or band, the
|
||||||
|
equivalent of PstRotator's automatic control. Each relay of each configured
|
||||||
|
board gets a rule:
|
||||||
|
|
||||||
|
| Mode | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| **Off** | OpsLog never touches this relay. |
|
||||||
|
| **Band** | ON while the current band is one of the bands you tick. |
|
||||||
|
| **Frequency** | ON while the frequency is inside a kHz range you type. |
|
||||||
|
|
||||||
|
Example — a three-way antenna switch on a KMTronic board:
|
||||||
|
|
||||||
|
| Relay | Label | Mode | Setting |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 1 | Beam 20-10 | Band | 20m, 17m, 15m, 12m, 10m |
|
||||||
|
| 2 | 40 m dipole | Band | 40m |
|
||||||
|
| 3 | 80 m inverted-V | Band | 80m, 160m |
|
||||||
|
| 4 | 6 m preamp | Frequency | 50000 – 54000 kHz |
|
||||||
|
|
||||||
|
Rules to know:
|
||||||
|
|
||||||
|
- A relay is only switched when it is **not already** in the wanted position.
|
||||||
|
On the first evaluation after launch OpsLog reads the boards' live state, so
|
||||||
|
a relay already correct is left alone rather than clunking.
|
||||||
|
- An **unknown** frequency or band changes nothing. When CAT disconnects the
|
||||||
|
frequency reads zero, and treating that as "out of range" would switch every
|
||||||
|
relay off.
|
||||||
|
- The rules follow **your radio**. Changing band in the QSO entry strip drives
|
||||||
|
the rig, the rig reports back, and the relays follow. **Without a CAT
|
||||||
|
connection** the Band selector itself is the band change, and the relays
|
||||||
|
follow that instead — unless the band or frequency 🔒 is on, which means the
|
||||||
|
entry is deliberately decoupled from the rig (logging an old contact) and no
|
||||||
|
hardware should move.
|
||||||
|
|
||||||
|
For a switch driven by a URL or a UDP message rather than by a relay board, see
|
||||||
|
[[Connections]].
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bench power supply
|
||||||
|
|
||||||
|
Settings → **Power supply**. A programmable supply on a serial port speaking
|
||||||
|
**Modbus RTU** — BSIDE, Wanptek and the other supplies that use function codes
|
||||||
|
03 and 06.
|
||||||
|
|
||||||
|
Give the COM port. The other two fields are the factory settings and only need
|
||||||
|
changing if you changed them on the supply: **9600 baud**, **Modbus address 1**
|
||||||
|
(8 data bits, no parity, 1 stop bit).
|
||||||
|
|
||||||
|
A card appears in **Station Control**: an **Output** on/off button, the measured
|
||||||
|
volts / amps / watts, and — in small type — the voltage and current the supply
|
||||||
|
is *set* to. That pairing is the useful part: `13.80 V set` beside `0.020 A`
|
||||||
|
says at a glance that the supply is on and the radio is drawing nothing.
|
||||||
|
|
||||||
|
> **OpsLog only ever writes the output on/off.** The supply's register map also
|
||||||
|
> exposes the voltage and current set points and the three protection trip
|
||||||
|
> levels, and none of them belong to a logbook — a wrong value there is 30 V
|
||||||
|
> where a radio expected 13.8. They are read and displayed, never changed.
|
||||||
|
> Set them on the supply's own front panel.
|
||||||
|
|
||||||
|
If the protection has tripped, the card says so with the supply's status word.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Station Control
|
## Station Control
|
||||||
|
|
||||||
The Station Control tab is a dashboard of fixed-width panels that wrap to fill
|
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 window — amplifier(s), tuner, relays, rotator, power supply, Ultrabeam…
|
||||||
the grip on its left edge to reorder; the order is remembered.
|
Drag a panel by the grip on its left edge to reorder; the order is remembered.
|
||||||
|
A column-count selector (Auto / 1–6) caps how many sit side by side.
|
||||||
|
|||||||
@@ -163,3 +163,40 @@ built-in award without fear of a future release overwriting it.
|
|||||||
|
|
||||||
**Rescan** re-pulls the logbook and recomputes — it picks up fresh LoTW / QRZ /
|
**Rescan** re-pulls the logbook and recomputes — it picks up fresh LoTW / QRZ /
|
||||||
eQSL confirmations (see [[QSL Management]]).
|
eQSL confirmations (see [[QSL Management]]).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## US counties (CQ USA-CA)
|
||||||
|
|
||||||
|
A county award needs a county on every US QSO, and most logs do not have one:
|
||||||
|
neither the callsign nor the prefix carries it, and few operators send it.
|
||||||
|
|
||||||
|
**Settings → US counties** downloads the FCC **ULS** database — every US
|
||||||
|
amateur licence with its address — and builds a local index of callsign →
|
||||||
|
county + grid. It is a few hundred megabytes to fetch and takes a few minutes;
|
||||||
|
once built it works offline and needs no key.
|
||||||
|
|
||||||
|
With it downloaded:
|
||||||
|
|
||||||
|
- A **new US QSO** gets its county (and grid, if you have none) filled in
|
||||||
|
automatically — **only where the field is empty**. A value you or QRZ
|
||||||
|
supplied is never overwritten: the ZIP-derived county is about 98 % right and
|
||||||
|
the one you logged is usually better.
|
||||||
|
- **Right-click → Update US county from the ULS database** re-derives the
|
||||||
|
county of the selected contacts and **replaces** what is stored. That is the
|
||||||
|
deliberate opposite of the automatic pass, and it is the way to correct
|
||||||
|
counties already in the log — see the Connecticut note below. Contacts
|
||||||
|
outside the US, and callsigns the database does not hold, are left alone.
|
||||||
|
The entry only appears once the database is downloaded.
|
||||||
|
|
||||||
|
### Connecticut
|
||||||
|
|
||||||
|
Connecticut **abolished its eight counties** for statistical purposes in 2022;
|
||||||
|
the Census Bureau replaced them with nine **planning regions**. CQ USA-CA still
|
||||||
|
uses the old county names, so OpsLog maps a Connecticut address back to its
|
||||||
|
legal county.
|
||||||
|
|
||||||
|
If your database was built by an older OpsLog, the Settings panel says so and
|
||||||
|
offers a refresh — a download date alone cannot show it, because a database
|
||||||
|
fetched yesterday by an older version still holds the wrong Connecticut
|
||||||
|
counties. After refreshing, use the right-click update on your CT contacts.
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
# Connections
|
||||||
|
|
||||||
|
Settings → **Connections** is where OpsLog talks to other programs and to
|
||||||
|
hardware over the network: what it **listens** to, what it **sends**, and
|
||||||
|
messages you write yourself.
|
||||||
|
|
||||||
|
It was called *UDP* until it grew a second transport — a row can now send an
|
||||||
|
**HTTP GET** instead of a datagram, which is what most home-made antenna
|
||||||
|
switches understand.
|
||||||
|
|
||||||
|
Each row has a name, a direction, a service type, a port, and an on/off switch.
|
||||||
|
**Everything sent and received is written to the application log**, so a row
|
||||||
|
that does not work can be diagnosed rather than guessed at — see
|
||||||
|
[[Troubleshooting]] for where the log lives.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Inbound — what OpsLog listens to
|
||||||
|
|
||||||
|
| Service | Sends it | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| **WSJT-X / JTDX / MSHV** | those programs, port 2237 | Log their QSOs automatically, follow the DX call being worked, and show their decodes. Multicast is normal here. |
|
||||||
|
| **ADIF over UDP** | JTAlert, GridTracker, FLDIGI | A text ADIF record per logged QSO. |
|
||||||
|
| **N1MM Logger+** | N1MM | Its XML contact record. |
|
||||||
|
| **Remote callsign** | DXHunter and similar | A callsign — and optionally a frequency and mode — to load into the entry form and tune to. |
|
||||||
|
|
||||||
|
### Multicast or unicast?
|
||||||
|
|
||||||
|
WSJT-X and MSHV normally broadcast to a **multicast group** (usually
|
||||||
|
`239.255.0.1`) so several programs can hear them at once. Tick *Multicast* and
|
||||||
|
give the group. A program sending to one address only needs unicast — leave it
|
||||||
|
unticked.
|
||||||
|
|
||||||
|
> **Do not put an inbound row and an outbound row on the same port.** OpsLog
|
||||||
|
> then receives its own messages, and something it publishes can come back as a
|
||||||
|
> command. It says so in the log when it spots the arrangement:
|
||||||
|
> *"X sends on port 2241 and Y listens on it — OpsLog will receive its own
|
||||||
|
> messages there; give one of the two another port."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Outbound — what OpsLog sends
|
||||||
|
|
||||||
|
| Service | Format | Fires on |
|
||||||
|
|---|---|---|
|
||||||
|
| **ADIF message** | a plain ADIF record | each QSO logged |
|
||||||
|
| **WSJT-X logged QSO** | the same ADIF wrapped in a WSJT-X datagram | each QSO logged |
|
||||||
|
| **PstRotator frequency** | `<PST><FREQUENCY>` | frequency change |
|
||||||
|
| **N1MM RadioInfo** | N1MM's RadioInfo XML | frequency or mode change |
|
||||||
|
| **Custom message** | whatever you write | a trigger you pick |
|
||||||
|
|
||||||
|
### Which one for another logger?
|
||||||
|
|
||||||
|
If a logger says it accepts "WSJT-X UDP", it wants the **WSJT-X logged QSO**
|
||||||
|
row — it listens on the WSJT-X interface and silently discards a bare ADIF
|
||||||
|
record. Logger32 is the usual case. A logger that documents a plain ADIF
|
||||||
|
listener wants the **ADIF message** row instead. They are two rows because they
|
||||||
|
are two different things on the wire; enable the one your logger asks for, not
|
||||||
|
both, or the QSO arrives twice.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Custom messages
|
||||||
|
|
||||||
|
The general case: **you choose when it fires, what it says, and how it leaves.**
|
||||||
|
This is how a home-made antenna switch, a relay box or a home-automation server
|
||||||
|
gets told what the station is doing.
|
||||||
|
|
||||||
|
### 1. Pick the trigger
|
||||||
|
|
||||||
|
| Trigger | Fires when |
|
||||||
|
|---|---|
|
||||||
|
| **QSO logged** | a contact is saved |
|
||||||
|
| **Band change** | the radio changes band — or, on a station with no CAT, the Band selector in the entry strip |
|
||||||
|
| **Rotator command** | the antenna is told to turn (compass, SP/LP buttons, a spot click) |
|
||||||
|
| **Lookup done** | a callbook lookup returns |
|
||||||
|
|
||||||
|
### 2. Write the message
|
||||||
|
|
||||||
|
Anything in `{braces}` is replaced. What is available depends on the trigger:
|
||||||
|
|
||||||
|
| Trigger | Placeholders |
|
||||||
|
|---|---|
|
||||||
|
| QSO logged | `{call}` `{band}` `{band_m}` `{mode}` `{grid}` `{name}` `{country}` `{rst_s}` `{rst_r}` `{comment}` `{date}` `{time}` `{freq_hz}` `{freq_mhz}` `{dxcc}` |
|
||||||
|
| Band change | `{band}` `{band_m}` `{mode}` `{freq_hz}` `{freq_mhz}` |
|
||||||
|
| Rotator command | `{az}` `{el}` `{path}` (`SP`, `LP` or empty) |
|
||||||
|
| Lookup done | `{call}` `{name}` `{grid}` `{country}` `{qth}` `{state}` `{dxcc}` |
|
||||||
|
|
||||||
|
`{band}` is `20m`; `{band_m}` is just `20`, because an antenna switch usually
|
||||||
|
wants the number and nothing else.
|
||||||
|
|
||||||
|
### 3. Choose the transport
|
||||||
|
|
||||||
|
- **UDP** — a datagram to an address and port. You choose the line ending.
|
||||||
|
- **URL** — an HTTP GET. Values are URL-encoded automatically.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
**Turn an antenna switch on every band change, over HTTP:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Trigger: Band change
|
||||||
|
Transport: URL
|
||||||
|
URL: http://192.168.1.50/set?band={band_m}
|
||||||
|
```
|
||||||
|
20 m → `http://192.168.1.50/set?band=20`
|
||||||
|
|
||||||
|
**Tell a rotator display where the antenna is going:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Trigger: Rotator command
|
||||||
|
Transport: UDP → 192.168.1.77:8100
|
||||||
|
Message: <AZIMUT>{az}</AZIMUT><PATH>{path}</PATH>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Announce each QSO to a shack dashboard:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Trigger: QSO logged
|
||||||
|
Transport: URL
|
||||||
|
URL: http://homeassistant.local:8123/api/webhook/qso?call={call}&band={band}&mode={mode}
|
||||||
|
```
|
||||||
|
|
||||||
|
> Credentials in a URL are sent as typed — this is meant for a LAN. Passwords
|
||||||
|
> are **redacted in the log** so a log file can be shared safely.
|
||||||
|
|
||||||
|
### When a band change is a band change
|
||||||
|
|
||||||
|
The Band-change trigger follows **the radio**, because an antenna switch should
|
||||||
|
follow the radio and not what is being typed. Changing band in the entry strip
|
||||||
|
drives the rig, the rig reports the new band, and the trigger fires from that.
|
||||||
|
|
||||||
|
On a station whose rig OpsLog does not control there is nothing to report back,
|
||||||
|
so the **Band selector itself** counts as the band change. The 🔒 lock on Band
|
||||||
|
or Frequency suppresses it: a lock means the entry is deliberately decoupled
|
||||||
|
from the rig, and nothing should move for a contact logged from last year.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Diagnosing a row that does nothing
|
||||||
|
|
||||||
|
The log names every message. In order, check:
|
||||||
|
|
||||||
|
1. **Is the row enabled?** The list shows it.
|
||||||
|
2. **`udp: Reload done — N server(s) running`** at startup, and one
|
||||||
|
`cfg id=… name=… dir=… service=… port=…` line per row. A row that failed to
|
||||||
|
start is listed with its error.
|
||||||
|
3. **Outbound:** `udp: [NAME] sent N bytes to ADDRESS (service)` for a datagram,
|
||||||
|
or the URL for an HTTP row.
|
||||||
|
4. **A message that renders empty is skipped** and the log says so — usually a
|
||||||
|
placeholder that the trigger does not provide.
|
||||||
|
5. **`a QSO was logged but no outbound "ADIF message" row is enabled`** — the
|
||||||
|
answer to "why does my other logger get nothing".
|
||||||
|
|
||||||
|
See also: [[Amplifiers and Switches]] for relay boards and their own automatic
|
||||||
|
control, and [[DX Cluster and Spots]] for spot sources.
|
||||||
@@ -56,3 +56,20 @@ A **DXHunter** remote-call UDP packet fills the callsign, and when it carries
|
|||||||
[[Settings and Data]] for the UDP configuration.
|
[[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.
|
||||||
|
|
||||||
|
## When the list looks empty
|
||||||
|
|
||||||
|
If spots are arriving and none are shown, the panel says so, names the filters
|
||||||
|
responsible and offers a button to clear them all. The two easiest to forget are
|
||||||
|
the **band and mode locks** (🔒), because they follow the radio rather than a
|
||||||
|
click — with the rig on 20 m SSB and both locked, a hundred spots on other bands
|
||||||
|
show nothing.
|
||||||
|
|
||||||
|
Filter selections are remembered between sessions; a fresh installation starts
|
||||||
|
with none set.
|
||||||
|
|
||||||
|
## Spotting
|
||||||
|
|
||||||
|
When you send a spot, the comment carries the mode and then the **award
|
||||||
|
references you have assigned** to that contact — the ones on the QSO, not the
|
||||||
|
ones OpsLog would compute for it.
|
||||||
|
|||||||
@@ -23,14 +23,41 @@ portable contacts. No API key.
|
|||||||
## Rotators
|
## Rotators
|
||||||
|
|
||||||
An azimuthal-equidistant **click-to-turn** compass; the current heading and
|
An azimuthal-equidistant **click-to-turn** compass; the current heading and
|
||||||
target are shown. Three rotator types (Settings → Rotator):
|
target are shown. The needle follows a turning antenna smoothly — OpsLog polls
|
||||||
|
faster while the position is changing and backs off once it is parked.
|
||||||
|
|
||||||
- **PstRotator** — via its UDP interface.
|
**Two rotors** can be configured (two towers, or a Rotator Genius driving two).
|
||||||
- **microHAM ARCO** — native, over LAN or USB: set the ARCO's CONTROL PROTOCOL
|
A Rotor 1 / 2 selector appears on the compass; OpsLog turns and displays the
|
||||||
to *Yaesu GS-232A*.
|
one you pick.
|
||||||
- **GS-232A (generic)** — any GS-232A controller, ERC (Easy Rotor Control)
|
|
||||||
included; serial speed selectable. Set an ERC to GS-232 emulation, not
|
### Types (Settings → Rotator)
|
||||||
Hy-Gain DCU-1.
|
|
||||||
|
| Type | Connection | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| **PstRotator** | UDP | Enable PstRotator's UDP listener (Setup → Communication → UDP). |
|
||||||
|
| **Rotator Genius** (4O3A) | TCP, port 9006 | Native. *Rotator #* picks which of the two the box drives; *Two rotors* adds the second as its own rotor. |
|
||||||
|
| **microHAM ARCO / GS-232A** | LAN or USB | Set the controller's CONTROL PROTOCOL to *Yaesu GS-232A*. An **ERC** must be in GS-232 emulation, **not** Hy-Gain DCU-1. |
|
||||||
|
| **Hy-Gain DCU-1** | COM port or serial-over-IP | RotorCard DXA, Idiom Press Rotor-EZ, Green Heron. Azimuth only. A DCU-1 is 4800 baud; others may differ — match the controller. |
|
||||||
|
| **SPID / AlfaSpid** | COM port | Native, so PstRotator is not needed in between. See below. |
|
||||||
|
|
||||||
|
### SPID / AlfaSpid
|
||||||
|
|
||||||
|
Pick the dialect to match the controller — they are not interchangeable:
|
||||||
|
|
||||||
|
- **Rot2Prog** — RAS, BIG-RAS/HR, MD-01, MD-02. Azimuth **and** elevation.
|
||||||
|
600 baud.
|
||||||
|
- **Rot1Prog** — the older azimuth-only controllers (RAK, RAU). 1200 baud.
|
||||||
|
|
||||||
|
The two use different frame lengths, so a wrong choice shows up at once: the
|
||||||
|
**Test** button reports a reply of the wrong length rather than leaving you to
|
||||||
|
find out when the antenna turns oddly.
|
||||||
|
|
||||||
|
Two towers means two controllers, each on its own COM port and each a separate
|
||||||
|
rotor in OpsLog.
|
||||||
|
|
||||||
|
> **The Test button on a SPID, an ARCO or a DCU-1 only READS the heading** — it
|
||||||
|
> confirms the port, the speed and the dialect without moving anything, and
|
||||||
|
> says so. (PstRotator and a Rotator Genius are instead sent a move to 0°.)
|
||||||
|
|
||||||
## Motorized antennas (Ultrabeam / SteppIR)
|
## Motorized antennas (Ultrabeam / SteppIR)
|
||||||
|
|
||||||
|
|||||||
@@ -51,3 +51,13 @@ This is also what powers [[Multi-Operator Live Status]].
|
|||||||
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 settings and local logbook. See
|
copying the `data/` folder backs up your settings and local logbook. See
|
||||||
[[Settings and Data]].
|
[[Settings and Data]].
|
||||||
|
|
||||||
|
## What follows the active profile
|
||||||
|
|
||||||
|
Every setting is per profile, station hardware included. Switching profile
|
||||||
|
reconnects the **CAT link**, the **amplifiers**, the **motorized antenna**, the
|
||||||
|
**Antenna Genius** and the **tuner** using the new profile's settings — so an
|
||||||
|
operator with, say, an SPE on COM9 for HF and another on COM10 for 6 m gets the
|
||||||
|
right one by switching profile, with no visit to Settings.
|
||||||
|
|
||||||
|
The Winkeyer is deliberately left alone: you connect it explicitly.
|
||||||
|
|||||||
@@ -34,6 +34,38 @@ auto-refreshes the **award** stats.
|
|||||||
- **LoTW upload** goes through **TQSL** (ARRL's signer) — set the TQSL path and
|
- **LoTW upload** goes through **TQSL** (ARRL's signer) — set the TQSL path and
|
||||||
station location in Settings.
|
station location in Settings.
|
||||||
|
|
||||||
|
## Paper QSL: sent, received, and how
|
||||||
|
|
||||||
|
The QSL Manager's paper form sets four things on a batch at once — the sent
|
||||||
|
status and date, the received status and date — plus **how the card travelled**,
|
||||||
|
in each direction.
|
||||||
|
|
||||||
|
Two ADIF fields are involved and they are **not** the same thing:
|
||||||
|
|
||||||
|
| Field | Holds | Example |
|
||||||
|
|---|---|---|
|
||||||
|
| `QSL_VIA` | the **manager's callsign** | `M0OXO` |
|
||||||
|
| `QSL_SENT_VIA` / `QSL_RCVD_VIA` | **how the card travelled** — an enumeration | `B` bureau · `D` direct · `E` electronic |
|
||||||
|
|
||||||
|
Some loggers write the routing into the manager field, and OpsLog's own older
|
||||||
|
versions wrote the words "Bureau" / "Direct" / "Electronic" there too. If your
|
||||||
|
log has a mixture, OpsLog offers a **repair** — it tells you how many QSOs are
|
||||||
|
affected **before** it changes anything, and it only moves values that are
|
||||||
|
unmistakably a routing method (a manager is a callsign, never one of those six
|
||||||
|
words).
|
||||||
|
|
||||||
|
On **import**, the two fields are kept apart and nothing is invented: a file
|
||||||
|
without a routing gives an empty routing. If you see `E` on every imported QSO
|
||||||
|
it came from the file — Log4OM writes `QSL_SENT_VIA:E` on every record whether
|
||||||
|
a card was ever sent. To clear them, filter the imported QSOs and use
|
||||||
|
**Bulk edit field** with an empty value ([[Recent QSOs and Filters]]).
|
||||||
|
|
||||||
|
## Confirmed means Y or V
|
||||||
|
|
||||||
|
A LoTW confirmation can come back as **V** (verified) rather than **Y**. Both
|
||||||
|
count as confirmed — in the awards, the band/mode matrix, the slot statistics,
|
||||||
|
the row colours and the QSL Info tab alike.
|
||||||
|
|
||||||
## E-mail eQSL
|
## E-mail eQSL
|
||||||
|
|
||||||
Right-click a QSO → **Send eQSL by e-mail** via the configured SMTP account. The
|
Right-click a QSO → **Send eQSL by e-mail** via the configured SMTP account. The
|
||||||
|
|||||||
+117
-10
@@ -2,29 +2,64 @@
|
|||||||
|
|
||||||
## The Recent QSOs grid
|
## The Recent QSOs grid
|
||||||
|
|
||||||
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 everything
|
||||||
(see [[Logging Basics]]). Columns are configurable — click **Columns** to choose
|
else (see [The right-click menu](#the-right-click-menu) below). Columns are
|
||||||
which are visible; widths, order and hidden columns are saved (per profile),
|
configurable — click **Columns** to choose which are visible; widths, order and
|
||||||
with a separate layout for the narrower Main-tab pane.
|
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 /
|
- The selected-row count is always visible at the top, and a **Select all /
|
||||||
Unselect all** button takes every displayed row.
|
Unselect all** button takes every displayed row.
|
||||||
- QSL and upload status columns are coloured: **Y** green, **N** red, **R**
|
- QSL and upload status columns are coloured: **Y** green, **N** red, **R**
|
||||||
blue.
|
blue, **V** (verified) green — a LoTW contact marked *V* counts as confirmed
|
||||||
|
everywhere, like *Y*.
|
||||||
- A **Distance (km)** column (also in Worked-before) is computed from the QSO's
|
- A **Distance (km)** column (also in Worked-before) is computed from the QSO's
|
||||||
own locator pair.
|
own locator pair.
|
||||||
- There are **no per-column header filters** — they only ever searched the rows
|
- 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
|
on screen, which made a QSO further back in the log look missing. Use the
|
||||||
advanced filter, which queries the whole logbook.
|
advanced filter, which queries the whole logbook.
|
||||||
|
|
||||||
|
A QSO logged from **WSJT-X, MSHV, JTDX or a net** appears here immediately, not
|
||||||
|
when its auto-upload eventually goes out. That matters if you use a *delayed*
|
||||||
|
upload to leave yourself time to correct a contact before it is sent.
|
||||||
|
|
||||||
### Award columns
|
### Award columns
|
||||||
|
|
||||||
Each defined award can show a column with the reference the QSO counts for.
|
Each defined award can show a column with the reference the QSO counts for.
|
||||||
These columns are **hidden by default** and opt-in from the Columns picker.
|
These columns are **hidden by default** and opt-in from the Columns picker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## The right-click menu
|
||||||
|
|
||||||
|
Select one or more rows, right-click, and the menu acts on **the whole
|
||||||
|
selection**. Right-clicking a row that is not selected selects it first, so a
|
||||||
|
single right-click on one QSO always does what it looks like it will.
|
||||||
|
|
||||||
|
| Entry | What it does |
|
||||||
|
|---|---|
|
||||||
|
| **Fix country & zones from cty.dat** | Re-derives DXCC, continent, CQ and ITU zones from the prefix database. Use after a cty.dat update, or on QSOs imported without zones. |
|
||||||
|
| **Update from the callsign databases** | Re-queries QRZ.com / HamQTH per QSO. A network round trip each — a progress bar appears. |
|
||||||
|
| **Update from ClubLog (exceptions)** | Applies Club Log's exception list: callsigns whose real entity is not what the prefix says. |
|
||||||
|
| **Update US county from the ULS database** | Re-derives the county of US contacts and **replaces** what is stored. Only appears once the county database is downloaded — see [[Awards]]. |
|
||||||
|
| **Send OpsLog QSL by e-mail** | Renders the [[QSL Card Designer]] card for that QSO and mails it. |
|
||||||
|
| **Bulk edit field…** | One field, one value, on every selected QSO. Leaving the value **empty clears** the field. |
|
||||||
|
| **Export selected to ADIF / Cabrillo** | Just the rows you picked. |
|
||||||
|
| **Export selected — choose fields…** | Same, but you pick which ADIF fields go in the file. |
|
||||||
|
| **Export filtered view to ADIF / Cabrillo** | **Every QSO the current filter matches** — not only the rows on screen, and with no row limit. |
|
||||||
|
| **Send to QRZ / Club Log / HRDLog / eQSL / LoTW** | Uploads the selection now, whatever the automatic upload mode is. |
|
||||||
|
| **Delete…** | With a confirmation naming the count. |
|
||||||
|
|
||||||
|
> **Exporting a filtered search is two different entries.** *Export selected*
|
||||||
|
> takes the rows you highlighted. *Export filtered view* takes the whole result
|
||||||
|
> of the filter, including the QSOs scrolled off the bottom. When you have just
|
||||||
|
> built a filter and want the lot, it is the second one.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Advanced filter builder
|
## Advanced filter builder
|
||||||
|
|
||||||
Click the filter button to open the **QSO filter builder** (Log4OM-style):
|
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
|
||||||
@@ -32,12 +67,84 @@ Click the filter button to open the **QSO filter builder** (Log4OM-style):
|
|||||||
- Fields include every confirmation **status and date** (sent / received,
|
- Fields include every confirmation **status and date** (sent / received,
|
||||||
before / after) for paper QSL, LoTW, eQSL, Club Log, HRDLog and QRZ.com.
|
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.
|
Field names stay in English — they are ADIF names, a standard vocabulary.
|
||||||
- Join conditions with **ALL (AND)** or **ANY (OR)**.
|
- Join the 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).
|
||||||
|
|
||||||
A filter shows **every** match (the on-screen row limit only applies to the
|
A filter shows **every** match — the on-screen row limit only applies to the
|
||||||
unfiltered log; safety cap 10 000). The filtered view can be exported:
|
unfiltered log (safety cap 10 000).
|
||||||
**selected rows** or the **whole filtered view** → ADIF or Cabrillo.
|
|
||||||
|
### Worked examples
|
||||||
|
|
||||||
|
Each of these is a few clicks in the builder. The point of the last column is
|
||||||
|
what you then do with the result.
|
||||||
|
|
||||||
|
**1. Everything worked on 20 m in FT8 that is still unconfirmed on LoTW**
|
||||||
|
|
||||||
|
| Join | Field | Operator | Value |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ALL | `band` | equals | `20m` |
|
||||||
|
| | `mode` | equals | `FT8` |
|
||||||
|
| | `lotw_qsl_rcvd` | is empty | |
|
||||||
|
|
||||||
|
→ right-click → **Export filtered view to ADIF**, and you have the file to
|
||||||
|
re-upload or to check against LoTW.
|
||||||
|
|
||||||
|
**2. Paper cards sent but never answered, older than a year**
|
||||||
|
|
||||||
|
| Join | Field | Operator | Value |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ALL | `qsl_sent` | equals | `Y` |
|
||||||
|
| | `qsl_rcvd` | is empty | |
|
||||||
|
| | `qso_date` | less than | `20250816` |
|
||||||
|
|
||||||
|
→ select all → **Bulk edit field** to mark them for a bureau chase, or export
|
||||||
|
them for a follow-up list.
|
||||||
|
|
||||||
|
**3. Every contact with one entity, on any band, in a date range**
|
||||||
|
|
||||||
|
| Join | Field | Operator | Value |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ALL | `dxcc` | equals | `291` |
|
||||||
|
| | `qso_date` | greater or equal | `20260101` |
|
||||||
|
| | `qso_date` | less or equal | `20261231` |
|
||||||
|
|
||||||
|
→ this is the shape for an award submission. Save it as a **preset** so next
|
||||||
|
year is one click and a date change.
|
||||||
|
|
||||||
|
**4. Anything that might be a duplicate of tonight's run**
|
||||||
|
|
||||||
|
| Join | Field | Operator | Value |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ALL | `call` | contains | `DL` |
|
||||||
|
| | `band` | equals | `40m` |
|
||||||
|
| ANY | `qsl_sent` | equals | `Y` |
|
||||||
|
| | `lotw_qsl_sent` | equals | `Y` |
|
||||||
|
|
||||||
|
Note the mix: the **ALL** block narrows to the contacts you care about, the
|
||||||
|
**ANY** block accepts a QSO confirmed by either route.
|
||||||
|
|
||||||
|
**5. US contacts with no county recorded**
|
||||||
|
|
||||||
|
| Join | Field | Operator | Value |
|
||||||
|
|---|---|---|---|
|
||||||
|
| ALL | `dxcc` | equals | `291` |
|
||||||
|
| | `cnty` | is empty | |
|
||||||
|
|
||||||
|
→ select all → right-click → **Update US county from the ULS database**.
|
||||||
|
|
||||||
|
### Tips
|
||||||
|
|
||||||
|
- **Dates are ADIF dates**: `YYYYMMDD`, no separators. `20260816`, not
|
||||||
|
`16/08/2026`.
|
||||||
|
- **`is empty` is not the same as `equals N`.** A QSL that was never sent is
|
||||||
|
empty; one explicitly marked *not sent* holds `N`. Filters on confirmation
|
||||||
|
usually want *is empty*.
|
||||||
|
- To find what a field is called, show its column in the grid: the Columns
|
||||||
|
picker and the filter builder use the same ADIF names.
|
||||||
|
- A preset saves the conditions, not the results — it re-runs against the log
|
||||||
|
as it is today.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Worked-before matrix
|
## Worked-before matrix
|
||||||
|
|
||||||
|
|||||||
@@ -76,3 +76,40 @@ selectable, with a copy button.
|
|||||||
|
|
||||||
Open an issue (or *Send log to F4BPO*) with: what you did, what happened, your
|
Open an issue (or *Send log to F4BPO*) with: what you did, what happened, your
|
||||||
radio/model, and the relevant log lines.
|
radio/model, and the relevant log lines.
|
||||||
|
|
||||||
|
## The rig disconnects during digital transmissions
|
||||||
|
|
||||||
|
Symptom: with WSJT-X or JTDX set to **Fake It** split, the CAT link drops on
|
||||||
|
every over. Fake It shifts the dial for each transmission instead of using
|
||||||
|
split, so it changes the radio state twice per cycle.
|
||||||
|
|
||||||
|
Check that **no inbound Connections row shares a port with an outbound one**.
|
||||||
|
If it does, OpsLog receives what it publishes, and its own frequency broadcast
|
||||||
|
can come back as a command to re-tune — a loop that fires on every dial shift.
|
||||||
|
The log names the clash at startup. Give one of the two rows another port.
|
||||||
|
|
||||||
|
## A motorized antenna does not follow the rig
|
||||||
|
|
||||||
|
Look for this line in the log:
|
||||||
|
|
||||||
|
antenna: steppir connected, but TRACKING IS OFF in Settings — it will not
|
||||||
|
follow the rig, and only moves when you tune it by hand
|
||||||
|
|
||||||
|
That is not a fault. Tracking is a setting (Settings → Antenna), and with it off
|
||||||
|
the antenna only moves when you tune it yourself.
|
||||||
|
|
||||||
|
If the antenna genuinely stops answering, the log says so and reconnects on its
|
||||||
|
own:
|
||||||
|
|
||||||
|
steppir: status query failed, reconnecting: read status: timed out …
|
||||||
|
steppir: reconnected after 2 failed attempt(s)
|
||||||
|
|
||||||
|
## A rotator turns the wrong way
|
||||||
|
|
||||||
|
On a **SPID**, check the dialect first: **Rot1Prog** for the azimuth-only
|
||||||
|
controllers (RAK, RAU) and **Rot2Prog** for the RAS / BIG-RAS / MD-01 / MD-02.
|
||||||
|
They use different frame lengths, and the wrong one is the usual cause of a
|
||||||
|
heading that reads correctly while every commanded move goes the long way round.
|
||||||
|
|
||||||
|
Test with a **small** move first — from 45° to 50°, not to 180° — so a wrong
|
||||||
|
result cannot wrap the coax at the top of the tower.
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
- [[Contest Logging]]
|
- [[Contest Logging]]
|
||||||
- [[Net Control]]
|
- [[Net Control]]
|
||||||
- [[Multi-Operator Live Status]]
|
- [[Multi-Operator Live Status]]
|
||||||
|
- [[Connections]]
|
||||||
|
|
||||||
**QSL & Awards**
|
**QSL & Awards**
|
||||||
- [[Awards]]
|
- [[Awards]]
|
||||||
|
|||||||
Reference in New Issue
Block a user