Compare commits
4
Commits
3cef885934
...
666b933114
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
666b933114 | ||
|
|
def59da748 | ||
|
|
fe69bc308c | ||
|
|
c07a17dc47 |
@@ -182,10 +182,16 @@ const (
|
||||
keyAntGeniusHost = "antgenius.host"
|
||||
keyAntGeniusPassword = "antgenius.password" // remote/AUTH password (blank on LAN)
|
||||
|
||||
// PowerGenius XL (4O3A) amplifier fan-mode control — Hardware → PowerGenius.
|
||||
// Amplifier control — Hardware → Amplifier (PowerGenius XL over TCP; SPE Expert
|
||||
// over USB serial or an RS232-to-Ethernet bridge). Keys keep the pgxl.* prefix
|
||||
// for backward compatibility with existing saved settings.
|
||||
keyPGXLEnabled = "pgxl.enabled"
|
||||
keyPGXLHost = "pgxl.host"
|
||||
keyPGXLPort = "pgxl.port"
|
||||
keyPGXLType = "pgxl.type" // "pgxl" | "spe13" | "spe15" | "spe2k"
|
||||
keyPGXLTransport = "pgxl.transport" // "tcp" | "serial"
|
||||
keyPGXLComPort = "pgxl.com_port"
|
||||
keyPGXLBaud = "pgxl.baud"
|
||||
|
||||
// WinKeyer CW keyer (serial) — Hardware → CW Keyer.
|
||||
keyWKEnabled = "winkeyer.enabled"
|
||||
@@ -11397,29 +11403,59 @@ func boolStr(b bool) string {
|
||||
// StationDevice is one configured relay board for the Station Control tab.
|
||||
type StationDevice struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // "webswitch" | "kmtronic"
|
||||
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Host string `json:"host"` // IP for network boards; FTDI serial for denkovi; COM port for usbrelay
|
||||
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
|
||||
Pass string `json:"pass,omitempty"`
|
||||
Channels int `json:"channels,omitempty"` // usbrelay only: number of relays (1/2/4/8/16)
|
||||
Labels []string `json:"labels"` // per-relay label (index 0 = relay 1)
|
||||
}
|
||||
|
||||
// deviceRelayCount is the relay count for a configured device — fixed by type,
|
||||
// except the Denkovi (4 or 8) and the generic USB-serial board, whose channel
|
||||
// count the user picks.
|
||||
func deviceRelayCount(d StationDevice) int {
|
||||
if d.Type == "usbrelay" || d.Type == "denkovi" {
|
||||
if d.Channels >= 1 {
|
||||
return d.Channels
|
||||
}
|
||||
return 8
|
||||
}
|
||||
return relayCountFor(d.Type)
|
||||
}
|
||||
|
||||
// relayCountFor returns a device type's fixed relay count.
|
||||
func relayCountFor(typ string) int {
|
||||
if typ == "kmtronic" {
|
||||
switch typ {
|
||||
case "kmtronic", "denkovi":
|
||||
return 8
|
||||
}
|
||||
default:
|
||||
return 5 // webswitch 1216H
|
||||
}
|
||||
}
|
||||
|
||||
// deviceDriver builds the wire driver for a configured device.
|
||||
func deviceDriver(d StationDevice) relaydev.Device {
|
||||
if d.Type == "kmtronic" {
|
||||
switch d.Type {
|
||||
case "kmtronic":
|
||||
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
|
||||
}
|
||||
case "denkovi":
|
||||
// Host carries the FTDI serial number (e.g. "DAE0006K"), not a hostname.
|
||||
return relaydev.NewDenkovi(d.Host, deviceRelayCount(d))
|
||||
case "usbrelay":
|
||||
// Host carries the COM port (e.g. "COM5"); CH340/LCUS "A0" serial protocol.
|
||||
return relaydev.NewSerialRelay(d.Host, deviceRelayCount(d))
|
||||
default:
|
||||
return relaydev.NewWebswitch(d.Host)
|
||||
}
|
||||
}
|
||||
|
||||
// ListDenkoviDevices returns the FTDI serial numbers of connected Denkovi/FTDI
|
||||
// boards, for the settings picker. Windows-only (FTDI D2XX).
|
||||
func (a *App) ListDenkoviDevices() ([]string, error) {
|
||||
return relaydev.ListDenkovi()
|
||||
}
|
||||
|
||||
// GetStationDevices returns the configured relay boards (without live state).
|
||||
func (a *App) GetStationDevices() []StationDevice {
|
||||
@@ -11444,13 +11480,16 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
|
||||
return fmt.Errorf("db not initialized")
|
||||
}
|
||||
for i := range devs {
|
||||
if devs[i].Type != "kmtronic" {
|
||||
switch devs[i].Type {
|
||||
case "kmtronic", "denkovi", "usbrelay", "webswitch":
|
||||
// known type — keep
|
||||
default:
|
||||
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)
|
||||
n := deviceRelayCount(devs[i])
|
||||
for len(devs[i].Labels) < n {
|
||||
devs[i].Labels = append(devs[i].Labels, "")
|
||||
}
|
||||
@@ -11491,7 +11530,7 @@ func (a *App) GetStationStatus() []StationDeviceStatus {
|
||||
go func(i int) {
|
||||
defer wg.Done()
|
||||
d := devs[i]
|
||||
n := relayCountFor(d.Type)
|
||||
n := deviceRelayCount(d)
|
||||
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
|
||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||
defer cancel()
|
||||
@@ -12098,19 +12137,25 @@ func (a *App) AntGeniusDeselect(port int) error {
|
||||
|
||||
// ── PowerGenius XL (4O3A) amplifier fan control (TCP, default port 9008) ─────
|
||||
|
||||
// PGXLSettings is the JSON shape for the Hardware → PowerGenius panel.
|
||||
// PGXLSettings is the JSON shape for the Hardware → Amplifier panel. It covers
|
||||
// the 4O3A PowerGenius XL (TCP) and the SPE Expert amps (USB serial or an
|
||||
// RS232-to-Ethernet bridge). The type name is kept for binding compatibility.
|
||||
type PGXLSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Type string `json:"type"` // "pgxl" | "spe13" | "spe15" | "spe2k"
|
||||
Transport string `json:"transport"` // "tcp" | "serial"
|
||||
Host string `json:"host"` // TCP transport
|
||||
Port int `json:"port"` // TCP transport
|
||||
ComPort string `json:"com_port"` // serial transport
|
||||
Baud int `json:"baud"` // serial transport
|
||||
}
|
||||
|
||||
func (a *App) GetPGXLSettings() (PGXLSettings, error) {
|
||||
out := PGXLSettings{Port: 9008}
|
||||
out := PGXLSettings{Type: "pgxl", Transport: "tcp", Port: 9008, Baud: 115200}
|
||||
if a.settings == nil {
|
||||
return out, fmt.Errorf("db not initialized")
|
||||
}
|
||||
m, err := a.settings.GetMany(a.ctx, keyPGXLEnabled, keyPGXLHost, keyPGXLPort)
|
||||
m, err := a.settings.GetMany(a.ctx, keyPGXLEnabled, keyPGXLHost, keyPGXLPort, keyPGXLType, keyPGXLTransport, keyPGXLComPort, keyPGXLBaud)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
@@ -12119,6 +12164,16 @@ func (a *App) GetPGXLSettings() (PGXLSettings, error) {
|
||||
if p, _ := strconv.Atoi(m[keyPGXLPort]); p > 0 && p <= 65535 {
|
||||
out.Port = p
|
||||
}
|
||||
if t := strings.TrimSpace(m[keyPGXLType]); t != "" {
|
||||
out.Type = t
|
||||
}
|
||||
if tr := strings.TrimSpace(m[keyPGXLTransport]); tr != "" {
|
||||
out.Transport = tr
|
||||
}
|
||||
out.ComPort = m[keyPGXLComPort]
|
||||
if b, _ := strconv.Atoi(m[keyPGXLBaud]); b > 0 {
|
||||
out.Baud = b
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
@@ -12129,10 +12184,23 @@ func (a *App) SavePGXLSettings(s PGXLSettings) error {
|
||||
if s.Port <= 0 || s.Port > 65535 {
|
||||
s.Port = 9008
|
||||
}
|
||||
if s.Baud <= 0 {
|
||||
s.Baud = 115200
|
||||
}
|
||||
if s.Type == "" {
|
||||
s.Type = "pgxl"
|
||||
}
|
||||
if s.Transport == "" {
|
||||
s.Transport = "tcp"
|
||||
}
|
||||
for k, v := range map[string]string{
|
||||
keyPGXLEnabled: boolStr(s.Enabled),
|
||||
keyPGXLHost: strings.TrimSpace(s.Host),
|
||||
keyPGXLPort: strconv.Itoa(s.Port),
|
||||
keyPGXLType: s.Type,
|
||||
keyPGXLTransport: s.Transport,
|
||||
keyPGXLComPort: strings.TrimSpace(s.ComPort),
|
||||
keyPGXLBaud: strconv.Itoa(s.Baud),
|
||||
} {
|
||||
if err := a.settings.Set(a.ctx, k, v); err != nil {
|
||||
return err
|
||||
@@ -12152,7 +12220,17 @@ func (a *App) startPGXL() {
|
||||
a.pgxl = nil
|
||||
}
|
||||
s, err := a.GetPGXLSettings()
|
||||
if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" {
|
||||
if err != nil || !s.Enabled {
|
||||
return
|
||||
}
|
||||
// Only the PowerGenius XL (TCP) is driven for now. SPE Expert control (serial /
|
||||
// RS232-to-Ethernet) is a separate protocol, not yet implemented — its settings
|
||||
// are stored but no client is started.
|
||||
if s.Type != "" && s.Type != "pgxl" {
|
||||
applog.Printf("amplifier: type %q selected — control not implemented yet (settings saved)", s.Type)
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(s.Host) == "" {
|
||||
return
|
||||
}
|
||||
a.pgxl = powergenius.New(s.Host, s.Port)
|
||||
|
||||
@@ -276,7 +276,7 @@ const SECTION_LABELS: Partial<Record<SectionId, string>> = {
|
||||
winkeyer: 'CW Keyer',
|
||||
antenna: 'Ultrabeam / Steppir',
|
||||
antgenius: 'Antenna Genius',
|
||||
pgxl: 'Power Genius',
|
||||
pgxl: 'Amplifier',
|
||||
flex: 'FlexRadio',
|
||||
relayauto: 'Relay auto-control',
|
||||
audio: 'Audio devices',
|
||||
@@ -647,7 +647,7 @@ function ADIFMonitorPanel() {
|
||||
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
|
||||
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
|
||||
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
|
||||
const relayCountUI = (type: string) => (type === 'kmtronic' ? 8 : 5);
|
||||
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
|
||||
function RelayAutoPanel() {
|
||||
const { t } = useI18n();
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
@@ -698,7 +698,7 @@ function RelayAutoPanel() {
|
||||
<div key={dev.id} className="rounded-md border border-border">
|
||||
<div className="px-3 py-1.5 border-b border-border bg-muted/40 text-xs font-semibold">{dev.name || dev.id}</div>
|
||||
<div className="divide-y divide-border/60">
|
||||
{Array.from({ length: relayCountUI(dev.type) }, (_, i) => i + 1).map((relay) => {
|
||||
{Array.from({ length: (dev.labels?.length || relayCountUI(dev.type)) }, (_, i) => i + 1).map((relay) => {
|
||||
const r = ruleFor(dev.id, relay);
|
||||
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
|
||||
return (
|
||||
@@ -1013,8 +1013,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
// Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007.
|
||||
const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string; password: string }>({ enabled: false, host: '', password: '' });
|
||||
|
||||
// PowerGenius XL (4O3A) amp fan-control settings.
|
||||
const [pgxl, setPgxl] = useState<{ enabled: boolean; host: string; port: number }>({ enabled: false, host: '', port: 9008 });
|
||||
// Amplifier control settings (PowerGenius XL over TCP; SPE Expert over serial/IP).
|
||||
const [pgxl, setPgxl] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com_port: string; baud: number }>(
|
||||
{ enabled: false, type: 'pgxl', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200 });
|
||||
|
||||
// WinKeyer CW keyer settings + macro editor.
|
||||
type WKMac = { label: string; text: string };
|
||||
@@ -2645,36 +2646,89 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
}
|
||||
|
||||
function PGXLPanelSettings() {
|
||||
const isPGXL = pgxl.type === 'pgxl';
|
||||
const isSerial = pgxl.transport === 'serial';
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Power Genius XL"
|
||||
/>
|
||||
<SectionHeader title="Amplifier" />
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={pgxl.enabled} onCheckedChange={(c) => setPgxl((s) => ({ ...s, enabled: !!c }))} />
|
||||
Enable PowerGenius fan control
|
||||
Enable amplifier control
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label>Amplifier</Label>
|
||||
<Select value={pgxl.type} onValueChange={(v) => setPgxl((s) => ({ ...s, type: v, transport: v === 'pgxl' ? 'tcp' : s.transport }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pgxl">4O3A PowerGenius XL</SelectItem>
|
||||
<SelectItem value="spe13">SPE Expert 1.3K-FA</SelectItem>
|
||||
<SelectItem value="spe15">SPE Expert 1.5K-FA</SelectItem>
|
||||
<SelectItem value="spe2k">SPE Expert 2K-FA</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{/* PowerGenius is TCP-only; SPE amps connect over USB serial or an
|
||||
RS232-to-Ethernet bridge, so they offer both. */}
|
||||
{!isPGXL && (
|
||||
<div className="space-y-1">
|
||||
<Label>Connection</Label>
|
||||
<Select value={pgxl.transport} onValueChange={(v) => setPgxl((s) => ({ ...s, transport: v }))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="serial">USB (serial COM)</SelectItem>
|
||||
<SelectItem value="tcp">Network (RS232-to-Ethernet)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isSerial ? (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>COM port</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={pgxl.com_port || '_'} onValueChange={(v) => setPgxl((s) => ({ ...s, com_port: v === '_' ? '' : v }))}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" className="h-9" onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<ArrowDown className="size-3.5 rotate-90" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Baud</Label>
|
||||
<Input type="number" min={1200} value={pgxl.baud}
|
||||
onChange={(e) => setPgxl((s) => ({ ...s, baud: parseInt(e.target.value) || 115200 }))} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>Host / IP</Label>
|
||||
<Input
|
||||
value={pgxl.host ?? ''}
|
||||
onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
||||
placeholder="192.168.1.70"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input value={pgxl.host ?? ''} onChange={(e) => setPgxl((s) => ({ ...s, host: e.target.value }))}
|
||||
placeholder="192.168.1.70" className="font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>TCP port</Label>
|
||||
<Input
|
||||
type="number" min={1} max={65535}
|
||||
value={pgxl.port}
|
||||
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input type="number" min={1} max={65535} value={pgxl.port}
|
||||
onChange={(e) => setPgxl((s) => ({ ...s, port: parseInt(e.target.value) || 9008 }))} className="font-mono" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isPGXL && (
|
||||
<p className="text-xs text-warning">
|
||||
SPE Expert control is not wired up yet — these settings are saved, but OpsLog can't command the amp until its serial protocol is implemented.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -12,16 +12,22 @@ import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
ListDenkoviDevices, ListSerialPorts,
|
||||
} 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 Device = { id: string; type: string; name: string; host: string; user?: string; pass?: string; channels?: number; 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' };
|
||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8, usbrelay: 8 };
|
||||
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB (FT245)', usbrelay: 'Denkovi USB (serial)' };
|
||||
|
||||
// Relay count for a configured device: fixed by type, except Denkovi (4/8) and
|
||||
// the generic USB-serial board, whose channel count the user picks.
|
||||
const chanCount = (d: Device): number =>
|
||||
(d.type === 'denkovi' || d.type === 'usbrelay') ? (d.channels && d.channels >= 1 ? d.channels : 8) : (RELAY_COUNT[d.type] ?? 5);
|
||||
|
||||
function blankDevice(): Device {
|
||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||
@@ -445,11 +451,39 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||
const setType = (type: string) => {
|
||||
const n = RELAY_COUNT[type] ?? 5;
|
||||
const channels = (type === 'denkovi' || type === 'usbrelay') ? (device.channels || 8) : undefined;
|
||||
const n = chanCount({ ...device, type, channels });
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, type, labels });
|
||||
onChange({ ...device, type, channels, labels });
|
||||
};
|
||||
const setChannels = (channels: number) => {
|
||||
const n = chanCount({ ...device, channels });
|
||||
const labels = Array.from({ length: n }, (_, i) => device.labels[i] ?? '');
|
||||
onChange({ ...device, channels, labels });
|
||||
};
|
||||
const isKM = device.type === 'kmtronic';
|
||||
const isDenkovi = device.type === 'denkovi';
|
||||
const isUsbRelay = device.type === 'usbrelay';
|
||||
// COM ports for the generic USB-serial relay picker.
|
||||
const [serialPorts, setSerialPorts] = useState<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (!isUsbRelay) return;
|
||||
ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => setSerialPorts([]));
|
||||
}, [isUsbRelay]);
|
||||
// Detected FTDI serials for the Denkovi picker.
|
||||
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||
const [detecting, setDetecting] = useState(false);
|
||||
const detectDenkovi = async () => {
|
||||
setDetecting(true);
|
||||
try {
|
||||
const list = ((await ListDenkoviDevices()) ?? []) as string[];
|
||||
setDenkoviSerials(list);
|
||||
// Auto-fill when there's exactly one and nothing chosen yet.
|
||||
if (list.length >= 1 && !device.host.trim()) onChange({ ...device, host: list[0] });
|
||||
} catch { setDenkoviSerials([]); }
|
||||
finally { setDetecting(false); }
|
||||
};
|
||||
useEffect(() => { if (isDenkovi) void detectDenkovi(); /* eslint-disable-next-line */ }, [isDenkovi]);
|
||||
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>
|
||||
@@ -461,6 +495,8 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<SelectContent>
|
||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||
<SelectItem value="denkovi">Denkovi USB (FT245 / D2XX)</SelectItem>
|
||||
<SelectItem value="usbrelay">Denkovi USB (serial / COM)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -469,6 +505,54 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||
</div>
|
||||
</div>
|
||||
{(isDenkovi || isUsbRelay) && (
|
||||
<div className="space-y-1 max-w-[10rem]">
|
||||
<Label>{t('station.channels')}</Label>
|
||||
<Select value={String(chanCount(device))} onValueChange={(v) => setChannels(parseInt(v, 10))}>
|
||||
<SelectTrigger className="h-9"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{(isDenkovi ? [4, 8] : [1, 2, 4, 8, 16]).map((n) => (
|
||||
<SelectItem key={n} value={String(n)}>{n}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
{isDenkovi ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
<Label>{t('station.ftdiSerial')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input className="font-mono flex-1" value={device.host} placeholder="DAE0006K"
|
||||
list="denkovi-serials"
|
||||
onChange={(e) => onChange({ ...device, host: e.target.value })} />
|
||||
<datalist id="denkovi-serials">
|
||||
{denkoviSerials.map((s) => <option key={s} value={s} />)}
|
||||
</datalist>
|
||||
<Button size="sm" variant="outline" onClick={detectDenkovi} disabled={detecting}>
|
||||
{detecting ? <Loader2 className="size-3.5 mr-1 animate-spin" /> : <RefreshCw className="size-3.5 mr-1" />}
|
||||
{t('station.detect')}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||
</div>
|
||||
) : isUsbRelay ? (
|
||||
<div className="space-y-1 max-w-md">
|
||||
<Label>{t('station.comPort')}</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={device.host || '_'} onValueChange={(v) => onChange({ ...device, host: v === '_' ? '' : v })}>
|
||||
<SelectTrigger className="h-9 flex-1"><SelectValue placeholder="— COM —" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{serialPorts.length === 0 && <SelectItem value="_" disabled>{t('station.noPorts')}</SelectItem>}
|
||||
{serialPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="sm" variant="outline" onClick={() => ListSerialPorts().then((p) => setSerialPorts((p ?? []) as string[])).catch(() => {})}>
|
||||
<RefreshCw className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">{t('station.usbRelayHint')}</p>
|
||||
</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>
|
||||
@@ -487,6 +571,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
<Label>{t('station.labels')}</Label>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
|
||||
@@ -131,9 +131,9 @@ const en: Dict = {
|
||||
'uscty.backfillIntro': 'Resolve county (and grid) for US QSOs already in your log that are missing them. Existing values are kept — only blanks are filled.',
|
||||
'uscty.backfillRun': 'Fill missing counties',
|
||||
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
|
||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', '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',
|
||||
'station.title': 'Station Control', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'No heading read', 'station.pattern': 'Pattern', 'station.bi': 'Bi', 'station.retract': 'Retract elements', 'station.moving': 'MOVING', 'station.elements': 'Elements (mm)', 'station.read': 'Read', 'station.readLengths': 'Read current element lengths from the controller', 'station.noLengths': 'Lengths unknown — click Read to fetch them from the controller.', 'station.element': 'Element', 'station.reflector': 'Reflector', 'station.driven': 'Driven', 'station.director': 'Dir', 'station.set': 'Set', 'station.elementsHint': 'Each press lengthens/shortens the element by 2 mm (like the physical console). Verify which element responds on your antenna.', 'station.setExactLen': 'Click to type the exact current length (fixes the baseline if the auto-read is off).', 'station.atMax': 'Controller refused — the element is likely at its maximum length for this band, so it can\'t extend further.', 'station.go': 'Go', 'station.stop': 'Stop', 'station.dragHint': 'Drag a widget by its card to reorder. Pick a column count to lay them out in a grid.', 'station.colsAuto': 'Auto', '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.ftdiSerial': 'FTDI serial number', 'station.detect': 'Detect', 'station.ftdiHint': 'The Denkovi board is driven via FTDI bit-bang (not the COM port). Pick its serial (e.g. DAE0006K). Needs the FTDI D2XX driver installed.', 'station.channels': 'Relays', 'station.comPort': 'COM port', 'station.noPorts': 'No ports found', 'station.usbRelayHint': 'Cheap USB-serial relay boards (CH340/LCUS) using the A0 command protocol. If yours does not switch, tell me its model / command set.', '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',
|
||||
'sec.antenna': 'Ultrabeam / Steppir', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplifier', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||
'sec.relayauto': 'Relay auto-control',
|
||||
'relayauto.hint': 'Automatically switch Station Control relays from the rig frequency / band (like PstRotator). Each relay: a frequency window (ON inside, OFF outside) or a set of bands. Relays are set up in the Station Control panel.',
|
||||
'relayauto.enable': 'Enable relay auto-control',
|
||||
@@ -444,9 +444,9 @@ const fr: Dict = {
|
||||
'uscty.backfillIntro': "Résout le comté (et le locator) pour les QSO US déjà dans ton log qui n'en ont pas. Les valeurs existantes sont conservées — seuls les vides sont remplis.",
|
||||
'uscty.backfillRun': 'Remplir les comtés manquants',
|
||||
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
|
||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', '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',
|
||||
'station.title': 'Contrôle station', 'station.rotator': 'Rotator', 'station.rotatorNoRead': 'Azimut non lu', 'station.pattern': 'Diagramme', 'station.bi': 'Bi', 'station.retract': 'Rétracter les éléments', 'station.moving': 'EN MOUVEMENT', 'station.elements': 'Éléments (mm)', 'station.read': 'Lire', 'station.readLengths': 'Lire les longueurs actuelles depuis le contrôleur', 'station.noLengths': 'Longueurs inconnues — clique sur Lire pour les récupérer depuis le contrôleur.', 'station.element': 'Élément', 'station.reflector': 'Réflecteur', 'station.driven': 'Radiateur', 'station.director': 'Dir', 'station.set': 'Régler', 'station.elementsHint': "Chaque appui allonge/raccourcit l'élément de 2 mm (comme le pupitre). Vérifie quel élément répond sur ton antenne.", 'station.setExactLen': "Clique pour taper la longueur actuelle exacte (recale la base si la lecture auto est fausse).", 'station.atMax': "Refusé par le contrôleur — l'élément est probablement en butée (longueur max pour cette bande), il ne peut plus s'allonger.", 'station.go': 'Aller', 'station.stop': 'Stop', 'station.dragHint': 'Glisse une carte pour réordonner. Choisis un nombre de colonnes pour la disposition.', 'station.colsAuto': 'Auto', '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.ftdiSerial': 'Numéro de série FTDI', 'station.detect': 'Détecter', 'station.ftdiHint': "La carte Denkovi se pilote en FTDI bit-bang (pas via le port COM). Choisis son numéro de série (ex. DAE0006K). Nécessite le driver FTDI D2XX installé.", 'station.channels': 'Relais', 'station.comPort': 'Port COM', 'station.noPorts': 'Aucun port', 'station.usbRelayHint': "Cartes USB-série bon marché (CH340/LCUS) protocole A0. Si la tienne ne commute pas, donne-moi le modèle / jeu de commandes.", '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',
|
||||
'sec.antenna': 'Antenne motorisée', 'sec.antgenius': 'Antenna Genius', 'sec.pgxl': 'Amplificateur', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||
'sec.relayauto': 'Relais automatiques',
|
||||
'relayauto.hint': "Commute automatiquement les relais du Station Control selon la fréquence / bande du poste (comme PstRotator). Par relais : une plage de fréquence (ON dedans, OFF dehors) ou un ensemble de bandes. Les relais se configurent dans le panneau Station Control.",
|
||||
'relayauto.enable': 'Activer les relais automatiques',
|
||||
|
||||
Vendored
+2
@@ -546,6 +546,8 @@ export function ListContests():Promise<Array<contest.Def>>;
|
||||
|
||||
export function ListCountries():Promise<Array<string>>;
|
||||
|
||||
export function ListDenkoviDevices():Promise<Array<string>>;
|
||||
|
||||
export function ListOperatingTree():Promise<Array<operating.Station>>;
|
||||
|
||||
export function ListProfiles():Promise<Array<profile.Profile>>;
|
||||
|
||||
@@ -1050,6 +1050,10 @@ export function ListCountries() {
|
||||
return window['go']['main']['App']['ListCountries']();
|
||||
}
|
||||
|
||||
export function ListDenkoviDevices() {
|
||||
return window['go']['main']['App']['ListDenkoviDevices']();
|
||||
}
|
||||
|
||||
export function ListOperatingTree() {
|
||||
return window['go']['main']['App']['ListOperatingTree']();
|
||||
}
|
||||
|
||||
@@ -2161,8 +2161,12 @@ export namespace main {
|
||||
}
|
||||
export class PGXLSettings {
|
||||
enabled: boolean;
|
||||
type: string;
|
||||
transport: string;
|
||||
host: string;
|
||||
port: number;
|
||||
com_port: string;
|
||||
baud: number;
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
return new PGXLSettings(source);
|
||||
@@ -2171,8 +2175,12 @@ export namespace main {
|
||||
constructor(source: any = {}) {
|
||||
if ('string' === typeof source) source = JSON.parse(source);
|
||||
this.enabled = source["enabled"];
|
||||
this.type = source["type"];
|
||||
this.transport = source["transport"];
|
||||
this.host = source["host"];
|
||||
this.port = source["port"];
|
||||
this.com_port = source["com_port"];
|
||||
this.baud = source["baud"];
|
||||
}
|
||||
}
|
||||
export class POTAUnmatched {
|
||||
@@ -2600,6 +2608,7 @@ export namespace main {
|
||||
host: string;
|
||||
user?: string;
|
||||
pass?: string;
|
||||
channels?: number;
|
||||
labels: string[];
|
||||
|
||||
static createFrom(source: any = {}) {
|
||||
@@ -2614,6 +2623,7 @@ export namespace main {
|
||||
this.host = source["host"];
|
||||
this.user = source["user"];
|
||||
this.pass = source["pass"];
|
||||
this.channels = source["channels"];
|
||||
this.labels = source["labels"];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//go:build !windows
|
||||
|
||||
// The Denkovi USB board is driven through FTDI's D2XX bit-bang API (ftd2xx.dll),
|
||||
// which OpsLog only wires up on Windows. This stub keeps the package building on
|
||||
// other platforms; every call reports the board is unavailable.
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type denkoviStub struct{ count int }
|
||||
|
||||
// NewDenkovi returns a stub on non-Windows builds.
|
||||
func NewDenkovi(serial string, count int) Device {
|
||||
if count != 4 {
|
||||
count = 8
|
||||
}
|
||||
return denkoviStub{count: count}
|
||||
}
|
||||
|
||||
func (s denkoviStub) Count() int { return s.count }
|
||||
func (denkoviStub) Status(context.Context) ([]bool, error) {
|
||||
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
func (denkoviStub) Set(context.Context, int, bool) error {
|
||||
return fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
|
||||
// ListDenkovi has no devices to report off Windows.
|
||||
func ListDenkovi() ([]string, error) {
|
||||
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
//go:build windows
|
||||
|
||||
// Denkovi USB 8-channel relay board (FT245RL). Despite enumerating as a virtual
|
||||
// COM port, this board is NOT driven by serial/ASCII: the FT245's 8 data lines
|
||||
// each drive a relay, controlled through FTDI's D2XX "bit-bang" mode. One byte
|
||||
// written = the 8 relays at once (bit 0 = relay 1). We load ftd2xx.dll at runtime
|
||||
// (no CGO) and call the D2XX API directly, exactly as the vendor's tool does
|
||||
// (which addresses the board by its FTDI serial, e.g. "DAE0006K").
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
var (
|
||||
ftdll = syscall.NewLazyDLL("ftd2xx.dll")
|
||||
procOpenEx = ftdll.NewProc("FT_OpenEx")
|
||||
procClose = ftdll.NewProc("FT_Close")
|
||||
procSetBitMode = ftdll.NewProc("FT_SetBitMode")
|
||||
procSetBaudRate = ftdll.NewProc("FT_SetBaudRate")
|
||||
procWrite = ftdll.NewProc("FT_Write")
|
||||
procPurge = ftdll.NewProc("FT_Purge")
|
||||
procCreateInfo = ftdll.NewProc("FT_CreateDeviceInfoList")
|
||||
procGetInfoDetail = ftdll.NewProc("FT_GetDeviceInfoDetail")
|
||||
)
|
||||
|
||||
const (
|
||||
ftOpenBySerial = 1 // FT_OPEN_BY_SERIAL_NUMBER
|
||||
ftBitModeSyncBB = 0x04 // FT_BITMODE_SYNC_BITBANG (the mode Denkovi documents)
|
||||
ftPurgeRX = 1 // FT_PURGE_RX
|
||||
)
|
||||
|
||||
func ftOK(r uintptr) bool { return r == 0 } // FT_OK == 0
|
||||
|
||||
type denkovi struct {
|
||||
serial string
|
||||
count int
|
||||
mu sync.Mutex
|
||||
shadow byte // last output byte (bit n = relay n+1); authoritative state
|
||||
h uintptr // FT handle
|
||||
opened bool
|
||||
}
|
||||
|
||||
// NewDenkovi builds a driver for a Denkovi USB relay board (4 or 8 relays)
|
||||
// identified by its FTDI serial number (shown by the vendor tool / FT_PROG,
|
||||
// e.g. "DAE0006K"). count defaults to 8; a 4-relay board just uses the low 4 bits.
|
||||
func NewDenkovi(serial string, count int) Device {
|
||||
if count != 4 && count != 8 {
|
||||
count = 8
|
||||
}
|
||||
return &denkovi{serial: strings.TrimSpace(serial), count: count}
|
||||
}
|
||||
|
||||
func (d *denkovi) Count() int { return d.count }
|
||||
|
||||
// ensureOpen opens the board and puts it in synchronous bit-bang mode with all 8
|
||||
// lines as outputs. Idempotent.
|
||||
func (d *denkovi) ensureOpen() error {
|
||||
if d.opened {
|
||||
return nil
|
||||
}
|
||||
if err := ftdll.Load(); err != nil {
|
||||
return fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software: %w", err)
|
||||
}
|
||||
if d.serial == "" {
|
||||
return fmt.Errorf("no FTDI serial number set for the Denkovi board")
|
||||
}
|
||||
ser, err := syscall.BytePtrFromString(d.serial)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var h uintptr
|
||||
if r, _, _ := procOpenEx.Call(uintptr(unsafe.Pointer(ser)), ftOpenBySerial, uintptr(unsafe.Pointer(&h))); !ftOK(r) {
|
||||
return fmt.Errorf("cannot open Denkovi board %q (FT_OpenEx status %d) — is it connected and not in use by another app?", d.serial, r)
|
||||
}
|
||||
// All 8 lines output, synchronous bit-bang.
|
||||
if r, _, _ := procSetBitMode.Call(h, 0xFF, ftBitModeSyncBB); !ftOK(r) {
|
||||
procClose.Call(h)
|
||||
return fmt.Errorf("FT_SetBitMode failed (status %d)", r)
|
||||
}
|
||||
procSetBaudRate.Call(h, 9600) // bit-bang pin-update clock; relays don't need speed
|
||||
d.h = h
|
||||
d.opened = true
|
||||
// Make the hardware match our shadow (starts all-off on first open).
|
||||
return d.writeLocked()
|
||||
}
|
||||
|
||||
// writeLocked pushes the shadow byte to the relays. Caller holds d.mu.
|
||||
func (d *denkovi) writeLocked() error {
|
||||
var written uint32
|
||||
b := d.shadow
|
||||
if r, _, _ := procWrite.Call(d.h, uintptr(unsafe.Pointer(&b)), 1, uintptr(unsafe.Pointer(&written))); !ftOK(r) {
|
||||
return fmt.Errorf("FT_Write failed (status %d)", r)
|
||||
}
|
||||
// Synchronous bit-bang echoes each written byte into the RX buffer; drop it so
|
||||
// it doesn't fill over the life of the connection.
|
||||
procPurge.Call(d.h, ftPurgeRX)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *denkovi) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > d.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, d.count)
|
||||
}
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.ensureOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
bit := byte(1) << uint(relay-1) // relay 1 → bit 0
|
||||
if on {
|
||||
d.shadow |= bit
|
||||
} else {
|
||||
d.shadow &^= bit
|
||||
}
|
||||
return d.writeLocked()
|
||||
}
|
||||
|
||||
func (d *denkovi) Status(ctx context.Context) ([]bool, error) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if err := d.ensureOpen(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]bool, d.count)
|
||||
for i := 0; i < d.count; i++ {
|
||||
out[i] = d.shadow&(byte(1)<<uint(i)) != 0
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// ListDenkovi returns the FTDI serial numbers of connected devices, for the
|
||||
// settings UI to pick from. Requires ftd2xx.dll.
|
||||
func ListDenkovi() ([]string, error) {
|
||||
if err := ftdll.Load(); err != nil {
|
||||
return nil, fmt.Errorf("FTDI D2XX driver (ftd2xx.dll) not found — install the FTDI D2XX driver / Denkovi software")
|
||||
}
|
||||
var n uint32
|
||||
if r, _, _ := procCreateInfo.Call(uintptr(unsafe.Pointer(&n))); !ftOK(r) {
|
||||
return nil, fmt.Errorf("FT_CreateDeviceInfoList failed (status %d)", r)
|
||||
}
|
||||
var out []string
|
||||
for i := uint32(0); i < n; i++ {
|
||||
var flags, typ, id, loc uint32
|
||||
serial := make([]byte, 16)
|
||||
desc := make([]byte, 64)
|
||||
var h uintptr
|
||||
r, _, _ := procGetInfoDetail.Call(
|
||||
uintptr(i),
|
||||
uintptr(unsafe.Pointer(&flags)), uintptr(unsafe.Pointer(&typ)),
|
||||
uintptr(unsafe.Pointer(&id)), uintptr(unsafe.Pointer(&loc)),
|
||||
uintptr(unsafe.Pointer(&serial[0])), uintptr(unsafe.Pointer(&desc[0])),
|
||||
uintptr(unsafe.Pointer(&h)),
|
||||
)
|
||||
if !ftOK(r) {
|
||||
continue
|
||||
}
|
||||
if s := cstr(serial); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// cstr trims a C string (up to the first NUL) from a fixed buffer.
|
||||
func cstr(b []byte) string {
|
||||
if i := indexByte(b, 0); i >= 0 {
|
||||
b = b[:i]
|
||||
}
|
||||
return strings.TrimSpace(string(b))
|
||||
}
|
||||
|
||||
func indexByte(b []byte, c byte) int {
|
||||
for i := range b {
|
||||
if b[i] == c {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package relaydev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"go.bug.st/serial"
|
||||
)
|
||||
|
||||
// serialRelay drives the common cheap USB-serial relay boards (CH340 / LCUS-1
|
||||
// style) that use the "A0" command protocol over a virtual COM port:
|
||||
//
|
||||
// [0xA0] [relay 1-based] [state 0|1] [checksum] checksum = (0xA0+relay+state) & 0xFF
|
||||
//
|
||||
// e.g. relay 1 ON = A0 01 01 A2, relay 1 OFF = A0 01 00 A1. These boards are
|
||||
// write-only (no state readback), so Status reflects a shadow of what we sent.
|
||||
// Channel count varies by board (1/2/4/8/16), so it is configurable.
|
||||
type serialRelay struct {
|
||||
portName string
|
||||
count int
|
||||
baud int
|
||||
mu sync.Mutex
|
||||
port serial.Port
|
||||
shadow []bool
|
||||
}
|
||||
|
||||
// NewSerialRelay builds a driver for a CH340/LCUS-style USB-serial relay board on
|
||||
// the given COM port with `count` channels (defaults to 8).
|
||||
func NewSerialRelay(port string, count int) Device {
|
||||
if count < 1 {
|
||||
count = 8
|
||||
}
|
||||
return &serialRelay{portName: strings.TrimSpace(port), count: count, baud: 9600, shadow: make([]bool, count)}
|
||||
}
|
||||
|
||||
func (s *serialRelay) Count() int { return s.count }
|
||||
|
||||
func (s *serialRelay) ensureOpen() error {
|
||||
if s.port != nil {
|
||||
return nil
|
||||
}
|
||||
if s.portName == "" {
|
||||
return fmt.Errorf("no COM port set for the USB relay board")
|
||||
}
|
||||
p, err := serial.Open(s.portName, &serial.Mode{BaudRate: s.baud})
|
||||
if err != nil {
|
||||
return fmt.Errorf("open %s: %w", s.portName, err)
|
||||
}
|
||||
s.port = p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serialRelay) Set(ctx context.Context, relay int, on bool) error {
|
||||
if relay < 1 || relay > s.count {
|
||||
return fmt.Errorf("relay %d out of range 1..%d", relay, s.count)
|
||||
}
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return err
|
||||
}
|
||||
st := byte(0)
|
||||
if on {
|
||||
st = 1
|
||||
}
|
||||
r := byte(relay)
|
||||
frame := []byte{0xA0, r, st, byte(0xA0) + r + st} // last byte = checksum
|
||||
if _, err := s.port.Write(frame); err != nil {
|
||||
// Drop the handle so the next call reopens (USB unplugged / port reset).
|
||||
s.port.Close()
|
||||
s.port = nil
|
||||
return fmt.Errorf("write to %s: %w", s.portName, err)
|
||||
}
|
||||
s.shadow[relay-1] = on
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serialRelay) Status(ctx context.Context) ([]bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.ensureOpen(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]bool, s.count)
|
||||
copy(out, s.shadow)
|
||||
return out, nil
|
||||
}
|
||||
Reference in New Issue
Block a user