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.
181 lines
5.9 KiB
Go
181 lines
5.9 KiB
Go
package psu
|
|
|
|
import (
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// The CRC is the one thing here that cannot be checked by inspection, and every
|
|
// frame depends on it. CRC-16/MODBUS has a published check value: the CRC of
|
|
// the ASCII digits "123456789" is 0x4B37. If this passes, the polynomial, the
|
|
// initial value, the reflection and the absence of a final xor are all right.
|
|
func TestCRCMatchesTheStandardCheckValue(t *testing.T) {
|
|
if got := crc16([]byte("123456789")); got != 0x4B37 {
|
|
t.Fatalf("crc16(\"123456789\") = 0x%04X, want 0x4B37 — this is not CRC-16/MODBUS", got)
|
|
}
|
|
}
|
|
|
|
// A frame including its own CRC checks to zero. That property is what crcOK
|
|
// relies on, so it is worth pinning separately from the check value.
|
|
func TestAFrameVerifiesItself(t *testing.T) {
|
|
for _, f := range [][]byte{
|
|
buildRead(1, regVolts, 2),
|
|
buildWrite(1, regOnOff, 1),
|
|
buildWrite(15, regOnOff, 0),
|
|
} {
|
|
if !crcOK(f) {
|
|
t.Errorf("% X does not verify against its own CRC", f)
|
|
}
|
|
// And a single flipped bit must be caught.
|
|
bad := append([]byte(nil), f...)
|
|
bad[2] ^= 0x01
|
|
if crcOK(bad) {
|
|
t.Errorf("% X passed the CRC with a corrupted byte", bad)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The frame layout, byte for byte against the manual: address, function,
|
|
// register high/low, count or value high/low, then CRC low byte first.
|
|
func TestFrameLayout(t *testing.T) {
|
|
r := buildRead(1, 0x0010, 2)
|
|
if len(r) != 8 {
|
|
t.Fatalf("read frame is %d bytes, want 8", len(r))
|
|
}
|
|
want := []byte{0x01, 0x03, 0x00, 0x10, 0x00, 0x02}
|
|
for i := range want {
|
|
if r[i] != want[i] {
|
|
t.Fatalf("read frame % X, want % X…", r, want)
|
|
}
|
|
}
|
|
w := buildWrite(1, regOnOff, 1)
|
|
want = []byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x01}
|
|
for i := range want {
|
|
if w[i] != want[i] {
|
|
t.Fatalf("write frame % X, want % X…", w, want)
|
|
}
|
|
}
|
|
// The CRC goes out low byte first — the manual is explicit, and getting it
|
|
// backwards makes every frame be ignored in silence.
|
|
c := crc16(w[:6])
|
|
if w[6] != byte(c&0xFF) || w[7] != byte(c>>8) {
|
|
t.Errorf("CRC bytes % X, want %02X %02X (low first)", w[6:], byte(c&0xFF), byte(c>>8))
|
|
}
|
|
}
|
|
|
|
func TestParseRead(t *testing.T) {
|
|
// Two registers: 13.80 V (1380 at 2 decimals) and 2.500 A (2500 at 3).
|
|
frame := appendCRC([]byte{0x01, 0x03, 0x04, 0x05, 0x64, 0x09, 0xC4})
|
|
got, err := parseRead(1, 2, frame)
|
|
if err != nil {
|
|
t.Fatalf("parseRead: %v", err)
|
|
}
|
|
if len(got) != 2 || got[0] != 1380 || got[1] != 2500 {
|
|
t.Fatalf("got %v, want [1380 2500]", got)
|
|
}
|
|
if v := float64(got[0]) / voltScale; v != 13.80 {
|
|
t.Errorf("voltage scaled to %v, want 13.8", v)
|
|
}
|
|
}
|
|
|
|
// A reply from another slave on the same bus must not be read as ours.
|
|
func TestParseRejectsAnotherSlave(t *testing.T) {
|
|
frame := appendCRC([]byte{0x02, 0x03, 0x02, 0x05, 0x64})
|
|
if _, err := parseRead(1, 1, frame); err == nil {
|
|
t.Error("a reply from address 2 was accepted as address 1")
|
|
}
|
|
}
|
|
|
|
func TestParseRejectsABadCRC(t *testing.T) {
|
|
frame := appendCRC([]byte{0x01, 0x03, 0x02, 0x05, 0x64})
|
|
frame[3] ^= 0xFF
|
|
if _, err := parseRead(1, 1, frame); err == nil || !strings.Contains(err.Error(), "CRC") {
|
|
t.Errorf("err = %v, want a CRC complaint", err)
|
|
}
|
|
}
|
|
|
|
// An exception reply is the supply refusing, not the line failing, and the two
|
|
// need different answers from the operator.
|
|
func TestParseReportsAnException(t *testing.T) {
|
|
frame := appendCRC([]byte{0x01, 0x83, 0x02})
|
|
_, err := parseRead(1, 1, frame)
|
|
var me modbusError
|
|
if !errors.As(err, &me) {
|
|
t.Fatalf("err = %v, want a modbusError", err)
|
|
}
|
|
if me.code != 2 || !strings.Contains(err.Error(), "illegal data address") {
|
|
t.Errorf("exception decoded as %v", err)
|
|
}
|
|
}
|
|
|
|
// The echo is the ONLY confirmation that the output actually switched. A reply
|
|
// echoing a different value means the supply did something else, and reporting
|
|
// that as success is how a radio ends up with no power and a green light.
|
|
func TestWriteEchoMustMatch(t *testing.T) {
|
|
ok := appendCRC([]byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x01})
|
|
if err := parseWriteEcho(1, regOnOff, 1, ok); err != nil {
|
|
t.Fatalf("a correct echo was rejected: %v", err)
|
|
}
|
|
wrongVal := appendCRC([]byte{0x01, 0x06, 0x00, 0x01, 0x00, 0x00})
|
|
if err := parseWriteEcho(1, regOnOff, 1, wrongVal); err == nil {
|
|
t.Error("an echo of 0 was accepted for a command of 1 — the output never switched")
|
|
}
|
|
wrongReg := appendCRC([]byte{0x01, 0x06, 0x00, 0x30, 0x00, 0x01})
|
|
if err := parseWriteEcho(1, regOnOff, 1, wrongReg); err == nil {
|
|
t.Error("an echo for register 0x0030 was accepted for a write to 0x0001")
|
|
}
|
|
}
|
|
|
|
// quietPort delivers a frame in pieces, then goes quiet — a serial port that
|
|
// reports a timeout as (0, nil), which is what Windows does.
|
|
type quietPort struct {
|
|
chunks [][]byte
|
|
i int
|
|
}
|
|
|
|
func (p *quietPort) Read(b []byte) (int, error) {
|
|
if p.i >= len(p.chunks) {
|
|
time.Sleep(2 * time.Millisecond)
|
|
return 0, nil // timeout, not an error
|
|
}
|
|
n := copy(b, p.chunks[p.i])
|
|
p.i++
|
|
return n, nil
|
|
}
|
|
|
|
// A Modbus RTU frame has no terminator: it ends when the line falls quiet. The
|
|
// reader must assemble a dribbled frame and then stop on its own.
|
|
func TestReadFrameAssemblesUntilQuiet(t *testing.T) {
|
|
want := appendCRC([]byte{0x01, 0x03, 0x04, 0x05, 0x64, 0x09, 0xC4})
|
|
p := &quietPort{chunks: [][]byte{want[:2], want[2:5], want[5:]}}
|
|
got, err := readFrame(p, time.Second)
|
|
if err != nil {
|
|
t.Fatalf("readFrame: %v", err)
|
|
}
|
|
if len(got) != len(want) {
|
|
t.Fatalf("read % X, want % X", got, want)
|
|
}
|
|
for i := range want {
|
|
if got[i] != want[i] {
|
|
t.Fatalf("read % X, want % X", got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A supply that is switched off, or not on this port, must produce an error
|
|
// rather than a wait that never ends.
|
|
func TestReadFrameGivesUpOnSilence(t *testing.T) {
|
|
done := make(chan error, 1)
|
|
go func() { _, err := readFrame(&quietPort{}, 80*time.Millisecond); done <- err }()
|
|
select {
|
|
case err := <-done:
|
|
if err == nil {
|
|
t.Error("silence was reported as a frame")
|
|
}
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("readFrame never returned")
|
|
}
|
|
}
|