feat: Denkovi USB 4/8 (selectable) + generic USB-serial relay board (CH340/LCUS)
- Denkovi (FT245 D2XX) now selectable as 4 or 8 relays (a 4-relay board just uses the low nibble); NewDenkovi takes a count, driven by the device's Channels. - New 'usbrelay' backend: cheap CH340/LCUS USB-serial boards over a COM port using the common A0 protocol ([0xA0][relay][state][checksum]); write-only, so Status is a shadow. Channel count configurable (1/2/4/8/16). - StationDevice gains Channels; deviceRelayCount() drives label/relay counts for the variable-size types. Device editor: channel selector + COM-port picker (usbrelay) / FTDI-serial (denkovi). Both untested on hardware; the A0 command set / Denkovi bit order may need a tweak after a live test.
This commit is contained in:
@@ -11397,14 +11397,28 @@ func boolStr(b bool) string {
|
|||||||
// StationDevice is one configured relay board for the Station Control tab.
|
// StationDevice is one configured relay board for the Station Control tab.
|
||||||
type StationDevice struct {
|
type StationDevice struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi"
|
Type string `json:"type"` // "webswitch" | "kmtronic" | "denkovi" | "usbrelay"
|
||||||
Name string `json:"name"`
|
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)
|
User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none)
|
||||||
Pass string `json:"pass,omitempty"`
|
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)
|
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.
|
// relayCountFor returns a device type's fixed relay count.
|
||||||
func relayCountFor(typ string) int {
|
func relayCountFor(typ string) int {
|
||||||
switch typ {
|
switch typ {
|
||||||
@@ -11422,7 +11436,10 @@ func deviceDriver(d StationDevice) relaydev.Device {
|
|||||||
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
|
return relaydev.NewKMTronic(d.Host, d.User, d.Pass)
|
||||||
case "denkovi":
|
case "denkovi":
|
||||||
// Host carries the FTDI serial number (e.g. "DAE0006K"), not a hostname.
|
// Host carries the FTDI serial number (e.g. "DAE0006K"), not a hostname.
|
||||||
return relaydev.NewDenkovi(d.Host)
|
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:
|
default:
|
||||||
return relaydev.NewWebswitch(d.Host)
|
return relaydev.NewWebswitch(d.Host)
|
||||||
}
|
}
|
||||||
@@ -11458,7 +11475,7 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
|
|||||||
}
|
}
|
||||||
for i := range devs {
|
for i := range devs {
|
||||||
switch devs[i].Type {
|
switch devs[i].Type {
|
||||||
case "kmtronic", "denkovi", "webswitch":
|
case "kmtronic", "denkovi", "usbrelay", "webswitch":
|
||||||
// known type — keep
|
// known type — keep
|
||||||
default:
|
default:
|
||||||
devs[i].Type = "webswitch"
|
devs[i].Type = "webswitch"
|
||||||
@@ -11466,7 +11483,7 @@ func (a *App) SaveStationDevices(devs []StationDevice) error {
|
|||||||
if strings.TrimSpace(devs[i].ID) == "" {
|
if strings.TrimSpace(devs[i].ID) == "" {
|
||||||
devs[i].ID = fmt.Sprintf("dev%d-%d", i, time.Now().UnixNano())
|
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 {
|
for len(devs[i].Labels) < n {
|
||||||
devs[i].Labels = append(devs[i].Labels, "")
|
devs[i].Labels = append(devs[i].Labels, "")
|
||||||
}
|
}
|
||||||
@@ -11507,7 +11524,7 @@ func (a *App) GetStationStatus() []StationDeviceStatus {
|
|||||||
go func(i int) {
|
go func(i int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
d := devs[i]
|
d := devs[i]
|
||||||
n := relayCountFor(d.Type)
|
n := deviceRelayCount(d)
|
||||||
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
|
ds := StationDeviceStatus{ID: d.ID, Name: d.Name, Type: d.Type}
|
||||||
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
ctx, cancel := context.WithTimeout(a.ctx, 6*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|||||||
@@ -698,7 +698,7 @@ function RelayAutoPanel() {
|
|||||||
<div key={dev.id} className="rounded-md border border-border">
|
<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="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">
|
<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 r = ruleFor(dev.id, relay);
|
||||||
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
|
const label = (dev.labels?.[relay - 1] || '').trim() || `${t('relayauto.relay')} ${relay}`;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -12,17 +12,22 @@ import {
|
|||||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||||
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
GetRotatorHeading, RotatorGoTo, RotatorStop,
|
||||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||||
ListDenkoviDevices,
|
ListDenkoviDevices, ListSerialPorts,
|
||||||
} from '../../wailsjs/go/main/App';
|
} from '../../wailsjs/go/main/App';
|
||||||
|
|
||||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
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 Relay = { number: number; label: string; on: boolean };
|
||||||
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
type DevStatus = { id: string; name: string; type: string; connected: boolean; error?: string; relays: Relay[] };
|
||||||
|
|
||||||
const RELAY_COUNT: Record<string, number> = { webswitch: 5, kmtronic: 8, denkovi: 8 };
|
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 8-relay' };
|
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB relay', usbrelay: 'USB serial relay' };
|
||||||
|
|
||||||
|
// 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 {
|
function blankDevice(): Device {
|
||||||
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
|
||||||
@@ -446,12 +451,25 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); }, []);
|
||||||
const setType = (type: string) => {
|
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] ?? '');
|
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 isKM = device.type === 'kmtronic';
|
||||||
const isDenkovi = device.type === 'denkovi';
|
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.
|
// Detected FTDI serials for the Denkovi picker.
|
||||||
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
const [denkoviSerials, setDenkoviSerials] = useState<string[]>([]);
|
||||||
const [detecting, setDetecting] = useState(false);
|
const [detecting, setDetecting] = useState(false);
|
||||||
@@ -477,7 +495,8 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
<SelectItem value="webswitch">WebSwitch 1216H (5 relays)</SelectItem>
|
||||||
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
<SelectItem value="kmtronic">KMTronic 8-relay (LAN)</SelectItem>
|
||||||
<SelectItem value="denkovi">Denkovi USB 8-relay (FT245)</SelectItem>
|
<SelectItem value="denkovi">Denkovi USB relay (FT245, 4/8)</SelectItem>
|
||||||
|
<SelectItem value="usbrelay">USB serial relay (CH340/LCUS)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -486,6 +505,19 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
<Input value={device.name} placeholder={TYPE_LABEL[device.type]} onChange={(e) => onChange({ ...device, name: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
</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 ? (
|
{isDenkovi ? (
|
||||||
<div className="space-y-1 max-w-md">
|
<div className="space-y-1 max-w-md">
|
||||||
<Label>{t('station.ftdiSerial')}</Label>
|
<Label>{t('station.ftdiSerial')}</Label>
|
||||||
@@ -503,6 +535,23 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
<p className="text-[10px] text-muted-foreground">{t('station.ftdiHint')}</p>
|
||||||
</div>
|
</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('grid gap-3', isKM ? 'grid-cols-3' : 'grid-cols-1')}>
|
||||||
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
<div className={cn('space-y-1', isKM ? '' : 'max-w-xs')}>
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ 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.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.backfillRun': 'Fill missing counties',
|
||||||
'uscty.backfillDone': '{s} US QSOs scanned · {c} counties, {g} grids filled.',
|
'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.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.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.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': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Audio devices',
|
||||||
'sec.relayauto': 'Relay auto-control',
|
'sec.relayauto': 'Relay auto-control',
|
||||||
@@ -444,7 +444,7 @@ 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.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.backfillRun': 'Remplir les comtés manquants',
|
||||||
'uscty.backfillDone': '{s} QSO US analysés · {c} comtés, {g} locators remplis.',
|
'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.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.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.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': 'Power Genius', 'sec.flex': 'FlexRadio', 'sec.audio': 'Périphériques audio',
|
||||||
'sec.relayauto': 'Relais automatiques',
|
'sec.relayauto': 'Relais automatiques',
|
||||||
|
|||||||
@@ -2600,6 +2600,7 @@ export namespace main {
|
|||||||
host: string;
|
host: string;
|
||||||
user?: string;
|
user?: string;
|
||||||
pass?: string;
|
pass?: string;
|
||||||
|
channels?: number;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
@@ -2614,6 +2615,7 @@ export namespace main {
|
|||||||
this.host = source["host"];
|
this.host = source["host"];
|
||||||
this.user = source["user"];
|
this.user = source["user"];
|
||||||
this.pass = source["pass"];
|
this.pass = source["pass"];
|
||||||
|
this.channels = source["channels"];
|
||||||
this.labels = source["labels"];
|
this.labels = source["labels"];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
)
|
)
|
||||||
|
|
||||||
type denkoviStub struct{}
|
type denkoviStub struct{ count int }
|
||||||
|
|
||||||
// NewDenkovi returns a stub on non-Windows builds.
|
// NewDenkovi returns a stub on non-Windows builds.
|
||||||
func NewDenkovi(serial string) Device { return denkoviStub{} }
|
func NewDenkovi(serial string, count int) Device {
|
||||||
|
if count != 4 {
|
||||||
|
count = 8
|
||||||
|
}
|
||||||
|
return denkoviStub{count: count}
|
||||||
|
}
|
||||||
|
|
||||||
func (denkoviStub) Count() int { return 8 }
|
func (s denkoviStub) Count() int { return s.count }
|
||||||
func (denkoviStub) Status(context.Context) ([]bool, error) {
|
func (denkoviStub) Status(context.Context) ([]bool, error) {
|
||||||
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
return nil, fmt.Errorf("Denkovi USB relay board is only supported on Windows")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,10 +46,14 @@ type denkovi struct {
|
|||||||
opened bool
|
opened bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewDenkovi builds a driver for a Denkovi USB 8-relay board identified by its
|
// NewDenkovi builds a driver for a Denkovi USB relay board (4 or 8 relays)
|
||||||
// FTDI serial number (shown by the vendor tool / FT_PROG, e.g. "DAE0006K").
|
// identified by its FTDI serial number (shown by the vendor tool / FT_PROG,
|
||||||
func NewDenkovi(serial string) Device {
|
// e.g. "DAE0006K"). count defaults to 8; a 4-relay board just uses the low 4 bits.
|
||||||
return &denkovi{serial: strings.TrimSpace(serial), count: 8}
|
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 }
|
func (d *denkovi) Count() int { return d.count }
|
||||||
|
|||||||
@@ -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