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
+29
View File
@@ -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")
}
+181
View File
@@ -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)<<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
}