From c07a17dc47b7c35464b0e405edc120b9d9ca86ff Mon Sep 17 00:00:00 2001 From: rouggy Date: Mon, 20 Jul 2026 16:50:04 +0200 Subject: [PATCH] feat: Denkovi USB 8-relay board (FT245 D2XX bit-bang) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 28 ++- frontend/src/components/SettingsModal.tsx | 2 +- .../src/components/StationControlPanel.tsx | 40 +++- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 2 + frontend/wailsjs/go/main/App.js | 4 + internal/relaydev/denkovi_other.go | 29 +++ internal/relaydev/denkovi_windows.go | 181 ++++++++++++++++++ 8 files changed, 279 insertions(+), 11 deletions(-) create mode 100644 internal/relaydev/denkovi_other.go create mode 100644 internal/relaydev/denkovi_windows.go diff --git a/app.go b/app.go index 098d2c8..4ce112a 100644 --- a/app.go +++ b/app.go @@ -11397,7 +11397,7 @@ 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" Name string `json:"name"` Host string `json:"host"` User string `json:"user,omitempty"` // KMTronic HTTP auth (blank = none) @@ -11407,18 +11407,31 @@ type StationDevice struct { // 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 } - 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) + default: + return relaydev.NewWebswitch(d.Host) } - 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). @@ -11444,7 +11457,10 @@ 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", "webswitch": + // known type — keep + default: devs[i].Type = "webswitch" } if strings.TrimSpace(devs[i].ID) == "" { diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 1ad03d6..ba42097 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -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); diff --git a/frontend/src/components/StationControlPanel.tsx b/frontend/src/components/StationControlPanel.tsx index 371b832..b12ed95 100644 --- a/frontend/src/components/StationControlPanel.tsx +++ b/frontend/src/components/StationControlPanel.tsx @@ -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 = { webswitch: 5, kmtronic: 8 }; -const TYPE_LABEL: Record = { webswitch: 'WebSwitch 1216H', kmtronic: 'KMTronic 8-relay' }; +const RELAY_COUNT: Record = { webswitch: 5, kmtronic: 8, denkovi: 8 }; +const TYPE_LABEL: Record = { 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([]); + 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 (
{device.id ? t('station.editDevice') : t('station.addDevice')}
@@ -461,6 +477,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: { WebSwitch 1216H (5 relays) KMTronic 8-relay (LAN) + Denkovi USB 8-relay (FT245)
@@ -469,6 +486,24 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: { onChange({ ...device, name: e.target.value })} /> + {isDenkovi ? ( +
+ +
+ onChange({ ...device, host: e.target.value })} /> + + {denkoviSerials.map((s) => + +
+

{t('station.ftdiHint')}

+
+ ) : (
@@ -487,6 +522,7 @@ function DeviceEditor({ device, onChange, onSave, onCancel, t }: { )}
+ )}
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 3eb59fd..272991d 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -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', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index e1a5e96..e029d0c 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -546,6 +546,8 @@ export function ListContests():Promise>; export function ListCountries():Promise>; +export function ListDenkoviDevices():Promise>; + export function ListOperatingTree():Promise>; export function ListProfiles():Promise>; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index bbf7fab..36f3e13 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -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'](); } diff --git a/internal/relaydev/denkovi_other.go b/internal/relaydev/denkovi_other.go new file mode 100644 index 0000000..ae6048d --- /dev/null +++ b/internal/relaydev/denkovi_other.go @@ -0,0 +1,29 @@ +//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{} + +// NewDenkovi returns a stub on non-Windows builds. +func NewDenkovi(serial string) Device { return denkoviStub{} } + +func (denkoviStub) Count() int { return 8 } +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") +} diff --git a/internal/relaydev/denkovi_windows.go b/internal/relaydev/denkovi_windows.go new file mode 100644 index 0000000..2fe9868 --- /dev/null +++ b/internal/relaydev/denkovi_windows.go @@ -0,0 +1,181 @@ +//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 8-relay board identified by its +// FTDI serial number (shown by the vendor tool / FT_PROG, e.g. "DAE0006K"). +func NewDenkovi(serial string) Device { + return &denkovi{serial: strings.TrimSpace(serial), count: 8} +} + +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)<= 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 +}