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.
182 lines
5.4 KiB
Go
182 lines
5.4 KiB
Go
//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
|
|
}
|