package m31 import ( "context" "errors" "fmt" "slices" "sync" "testing" "time" "github.com/stretchr/testify/require" "onehouse/driver" "onehouse/protocol" ) const testDeviceID = "01ARZ3NDEKTSV4RRFFQ69G5FAV" type fakeClient struct { registerReadErr error wrongRegister uint16 failAfterWrites int mu sync.Mutex inputs []bool coils []bool analog []uint16 registers map[uint16]uint16 writes []coilWrite registerWrites []uint16 attempts []time.Time closes int coilReads int connectErrors []error inputErr error readErr error writeErr error registerErr error closeErr error wrongConfig bool shortRead bool mismatch int writeDelay time.Duration inputDelay time.Duration } func (c *fakeClient) Connect(ctx context.Context, _ string, _ byte) error { c.mu.Lock() defer c.mu.Unlock() c.attempts = append(c.attempts, time.Now()) if len(c.connectErrors) != 0 { err := c.connectErrors[0] c.connectErrors = c.connectErrors[1:] return err } return ctx.Err() } func (c *fakeClient) Close() error { c.mu.Lock() defer c.mu.Unlock() c.closes++ return c.closeErr } func (c *fakeClient) ReadInputs(ctx context.Context, _ uint16) ([]bool, error) { c.mu.Lock() defer c.mu.Unlock() time.Sleep(c.inputDelay) if c.inputErr != nil || ctx.Err() != nil { return nil, errors.Join(c.inputErr, ctx.Err()) } return slices.Clone(c.inputs), nil } func (c *fakeClient) ReadCoils(ctx context.Context, _ uint16) ([]bool, error) { c.mu.Lock() defer c.mu.Unlock() c.coilReads++ if c.readErr != nil || ctx.Err() != nil { return nil, errors.Join(c.readErr, ctx.Err()) } if c.shortRead { return nil, nil } return slices.Clone(c.coils), nil } func (c *fakeClient) ReadRegisters(ctx context.Context, address uint16, count uint16) ([]uint16, error) { c.mu.Lock() defer c.mu.Unlock() if c.registerErr != nil || c.registerReadErr != nil || ctx.Err() != nil { return nil, errors.Join(c.registerErr, c.registerReadErr, ctx.Err()) } if address >= pulseWidthBase { value := c.registers[address] if c.wrongConfig || address == c.wrongRegister { value++ } return []uint16{value}, nil } return slices.Clone(c.analog[:min(int(count), len(c.analog))]), nil } func (c *fakeClient) WriteCoil(ctx context.Context, address uint16, value bool) error { return c.write(ctx, address, []bool{value}, false) } func (c *fakeClient) WriteCoils(ctx context.Context, address uint16, values []bool) error { return c.write(ctx, address, values, true) } func (c *fakeClient) write(ctx context.Context, address uint16, values []bool, multiple bool) error { c.mu.Lock() defer c.mu.Unlock() time.Sleep(c.writeDelay) if c.writeErr != nil && len(c.writes) >= c.failAfterWrites || ctx.Err() != nil { return errors.Join(c.writeErr, ctx.Err()) } c.writes = append(c.writes, coilWrite{ address: address, values: slices.Clone(values), multiple: multiple, }) for offset, value := range values { if int(address)+offset != c.mismatch { c.coils[int(address)+offset] = value } } return nil } func (c *fakeClient) WriteRegister(ctx context.Context, address uint16, value uint16) error { c.mu.Lock() defer c.mu.Unlock() if c.registerErr != nil || ctx.Err() != nil { return errors.Join(c.registerErr, ctx.Err()) } c.registerWrites = append(c.registerWrites, address) if address >= pulseWidthBase { c.registers[address] = value } else if int(address) != c.mismatch { c.analog[address] = value } return nil } func (c *fakeClient) withLock(fn func(*fakeClient)) { c.mu.Lock() defer c.mu.Unlock() fn(c) } type coilWrite struct { address uint16 values []bool multiple bool } type fakeTransport struct { mu sync.Mutex messages []protocol.Message[any] failKind protocol.Kind failOnce bool err error } func (f *fakeTransport) Publish(ctx context.Context, publication protocol.Publication) error { f.mu.Lock() defer f.mu.Unlock() message, err := protocol.Parse(publication.Topic, publication.Data, publication.Retain) if err != nil { return fmt.Errorf("parse fake publication: %w", err) } if message.Address.Kind == f.failKind && f.err != nil { err = f.err if f.failOnce { f.err = nil } return err } if ctx.Err() != nil { return ctx.Err() } f.messages = append(f.messages, message) return nil } func (f *fakeTransport) ofKind(kind protocol.Kind) []protocol.Message[any] { f.mu.Lock() defer f.mu.Unlock() var messages []protocol.Message[any] for _, message := range f.messages { if message.Address.Kind == kind { messages = append(messages, message) } } return messages } func (f *fakeTransport) all() []protocol.Message[any] { f.mu.Lock() defer f.mu.Unlock() return slices.Clone(f.messages) } func testAssembly(t *testing.T) (*Driver, *fakeClient, *fakeTransport, *driver.Router) { t.Helper() transport := &fakeTransport{} publisher := driver.NewPublisher(transport) router, err := driver.NewRouter([]string{testDeviceID}) require.NoError(t, err) client := &fakeClient{ inputs: make([]bool, 16), coils: make([]bool, 32), analog: make([]uint16, 8), registers: make(map[uint16]uint16), mismatch: -1, } settings := Settings{ DeviceID: testDeviceID, Address: "example.com:502", Unit: 1, PollInterval: 50 * time.Millisecond, Modules: []Module{ {Model: HostModel, PulseWidths: map[int]time.Duration{2: 200 * time.Millisecond}}, {Model: OutputModel}, {Model: AnalogModel}, {Model: MixedModel}, }, } d, err := New(settings, publisher, router, client) require.NoError(t, err) return d, client, transport, router } func testCommand(t *testing.T, capability string, value string) driver.Command { t.Helper() id, err := protocol.NewID(time.Now()) require.NoError(t, err) expires := time.Now().UTC().Add(time.Minute) return driver.Command{ DeviceID: testDeviceID, Capability: capability, Metadata: protocol.Metadata{ MessageID: id, ExpiresAt: &expires, }, Payload: protocol.ValuePayload{Value: []byte(value)}, } } func testConnection(t *testing.T) (*connection, *fakeClient, *fakeTransport) { t.Helper() d, client, transport, _ := testAssembly(t) connection := &connection{owner: d} require.NoError(t, connection.start(t.Context())) transport.messages = nil client.writes = nil client.coilReads = 0 return connection, client, transport } func queueCommands(t *testing.T, router *driver.Router, commands []driver.Command) { t.Helper() var ( publication protocol.Publication err error ) if len(commands) == 1 { command := commands[0] publication, err = protocol.BuildCommand(command.DeviceID, command.Capability, command.Metadata, command.Payload) } else { members := make([]protocol.BatchCommand, len(commands)) for index, command := range commands { members[index] = protocol.BatchCommand{ Capability: command.Capability, Metadata: command.Metadata, Payload: command.Payload, } } publication, err = protocol.BuildCommandBatch(testDeviceID, commands[0].Metadata, protocol.BatchPayload{Commands: members}) } require.NoError(t, err) require.NoError(t, router.Handle(t.Context(), publication.Topic, publication.Data, false)) }