package plugin import ( "errors" "testing" "github.com/andig/gosunspec/memory" "github.com/andig/gosunspec/models/model704" gridx "github.com/grid-x/modbus" "github.com/stretchr/testify/require" sunsdev "github.com/volkszaehler/mbmd/meters/sunspec" ) // countingClient counts the physical reads reaching the device, failing the // first failReads of them type countingClient struct { gridx.Client reads int failReads int } func (c *countingClient) ReadHoldingRegisters(_, quantity uint16) ([]byte, error) { c.reads++ if c.reads <= c.failReads { return nil, errors.New("read failed") } return make([]byte, 2*quantity), nil } func (c *countingClient) WriteSingleRegister(_, _ uint16) ([]byte, error) { return nil, nil } func TestSunspecCachedClient(t *testing.T) { cnt := new(countingClient) c := newSunspecCachedClient(cnt) for range 3 { _, err := c.ReadHoldingRegisters(40000, 4) require.NoError(t, err) } require.Equal(t, 1, cnt.reads, "reads of the same block must share one exchange") _, err := c.ReadHoldingRegisters(40100, 4) require.NoError(t, err) require.Equal(t, 2, cnt.reads, "different block must be read separately") // a write invalidates the cache _, err = c.WriteSingleRegister(40000, 1) require.NoError(t, err) _, err = c.ReadHoldingRegisters(40000, 4) require.NoError(t, err) require.Equal(t, 3, cnt.reads, "read after write must not serve stale data") } func TestSunspecCachedClientError(t *testing.T) { cnt := &countingClient{failReads: 1} c := newSunspecCachedClient(cnt) _, err := c.ReadHoldingRegisters(40000, 4) require.Error(t, err, "read error must be propagated") // errors are not cached, the next read reaches the device again _, err = c.ReadHoldingRegisters(40000, 4) require.NoError(t, err) require.Equal(t, 2, cnt.reads) _, err = c.ReadHoldingRegisters(40000, 4) require.NoError(t, err) require.Equal(t, 2, cnt.reads, "successful read must be cached afterwards") } func TestSunspecCachedClientPayloadIsolated(t *testing.T) { c := newSunspecCachedClient(new(countingClient)) b, err := c.ReadHoldingRegisters(40000, 1) require.NoError(t, err) b[0] = 0xff b, err = c.ReadHoldingRegisters(40000, 1) require.NoError(t, err) require.Equal(t, []byte{0, 0}, b, "cached payload must not be mutated by callers") } const slabBase = 40000 // slabClient serves a gosunspec memory slab as a modbus device, counting the // register reads a real device would have to answer. type slabClient struct { gridx.Client slab []byte reads int } func (s *slabClient) offset(address, quantity uint16) (int, error) { if address < slabBase || int(address-slabBase)*2+int(quantity)*2 > len(s.slab) { return 0, errors.New("bad address") } return int(address-slabBase) * 2, nil } func (s *slabClient) ReadHoldingRegisters(address, quantity uint16) ([]byte, error) { off, err := s.offset(address, quantity) if err != nil { return nil, err } s.reads++ return s.slab[off : off+int(quantity)*2], nil } func (s *slabClient) WriteMultipleRegisters(address, quantity uint16, value []byte) ([]byte, error) { off, err := s.offset(address, quantity) if err != nil { return nil, err } copy(s.slab[off:off+int(quantity)*2], value) return nil, nil } func (s *slabClient) WriteSingleRegister(address, value uint16) ([]byte, error) { return s.WriteMultipleRegisters(address, 1, []byte{byte(value >> 8), byte(value)}) } // TestSunspecCachedClientSharedBlock exercises the full gosunspec read path: // values pointing at different points of the same model block must collapse // into a single physical block read. func TestSunspecCachedClientSharedBlock(t *testing.T) { slab, err := memory.NewSlabBuilder().AddModel(model704.ModelID).Build() require.NoError(t, err) sim := &slabClient{slab: slab} c := newSunspecCachedClient(sim) devices, err := sunsdev.DeviceTree(c) require.NoError(t, err) dev := sunsdev.NewDevice("test") require.NoError(t, dev.InitializeWithTree(devices)) c.cache.Clear() sim.reads = 0 _, _, err = dev.QueryPointAny(c, model704.ModelID, 0, model704.WMaxLimPct) require.NoError(t, err) require.NotZero(t, sim.reads) reads := sim.reads _, _, err = dev.QueryPointAny(c, model704.ModelID, 0, model704.WMaxLimPctEna) require.NoError(t, err) require.Equal(t, reads, sim.reads, "second point of the same block must be served from cache") // a write must invalidate the cached block block, point, err := dev.QueryPointAny(c, model704.ModelID, 0, model704.WMaxLimPct) require.NoError(t, err) point.SetUint16(42) require.NoError(t, block.Write(model704.WMaxLimPct)) _, _, err = dev.QueryPointAny(c, model704.ModelID, 0, model704.WMaxLimPctEna) require.NoError(t, err) require.Greater(t, sim.reads, reads, "read after write must not serve stale data") }