package m31 import ( "cmp" "context" "errors" "fmt" "slices" "time" "onehouse/driver" "onehouse/protocol" ) type commandHandler struct { connection *connection } func (h *commandHandler) handle(ctx context.Context, commands []driver.Command) error { pending, err := h.prepare(ctx, commands) if err != nil { return fmt.Errorf("prepare output commands: %w", err) } if len(pending) == 0 { return nil } if len(commands) == 1 && (pending[0].analog || pending[0].point.pulse != 0) { err = h.special(ctx, pending[0]) if err != nil { return fmt.Errorf("apply special output: %w", err) } return nil } slices.SortFunc(pending, func(first outputRequest, second outputRequest) int { return cmp.Compare(first.point.address, second.point.address) }) active := make([]outputRequest, 0, len(pending)) for start := 0; start < len(pending); { if pending[start].command.Expired(time.Now()) { err = h.result(ctx, pending[start].command, protocol.ResultPayload{ Status: "rejected", Reason: "Command expired", }) if err != nil { return fmt.Errorf("reject expired write: %w", err) } start++ continue } end := start + 1 for end < len(pending) && pending[end].point.address == pending[end-1].point.address+1 && !pending[end].command.Expired(time.Now()) { end++ } err = h.writeRange(ctx, pending[start:end]) if err != nil { return fmt.Errorf("fail coil writes: %w", h.fail(ctx, pending, err)) } active = append(active, pending[start:end]...) start = end } if len(active) == 0 { return nil } next := h.connection.state next.outputs, err = h.connection.owner.client.ReadCoils(ctx, uint16(len(h.connection.owner.topology.outputs))) if err != nil { return fmt.Errorf("fail coil readback: %w", h.fail(ctx, active, fmt.Errorf("read command coils: %w", err))) } if len(next.outputs) != len(h.connection.owner.topology.outputs) { return fmt.Errorf("fail incomplete readback: %w", h.fail(ctx, active, fmt.Errorf("incomplete coil readback"))) } next.observedAt = time.Now().UTC() err = h.complete(ctx, active, next) if err != nil { return fmt.Errorf("complete coil commands: %w", err) } return nil } func (h *commandHandler) prepare(ctx context.Context, commands []driver.Command) ([]outputRequest, error) { var pending []outputRequest for _, command := range commands { record, exists := h.connection.owner.ledger.Lookup(command.Metadata.MessageID) if exists { err := h.send(ctx, command.Metadata.MessageID, record) if err != nil { return nil, fmt.Errorf("replay result: %w", err) } continue } request, reason := newOutputRequest(command, h.connection.owner.topology, h.connection.owner.deviceID, len(commands) > 1) if reason != "" { err := h.result(ctx, command, protocol.ResultPayload{ Status: "rejected", Reason: reason, }) if err != nil { return nil, fmt.Errorf("reject command: %w", err) } continue } pending = append(pending, request) } return pending, nil } func (h *commandHandler) send(ctx context.Context, commandID string, record driver.ResultRecord) error { err := h.connection.owner.publisher.Result(ctx, h.connection.owner.deviceID, commandID, record.Metadata, record.Payload) if err != nil { return fmt.Errorf("publish result: %w", err) } return nil } func (h *commandHandler) result(ctx context.Context, command driver.Command, payload protocol.ResultPayload) error { record, err := h.connection.owner.ledger.Remember(command.Metadata.MessageID, payload) if err != nil { return fmt.Errorf("remember command result: %w", err) } err = h.send(ctx, command.Metadata.MessageID, record) if err != nil { return fmt.Errorf("send command result: %w", err) } return nil } func (h *commandHandler) writeRange(ctx context.Context, requests []outputRequest) error { expires := *requests[0].command.Metadata.ExpiresAt values := make([]bool, len(requests)) for index, request := range requests { values[index] = request.value == 1 if request.command.Metadata.ExpiresAt.Before(expires) { expires = *request.command.Metadata.ExpiresAt } } ctx, cancel := context.WithDeadline(ctx, expires) defer cancel() var err error if len(requests) == 1 { err = h.connection.owner.client.WriteCoil(ctx, requests[0].point.address, values[0]) } else { err = h.connection.owner.client.WriteCoils(ctx, requests[0].point.address, values) } if err != nil { return fmt.Errorf("write output coils: %w", err) } return nil } func (h *commandHandler) fail(ctx context.Context, requests []outputRequest, cause error) error { records := make([]driver.ResultRecord, len(requests)) for index, request := range requests { record, err := h.connection.owner.ledger.Remember(request.command.Metadata.MessageID, protocol.ResultPayload{ Status: "failed", Reason: "Modbus operation failed", }) if err != nil { return fmt.Errorf("remember failed commands: %w", errors.Join(cause, err)) } records[index] = record } for index, request := range requests { err := h.send(ctx, request.command.Metadata.MessageID, records[index]) if err != nil { return fmt.Errorf("send failed commands: %w", errors.Join(cause, err)) } } return &deviceError{err: cause} } func (h *commandHandler) complete(ctx context.Context, requests []outputRequest, next snapshot) error { records := make([]driver.ResultRecord, len(requests)) causes := make(map[string]string, len(requests)) for index, request := range requests { actual := uint16(0) if request.analog { actual = next.analog[request.point.address] } else if next.outputs[request.point.address] { actual = 1 } payload := protocol.ResultPayload{Status: "completed"} if actual != request.value { payload.Status, payload.Reason = "failed", "Output readback differs from command" } record, err := h.connection.owner.ledger.Remember(request.command.Metadata.MessageID, payload) if err != nil { return fmt.Errorf("remember output results: %w", err) } records[index] = record causes[request.point.id] = request.command.Metadata.MessageID } err := h.connection.publish(ctx, next, false, causes) if err != nil { return fmt.Errorf("publish output feedback: %w", err) } for index, request := range requests { err = h.send(ctx, request.command.Metadata.MessageID, records[index]) if err != nil { return fmt.Errorf("publish completed results: %w", err) } } return nil } func (h *commandHandler) special(ctx context.Context, request outputRequest) error { next, err := h.applySpecial(ctx, request) if err != nil { return fmt.Errorf("fail special command: %w", h.fail(ctx, []outputRequest{request}, err)) } if !request.analog { err = h.result(ctx, request.command, protocol.ResultPayload{Status: "completed"}) if err != nil { return fmt.Errorf("complete pulse: %w", err) } return nil } err = h.complete(ctx, []outputRequest{request}, next) if err != nil { return fmt.Errorf("complete voltage command: %w", err) } return nil } func (h *commandHandler) applySpecial(ctx context.Context, request outputRequest) (snapshot, error) { ctx, cancel := context.WithDeadline(ctx, *request.command.Metadata.ExpiresAt) defer cancel() if !request.analog { err := h.connection.owner.client.WriteCoil(ctx, request.point.address, true) if err != nil { return snapshot{}, fmt.Errorf("write pulse: %w", err) } return h.connection.state, nil } err := h.connection.owner.client.WriteRegister(ctx, request.point.address, request.value) if err != nil { return snapshot{}, fmt.Errorf("write voltage: %w", err) } next := h.connection.state next.analog, err = h.connection.owner.client.ReadRegisters(ctx, 0, uint16(len(h.connection.owner.topology.analog))) if err != nil { return snapshot{}, fmt.Errorf("read voltage feedback: %w", err) } if len(next.analog) != len(h.connection.owner.topology.analog) { return snapshot{}, fmt.Errorf("incomplete voltage feedback") } err = validateVoltage(next.analog) if err != nil { return snapshot{}, fmt.Errorf("validate voltage feedback: %w", err) } next.observedAt = time.Now().UTC() return next, nil } func (h *commandHandler) unavailable(ctx context.Context, commands []driver.Command) error { pending, err := h.prepare(ctx, commands) if err != nil { return fmt.Errorf("prepare unavailable commands: %w", err) } for _, request := range pending { err = h.result(ctx, request.command, protocol.ResultPayload{ Status: "failed", Reason: "Modbus device is unavailable", }) if err != nil { return fmt.Errorf("publish unavailable result: %w", err) } } return nil }