From 82ce3353c4f300791d9b9983ca14bf9e224a4ff7 Mon Sep 17 00:00:00 2001 From: Gregory Salaun Date: Thu, 6 Aug 2026 10:40:36 +0200 Subject: [PATCH] feat(rotator): add Hy-Gain DCU-1 backend (RotorCard DXA, Rotor-EZ, Green Heron) New internal/rotator/dcu1 client speaking the DCU-1 command set (AP1nnn / AM1 to go-to, AI1 to read bearing), over a serial COM port (4800 baud default) or a raw TCP serial-over-IP bridge. Azimuth-only; Stop re-commands the current bearing since the base set has no stop opcode. Wired through app.go (normRotorType, default port 4001, GoTo/Heading/Stop/test switches, park returns the same "PstRotator only" note as ARCO) and exposed in the rotor settings as "Hy-Gain DCU-1 (RotorCard DXA, Rotor-EZ, Green Heron)" with the ARCO's serial/TCP transport chooser and a bilingual hint. Protocol is implemented from the standard DCU-1 spec and still needs field confirmation against a real RotorCard DXA. --- app.go | 39 +++++- changelog.json | 8 +- frontend/src/components/SettingsModal.tsx | 20 +-- frontend/src/lib/i18n.tsx | 4 +- internal/rotator/dcu1/dcu1.go | 158 ++++++++++++++++++++++ 5 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 internal/rotator/dcu1/dcu1.go diff --git a/app.go b/app.go index 2f731e7..3362beb 100644 --- a/app.go +++ b/app.go @@ -52,6 +52,7 @@ import ( "hamlog/internal/qso" "hamlog/internal/relaydev" "hamlog/internal/rigctld" + "hamlog/internal/rotator/dcu1" "hamlog/internal/rotator/gs232" "hamlog/internal/rotator/pst" "hamlog/internal/rotgenius" @@ -13477,7 +13478,7 @@ type logicalRotor struct { // normRotorType clamps a rotor type to a known backend. func normRotorType(t string) string { - if t == "rotgenius" || t == "arco" { + if t == "rotgenius" || t == "arco" || t == "dcu1" { return t } return "pst" @@ -13490,6 +13491,8 @@ func rotatorDefaultPort(typ string) int { return 9006 // 4O3A native default case "arco": return 4001 // placeholder — the real number is set in ARCO's LAN menu + case "dcu1": + return 4001 // only used with a serial-over-IP bridge; DCU-1 has no standard default: return 12000 // PstRotator UDP } @@ -13682,6 +13685,16 @@ func arcoClient(l rotorLink) *gs232.Client { return gs232.New(l.Host, l.Port) } +// dcu1Client builds the Hy-Gain DCU-1 client for a rotor's transport: the +// controller's COM port (the usual case — RotorCard DXA, Green Heron, Rotor-EZ) +// or a serial-over-IP bridge on TCP. +func dcu1Client(l rotorLink) *dcu1.Client { + if l.Transport == "serial" { + return dcu1.NewSerial(l.ComPort, l.Baud) + } + return dcu1.New(l.Host, l.Port) +} + // RotatorHeading is the live antenna heading for the status bar and compass. type RotatorHeading struct { Enabled bool `json:"enabled"` @@ -13748,6 +13761,16 @@ func (a *App) GetRotatorHeading() RotatorHeading { base.Azimuth = az base.Raw = raw return base + case "dcu1": + az, raw, herr := dcu1Client(link).Heading() + if herr != nil { + base.Raw = herr.Error() + return base + } + base.OK = true + base.Azimuth = az + base.Raw = raw + return base default: az, raw, herr := pst.New(link.Host, link.Port).Heading() if herr != nil { @@ -13774,6 +13797,8 @@ func (a *App) RotatorGoTo(az int, el int) error { return rotgenius.New(link.Host, link.Port).GoTo(link.Num, az) case "arco": return arcoClient(link).GoTo(az) + case "dcu1": + return dcu1Client(link).GoTo(az) default: return pst.New(link.Host, link.Port).GoTo(az, link.HasElevation, el) } @@ -13791,6 +13816,8 @@ func (a *App) RotatorStop() error { return rotgenius.New(link.Host, link.Port).Stop() case "arco": return arcoClient(link).Stop() + case "dcu1": + return dcu1Client(link).Stop() default: return pst.New(link.Host, link.Port).Stop() } @@ -13809,6 +13836,8 @@ func (a *App) RotatorPark() error { return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius") case "arco": return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link") + case "dcu1": + return fmt.Errorf("park is a PstRotator feature; not available over the DCU-1 link") default: return pst.New(link.Host, link.Port).Park() } @@ -13847,6 +13876,14 @@ func testRotorLink(l rotorLink) error { // GS-232 — without moving the antenna. _, _, err := arcoClient(l).Heading() return err + case "dcu1": + if l.Transport == "serial" && strings.TrimSpace(l.ComPort) == "" { + return fmt.Errorf("select the DCU-1 controller's COM port first") + } + // A bearing query (AI1) proves the link and the DCU-1 command set without + // moving the antenna. + _, _, err := dcu1Client(l).Heading() + return err default: return pst.New(l.Host, l.Port).GoTo(0, false, -1) } diff --git a/changelog.json b/changelog.json index 6a702e1..9dbfaa2 100644 --- a/changelog.json +++ b/changelog.json @@ -8,7 +8,8 @@ "Performance: fixed a slowdown introduced in 0.23.6. To dim the \"represents nothing\" spots, the DX Cluster grid was redrawing EVERY row on each spot-status update, which pegged the CPU on a busy cluster (some PCs became sluggish). The dimming now updates with a light per-cell refresh instead — same look, no more churn.", "DX Cluster: after you log a QSO, the spots update — a callsign, entity, POTA, prefix or band/mode slot you just worked stops showing its NEW badge, instead of staying \"new\" until a restart. Kept fast: it re-evaluates only while the cluster is actually on screen (nothing runs otherwise — a QSO logged while it's hidden refreshes once when you next open it), and it's debounced so a quick run doesn't re-scan on every QSO.", "Station Control relays (WebSwitch / KMTronic / Dingtian): the Host field now accepts a full URL, so you can reach a network relay board through an HTTPS reverse proxy — e.g. https://relay.yourdomain.com.", - "Stats slot drill-down: the pop-up listing the QSOs behind a band/mode square looks tidier (banded rows, cleaner header), and you can now click a callsign in it to open that QSO for editing." + "Stats slot drill-down: the pop-up listing the QSOs behind a band/mode square looks tidier (banded rows, cleaner header), and you can now click a callsign in it to open that QSO for editing.", + "DCU-1 rotor support: OpsLog can now steer controllers that speak the Hy-Gain DCU-1 protocol (RotorCard DXA for Yaesu DXA rotors, Idiom Press Rotor-EZ, Green Heron) over their COM port or a serial-over-IP bridge." ], "fr": [ "Édition de QSO : ajout du bouton QRZ ↗ à côté de l'indicatif, comme dans le formulaire de saisie — un clic ouvre le profil qrz.com de la station.", @@ -16,7 +17,8 @@ "Performance : correction d'un ralentissement apparu en 0.23.6. Pour atténuer les spots « qui ne représentent rien », la grille du DX Cluster redessinait TOUTES les lignes à chaque mise à jour de statut, ce qui saturait le CPU sur un cluster actif (des PC devenaient lents). L'atténuation se met désormais à jour via un rafraîchissement léger par cellule — même rendu, sans le brassage.", "DX Cluster : après avoir loggué un QSO, les spots se mettent à jour — un indicatif, une entité, un POTA, un préfixe ou un slot bande/mode que vous venez de contacter cesse d'afficher son badge NEW, au lieu de rester « new » jusqu'au redémarrage. Reste rapide : la réévaluation n'a lieu que si le cluster est affiché (sinon rien ne tourne — un QSO loggué cluster caché se rafraîchit une fois à sa réouverture), et c'est débouncé pour ne pas re-scanner à chaque QSO en série.", "Relais du Contrôle station (WebSwitch / KMTronic / Dingtian) : le champ Hôte accepte désormais une URL complète ex. https://relais.tondomaine.com derrière Nginx Proxy Manager. Avant, OpsLog forçait http://, donc une carte sur le LAN derrière un proxy (quand vos 80/443 vont déjà ailleurs) était injoignable de l'extérieur.", - "Détail d’un slot Stats : la fenêtre listant les QSO derrière une case bande/mode est plus soignée (lignes alternées, en-tête plus propre), et vous pouvez maintenant cliquer un indicatif pour ouvrir ce QSO en édition." + "Détail d’un slot Stats : la fenêtre listant les QSO derrière une case bande/mode est plus soignée (lignes alternées, en-tête plus propre), et vous pouvez maintenant cliquer un indicatif pour ouvrir ce QSO en édition.", + "Prise en charge des rotors DCU-1 : OpsLog pilote désormais les contrôleurs parlant le protocole Hy-Gain DCU-1 (RotorCard DXA pour rotors Yaesu DXA, Idiom Press Rotor-EZ, Green Heron) via leur port COM ou un pont série-sur-IP." ] }, { @@ -887,4 +889,4 @@ "Ce résumé « Nouveautés » s'affiche désormais au premier lancement après chaque mise à jour." ] } -] \ No newline at end of file +] diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 3b12066..70ae6db 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -3388,6 +3388,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const dev = d as any; const isRG = dev.type === 'rotgenius'; const isARCO = dev.type === 'arco'; + const isDCU1 = dev.type === 'dcu1'; + const isSerialCap = isARCO || isDCU1; // COM-port or serial-over-IP controllers const transport = dev.transport ?? 'tcp'; return (
@@ -3406,12 +3408,13 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan {/* Each backend gets its default port: Rotator Genius 9006, ARCO 4001 (placeholder — must match the ARCO's LAN menu), PstRotator 12000. */}
@@ -3428,8 +3431,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan )} - {/* The ARCO is reachable over the LAN (TCP) or its USB virtual COM. */} - {isARCO && ( + {/* ARCO and DCU-1 controllers reach over the LAN (TCP) or a serial COM. */} + {isSerialCap && (
patch(i, { host: e.target.value })} - placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'} className="font-mono" /> + placeholder={isRG || isSerialCap ? '192.168.1.60' : '127.0.0.1'} className="font-mono" />
- + patch(i, { port: n })} - fallback={isRG ? 9006 : isARCO ? 4001 : 12000} className="font-mono" /> + fallback={isRG ? 9006 : isSerialCap ? 4001 : 12000} className="font-mono" />
)} - {!isRG && !isARCO && ( + {!isRG && !isSerialCap && (