fix: bug on steppir control
This commit is contained in:
@@ -179,7 +179,7 @@ const en: Dict = {
|
|||||||
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
|
'cat.omnirigHint': 'Configure your rig (COM port, baud rate, model) in OmniRig\'s own settings GUI first. OpsLog will read whichever Rig slot you select here. Set CAT delay above 0 if your rig drops commands sent back-to-back (some older Kenwood/Yaesu). OmniRig only reports generic "DIG" for digital modes — Default digital mode is the specific mode OpsLog will surface (and log).',
|
||||||
'cat.flexHint': 'Native SmartSDR API — no OmniRig needed. Frequency, mode and split are read in real time from the radio (no polling, no second-click mode bug). Use Detect radios or enter the IP. Default digital mode is what OpsLog logs when the slice is in a digital mode (DIGU/DIGL).',
|
'cat.flexHint': 'Native SmartSDR API — no OmniRig needed. Frequency, mode and split are read in real time from the radio (no polling, no second-click mode bug). Use Detect radios or enter the IP. Default digital mode is what OpsLog logs when the slice is in a digital mode (DIGU/DIGL).',
|
||||||
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
|
'cat.rotatorOk': "Packet sent — antenna should swing to 0° (north). If it didn't, check PstRotator host/port and that PstRotator's UDP listener is enabled.",
|
||||||
'cat.ubOk': 'Connected — the Ultrabeam responded with a status frame.',
|
'cat.ubOk': 'Connected — the antenna responded with a status frame.',
|
||||||
// External services (repeated labels)
|
// External services (repeated labels)
|
||||||
'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
'es.autoUpload': 'Automatic upload on new QSO', 'es.uploadTiming': 'Upload timing', 'es.immediate': 'Immediate', 'es.delayed': 'Delayed (1–2 min, lets you fix mistakes)', 'es.onClose': 'On app close (batch)', 'es.testConn': 'Test connection', 'es.testing': 'Testing…', 'es.password': 'Password', 'es.apiKey': 'API key', 'es.forceCall': 'Force station callsign', 'es.accountEmail': 'Account email', 'es.logbookCall': 'Logbook callsign',
|
||||||
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
'lotw.usersTitle': 'LoTW user list', 'lotw.usersHint': "Downloads ARRL's public list of LoTW users + their last-upload date, to show a colour-coded LoTW badge next to a callsign (green < 1 week · amber 1–4 weeks · red > 30 days).", 'lotw.usersDownload': 'Download LoTW user list', 'lotw.usersDownloading': 'Downloading…', 'lotw.usersLoaded': '{n} users loaded · updated {date}', 'lotw.usersNone': 'Not downloaded yet — the badge stays hidden until you do.',
|
||||||
@@ -433,7 +433,7 @@ const fr: Dict = {
|
|||||||
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
|
'cat.omnirigHint': "Configure d'abord ton poste (port COM, débit, modèle) dans l'interface de réglages d'OmniRig. OpsLog lira le slot Rig que tu choisis ici. Mets le délai CAT au-dessus de 0 si ton poste perd des commandes envoyées coup sur coup (certains anciens Kenwood/Yaesu). OmniRig ne rapporte qu'un « DIG » générique pour les modes numériques — le mode numérique par défaut est le mode précis qu'OpsLog affichera (et loggera).",
|
||||||
'cat.flexHint': "API SmartSDR native — pas besoin d'OmniRig. Fréquence, mode et split sont lus en temps réel depuis la radio (sans poll, sans le bug du mode au 2ᵉ clic). Utilise Détecter les radios ou saisis l'IP. Le mode numérique par défaut est ce qu'OpsLog logge quand la slice est en mode numérique (DIGU/DIGL).",
|
'cat.flexHint': "API SmartSDR native — pas besoin d'OmniRig. Fréquence, mode et split sont lus en temps réel depuis la radio (sans poll, sans le bug du mode au 2ᵉ clic). Utilise Détecter les radios ou saisis l'IP. Le mode numérique par défaut est ce qu'OpsLog logge quand la slice est en mode numérique (DIGU/DIGL).",
|
||||||
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
'cat.rotatorOk': "Paquet envoyé — l'antenne devrait tourner vers 0° (nord). Sinon, vérifie l'hôte/port PstRotator et que l'écouteur UDP de PstRotator est activé.",
|
||||||
'cat.ubOk': "Connecté — l'Ultrabeam a répondu avec une trame de statut.",
|
'cat.ubOk': "Connecté — l'antenne a répondu avec une trame de statut.",
|
||||||
'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
'es.autoUpload': 'Envoi automatique à chaque nouveau QSO', 'es.uploadTiming': "Moment de l'envoi", 'es.immediate': 'Immédiat', 'es.delayed': 'Différé (1–2 min, permet de corriger les erreurs)', 'es.onClose': "À la fermeture (par lot)", 'es.testConn': 'Tester la connexion', 'es.testing': 'Test…', 'es.password': 'Mot de passe', 'es.apiKey': 'Clé API', 'es.forceCall': "Forcer l'indicatif de station", 'es.accountEmail': 'E-mail du compte', 'es.logbookCall': 'Indicatif du carnet',
|
||||||
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
'lotw.usersTitle': 'Liste des utilisateurs LoTW', 'lotw.usersHint': "Télécharge la liste publique ARRL des utilisateurs LoTW + leur date de dernier upload, pour afficher un badge LoTW coloré à côté d'un indicatif (vert < 1 semaine · ambre 1–4 semaines · rouge > 30 j).", 'lotw.usersDownload': 'Télécharger la liste LoTW', 'lotw.usersDownloading': 'Téléchargement…', 'lotw.usersLoaded': '{n} utilisateurs chargés · maj {date}', 'lotw.usersNone': 'Pas encore téléchargée — le badge reste caché tant que ce n’est pas fait.',
|
||||||
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
'hw.connecting': 'Connexion…', 'hw.testConn': 'Tester la connexion', 'hw.sending': 'Envoi…', 'hw.testRotator': 'Tester (pointer vers 0°)',
|
||||||
|
|||||||
+25
-19
@@ -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)
|
||||||
@@ -299,6 +302,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 +315,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 +352,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 +365,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)
|
||||||
|
|||||||
Reference in New Issue
Block a user