Files
OpsLog/internal/psu/modbus.go
T
rouggy 8683a450a7 feat(psu): switch a Modbus RTU bench supply from OpsLog
The manufacturer's document arrived, so this is no longer guesswork: 9600 8N1,
function codes 03 and 06 only, and a register map with the output on/off at
0x0001, the measurements at 0x0010…0x0013 and the set points at 0x0030/0x0031.

ONE REGISTER IS WRITTEN — 0x0001, the output. The map also exposes the voltage
and current set points and the three protection trip levels as writable, and
none of them belong to a logbook: a wrong value there is 30 V where a radio
expected 13.8, or a trip level lifted on a supply feeding an amplifier. They are
read and displayed instead, next to the measured values, which is also how an
operator sees at a glance that the supply is on and the radio is drawing nothing.

The wire layer is tested where it can be. CRC-16/MODBUS is pinned against its
published check value — the CRC of "123456789" is 0x4B37 — which fixes the
polynomial, the initial value, the reflection and the absence of a final xor all
at once; the rest of the protocol is checked frame by frame against the manual,
including the byte order of the CRC, an exception reply told apart from a broken
line, and a reply from another slave on the bus refused. The write echo must
match the value sent: it is the only confirmation the output really switched,
and accepting the frame without it is how a radio ends up dark behind a green
light.

Framing follows the manual's own rules: 3.5 character times of silence between
frames (4 ms at 9600), and a frame is over when the line falls quiet — Modbus
RTU has no terminator, and a serial read that times out returns (0, nil) here,
so the reader is built on a deadline and a quiet-time rather than on an error
that never comes.

Untested against hardware — nobody here has the supply.
2026-08-16 13:15:55 +02:00

240 lines
8.0 KiB
Go

// Package psu drives a bench power supply over Modbus RTU — the BSIDE / Wanptek
// family of programmable supplies that sit in a shack feeding the radios.
//
// The register map and the wire settings come from the manufacturer's own
// document ("This machine only support function code 03,06", version 20180611):
//
// 9600 baud, 8 data bits, no parity, 1 stop bit
// function 03 read holding registers
// function 06 write single register
// slave address 1…15, address 0 broadcast
//
// NOTHING ELSE IS WRITTEN. The map also carries the output voltage and current
// SET points, and the over-voltage, over-current and over-power trip levels, all
// read/write. This driver reads them and writes exactly one register: 0x0001,
// the output on/off. A wrong value in any of the others is not a wrong reading —
// it is 30 V where a radio expected 13.8, or a protection trip lifted on a
// supply feeding an amplifier. There is no reason for a logbook to set them, so
// it cannot.
package psu
import (
"encoding/binary"
"fmt"
"io"
"time"
)
// Registers, from the manufacturer's table. Addresses are as printed there.
const (
regOnOff = 0x0001 // output on/off — 1 or 0. The ONLY register written.
regProtect = 0x0002 // protection status word
regModel = 0x0003 // specification model
regDecimals = 0x0005 // "V_A_W number of digits" — see readDecimals
regVolts = 0x0010 // measured output voltage, 2 decimals
regAmps = 0x0011 // measured output current, 3 decimals
regWatts = 0x0012 // measured output power, 32-bit across 0x0012/0x0013, 3 decimals
regSetVolts = 0x0030 // voltage set point, 2 decimals
regSetAmps = 0x0031 // current set point, 3 decimals
)
const (
fnRead = 0x03
fnWrite = 0x06
)
// Fixed scaling from the manufacturer's "Decimal place" column. The supply also
// reports its own digit counts in 0x0005, but the document's "Note 2" that
// explains how to decode that word is not in the manual we have — so the
// documented per-register values are used, and the raw word is logged once at
// connect. If an operator ever reports readings out by a factor of ten, that
// line is what says how to decode it properly.
const (
voltScale = 100.0 // 2 decimals
ampScale = 1000.0 // 3 decimals
wattScale = 1000.0 // 3 decimals
)
// crc16 is the Modbus RTU frame check: CRC-16/MODBUS — reflected, polynomial
// 0xA001, initial value 0xFFFF, no final xor. Transmitted low byte first.
func crc16(b []byte) uint16 {
crc := uint16(0xFFFF)
for _, c := range b {
crc ^= uint16(c)
for i := 0; i < 8; i++ {
if crc&1 != 0 {
crc = (crc >> 1) ^ 0xA001
} else {
crc >>= 1
}
}
}
return crc
}
// appendCRC closes a frame: low byte first, as the manual states.
func appendCRC(f []byte) []byte {
c := crc16(f)
return append(f, byte(c&0xFF), byte(c>>8))
}
// buildRead frames a function 03 "read holding registers".
func buildRead(addr byte, reg uint16, count uint16) []byte {
f := []byte{addr, fnRead, byte(reg >> 8), byte(reg), byte(count >> 8), byte(count)}
return appendCRC(f)
}
// buildWrite frames a function 06 "write single register".
func buildWrite(addr byte, reg, val uint16) []byte {
f := []byte{addr, fnWrite, byte(reg >> 8), byte(reg), byte(val >> 8), byte(val)}
return appendCRC(f)
}
// modbusError is an exception response — the supply understood the frame and
// refused it. Kept distinct from a transport failure: one means "ask
// differently", the other means "the cable".
type modbusError struct {
fn byte
code byte
}
func (e modbusError) Error() string {
what := map[byte]string{
1: "illegal function",
2: "illegal data address",
3: "illegal data value",
4: "slave device failure",
6: "device busy",
}[e.code]
if what == "" {
what = fmt.Sprintf("exception %d", e.code)
}
return fmt.Sprintf("supply refused function 0x%02X: %s", e.fn, what)
}
// parseRead validates a function 03 reply and returns the register values.
func parseRead(addr byte, want uint16, frame []byte) ([]uint16, error) {
if err := checkFrame(addr, fnRead, frame, 5); err != nil {
return nil, err
}
n := int(frame[2])
if n != int(want)*2 {
return nil, fmt.Errorf("reply carries %d data byte(s), expected %d", n, want*2)
}
if len(frame) != 3+n+2 {
return nil, fmt.Errorf("reply is %d bytes, expected %d", len(frame), 3+n+2)
}
out := make([]uint16, want)
for i := range out {
out[i] = binary.BigEndian.Uint16(frame[3+i*2:])
}
return out, nil
}
// parseWriteEcho validates a function 06 reply, which echoes the request.
func parseWriteEcho(addr byte, reg, val uint16, frame []byte) error {
if err := checkFrame(addr, fnWrite, frame, 8); err != nil {
return err
}
if len(frame) != 8 {
return fmt.Errorf("write reply is %d bytes, expected 8", len(frame))
}
if got := binary.BigEndian.Uint16(frame[2:]); got != reg {
return fmt.Errorf("write reply is for register 0x%04X, not 0x%04X", got, reg)
}
// The echoed VALUE is the confirmation that the output actually changed.
// Accepting the frame without checking it would report an on/off that the
// supply never made.
if got := binary.BigEndian.Uint16(frame[4:]); got != val {
return fmt.Errorf("supply echoed value %d, not the %d it was sent", got, val)
}
return nil
}
// checkFrame covers what every reply must satisfy: our address, our function
// (or its exception), and a good CRC.
func checkFrame(addr, fn byte, frame []byte, min int) error {
if len(frame) < 4 {
return fmt.Errorf("short reply (%d bytes)", len(frame))
}
if frame[0] != addr {
return fmt.Errorf("reply from address %d, expected %d", frame[0], addr)
}
if frame[1] == fn|0x80 {
if len(frame) < 5 {
return fmt.Errorf("short exception reply (%d bytes)", len(frame))
}
if !crcOK(frame[:5]) {
return fmt.Errorf("exception reply failed its CRC")
}
return modbusError{fn: fn, code: frame[2]}
}
if frame[1] != fn {
return fmt.Errorf("reply to function 0x%02X, expected 0x%02X", frame[1], fn)
}
if len(frame) < min {
return fmt.Errorf("short reply (%d bytes, expected at least %d)", len(frame), min)
}
if !crcOK(frame) {
return fmt.Errorf("reply failed its CRC")
}
return nil
}
// crcOK checks a whole frame, trailing CRC included: the CRC of the entire
// frame is zero when it is intact.
func crcOK(frame []byte) bool {
if len(frame) < 3 {
return false
}
body := frame[:len(frame)-2]
want := uint16(frame[len(frame)-2]) | uint16(frame[len(frame)-1])<<8
return crc16(body) == want
}
// frameGap is the silence that separates two Modbus RTU frames: 3.5 character
// times, which at 9600 baud 8N1 (10 bits per character) is 3.65 ms. Rounded up,
// because the cost of waiting is nothing and the cost of being early is a
// supply that treats our request as the tail of the previous one.
const frameGap = 4 * time.Millisecond
// replyWait is how long a reply may take. The manual promises under 5 ms at
// 9600 baud or better; this is generous by two orders of magnitude so a USB
// serial bridge that buffers cannot be mistaken for a supply that is not there.
const replyWait = 500 * time.Millisecond
// readFrame collects a reply until it stops arriving.
//
// A serial read that times out returns (0, nil) on Windows — a timeout is not
// an error on this transport — so a loop that trusts an error to end it never
// ends. Modbus RTU has no terminator either: a frame is over when the line has
// been quiet for 3.5 character times. Both facts point at the same shape, a
// deadline and a quiet-time.
func readFrame(conn io.Reader, d time.Duration) ([]byte, error) {
deadline := time.Now().Add(d)
buf := make([]byte, 0, 64)
tmp := make([]byte, 64)
lastByte := time.Time{}
for {
n, err := conn.Read(tmp)
if err != nil {
return buf, err
}
if n > 0 {
buf = append(buf, tmp[:n]...)
lastByte = time.Now()
continue
}
// Nothing this time: either the frame has ended, or it never started.
if len(buf) > 0 && time.Since(lastByte) >= frameGap {
return buf, nil
}
if time.Now().After(deadline) {
if len(buf) > 0 {
return buf, nil // partial — let the parser say what is wrong with it
}
return nil, fmt.Errorf("no reply after %s", d)
}
}
}