feat: While closing OpsLog will keep the same size and position for next launch
This commit is contained in:
@@ -3503,10 +3503,10 @@ export default function App() {
|
|||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Ultrabeam pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
|
{/* Motorized-antenna pattern (Normal / 180° reverse / Bidirectional), next to the azimuth. */}
|
||||||
{ubStatus.enabled && (
|
{ubStatus.enabled && (
|
||||||
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
|
<div className="inline-flex items-center rounded-full border border-success-border bg-success-muted overflow-hidden text-[10px] font-semibold ml-1"
|
||||||
title={ubStatus.connected ? (ubStatus.moving ? 'Ultrabeam: moving…' : 'Ultrabeam pattern') : 'Ultrabeam: connecting…'}>
|
title={ubStatus.connected ? (ubStatus.moving ? 'Antenna: moving…' : 'Antenna pattern') : 'Antenna: connecting…'}>
|
||||||
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
<button type="button" className="pl-1.5 pr-0.5 flex items-center" onClick={() => { setSettingsSection('antenna'); setShowSettings(true); }} title="Antenna settings">
|
||||||
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
|
<span className={cn('size-2 rounded-full', ubStatus.connected ? (ubStatus.moving ? 'bg-warning' : 'bg-success') : 'bg-muted-foreground/40')} />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
|||||||
cat: 'CAT interface',
|
cat: 'CAT interface',
|
||||||
rotator: 'PstRotator',
|
rotator: 'PstRotator',
|
||||||
winkeyer: 'CW Keyer',
|
winkeyer: 'CW Keyer',
|
||||||
antenna: 'UltraBeam',
|
antenna: 'Ultrabeam / Steppir',
|
||||||
antgenius: 'Antenna Genius',
|
antgenius: 'Antenna Genius',
|
||||||
pgxl: 'Power Genius',
|
pgxl: 'Power Genius',
|
||||||
flex: 'FlexRadio',
|
flex: 'FlexRadio',
|
||||||
@@ -826,9 +826,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
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);
|
||||||
|
|
||||||
// Ultrabeam antenna (TCP) settings.
|
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
|
||||||
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; host: string; port: 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 }>({
|
||||||
enabled: false, host: '', port: 23, follow: false, step_khz: 50,
|
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50,
|
||||||
});
|
});
|
||||||
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);
|
||||||
@@ -2267,36 +2267,92 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UltrabeamPanel() {
|
function UltrabeamPanel() {
|
||||||
|
const isSteppir = ultrabeam.type === 'steppir';
|
||||||
|
const isSerial = isSteppir && ultrabeam.transport === 'serial';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
title={t('hw.ultrabeam')}
|
title={t('hw.motorAntenna')}
|
||||||
/>
|
/>
|
||||||
<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={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
<Checkbox checked={ultrabeam.enabled} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, enabled: !!c }))} />
|
||||||
Enable Ultrabeam control
|
{t('hw.motorEnable')}
|
||||||
</label>
|
</label>
|
||||||
<div className="grid grid-cols-3 gap-3">
|
<div className="grid grid-cols-2 gap-3">
|
||||||
<div className="space-y-1 col-span-2">
|
|
||||||
<Label>Host / IP</Label>
|
|
||||||
<Input
|
|
||||||
value={ultrabeam.host ?? ''}
|
|
||||||
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
|
|
||||||
placeholder="192.168.1.50"
|
|
||||||
className="font-mono"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>TCP port</Label>
|
<Label>{t('hw.motorType')}</Label>
|
||||||
<Input
|
{/* Ultrabeam is TCP only; picking it forces the transport back to TCP
|
||||||
type="number" min={1} max={65535}
|
so the serial fields never apply to it. */}
|
||||||
value={ultrabeam.port}
|
<Select value={ultrabeam.type ?? 'ultrabeam'}
|
||||||
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
|
onValueChange={(v) => setUltrabeam((s) => ({ ...s, type: v, transport: v === 'ultrabeam' ? 'tcp' : s.transport }))}>
|
||||||
className="font-mono"
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
/>
|
<SelectContent>
|
||||||
|
<SelectItem value="ultrabeam">Ultrabeam</SelectItem>
|
||||||
|
<SelectItem value="steppir">SteppIR</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
{isSteppir && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorTransport')}</Label>
|
||||||
|
<Select value={ultrabeam.transport ?? 'tcp'}
|
||||||
|
onValueChange={(v) => setUltrabeam((s) => ({ ...s, transport: v }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="tcp">{t('hw.motorTcp')}</SelectItem>
|
||||||
|
<SelectItem value="serial">{t('hw.motorSerial')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{isSerial ? (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>{t('hw.motorCom')}</Label>
|
||||||
|
<Input
|
||||||
|
value={ultrabeam.com ?? ''}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, com: e.target.value }))}
|
||||||
|
placeholder="COM3"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>{t('hw.motorBaud')}</Label>
|
||||||
|
<Select value={String(ultrabeam.baud || 9600)} onValueChange={(v) => setUltrabeam((s) => ({ ...s, baud: parseInt(v, 10) || 9600 }))}>
|
||||||
|
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{[1200, 4800, 9600, 19200].map((b) => <SelectItem key={b} value={String(b)}>{b}</SelectItem>)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="space-y-1 col-span-2">
|
||||||
|
<Label>Host / IP</Label>
|
||||||
|
<Input
|
||||||
|
value={ultrabeam.host ?? ''}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, host: e.target.value }))}
|
||||||
|
placeholder="192.168.1.50"
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>TCP port</Label>
|
||||||
|
<Input
|
||||||
|
type="number" min={1} max={65535}
|
||||||
|
value={ultrabeam.port}
|
||||||
|
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isSteppir && (
|
||||||
|
<p className="text-xs text-muted-foreground">{t('hw.steppirHint')}</p>
|
||||||
|
)}
|
||||||
<div className="border-t border-border/60 pt-3 space-y-2">
|
<div className="border-t border-border/60 pt-3 space-y-2">
|
||||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||||
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
<Checkbox checked={ultrabeam.follow} onCheckedChange={(c) => setUltrabeam((s) => ({ ...s, follow: !!c }))} />
|
||||||
@@ -2318,7 +2374,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
)}
|
)}
|
||||||
</div>
|
</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 || !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')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ const en: Dict = {
|
|||||||
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
'sec.bands': 'Bands', 'sec.modes': 'Modes & default RST', 'sec.cluster': 'DX Cluster',
|
||||||
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup',
|
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup',
|
||||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer',
|
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer',
|
||||||
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||||
// General panel
|
// General panel
|
||||||
'gen.hint': 'App behaviour (saved instantly).',
|
'gen.hint': 'App behaviour (saved instantly).',
|
||||||
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
'gen.autofocusWB': 'Auto-focus "Worked before" for known stations',
|
||||||
@@ -165,7 +165,7 @@ const en: Dict = {
|
|||||||
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
'ag2.hint': 'OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B).', 'ag2.password': 'Remote password', 'ag2.passwordPh': 'blank on LAN', 'ag2.passwordHint': 'Only needed when reaching the device remotely — it then announces "AG AUTH" and rejects commands until you log in. Leave blank on the local network.',
|
||||||
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
'rot.hint': "OpsLog sends UDP commands to PstRotator. Enable PstRotator's UDP listener (Setup → Communication → UDP) before testing.",
|
||||||
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
'extsvc.hint': 'Upload logged QSOs to online logbooks. Each service uploads automatically on a new QSO when enabled; timing is per-service (immediate, or a 1–2 min delay so a mis-logged QSO can still be fixed first).',
|
||||||
'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
'hw.motorAntenna': 'Ultrabeam / Steppir', 'hw.motorEnable': 'Enable antenna control', 'hw.motorType': 'Antenna type', 'hw.motorTransport': 'Connection', 'hw.motorTcp': 'Network (TCP)', 'hw.motorSerial': 'Serial (COM)', 'hw.motorCom': 'Serial port', 'hw.motorBaud': 'Baud', 'hw.steppirHint': 'SteppIR controllers are RS-232 serial (the DATA OUT DB9 port). Serial = a USB↔RS-232 (FTDI) adapter, shown as a COM port. Network = a serial-to-Ethernet bridge (as for the Ultrabeam).', 'hw.ultrabeam': 'Antenna (Ultrabeam)', 'hw.audioVoice': 'Audio devices & voice keyer',
|
||||||
// CAT panel body
|
// CAT panel body
|
||||||
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Enable CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (any rig, Windows COM)', 'cat.optFlex': 'FlexRadio / SmartSDR (native)', 'cat.optIcom': 'Icom CI-V (USB serial)', 'cat.optIcomNet': 'Icom CI-V (network / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||||
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
'cat.icomNetHost': 'Rig IP / hostname', 'cat.icomNetUser': 'Network user (ID)', 'cat.icomNetPass': 'Network password',
|
||||||
@@ -355,7 +355,7 @@ const fr: Dict = {
|
|||||||
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
'sec.bands': 'Bandes', 'sec.modes': 'Modes & RST par défaut', 'sec.cluster': 'DX Cluster',
|
||||||
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base',
|
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base',
|
||||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW',
|
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||||
'sec.antenna': 'UltraBeam', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||||
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
'gen.hint': 'Comportement de l\'application (enregistré immédiatement).',
|
||||||
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
'gen.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
||||||
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
'gen.showBeam': 'Afficher le cap de l\'antenne sur la carte principale',
|
||||||
@@ -420,7 +420,7 @@ const fr: Dict = {
|
|||||||
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
'ag2.hint': "OpsLog dialogue avec le switch 4O3A Antenna Genius en TCP (protocole GSCP). Le port est fixé à 9007, seule l'IP de l'appareil est nécessaire. Un widget ancré permet ensuite de commuter les antennes par port (A/B).", 'ag2.password': 'Mot de passe distant', 'ag2.passwordPh': 'vide en LAN', 'ag2.passwordHint': "Nécessaire seulement à distance — l'appareil annonce alors « AG AUTH » et refuse les commandes tant qu'on n'est pas identifié. Laisse vide sur le réseau local.",
|
||||||
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
'rot.hint': "OpsLog envoie des commandes UDP à PstRotator. Active l'écouteur UDP de PstRotator (Setup → Communication → UDP) avant de tester.",
|
||||||
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
'extsvc.hint': "Envoie les QSO enregistrés vers des carnets en ligne. Chaque service upload automatiquement à chaque nouveau QSO si activé ; le délai est propre à chaque service (immédiat, ou 1–2 min pour corriger un QSO mal saisi avant).",
|
||||||
'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
'hw.motorAntenna': 'Antenne motorisée', 'hw.motorEnable': "Activer le contrôle de l'antenne", 'hw.motorType': "Type d'antenne", 'hw.motorTransport': 'Connexion', 'hw.motorTcp': 'Réseau (TCP)', 'hw.motorSerial': 'Série (COM)', 'hw.motorCom': 'Port série', 'hw.motorBaud': 'Débit', 'hw.steppirHint': "Les contrôleurs SteppIR sont en RS-232 série (port DB9 « DATA OUT »). Série = un adaptateur USB↔RS-232 (FTDI), vu comme un port COM. Réseau = un pont série-Ethernet (comme pour l'Ultrabeam).", 'hw.ultrabeam': 'Antenne (Ultrabeam)', 'hw.audioVoice': 'Périphériques audio & manipulateur vocal',
|
||||||
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
'cat.enable': 'Activer le CAT', 'cat.backend': 'Backend', 'cat.optOmnirig': 'OmniRig (tout poste, COM Windows)', 'cat.optFlex': 'FlexRadio / SmartSDR (natif)', 'cat.optIcom': 'Icom CI-V (USB série)', 'cat.optIcomNet': 'Icom CI-V (réseau / remote)', 'cat.optTci': 'TCI (Expert Electronics / SunSDR)',
|
||||||
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
'cat.icomNetHost': 'IP / nom d\'hôte du poste', 'cat.icomNetUser': 'Utilisateur réseau (ID)', 'cat.icomNetPass': 'Mot de passe réseau',
|
||||||
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
'cat.icomNetHint': "Se connecte directement au serveur LAN intégré du poste — sans RS-BA1 ni Remote Utility (ferme-les d'abord). Utilise l'ID/mot de passe Network User1 configurés dans le menu Network du poste. Un poste en veille est allumé automatiquement.",
|
||||||
|
|||||||
@@ -2465,8 +2465,12 @@ export namespace main {
|
|||||||
}
|
}
|
||||||
export class UltrabeamSettings {
|
export class UltrabeamSettings {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
type: string;
|
||||||
|
transport: string;
|
||||||
host: string;
|
host: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
com: string;
|
||||||
|
baud: number;
|
||||||
follow: boolean;
|
follow: boolean;
|
||||||
step_khz: number;
|
step_khz: number;
|
||||||
|
|
||||||
@@ -2477,8 +2481,12 @@ 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.transport = source["transport"];
|
||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.port = source["port"];
|
this.port = source["port"];
|
||||||
|
this.com = source["com"];
|
||||||
|
this.baud = source["baud"];
|
||||||
this.follow = source["follow"];
|
this.follow = source["follow"];
|
||||||
this.step_khz = source["step_khz"];
|
this.step_khz = source["step_khz"];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,363 @@
|
|||||||
|
// Package steppir controls a SteppIR SDA-100 / SDA-2000 antenna controller over
|
||||||
|
// its "Transceiver Interface" serial protocol, reached either directly on a COM
|
||||||
|
// port or over TCP through an RS232↔Ethernet bridge (the same way OpsLog talks to
|
||||||
|
// an Ultrabeam). The client mirrors the ultrabeam.Client surface so the app can
|
||||||
|
// drive either behind one interface.
|
||||||
|
//
|
||||||
|
// Protocol (cross-checked against the SteppIR "Transceiver Interface Operation"
|
||||||
|
// note, the we7u/steppir library, and the la1k.no write-up — three independent
|
||||||
|
// sources that agree, which is what makes the byte layout trustworthy):
|
||||||
|
//
|
||||||
|
// SET : "@A" <freq> 00 <dir> <cmd> 00 0x0D (11 bytes)
|
||||||
|
// <freq> = int32 big-endian of (Hz / 10)
|
||||||
|
// <dir> = 0x00 normal · 0x40 180° · 0x80 bidirectional · 0x20 3/4-wave
|
||||||
|
// <cmd> = '1' set freq+dir · 'R' autotrack ON · 'U' autotrack OFF
|
||||||
|
// 'S' home/retract · 'V' calibrate
|
||||||
|
// STATUS: "?A" 0x0D → 11 bytes back:
|
||||||
|
// [2:6] int32 big-endian frequency (× 10 = Hz)
|
||||||
|
// [6] active-motor bitmask (0xFF = command received / setup)
|
||||||
|
// [7] & 0xE0 direction
|
||||||
|
//
|
||||||
|
// Timing: the controller needs ≥100 ms between commands and dislikes status
|
||||||
|
// polls faster than ~10/s. The poll loop runs at 2 s, well inside that.
|
||||||
|
package steppir
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.bug.st/serial"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Direction values, matching the app-wide convention (also used by Ultrabeam):
|
||||||
|
// 0 normal, 1 reverse (180°), 2 bidirectional.
|
||||||
|
const (
|
||||||
|
DirNormal = 0
|
||||||
|
Dir180 = 1
|
||||||
|
DirBi = 2
|
||||||
|
)
|
||||||
|
|
||||||
|
// SteppIR direction bytes on the wire.
|
||||||
|
const (
|
||||||
|
wireNormal = 0x00
|
||||||
|
wire180 = 0x40
|
||||||
|
wireBi = 0x80
|
||||||
|
)
|
||||||
|
|
||||||
|
// Transport says how to reach the controller.
|
||||||
|
type Transport struct {
|
||||||
|
Mode string // "tcp" | "serial"
|
||||||
|
Host string // tcp
|
||||||
|
Port int // tcp
|
||||||
|
COM string // serial device (COM3, /dev/ttyUSB0)
|
||||||
|
Baud int // serial baud (controller default 9600; 1200-19200 valid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status is the antenna state, in the same shape the app reads from the
|
||||||
|
// Ultrabeam so the two are interchangeable at the UI.
|
||||||
|
type Status struct {
|
||||||
|
Connected bool `json:"connected"`
|
||||||
|
Frequency int `json:"frequency"` // kHz
|
||||||
|
Band int `json:"band"` // 0 (SteppIR does not report a band index)
|
||||||
|
Direction int `json:"direction"` // 0 normal, 1 180°, 2 bidirectional
|
||||||
|
MotorsMoving int `json:"motors_moving"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
tr Transport
|
||||||
|
|
||||||
|
connMu sync.Mutex
|
||||||
|
conn io.ReadWriteCloser
|
||||||
|
|
||||||
|
statusMu sync.RWMutex
|
||||||
|
lastStatus *Status
|
||||||
|
lastSetKHz int
|
||||||
|
|
||||||
|
// A just-commanded direction is held until the controller's poll reports it —
|
||||||
|
// the motors take a second or two, and a stale poll would otherwise snap the
|
||||||
|
// UI back. Same trick as the Ultrabeam client.
|
||||||
|
pendingDir int
|
||||||
|
pendingDirAt time.Time
|
||||||
|
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{}
|
||||||
|
running bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(tr Transport) *Client {
|
||||||
|
if tr.Baud <= 0 {
|
||||||
|
tr.Baud = 9600
|
||||||
|
}
|
||||||
|
return &Client{tr: tr, stopChan: make(chan struct{})}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Start() error {
|
||||||
|
c.running = true
|
||||||
|
go c.pollLoop()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) Stop() {
|
||||||
|
if !c.running {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.running = false
|
||||||
|
close(c.stopChan)
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// LastSetKHz returns the frequency last commanded, or 0.
|
||||||
|
func (c *Client) LastSetKHz() int {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
return c.lastSetKHz
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) GetStatus() (*Status, error) {
|
||||||
|
c.statusMu.RLock()
|
||||||
|
defer c.statusMu.RUnlock()
|
||||||
|
if c.lastStatus == nil {
|
||||||
|
return &Status{Connected: false}, nil
|
||||||
|
}
|
||||||
|
return c.lastStatus, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// open dials the transport. Callers hold connMu.
|
||||||
|
func (c *Client) open() (io.ReadWriteCloser, error) {
|
||||||
|
switch c.tr.Mode {
|
||||||
|
case "serial":
|
||||||
|
if c.tr.COM == "" {
|
||||||
|
return nil, fmt.Errorf("steppir: no serial port configured")
|
||||||
|
}
|
||||||
|
p, err := serial.Open(c.tr.COM, &serial.Mode{BaudRate: c.tr.Baud})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// A finite read timeout so a silent controller doesn't wedge the poll loop.
|
||||||
|
_ = p.SetReadTimeout(2 * time.Second)
|
||||||
|
return p, nil
|
||||||
|
default: // tcp
|
||||||
|
if c.tr.Host == "" {
|
||||||
|
return nil, fmt.Errorf("steppir: no host configured")
|
||||||
|
}
|
||||||
|
d := net.Dialer{Timeout: 5 * time.Second}
|
||||||
|
return d.Dial("tcp", net.JoinHostPort(c.tr.Host, fmt.Sprintf("%d", c.tr.Port)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) pollLoop() {
|
||||||
|
ticker := time.NewTicker(2 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-c.stopChan:
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn == nil {
|
||||||
|
conn, err := c.open()
|
||||||
|
if err != nil {
|
||||||
|
c.connMu.Unlock()
|
||||||
|
c.setDisconnected()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
|
||||||
|
st, err := c.queryStatus()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("steppir: status query failed, reconnecting: %v", err)
|
||||||
|
c.closeConn()
|
||||||
|
c.setDisconnected()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
st.Connected = true
|
||||||
|
c.statusMu.Lock()
|
||||||
|
if c.pendingDirSet {
|
||||||
|
if time.Since(c.pendingDirAt) > 4*time.Second || st.Direction == c.pendingDir {
|
||||||
|
c.pendingDirSet = false
|
||||||
|
} else {
|
||||||
|
st.Direction = c.pendingDir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.lastStatus = st
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) setDisconnected() {
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastStatus = &Status{Connected: false}
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) closeConn() {
|
||||||
|
c.connMu.Lock()
|
||||||
|
if c.conn != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
}
|
||||||
|
c.connMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// setDeadline applies a read/write deadline on TCP; serial uses its own timeout.
|
||||||
|
func setDeadline(conn io.ReadWriteCloser, d time.Duration) {
|
||||||
|
if nc, ok := conn.(net.Conn); ok {
|
||||||
|
_ = nc.SetDeadline(time.Now().Add(d))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) queryStatus() (*Status, error) {
|
||||||
|
c.connMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.connMu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return nil, fmt.Errorf("steppir: not connected")
|
||||||
|
}
|
||||||
|
setDeadline(conn, 3*time.Second)
|
||||||
|
if _, err := conn.Write([]byte("?A\r")); err != nil {
|
||||||
|
return nil, fmt.Errorf("write status cmd: %w", err)
|
||||||
|
}
|
||||||
|
buf := make([]byte, 11)
|
||||||
|
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||||
|
return nil, fmt.Errorf("read status: %w", err)
|
||||||
|
}
|
||||||
|
return parseStatus(buf)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatus decodes an 11-byte status frame.
|
||||||
|
func parseStatus(b []byte) (*Status, error) {
|
||||||
|
if len(b) < 11 {
|
||||||
|
return nil, fmt.Errorf("steppir: short status frame (%d bytes)", len(b))
|
||||||
|
}
|
||||||
|
freqHz := int(int32(binary.BigEndian.Uint32(b[2:6]))) * 10
|
||||||
|
active := b[6]
|
||||||
|
dir := decodeDir(b[7])
|
||||||
|
// active==0xFF means "command just received" (not motion); the 0x01 bit is
|
||||||
|
// documented as always set. Treat anything else non-zero as motors busy.
|
||||||
|
moving := 0
|
||||||
|
if active != 0xFF && (active & ^byte(0x01)) != 0 {
|
||||||
|
moving = 1
|
||||||
|
}
|
||||||
|
return &Status{Frequency: freqHz / 1000, Direction: dir, MotorsMoving: moving}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeDir(b byte) int {
|
||||||
|
switch b & 0xE0 {
|
||||||
|
case wireBi:
|
||||||
|
return DirBi
|
||||||
|
case wire180:
|
||||||
|
return Dir180
|
||||||
|
default:
|
||||||
|
return DirNormal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func dirWireByte(dir int) byte {
|
||||||
|
switch dir {
|
||||||
|
case Dir180:
|
||||||
|
return wire180
|
||||||
|
case DirBi:
|
||||||
|
return wireBi
|
||||||
|
default:
|
||||||
|
return wireNormal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSet frames a SET command: "@A" <freq be32 of Hz/10> 00 <dir> <cmd> 00 CR.
|
||||||
|
func buildSet(freqHz int, dir int, cmd byte) []byte {
|
||||||
|
var f [4]byte
|
||||||
|
binary.BigEndian.PutUint32(f[:], uint32(freqHz/10))
|
||||||
|
out := make([]byte, 0, 11)
|
||||||
|
out = append(out, '@', 'A')
|
||||||
|
out = append(out, f[:]...)
|
||||||
|
out = append(out, 0x00, dirWireByte(dir), cmd, 0x00, 0x0D)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) writeCmd(pkt []byte) error {
|
||||||
|
c.connMu.Lock()
|
||||||
|
conn := c.conn
|
||||||
|
c.connMu.Unlock()
|
||||||
|
if conn == nil {
|
||||||
|
return fmt.Errorf("steppir: not connected")
|
||||||
|
}
|
||||||
|
setDeadline(conn, 3*time.Second)
|
||||||
|
if _, err := conn.Write(pkt); err != nil {
|
||||||
|
c.closeConn()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The controller needs breathing room between commands.
|
||||||
|
time.Sleep(120 * time.Millisecond)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetFrequency tunes the elements to freqKhz with the given direction. If a prior
|
||||||
|
// Retract dropped AUTOTRACK, re-enable it first — otherwise the set is ignored.
|
||||||
|
func (c *Client) SetFrequency(freqKhz int, direction int) error {
|
||||||
|
if c.needAutotrack {
|
||||||
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, 'R')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.needAutotrack = false
|
||||||
|
}
|
||||||
|
if err := c.writeCmd(buildSet(freqKhz*1000, direction, '1')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.statusMu.Lock()
|
||||||
|
c.lastSetKHz = freqKhz
|
||||||
|
c.pendingDir, c.pendingDirAt, c.pendingDirSet = direction, time.Now(), true
|
||||||
|
c.statusMu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDirection changes the pattern. SteppIR has no standalone direction command —
|
||||||
|
// it is a SET with the current frequency and the new direction byte.
|
||||||
|
func (c *Client) SetDirection(direction int) error {
|
||||||
|
khz := c.LastSetKHz()
|
||||||
|
if khz <= 0 {
|
||||||
|
if st, _ := c.GetStatus(); st != nil {
|
||||||
|
khz = st.Frequency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if khz <= 0 {
|
||||||
|
return fmt.Errorf("steppir: no frequency known yet — cannot set direction")
|
||||||
|
}
|
||||||
|
return c.SetFrequency(khz, direction)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retract homes the elements into the hubs (storage). This leaves AUTOTRACK off,
|
||||||
|
// so the next SetFrequency re-enables it.
|
||||||
|
func (c *Client) Retract() error {
|
||||||
|
// A valid frequency must accompany the command; reuse the last one.
|
||||||
|
khz := c.LastSetKHz()
|
||||||
|
if khz <= 0 {
|
||||||
|
if st, _ := c.GetStatus(); st != nil && st.Frequency > 0 {
|
||||||
|
khz = st.Frequency
|
||||||
|
} else {
|
||||||
|
khz = 14000 // any in-range value; the controller just homes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := c.writeCmd(buildSet(khz*1000, DirNormal, 'S')); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
c.needAutotrack = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package steppir
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/binary"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The exact bytes are the correctness checksum. If buildSet ever drifts from the
|
||||||
|
// three-source-agreed layout, this fails — a wrong packet is a silently mistuned
|
||||||
|
// antenna, far worse than a compile error.
|
||||||
|
func TestBuildSetLayout(t *testing.T) {
|
||||||
|
// 14.074 MHz, normal, set-freq. freq/10 = 1_407_400 = 0x00 0x15 0x79 0xA8.
|
||||||
|
pkt := buildSet(14_074_000, DirNormal, '1')
|
||||||
|
want := []byte{'@', 'A', 0x00, 0x15, 0x79, 0xA8, 0x00, 0x00, '1', 0x00, 0x0D}
|
||||||
|
if len(pkt) != 11 {
|
||||||
|
t.Fatalf("packet is %d bytes, want 11", len(pkt))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if pkt[i] != want[i] {
|
||||||
|
t.Fatalf("byte %d = 0x%02X, want 0x%02X\n got %X\nwant %X", i, pkt[i], want[i], pkt, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Frequency must round-trip: bytes [2:6] × 10 = Hz.
|
||||||
|
if got := int(binary.BigEndian.Uint32(pkt[2:6])) * 10; got != 14_074_000 {
|
||||||
|
t.Fatalf("freq round-trip = %d, want 14074000", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBuildSetDirectionAndCommand(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
dir int
|
||||||
|
cmd byte
|
||||||
|
wantDir byte
|
||||||
|
wantCmd byte
|
||||||
|
}{
|
||||||
|
{DirNormal, '1', 0x00, '1'},
|
||||||
|
{Dir180, '1', 0x40, '1'},
|
||||||
|
{DirBi, '1', 0x80, '1'},
|
||||||
|
{DirNormal, 'S', 0x00, 'S'}, // retract / home
|
||||||
|
{DirNormal, 'R', 0x00, 'R'}, // autotrack on
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
pkt := buildSet(21_000_000, c.dir, c.cmd)
|
||||||
|
if pkt[7] != c.wantDir {
|
||||||
|
t.Errorf("dir %d → byte 0x%02X, want 0x%02X", c.dir, pkt[7], c.wantDir)
|
||||||
|
}
|
||||||
|
if pkt[8] != c.wantCmd {
|
||||||
|
t.Errorf("cmd %q → byte 0x%02X, want 0x%02X", c.cmd, pkt[8], c.wantCmd)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseStatus decodes what the controller sends back — the inverse of buildSet's
|
||||||
|
// frequency field, plus the direction nibble.
|
||||||
|
func TestParseStatus(t *testing.T) {
|
||||||
|
frame := []byte{0x00, 0x00, 0x00, 0x15, 0x79, 0xA8, 0x01, 0x40, '1', '2', 0x0D}
|
||||||
|
st, err := parseStatus(frame)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if st.Frequency != 14074 {
|
||||||
|
t.Errorf("freq = %d kHz, want 14074", st.Frequency)
|
||||||
|
}
|
||||||
|
if st.Direction != Dir180 {
|
||||||
|
t.Errorf("direction = %d, want %d (180°)", st.Direction, Dir180)
|
||||||
|
}
|
||||||
|
if st.MotorsMoving != 0 { // 0x01 is the always-set bit, not motion
|
||||||
|
t.Errorf("moving = %d, want 0 (only the always-on bit set)", st.MotorsMoving)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Motors busy: a bit beyond 0x01 is set.
|
||||||
|
frame[6] = 0x07
|
||||||
|
st, _ = parseStatus(frame)
|
||||||
|
if st.MotorsMoving == 0 {
|
||||||
|
t.Error("active-motors 0x07 should read as moving")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 0xFF is "command received", not motion.
|
||||||
|
frame[6] = 0xFF
|
||||||
|
st, _ = parseStatus(frame)
|
||||||
|
if st.MotorsMoving != 0 {
|
||||||
|
t.Error("active-motors 0xFF (command received) must not read as moving")
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user