520 lines
18 KiB
Go
520 lines
18 KiB
Go
package dispenser
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
type fakeSequenceDevice struct {
|
|
statusResponses []cmdResp
|
|
commandErrors map[cmdType][]error
|
|
commands []cmdType
|
|
commandTimes []time.Time
|
|
onCommand func(cmdType)
|
|
}
|
|
|
|
func newSequenceTestClient(t *testing.T, statusResponses ...cmdResp) (*Client, *fakeSequenceDevice) {
|
|
t.Helper()
|
|
|
|
device := &fakeSequenceDevice{
|
|
statusResponses: append([]cmdResp(nil), statusResponses...),
|
|
commandErrors: make(map[cmdType][]error),
|
|
}
|
|
now := time.Date(2026, time.July, 23, 12, 0, 0, 0, time.UTC)
|
|
client := &Client{
|
|
reqCh: make(chan cmdReq),
|
|
done: make(chan struct{}),
|
|
sequenceTiming: sequenceTiming{
|
|
now: func() time.Time {
|
|
return now
|
|
},
|
|
wait: func(ctx context.Context, duration time.Duration) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
now = now.Add(duration)
|
|
return nil
|
|
}
|
|
},
|
|
},
|
|
}
|
|
|
|
go func() {
|
|
for {
|
|
select {
|
|
case <-client.done:
|
|
return
|
|
case request := <-client.reqCh:
|
|
device.commands = append(device.commands, request.typ)
|
|
device.commandTimes = append(device.commandTimes, now)
|
|
if device.onCommand != nil {
|
|
device.onCommand(request.typ)
|
|
}
|
|
if request.typ == cmdStatus {
|
|
if len(device.statusResponses) == 0 {
|
|
request.respCh <- cmdResp{err: errors.New("unexpected status read")}
|
|
continue
|
|
}
|
|
response := device.statusResponses[0]
|
|
device.statusResponses = device.statusResponses[1:]
|
|
request.respCh <- response
|
|
continue
|
|
}
|
|
|
|
var err error
|
|
if queued := device.commandErrors[request.typ]; len(queued) > 0 {
|
|
err = queued[0]
|
|
device.commandErrors[request.typ] = queued[1:]
|
|
}
|
|
request.respCh <- cmdResp{err: err}
|
|
}
|
|
}
|
|
}()
|
|
t.Cleanup(client.Close)
|
|
|
|
return client, device
|
|
}
|
|
|
|
func status(position byte) []byte {
|
|
return []byte{0x30, 0x30, 0x30, position}
|
|
}
|
|
|
|
func commandCount(commands []cmdType, target cmdType) int {
|
|
count := 0
|
|
for _, command := range commands {
|
|
if command == target {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func TestResetPacket(t *testing.T) {
|
|
got := createPacket([]byte{0x30, 0x30}, commandRS)
|
|
want := []byte{0x02, 0x30, 0x30, 0x00, 0x02, 0x52, 0x53, 0x03, 0x02}
|
|
if !bytes.Equal(got, want) {
|
|
t.Errorf("createPacket(RS) = % X, want % X", got, want)
|
|
}
|
|
}
|
|
|
|
func TestDeliverCurrentCardSucceedsAfterCommandAcceptanceWithoutStatusPolling(t *testing.T) {
|
|
staleStatus := []byte{0x30, 0x32, 0x32, 0x33}
|
|
client, device := newSequenceTestClient(t, cmdResp{status: staleStatus})
|
|
|
|
stock, err := client.DeliverCurrentCard(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if stock != "" {
|
|
t.Fatalf("stock status = %q, want empty", stock)
|
|
}
|
|
if got := commandCount(device.commands, cmdOutOfMouth); got != 1 {
|
|
t.Fatalf("out-of-mouth commands = %d, want 1", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
|
t.Fatalf("status reads = %d, want 0", got)
|
|
}
|
|
if len(device.statusResponses) != 1 {
|
|
t.Fatalf("queued status responses = %d, want stale status unread", len(device.statusResponses))
|
|
}
|
|
}
|
|
|
|
func TestDeliverCurrentCardRejectsCommandFailures(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
err error
|
|
}{
|
|
{name: "serial dispatch failure", err: errors.New("serial write failed")},
|
|
{name: "invalid acknowledgement", err: errors.New("unexpected response status")},
|
|
{name: "missing acknowledgement", err: errors.New("no response from dispenser")},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
client, device := newSequenceTestClient(t)
|
|
device.commandErrors[cmdOutOfMouth] = []error{test.err}
|
|
|
|
_, err := client.DeliverCurrentCard(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), test.err.Error()) {
|
|
t.Fatalf("error = %v, want containing %q", err, test.err)
|
|
}
|
|
if got := commandCount(device.commands, cmdOutOfMouth); got != 1 {
|
|
t.Fatalf("out-of-mouth commands = %d, want 1", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
|
t.Fatalf("status reads = %d, want 0", got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDeliverCurrentCardPreservesContextCancellation(t *testing.T) {
|
|
client, _ := newSequenceTestClient(t)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
_, err := client.DeliverCurrentCard(ctx)
|
|
if !errors.Is(err, context.Canceled) {
|
|
t.Fatalf("error = %v, want context.Canceled", err)
|
|
}
|
|
}
|
|
|
|
func TestBeginPrepareNextCardDispatchesWithoutReadinessPolling(t *testing.T) {
|
|
client, device := newSequenceTestClient(t)
|
|
|
|
if err := client.BeginPrepareNextCard(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
|
|
t.Fatalf("to-encoder commands = %d, want 1", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
|
t.Fatalf("status reads = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestBeginPrepareNextCardReturnsDispatchFailureWithoutPolling(t *testing.T) {
|
|
client, device := newSequenceTestClient(t)
|
|
device.commandErrors[cmdToEncoder] = []error{errors.New("dispatch failed")}
|
|
|
|
err := client.BeginPrepareNextCard(context.Background())
|
|
if err == nil || !strings.Contains(err.Error(), "dispatch failed") {
|
|
t.Fatalf("error = %v, want dispatch failure", err)
|
|
}
|
|
if got := commandCount(device.commands, cmdToEncoder); got != 1 {
|
|
t.Fatalf("to-encoder commands = %d, want 1", got)
|
|
}
|
|
if got := commandCount(device.commands, cmdStatus); got != 0 {
|
|
t.Fatalf("status reads = %d, want 0", got)
|
|
}
|
|
}
|
|
|
|
func TestPositionDescriptions(t *testing.T) {
|
|
for _, test := range []struct {
|
|
position byte
|
|
want string
|
|
}{
|
|
{0x30, ""},
|
|
{0x31, "Card at pre-dispense position; "},
|
|
{0x32, "Card at encoder position; "},
|
|
{0x34, "Card at mouth position; "},
|
|
{0x37, "Card at pre-dispense position; Card at encoder position; Card at mouth position; "},
|
|
{0x39, "Card at pre-dispense position; Card empty; "},
|
|
{0x3F, "Card at pre-dispense position; Card at encoder position; Card at mouth position; Card empty; "},
|
|
{0x40, "Unknown status 0x40 at position 4; "},
|
|
} {
|
|
if got := statusDescription(status(test.position)); got != test.want {
|
|
t.Errorf("statusDescription(position %X) = %q, want %q", test.position, got, test.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func positionResponses(position byte, count int) []cmdResp {
|
|
responses := make([]cmdResp, count)
|
|
for i := range responses {
|
|
responses[i] = cmdResp{status: status(position)}
|
|
}
|
|
return responses
|
|
}
|
|
|
|
func TestPreparationClasses(t *testing.T) {
|
|
classes := map[preparationClass][]byte{
|
|
encoderConfirmed: {0x32, 0x33, 0x36, 0x37, 0x3A, 0x3B, 0x3E, 0x3F},
|
|
positionUncertain: {0x31, 0x34, 0x35, 0x39, 0x3C, 0x3D},
|
|
positionWellEmpty: {0x38}, positionNoCard: {0x30},
|
|
}
|
|
for want, positions := range classes {
|
|
for _, position := range positions {
|
|
st := []byte{0xFF, 0x36, 0x32, position}
|
|
if got, err := classifyPreparationStatus(st); got != want || err != nil {
|
|
t.Errorf("classify(% X)=%s,%v, want %s", st, got, err, want)
|
|
}
|
|
}
|
|
}
|
|
for _, st := range [][]byte{nil, {0x30}, status(0x02), status(0x40), status(0xFF), {0x30, 0x30, 0x30, 0x37, 0x30}} {
|
|
if got, err := classifyPreparationStatus(st); err == nil {
|
|
t.Errorf("classify(% X)=%s,nil, want malformed error", st, got)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPreparationAllPositionsBothMethods(t *testing.T) {
|
|
for _, next := range []bool{false, true} {
|
|
for position := byte(0x30); position <= 0x3F; position++ {
|
|
t.Run(fmt.Sprintf("next=%t/position=%X", next, position), func(t *testing.T) {
|
|
responses := positionResponses(position, 40)
|
|
for i := range responses {
|
|
responses[i].status[0], responses[i].status[1], responses[i].status[2] = 0xFF, 0x38, 0x34
|
|
}
|
|
c, d := newSequenceTestClient(t, responses...)
|
|
// Passive encoder data must never replace fresh AP.
|
|
c.lastStatus, c.lastStatusT, c.statusTTL = status(0x33), time.Now(), time.Hour
|
|
prepare := c.PrepareCurrentCard
|
|
if next {
|
|
prepare = c.PrepareNextCard
|
|
}
|
|
started := c.now()
|
|
_, err := prepare(context.Background())
|
|
wantFC7, wantRS, wantElapsed := 0, 0, time.Duration(0)
|
|
var wantErr error
|
|
switch position {
|
|
case 0x30:
|
|
wantFC7, wantRS, wantElapsed, wantErr = 4, 3, 18*time.Second, ErrPreparationExhausted
|
|
case 0x38:
|
|
wantErr = ErrCardWellEmpty
|
|
case 0x31, 0x34, 0x35, 0x39, 0x3C, 0x3D:
|
|
wantFC7, wantElapsed = 1, 4*time.Second
|
|
}
|
|
if !errors.Is(err, wantErr) {
|
|
t.Errorf("prepare(%X) error=%v, want %v", position, err, wantErr)
|
|
}
|
|
if commandCount(d.commands, cmdToEncoder) != wantFC7 || commandCount(d.commands, cmdReset) != wantRS {
|
|
t.Errorf("prepare(%X) commands=%v, want FC7=%d RS=%d", position, d.commands, wantFC7, wantRS)
|
|
}
|
|
if got := c.now().Sub(started); got != wantElapsed {
|
|
t.Errorf("prepare(%X) elapsed=%s, want %s", position, got, wantElapsed)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestThreeShakesAreRequestLocalAndSettled(t *testing.T) {
|
|
c, d := newSequenceTestClient(t, positionResponses(0x30, 34)...)
|
|
for request := 0; request < 2; request++ {
|
|
start := len(d.commands)
|
|
if _, err := c.PrepareCurrentCard(context.Background()); !errors.Is(err, ErrPreparationExhausted) {
|
|
t.Fatalf("request %d error=%v, want exhausted", request, err)
|
|
}
|
|
want := []cmdType{cmdStatus, cmdToEncoder}
|
|
for attempt := 0; attempt < 4; attempt++ {
|
|
want = append(want, cmdStatus, cmdStatus, cmdStatus, cmdStatus)
|
|
if attempt < 3 {
|
|
want = append(want, cmdReset, cmdToEncoder)
|
|
}
|
|
}
|
|
if !reflect.DeepEqual(d.commands[start:], want) {
|
|
t.Errorf("request %d commands=%v, want %v", request, d.commands[start:], want)
|
|
}
|
|
var lastFC7 time.Time
|
|
for i := start; i < len(d.commands); i++ {
|
|
if d.commands[i] == cmdReset && d.commandTimes[i].Sub(lastFC7) != 3*time.Second {
|
|
t.Errorf("RS at %s after FC7, want 3s", d.commandTimes[i].Sub(lastFC7))
|
|
}
|
|
if d.commands[i] == cmdToEncoder {
|
|
if i > start && d.commands[i-1] == cmdReset && d.commandTimes[i].Sub(d.commandTimes[i-1]) != 2*time.Second {
|
|
t.Errorf("FC7 settle=%s, want 2s", d.commandTimes[i].Sub(d.commandTimes[i-1]))
|
|
}
|
|
lastFC7 = d.commandTimes[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPreparationReclassifiesEverySample(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
positions []byte
|
|
wantRS int
|
|
wantTime time.Duration
|
|
wantErr error
|
|
}{
|
|
{"uncertain changes do not restart timer", []byte{0x30, 0x31, 0x34, 0x35, 0x39, 0x3C}, 0, 4 * time.Second, nil},
|
|
{"uncertain becomes encoder", []byte{0x30, 0x34, 0x37}, 0, time.Second, nil},
|
|
{"uncertain becomes empty", []byte{0x30, 0x34, 0x38}, 0, time.Second, ErrCardWellEmpty},
|
|
{"no sensors becomes empty", []byte{0x30, 0x30, 0x38}, 0, time.Second, ErrCardWellEmpty},
|
|
{"uncertain returns to shake path twice", []byte{0x30, 0x34, 0x30, 0x30, 0x30, 0x34, 0x34, 0x30, 0x30, 0x37}, 2, 10 * time.Second, nil},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var responses []cmdResp
|
|
for _, p := range tc.positions {
|
|
responses = append(responses, cmdResp{status: status(p)})
|
|
}
|
|
c, d := newSequenceTestClient(t, responses...)
|
|
start := c.now()
|
|
_, err := c.PrepareCurrentCard(context.Background())
|
|
if !errors.Is(err, tc.wantErr) || c.now().Sub(start) != tc.wantTime || commandCount(d.commands, cmdReset) != tc.wantRS {
|
|
t.Errorf("prepare(%s) error=%v elapsed=%s commands=%v, want error=%v time=%s RS=%d", tc.name, err, c.now().Sub(start), d.commands, tc.wantErr, tc.wantTime, tc.wantRS)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEveryShakeReclassifiesEncoderEmptyAndUncertain(t *testing.T) {
|
|
for shake := 1; shake <= 3; shake++ {
|
|
for _, position := range []byte{0x37, 0x38, 0x35} {
|
|
responses := positionResponses(0x30, 1+4*shake)
|
|
responses = append(responses, positionResponses(position, 5)...)
|
|
c, d := newSequenceTestClient(t, responses...)
|
|
_, err := c.PrepareCurrentCard(context.Background())
|
|
var wantErr error
|
|
if position == 0x38 {
|
|
wantErr = ErrCardWellEmpty
|
|
}
|
|
if !errors.Is(err, wantErr) || commandCount(d.commands, cmdReset) != shake || commandCount(d.commands, cmdToEncoder) != shake+1 {
|
|
t.Errorf("shake %d position=%X error=%v commands=%v, want %v and no further shake", shake, position, err, d.commands, wantErr)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPreparationTransportAndDispatchFailuresDoNotShake(t *testing.T) {
|
|
failure := errors.New("serial failure")
|
|
for _, response := range []cmdResp{{err: failure}, {status: []byte{0x30}}, {status: status(0x40)}} {
|
|
c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, response)
|
|
if _, err := c.PrepareCurrentCard(context.Background()); err == nil || errors.Is(err, ErrPreparationExhausted) {
|
|
t.Errorf("AP failure %v error=%v, want transport/malformed error", response, err)
|
|
}
|
|
if commandCount(d.commands, cmdToEncoder) != 1 || commandCount(d.commands, cmdReset) != 0 {
|
|
t.Errorf("AP failure commands=%v, want FC7 once and no RS", d.commands)
|
|
}
|
|
}
|
|
for _, command := range []cmdType{cmdToEncoder, cmdReset} {
|
|
c, d := newSequenceTestClient(t, positionResponses(0x30, 10)...)
|
|
d.commandErrors[command] = []error{failure}
|
|
if _, err := c.PrepareCurrentCard(context.Background()); !errors.Is(err, failure) {
|
|
t.Errorf("command %v error=%v, want serial failure", command, err)
|
|
}
|
|
if commandCount(d.commands, command) != 1 {
|
|
t.Errorf("command %v retried: %v", command, d.commands)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPreparationStopsAtCancellationAndDeadlineStages(t *testing.T) {
|
|
for _, stage := range []string{"initial AP", "FC7", "poll", "RS", "settle"} {
|
|
for _, callerCanceled := range []bool{false, true} {
|
|
t.Run(fmt.Sprintf("%s/cancel=%t", stage, callerCanceled), func(t *testing.T) {
|
|
c, d := newSequenceTestClient(t, positionResponses(0x30, 30)...)
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
t.Cleanup(cancel)
|
|
now := c.now()
|
|
c.sequenceTiming.now = func() time.Time { return now }
|
|
stoppedAt := -1
|
|
stop := func() {
|
|
if stoppedAt >= 0 {
|
|
return
|
|
}
|
|
stoppedAt = len(d.commands)
|
|
if callerCanceled {
|
|
cancel()
|
|
} else {
|
|
now = now.Add(sequenceTimeout)
|
|
}
|
|
}
|
|
c.sequenceTiming.wait = func(_ context.Context, duration time.Duration) error {
|
|
now = now.Add(duration)
|
|
if (stage == "poll" && duration == sequencePollInterval) || (stage == "settle" && duration == sequenceResetWait) {
|
|
stop()
|
|
}
|
|
return nil
|
|
}
|
|
d.onCommand = func(cmd cmdType) {
|
|
if (stage == "initial AP" && cmd == cmdStatus) || (stage == "FC7" && cmd == cmdToEncoder) || (stage == "RS" && cmd == cmdReset) {
|
|
stop()
|
|
}
|
|
}
|
|
_, err := c.PrepareCurrentCard(ctx)
|
|
wantErr := error(ErrPreparationExhausted)
|
|
if callerCanceled {
|
|
wantErr = context.Canceled
|
|
} else if stage == "initial AP" {
|
|
wantErr = context.DeadlineExceeded
|
|
}
|
|
if !errors.Is(err, wantErr) || len(d.commands) != stoppedAt {
|
|
t.Errorf("stage=%s cancel=%t error=%v commands=%v stoppedAt=%d, want %v and no commands after stop", stage, callerCanceled, err, d.commands, stoppedAt, wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPositionFlagsRemainDiagnostic(t *testing.T) {
|
|
for position := byte(0x30); position <= 0x3F; position++ {
|
|
st := status(position)
|
|
if got := isAtEncoderPosition(st); got != (position&0x02 != 0) {
|
|
t.Errorf("encoder sensor(%X)=%t", position, got)
|
|
}
|
|
if got := isCardWellEmpty(st); got != (position&0x08 != 0) {
|
|
t.Errorf("stock empty flag(%X)=%t", position, got)
|
|
}
|
|
wantStock := ""
|
|
if position&0x08 != 0 {
|
|
wantStock = "Card empty"
|
|
}
|
|
if got := stockTake(st); got != wantStock {
|
|
t.Errorf("stockTake(%X)=%q, want %q", position, got, wantStock)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestPreparationCallerDeadlineIsNotRetryableExhaustion(t *testing.T) {
|
|
c, d := newSequenceTestClient(t)
|
|
ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second))
|
|
defer cancel()
|
|
_, err := c.PrepareCurrentCard(ctx)
|
|
if !errors.Is(err, context.DeadlineExceeded) || errors.Is(err, ErrPreparationExhausted) || len(d.commands) != 0 {
|
|
t.Errorf("expired caller: err=%v commands=%v, want caller deadline and no commands", err, d.commands)
|
|
}
|
|
}
|
|
|
|
func TestPreparationExpiredEncoderSampleDoesNotHandoff(t *testing.T) {
|
|
c, d := newSequenceTestClient(t, cmdResp{status: status(0x30)}, cmdResp{status: status(0x37)})
|
|
now := c.now()
|
|
c.sequenceTiming.now = func() time.Time { return now }
|
|
d.onCommand = func(cmd cmdType) {
|
|
if cmd == cmdStatus && commandCount(d.commands, cmdStatus) == 2 {
|
|
now = now.Add(sequenceTimeout)
|
|
}
|
|
}
|
|
_, err := c.PrepareCurrentCard(context.Background())
|
|
if !errors.Is(err, ErrPreparationExhausted) {
|
|
t.Errorf("late encoder sample: err=%v, want preparation exhausted", err)
|
|
}
|
|
if len(d.commands) != 3 {
|
|
t.Errorf("late encoder sample commands=%v, want AP FC7 AP", d.commands)
|
|
}
|
|
}
|
|
|
|
func TestBeginPrepareNextCardStopsWhileDeliveryPending(t *testing.T) {
|
|
for _, mode := range []string{"cancel", "deadline", "clearance timeout", "transport failure"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
c, d := newSequenceTestClient(t)
|
|
d.commandErrors[cmdToEncoder] = []error{errDeliveryPending}
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
wantErr := error(context.Canceled)
|
|
c.sequenceTiming.wait = func(context.Context, time.Duration) error {
|
|
cancel()
|
|
return context.Canceled
|
|
}
|
|
switch mode {
|
|
case "deadline":
|
|
wantErr = context.DeadlineExceeded
|
|
c.sequenceTiming.wait = func(context.Context, time.Duration) error { return context.DeadlineExceeded }
|
|
case "clearance timeout":
|
|
wantErr = context.DeadlineExceeded
|
|
now := c.now()
|
|
c.sequenceTiming.now = func() time.Time { return now }
|
|
c.sequenceTiming.wait = func(context.Context, time.Duration) error { now = now.Add(deliveryClearanceTimeout); return nil }
|
|
case "transport failure":
|
|
wantErr = errors.New("AP read failed")
|
|
d.commandErrors[cmdToEncoder] = []error{wantErr}
|
|
}
|
|
if err := c.BeginPrepareNextCard(ctx); !errors.Is(err, wantErr) {
|
|
t.Errorf("BeginPrepareNextCard(%s) = %v, want %v", mode, err, wantErr)
|
|
}
|
|
if !reflect.DeepEqual(d.commands, []cmdType{cmdToEncoder}) {
|
|
t.Errorf("BeginPrepareNextCard(%s) commands = %v, want one guarded FC7 request", mode, d.commands)
|
|
}
|
|
})
|
|
}
|
|
}
|