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.
240 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|