feat: Denkovi USB 8-relay board (FT245 D2XX bit-bang)

Third relay device alongside WebSwitch and KMTronic. Despite enumerating as a
COM port, this board is driven via FTDI D2XX synchronous bit-bang (one byte = 8
relays, bit 0 = relay 1), not serial ASCII — so we load ftd2xx.dll at runtime
(no CGO) and call FT_OpenEx/FT_SetBitMode/FT_Write, addressing the board by its
FTDI serial (e.g. DAE0006K), exactly like the vendor tool. Windows-only (stub
elsewhere); ListDenkoviDevices enumerates connected serials for the picker.

Wired into relaydev (Count/Status/Set), app.go (deviceDriver/relayCountFor/type
whitelist + ListDenkoviDevices binding), and the Station device editor (new type
+ FTDI-serial field with Detect). Untested on hardware; bit order / on-polarity
may need a flip after a live test.
This commit is contained in:
2026-07-20 16:50:04 +02:00
parent 3cef885934
commit c07a17dc47
8 changed files with 279 additions and 11 deletions
+1 -1
View File
@@ -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);
@@ -12,6 +12,7 @@ import {
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
GetRotatorHeading, RotatorGoTo, RotatorStop,
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
ListDenkoviDevices,
} from '../../wailsjs/go/main/App';
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
@@ -20,8 +21,8 @@ type Device = { id: string; type: string; name: string; host: string; user?: str
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 };
const TYPE_LABEL: Record<string, string> = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay', denkovi: 'Denkovi USB 8-relay' };
function blankDevice(): Device {
return { id: '', type: 'webswitch', name: '', host: '', user: '', pass: '', labels: Array(5).fill('') };
@@ -450,6 +451,21 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: {
onChange({ ...device, type, labels });
};
const isKM = device.type === 'kmtronic';
const isDenkovi = device.type === 'denkovi';
// 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 +477,7 @@ 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 8-relay (FT245)</SelectItem>
</SelectContent>
</Select>
</div>
@@ -469,6 +486,24 @@ 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 ? (
<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>
) : (
<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 +522,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">
+2 -2
View File
@@ -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.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.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.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.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.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.relayauto': 'Relais automatiques',