// 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) } } }