feat: New Station Control, allow to control Webswitch 1216H or KMTronic
This commit is contained in:
@@ -49,6 +49,8 @@ import (
|
||||
"hamlog/internal/qslcard"
|
||||
"hamlog/internal/qso"
|
||||
"hamlog/internal/rotator/pst"
|
||||
"hamlog/internal/relaydev"
|
||||
"hamlog/internal/rotgenius"
|
||||
"hamlog/internal/settings"
|
||||
"hamlog/internal/solar"
|
||||
"hamlog/internal/steppir"
|
||||
@@ -154,6 +156,8 @@ 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
|
||||
|
||||
// Motorized antenna (Ultrabeam or SteppIR) — Hardware → Antenna. Keys keep the
|
||||
// "ultrabeam." prefix for backward compatibility with configs saved before the
|
||||
@@ -169,6 +173,7 @@ const (
|
||||
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
|
||||
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
|
||||
keyMotorTXInhibit = "ultrabeam.tx_inhibit" // "1" → block Flex TX while the antenna is moving
|
||||
keyStationDevices = "station.devices" // JSON list of relay boards for the Station Control tab
|
||||
|
||||
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
|
||||
// port is fixed at 9007, so only the IP is configurable.
|
||||
@@ -10393,30 +10398,40 @@ 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)
|
||||
Host string `json:"host"` // default 127.0.0.1
|
||||
Port int `json:"port"` // default 12000
|
||||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets
|
||||
Port int `json:"port"` // default 12000 (pst) / 9006 (rotgenius)
|
||||
HasElevation bool `json:"has_elevation"` // include EL in GoTo packets (PstRotator)
|
||||
RotatorNum int `json:"rotator_num"` // Rotator Genius index: 1 or 2
|
||||
}
|
||||
|
||||
// GetRotatorSettings returns the persisted rotator config with defaults.
|
||||
func (a *App) GetRotatorSettings() (RotatorSettings, error) {
|
||||
out := RotatorSettings{Host: "127.0.0.1", Port: 12000}
|
||||
out := RotatorSettings{Type: "pst", Host: "127.0.0.1", Port: 12000, RotatorNum: 1}
|
||||
if a.settings == nil {
|
||||
return out, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx,
|
||||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation)
|
||||
keyRotatorEnabled, keyRotatorHost, keyRotatorPort, keyRotatorHasElevation, keyRotatorType, keyRotatorNum)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out.Enabled = m[keyRotatorEnabled] == "1"
|
||||
if t := m[keyRotatorType]; t == "rotgenius" || t == "pst" {
|
||||
out.Type = t
|
||||
}
|
||||
if h := m[keyRotatorHost]; h != "" {
|
||||
out.Host = h
|
||||
}
|
||||
if p, _ := strconv.Atoi(m[keyRotatorPort]); p > 0 && p <= 65535 {
|
||||
out.Port = p
|
||||
} else if out.Type == "rotgenius" {
|
||||
out.Port = 9006 // native default when no port stored yet
|
||||
}
|
||||
out.HasElevation = m[keyRotatorHasElevation] == "1"
|
||||
if rn, _ := strconv.Atoi(m[keyRotatorNum]); rn == 2 {
|
||||
out.RotatorNum = 2
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -10426,17 +10441,25 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
if s.Type != "rotgenius" {
|
||||
s.Type = "pst"
|
||||
}
|
||||
if s.Host == "" {
|
||||
s.Host = "127.0.0.1"
|
||||
}
|
||||
if s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 12000
|
||||
s.Port = map[string]int{"rotgenius": 9006, "pst": 12000}[s.Type]
|
||||
}
|
||||
if s.RotatorNum != 2 {
|
||||
s.RotatorNum = 1
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyRotatorEnabled: boolStr(s.Enabled),
|
||||
keyRotatorType: s.Type,
|
||||
keyRotatorHost: s.Host,
|
||||
keyRotatorPort: strconv.Itoa(s.Port),
|
||||
keyRotatorHasElevation: boolStr(s.HasElevation),
|
||||
keyRotatorNum: strconv.Itoa(s.RotatorNum),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -10445,18 +10468,6 @@ func (a *App) SaveRotatorSettings(s RotatorSettings) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// rotatorClient returns a fresh PST UDP client built from current settings,
|
||||
// or an error if the rotator is disabled / misconfigured.
|
||||
func (a *App) rotatorClient() (*pst.Client, RotatorSettings, error) {
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return nil, s, err
|
||||
}
|
||||
if !s.Enabled {
|
||||
return nil, s, fmt.Errorf("rotator disabled in settings")
|
||||
}
|
||||
return pst.New(s.Host, s.Port), s, nil
|
||||
}
|
||||
|
||||
// RotatorHeading is the live antenna heading for the status bar.
|
||||
type RotatorHeading struct {
|
||||
@@ -10473,6 +10484,16 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
||||
if err != nil || !s.Enabled {
|
||||
return RotatorHeading{Enabled: false}
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
st, raw, herr := rotgenius.New(s.Host, s.Port).Heading(s.RotatorNum)
|
||||
if herr != nil {
|
||||
return RotatorHeading{Enabled: true, OK: false, Raw: herr.Error()}
|
||||
}
|
||||
if !st.Connected {
|
||||
return RotatorHeading{Enabled: true, OK: false, Raw: "sensor not connected (999)"}
|
||||
}
|
||||
return RotatorHeading{Enabled: true, OK: true, Azimuth: st.Azimuth, Raw: raw}
|
||||
}
|
||||
az, raw, herr := pst.New(s.Host, s.Port).Heading()
|
||||
if herr != nil {
|
||||
return RotatorHeading{Enabled: true, OK: false, Raw: raw}
|
||||
@@ -10483,30 +10504,49 @@ func (a *App) GetRotatorHeading() RotatorHeading {
|
||||
// RotatorGoTo points the antenna at the given azimuth (and optional
|
||||
// elevation if the rotator is configured for it).
|
||||
func (a *App) RotatorGoTo(az int, el int) error {
|
||||
c, s, err := a.rotatorClient()
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.GoTo(az, s.HasElevation, el)
|
||||
if !s.Enabled {
|
||||
return fmt.Errorf("rotator disabled in settings")
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
return rotgenius.New(s.Host, s.Port).GoTo(s.RotatorNum, az)
|
||||
}
|
||||
return pst.New(s.Host, s.Port).GoTo(az, s.HasElevation, el)
|
||||
}
|
||||
|
||||
// RotatorStop interrupts any in-progress rotation.
|
||||
// RotatorStop interrupts any in-progress rotation, on either backend.
|
||||
func (a *App) RotatorStop() error {
|
||||
c, _, err := a.rotatorClient()
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Stop()
|
||||
if !s.Enabled {
|
||||
return fmt.Errorf("rotator disabled in settings")
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
return rotgenius.New(s.Host, s.Port).Stop()
|
||||
}
|
||||
return pst.New(s.Host, s.Port).Stop()
|
||||
}
|
||||
|
||||
// RotatorPark moves the antenna to its parked position (configured in
|
||||
// PstRotator itself).
|
||||
// PstRotator itself). Park is a PstRotator concept — the 4O3A native protocol
|
||||
// has no park command.
|
||||
func (a *App) RotatorPark() error {
|
||||
c, _, err := a.rotatorClient()
|
||||
s, err := a.GetRotatorSettings()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Park()
|
||||
if !s.Enabled {
|
||||
return fmt.Errorf("rotator disabled in settings")
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
return fmt.Errorf("park is a PstRotator feature; not available on the Rotator Genius")
|
||||
}
|
||||
return pst.New(s.Host, s.Port).Park()
|
||||
}
|
||||
|
||||
// TestRotator sends a no-op GoTo to the rotator's current heading to
|
||||
@@ -10517,6 +10557,19 @@ func (a *App) TestRotator(s RotatorSettings) error {
|
||||
if s.Host == "" {
|
||||
s.Host = "127.0.0.1"
|
||||
}
|
||||
if s.Type == "rotgenius" {
|
||||
if s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 9006
|
||||
}
|
||||
rn := s.RotatorNum
|
||||
if rn != 2 {
|
||||
rn = 1
|
||||
}
|
||||
// Match the button ("Test — point to 0°"): actually command the move, like
|
||||
// the PstRotator path does. GoTo confirms the link AND the accept ('K')
|
||||
// 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.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 12000
|
||||
}
|
||||
@@ -10530,6 +10583,146 @@ func boolStr(b bool) string {
|
||||
return "0"
|
||||
}
|
||||
|
||||
// ── Station Control (relay boards) ─────────────────────────────────────
|
||||
|
||||
// StationDevice is one configured relay board for the Station Control tab.
|
||||
type StationDevice struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "webswitch" | "kmtronic"
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
|
||||
Pass string `json:"pass,omitempty"`
|
||||
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
|
||||
}
|
||||
|
||||
// relayCountFor returns a device type's fixed relay count.
|
||||
func relayCountFor(typ string) int {
|
||||
if typ == "kmtronic" {
|
||||
return 8
|
||||
}
|
||||
return 5 // webswitch 1216H
|
||||
}
|
||||
|
||||
// deviceDriver builds the wire driver for a configured device.
|
||||
func deviceDriver(d StationDevice) relaydev.Device {
|
||||
if d.Type == "kmtronic" {
|
||||
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
|
||||
}
|
||||
return relaydev.NewWebswitch(d.Host)
|
||||
}
|
||||
|
||||
// GetStationDevices returns the configured relay boards (without live state).
|
||||
func (a *App) GetStationDevices() []StationDevice {
|
||||
out := []StationDevice{}
|
||||
if a.settings == nil {
|
||||
return out
|
||||
}
|
||||
s, _ := a.settings.GetGlobal(a.ctx, keyStationDevices)
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return out
|
||||
}
|
||||
if err := json.Unmarshal([]byte(s), &out); err != nil || out == nil {
|
||||
return []StationDevice{}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SaveStationDevices persists the relay-board list. IDs and label lengths are
|
||||
// normalized so the UI can rely on them.
|
||||
func (a *App) SaveStationDevices(devs []StationDevice) error {
|
||||
if a.settings == nil {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
for i := range devs {
|
||||
if devs[i].Type != "kmtronic" {
|
||||
devs[i].Type = "webswitch"
|
||||
}
|
||||
if strings.TrimSpace(devs[i].ID) == "" {
|
||||
devs[i].ID = fmt.Sprintf("dev%d-%d", i, time.Now().UnixNano())
|
||||
}
|
||||
n := relayCountFor(devs[i].Type)
|
||||
for len(devs[i].Labels) < n {
|
||||
devs[i].Labels = append(devs[i].Labels, "")
|
||||
}
|
||||
devs[i].Labels = devs[i].Labels[:n]
|
||||
}
|
||||
b, err := json.Marshal(devs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return a.settings.SetGlobal(a.ctx, keyStationDevices, string(b))
|
||||
}
|
||||
|
||||
// StationRelay is one relay's live state for the UI.
|
||||
type StationRelay struct {
|
||||
Number int `json:"number"` // 1-based
|
||||
Label string `json:"label"`
|
||||
On bool `json:"on"`
|
||||
}
|
||||
|
||||
// StationDeviceStatus is a device plus its live relay states.
|
||||
type StationDeviceStatus struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Connected bool `json:"connected"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Relays []StationRelay `json:"relays"`
|
||||
}
|
||||
|
||||
// GetStationStatus polls every configured board for its live relay states.
|
||||
// Boards are polled concurrently so one slow/offline device doesn't stall the rest.
|
||||
func (a *App) GetStationStatus() []StationDeviceStatus {
|
||||
devs := a.GetStationDevices()
|
||||
out := make([]StationDeviceStatus, len(devs))
|
||||
var wg sync.WaitGroup
|
||||
for i := range devs {
|
||||
wg.Add(1)
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
d := devs[i]
|
||||
n := relayCountFor(d.Type)
|
||||
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
states, err := deviceDriver(d).Status(ctx)
|
||||
if err != nil {
|
||||
ds.Error = err.Error()
|
||||
} else {
|
||||
ds.Connected = true
|
||||
}
|
||||
ds.Relays = make([]StationRelay, n)
|
||||
for r := 0; r < n; r++ {
|
||||
label := ""
|
||||
if r < len(d.Labels) {
|
||||
label = d.Labels[r]
|
||||
}
|
||||
on := false
|
||||
if r < len(states) {
|
||||
on = states[r]
|
||||
}
|
||||
ds.Relays[r] = StationRelay{Number: r + 1, Label: label, On: on}
|
||||
}
|
||||
out[i] = ds
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
return out
|
||||
}
|
||||
|
||||
// StationSetRelay switches one relay on a configured board.
|
||||
func (a *App) StationSetRelay(id string, relay int, on bool) error {
|
||||
for _, d := range a.GetStationDevices() {
|
||||
if d.ID == id {
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
return deviceDriver(d).Set(ctx, relay, on)
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("station device %q not found", id)
|
||||
}
|
||||
|
||||
// ── Motorized antenna (Ultrabeam / SteppIR) ────────────────────────────
|
||||
|
||||
// motorAntenna is the shared control surface of a motorized antenna. The
|
||||
|
||||
@@ -68,6 +68,7 @@ import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel';
|
||||
import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder';
|
||||
import { AwardsPanel } from '@/components/AwardsPanel';
|
||||
import { StatsPanel } from '@/components/StatsPanel';
|
||||
import { StationControlPanel } from '@/components/StationControlPanel';
|
||||
import { RecentQSOsGrid } from '@/components/RecentQSOsGrid';
|
||||
import { ShutdownProgress } from '@/components/ShutdownProgress';
|
||||
import { ClusterGrid } from '@/components/ClusterGrid';
|
||||
@@ -632,6 +633,11 @@ export default function App() {
|
||||
setStatsTabOpen(false);
|
||||
setActiveTab((t) => (t === 'stats' ? 'recent' : t));
|
||||
}
|
||||
const [stationTabOpen, setStationTabOpen] = useState(false);
|
||||
function closeStationTab() {
|
||||
setStationTabOpen(false);
|
||||
setActiveTab((t) => (t === 'station' ? 'recent' : t));
|
||||
}
|
||||
// Recent QSOs row cap, persisted. With AG Grid's virtual scroller
|
||||
// huge logs render OK once loaded, but a 25k+ logbook still takes a
|
||||
// couple of seconds to round-trip from SQLite at launch. Defaulting
|
||||
@@ -2532,6 +2538,7 @@ export default function App() {
|
||||
{ name: 'tools', label: t('menu.tools'), items: [
|
||||
{ type: 'item', label: t('tools.qslManager'), action: 'tools.qslmanager' },
|
||||
{ type: 'item', label: t('stats.tab'), action: 'tools.stats' },
|
||||
{ type: 'item', label: t('station.title'), action: 'tools.station' },
|
||||
{ type: 'item', label: t('tools.qslDesigner'), action: 'tools.qsldesigner' },
|
||||
{ type: 'separator' },
|
||||
{ type: 'item', label: (wkEnabled ? '✓ ' : '') + t('tools.winkeyer'), action: 'tools.winkeyer' },
|
||||
@@ -2568,6 +2575,7 @@ export default function App() {
|
||||
case 'edit.prefs': setShowSettings(true); break;
|
||||
case 'tools.qslmanager': setQslTabOpen(true); setActiveTab('qsl'); break;
|
||||
case 'tools.stats': setStatsTabOpen(true); setActiveTab('stats'); break;
|
||||
case 'tools.station': setStationTabOpen(true); setActiveTab('station'); break;
|
||||
case 'tools.qsldesigner': setQslDesignerOpen(true); break;
|
||||
case 'tools.winkeyer': wkSetEnabled(!wkEnabled); break;
|
||||
case 'tools.dvk': setDvkEnabled((v) => !v); break;
|
||||
@@ -4192,6 +4200,21 @@ export default function App() {
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{stationTabOpen && (
|
||||
<TabsTrigger value="station" className="gap-1.5">
|
||||
{t('station.title')}
|
||||
<span
|
||||
role="button"
|
||||
aria-label="Close Station Control"
|
||||
title="Close"
|
||||
className="inline-flex items-center justify-center size-4 rounded hover:bg-foreground/10 text-muted-foreground hover:text-foreground"
|
||||
onPointerDown={(e) => { e.stopPropagation(); }}
|
||||
onClick={(e) => { e.stopPropagation(); closeStationTab(); }}
|
||||
>
|
||||
<X className="size-3" />
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{qslTabOpen && (
|
||||
<TabsTrigger value="qsl" className="gap-1.5">
|
||||
QSL Manager
|
||||
@@ -4532,6 +4555,16 @@ export default function App() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{stationTabOpen && (
|
||||
<TabsContent value="station" className="mt-0 flex flex-col min-h-0 flex-1 data-[state=inactive]:hidden">
|
||||
<StationControlPanel
|
||||
centerLat={gridToLatLon(station.my_grid)?.lat ?? null}
|
||||
centerLon={gridToLatLon(station.my_grid)?.lon ?? null}
|
||||
bearing={dxPath?.bearingShort ?? null}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{contestTabEnabled && (
|
||||
<TabsContent value="contest" className="flex-1 min-h-0 p-0">
|
||||
<ContestPanel session={contest} onChange={updateContest} />
|
||||
|
||||
@@ -259,7 +259,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
udp: 'UDP integrations',
|
||||
awards: 'Awards',
|
||||
cat: 'CAT interface',
|
||||
rotator: 'PstRotator',
|
||||
rotator: 'Rotator',
|
||||
winkeyer: 'CW Keyer',
|
||||
antenna: 'Ultrabeam / Steppir',
|
||||
antgenius: 'Antenna Genius',
|
||||
@@ -821,8 +821,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
digital_default: 'FT8',
|
||||
});
|
||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
||||
enabled: false, host: '127.0.0.1', port: 12000, has_elevation: false,
|
||||
});
|
||||
enabled: false, type: 'pst', host: '127.0.0.1', port: 12000, has_elevation: false, rotator_num: 1,
|
||||
} as any);
|
||||
const [rotatorTesting, setRotatorTesting] = useState(false);
|
||||
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
@@ -2245,7 +2245,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
setRotatorTest(null);
|
||||
try {
|
||||
await TestRotator(rotator as any);
|
||||
setRotatorTest({ ok: true, msg: t('cat.rotatorOk') });
|
||||
setRotatorTest({ ok: true, msg: (rotator as any).type === 'rotgenius' ? t('rot.testOkRG') : t('cat.rotatorOk') });
|
||||
} catch (e: any) {
|
||||
setRotatorTest({ ok: false, msg: String(e?.message ?? e) });
|
||||
} finally {
|
||||
@@ -2474,41 +2474,73 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
|
||||
function RotatorPanel() {
|
||||
const isRG = (rotator as any).type === 'rotgenius';
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Rotator"
|
||||
hint={t('rot.hint')}
|
||||
hint={isRG ? undefined : t('rot.hint')}
|
||||
/>
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={rotator.enabled} onCheckedChange={(c) => setRotator((s) => ({ ...s, enabled: !!c }))} />
|
||||
Enable PstRotator control
|
||||
{t('rot.enable')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.type')}</Label>
|
||||
{/* Switching to Rotator Genius moves the default port to its native
|
||||
9006; back to PstRotator restores 12000. */}
|
||||
<Select value={(rotator as any).type ?? 'pst'}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, type: v, port: v === 'rotgenius' ? 9006 : 12000 } as any))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pst">PstRotator (UDP)</SelectItem>
|
||||
<SelectItem value="rotgenius">Rotator Genius (4O3A, native)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{isRG && (
|
||||
<div className="space-y-1">
|
||||
<Label>{t('rot.rotatorNum')}</Label>
|
||||
<Select value={String((rotator as any).rotator_num ?? 1)}
|
||||
onValueChange={(v) => setRotator((s) => ({ ...s, rotator_num: parseInt(v, 10) || 1 } as any))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">1</SelectItem>
|
||||
<SelectItem value="2">2</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Host</Label>
|
||||
<Label>Host / IP</Label>
|
||||
<Input
|
||||
value={rotator.host ?? ''}
|
||||
onChange={(e) => setRotator((s) => ({ ...s, host: e.target.value }))}
|
||||
placeholder="127.0.0.1"
|
||||
placeholder={isRG ? '192.168.1.60' : '127.0.0.1'}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>UDP port</Label>
|
||||
<Label>{isRG ? 'TCP port' : 'UDP port'}</Label>
|
||||
<Input
|
||||
type="number" min={1} max={65535}
|
||||
value={rotator.port}
|
||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || 12000 }))}
|
||||
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : 12000) }))}
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{!isRG && (
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={rotator.has_elevation} onCheckedChange={(c) => setRotator((s) => ({ ...s, has_elevation: !!c }))} />
|
||||
This rotator supports elevation (VHF / satellite)
|
||||
</label>
|
||||
)}
|
||||
{isRG && <p className="text-xs text-muted-foreground">{t('rot.rgHint')}</p>}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button variant="outline" size="sm" onClick={testRotator} disabled={rotatorTesting}>
|
||||
{rotatorTesting ? t('hw.sending') : t('hw.testRotator')}
|
||||
@@ -2516,9 +2548,11 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorStop().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||
Stop
|
||||
</Button>
|
||||
{!isRG && (
|
||||
<Button variant="outline" size="sm" onClick={() => RotatorPark().catch((e) => setErr(String(e?.message ?? e)))} disabled={!rotator.enabled}>
|
||||
Park
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{rotatorTest && (
|
||||
<div className={cn(
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { writeUiPref } from '@/lib/uiPref';
|
||||
import { RotorCompass } from '@/components/RotorCompass';
|
||||
import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
|
||||
type Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; labels: string[] };
|
||||
type Relay = { number: number; label: string; on: boolean };
|
||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' };
|
||||
|
||||
function blankDevice(): Device {
|
||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||
}
|
||||
|
||||
type Heading = { enabled: boolean; ok: boolean; azimuth: number };
|
||||
|
||||
// RotatorWidget shows the configured rotator (Settings → Rotator) right in the
|
||||
// Station Control tab: a live compass you can click to turn, a heading readout, a
|
||||
// GoTo box, quick N/E/S/W presets, and Stop. Heading is polled by the panel and
|
||||
// passed in (so the panel can also order this widget); it drives the shared
|
||||
// rotator backend.
|
||||
function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: RotatorProps & {
|
||||
hd: Heading; refetch: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const [goto, setGoto] = useState('');
|
||||
const [err, setErr] = useState('');
|
||||
|
||||
const turn = (az: number) => {
|
||||
const a = ((Math.round(az) % 360) + 360) % 360;
|
||||
RotatorGoTo(a, -1).then(refetch).catch((e) => setErr(String(e?.message ?? e)));
|
||||
};
|
||||
const poll = refetch;
|
||||
|
||||
const presets: [string, number][] = [['N', 0], ['E', 90], ['S', 180], ['W', 270]];
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Compass className="size-4 text-primary" />
|
||||
<div className="text-sm font-semibold">{t('station.rotator')}</div>
|
||||
<span className={cn('ml-auto size-2 rounded-full', hd.ok ? 'bg-success' : 'bg-warning')}
|
||||
title={hd.ok ? t('station.online') : t('station.rotatorNoRead')} />
|
||||
</div>
|
||||
<div className="p-3 flex gap-4 items-start">
|
||||
<RotorCompass
|
||||
bearing={bearing ?? null}
|
||||
headings={hd.ok ? [hd.azimuth] : []}
|
||||
centerLat={centerLat ?? null}
|
||||
centerLon={centerLon ?? null}
|
||||
rotorEnabled={hd.ok}
|
||||
onGoto={(az) => turn(az)}
|
||||
/>
|
||||
<div className="flex-1 min-w-0 space-y-2">
|
||||
<div className="font-mono">
|
||||
<span className="text-2xl font-bold tabular-nums">{hd.ok ? `${hd.azimuth}°` : '—'}</span>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{presets.map(([lbl, az]) => (
|
||||
<button key={lbl} type="button" onClick={() => turn(az)}
|
||||
className="flex-1 rounded-md border border-border bg-muted/40 py-1 text-xs font-semibold hover:bg-muted">
|
||||
{lbl}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-1.5">
|
||||
<Input value={goto} onChange={(e) => setGoto(e.target.value.replace(/[^0-9]/g, ''))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && goto) turn(parseInt(goto, 10)); }}
|
||||
placeholder="0–359" className="h-8 flex-1 min-w-0 font-mono text-sm" />
|
||||
<Button size="sm" className="h-8 shrink-0" disabled={!goto} onClick={() => turn(parseInt(goto, 10))}>{t('station.go')}</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" className="h-8 w-full" onClick={() => RotatorStop().then(poll).catch((e) => setErr(String(e?.message ?? e)))}>
|
||||
<Square className="size-3 mr-1" /> {t('station.stop')}
|
||||
</Button>
|
||||
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorProps) {
|
||||
const { t } = useI18n();
|
||||
const [devices, setDevices] = useState<Device[]>([]);
|
||||
const [status, setStatus] = useState<Record<string, DevStatus>>({});
|
||||
const [editing, setEditing] = useState<Device | null>(null); // device being added/edited
|
||||
const [busy, setBusy] = useState<Record<string, boolean>>({}); // per-relay in-flight
|
||||
const [rot, setRot] = useState<Heading>({ enabled: false, ok: false, azimuth: 0 });
|
||||
// Widget order (rotator + device ids), drag-and-drop reorderable, persisted.
|
||||
const [order, setOrder] = useState<string[]>(() => {
|
||||
try { const v = JSON.parse(localStorage.getItem('opslog.stationOrder') || '[]'); return Array.isArray(v) ? v : []; } catch { return []; }
|
||||
});
|
||||
const dragId = useRef<string | null>(null);
|
||||
|
||||
const loadDevices = useCallback(async () => {
|
||||
try { setDevices(((await GetStationDevices()) ?? []) as Device[]); } catch { /* db not ready */ }
|
||||
}, []);
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const s = ((await GetStationStatus()) ?? []) as DevStatus[];
|
||||
setStatus(Object.fromEntries(s.map((d) => [d.id, d])));
|
||||
} catch { /* ignore transient */ }
|
||||
}, []);
|
||||
const pollRot = useCallback(async () => {
|
||||
try { setRot((await GetRotatorHeading()) as any); } catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
useEffect(() => { loadDevices(); }, [loadDevices]);
|
||||
useEffect(() => {
|
||||
poll(); pollRot();
|
||||
const id = window.setInterval(() => { poll(); pollRot(); }, 3000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [poll, pollRot, devices.length]);
|
||||
|
||||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||
// Reorder so `dragged` lands just before `target`.
|
||||
const onDrop = (targetId: string) => {
|
||||
const src = dragId.current; dragId.current = null;
|
||||
if (!src || src === targetId) return;
|
||||
const ids = widgetIds.filter((id) => id !== src);
|
||||
const at = ids.indexOf(targetId);
|
||||
ids.splice(at < 0 ? ids.length : at, 0, src);
|
||||
persistOrder(ids);
|
||||
};
|
||||
|
||||
const persist = async (next: Device[]) => {
|
||||
setDevices(next);
|
||||
try { await SaveStationDevices(next as any); } catch { /* surfaced by status */ }
|
||||
await loadDevices();
|
||||
poll();
|
||||
};
|
||||
|
||||
const toggle = async (dev: Device, relay: number, on: boolean) => {
|
||||
const key = `${dev.id}:${relay}`;
|
||||
setBusy((b) => ({ ...b, [key]: true }));
|
||||
// Optimistic flip so the switch feels instant; the poll reconciles.
|
||||
setStatus((st) => {
|
||||
const d = st[dev.id]; if (!d) return st;
|
||||
return { ...st, [dev.id]: { ...d, relays: d.relays.map((r) => (r.number === relay ? { ...r, on } : r)) } };
|
||||
});
|
||||
try { await StationSetRelay(dev.id, relay, on); } catch { /* poll will correct */ }
|
||||
await poll();
|
||||
setBusy((b) => ({ ...b, [key]: false }));
|
||||
};
|
||||
|
||||
const saveEdit = async () => {
|
||||
if (!editing) return;
|
||||
const d = { ...editing, name: editing.name.trim() || TYPE_LABEL[editing.type], host: editing.host.trim() };
|
||||
const exists = devices.some((x) => x.id && x.id === d.id);
|
||||
await persist(exists ? devices.map((x) => (x.id === d.id ? d : x)) : [...devices, d]);
|
||||
setEditing(null);
|
||||
};
|
||||
|
||||
const removeDevice = async (id: string) => { await persist(devices.filter((x) => x.id !== id)); };
|
||||
|
||||
// Build the widget list (rotator first by default, then devices), then order it
|
||||
// by the saved drag order — unknown ids fall to the end in their natural order.
|
||||
const deviceCard = (dev: Device) => {
|
||||
const st = status[dev.id];
|
||||
const relays = st?.relays ?? dev.labels.map((label, i) => ({ number: i + 1, label, on: false }));
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<PlugZap className="size-4 text-primary" />
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{dev.name || TYPE_LABEL[dev.type]}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono truncate">{TYPE_LABEL[dev.type]} · {dev.host}</div>
|
||||
</div>
|
||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', st?.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={st?.connected ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||
<button className="text-muted-foreground hover:text-foreground" title={t('station.edit')}
|
||||
onClick={() => setEditing({ ...dev, labels: [...dev.labels] })}><Pencil className="size-3.5" /></button>
|
||||
<button className="text-muted-foreground hover:text-destructive" title={t('station.delete')}
|
||||
onClick={() => removeDevice(dev.id)}><Trash2 className="size-3.5" /></button>
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2">
|
||||
{relays.map((r) => {
|
||||
const key = `${dev.id}:${r.number}`;
|
||||
const label = r.label || `${t('station.relay')} ${r.number}`;
|
||||
return (
|
||||
<button key={r.number} type="button" disabled={!st?.connected}
|
||||
onClick={() => toggle(dev, r.number, !r.on)}
|
||||
className={cn('flex items-center gap-2 rounded-lg border px-2.5 py-2 text-left transition-colors disabled:opacity-40',
|
||||
r.on ? 'bg-success/15 border-success/50' : 'bg-muted/30 border-border hover:bg-muted')}>
|
||||
<span className={cn('flex items-center justify-center size-7 rounded-md shrink-0',
|
||||
r.on ? 'bg-success text-success-foreground' : 'bg-muted-foreground/15 text-muted-foreground')}>
|
||||
{busy[key] ? <Loader2 className="size-3.5 animate-spin" /> : <Power className="size-3.5" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-xs font-medium truncate">{label}</span>
|
||||
<span className={cn('block text-[10px] font-semibold', r.on ? 'text-success' : 'text-muted-foreground')}>
|
||||
{r.on ? t('station.on') : t('station.off')}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const widgets: { id: string; node: React.ReactNode }[] = [];
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pollRot} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
}
|
||||
for (const dev of devices) widgets.push({ id: dev.id, node: deviceCard(dev) });
|
||||
|
||||
const rank = (id: string) => { const i = order.indexOf(id); return i < 0 ? 1e6 : i; };
|
||||
const ordered = widgets.map((w, i) => ({ ...w, i })).sort((a, b) => (rank(a.id) - rank(b.id)) || (a.i - b.i));
|
||||
const widgetIds = ordered.map((w) => w.id);
|
||||
|
||||
const noDevices = devices.length === 0 && !rot.enabled;
|
||||
|
||||
return (
|
||||
<div className="flex-1 min-h-0 overflow-auto p-4">
|
||||
<div className="flex items-center justify-between mb-3 max-w-4xl">
|
||||
<h2 className="text-sm font-bold uppercase tracking-wider text-muted-foreground">{t('station.title')}</h2>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(blankDevice())}>
|
||||
<Plus className="size-3.5 mr-1" /> {t('station.addDevice')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{noDevices && !editing && (
|
||||
<div className="max-w-4xl rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
|
||||
{t('station.empty')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 max-w-4xl md:grid-cols-2 items-start">
|
||||
{ordered.map((w) => (
|
||||
<div key={w.id} draggable
|
||||
onDragStart={(e) => { dragId.current = w.id; e.dataTransfer.effectAllowed = 'move'; }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; }}
|
||||
onDrop={(e) => { e.preventDefault(); onDrop(w.id); }}
|
||||
className={cn('cursor-grab active:cursor-grabbing', dragId.current === w.id && 'opacity-60')}>
|
||||
{w.node}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!noDevices && (
|
||||
<p className="text-[11px] text-muted-foreground mt-2 max-w-4xl">{t('station.dragHint')}</p>
|
||||
)}
|
||||
|
||||
{editing && (
|
||||
<DeviceEditor device={editing} onChange={setEditing} onSave={saveEdit} onCancel={() => setEditing(null)} t={t} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
device: Device; onChange: (d: Device) => void; onSave: () => void; onCancel: () => void; t: (k: string, v?: any) => string;
|
||||
}) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||
const setType = (type: string) => {
|
||||
const n = RELAY_COUNT[type] ?? 5;
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, type, labels });
|
||||
};
|
||||
const isKM = device.type === 'kmtronic';
|
||||
return (
|
||||
<div ref={ref} className="mt-4 max-w-4xl rounded-xl border border-primary/40 bg-card p-4 space-y-3">
|
||||
<div className="text-sm font-semibold">{device.id ? t('station.editDevice') : t('station.addDevice')}</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.type')}</Label>
|
||||
<Select value={device.type} onValueChange={setType}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.name')}</Label>
|
||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
<div className={cn('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||
<Label>{t('station.host')}</Label>
|
||||
<Input className="font-mono" value={device.host} placeholder="192.168.1.100" onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
</div>
|
||||
{isKM && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.user')}</Label>
|
||||
<Input value={device.user ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, user: e.target.value })} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.pass')}</Label>
|
||||
<Input type="password" value={device.pass ?? ''} placeholder={t('station.optional')} onChange={(e) => onChange({ ...device, pass: e.target.value })} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.labels')}</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{device.labels.map((lab, i) => (
|
||||
<Input key={i} value={lab} placeholder={`${t('station.relay')} ${i + 1}`} className="h-8 text-xs"
|
||||
onChange={(e) => { const labels = [...device.labels]; labels[i] = e.target.value; onChange({ ...device, labels }); }} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={onCancel}><X className="size-3.5 mr-1" />{t('station.cancel')}</Button>
|
||||
<Button size="sm" onClick={onSave} disabled={!device.host.trim()}><Check className="size-3.5 mr-1" />{t('station.save')}</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,8 +89,8 @@ const en: Dict = {
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'External services',
|
||||
'sec.general': 'General', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': 'Callsign Lookup',
|
||||
'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.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'CW Keyer',
|
||||
'sec.udp': 'UDP integrations', 'sec.database': 'Database', 'sec.autostart': 'Autostart', 'sec.backup': 'Database backup', 'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder.', 'station.addDevice': 'Add device', 'station.editDevice': 'Edit device', 'station.empty': 'No relay boards yet. Add a WebSwitch 1216H or a KMTronic 8-relay board to control your station power and accessories.', 'station.online': 'Online', 'station.offline': 'Offline', 'station.edit': 'Edit', 'station.delete': 'Delete', 'station.relay': 'Relay', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': 'Device type', 'station.name': 'Name', 'station.host': 'Host / IP', 'station.user': 'Username', 'station.pass': 'Password', 'station.optional': 'optional', 'station.labels': 'Relay labels', 'station.cancel': 'Cancel', 'station.save': 'Save',
|
||||
'sec.awards': 'Awards', 'sec.cat': 'CAT interface', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'CW Keyer',
|
||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||
// General panel
|
||||
'gen.hint': 'App behaviour (saved instantly).',
|
||||
@@ -163,7 +163,7 @@ const en: Dict = {
|
||||
'autostart.hint': 'Launch external programs (WSJT-X, JTAlert, rotator control…) when OpsLog starts. A program already running is not started again. Saved per profile.',
|
||||
'cat.hint': "Reads the rig's frequency / band / mode and pushes them into the entry strip in real time. Use OmniRig (free, any rig) or — for FlexRadio — the native SmartSDR API (no OmniRig needed, real-time, no second-click mode bug).",
|
||||
'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.enable': 'Enable rotator control', 'rot.testOkRG': 'Connected — the Rotator Genius accepted the command (moving to 0°).', 'rot.type': 'Rotator type', 'rot.rotatorNum': 'Rotator #', 'rot.rgHint': 'Talks directly to a 4O3A Rotator Genius over TCP (default port 9006) — no PstRotator needed. Rotator # selects which of the two rotators to drive.', '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).',
|
||||
'hw.motorTxInhibit': 'Inhibit transmission while the antenna is moving', 'hw.motorTxInhibitHint': 'Blocks the FlexRadio from transmitting while the elements move (needs FlexRadio in API mode + this antenna enabled). No effect with other radios.', '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
|
||||
@@ -353,8 +353,8 @@ const fr: Dict = {
|
||||
'sec.confirmations': 'Confirmations', 'sec.external': 'Services externes',
|
||||
'sec.general': 'Général', 'sec.email': 'E-mail (SMTP)', 'sec.lookup': "Recherche d'indicatif",
|
||||
'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.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'PstRotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||
'sec.udp': 'Intégrations UDP', 'sec.database': 'Base de données', 'sec.autostart': 'Démarrage auto', 'sec.backup': 'Sauvegarde base', 'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner les widgets.', 'station.addDevice': 'Ajouter un appareil', 'station.editDevice': "Modifier l'appareil", 'station.empty': "Aucune carte relais. Ajoute un WebSwitch 1216H ou une carte KMTronic 8 relais pour piloter l'alimentation et les accessoires de ta station.", 'station.online': 'En ligne', 'station.offline': 'Hors ligne', 'station.edit': 'Modifier', 'station.delete': 'Supprimer', 'station.relay': 'Relais', 'station.on': 'ON', 'station.off': 'OFF', 'station.type': "Type d'appareil", 'station.name': 'Nom', 'station.host': 'Hôte / IP', 'station.user': "Nom d'utilisateur", 'station.pass': 'Mot de passe', 'station.optional': 'optionnel', 'station.labels': 'Libellés des relais', 'station.cancel': 'Annuler', 'station.save': 'Enregistrer',
|
||||
'sec.awards': 'Diplômes', 'sec.cat': 'Interface CAT', 'sec.rotator': 'Rotator', 'sec.winkeyer': 'Manipulateur CW',
|
||||
'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.autofocusWB': 'Focus auto sur « Déjà contacté » pour les stations connues',
|
||||
@@ -418,7 +418,7 @@ const fr: Dict = {
|
||||
'autostart.hint': "Lance des programmes externes (WSJT-X, JTAlert, contrôle rotator…) au démarrage d'OpsLog. Un programme déjà lancé n'est pas relancé. Enregistré par profil.",
|
||||
'cat.hint': "Lit la fréquence / bande / mode du poste et les injecte dans le bandeau de saisie en temps réel. Utilise OmniRig (gratuit, tout poste) ou — pour FlexRadio — l'API native SmartSDR (sans OmniRig, temps réel, sans le bug du mode au 2ᵉ clic).",
|
||||
'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.enable': 'Activer le contrôle du rotator', 'rot.testOkRG': 'Connecté — le Rotator Genius a accepté la commande (rotation vers 0°).', 'rot.type': 'Type de rotator', 'rot.rotatorNum': 'Rotator n°', 'rot.rgHint': 'Parle directement à un Rotator Genius 4O3A en TCP (port 9006 par défaut) — sans PstRotator. Le n° choisit lequel des deux rotators du boîtier piloter.', '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).",
|
||||
'hw.motorTxInhibit': "Inhiber la transmission pendant que l'antenne bouge", 'hw.motorTxInhibitHint': "Empêche le FlexRadio d'émettre pendant que les éléments bougent (nécessite le FlexRadio en API + cette antenne activée). Sans effet avec les autres radios.", '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)',
|
||||
|
||||
Vendored
+8
@@ -386,8 +386,12 @@ export function GetSolarData():Promise<solar.Data>;
|
||||
|
||||
export function GetStartupStatus():Promise<main.StartupStatus>;
|
||||
|
||||
export function GetStationDevices():Promise<Array<main.StationDevice>>;
|
||||
|
||||
export function GetStationSettings():Promise<main.StationSettings>;
|
||||
|
||||
export function GetStationStatus():Promise<Array<main.StationDeviceStatus>>;
|
||||
|
||||
export function GetTelemetryEnabled():Promise<boolean>;
|
||||
|
||||
export function GetUIPref(arg1:string):Promise<string>;
|
||||
@@ -718,6 +722,8 @@ export function SaveQSLDefaults(arg1:main.QSLDefaults):Promise<void>;
|
||||
|
||||
export function SaveRotatorSettings(arg1:main.RotatorSettings):Promise<void>;
|
||||
|
||||
export function SaveStationDevices(arg1:Array<main.StationDevice>):Promise<void>;
|
||||
|
||||
export function SaveStationSettings(arg1:main.StationSettings):Promise<void>;
|
||||
|
||||
export function SaveUDPIntegration(arg1:udp.Config):Promise<udp.Config>;
|
||||
@@ -766,6 +772,8 @@ export function SetUltrabeamDirection(arg1:number):Promise<void>;
|
||||
|
||||
export function StartCWDecoder():Promise<void>;
|
||||
|
||||
export function StationSetRelay(arg1:string,arg2:number,arg3:boolean):Promise<void>;
|
||||
|
||||
export function StopCWDecoder():Promise<void>;
|
||||
|
||||
export function SwitchCATRig(arg1:number):Promise<void>;
|
||||
|
||||
@@ -730,10 +730,18 @@ export function GetStartupStatus() {
|
||||
return window['go']['main']['App']['GetStartupStatus']();
|
||||
}
|
||||
|
||||
export function GetStationDevices() {
|
||||
return window['go']['main']['App']['GetStationDevices']();
|
||||
}
|
||||
|
||||
export function GetStationSettings() {
|
||||
return window['go']['main']['App']['GetStationSettings']();
|
||||
}
|
||||
|
||||
export function GetStationStatus() {
|
||||
return window['go']['main']['App']['GetStationStatus']();
|
||||
}
|
||||
|
||||
export function GetTelemetryEnabled() {
|
||||
return window['go']['main']['App']['GetTelemetryEnabled']();
|
||||
}
|
||||
@@ -1394,6 +1402,10 @@ export function SaveRotatorSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveRotatorSettings'](arg1);
|
||||
}
|
||||
|
||||
export function SaveStationDevices(arg1) {
|
||||
return window['go']['main']['App']['SaveStationDevices'](arg1);
|
||||
}
|
||||
|
||||
export function SaveStationSettings(arg1) {
|
||||
return window['go']['main']['App']['SaveStationSettings'](arg1);
|
||||
}
|
||||
@@ -1490,6 +1502,10 @@ export function StartCWDecoder() {
|
||||
return window['go']['main']['App']['StartCWDecoder']();
|
||||
}
|
||||
|
||||
export function StationSetRelay(arg1, arg2, arg3) {
|
||||
return window['go']['main']['App']['StationSetRelay'](arg1, arg2, arg3);
|
||||
}
|
||||
|
||||
export function StopCWDecoder() {
|
||||
return window['go']['main']['App']['StopCWDecoder']();
|
||||
}
|
||||
|
||||
@@ -2333,9 +2333,11 @@ export namespace main {
|
||||
}
|
||||
export class RotatorSettings {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
has_elevation: boolean;
|
||||
rotator_num: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new RotatorSettings(source);
|
||||
@@ -2344,9 +2346,11 @@ export namespace main {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.type = source["type"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.has_elevation = source["has_elevation"];
|
||||
this.rotator_num = source["rotator_num"];
|
||||
}
|
||||
}
|
||||
export class SecretStatus {
|
||||
@@ -2419,6 +2423,86 @@ export namespace main {
|
||||
this.db_path = source["db_path"];
|
||||
}
|
||||
}
|
||||
export class StationDevice {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
host: string;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
labels: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StationDevice(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.type = source["type"];
|
||||
this.name = source["name"];
|
||||
this.host = source["host"];
|
||||
this.user = source["user"];
|
||||
this.pass = source["pass"];
|
||||
this.labels = source["labels"];
|
||||
}
|
||||
}
|
||||
export class StationRelay {
|
||||
number: number;
|
||||
label: string;
|
||||
on: boolean;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StationRelay(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.number = source["number"];
|
||||
this.label = source["label"];
|
||||
this.on = source["on"];
|
||||
}
|
||||
}
|
||||
export class StationDeviceStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
type: string;
|
||||
connected: boolean;
|
||||
error?: string;
|
||||
relays: StationRelay[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new StationDeviceStatus(source);
|
||||
}
|
||||
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.id = source["id"];
|
||||
this.name = source["name"];
|
||||
this.type = source["type"];
|
||||
this.connected = source["connected"];
|
||||
this.error = source["error"];
|
||||
this.relays = this.convertValues(source["relays"], StationRelay);
|
||||
}
|
||||
|
||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||
if (!a) {
|
||||
return a;
|
||||
}
|
||||
if (a.slice && a.map) {
|
||||
return (a as any[]).map(elem => this.convertValues(elem, classs));
|
||||
} else if ("object" === typeof a) {
|
||||
if (asMap) {
|
||||
for (const key of Object.keys(a)) {
|
||||
a[key] = new classs(a[key]);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
return new classs(a);
|
||||
}
|
||||
return a;
|
||||
}
|
||||
}
|
||||
export class StationInfoComputed {
|
||||
country: string;
|
||||
dxcc: number;
|
||||
@@ -2441,6 +2525,7 @@ export namespace main {
|
||||
this.lon = source["lon"];
|
||||
}
|
||||
}
|
||||
|
||||
export class StationSettings {
|
||||
callsign: string;
|
||||
operator: string;
|
||||
|
||||
@@ -1713,7 +1713,16 @@ func (f *Flex) SetTune(on bool) error {
|
||||
// the operator from keying while a motorized antenna's elements are moving —
|
||||
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
|
||||
// footswitch or an external keyer, which a software PTT block could not.
|
||||
//
|
||||
// It also labels the interlock reason "OpsLog" so SmartSDR shows WHY TX is
|
||||
// blocked. The reason is best-effort (a separate interlock object); the inhibit
|
||||
// is what actually blocks TX, so a rig that ignores the reason still stays safe.
|
||||
func (f *Flex) SetTXInhibit(on bool) error {
|
||||
if on {
|
||||
f.send("interlock set reason=OpsLog")
|
||||
} else {
|
||||
f.send("interlock set reason=")
|
||||
}
|
||||
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = on })
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// Package relaydev drives network relay boards used for station control (power
|
||||
// sequencing, switching accessories). Two devices are supported, both over HTTP:
|
||||
//
|
||||
// - WebSwitch 1216H — 5 relays. Control: GET /relaycontrol/{on|off}/{n};
|
||||
// status: GET /relaystate/get2/1$2$3$4$5$ → lines "n,state".
|
||||
// (Protocol taken from the operator's own working ShackMaster driver.)
|
||||
// - KMTronic LAN 8-relay WEB board — 8 relays. Control: GET /FF{rr}{ss}
|
||||
// (rr = 01..08, ss = 01 on / 00 off); status: GET /status.xml with
|
||||
// <relay1>..<relay8> (relay0 is reserved). Optional HTTP basic auth.
|
||||
//
|
||||
// A Device presents the same surface to the app regardless of wire protocol.
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Device is one relay board.
|
||||
type Device interface {
|
||||
Count() int // number of user-controllable relays
|
||||
Status(ctx context.Context) ([]bool, error) // state of each relay (index 0 = relay 1)
|
||||
Set(ctx context.Context, relay int, on bool) error // relay is 1-based
|
||||
}
|
||||
|
||||
func httpClient() *http.Client { return &http.Client{Timeout: 5 * time.Second} }
|
||||
|
||||
// get issues a GET with optional basic auth and returns the body on 2xx.
|
||||
func get(ctx context.Context, url, user, pass string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user != "" || pass != "" {
|
||||
req.SetBasicAuth(user, pass)
|
||||
}
|
||||
resp, err := httpClient().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// ── WebSwitch 1216H ────────────────────────────────────────────────────
|
||||
|
||||
type webswitch struct {
|
||||
host string
|
||||
count int
|
||||
}
|
||||
|
||||
// NewWebswitch builds a WebSwitch 1216H client (5 relays).
|
||||
func NewWebswitch(host string) Device { return &webswitch{host: host, count: 5} }
|
||||
|
||||
func (w *webswitch) Count() int { return w.count }
|
||||
|
||||
func (w *webswitch) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > w.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, w.count)
|
||||
}
|
||||
action := "off"
|
||||
if on {
|
||||
action = "on"
|
||||
}
|
||||
_, err := get(ctx, fmt.Sprintf("http://%s/relaycontrol/%s/%d", w.host, action, relay), "", "")
|
||||
return err
|
||||
}
|
||||
|
||||
func (w *webswitch) Status(ctx context.Context) ([]bool, error) {
|
||||
// Build the "1$2$3$..." selector the device expects.
|
||||
var sel strings.Builder
|
||||
for i := 1; i <= w.count; i++ {
|
||||
sel.WriteString(strconv.Itoa(i))
|
||||
sel.WriteByte('$')
|
||||
}
|
||||
body, err := get(ctx, fmt.Sprintf("http://%s/relaystate/get2/%s", w.host, sel.String()), "", "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]bool, w.count)
|
||||
// Lines "n,state" — "1,1", "2,0", …
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(body)), "\n") {
|
||||
parts := strings.Split(strings.TrimSpace(line), ",")
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
n, e1 := strconv.Atoi(parts[0])
|
||||
st, e2 := strconv.Atoi(strings.TrimSpace(parts[1]))
|
||||
if e1 != nil || e2 != nil || n < 1 || n > w.count {
|
||||
continue
|
||||
}
|
||||
out[n-1] = st == 1
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ── KMTronic LAN 8-relay WEB ───────────────────────────────────────────
|
||||
|
||||
type kmtronic struct {
|
||||
host string
|
||||
user, pass string
|
||||
count int
|
||||
}
|
||||
|
||||
// NewKMTronic builds a KMTronic LAN WEB relay client (8 relays). user/pass are
|
||||
// blank unless the board's HTTP authentication is enabled.
|
||||
func NewKMTronic(host, user, pass string) Device {
|
||||
return &kmtronic{host: host, user: user, pass: pass, count: 8}
|
||||
}
|
||||
|
||||
func (k *kmtronic) Count() int { return k.count }
|
||||
|
||||
func (k *kmtronic) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > k.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, k.count)
|
||||
}
|
||||
state := "00"
|
||||
if on {
|
||||
state = "01"
|
||||
}
|
||||
// FF<rr><ss>: e.g. FF0101 = relay 1 on, FF0800 = relay 8 off.
|
||||
_, err := get(ctx, fmt.Sprintf("http://%s/FF%02d%s", k.host, relay, state), k.user, k.pass)
|
||||
return err
|
||||
}
|
||||
|
||||
// kmStatus mirrors status.xml. relay0 is reserved; relay1..relay8 are the board.
|
||||
type kmStatus struct {
|
||||
XMLName xml.Name `xml:"response"`
|
||||
Relays []struct {
|
||||
XMLName xml.Name
|
||||
Value string `xml:",chardata"`
|
||||
} `xml:",any"`
|
||||
}
|
||||
|
||||
func (k *kmtronic) Status(ctx context.Context) ([]bool, error) {
|
||||
body, err := get(ctx, fmt.Sprintf("http://%s/status.xml", k.host), k.user, k.pass)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var s kmStatus
|
||||
if err := xml.Unmarshal(body, &s); err != nil {
|
||||
return nil, fmt.Errorf("kmtronic: bad status.xml: %w", err)
|
||||
}
|
||||
out := make([]bool, k.count)
|
||||
for _, r := range s.Relays {
|
||||
// Element names are relay0..relay8; relay0 is reserved.
|
||||
name := r.XMLName.Local
|
||||
if !strings.HasPrefix(name, "relay") {
|
||||
continue
|
||||
}
|
||||
n, e := strconv.Atoi(strings.TrimPrefix(name, "relay"))
|
||||
if e != nil || n < 1 || n > k.count {
|
||||
continue
|
||||
}
|
||||
out[n-1] = strings.TrimSpace(r.Value) == "1"
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The status parsers are the risky part; pin them against the documented wire
|
||||
// formats using a stub HTTP server.
|
||||
|
||||
func TestWebswitchStatus(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !strings.HasPrefix(r.URL.Path, "/relaystate/get2/") {
|
||||
t.Errorf("unexpected status path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte("1,1\n2,0\n3,1\n4,0\n5,1\n"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
|
||||
st, err := d.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []bool{true, false, true, false, true}
|
||||
if len(st) != 5 {
|
||||
t.Fatalf("got %d relays, want 5", len(st))
|
||||
}
|
||||
for i := range want {
|
||||
if st[i] != want[i] {
|
||||
t.Errorf("relay %d = %v, want %v", i+1, st[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebswitchSetURL(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
}))
|
||||
defer srv.Close()
|
||||
d := NewWebswitch(strings.TrimPrefix(srv.URL, "http://"))
|
||||
if err := d.Set(context.Background(), 4, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotPath != "/relaycontrol/on/4" {
|
||||
t.Errorf("set path = %q, want /relaycontrol/on/4", gotPath)
|
||||
}
|
||||
_ = d.Set(context.Background(), 4, false)
|
||||
}
|
||||
|
||||
func TestKMTronicStatus(t *testing.T) {
|
||||
// relay0 reserved (ignored); relay7 + relay8 ON.
|
||||
xmlBody := `<?xml version="1.0"?><response>` +
|
||||
`<relay0>0</relay0><relay1>0</relay1><relay2>0</relay2><relay3>0</relay3>` +
|
||||
`<relay4>0</relay4><relay5>0</relay5><relay6>0</relay6><relay7>1</relay7><relay8>1</relay8>` +
|
||||
`</response>`
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/status.xml" {
|
||||
t.Errorf("unexpected path %q", r.URL.Path)
|
||||
}
|
||||
_, _ = w.Write([]byte(xmlBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
|
||||
st, err := d.Status(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(st) != 8 {
|
||||
t.Fatalf("got %d relays, want 8", len(st))
|
||||
}
|
||||
if st[6] != true || st[7] != true {
|
||||
t.Errorf("relay7/8 = %v/%v, want on/on", st[6], st[7])
|
||||
}
|
||||
for i := 0; i < 6; i++ {
|
||||
if st[i] {
|
||||
t.Errorf("relay %d unexpectedly on", i+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKMTronicSetURL(t *testing.T) {
|
||||
var gotPath string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
}))
|
||||
defer srv.Close()
|
||||
d := NewKMTronic(strings.TrimPrefix(srv.URL, "http://"), "", "")
|
||||
if err := d.Set(context.Background(), 8, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gotPath != "/FF0801" {
|
||||
t.Errorf("set path = %q, want /FF0801", gotPath)
|
||||
}
|
||||
_ = d.Set(context.Background(), 1, false) // → /FF0100
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
// Package rotgenius drives a 4O3A Rotator Genius over its native TCP text
|
||||
// protocol (rev 4, default port 9006). All data is fixed-length extended-ASCII;
|
||||
// there is no sequence/framing wrapper — you send a short command and read back a
|
||||
// fixed-length reply.
|
||||
//
|
||||
// Commands used here:
|
||||
//
|
||||
// |h read heading + full state (both rotators)
|
||||
// |A<rot><az3> move rotator <rot> ('1'|'2') to azimuth az3 (000..360)
|
||||
// |P<rot> / |M<rot> rotate CW / CCW
|
||||
// |S stop all movement
|
||||
//
|
||||
// The |h reply is 72 bytes: "|h" + Active[1] + Panic[1] then, per rotator,
|
||||
// CurrentAzimuth[3] LimitCW[3] LimitCCW[3] Config[1] Moving[1] Offset[4]
|
||||
// TargetAzimuth[3] StartAzimuth[3] Limit[1] Name[12]. Numeric fields may be
|
||||
// space-padded; a CurrentAzimuth of 999 means the sensor is not connected.
|
||||
package rotgenius
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPort = 9006
|
||||
dialTimeout = 4 * time.Second
|
||||
ioTimeout = 4 * time.Second
|
||||
hdrReplyLen = 72 // fixed length of the |h reply
|
||||
)
|
||||
|
||||
// Status is one rotator's live state parsed from a |h reply.
|
||||
type Status struct {
|
||||
Azimuth int // current heading in degrees (0..360)
|
||||
Connected bool // false when the sensor reports 999 (not connected)
|
||||
Moving int // 0 not moving, 1 CW, 2 CCW
|
||||
Target int // target azimuth when moving (else -1)
|
||||
}
|
||||
|
||||
// Client is a stateless connector: each call opens a short-lived TCP connection,
|
||||
// mirroring how the PstRotator client works, so there is no socket to manage.
|
||||
type Client struct {
|
||||
host string
|
||||
port int
|
||||
}
|
||||
|
||||
func New(host string, port int) *Client {
|
||||
if port <= 0 {
|
||||
port = defaultPort
|
||||
}
|
||||
return &Client{host: host, port: port}
|
||||
}
|
||||
|
||||
func (c *Client) dial() (net.Conn, error) {
|
||||
d := net.Dialer{Timeout: dialTimeout}
|
||||
conn, err := d.Dial("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(ioTimeout))
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
// exchange sends cmd and returns up to max bytes of the reply.
|
||||
func (c *Client) exchange(cmd string, max int) ([]byte, error) {
|
||||
conn, err := c.dial()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer conn.Close()
|
||||
if _, err := conn.Write([]byte(cmd)); err != nil {
|
||||
return nil, fmt.Errorf("write %q: %w", cmd, err)
|
||||
}
|
||||
buf := make([]byte, 0, max)
|
||||
tmp := make([]byte, max)
|
||||
for len(buf) < max {
|
||||
n, rerr := conn.Read(tmp)
|
||||
if n > 0 {
|
||||
buf = append(buf, tmp[:n]...)
|
||||
}
|
||||
if rerr != nil {
|
||||
break // deadline or EOF — return what we have and let the parser judge
|
||||
}
|
||||
}
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// atoiField trims the space-padding a Rotator Genius field may carry and parses
|
||||
// it. An empty or non-numeric field yields 0.
|
||||
func atoiField(s string) int {
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(s))
|
||||
return n
|
||||
}
|
||||
|
||||
// Heading reads the current azimuth of the given rotator (1 or 2). raw is the
|
||||
// decoded field for diagnostics.
|
||||
func (c *Client) Heading(rotator int) (Status, string, error) {
|
||||
st, err := c.Read(rotator)
|
||||
if err != nil {
|
||||
return Status{}, "", err
|
||||
}
|
||||
return st, strconv.Itoa(st.Azimuth), nil
|
||||
}
|
||||
|
||||
// Read fetches and parses the full |h reply for one rotator (1 or 2).
|
||||
func (c *Client) Read(rotator int) (Status, error) {
|
||||
if rotator != 1 && rotator != 2 {
|
||||
rotator = 1
|
||||
}
|
||||
reply, err := c.exchange("|h", hdrReplyLen)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
i := strings.Index(string(reply), "|h")
|
||||
if i < 0 || len(reply)-i < hdrReplyLen {
|
||||
return Status{}, fmt.Errorf("rotgenius: short |h reply (%d bytes)", len(reply))
|
||||
}
|
||||
p := reply[i:]
|
||||
// Per-rotator block base: rotator 1 at offset 4, rotator 2 at 4+34=38.
|
||||
base := 4
|
||||
if rotator == 2 {
|
||||
base = 38
|
||||
}
|
||||
// Within a rotator block: CurrentAzimuth@0, LimitCW@3, LimitCCW@6, Config@9,
|
||||
// Moving@10, Offset@11, TargetAzimuth@15, StartAzimuth@18, Limit@21, Name@22.
|
||||
cur := atoiField(string(p[base : base+3]))
|
||||
moving := atoiField(string(p[base+10 : base+11]))
|
||||
target := atoiField(string(p[base+15 : base+18]))
|
||||
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
|
||||
if target != 999 {
|
||||
st.Target = target
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// GoTo moves the rotator to az (0..360). The reply's status byte is 'K' on
|
||||
// accept, 'F' on reject.
|
||||
func (c *Client) GoTo(rotator, az int) error {
|
||||
if rotator != 1 && rotator != 2 {
|
||||
rotator = 1
|
||||
}
|
||||
if az < 0 {
|
||||
az = 0
|
||||
}
|
||||
if az > 360 {
|
||||
az = 360
|
||||
}
|
||||
reply, err := c.exchange(fmt.Sprintf("|A%d%03d", rotator, az), 8)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkKF(reply, "GoTo")
|
||||
}
|
||||
|
||||
// Stop halts all movement.
|
||||
func (c *Client) Stop() error {
|
||||
reply, err := c.exchange("|S", 8)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkKF(reply, "Stop")
|
||||
}
|
||||
|
||||
// CW / CCW nudge a rotator; it runs to its limit unless stopped.
|
||||
func (c *Client) CW(rotator int) error { return c.rotate('P', rotator) }
|
||||
func (c *Client) CCW(rotator int) error { return c.rotate('M', rotator) }
|
||||
|
||||
func (c *Client) rotate(cmd byte, rotator int) error {
|
||||
if rotator != 1 && rotator != 2 {
|
||||
rotator = 1
|
||||
}
|
||||
reply, err := c.exchange(fmt.Sprintf("|%c%d", cmd, rotator), 8)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return checkKF(reply, string(cmd))
|
||||
}
|
||||
|
||||
// checkKF reads the accept/reject status: 'K' ok, 'F' failed. The reply carries
|
||||
// no other letters (the rest is the header + digits), so scanning for them is
|
||||
// unambiguous.
|
||||
func checkKF(reply []byte, what string) error {
|
||||
s := string(reply)
|
||||
if strings.ContainsRune(s, 'K') {
|
||||
return nil
|
||||
}
|
||||
if strings.ContainsRune(s, 'F') {
|
||||
return fmt.Errorf("rotgenius: %s rejected by the controller", what)
|
||||
}
|
||||
return fmt.Errorf("rotgenius: no reply to %s", what)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
package rotgenius
|
||||
|
||||
import "testing"
|
||||
|
||||
// Build a 72-byte |h reply from per-rotator field values, so the fixed offsets in
|
||||
// Read() are pinned to the rev-4 layout. Numeric fields are space/zero padded to
|
||||
// their documented widths.
|
||||
func buildHReply(cur1, cw1, ccw1 string, cfg1, mv1 byte, off1, tgt1, start1 string, lim1 byte, name1,
|
||||
cur2, cw2, ccw2 string, cfg2, mv2 byte, off2, tgt2, start2 string, lim2 byte, name2 string) []byte {
|
||||
pad := func(s string, n int) string {
|
||||
for len(s) < n {
|
||||
s = " " + s
|
||||
}
|
||||
return s[:n]
|
||||
}
|
||||
b := []byte("|h")
|
||||
b = append(b, '0', 0x00) // Active, Panic
|
||||
block := func(cur, cw, ccw string, cfg, mv byte, off, tgt, start string, lim byte, name string) {
|
||||
b = append(b, []byte(pad(cur, 3))...)
|
||||
b = append(b, []byte(pad(cw, 3))...)
|
||||
b = append(b, []byte(pad(ccw, 3))...)
|
||||
b = append(b, cfg, mv)
|
||||
b = append(b, []byte(pad(off, 4))...)
|
||||
b = append(b, []byte(pad(tgt, 3))...)
|
||||
b = append(b, []byte(pad(start, 3))...)
|
||||
b = append(b, lim)
|
||||
b = append(b, []byte(pad(name, 12))...)
|
||||
}
|
||||
block(cur1, cw1, ccw1, cfg1, mv1, off1, tgt1, start1, lim1, name1)
|
||||
block(cur2, cw2, ccw2, cfg2, mv2, off2, tgt2, start2, lim2, name2)
|
||||
return b
|
||||
}
|
||||
|
||||
func TestReadParsesBothRotators(t *testing.T) {
|
||||
// Rotator 1: az 100, moving CW (1), no target (999). Rotator 2: az 999 (sensor
|
||||
// offline), not moving. Mirrors the manual's worked example.
|
||||
reply := buildHReply(
|
||||
"100", "005", "350", 'A', '1', "0", "999", "999", '0', "TOW1",
|
||||
"999", "010", "060", 'E', '0', "1", "999", "999", '0', "")
|
||||
|
||||
c := &Client{}
|
||||
_ = c
|
||||
if len(reply) != hdrReplyLen {
|
||||
t.Fatalf("built reply is %d bytes, want %d — field widths drifted from rev 4", len(reply), hdrReplyLen)
|
||||
}
|
||||
|
||||
st1, err := parseFor(reply, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st1.Azimuth != 100 || !st1.Connected || st1.Moving != 1 {
|
||||
t.Errorf("rotator 1 = %+v, want az 100, connected, moving CW", st1)
|
||||
}
|
||||
if st1.Target != -1 {
|
||||
t.Errorf("rotator 1 target = %d, want -1 (999 = not set)", st1.Target)
|
||||
}
|
||||
|
||||
st2, err := parseFor(reply, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st2.Connected || st2.Azimuth != 999 {
|
||||
t.Errorf("rotator 2 = %+v, want disconnected (az 999)", st2)
|
||||
}
|
||||
}
|
||||
|
||||
// parseFor exercises the offset math without a socket.
|
||||
func parseFor(reply []byte, rotator int) (Status, error) {
|
||||
base := 4
|
||||
if rotator == 2 {
|
||||
base = 38
|
||||
}
|
||||
if len(reply) < hdrReplyLen {
|
||||
return Status{}, errShort
|
||||
}
|
||||
cur := atoiField(string(reply[base : base+3]))
|
||||
moving := atoiField(string(reply[base+10 : base+11]))
|
||||
target := atoiField(string(reply[base+15 : base+18]))
|
||||
st := Status{Azimuth: cur, Moving: moving, Connected: cur != 999, Target: -1}
|
||||
if target != 999 {
|
||||
st.Target = target
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
var errShort = fmtErrorf("short")
|
||||
|
||||
func fmtErrorf(s string) error { return &strErr{s} }
|
||||
|
||||
type strErr struct{ s string }
|
||||
|
||||
func (e *strErr) Error() string { return e.s }
|
||||
|
||||
func TestGoToFormatting(t *testing.T) {
|
||||
// The command must zero-pad the azimuth to 3 digits, per the manual's fields.
|
||||
cases := map[int]string{0: "|A1000", 5: "|A1005", 90: "|A1090", 360: "|A1360"}
|
||||
for az, want := range cases {
|
||||
got := "|A" + "1" + pad3(az)
|
||||
if got != want {
|
||||
t.Errorf("az %d → %q, want %q", az, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func pad3(az int) string {
|
||||
s := ""
|
||||
switch {
|
||||
case az >= 100:
|
||||
s = itoa(az)
|
||||
case az >= 10:
|
||||
s = "0" + itoa(az)
|
||||
default:
|
||||
s = "00" + itoa(az)
|
||||
}
|
||||
return s
|
||||
}
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
var b []byte
|
||||
for n > 0 {
|
||||
b = append([]byte{byte('0' + n%10)}, b...)
|
||||
n /= 10
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
Reference in New Issue
Block a user