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:
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user