diff --git a/app.go b/app.go
index 694210c..96b41bf 100644
--- a/app.go
+++ b/app.go
@@ -49,6 +49,7 @@ import (
"hamlog/internal/qslcard"
"hamlog/internal/qso"
"hamlog/internal/relaydev"
+ "hamlog/internal/rotator/gs232"
"hamlog/internal/rotator/pst"
"hamlog/internal/rotgenius"
"hamlog/internal/settings"
@@ -158,8 +159,10 @@ const (
keyRotatorHost = "rotator.host"
keyRotatorPort = "rotator.port"
keyRotatorHasElevation = "rotator.has_elevation"
- keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
- keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
+ keyRotatorType = "rotator.type" // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, GS-232A)
+ keyRotatorNum = "rotator.num" // Rotator Genius rotator index: 1 or 2
+ keyRotatorTransport = "rotator.transport" // arco: "tcp" (LAN) | "serial" (USB virtual COM)
+ keyRotatorComPort = "rotator.com_port" // arco serial transport
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
// "ultrabeam." prefix for backward compatibility with configs saved before the
@@ -11283,11 +11286,13 @@ func (a *App) DuplicateProfile(id int64, newName string) (profile.Profile, error
// RotatorSettings is the JSON shape for the Hardware → Rotator panel.
type RotatorSettings struct {
Enabled bool `json:"enabled"`
- Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP)
+ Type string `json:"type"` // "pst" (PstRotator UDP) | "rotgenius" (4O3A native TCP) | "arco" (microHAM ARCO, Yaesu GS-232A over TCP)
Host string `json:"host"` // default 127.0.0.1
- Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius)
+ Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius) / 4001 (arco — must match the port set in ARCO's LAN CONTROL PROTOCOL)
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
+ Transport string `json:"transport"` // arco: "tcp" (LAN) | "serial" (USB virtual COM)
+ ComPort string `json:"com_port"` // arco serial transport
}
// GetRotatorSettings returns the persisted rotator config with defaults.
@@ -11297,12 +11302,13 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx,
- keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum)
+ keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum,
+ keyRotatorTransport, keyRotatorComPort)
if err != nil {
return out, err
}
out.Enabled = m[keyRotatorEnabled] == "1"
- if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" {
+ if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" || t == "arco" {
out.Type = t
}
if h := m[keyRotatorHost]; h != "" {
@@ -11312,11 +11318,18 @@ func (a *App) GetRotatorSettings() (RotatorSettings, error) {
out.Port = p
} else if out.Type == "rotgenius" {
out.Port = 9006 // native default when no port stored yet
+ } else if out.Type == "arco" {
+ out.Port = 4001 // placeholder — the real number is set in ARCO's LAN menu
}
out.HasElevation = m[keyRotatorHasElevation] == "1"
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
out.RotatorNum = 2
}
+ out.Transport = "tcp"
+ if m[keyRotatorTransport] == "serial" {
+ out.Transport = "serial"
+ }
+ out.ComPort = m[keyRotatorComPort]
return out, nil
}
@@ -11326,18 +11339,21 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
if a.settings == nil {
return fmt.Errorf("db not initialized")
}
- if s.Type != "rotgenius" {
+ if s.Type != "rotgenius" && s.Type != "arco" {
s.Type = "pst"
}
if s.Host == "" {
s.Host = "127.0.0.1"
}
if s.Port <= 0 || s.Port > 65535 {
- s.Port = map[string]int{"rotgenius": 9006, "pst": 12000}[s.Type]
+ s.Port = map[string]int{"rotgenius": 9006, "pst": 12000, "arco": 4001}[s.Type]
}
if s.RotatorNum != 2 {
s.RotatorNum = 1
}
+ if s.Transport != "serial" {
+ s.Transport = "tcp"
+ }
for k, v := range map[string]string{
keyRotatorEnabled: boolStr(s.Enabled),
keyRotatorType: s.Type,
@@ -11345,6 +11361,8 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
keyRotatorPort: strconv.Itoa(s.Port),
keyRotatorHasElevation: boolStr(s.HasElevation),
keyRotatorNum: strconv.Itoa(s.RotatorNum),
+ keyRotatorTransport: s.Transport,
+ keyRotatorComPort: strings.TrimSpace(s.ComPort),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -11353,6 +11371,15 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
return nil
}
+// arcoClient builds the GS-232 client for the configured ARCO transport:
+// USB virtual COM (serial) or the LAN CONTROL PROTOCOL TCP port.
+func arcoClient(s RotatorSettings) *gs232.Client {
+ if s.Transport == "serial" {
+ return gs232.NewSerial(s.ComPort)
+ }
+ return gs232.New(s.Host, s.Port)
+}
+
// RotatorHeading is the live antenna heading for the status bar.
type RotatorHeading struct {
Enabled bool `json:"enabled"`
@@ -11378,6 +11405,13 @@ func (a *App) GetRotatorHeading() RotatorHeading {
}
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
}
+ if s.Type == "arco" {
+ az, raw, herr := arcoClient(s).Heading()
+ if herr != nil {
+ return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
+ }
+ return RotatorHeading{Enabled: true, OK: true, Azimuth: az, Raw: raw}
+ }
az, raw, herr := pst.New(s.Host, s.Port).Heading()
if herr != nil {
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
@@ -11398,6 +11432,9 @@ func (a *App) RotatorGoTo(az int, el int) error {
if s.Type == "rotgenius" {
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
}
+ if s.Type == "arco" {
+ return arcoClient(s).GoTo(az)
+ }
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
}
@@ -11413,6 +11450,9 @@ func (a *App) RotatorStop() error {
if s.Type == "rotgenius" {
return rotgenius.New(s.Host, s.Port).Stop()
}
+ if s.Type == "arco" {
+ return arcoClient(s).Stop()
+ }
return pst.New(s.Host, s.Port).Stop()
}
@@ -11430,6 +11470,9 @@ func (a *App) RotatorPark() error {
if s.Type == "rotgenius" {
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
}
+ if s.Type == "arco" {
+ return fmt.Errorf("park is a PstRotator feature; not available over the ARCO GS-232 link")
+ }
return pst.New(s.Host, s.Port).Park()
}
@@ -11454,6 +11497,18 @@ func (a *App) TestRotator(s RotatorSettings) error {
// 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.Type == "arco" {
+ if s.Port <= 0 || s.Port > 65535 {
+ s.Port = 4001
+ }
+ if s.Transport == "serial" && strings.TrimSpace(s.ComPort) == "" {
+ return fmt.Errorf("select the ARCO's COM port first")
+ }
+ // A heading query proves the link AND that the ARCO port really speaks
+ // GS-232 — without moving the antenna.
+ _, _, err := arcoClient(s).Heading()
+ return err
+ }
if s.Port <= 0 || s.Port > 65535 {
s.Port = 12000
}
@@ -12470,6 +12525,15 @@ func (a *App) GetPGXLStatus() powergenius.Status {
return a.pgxl.GetStatus()
}
+// PGXLSetOperate puts the PowerGenius XL in OPERATE (true) or STANDBY (false)
+// over its direct TCP link (no Flex needed).
+func (a *App) PGXLSetOperate(on bool) error {
+ if a.pgxl == nil {
+ return fmt.Errorf("PowerGenius not connected — enable it in Settings → Amplifier")
+ }
+ return a.pgxl.SetOperate(on)
+}
+
// PGXLSetFanMode sets the amplifier fan mode (STANDARD | CONTEST | BROADCAST).
func (a *App) PGXLSetFanMode(mode string) error {
if a.pgxl == nil {
diff --git a/changelog.json b/changelog.json
index 3999ee4..3036d25 100644
--- a/changelog.json
+++ b/changelog.json
@@ -1,4 +1,22 @@
[
+ {
+ "version": "0.20.8",
+ "date": "2026-07-21",
+ "en": [
+ "New: microHAM ARCO rotator controlled natively — no PstRotator needed. Over the LAN or over USB: set the ARCO's CONTROL PROTOCOL (LAN or USB) to 'Yaesu GS-232A' and pick 'microHAM ARCO' in Settings → Rotator.",
+ "Amplifier without a FlexRadio: the amplifier controls (OPERATE/STANDBY, ON/OFF, live power/SWR/temperature) now also live in the Station Control tab, and a status chip sits in the bottom bar next to Cluster/CAT/Rotator — green ON AIR = OPERATE, orange = STANDBY, red = offline. Clicking the chip toggles OPERATE/STANDBY on all three amps (PowerGenius XL included, over its direct network link).",
+ "NET Control: after logging a station, the next on-air station is selected automatically (chain log → log → log), and a new 'Log everyone' button logs every on-air station at once when the net ends.",
+ "Switching to a profile whose MySQL database is unreachable now shows a clear warning — before, OpsLog silently stayed on the previous logbook and QSOs could land in the wrong log.",
+ "The ON AIR badge is back for local (SQLite) logbooks — it had disappeared with the removal of the live-status option."
+ ],
+ "fr": [
+ "Nouveau : rotator microHAM ARCO contrôlé en natif — sans PstRotator. Par le réseau ou par USB : régler le CONTROL PROTOCOL de l'ARCO (LAN ou USB) sur « Yaesu GS-232A » et choisir « microHAM ARCO » dans Réglages → Rotator.",
+ "Amplificateur sans FlexRadio : les commandes d'ampli (OPERATE/STANDBY, ON/OFF, puissance/ROS/température en direct) sont maintenant aussi dans l'onglet Station Control, et une pastille de statut s'affiche dans la barre du bas à côté de Cluster/CAT/Rotator — vert = OPERATE, orange = STANDBY, rouge = hors ligne. Un clic sur la pastille bascule OPERATE/STANDBY sur les trois amplis (PowerGenius XL compris, via son lien réseau direct).",
+ "NET Control : après avoir loggé une station, la station on air suivante est sélectionnée automatiquement (enchaînement log → log → log), et un nouveau bouton « Logger tout le monde » enregistre toutes les stations on air d'un coup à la fin du net.",
+ "Passer sur un profil dont la base MySQL est injoignable affiche maintenant un avertissement clair — avant, OpsLog restait silencieusement sur l'ancien logbook et les QSO pouvaient partir dans le mauvais log.",
+ "Le badge ON AIR est de retour pour les logbooks locaux (SQLite) — il avait disparu avec la suppression de l'option de statut en direct."
+ ]
+ },
{
"version": "0.20.7",
"date": "2026-07-21",
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index ad19450..813921d 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -43,7 +43,7 @@ import {
GetAwardDefs,
GetUIPref, GetActiveProfile, QuitApp,
ReportLiveActivity, LiveLastQSOAgeSec,
- GetPGXLSettings, GetPGXLStatus, GetSPEStatus, GetACOMStatus, SPESetOperate, ACOMSetOperate,
+ GetPGXLSettings, GetPGXLStatus, GetSPEStatus, GetACOMStatus, SPESetOperate, ACOMSetOperate, PGXLSetOperate,
} from '../wailsjs/go/main/App';
import { Combobox } from '@/components/ui/combobox';
import { applyAwardRefs } from '@/lib/awardRefs';
@@ -5145,30 +5145,31 @@ export default function App() {
onClick={() => { setSettingsSection('rotator'); setShowSettings(true); }}
/>
{/* Amplifier chip: green = OPERATE, orange = STANDBY, red = offline.
- CLICK toggles OPERATE ↔ STANDBY (SPE/ACOM; optimistic flip, the 2s
- poll reconciles). The PGXL has no standby command here — its chip
- opens Settings instead, and shows green when connected. */}
+ CLICK toggles OPERATE ↔ STANDBY on every backend (PGXL included —
+ its direct TCP link takes operate=0/1); optimistic flip, the 2s
+ poll reconciles. Offline → click opens the settings instead. */}
{ampCfg.enabled && (() => {
const isPGXL = ampCfg.type === 'pgxl';
const name = isPGXL ? 'PGXL'
: ampCfg.type.startsWith('acom') ? `ACOM ${ampSt.model || ''}`.trim()
: `SPE ${ampSt.model || ''}`.trim();
- const dot = !ampSt.connected ? 'bg-danger' : (isPGXL || ampSt.operate) ? 'bg-success' : 'bg-warning';
- const state = !ampSt.connected ? (ampSt.last_error || 'offline') : (isPGXL ? (ampSt.state || 'connected') : ampSt.operate ? 'OPERATE' : 'STANDBY');
+ const dot = !ampSt.connected ? 'bg-danger' : ampSt.operate ? 'bg-success' : 'bg-warning';
+ const state = !ampSt.connected ? (ampSt.last_error || 'offline') : ampSt.operate ? 'OPERATE' : 'STANDBY';
const toggle = () => {
- if (isPGXL) { setSettingsSection('pgxl'); setShowSettings(true); return; }
+ // Offline — nothing to command; open the settings to fix the link.
+ if (!ampSt.connected) { setSettingsSection('pgxl'); setShowSettings(true); return; }
const want = !ampSt.operate;
setAmpSt((s: any) => ({ ...s, operate: want }));
- (ampCfg.type.startsWith('acom') ? ACOMSetOperate(want) : SPESetOperate(want)).catch(() => {});
+ (isPGXL ? PGXLSetOperate(want)
+ : ampCfg.type.startsWith('acom') ? ACOMSetOperate(want)
+ : SPESetOperate(want)).catch(() => {});
};
return (
+
)}
diff --git a/frontend/src/components/RecentQSOsGrid.tsx b/frontend/src/components/RecentQSOsGrid.tsx
index fd7c3d8..ba6d7b5 100644
--- a/frontend/src/components/RecentQSOsGrid.tsx
+++ b/frontend/src/components/RecentQSOsGrid.tsx
@@ -30,6 +30,9 @@ type Props = {
// Bump this number to programmatically select every row (e.g. after a search
// in the QSL Manager, where the default is "all selected").
selectAllSignal?: number;
+ // Bump `seq` to programmatically select the single row with this id (e.g. NET
+ // Control auto-advancing to the next on-air station after logging one).
+ selectRowSignal?: { id: number; seq: number };
// storageKey scopes the persisted column layout/visibility. Omit for the main
// log (keeps the historical keys); pass a distinct value (e.g. "net.onair") to
// reuse this grid elsewhere with its OWN independent column config.
@@ -249,7 +252,7 @@ export const groupLabel = (t: TFn, g: string): string => t(GRP_KEYS[g] ?? g);
const stripAwardCols = (st: any[] | null | undefined): any[] =>
(st ?? []).filter((s) => !String(s?.colId ?? '').startsWith('award_'));
-export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
+export function RecentQSOsGrid({ rows, selectAllSignal, selectRowSignal, storageKey, onRowDoubleClicked, onRowClicked, onRowSelected, onUpdateFromCty, onUpdateFromQRZ, onUpdateFromClublog, onSendTo, onSendRecording, onSendEQSL, onBulkEdit, onExportSelected, onExportFiltered, onExportCabrilloSelected, onExportCabrilloFiltered, onDelete, onFilteredCountChange, awardCols }: Props) {
const { t } = useI18n();
const gridRef = useRef(null);
const [pickerOpen, setPickerOpen] = useState(false);
@@ -406,6 +409,21 @@ export function RecentQSOsGrid({ rows, selectAllSignal, storageKey, onRowDoubleC
gridRef.current?.api?.selectAll();
}, [selectAllSignal]);
+ // Select ONE row by id when the caller bumps selectRowSignal.seq. Deferred a
+ // tick so a row-data refresh in the same render settles first.
+ useEffect(() => {
+ if (!selectRowSignal || selectRowSignal.seq === 0) return;
+ const t = window.setTimeout(() => {
+ const api = gridRef.current?.api;
+ if (!api) return;
+ api.deselectAll();
+ api.forEachNode((n: any) => {
+ if (n.data?.id === selectRowSignal.id) n.setSelected(true);
+ });
+ }, 0);
+ return () => window.clearTimeout(t);
+ }, [selectRowSignal?.seq]);
+
// ── Column picker (visibility) ──
// Drives AG Grid via setColumnsVisible(). We don't keep a parallel React
// state for "which columns are visible" — AG Grid's column state is the
diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx
index 71d534f..f990989 100644
--- a/frontend/src/components/SettingsModal.tsx
+++ b/frontend/src/components/SettingsModal.tsx
@@ -2853,11 +2853,12 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
function RotatorPanel() {
const isRG = (rotator as any).type === 'rotgenius';
+ const isARCO = (rotator as any).type === 'arco';
return (
<>