Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f853dd479e | ||
|
|
829c236d6c | ||
|
|
c9fd1379e1 | ||
|
|
28b6f04ea4 | ||
|
|
e6744c1d0a | ||
|
|
5152366717 | ||
|
|
e8691a43ba | ||
|
|
b83a4f4455 | ||
|
|
9d7091b1b8 | ||
|
|
f650183936 |
@@ -49,6 +49,8 @@ import (
|
|||||||
"hamlog/internal/qslcard"
|
"hamlog/internal/qslcard"
|
||||||
"hamlog/internal/qso"
|
"hamlog/internal/qso"
|
||||||
"hamlog/internal/rotator/pst"
|
"hamlog/internal/rotator/pst"
|
||||||
|
"hamlog/internal/relaydev"
|
||||||
|
"hamlog/internal/rotgenius"
|
||||||
"hamlog/internal/settings"
|
"hamlog/internal/settings"
|
||||||
"hamlog/internal/solar"
|
"hamlog/internal/solar"
|
||||||
"hamlog/internal/steppir"
|
"hamlog/internal/steppir"
|
||||||
@@ -154,6 +156,8 @@ const (
|
|||||||
keyRotatorHost = "rotator.host"
|
keyRotatorHost = "rotator.host"
|
||||||
keyRotatorPort = "rotator.port"
|
keyRotatorPort = "rotator.port"
|
||||||
keyRotatorHasElevation = "rotator.has_elevation"
|
keyRotatorHasElevation = "rotator.has_elevation"
|
||||||
|
keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
|
||||||
|
keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
|
||||||
|
|
||||||
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
||||||
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
||||||
@@ -168,6 +172,8 @@ const (
|
|||||||
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
|
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
|
||||||
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
||||||
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
|
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
|
||||||
|
keyMotorTXInhibit = "ultrabeam.tx_inhibit" // "1" → block Flex TX while the antenna is moving
|
||||||
|
keyStationDevices = "station.devices" // JSON list of relay boards for the Station Control tab
|
||||||
|
|
||||||
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
|
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
|
||||||
// port is fixed at 9007, so only the IP is configurable.
|
// port is fixed at 9007, so only the IP is configurable.
|
||||||
@@ -441,6 +447,9 @@ type App struct {
|
|||||||
clublog *clublog.Manager
|
clublog *clublog.Manager
|
||||||
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
|
motorAnt motorAntenna // motorized antenna (Ultrabeam or SteppIR); nil when disabled
|
||||||
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off
|
||||||
|
motorInhibStop chan struct{} // stops the "inhibit TX while moving" loop; nil when off
|
||||||
|
motorMoveCmdNs atomic.Int64 // unixnano of the last commanded antenna move (grace window)
|
||||||
|
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
|
||||||
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
|
||||||
audioMgr *audio.Manager
|
audioMgr *audio.Manager
|
||||||
@@ -2090,6 +2099,11 @@ func (a *App) applyStationDefaults(q *qso.QSO, includeIdentity bool) {
|
|||||||
if q.MyPOTARef == "" {
|
if q.MyPOTARef == "" {
|
||||||
q.MyPOTARef = p.MyPOTARef
|
q.MyPOTARef = p.MyPOTARef
|
||||||
}
|
}
|
||||||
|
// MY_NAME = the operator's personal name (profile OpName, e.g. "Greg") — stamped
|
||||||
|
// like the other My* fields so every QSO carries it, not just those edited by hand.
|
||||||
|
if q.MyName == "" {
|
||||||
|
q.MyName = p.OpName
|
||||||
|
}
|
||||||
// Per-band rig/antenna from Operating conditions (the antenna ticked as
|
// Per-band rig/antenna from Operating conditions (the antenna ticked as
|
||||||
// DEFAULT for this band) — the same auto-fill the entry strip does, applied
|
// DEFAULT for this band) — the same auto-fill the entry strip does, applied
|
||||||
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
|
// here so imported QSOs get MY_RIG / MY_ANTENNA from the band defaults.
|
||||||
@@ -4651,6 +4665,10 @@ var bulkFieldColumns = map[string]string{
|
|||||||
"my_antenna": "my_antenna",
|
"my_antenna": "my_antenna",
|
||||||
"my_sig": "my_sig",
|
"my_sig": "my_sig",
|
||||||
"my_sig_info": "my_sig_info",
|
"my_sig_info": "my_sig_info",
|
||||||
|
"my_name": "my_name",
|
||||||
|
"my_arrl_sect": "my_arrl_sect",
|
||||||
|
"my_darc_dok": "my_darc_dok",
|
||||||
|
"my_vucc_grids": "my_vucc_grids",
|
||||||
// Contest
|
// Contest
|
||||||
"contest_id": "contest_id",
|
"contest_id": "contest_id",
|
||||||
"srx_string": "srx_string",
|
"srx_string": "srx_string",
|
||||||
@@ -8878,6 +8896,12 @@ func (a *App) consumeUDPEvents() {
|
|||||||
"source": ev.Source,
|
"source": ev.Source,
|
||||||
"adif": ev.LoggedADIF,
|
"adif": ev.LoggedADIF,
|
||||||
})
|
})
|
||||||
|
case ev.ClearCall:
|
||||||
|
applog.Printf("udp: emit udp:clear_call (DX Call cleared in the digital app)\n")
|
||||||
|
wruntime.EventsEmit(a.ctx, "udp:clear_call", map[string]any{
|
||||||
|
"service": string(ev.Service),
|
||||||
|
"source": ev.Source,
|
||||||
|
})
|
||||||
case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall:
|
case ev.DXCall != "" && ev.Service == udp.ServiceRemoteCall:
|
||||||
applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
|
applog.Printf("udp: emit udp:remote_call %q\n", ev.DXCall)
|
||||||
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
|
wruntime.EventsEmit(a.ctx, "udp:remote_call", ev.DXCall)
|
||||||
@@ -9232,6 +9256,7 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
|
|||||||
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
|
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
|
||||||
return // within the deadband — don't chase a tiny QSY
|
return // within the deadband — don't chase a tiny QSY
|
||||||
}
|
}
|
||||||
|
a.noteMotorMoveCommanded()
|
||||||
if err := c.SetFrequency(khz, st.Direction); err != nil {
|
if err := c.SetFrequency(khz, st.Direction); err != nil {
|
||||||
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
|
applog.Printf("ultrabeam: immediate re-tune to %d kHz failed: %v", khz, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -10373,30 +10398,40 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
|
|||||||
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
|
||||||
type RotatorSettings struct {
|
type RotatorSettings struct {
|
||||||
Enabled bool `json:"enabled"`
|
Enabled bool `json:"enabled"`
|
||||||
|
Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
|
||||||
Host string `json:"host"` // default 127.0.0.1
|
Host string `json:"host"` // default 127.0.0.1
|
||||||
Port int `json:"port"` // default 12000
|
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius)
|
||||||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets
|
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
||||||
|
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRotatorSettings returns the persisted rotator config with defaults.
|
// GetRotatorSettings returns the persisted rotator config with defaults.
|
||||||
func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
||||||
out := RotatorSettings{Host: "127.0.0.1", Port: 12000}
|
out := RotatorSettings{Type: "pst", Host: "127.0.0.1", Port: 12000, RotatorNum: 1}
|
||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return out, fmt.Errorf("db not initialized")
|
return out, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx,
|
m, err := a.settings.GetMany(a.ctx,
|
||||||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation)
|
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.Enabled = m[keyRotatorEnabled] == "1"
|
out.Enabled = m[keyRotatorEnabled] == "1"
|
||||||
|
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" {
|
||||||
|
out.Type = t
|
||||||
|
}
|
||||||
if h := m[keyRotatorHost]; h != "" {
|
if h := m[keyRotatorHost]; h != "" {
|
||||||
out.Host = h
|
out.Host = h
|
||||||
}
|
}
|
||||||
if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 {
|
if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 {
|
||||||
out.Port = p
|
out.Port = p
|
||||||
|
} else if out.Type == "rotgenius" {
|
||||||
|
out.Port = 9006 // native default when no port stored yet
|
||||||
}
|
}
|
||||||
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
||||||
|
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
|
||||||
|
out.RotatorNum = 2
|
||||||
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -10406,17 +10441,25 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
|||||||
if a.settings == nil {
|
if a.settings == nil {
|
||||||
return fmt.Errorf("db not initialized")
|
return fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
|
if s.Type != "rotgenius" {
|
||||||
|
s.Type = "pst"
|
||||||
|
}
|
||||||
if s.Host == "" {
|
if s.Host == "" {
|
||||||
s.Host = "127.0.0.1"
|
s.Host = "127.0.0.1"
|
||||||
}
|
}
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
s.Port = 12000
|
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000}[s.Type]
|
||||||
|
}
|
||||||
|
if s.RotatorNum != 2 {
|
||||||
|
s.RotatorNum = 1
|
||||||
}
|
}
|
||||||
for k, v := range map[string]string{
|
for k, v := range map[string]string{
|
||||||
keyRotatorEnabled: boolStr(s.Enabled),
|
keyRotatorEnabled: boolStr(s.Enabled),
|
||||||
|
keyRotatorType: s.Type,
|
||||||
keyRotatorHost: s.Host,
|
keyRotatorHost: s.Host,
|
||||||
keyRotatorPort: strconv.Itoa(s.Port),
|
keyRotatorPort: strconv.Itoa(s.Port),
|
||||||
keyRotatorHasElevation: boolStr(s.HasElevation),
|
keyRotatorHasElevation: boolStr(s.HasElevation),
|
||||||
|
keyRotatorNum: strconv.Itoa(s.RotatorNum),
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -10425,18 +10468,6 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// rotatorClient returns a fresh PST UDP client built from current settings,
|
|
||||||
// or an error if the rotator is disabled / misconfigured.
|
|
||||||
func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) {
|
|
||||||
s, err := a.GetRotatorSettings()
|
|
||||||
if err != nil {
|
|
||||||
return nil, s, err
|
|
||||||
}
|
|
||||||
if !s.Enabled {
|
|
||||||
return nil, s, fmt.Errorf("rotator disabled in settings")
|
|
||||||
}
|
|
||||||
return pst.New(s.Host, s.Port), s, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// RotatorHeading is the live antenna heading for the status bar.
|
// RotatorHeading is the live antenna heading for the status bar.
|
||||||
type RotatorHeading struct {
|
type RotatorHeading struct {
|
||||||
@@ -10453,6 +10484,16 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
|||||||
if err != nil || !s.Enabled {
|
if err != nil || !s.Enabled {
|
||||||
return RotatorHeading{Enabled: false}
|
return RotatorHeading{Enabled: false}
|
||||||
}
|
}
|
||||||
|
if s.Type == "rotgenius" {
|
||||||
|
st, raw, herr := rotgenius.New(s.Host, s.Port).Heading(s.RotatorNum)
|
||||||
|
if herr != nil {
|
||||||
|
return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
|
||||||
|
}
|
||||||
|
if !st.Connected {
|
||||||
|
return RotatorHeading{Enabled: true, OK: false, Raw: "sensor not connected (999)"}
|
||||||
|
}
|
||||||
|
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
|
||||||
|
}
|
||||||
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
||||||
if herr != nil {
|
if herr != nil {
|
||||||
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
||||||
@@ -10463,30 +10504,49 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
|||||||
// RotatorGoTo points the antenna at the given azimuth (and optional
|
// RotatorGoTo points the antenna at the given azimuth (and optional
|
||||||
// elevation if the rotator is configured for it).
|
// elevation if the rotator is configured for it).
|
||||||
func (a *App) RotatorGoTo(az int, el int) error {
|
func (a *App) RotatorGoTo(az int, el int) error {
|
||||||
c, s, err := a.rotatorClient()
|
s, err := a.GetRotatorSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.GoTo(az, s.HasElevation, el)
|
if !s.Enabled {
|
||||||
|
return fmt.Errorf("rotator disabled in settings")
|
||||||
|
}
|
||||||
|
if s.Type == "rotgenius" {
|
||||||
|
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
|
||||||
|
}
|
||||||
|
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotatorStop interrupts any in-progress rotation.
|
// RotatorStop interrupts any in-progress rotation, on either backend.
|
||||||
func (a *App) RotatorStop() error {
|
func (a *App) RotatorStop() error {
|
||||||
c, _, err := a.rotatorClient()
|
s, err := a.GetRotatorSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.Stop()
|
if !s.Enabled {
|
||||||
|
return fmt.Errorf("rotator disabled in settings")
|
||||||
|
}
|
||||||
|
if s.Type == "rotgenius" {
|
||||||
|
return rotgenius.New(s.Host, s.Port).Stop()
|
||||||
|
}
|
||||||
|
return pst.New(s.Host, s.Port).Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
// RotatorPark moves the antenna to its parked position (configured in
|
// RotatorPark moves the antenna to its parked position (configured in
|
||||||
// PstRotator itself).
|
// PstRotator itself). Park is a PstRotator concept — the 4O3A native protocol
|
||||||
|
// has no park command.
|
||||||
func (a *App) RotatorPark() error {
|
func (a *App) RotatorPark() error {
|
||||||
c, _, err := a.rotatorClient()
|
s, err := a.GetRotatorSettings()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return c.Park()
|
if !s.Enabled {
|
||||||
|
return fmt.Errorf("rotator disabled in settings")
|
||||||
|
}
|
||||||
|
if s.Type == "rotgenius" {
|
||||||
|
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
||||||
|
}
|
||||||
|
return pst.New(s.Host, s.Port).Park()
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestRotator sends a no-op GoTo to the rotator's current heading to
|
// TestRotator sends a no-op GoTo to the rotator's current heading to
|
||||||
@@ -10497,6 +10557,19 @@ func (a *App) TestRotator(s RotatorSettings) error {
|
|||||||
if s.Host == "" {
|
if s.Host == "" {
|
||||||
s.Host = "127.0.0.1"
|
s.Host = "127.0.0.1"
|
||||||
}
|
}
|
||||||
|
if s.Type == "rotgenius" {
|
||||||
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
|
s.Port = 9006
|
||||||
|
}
|
||||||
|
rn := s.RotatorNum
|
||||||
|
if rn != 2 {
|
||||||
|
rn = 1
|
||||||
|
}
|
||||||
|
// Match the button ("Test — point to 0°"): actually command the move, like
|
||||||
|
// the PstRotator path does. GoTo confirms the link AND the accept ('K')
|
||||||
|
// reply, so a rejected command surfaces as an error instead of a false OK.
|
||||||
|
return rotgenius.New(s.Host, s.Port).GoTo(rn, 0)
|
||||||
|
}
|
||||||
if s.Port <= 0 || s.Port > 65535 {
|
if s.Port <= 0 || s.Port > 65535 {
|
||||||
s.Port = 12000
|
s.Port = 12000
|
||||||
}
|
}
|
||||||
@@ -10510,6 +10583,146 @@ func boolStr(b bool) string {
|
|||||||
return "0"
|
return "0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Station Control (relay boards) ─────────────────────────────────────
|
||||||
|
|
||||||
|
// StationDevice is one configured relay board for the Station Control tab.
|
||||||
|
type StationDevice struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"` // "webswitch" | "kmtronic"
|
||||||
|
Name string `json:"name"`
|
||||||
|
Host string `json:"host"`
|
||||||
|
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
|
||||||
|
Pass string `json:"pass,omitempty"`
|
||||||
|
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayCountFor returns a device type's fixed relay count.
|
||||||
|
func relayCountFor(typ string) int {
|
||||||
|
if typ == "kmtronic" {
|
||||||
|
return 8
|
||||||
|
}
|
||||||
|
return 5 // webswitch 1216H
|
||||||
|
}
|
||||||
|
|
||||||
|
// deviceDriver builds the wire driver for a configured device.
|
||||||
|
func deviceDriver(d StationDevice) relaydev.Device {
|
||||||
|
if d.Type == "kmtronic" {
|
||||||
|
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
|
||||||
|
}
|
||||||
|
return relaydev.NewWebswitch(d.Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStationDevices returns the configured relay boards (without live state).
|
||||||
|
func (a *App) GetStationDevices() []StationDevice {
|
||||||
|
out := []StationDevice{}
|
||||||
|
if a.settings == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
s, _ := a.settings.GetGlobal(a.ctx, keyStationDevices)
|
||||||
|
if strings.TrimSpace(s) == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(s), &out); err != nil || out == nil {
|
||||||
|
return []StationDevice{}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveStationDevices persists the relay-board list. IDs and label lengths are
|
||||||
|
// normalized so the UI can rely on them.
|
||||||
|
func (a *App) SaveStationDevices(devs []StationDevice) error {
|
||||||
|
if a.settings == nil {
|
||||||
|
return fmt.Errorf("db not initialized")
|
||||||
|
}
|
||||||
|
for i := range devs {
|
||||||
|
if devs[i].Type != "kmtronic" {
|
||||||
|
devs[i].Type = "webswitch"
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(devs[i].ID) == "" {
|
||||||
|
devs[i].ID = fmt.Sprintf("dev%d-%d", i, time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
n := relayCountFor(devs[i].Type)
|
||||||
|
for len(devs[i].Labels) < n {
|
||||||
|
devs[i].Labels = append(devs[i].Labels, "")
|
||||||
|
}
|
||||||
|
devs[i].Labels = devs[i].Labels[:n]
|
||||||
|
}
|
||||||
|
b, err := json.Marshal(devs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.settings.SetGlobal(a.ctx, keyStationDevices, string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
// StationRelay is one relay's live state for the UI.
|
||||||
|
type StationRelay struct {
|
||||||
|
Number int `json:"number"` // 1-based
|
||||||
|
Label string `json:"label"`
|
||||||
|
On bool `json:"on"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StationDeviceStatus is a device plus its live relay states.
|
||||||
|
type StationDeviceStatus struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Relays []StationRelay `json:"relays"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStationStatus polls every configured board for its live relay states.
|
||||||
|
// Boards are polled concurrently so one slow/offline device doesn't stall the rest.
|
||||||
|
func (a *App) GetStationStatus() []StationDeviceStatus {
|
||||||
|
devs := a.GetStationDevices()
|
||||||
|
out := make([]StationDeviceStatus, len(devs))
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for i := range devs {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
d := devs[i]
|
||||||
|
n := relayCountFor(d.Type)
|
||||||
|
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
|
||||||
|
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
states, err := deviceDriver(d).Status(ctx)
|
||||||
|
if err != nil {
|
||||||
|
ds.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
ds.Connected = true
|
||||||
|
}
|
||||||
|
ds.Relays = make([]StationRelay, n)
|
||||||
|
for r := 0; r < n; r++ {
|
||||||
|
label := ""
|
||||||
|
if r < len(d.Labels) {
|
||||||
|
label = d.Labels[r]
|
||||||
|
}
|
||||||
|
on := false
|
||||||
|
if r < len(states) {
|
||||||
|
on = states[r]
|
||||||
|
}
|
||||||
|
ds.Relays[r] = StationRelay{Number: r + 1, Label: label, On: on}
|
||||||
|
}
|
||||||
|
out[i] = ds
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// StationSetRelay switches one relay on a configured board.
|
||||||
|
func (a *App) StationSetRelay(id string, relay int, on bool) error {
|
||||||
|
for _, d := range a.GetStationDevices() {
|
||||||
|
if d.ID == id {
|
||||||
|
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
return deviceDriver(d).Set(ctx, relay, on)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("station device %q not found", id)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Motorized antenna (Ultrabeam / SteppIR) ────────────────────────────
|
// ── Motorized antenna (Ultrabeam / SteppIR) ────────────────────────────
|
||||||
|
|
||||||
// motorAntenna is the shared control surface of a motorized antenna. The
|
// motorAntenna is the shared control surface of a motorized antenna. The
|
||||||
@@ -10583,6 +10796,7 @@ type UltrabeamSettings struct {
|
|||||||
Baud int `json:"baud"` // serial baud
|
Baud int `json:"baud"` // serial baud
|
||||||
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
|
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
|
||||||
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
|
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
|
||||||
|
TXInhibit bool `json:"tx_inhibit"` // block Flex transmission while the elements are moving
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
|
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
|
||||||
@@ -10594,7 +10808,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
|
|||||||
return out, fmt.Errorf("db not initialized")
|
return out, fmt.Errorf("db not initialized")
|
||||||
}
|
}
|
||||||
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
|
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
|
||||||
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud)
|
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
@@ -10614,6 +10828,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
|
|||||||
out.Baud = b
|
out.Baud = b
|
||||||
}
|
}
|
||||||
out.Follow = m[keyUltrabeamFollow] == "1"
|
out.Follow = m[keyUltrabeamFollow] == "1"
|
||||||
|
out.TXInhibit = m[keyMotorTXInhibit] == "1"
|
||||||
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
|
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
|
||||||
out.StepKHz = st
|
out.StepKHz = st
|
||||||
}
|
}
|
||||||
@@ -10651,6 +10866,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
|
|||||||
keyMotorTransport: s.Transport,
|
keyMotorTransport: s.Transport,
|
||||||
keyMotorCOM: strings.TrimSpace(s.COM),
|
keyMotorCOM: strings.TrimSpace(s.COM),
|
||||||
keyMotorBaud: strconv.Itoa(s.Baud),
|
keyMotorBaud: strconv.Itoa(s.Baud),
|
||||||
|
keyMotorTXInhibit: boolStr(s.TXInhibit),
|
||||||
} {
|
} {
|
||||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -10690,6 +10906,11 @@ func (a *App) startUltrabeam() {
|
|||||||
close(a.ubFollowStop)
|
close(a.ubFollowStop)
|
||||||
a.ubFollowStop = nil
|
a.ubFollowStop = nil
|
||||||
}
|
}
|
||||||
|
// Stop the inhibit watcher (its deferred cleanup releases any active inhibit).
|
||||||
|
if a.motorInhibStop != nil {
|
||||||
|
close(a.motorInhibStop)
|
||||||
|
a.motorInhibStop = nil
|
||||||
|
}
|
||||||
if a.motorAnt != nil {
|
if a.motorAnt != nil {
|
||||||
// Background teardown so saving Settings doesn't block on an in-progress
|
// Background teardown so saving Settings doesn't block on an in-progress
|
||||||
// connect (Stop waits for the dial timeout).
|
// connect (Stop waits for the dial timeout).
|
||||||
@@ -10711,12 +10932,68 @@ func (a *App) startUltrabeam() {
|
|||||||
a.ubFollowStop = stop
|
a.ubFollowStop = stop
|
||||||
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, stop)
|
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, stop)
|
||||||
}
|
}
|
||||||
|
if s.TXInhibit {
|
||||||
|
stop := make(chan struct{})
|
||||||
|
a.motorInhibStop = stop
|
||||||
|
go a.motorTXInhibitLoop(a.motorAnt, stop)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ultrabeamFollowLoop re-tunes the antenna to the rig's current frequency
|
// ultrabeamFollowLoop re-tunes the antenna to the rig's current frequency
|
||||||
// whenever it drifts at least stepKHz from what the antenna is set to — so the
|
// whenever it drifts at least stepKHz from what the antenna is set to — so the
|
||||||
// elements track the band without the motors chasing every small QSY. Runs
|
// elements track the band without the motors chasing every small QSY. Runs
|
||||||
// until stop is closed (a settings change or shutdown).
|
// until stop is closed (a settings change or shutdown).
|
||||||
|
// noteMotorMoveCommanded marks that OpsLog just told the antenna to move. The
|
||||||
|
// inhibit watcher polls the antenna's Moving status, but that status is only
|
||||||
|
// refreshed every couple of seconds — so a fresh command opens a short grace
|
||||||
|
// window during which TX stays inhibited even before the poll confirms motion,
|
||||||
|
// closing the gap at the start of a move.
|
||||||
|
func (a *App) noteMotorMoveCommanded() { a.motorMoveCmdNs.Store(time.Now().UnixNano()) }
|
||||||
|
|
||||||
|
// applyMotorInhibit sets or clears the Flex transmit inhibit, but only when the
|
||||||
|
// active CAT backend IS a FlexRadio — the inhibit is a Flex-API feature; with any
|
||||||
|
// other rig the option simply does nothing (as documented in the UI). Idempotent.
|
||||||
|
func (a *App) applyMotorInhibit(on bool) {
|
||||||
|
if a.cat == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.cat.State().Backend != "flex" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a.motorInhibited.Load() == on {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := a.cat.FlexDo(func(fc cat.FlexController) error { return fc.SetTXInhibit(on) }); err != nil {
|
||||||
|
applog.Printf("motor-antenna: TX inhibit=%v failed: %v", on, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.motorInhibited.Store(on)
|
||||||
|
applog.Printf("motor-antenna: Flex TX inhibit %s (antenna moving)", map[bool]string{true: "ON", false: "OFF"}[on])
|
||||||
|
}
|
||||||
|
|
||||||
|
// motorTXInhibitLoop blocks Flex transmission while the motorized antenna's
|
||||||
|
// elements are moving. Runs only when the option is on and a motor antenna is
|
||||||
|
// active; it errs toward SAFE — TX is inhibited while the status reports motion
|
||||||
|
// OR within a grace window after a commanded move — and always releases the
|
||||||
|
// inhibit when it stops (so a settings change / shutdown never leaves TX blocked).
|
||||||
|
func (a *App) motorTXInhibitLoop(c motorAntenna, stop <-chan struct{}) {
|
||||||
|
const grace = 3 * time.Second // cover the poll latency at the very start of a move
|
||||||
|
ticker := time.NewTicker(300 * time.Millisecond)
|
||||||
|
defer ticker.Stop()
|
||||||
|
defer a.applyMotorInhibit(false) // never leave TX blocked when the loop ends
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-stop:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
st := c.Status()
|
||||||
|
recentCmd := time.Since(time.Unix(0, a.motorMoveCmdNs.Load())) < grace
|
||||||
|
moving := st.Connected && (st.Moving || recentCmd)
|
||||||
|
a.applyMotorInhibit(moving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struct{}) {
|
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struct{}) {
|
||||||
if stepKHz <= 0 {
|
if stepKHz <= 0 {
|
||||||
stepKHz = 50
|
stepKHz = 50
|
||||||
@@ -10771,6 +11048,7 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
|
|||||||
if ref > 0 && diff < stepKHz {
|
if ref > 0 && diff < stepKHz {
|
||||||
continue // within the deadband — leave the motors alone
|
continue // within the deadband — leave the motors alone
|
||||||
}
|
}
|
||||||
|
a.noteMotorMoveCommanded()
|
||||||
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
|
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
|
||||||
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
|
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
|
||||||
} else {
|
} else {
|
||||||
@@ -10822,6 +11100,7 @@ func (a *App) SetUltrabeamDirection(direction int) error {
|
|||||||
// current frequency with the new direction byte. If the antenna hasn't reported
|
// current frequency with the new direction byte. If the antenna hasn't reported
|
||||||
// a frequency yet (just connected / link settling), fall back to the rig's CAT
|
// a frequency yet (just connected / link settling), fall back to the rig's CAT
|
||||||
// frequency so the control still works.
|
// frequency so the control still works.
|
||||||
|
a.noteMotorMoveCommanded()
|
||||||
st := a.motorAnt.Status()
|
st := a.motorAnt.Status()
|
||||||
if st.Frequency <= 0 && a.cat != nil {
|
if st.Frequency <= 0 && a.cat != nil {
|
||||||
if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {
|
if rs := a.cat.State(); rs.Connected && rs.FreqHz > 0 {
|
||||||
|
|||||||
+81
-11
@@ -68,6 +68,7 @@ import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
|||||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||||
import { StatsPanel } from '@/components/StatsPanel';
|
import { StatsPanel } from '@/components/StatsPanel';
|
||||||
|
import { StationControlPanel } from '@/components/StationControlPanel';
|
||||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||||
@@ -608,7 +609,16 @@ export default function App() {
|
|||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
const [activeFilter, setActiveFilter] = useState<QueryFilter>({ conditions: [], match: 'AND' });
|
||||||
const [matchCount, setMatchCount] = useState<number | null>(null);
|
const [matchCount, setMatchCount] = useState<number | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState('recent');
|
// The selected tab is remembered across restarts. Only the always-present tabs
|
||||||
|
// are restored: the conditional ones (flex/icom/contest/net/stats/qsl) depend on
|
||||||
|
// a feature or CAT backend that isn't known this early, and restoring one that
|
||||||
|
// isn't there would show a blank pane.
|
||||||
|
const ALWAYS_TABS = ['main', 'recent', 'cluster', 'worked', 'awards', 'bandmap'];
|
||||||
|
const [activeTab, setActiveTab] = useState(() => {
|
||||||
|
const saved = localStorage.getItem('opslog.activeTab') || '';
|
||||||
|
return ALWAYS_TABS.includes(saved) ? saved : 'recent';
|
||||||
|
});
|
||||||
|
useEffect(() => { writeUiPref('opslog.activeTab', activeTab); }, [activeTab]);
|
||||||
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
// QSL Manager is a closable tab opened on demand from Tools → QSL Manager.
|
||||||
const [qslTabOpen, setQslTabOpen] = useState(false);
|
const [qslTabOpen, setQslTabOpen] = useState(false);
|
||||||
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
const [qslDesignerOpen, setQslDesignerOpen] = useState(false);
|
||||||
@@ -623,6 +633,11 @@ export default function App() {
|
|||||||
setStatsTabOpen(false);
|
setStatsTabOpen(false);
|
||||||
setActiveTab((t) => (t === 'stats' ? 'recent' : t));
|
setActiveTab((t) => (t === 'stats' ? 'recent' : t));
|
||||||
}
|
}
|
||||||
|
const [stationTabOpen, setStationTabOpen] = useState(false);
|
||||||
|
function closeStationTab() {
|
||||||
|
setStationTabOpen(false);
|
||||||
|
setActiveTab((t) => (t === 'station' ? 'recent' : t));
|
||||||
|
}
|
||||||
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
||||||
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
||||||
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
||||||
@@ -909,8 +924,15 @@ export default function App() {
|
|||||||
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
|
// Ring buffer — only keep the last N spots; cluster firehose can be heavy.
|
||||||
const [spots, setSpots] = useState<ClusterSpot[]>([]);
|
const [spots, setSpots] = useState<ClusterSpot[]>([]);
|
||||||
const SPOTS_CAP = 1000;
|
const SPOTS_CAP = 1000;
|
||||||
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>('');
|
// Cluster filter selections persist across restarts (writeUiPref → localStorage
|
||||||
const [clusterGroup, setClusterGroup] = useState(true);
|
// + DB, so they also travel with a copied data/ folder). Loaders read the cache
|
||||||
|
// synchronously at first render; a single effect below writes them back.
|
||||||
|
const lsBool = (k: string, d: boolean) => { const v = localStorage.getItem(k); return v === null ? d : v === '1'; };
|
||||||
|
const lsSet = <T,>(k: string): Set<T> => { try { const a = JSON.parse(localStorage.getItem(k) || '[]'); return new Set(Array.isArray(a) ? a : []); } catch { return new Set<T>(); } };
|
||||||
|
const [clusterFilterSource, setClusterFilterSource] = useState<number | ''>(() => {
|
||||||
|
const v = localStorage.getItem('opslog.clusterFilterSource'); const n = v ? parseInt(v, 10) : NaN; return Number.isFinite(n) ? n : '';
|
||||||
|
});
|
||||||
|
const [clusterGroup, setClusterGroup] = useState(() => lsBool('opslog.clusterGroup', true));
|
||||||
const [clusterCmd, setClusterCmd] = useState('');
|
const [clusterCmd, setClusterCmd] = useState('');
|
||||||
// Cluster console: the raw traffic. Spots are parsed out of the stream into the
|
// Cluster console: the raw traffic. Spots are parsed out of the stream into the
|
||||||
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
|
// grid; everything else (SH/DX output, WHO, MOTD, errors) used to be discarded,
|
||||||
@@ -938,22 +960,37 @@ export default function App() {
|
|||||||
if (atBottom) el.scrollTop = el.scrollHeight;
|
if (atBottom) el.scrollTop = el.scrollHeight;
|
||||||
}, [clusterLines]);
|
}, [clusterLines]);
|
||||||
// Multi-band filter: empty set = all bands. The user toggles chips.
|
// Multi-band filter: empty set = all bands. The user toggles chips.
|
||||||
const [clusterBands, setClusterBands] = useState<Set<string>>(new Set());
|
const [clusterBands, setClusterBands] = useState<Set<string>>(() => lsSet<string>('opslog.clusterBands'));
|
||||||
// Lock-to-entry: when on, the band filter follows the entry's current
|
// Lock-to-entry: when on, the band filter follows the entry's current
|
||||||
// band and the mode filter follows the entry's current mode.
|
// band and the mode filter follows the entry's current mode.
|
||||||
const [clusterLockBand, setClusterLockBand] = useState(false);
|
const [clusterLockBand, setClusterLockBand] = useState(() => lsBool('opslog.clusterLockBand', false));
|
||||||
const [clusterLockMode, setClusterLockMode] = useState(false);
|
const [clusterLockMode, setClusterLockMode] = useState(() => lsBool('opslog.clusterLockMode', false));
|
||||||
// Status filter chips. Empty set = show every status (including
|
// Status filter chips. Empty set = show every status (including
|
||||||
// already-worked). Otherwise only matching spots pass.
|
// already-worked). Otherwise only matching spots pass.
|
||||||
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
type SpotStatusKey = 'new' | 'new-band' | 'new-mode' | 'new-slot' | 'worked';
|
||||||
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(new Set());
|
const [clusterStatusFilter, setClusterStatusFilter] = useState<Set<SpotStatusKey>>(() => lsSet<SpotStatusKey>('opslog.clusterStatusFilter'));
|
||||||
// Mode filter chips. Empty set = show every mode. Categories map the
|
// Mode filter chips. Empty set = show every mode. Categories map the
|
||||||
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
// inferred per-spot mode onto SSB (phone) / CW / DATA (digital).
|
||||||
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
type SpotModeCat = 'SSB' | 'CW' | 'DATA';
|
||||||
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(new Set());
|
const [clusterModeFilter, setClusterModeFilter] = useState<Set<SpotModeCat>>(() => lsSet<SpotModeCat>('opslog.clusterModeFilter'));
|
||||||
const [clusterSearch, setClusterSearch] = useState('');
|
const [clusterSearch, setClusterSearch] = useState(() => localStorage.getItem('opslog.clusterSearch') || '');
|
||||||
// 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(false);
|
const [clusterHideWorked, setClusterHideWorked] = useState(() => lsBool('opslog.clusterHideWorked', false));
|
||||||
|
|
||||||
|
// Persist every cluster filter selection whenever it changes, so it is still
|
||||||
|
// set after a close/reopen.
|
||||||
|
useEffect(() => {
|
||||||
|
writeUiPref('opslog.clusterFilterSource', clusterFilterSource === '' ? '' : String(clusterFilterSource));
|
||||||
|
writeUiPref('opslog.clusterGroup', clusterGroup ? '1' : '0');
|
||||||
|
writeUiPref('opslog.clusterBands', JSON.stringify([...clusterBands]));
|
||||||
|
writeUiPref('opslog.clusterLockBand', clusterLockBand ? '1' : '0');
|
||||||
|
writeUiPref('opslog.clusterLockMode', clusterLockMode ? '1' : '0');
|
||||||
|
writeUiPref('opslog.clusterStatusFilter', JSON.stringify([...clusterStatusFilter]));
|
||||||
|
writeUiPref('opslog.clusterModeFilter', JSON.stringify([...clusterModeFilter]));
|
||||||
|
writeUiPref('opslog.clusterSearch', clusterSearch);
|
||||||
|
writeUiPref('opslog.clusterHideWorked', clusterHideWorked ? '1' : '0');
|
||||||
|
}, [clusterFilterSource, clusterGroup, clusterBands, clusterLockBand, clusterLockMode,
|
||||||
|
clusterStatusFilter, clusterModeFilter, clusterSearch, clusterHideWorked]);
|
||||||
// Bands shown side-by-side in the Band Map tab (portable).
|
// Bands shown side-by-side in the Band Map tab (portable).
|
||||||
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
|
const [bandMapBands, setBandMapBands] = useState<string[]>(() => {
|
||||||
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
|
try { const v = JSON.parse(localStorage.getItem('opslog.bandMapBands') || '[]'); return Array.isArray(v) ? v : []; }
|
||||||
@@ -1756,6 +1793,12 @@ export default function App() {
|
|||||||
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
const unsubRC = EventsOn('udp:remote_call', (raw: string) => {
|
||||||
if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
|
if (applyUdpCall(raw, true)) restartRecordingForNewTarget(String(raw ?? '')); // explicit remote pick
|
||||||
});
|
});
|
||||||
|
// The DX Call was cleared in WSJT-X / JTDX / MSHV → clear our entry to match.
|
||||||
|
// Only when something is actually in the entry, so an idle digital app doesn't
|
||||||
|
// wipe a call being typed by hand.
|
||||||
|
const unsubClear = EventsOn('udp:clear_call', () => {
|
||||||
|
if (callsignRef.current?.value?.trim() || callsign.trim()) resetEntry();
|
||||||
|
});
|
||||||
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
|
// Clicked one of OpsLog's spots on the FlexRadio panadapter → fill the call
|
||||||
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
|
// (the radio already tuned via trigger_action=Tune, and CAT reads the freq).
|
||||||
// An explicit click always wins over whatever call is currently in the field.
|
// An explicit click always wins over whatever call is currently in the field.
|
||||||
@@ -1780,7 +1823,7 @@ export default function App() {
|
|||||||
else setError('UDP auto-log: ' + msg);
|
else setError('UDP auto-log: ' + msg);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return () => { unsubDX?.(); unsubRC?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
return () => { unsubDX?.(); unsubRC?.(); unsubClear?.(); unsubFlexSpot?.(); unsubProg?.(); unsubLog?.(); };
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -2495,6 +2538,7 @@ export default function App() {
|
|||||||
{ name: 'tools', label: t('menu.tools'), items: [
|
{ name: 'tools', label: t('menu.tools'), items: [
|
||||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||||
|
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||||
@@ -2531,6 +2575,7 @@ export default function App() {
|
|||||||
case 'edit.prefs': setShowSettings(true); break;
|
case 'edit.prefs': setShowSettings(true); break;
|
||||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||||
|
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||||
@@ -4155,6 +4200,21 @@ export default function App() {
|
|||||||
</span>
|
</span>
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
)}
|
)}
|
||||||
|
{stationTabOpen && (
|
||||||
|
<TabsTrigger value="station" className="gap-1.5">
|
||||||
|
{t('station.title')}
|
||||||
|
<span
|
||||||
|
role="button"
|
||||||
|
aria-label="Close Station Control"
|
||||||
|
title="Close"
|
||||||
|
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||||
|
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||||
|
onClick={(e) => { e.stopPropagation(); closeStationTab(); }}
|
||||||
|
>
|
||||||
|
<X className="size-3" />
|
||||||
|
</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
)}
|
||||||
{qslTabOpen && (
|
{qslTabOpen && (
|
||||||
<TabsTrigger value="qsl" className="gap-1.5">
|
<TabsTrigger value="qsl" className="gap-1.5">
|
||||||
QSL Manager
|
QSL Manager
|
||||||
@@ -4495,6 +4555,16 @@ export default function App() {
|
|||||||
</TabsContent>
|
</TabsContent>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{stationTabOpen && (
|
||||||
|
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||||
|
<StationControlPanel
|
||||||
|
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||||
|
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||||
|
bearing={dxPath?.bearingShort ?? null}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
)}
|
||||||
|
|
||||||
{contestTabEnabled && (
|
{contestTabEnabled && (
|
||||||
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
||||||
<ContestPanel session={contest} onChange={updateContest} />
|
<ContestPanel session={contest} onChange={updateContest} />
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ const FIELDS: FieldDef[] = [
|
|||||||
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
{ id: 'my_wwff_ref', label: 'bulk.fMyWwff', group: 'My station', kind: 'text', upper: true },
|
||||||
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
{ id: 'my_sig', label: 'bulk.fMySig', group: 'My station', kind: 'text' },
|
||||||
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
{ id: 'my_sig_info', label: 'bulk.fMySigInfo', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_name', label: 'bulk.fMyName', group: 'My station', kind: 'text' },
|
||||||
|
{ id: 'my_arrl_sect', label: 'bulk.fMyArrlSect', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_darc_dok', label: 'bulk.fMyDarcDok', group: 'My station', kind: 'text', upper: true },
|
||||||
|
{ id: 'my_vucc_grids', label: 'bulk.fMyVuccGrids', group: 'My station', kind: 'text', upper: true },
|
||||||
// Contest
|
// Contest
|
||||||
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
{ id: 'contest_id', label: 'bulk.fContestId', group: 'Contest', kind: 'text', upper: true },
|
||||||
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
{ id: 'srx_string', label: 'bulk.fSrxString', group: 'Contest', kind: 'text' },
|
||||||
|
|||||||
@@ -203,6 +203,13 @@ export const makeColCatalog = (t: TFn): ColEntry[] => [
|
|||||||
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
{ group: 'My station', label: t('rqg.c.my_zip'), colId: 'my_postal_code', headerName: t('rqg.h.my_zip'), field: 'my_postal_code' as any, width: 80 },
|
||||||
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
{ group: 'My station', label: t('rqg.c.my_rig'), colId: 'my_rig', headerName: t('rqg.c.my_rig'), field: 'my_rig' as any, width: 130 },
|
||||||
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
{ group: 'My station', label: t('rqg.c.my_antenna'), colId: 'my_antenna', headerName: t('rqg.h.my_antenna'), field: 'my_antenna' as any, width: 130 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_name'), colId: 'my_name', headerName: t('rqg.c.my_name'), field: 'my_name' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_wwff'), colId: 'my_wwff_ref', headerName: t('rqg.c.my_wwff'), field: 'my_wwff_ref' as any, width: 110, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig'), colId: 'my_sig', headerName: t('rqg.c.my_sig'), field: 'my_sig' as any, width: 90 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_sig_info'), colId: 'my_sig_info', headerName: t('rqg.c.my_sig_info'), field: 'my_sig_info' as any, width: 120 },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_arrl_sect'), colId: 'my_arrl_sect', headerName: t('rqg.c.my_arrl_sect'), field: 'my_arrl_sect' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_darc_dok'), colId: 'my_darc_dok', headerName: t('rqg.c.my_darc_dok'), field: 'my_darc_dok' as any, width: 90, cellClass: 'font-mono' },
|
||||||
|
{ group: 'My station', label: t('rqg.c.my_vucc_grids'),colId: 'my_vucc_grids', headerName: t('rqg.c.my_vucc_grids'), field: 'my_vucc_grids' as any, width: 130, cellClass: 'font-mono' },
|
||||||
|
|
||||||
// ── Misc ──
|
// ── Misc ──
|
||||||
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
{ group: 'Misc', label: t('rqg.c.comment'), colId: 'comment', headerName: t('rqg.c.comment'), field: 'comment' as any, flex: 1, minWidth: 160, defaultVisible: true },
|
||||||
@@ -262,6 +269,30 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// auto-save during that window. Set in the memo (runs at render, before the
|
// auto-save during that window. Set in the memo (runs at render, before the
|
||||||
// column events) and cleared by the re-apply effect below.
|
// column events) and cleared by the re-apply effect below.
|
||||||
const restoringRef = useRef(true);
|
const restoringRef = useRef(true);
|
||||||
|
|
||||||
|
// Award-column visibility is stored as an explicit set of award CODES, NOT via
|
||||||
|
// AG Grid's column-state round-trip. Those columns load asynchronously and
|
||||||
|
// race the state restore, and AG Grid preserves an existing column's visibility
|
||||||
|
// across a columnDefs rebuild instead of re-reading `hide` — which is exactly
|
||||||
|
// why previously-shown award columns kept vanishing on reopen. A dedicated set
|
||||||
|
// is deterministic: it drives `hide` directly and is re-enforced below.
|
||||||
|
const AWARD_SHOWN_KEY = 'hamlog.awardColsShown';
|
||||||
|
const [awardShown, setAwardShown] = useState<Set<string>>(() => new Set((loadLocal(AWARD_SHOWN_KEY) ?? []).map((s) => String(s).toUpperCase())));
|
||||||
|
useEffect(() => {
|
||||||
|
// Fresh machine: hydrate the set from the portable DB copy, then seed the cache.
|
||||||
|
if (loadLocal(AWARD_SHOWN_KEY)) return;
|
||||||
|
loadRemote(AWARD_SHOWN_KEY).then((remote) => {
|
||||||
|
if (remote && remote.length) {
|
||||||
|
seedLocal(AWARD_SHOWN_KEY, remote);
|
||||||
|
setAwardShown(new Set(remote.map((s: any) => String(s).toUpperCase())));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
const persistAwardShown = useCallback((next: Set<string>) => {
|
||||||
|
setAwardShown(next);
|
||||||
|
saveState(AWARD_SHOWN_KEY, [...next]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
const columnDefs = useMemo<ColDef<QSOForm>[]>(() => {
|
||||||
restoringRef.current = true;
|
restoringRef.current = true;
|
||||||
const base = COL_CATALOG.map((c) => {
|
const base = COL_CATALOG.map((c) => {
|
||||||
@@ -274,16 +305,26 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
headerTooltip: t('rqg.awardTip', { name: a.name }),
|
||||||
width: 110,
|
width: 110,
|
||||||
cellClass: 'text-[11px]',
|
cellClass: 'text-[11px]',
|
||||||
// Hidden by DEFAULT (award columns are opt-in). Without this, AG Grid shows
|
// Visibility comes from the persisted award-code set, so a column the user
|
||||||
// them whenever the saved column state doesn't cover them — a newly-added
|
// showed reappears on reopen and one they didn't stays hidden.
|
||||||
// award, an empty cache, or a rebuild that runs before the saved state is
|
hide: !awardShown.has(a.code.toUpperCase()),
|
||||||
// re-applied — which is why "all award columns" kept reappearing. The user's
|
|
||||||
// saved state (a column they explicitly showed) still overrides this.
|
|
||||||
hide: true,
|
|
||||||
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
valueGetter: (p) => (p.data as any)?.award_refs?.[a.code.toUpperCase()] ?? '',
|
||||||
}));
|
}));
|
||||||
return [...base, ...awards];
|
return [...base, ...awards];
|
||||||
}, [awardCols, COL_CATALOG, t]);
|
}, [awardCols, COL_CATALOG, t, awardShown]);
|
||||||
|
|
||||||
|
// Enforce award-column visibility via the API after the columns (re)appear —
|
||||||
|
// colDef.hide alone isn't honoured by AG Grid for a column that already exists,
|
||||||
|
// so we set it explicitly whenever the award set or the loaded columns change.
|
||||||
|
useEffect(() => {
|
||||||
|
const api = gridRef.current?.api;
|
||||||
|
if (!api || !awardCols?.length) return;
|
||||||
|
for (const a of awardCols) {
|
||||||
|
const want = awardShown.has(a.code.toUpperCase());
|
||||||
|
const col = api.getColumn(`award_${a.code}`);
|
||||||
|
if (col && col.isVisible() !== want) api.setColumnsVisible([`award_${a.code}`], want);
|
||||||
|
}
|
||||||
|
}, [awardCols, awardShown]);
|
||||||
|
|
||||||
const defaultColDef = useMemo<ColDef>(() => ({
|
const defaultColDef = useMemo<ColDef>(() => ({
|
||||||
sortable: true,
|
sortable: true,
|
||||||
@@ -342,10 +383,22 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
// state for "which columns are visible" — AG Grid's column state is the
|
// state for "which columns are visible" — AG Grid's column state is the
|
||||||
// source of truth, and saveColumnState persists it.
|
// source of truth, and saveColumnState persists it.
|
||||||
function isColVisible(colId: string): boolean {
|
function isColVisible(colId: string): boolean {
|
||||||
|
// Award columns: the persisted set is the source of truth (see awardShown).
|
||||||
|
if (colId.startsWith('award_')) return awardShown.has(colId.slice('award_'.length).toUpperCase());
|
||||||
const col = gridRef.current?.api?.getColumn(colId);
|
const col = gridRef.current?.api?.getColumn(colId);
|
||||||
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
return col ? col.isVisible() : !!COL_CATALOG.find((c) => c.colId === colId)?.defaultVisible;
|
||||||
}
|
}
|
||||||
function setColVisible(colId: string, visible: boolean) {
|
function setColVisible(colId: string, visible: boolean) {
|
||||||
|
// Award columns are driven by the persisted code set — update it and let the
|
||||||
|
// enforce effect apply visibility. This is what makes them stick across reopen.
|
||||||
|
if (colId.startsWith('award_')) {
|
||||||
|
const code = colId.slice('award_'.length).toUpperCase();
|
||||||
|
const next = new Set(awardShown);
|
||||||
|
if (visible) next.add(code); else next.delete(code);
|
||||||
|
persistAwardShown(next);
|
||||||
|
gridRef.current?.api?.setColumnsVisible([colId], visible);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const api = gridRef.current?.api;
|
const api = gridRef.current?.api;
|
||||||
if (!api) return;
|
if (!api) return;
|
||||||
api.setColumnsVisible([colId], visible);
|
api.setColumnsVisible([colId], visible);
|
||||||
@@ -373,6 +426,9 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
api.setColumnsVisible(visible, true);
|
api.setColumnsVisible(visible, true);
|
||||||
api.setColumnsVisible(hidden, false);
|
api.setColumnsVisible(hidden, false);
|
||||||
saveColumnState();
|
saveColumnState();
|
||||||
|
// Award columns are opt-in: reset hides them all.
|
||||||
|
persistAwardShown(new Set());
|
||||||
|
(awardCols ?? []).forEach((a) => api.setColumnsVisible([`award_${a.code}`], false));
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -469,8 +525,8 @@ export function RecentQSOsGrid({ rows, selectAllSignal, onRowDoubleClicked, onRo
|
|||||||
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
<div className="flex items-center justify-between mb-2 pb-1.5 border-b border-border/60">
|
||||||
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{t('rqg.grpAwards')}</span>
|
||||||
<div className="flex gap-0.5">
|
<div className="flex gap-0.5">
|
||||||
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, true)); }}>{t('rqg.all')}</button>
|
<button className="text-[10px] text-primary hover:underline px-1" onClick={() => { persistAwardShown(new Set(awardCols.map((a) => a.code.toUpperCase()))); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], true)); }}>{t('rqg.all')}</button>
|
||||||
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { awardCols.forEach((a) => setColVisible(`award_${a.code}`, false)); }}>{t('rqg.none')}</button>
|
<button className="text-[10px] text-muted-foreground hover:underline px-1" onClick={() => { persistAwardShown(new Set()); awardCols.forEach((a) => gridRef.current?.api?.setColumnsVisible([`award_${a.code}`], false)); }}>{t('rqg.none')}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
|
|||||||
@@ -259,7 +259,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
udp: 'UDP integrations',
|
udp: 'UDP integrations',
|
||||||
awards: 'Awards',
|
awards: 'Awards',
|
||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'PstRotator',
|
rotator: 'Rotator',
|
||||||
winkeyer: 'CW Keyer',
|
winkeyer: 'CW Keyer',
|
||||||
antenna: 'Ultrabeam / Steppir',
|
antenna: 'Ultrabeam / Steppir',
|
||||||
antgenius: 'Antenna Genius',
|
antgenius: 'Antenna Genius',
|
||||||
@@ -821,14 +821,14 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
digital_default: 'FT8',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||||
enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false,
|
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
||||||
});
|
} as any);
|
||||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
|
|
||||||
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number }>({
|
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; tx_inhibit: boolean }>({
|
||||||
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50,
|
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, tx_inhibit: false,
|
||||||
});
|
});
|
||||||
const [ubTesting, setUbTesting] = useState(false);
|
const [ubTesting, setUbTesting] = useState(false);
|
||||||
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||||
@@ -2245,7 +2245,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
setRotatorTest(null);
|
setRotatorTest(null);
|
||||||
try {
|
try {
|
||||||
await TestRotator(rotator as any);
|
await TestRotator(rotator as any);
|
||||||
setRotatorTest({ ok: true, msg: t('cat.rotatorOk') });
|
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2373,6 +2373,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-t border-border/60 pt-3 space-y-1">
|
||||||
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
|
<Checkbox checked={ultrabeam.tx_inhibit} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, tx_inhibit: !!c }))} />
|
||||||
|
{t('hw.motorTxInhibit')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-muted-foreground pl-6">{t('hw.motorTxInhibitHint')}</p>
|
||||||
|
</div>
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
|
<Button variant="outline" size="sm" onClick={testUltrabeam} disabled={ubTesting || (isSerial ? !ultrabeam.com.trim() : !ultrabeam.host.trim())}>
|
||||||
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
|
{ubTesting ? t('hw.connecting') : t('hw.testConn')}
|
||||||
@@ -2467,41 +2474,73 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function RotatorPanel() {
|
function RotatorPanel() {
|
||||||
|
const isRG = (rotator as any).type === 'rotgenius';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title="Rotator"
|
title="Rotator"
|
||||||
hint={t('rot.hint')}
|
hint={isRG ? undefined : t('rot.hint')}
|
||||||
/>
|
/>
|
||||||
<div className="space-y-4 max-w-xl">
|
<div className="space-y-4 max-w-xl">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable PstRotator control
|
{t('rot.enable')}
|
||||||
</label>
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.type')}</Label>
|
||||||
|
{/* Switching to Rotator Genius moves the default port to its native
|
||||||
|
9006; back to PstRotator restores 12000. */}
|
||||||
|
<Select value={(rotator as any).type ?? 'pst'}
|
||||||
|
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||||
|
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{isRG && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('rot.rotatorNum')}</Label>
|
||||||
|
<Select value={String((rotator as any).rotator_num ?? 1)}
|
||||||
|
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="1">1</SelectItem>
|
||||||
|
<SelectItem value="2">2</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-3 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
<div className="space-y-1 col-span-2">
|
||||||
<Label>Host</Label>
|
<Label>Host / IP</Label>
|
||||||
<Input
|
<Input
|
||||||
value={rotator.host ?? ''}
|
value={rotator.host ?? ''}
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
||||||
placeholder="127.0.0.1"
|
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>UDP port</Label>
|
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
|
||||||
<Input
|
<Input
|
||||||
type="number" min={1} max={65535}
|
type="number" min={1} max={65535}
|
||||||
value={rotator.port}
|
value={rotator.port}
|
||||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || 12000 }))}
|
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{!isRG && (
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||||
This rotator supports elevation (VHF / satellite)
|
This rotator supports elevation (VHF / satellite)
|
||||||
</label>
|
</label>
|
||||||
|
)}
|
||||||
|
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||||
<div className="flex items-center gap-2 pt-2">
|
<div className="flex items-center gap-2 pt-2">
|
||||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
||||||
@@ -2509,9 +2548,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||||
Stop
|
Stop
|
||||||
</Button>
|
</Button>
|
||||||
|
{!isRG && (
|
||||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||||
Park
|
Park
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{rotatorTest && (
|
{rotatorTest && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
|
|||||||
@@ -0,0 +1,329 @@
|
|||||||
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useI18n } from '@/lib/i18n';
|
||||||
|
import { writeUiPref } from '@/lib/uiPref';
|
||||||
|
import { RotorCompass } from '@/components/RotorCompass';
|
||||||
|
import {
|
||||||
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
|
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||||
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
|
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||||
|
|
||||||
|
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
|
||||||
|
type Relay = { number: number; label: string; on: boolean };
|
||||||
|
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||||
|
|
||||||
|
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
|
||||||
|
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
|
||||||
|
|
||||||
|
function blankDevice(): Device {
|
||||||
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
||||||
|
|
||||||
|
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||||
|
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||||
|
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
|
||||||
|
// passed in (so the panel can also order this widget); it drives the shared
|
||||||
|
// rotator backend.
|
||||||
|
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
|
||||||
|
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const [goto, setGoto] = useState('');
|
||||||
|
const [err, setErr] = useState('');
|
||||||
|
|
||||||
|
const turn = (az: number) => {
|
||||||
|
const a = ((Math.round(az) % 360) + 360) % 360;
|
||||||
|
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||||
|
};
|
||||||
|
const poll = refetch;
|
||||||
|
|
||||||
|
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||||
|
<Compass className="size-4 text-primary" />
|
||||||
|
<div className="text-sm font-semibold">{t('station.rotator')}</div>
|
||||||
|
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
|
||||||
|
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||||
|
</div>
|
||||||
|
<div className="p-3 flex gap-4 items-start">
|
||||||
|
<RotorCompass
|
||||||
|
bearing={bearing ?? null}
|
||||||
|
headings={hd.ok ? [hd.azimuth] : []}
|
||||||
|
centerLat={centerLat ?? null}
|
||||||
|
centerLon={centerLon ?? null}
|
||||||
|
rotorEnabled={hd.ok}
|
||||||
|
onGoto={(az) => turn(az)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0 space-y-2">
|
||||||
|
<div className="font-mono">
|
||||||
|
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{presets.map(([lbl, az]) => (
|
||||||
|
<button key={lbl} type="button" onClick={() => turn(az)}
|
||||||
|
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
|
||||||
|
{lbl}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
|
||||||
|
placeholder="0–359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
|
||||||
|
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
|
||||||
|
<Square className="size-3 mr-1" /> {t('station.stop')}
|
||||||
|
</Button>
|
||||||
|
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const [devices, setDevices] = useState<Device[]>([]);
|
||||||
|
const [status, setStatus] = useState<Record<string, DevStatus>>({});
|
||||||
|
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
|
||||||
|
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
|
||||||
|
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
|
||||||
|
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
|
||||||
|
const [order, setOrder] = useState<string[]>(() => {
|
||||||
|
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||||
|
});
|
||||||
|
const dragId = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const loadDevices = useCallback(async () => {
|
||||||
|
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const poll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const s = ((await GetStationStatus()) ?? []) as DevStatus[];
|
||||||
|
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
||||||
|
} catch { /* ignore transient */ }
|
||||||
|
}, []);
|
||||||
|
const pollRot = useCallback(async () => {
|
||||||
|
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||||
|
useEffect(() => {
|
||||||
|
poll(); pollRot();
|
||||||
|
const id = window.setInterval(() => { poll(); pollRot(); }, 3000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [poll, pollRot, devices.length]);
|
||||||
|
|
||||||
|
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||||
|
// Reorder so `dragged` lands just before `target`.
|
||||||
|
const onDrop = (targetId: string) => {
|
||||||
|
const src = dragId.current; dragId.current = null;
|
||||||
|
if (!src || src === targetId) return;
|
||||||
|
const ids = widgetIds.filter((id) => id !== src);
|
||||||
|
const at = ids.indexOf(targetId);
|
||||||
|
ids.splice(at < 0 ? ids.length : at, 0, src);
|
||||||
|
persistOrder(ids);
|
||||||
|
};
|
||||||
|
|
||||||
|
const persist = async (next: Device[]) => {
|
||||||
|
setDevices(next);
|
||||||
|
try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
|
||||||
|
await loadDevices();
|
||||||
|
poll();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (dev: Device, relay: number, on: boolean) => {
|
||||||
|
const key = `${dev.id}:${relay}`;
|
||||||
|
setBusy((b) => ({ ...b, [key]: true }));
|
||||||
|
// Optimistic flip so the switch feels instant; the poll reconciles.
|
||||||
|
setStatus((st) => {
|
||||||
|
const d = st[dev.id]; if (!d) return st;
|
||||||
|
return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
|
||||||
|
});
|
||||||
|
try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
|
||||||
|
await poll();
|
||||||
|
setBusy((b) => ({ ...b, [key]: false }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveEdit = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
|
||||||
|
const exists = devices.some((x) => x.id && x.id === d.id);
|
||||||
|
await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
|
||||||
|
setEditing(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };
|
||||||
|
|
||||||
|
// Build the widget list (rotator first by default, then devices), then order it
|
||||||
|
// by the saved drag order — unknown ids fall to the end in their natural order.
|
||||||
|
const deviceCard = (dev: Device) => {
|
||||||
|
const st = status[dev.id];
|
||||||
|
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
||||||
|
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">
|
||||||
|
<PlugZap className="size-4 text-primary" />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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>
|
||||||
|
<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')}
|
||||||
|
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||||||
|
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||||
|
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 grid grid-cols-2 gap-2">
|
||||||
|
{relays.map((r) => {
|
||||||
|
const key = `${dev.id}:${r.number}`;
|
||||||
|
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||||
|
return (
|
||||||
|
<button key={r.number} type="button" disabled={!st?.connected}
|
||||||
|
onClick={() => toggle(dev, r.number, !r.on)}
|
||||||
|
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
|
||||||
|
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||||
|
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0',
|
||||||
|
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||||
|
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-xs font-medium truncate">{label}</span>
|
||||||
|
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
|
||||||
|
{r.on ? t('station.on') : t('station.off')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const widgets: { id: string; node: React.ReactNode }[] = [];
|
||||||
|
if (rot.enabled) {
|
||||||
|
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||||
|
}
|
||||||
|
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 ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||||
|
const widgetIds = ordered.map((w) => w.id);
|
||||||
|
|
||||||
|
const noDevices = devices.length === 0 && !rot.enabled;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3 max-w-4xl">
|
||||||
|
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||||
|
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{noDevices && !editing && (
|
||||||
|
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||||
|
{t('station.empty')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-3 max-w-4xl md:grid-cols-2 items-start">
|
||||||
|
{ordered.map((w) => (
|
||||||
|
<div key={w.id} draggable
|
||||||
|
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||||
|
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||||
|
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
|
||||||
|
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
|
||||||
|
{w.node}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!noDevices && (
|
||||||
|
<p className="text-[11px] text-muted-foreground mt-2 max-w-4xl">{t('station.dragHint')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||||
|
device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||||
|
const setType = (type: string) => {
|
||||||
|
const n = RELAY_COUNT[type] ?? 5;
|
||||||
|
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||||
|
onChange({ ...device, type, labels });
|
||||||
|
};
|
||||||
|
const isKM = device.type === 'kmtronic';
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||||||
|
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.type')}</Label>
|
||||||
|
<Select value={device.type} onValueChange={setType}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||||
|
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.name')}</Label>
|
||||||
|
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||||
|
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||||
|
<Label>{t('station.host')}</Label>
|
||||||
|
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
{isKM && (
|
||||||
|
<>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.user')}</Label>
|
||||||
|
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.pass')}</Label>
|
||||||
|
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('station.labels')}</Label>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{device.labels.map((lab, i) => (
|
||||||
|
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||||||
|
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+16
-16
File diff suppressed because one or more lines are too long
@@ -26,6 +26,12 @@ const PORTABLE_KEYS = [
|
|||||||
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
'opslog.lookupOnBlur', // run the callsign lookup on blur instead of while typing
|
||||||
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
'opslog.clusterShowFilters', // cluster filter sidebar shown (tab + Main pane)
|
||||||
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
'opslog.mapBasemap', // world map basemap (light / street / satellite)
|
||||||
|
// Cluster filter selections — restored on reopen.
|
||||||
|
'opslog.clusterFilterSource', 'opslog.clusterGroup', 'opslog.clusterBands',
|
||||||
|
'opslog.clusterLockBand', 'opslog.clusterLockMode', 'opslog.clusterStatusFilter',
|
||||||
|
'opslog.clusterModeFilter', 'opslog.clusterSearch', 'opslog.clusterHideWorked',
|
||||||
|
'opslog.activeTab', // last selected tab
|
||||||
|
'hamlog.awardColsShown', // which award columns are shown in the QSO grid
|
||||||
];
|
];
|
||||||
|
|
||||||
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
// syncPortablePrefs reconciles the DB with the local cache at startup:
|
||||||
|
|||||||
@@ -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.19.7';
|
export const APP_VERSION = '0.19.8';
|
||||||
|
|
||||||
// Author / credits, shown in Help -> About.
|
// Author / credits, shown in Help -> About.
|
||||||
export const APP_AUTHOR = 'F4BPO';
|
export const APP_AUTHOR = 'F4BPO';
|
||||||
|
|||||||
Vendored
+8
@@ -386,8 +386,12 @@ export function GetSolarData():Promise<solar.Data>;
|
|||||||
|
|
||||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||||
|
|
||||||
|
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||||
|
|
||||||
export function GetStationSettings():Promise<main.StationSettings>;
|
export function GetStationSettings():Promise<main.StationSettings>;
|
||||||
|
|
||||||
|
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||||
|
|
||||||
export function GetTelemetryEnabled():Promise<boolean>;
|
export function GetTelemetryEnabled():Promise<boolean>;
|
||||||
|
|
||||||
export function GetUIPref(arg1:string):Promise<string>;
|
export function GetUIPref(arg1:string):Promise<string>;
|
||||||
@@ -718,6 +722,8 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
|||||||
|
|
||||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||||
|
|
||||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||||
|
|
||||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||||
@@ -766,6 +772,8 @@ export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
|||||||
|
|
||||||
export function StartCWDecoder():Promise<void>;
|
export function StartCWDecoder():Promise<void>;
|
||||||
|
|
||||||
|
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||||
|
|
||||||
export function StopCWDecoder():Promise<void>;
|
export function StopCWDecoder():Promise<void>;
|
||||||
|
|
||||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||||
|
|||||||
@@ -730,10 +730,18 @@ export function GetStartupStatus() {
|
|||||||
return window['go']['main']['App']['GetStartupStatus']();
|
return window['go']['main']['App']['GetStartupStatus']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetStationDevices() {
|
||||||
|
return window['go']['main']['App']['GetStationDevices']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetStationSettings() {
|
export function GetStationSettings() {
|
||||||
return window['go']['main']['App']['GetStationSettings']();
|
return window['go']['main']['App']['GetStationSettings']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function GetStationStatus() {
|
||||||
|
return window['go']['main']['App']['GetStationStatus']();
|
||||||
|
}
|
||||||
|
|
||||||
export function GetTelemetryEnabled() {
|
export function GetTelemetryEnabled() {
|
||||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||||
}
|
}
|
||||||
@@ -1394,6 +1402,10 @@ export function SaveRotatorSettings(arg1) {
|
|||||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveStationDevices(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveStationDevices'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function SaveStationSettings(arg1) {
|
export function SaveStationSettings(arg1) {
|
||||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||||
}
|
}
|
||||||
@@ -1490,6 +1502,10 @@ export function StartCWDecoder() {
|
|||||||
return window['go']['main']['App']['StartCWDecoder']();
|
return window['go']['main']['App']['StartCWDecoder']();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function StationSetRelay(arg1, arg2, arg3) {
|
||||||
|
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||||
|
}
|
||||||
|
|
||||||
export function StopCWDecoder() {
|
export function StopCWDecoder() {
|
||||||
return window['go']['main']['App']['StopCWDecoder']();
|
return window['go']['main']['App']['StopCWDecoder']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2333,9 +2333,11 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
export class RotatorSettings {
|
export class RotatorSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
has_elevation: boolean;
|
has_elevation: boolean;
|
||||||
|
rotator_num: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RotatorSettings(source);
|
return new RotatorSettings(source);
|
||||||
@@ -2344,9 +2346,11 @@ export namespace main {
|
|||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.enabled = source["enabled"];
|
this.enabled = source["enabled"];
|
||||||
|
this.type = source["type"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
this.has_elevation = source["has_elevation"];
|
this.has_elevation = source["has_elevation"];
|
||||||
|
this.rotator_num = source["rotator_num"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class SecretStatus {
|
export class SecretStatus {
|
||||||
@@ -2419,6 +2423,86 @@ export namespace main {
|
|||||||
this.db_path = source["db_path"];
|
this.db_path = source["db_path"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
export class StationDevice {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
name: string;
|
||||||
|
host: string;
|
||||||
|
user?: string;
|
||||||
|
pass?: string;
|
||||||
|
labels: string[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StationDevice(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.host = source["host"];
|
||||||
|
this.user = source["user"];
|
||||||
|
this.pass = source["pass"];
|
||||||
|
this.labels = source["labels"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class StationRelay {
|
||||||
|
number: number;
|
||||||
|
label: string;
|
||||||
|
on: boolean;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StationRelay(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.number = source["number"];
|
||||||
|
this.label = source["label"];
|
||||||
|
this.on = source["on"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export class StationDeviceStatus {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: string;
|
||||||
|
connected: boolean;
|
||||||
|
error?: string;
|
||||||
|
relays: StationRelay[];
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new StationDeviceStatus(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.id = source["id"];
|
||||||
|
this.name = source["name"];
|
||||||
|
this.type = source["type"];
|
||||||
|
this.connected = source["connected"];
|
||||||
|
this.error = source["error"];
|
||||||
|
this.relays = this.convertValues(source["relays"], StationRelay);
|
||||||
|
}
|
||||||
|
|
||||||
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
|
if (!a) {
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
if (a.slice && a.map) {
|
||||||
|
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||||
|
} else if ("object" === typeof a) {
|
||||||
|
if (asMap) {
|
||||||
|
for (const key of Object.keys(a)) {
|
||||||
|
a[key] = new classs(a[key]);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
return new classs(a);
|
||||||
|
}
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
}
|
||||||
export class StationInfoComputed {
|
export class StationInfoComputed {
|
||||||
country: string;
|
country: string;
|
||||||
dxcc: number;
|
dxcc: number;
|
||||||
@@ -2441,6 +2525,7 @@ export namespace main {
|
|||||||
this.lon = source["lon"];
|
this.lon = source["lon"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StationSettings {
|
export class StationSettings {
|
||||||
callsign: string;
|
callsign: string;
|
||||||
operator: string;
|
operator: string;
|
||||||
@@ -2473,6 +2558,7 @@ export namespace main {
|
|||||||
baud: number;
|
baud: number;
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
step_khz: number;
|
step_khz: number;
|
||||||
|
tx_inhibit: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new UltrabeamSettings(source);
|
return new UltrabeamSettings(source);
|
||||||
@@ -2489,6 +2575,7 @@ export namespace main {
|
|||||||
this.baud = source["baud"];
|
this.baud = source["baud"];
|
||||||
this.follow = source["follow"];
|
this.follow = source["follow"];
|
||||||
this.step_khz = source["step_khz"];
|
this.step_khz = source["step_khz"];
|
||||||
|
this.tx_inhibit = source["tx_inhibit"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
export class UltrabeamStatusInfo {
|
export class UltrabeamStatusInfo {
|
||||||
|
|||||||
@@ -352,6 +352,7 @@ type FlexController interface {
|
|||||||
SetRFPower(int) error
|
SetRFPower(int) error
|
||||||
SetTunePower(int) error
|
SetTunePower(int) error
|
||||||
SetTune(bool) error
|
SetTune(bool) error
|
||||||
|
SetTXInhibit(bool) error
|
||||||
SetVOX(bool) error
|
SetVOX(bool) error
|
||||||
SetVOXLevel(int) error
|
SetVOXLevel(int) error
|
||||||
SetVOXDelay(int) error
|
SetVOXDelay(int) error
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ type flexTX struct {
|
|||||||
tunePower int
|
tunePower int
|
||||||
tune bool
|
tune bool
|
||||||
transmitting bool // interlock state == TRANSMITTING
|
transmitting bool // interlock state == TRANSMITTING
|
||||||
|
inhibit bool // transmit inhibited (e.g. while a motorized antenna moves)
|
||||||
voxEnable bool
|
voxEnable bool
|
||||||
voxLevel int
|
voxLevel int
|
||||||
voxDelay int
|
voxDelay int
|
||||||
@@ -1708,6 +1709,23 @@ func (f *Flex) SetTune(on bool) error {
|
|||||||
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
|
return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetTXInhibit blocks (on=true) or allows transmission at the radio. Used to keep
|
||||||
|
// the operator from keying while a motorized antenna's elements are moving —
|
||||||
|
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
|
||||||
|
// footswitch or an external keyer, which a software PTT block could not.
|
||||||
|
//
|
||||||
|
// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is
|
||||||
|
// blocked. The reason is best-effort (a separate interlock object); the inhibit
|
||||||
|
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
|
||||||
|
func (f *Flex) SetTXInhibit(on bool) error {
|
||||||
|
if on {
|
||||||
|
f.send("interlock set reason=OpsLog")
|
||||||
|
} else {
|
||||||
|
f.send("interlock set reason=")
|
||||||
|
}
|
||||||
|
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
|
||||||
|
}
|
||||||
|
|
||||||
func (f *Flex) SetVOX(on bool) error {
|
func (f *Flex) SetVOX(on bool) error {
|
||||||
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
|
return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on })
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-1
@@ -286,19 +286,33 @@ func (n *icomNet) ctrlPump() {
|
|||||||
switch icnLE.Uint16(buf[4:]) {
|
switch icnLE.Uint16(buf[4:]) {
|
||||||
case 0x07: // ping
|
case 0x07: // ping
|
||||||
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
_, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote))
|
||||||
|
case 0x00: // idle keepalive from the rig — nothing to do
|
||||||
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
case 0x01: // retransmit request — resend from the CONTROL sent-buffer
|
||||||
if k >= 8 {
|
if k >= 8 {
|
||||||
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
n.ctrlResend(icnLE.Uint16(buf[6:]))
|
||||||
}
|
}
|
||||||
case 0x05: // rig-initiated disconnect — it dropped US
|
case 0x05: // rig-initiated disconnect — it dropped US
|
||||||
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig")
|
||||||
|
default:
|
||||||
|
// Anything else on the control stream is (almost always) the rig's
|
||||||
|
// reply to our token renewal. Log it: a 0x40-length token packet
|
||||||
|
// carries a result code, and if the rig is REJECTING renewals this is
|
||||||
|
// where the ~2-3 min disconnect originates. The hex makes the cause
|
||||||
|
// visible in the friend's log without a protocol analyzer.
|
||||||
|
if k >= 0x18 {
|
||||||
|
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:0x18])
|
||||||
|
} else {
|
||||||
|
debugLog.Printf("icom net: control reply len=%d head=% X", k, buf[:k])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if time.Since(lastIdle) > 100*time.Millisecond {
|
if time.Since(lastIdle) > 100*time.Millisecond {
|
||||||
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
_, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote))
|
||||||
lastIdle = time.Now()
|
lastIdle = time.Now()
|
||||||
}
|
}
|
||||||
if time.Since(lastToken) > 45*time.Second {
|
// Renew well inside the rig's ~2-min token timeout. 30 s (was 45) leaves room
|
||||||
|
// for one lost renewal + its retransmit before the token would lapse.
|
||||||
|
if time.Since(lastToken) > 30*time.Second {
|
||||||
n.renewToken()
|
n.renewToken()
|
||||||
lastToken = time.Now()
|
lastToken = time.Now()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,6 +283,7 @@ func (b *IcomSerial) ReadState() (RigState, error) {
|
|||||||
b.dspMu.Unlock()
|
b.dspMu.Unlock()
|
||||||
return s, nil
|
return s, nil
|
||||||
}
|
}
|
||||||
|
debugLog.Printf("icom net: control link went quiet (no rig packets for >6 s) → reconnecting. If this recurs every ~2-3 min, the rig is invalidating the session (token renewal rejected).")
|
||||||
return RigState{}, err // control link dead → let the Manager reconnect
|
return RigState{}, err // control link dead → let the Manager reconnect
|
||||||
}
|
}
|
||||||
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
// USB (no liveness signal): the rig briefly stops answering CI-V while it
|
||||||
|
|||||||
@@ -52,6 +52,11 @@ type Event struct {
|
|||||||
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
DecodeFreqHz int64 // RF frequency (dial + audio offset)
|
||||||
DecodeSNR int // reported SNR (dB)
|
DecodeSNR int // reported SNR (dB)
|
||||||
DecodeCQ bool // the decode was a CQ
|
DecodeCQ bool // the decode was a CQ
|
||||||
|
|
||||||
|
// ClearCall is set when a WSJT/JTDX/MSHV Status message reports an EMPTY DX
|
||||||
|
// Call after previously reporting one — i.e. the operator cleared the call in
|
||||||
|
// the digital app. OpsLog clears its entry to match.
|
||||||
|
ClearCall bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server is a single inbound UDP listener.
|
// Server is a single inbound UDP listener.
|
||||||
@@ -65,6 +70,7 @@ type Server struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
|
||||||
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
lastDialHz int64 // WSJT: dial freq from the last Status, added to Decode offsets
|
||||||
|
lastDX string // WSJT: last non-empty DX Call seen, to detect a clear
|
||||||
}
|
}
|
||||||
|
|
||||||
func newServer(cfg Config, out chan<- Event) *Server {
|
func newServer(cfg Config, out chan<- Event) *Server {
|
||||||
@@ -213,6 +219,17 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
ev.Mode = w.Mode
|
ev.Mode = w.Mode
|
||||||
ev.FreqHz = w.FreqHz
|
ev.FreqHz = w.FreqHz
|
||||||
ev.LoggedADIF = w.LoggedADIF
|
ev.LoggedADIF = w.LoggedADIF
|
||||||
|
// A Status with an empty DX Call, right after one that had a call, means the
|
||||||
|
// operator cleared it in WSJT-X / JTDX / MSHV. Fire ONE clear (tracked per
|
||||||
|
// server) — an idle app sends empty Status every second, and we must not
|
||||||
|
// re-clear (which would fight a manual entry) on each of those.
|
||||||
|
s.mu.Lock()
|
||||||
|
prev := s.lastDX
|
||||||
|
s.lastDX = w.DXCall
|
||||||
|
s.mu.Unlock()
|
||||||
|
if w.DXCall == "" && prev != "" {
|
||||||
|
ev.ClearCall = true
|
||||||
|
}
|
||||||
case ServiceADIF:
|
case ServiceADIF:
|
||||||
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
// JTAlert / GridTracker forward a text ADIF record after a QSO is
|
||||||
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
|
// logged. Guard against keep-alive / non-ADIF chatter on the socket:
|
||||||
@@ -268,8 +285,9 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
|||||||
default:
|
default:
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Empty events are useless; skip.
|
// Empty events are useless; skip — EXCEPT a clear signal, which is meant to be
|
||||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" {
|
// empty (the DX Call was cleared in the digital app).
|
||||||
|
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.DecodeCall == "" && !ev.ClearCall {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
select {
|
select {
|
||||||
|
|||||||
@@ -724,6 +724,10 @@ var bulkEditableCols = map[string]bool{
|
|||||||
"my_antenna": true,
|
"my_antenna": true,
|
||||||
"my_sig": true,
|
"my_sig": true,
|
||||||
"my_sig_info": true,
|
"my_sig_info": true,
|
||||||
|
"my_name": true,
|
||||||
|
"my_arrl_sect": true,
|
||||||
|
"my_darc_dok": true,
|
||||||
|
"my_vucc_grids": true,
|
||||||
// Contest — the exchange/label fields that are constant across a run.
|
// Contest — the exchange/label fields that are constant across a run.
|
||||||
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
// (srx/stx serial numbers stay excluded: they are per-QSO.)
|
||||||
"contest_id": true,
|
"contest_id": true,
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
// Package relaydev drives network relay boards used for station control (power
|
||||||
|
// sequencing, switching accessories). Two devices are supported, both over HTTP:
|
||||||
|
//
|
||||||
|
// - WebSwitch 1216H — 5 relays. Control: GET /relaycontrol/{on|off}/{n};
|
||||||
|
// status: GET /relaystate/get2/1$2$3$4$5$ → lines "n,state".
|
||||||
|
// (Protocol taken from the operator's own working ShackMaster driver.)
|
||||||
|
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
|
||||||
|
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
|
||||||
|
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
|
||||||
|
//
|
||||||
|
// A Device presents the same surface to the app regardless of wire protocol.
|
||||||
|
package relaydev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Device is one relay board.
|
||||||
|
type Device interface {
|
||||||
|
Count() int // number of user-controllable relays
|
||||||
|
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
|
||||||
|
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
|
||||||
|
}
|
||||||
|
|
||||||
|
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
|
||||||
|
|
||||||
|
// get issues a GET with optional basic auth and returns the body on 2xx.
|
||||||
|
func get(ctx context.Context, url, user, pass string) ([]byte, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if user != "" || pass != "" {
|
||||||
|
req.SetBasicAuth(user, pass)
|
||||||
|
}
|
||||||
|
resp, err := httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── WebSwitch 1216H ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type webswitch struct {
|
||||||
|
host string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
|
||||||
|
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
|
||||||
|
|
||||||
|
func (w *webswitch) Count() int { return w.count }
|
||||||
|
|
||||||
|
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
||||||
|
if relay < 1 || relay > w.count {
|
||||||
|
return fmt.Errorf("relay %d out of range 1..%d", relay, w.count)
|
||||||
|
}
|
||||||
|
action := "off"
|
||||||
|
if on {
|
||||||
|
action = "on"
|
||||||
|
}
|
||||||
|
_, err := get(ctx, fmt.Sprintf("http://%s/relaycontrol/%s/%d", w.host, action, relay), "", "")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
|
||||||
|
// Build the "1$2$3$..." selector the device expects.
|
||||||
|
var sel strings.Builder
|
||||||
|
for i := 1; i <= w.count; i++ {
|
||||||
|
sel.WriteString(strconv.Itoa(i))
|
||||||
|
sel.WriteByte('$')
|
||||||
|
}
|
||||||
|
body, err := get(ctx, fmt.Sprintf("http://%s/relaystate/get2/%s", w.host, sel.String()), "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]bool, w.count)
|
||||||
|
// Lines "n,state" — "1,1", "2,0", …
|
||||||
|
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
||||||
|
parts := strings.Split(strings.TrimSpace(line), ",")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, e1 := strconv.Atoi(parts[0])
|
||||||
|
st, e2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||||
|
if e1 != nil || e2 != nil || n < 1 || n > w.count {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[n-1] = st == 1
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── KMTronic LAN 8-relay WEB ───────────────────────────────────────────
|
||||||
|
|
||||||
|
type kmtronic struct {
|
||||||
|
host string
|
||||||
|
user, pass string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewKMTronic builds a KMTronic LAN WEB relay client (8 relays). user/pass are
|
||||||
|
// blank unless the board's HTTP authentication is enabled.
|
||||||
|
func NewKMTronic(host, user, pass string) Device {
|
||||||
|
return &kmtronic{host: host, user: user, pass: pass, count: 8}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *kmtronic) Count() int { return k.count }
|
||||||
|
|
||||||
|
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
|
||||||
|
if relay < 1 || relay > k.count {
|
||||||
|
return fmt.Errorf("relay %d out of range 1..%d", relay, k.count)
|
||||||
|
}
|
||||||
|
state := "00"
|
||||||
|
if on {
|
||||||
|
state = "01"
|
||||||
|
}
|
||||||
|
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
|
||||||
|
_, err := get(ctx, fmt.Sprintf("http://%s/FF%02d%s", k.host, relay, state), k.user, k.pass)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// kmStatus mirrors status.xml. relay0 is reserved; relay1..relay8 are the board.
|
||||||
|
type kmStatus struct {
|
||||||
|
XMLName xml.Name `xml:"response"`
|
||||||
|
Relays []struct {
|
||||||
|
XMLName xml.Name
|
||||||
|
Value string `xml:",chardata"`
|
||||||
|
} `xml:",any"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
|
||||||
|
body, err := get(ctx, fmt.Sprintf("http://%s/status.xml", k.host), k.user, k.pass)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var s kmStatus
|
||||||
|
if err := xml.Unmarshal(body, &s); err != nil {
|
||||||
|
return nil, fmt.Errorf("kmtronic: bad status.xml: %w", err)
|
||||||
|
}
|
||||||
|
out := make([]bool, k.count)
|
||||||
|
for _, r := range s.Relays {
|
||||||
|
// Element names are relay0..relay8; relay0 is reserved.
|
||||||
|
name := r.XMLName.Local
|
||||||
|
if !strings.HasPrefix(name, "relay") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
n, e := strconv.Atoi(strings.TrimPrefix(name, "relay"))
|
||||||
|
if e != nil || n < 1 || n > k.count {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[n-1] = strings.TrimSpace(r.Value) == "1"
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package relaydev
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The status parsers are the risky part; pin them against the documented wire
|
||||||
|
// formats using a stub HTTP server.
|
||||||
|
|
||||||
|
func TestWebswitchStatus(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !strings.HasPrefix(r.URL.Path, "/relaystate/get2/") {
|
||||||
|
t.Errorf("unexpected status path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte("1,1\n2,0\n3,1\n4,0\n5,1\n"))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
|
||||||
|
st, err := d.Status(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
want := []bool{true, false, true, false, true}
|
||||||
|
if len(st) != 5 {
|
||||||
|
t.Fatalf("got %d relays, want 5", len(st))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if st[i] != want[i] {
|
||||||
|
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebswitchSetURL(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
|
||||||
|
if err := d.Set(context.Background(), 4, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotPath != "/relaycontrol/on/4" {
|
||||||
|
t.Errorf("set path = %q, want /relaycontrol/on/4", gotPath)
|
||||||
|
}
|
||||||
|
_ = d.Set(context.Background(), 4, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKMTronicStatus(t *testing.T) {
|
||||||
|
// relay0 reserved (ignored); relay7 + relay8 ON.
|
||||||
|
xmlBody := `<?xml version="1.0"?><response>` +
|
||||||
|
`<relay0>0</relay0><relay1>0</relay1><relay2>0</relay2><relay3>0</relay3>` +
|
||||||
|
`<relay4>0</relay4><relay5>0</relay5><relay6>0</relay6><relay7>1</relay7><relay8>1</relay8>` +
|
||||||
|
`</response>`
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/status.xml" {
|
||||||
|
t.Errorf("unexpected path %q", r.URL.Path)
|
||||||
|
}
|
||||||
|
_, _ = w.Write([]byte(xmlBody))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
|
||||||
|
st, err := d.Status(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(st) != 8 {
|
||||||
|
t.Fatalf("got %d relays, want 8", len(st))
|
||||||
|
}
|
||||||
|
if st[6] != true || st[7] != true {
|
||||||
|
t.Errorf("relay7/8 = %v/%v, want on/on", st[6], st[7])
|
||||||
|
}
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
if st[i] {
|
||||||
|
t.Errorf("relay %d unexpectedly on", i+1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKMTronicSetURL(t *testing.T) {
|
||||||
|
var gotPath string
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotPath = r.URL.Path
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
|
||||||
|
if err := d.Set(context.Background(), 8, true); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if gotPath != "/FF0801" {
|
||||||
|
t.Errorf("set path = %q, want /FF0801", gotPath)
|
||||||
|
}
|
||||||
|
_ = d.Set(context.Background(), 1, false) // → /FF0100
|
||||||
|
}
|
||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Package rotgenius drives a 4O3A Rotator Genius over its native TCP text
|
||||||
|
// protocol (rev 4, default port 9006). All data is fixed-length extended-ASCII;
|
||||||
|
// there is no sequence/framing wrapper — you send a short command and read back a
|
||||||
|
// fixed-length reply.
|
||||||
|
//
|
||||||
|
// Commands used here:
|
||||||
|
//
|
||||||
|
// |h read heading + full state (both rotators)
|
||||||
|
// |A<rot><az3> move rotator <rot> ('1'|'2') to azimuth az3 (000..360)
|
||||||
|
// |P<rot> / |M<rot> rotate CW / CCW
|
||||||
|
// |S stop all movement
|
||||||
|
//
|
||||||
|
// The |h reply is 72 bytes: "|h" + Active[1] + Panic[1] then, per rotator,
|
||||||
|
// CurrentAzimuth[3] LimitCW[3] LimitCCW[3] Config[1] Moving[1] Offset[4]
|
||||||
|
// TargetAzimuth[3] StartAzimuth[3] Limit[1] Name[12]. Numeric fields may be
|
||||||
|
// space-padded; a CurrentAzimuth of 999 means the sensor is not connected.
|
||||||
|
package rotgenius
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultPort = 9006
|
||||||
|
dialTimeout = 4 * time.Second
|
||||||
|
ioTimeout = 4 * time.Second
|
||||||
|
hdrReplyLen = 72 // fixed length of the |h reply
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status is one rotator's live state parsed from a |h reply.
|
||||||
|
type Status struct {
|
||||||
|
Azimuth int // current heading in degrees (0..360)
|
||||||
|
Connected bool // false when the sensor reports 999 (not connected)
|
||||||
|
Moving int // 0 not moving, 1 CW, 2 CCW
|
||||||
|
Target int // target azimuth when moving (else -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client is a stateless connector: each call opens a short-lived TCP connection,
|
||||||
|
// mirroring how the PstRotator client works, so there is no socket to manage.
|
||||||
|
type Client struct {
|
||||||
|
host string
|
||||||
|
port int
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(host string, port int) *Client {
|
||||||
|
if port <= 0 {
|
||||||
|
port = defaultPort
|
||||||
|
}
|
||||||
|
return &Client{host: host, port: port}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) dial() (net.Conn, error) {
|
||||||
|
d := net.Dialer{Timeout: dialTimeout}
|
||||||
|
conn, err := d.Dial("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
_ = conn.SetDeadline(time.Now().Add(ioTimeout))
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchange sends cmd and returns up to max bytes of the reply.
|
||||||
|
func (c *Client) exchange(cmd string, max int) ([]byte, error) {
|
||||||
|
conn, err := c.dial()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
if _, err := conn.Write([]byte(cmd)); err != nil {
|
||||||
|
return nil, fmt.Errorf("write %q: %w", cmd, err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 0, max)
|
||||||
|
tmp := make([]byte, max)
|
||||||
|
for len(buf) < max {
|
||||||
|
n, rerr := conn.Read(tmp)
|
||||||
|
if n > 0 {
|
||||||
|
buf = append(buf, tmp[:n]...)
|
||||||
|
}
|
||||||
|
if rerr != nil {
|
||||||
|
break // deadline or EOF — return what we have and let the parser judge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// atoiField trims the space-padding a Rotator Genius field may carry and parses
|
||||||
|
// it. An empty or non-numeric field yields 0.
|
||||||
|
func atoiField(s string) int {
|
||||||
|
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heading reads the current azimuth of the given rotator (1 or 2). raw is the
|
||||||
|
// decoded field for diagnostics.
|
||||||
|
func (c *Client) Heading(rotator int) (Status, string, error) {
|
||||||
|
st, err := c.Read(rotator)
|
||||||
|
if err != nil {
|
||||||
|
return Status{}, "", err
|
||||||
|
}
|
||||||
|
return st, strconv.Itoa(st.Azimuth), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read fetches and parses the full |h reply for one rotator (1 or 2).
|
||||||
|
func (c *Client) Read(rotator int) (Status, error) {
|
||||||
|
if rotator != 1 && rotator != 2 {
|
||||||
|
rotator = 1
|
||||||
|
}
|
||||||
|
reply, err := c.exchange("|h", hdrReplyLen)
|
||||||
|
if err != nil {
|
||||||
|
return Status{}, err
|
||||||
|
}
|
||||||
|
i := strings.Index(string(reply), "|h")
|
||||||
|
if i < 0 || len(reply)-i < hdrReplyLen {
|
||||||
|
return Status{}, fmt.Errorf("rotgenius: short |h reply (%d bytes)", len(reply))
|
||||||
|
}
|
||||||
|
p := reply[i:]
|
||||||
|
// Per-rotator block base: rotator 1 at offset 4, rotator 2 at 4+34=38.
|
||||||
|
base := 4
|
||||||
|
if rotator == 2 {
|
||||||
|
base = 38
|
||||||
|
}
|
||||||
|
// Within a rotator block: CurrentAzimuth@0, LimitCW@3, LimitCCW@6, Config@9,
|
||||||
|
// Moving@10, Offset@11, TargetAzimuth@15, StartAzimuth@18, Limit@21, Name@22.
|
||||||
|
cur := atoiField(string(p[base : base+3]))
|
||||||
|
moving := atoiField(string(p[base+10 : base+11]))
|
||||||
|
target := atoiField(string(p[base+15 : base+18]))
|
||||||
|
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
|
||||||
|
if target != 999 {
|
||||||
|
st.Target = target
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
|
||||||
|
// accept, 'F' on reject.
|
||||||
|
func (c *Client) GoTo(rotator, az int) error {
|
||||||
|
if rotator != 1 && rotator != 2 {
|
||||||
|
rotator = 1
|
||||||
|
}
|
||||||
|
if az < 0 {
|
||||||
|
az = 0
|
||||||
|
}
|
||||||
|
if az > 360 {
|
||||||
|
az = 360
|
||||||
|
}
|
||||||
|
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkKF(reply, "GoTo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop halts all movement.
|
||||||
|
func (c *Client) Stop() error {
|
||||||
|
reply, err := c.exchange("|S", 8)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkKF(reply, "Stop")
|
||||||
|
}
|
||||||
|
|
||||||
|
// CW / CCW nudge a rotator; it runs to its limit unless stopped.
|
||||||
|
func (c *Client) CW(rotator int) error { return c.rotate('P', rotator) }
|
||||||
|
func (c *Client) CCW(rotator int) error { return c.rotate('M', rotator) }
|
||||||
|
|
||||||
|
func (c *Client) rotate(cmd byte, rotator int) error {
|
||||||
|
if rotator != 1 && rotator != 2 {
|
||||||
|
rotator = 1
|
||||||
|
}
|
||||||
|
reply, err := c.exchange(fmt.Sprintf("|%c%d", cmd, rotator), 8)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return checkKF(reply, string(cmd))
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkKF reads the accept/reject status: 'K' ok, 'F' failed. The reply carries
|
||||||
|
// no other letters (the rest is the header + digits), so scanning for them is
|
||||||
|
// unambiguous.
|
||||||
|
func checkKF(reply []byte, what string) error {
|
||||||
|
s := string(reply)
|
||||||
|
if strings.ContainsRune(s, 'K') {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.ContainsRune(s, 'F') {
|
||||||
|
return fmt.Errorf("rotgenius: %s rejected by the controller", what)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("rotgenius: no reply to %s", what)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package rotgenius
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// Build a 72-byte |h reply from per-rotator field values, so the fixed offsets in
|
||||||
|
// Read() are pinned to the rev-4 layout. Numeric fields are space/zero padded to
|
||||||
|
// their documented widths.
|
||||||
|
func buildHReply(cur1, cw1, ccw1 string, cfg1, mv1 byte, off1, tgt1, start1 string, lim1 byte, name1,
|
||||||
|
cur2, cw2, ccw2 string, cfg2, mv2 byte, off2, tgt2, start2 string, lim2 byte, name2 string) []byte {
|
||||||
|
pad := func(s string, n int) string {
|
||||||
|
for len(s) < n {
|
||||||
|
s = " " + s
|
||||||
|
}
|
||||||
|
return s[:n]
|
||||||
|
}
|
||||||
|
b := []byte("|h")
|
||||||
|
b = append(b, '0', 0x00) // Active, Panic
|
||||||
|
block := func(cur, cw, ccw string, cfg, mv byte, off, tgt, start string, lim byte, name string) {
|
||||||
|
b = append(b, []byte(pad(cur, 3))...)
|
||||||
|
b = append(b, []byte(pad(cw, 3))...)
|
||||||
|
b = append(b, []byte(pad(ccw, 3))...)
|
||||||
|
b = append(b, cfg, mv)
|
||||||
|
b = append(b, []byte(pad(off, 4))...)
|
||||||
|
b = append(b, []byte(pad(tgt, 3))...)
|
||||||
|
b = append(b, []byte(pad(start, 3))...)
|
||||||
|
b = append(b, lim)
|
||||||
|
b = append(b, []byte(pad(name, 12))...)
|
||||||
|
}
|
||||||
|
block(cur1, cw1, ccw1, cfg1, mv1, off1, tgt1, start1, lim1, name1)
|
||||||
|
block(cur2, cw2, ccw2, cfg2, mv2, off2, tgt2, start2, lim2, name2)
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadParsesBothRotators(t *testing.T) {
|
||||||
|
// Rotator 1: az 100, moving CW (1), no target (999). Rotator 2: az 999 (sensor
|
||||||
|
// offline), not moving. Mirrors the manual's worked example.
|
||||||
|
reply := buildHReply(
|
||||||
|
"100", "005", "350", 'A', '1', "0", "999", "999", '0', "TOW1",
|
||||||
|
"999", "010", "060", 'E', '0', "1", "999", "999", '0', "")
|
||||||
|
|
||||||
|
c := &Client{}
|
||||||
|
_ = c
|
||||||
|
if len(reply) != hdrReplyLen {
|
||||||
|
t.Fatalf("built reply is %d bytes, want %d — field widths drifted from rev 4", len(reply), hdrReplyLen)
|
||||||
|
}
|
||||||
|
|
||||||
|
st1, err := parseFor(reply, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st1.Azimuth != 100 || !st1.Connected || st1.Moving != 1 {
|
||||||
|
t.Errorf("rotator 1 = %+v, want az 100, connected, moving CW", st1)
|
||||||
|
}
|
||||||
|
if st1.Target != -1 {
|
||||||
|
t.Errorf("rotator 1 target = %d, want -1 (999 = not set)", st1.Target)
|
||||||
|
}
|
||||||
|
|
||||||
|
st2, err := parseFor(reply, 2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st2.Connected || st2.Azimuth != 999 {
|
||||||
|
t.Errorf("rotator 2 = %+v, want disconnected (az 999)", st2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFor exercises the offset math without a socket.
|
||||||
|
func parseFor(reply []byte, rotator int) (Status, error) {
|
||||||
|
base := 4
|
||||||
|
if rotator == 2 {
|
||||||
|
base = 38
|
||||||
|
}
|
||||||
|
if len(reply) < hdrReplyLen {
|
||||||
|
return Status{}, errShort
|
||||||
|
}
|
||||||
|
cur := atoiField(string(reply[base : base+3]))
|
||||||
|
moving := atoiField(string(reply[base+10 : base+11]))
|
||||||
|
target := atoiField(string(reply[base+15 : base+18]))
|
||||||
|
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
|
||||||
|
if target != 999 {
|
||||||
|
st.Target = target
|
||||||
|
}
|
||||||
|
return st, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var errShort = fmtErrorf("short")
|
||||||
|
|
||||||
|
func fmtErrorf(s string) error { return &strErr{s} }
|
||||||
|
|
||||||
|
type strErr struct{ s string }
|
||||||
|
|
||||||
|
func (e *strErr) Error() string { return e.s }
|
||||||
|
|
||||||
|
func TestGoToFormatting(t *testing.T) {
|
||||||
|
// The command must zero-pad the azimuth to 3 digits, per the manual's fields.
|
||||||
|
cases := map[int]string{0: "|A1000", 5: "|A1005", 90: "|A1090", 360: "|A1360"}
|
||||||
|
for az, want := range cases {
|
||||||
|
got := "|A" + "1" + pad3(az)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("az %d → %q, want %q", az, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pad3(az int) string {
|
||||||
|
s := ""
|
||||||
|
switch {
|
||||||
|
case az >= 100:
|
||||||
|
s = itoa(az)
|
||||||
|
case az >= 10:
|
||||||
|
s = "0" + itoa(az)
|
||||||
|
default:
|
||||||
|
s = "00" + itoa(az)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
var b []byte
|
||||||
|
for n > 0 {
|
||||||
|
b = append([]byte{byte('0' + n%10)}, b...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
+32
-22
@@ -74,6 +74,12 @@ type Client struct {
|
|||||||
connMu sync.Mutex
|
connMu sync.Mutex
|
||||||
conn io.ReadWriteCloser
|
conn io.ReadWriteCloser
|
||||||
|
|
||||||
|
// ioMu serialises EVERY exchange on the shared connection — a status query
|
||||||
|
// (write "?A" then read 11 bytes) and a command write must never interleave,
|
||||||
|
// or their bytes mix on the wire and both frames are corrupted. The status
|
||||||
|
// poll runs on one goroutine, tuning on another, so this is essential.
|
||||||
|
ioMu sync.Mutex
|
||||||
|
|
||||||
statusMu sync.RWMutex
|
statusMu sync.RWMutex
|
||||||
lastStatus *Status
|
lastStatus *Status
|
||||||
lastSetKHz int
|
lastSetKHz int
|
||||||
@@ -85,11 +91,6 @@ type Client struct {
|
|||||||
pendingDirAt time.Time
|
pendingDirAt time.Time
|
||||||
pendingDirSet bool
|
pendingDirSet bool
|
||||||
|
|
||||||
// After a Home/Retract the controller drops out of AUTOTRACK and ignores
|
|
||||||
// frequency sets until it is turned back ON. Set on Retract, cleared by
|
|
||||||
// re-enabling on the next SetFrequency.
|
|
||||||
needAutotrack bool
|
|
||||||
|
|
||||||
stopChan chan struct{}
|
stopChan chan struct{}
|
||||||
running bool
|
running bool
|
||||||
}
|
}
|
||||||
@@ -231,6 +232,8 @@ func (c *Client) queryStatus() (*Status, error) {
|
|||||||
if conn == nil {
|
if conn == nil {
|
||||||
return nil, fmt.Errorf("steppir: not connected")
|
return nil, fmt.Errorf("steppir: not connected")
|
||||||
}
|
}
|
||||||
|
c.ioMu.Lock()
|
||||||
|
defer c.ioMu.Unlock()
|
||||||
setDeadline(conn, 3*time.Second)
|
setDeadline(conn, 3*time.Second)
|
||||||
if _, err := conn.Write([]byte("?A\r")); err != nil {
|
if _, err := conn.Write([]byte("?A\r")); err != nil {
|
||||||
return nil, fmt.Errorf("write status cmd: %w", err)
|
return nil, fmt.Errorf("write status cmd: %w", err)
|
||||||
@@ -250,10 +253,14 @@ func parseStatus(b []byte) (*Status, error) {
|
|||||||
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
|
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
|
||||||
active := b[6]
|
active := b[6]
|
||||||
dir := decodeDir(b[7])
|
dir := decodeDir(b[7])
|
||||||
// active==0xFF means "command just received" (not motion); the 0x01 bit is
|
// active-motors byte: one bit per element that is currently moving.
|
||||||
// documented as always set. Treat anything else non-zero as motors busy.
|
// 0x04 driver · 0x08 DIR1 · 0x10 reflector · 0x20 DIR2 (mask 0x3C)
|
||||||
|
// Bit 0 (0x01) is documented as always set — not a motor. 0xFF means the
|
||||||
|
// controller just received a command, not motion. So "moving" is precisely
|
||||||
|
// "any real motor bit set", ignoring the always-on bit and the ack value.
|
||||||
|
const motorBits = 0x3C
|
||||||
moving := 0
|
moving := 0
|
||||||
if active != 0xFF && (active & ^byte(0x01)) != 0 {
|
if active != 0xFF && active&motorBits != 0 {
|
||||||
moving = 1
|
moving = 1
|
||||||
}
|
}
|
||||||
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
|
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
|
||||||
@@ -299,6 +306,9 @@ 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")
|
||||||
}
|
}
|
||||||
|
c.ioMu.Lock()
|
||||||
|
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()
|
||||||
@@ -309,16 +319,19 @@ func (c *Client) writeCmd(pkt []byte) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetFrequency tunes the elements to freqKhz with the given direction. If a prior
|
// SetFrequency tunes the elements to freqKhz with the given direction.
|
||||||
// Retract dropped AUTOTRACK, re-enable it first — otherwise the set is ignored.
|
//
|
||||||
|
// AUTOTRACK is (re-)enabled first, EVERY time: the controller ignores frequency
|
||||||
|
// sets unless it is in AUTOTRACK mode ("when not in AUTOTRACK only CALIBRATE and
|
||||||
|
// RETRACT work"), and it can be out of AUTOTRACK at power-on, after a Home, or if
|
||||||
|
// switched off on the front panel. Sending the 'R' command each tune is cheap and
|
||||||
|
// makes tuning work regardless of the controller's current mode — which is what
|
||||||
|
// was silently failing before.
|
||||||
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
if c.needAutotrack {
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil { // AUTOTRACK ON
|
||||||
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.needAutotrack = false
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil { // set freq + dir
|
||||||
}
|
|
||||||
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.statusMu.Lock()
|
c.statusMu.Lock()
|
||||||
@@ -343,8 +356,9 @@ func (c *Client) SetDirection(direction int) error {
|
|||||||
return c.SetFrequency(khz, direction)
|
return c.SetFrequency(khz, direction)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Retract homes the elements into the hubs (storage). This leaves AUTOTRACK off,
|
// Retract homes the elements into the hubs (storage). This drops the controller
|
||||||
// so the next SetFrequency re-enables it.
|
// out of AUTOTRACK, but that is handled transparently: the next SetFrequency
|
||||||
|
// re-issues AUTOTRACK ON before tuning.
|
||||||
func (c *Client) Retract() error {
|
func (c *Client) Retract() error {
|
||||||
// A valid frequency must accompany the command; reuse the last one.
|
// A valid frequency must accompany the command; reuse the last one.
|
||||||
khz := c.LastSetKHz()
|
khz := c.LastSetKHz()
|
||||||
@@ -355,9 +369,5 @@ func (c *Client) Retract() error {
|
|||||||
khz = 14000 // any in-range value; the controller just homes
|
khz = 14000 // any in-range value; the controller just homes
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil {
|
return c.writeCmd(buildSet(khz*1000, DirNormal, 'S'))
|
||||||
return err
|
|
||||||
}
|
|
||||||
c.needAutotrack = true
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,10 +50,32 @@ func TestBuildSetDirectionAndCommand(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A REAL status frame captured off an SDA controller (F4BPO's, 2026-07-15):
|
||||||
|
// 40 41 00 4C C5 84 00 87 30 38 0D — 50.313 MHz, idle, bidirectional, interface
|
||||||
|
// version "08". Using the actual device output (rather than hand-built bytes)
|
||||||
|
// pins parseStatus to real hardware, and independently confirms the ÷10 wire
|
||||||
|
// scale: 0x4CC584 × 10 = 50 313 000 Hz, a genuine 6 m frequency.
|
||||||
|
func TestParseStatusRealFrame(t *testing.T) {
|
||||||
|
frame := []byte{0x40, 0x41, 0x00, 0x4C, 0xC5, 0x84, 0x00, 0x87, 0x30, 0x38, 0x0D}
|
||||||
|
st, err := parseStatus(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Frequency != 50313 {
|
||||||
|
t.Errorf("freq = %d kHz, want 50313 (50.313 MHz)", st.Frequency)
|
||||||
|
}
|
||||||
|
if st.Direction != DirBi { // 0x87 & 0xE0 = 0x80 = bidirectional
|
||||||
|
t.Errorf("direction = %d, want %d (bidirectional)", st.Direction, DirBi)
|
||||||
|
}
|
||||||
|
if st.MotorsMoving != 0 {
|
||||||
|
t.Errorf("moving = %d, want 0 (motors byte 0x00)", st.MotorsMoving)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// parseStatus decodes what the controller sends back — the inverse of buildSet's
|
// parseStatus decodes what the controller sends back — the inverse of buildSet's
|
||||||
// frequency field, plus the direction nibble.
|
// frequency field, plus the direction nibble and the motors-busy byte.
|
||||||
func TestParseStatus(t *testing.T) {
|
func TestParseStatus(t *testing.T) {
|
||||||
frame := []byte{0x00, 0x00, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '1', '2', 0x0D}
|
frame := []byte{0x40, 0x41, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '0', '8', 0x0D}
|
||||||
st, err := parseStatus(frame)
|
st, err := parseStatus(frame)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
+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.19.7"
|
appVersion = "0.19.8"
|
||||||
|
|
||||||
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
|
||||||
// to https://us.i.posthog.com for a US project.
|
// to https://us.i.posthog.com for a US project.
|
||||||
|
|||||||
Reference in New Issue
Block a user