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 ( <>
-
-
- - setRotator((s) => ({ ...s, host: e.target.value }))} - placeholder={isRG ? '192.168.1.60' : '127.0.0.1'} - className="font-mono" - /> + {isARCO && (rotator as any).transport === 'serial' ? ( +
+ +
+ + +
-
- - setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))} - className="font-mono" - /> + ) : ( +
+
+ + setRotator((s) => ({ ...s, host: e.target.value }))} + placeholder={isRG || isARCO ? '192.168.1.60' : '127.0.0.1'} + className="font-mono" + /> +
+
+ + setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))} + className="font-mono" + /> +
-
- {!isRG && ( + )} + {!isRG && !isARCO && ( )} {isRG &&

{t('rot.rgHint')}

} + {isARCO &&

{t('rot.arcoHint')}

}
{(['STANDARD', 'CONTEST', 'BROADCAST'] as const).map((m) => (