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.
This commit is contained in:
@@ -0,0 +1,239 @@
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user