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