Skip to content

Commit fafae49

Browse files
committed
feat: serial expect/send sequences in any order
The serial module accepts tagged expect:/send: arguments, so an automation can send input before expecting, or interleave sends and expects in any order — not just strict expect-send pairs. Signed-off-by: llogen <christoph.lange@blindspot.software>
1 parent e738596 commit fafae49

3 files changed

Lines changed: 365 additions & 60 deletions

File tree

pkg/module/serial/serial.go

Lines changed: 149 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -55,11 +55,24 @@ const deviceLossGraceDefault = 2 * time.Second
5555
// staying far larger than any realistic prompt or expect pattern.
5656
const maxMatchWindow = 64 * 1024
5757

58-
// expectSendPair holds a compiled regex pattern and the bytes to send when it matches.
59-
// A nil or empty response means the module exits on match without sending anything.
60-
type expectSendPair struct {
61-
pattern *regexp.Regexp
62-
response []byte
58+
// stepKind distinguishes the two kinds of step in a serial sequence.
59+
type stepKind int
60+
61+
const (
62+
stepExpect stepKind = iota // wait until pattern matches the serial output
63+
stepSend // write data to the serial port
64+
)
65+
66+
// seqStep is one step of a serial automation sequence. The module walks the
67+
// steps in order: it waits for each expect-step's pattern to appear in the
68+
// output, and writes each send-step's data to the port. A single expect
69+
// pattern and the legacy expect-send pairs both compile down to a slice of
70+
// these, so the run loop has exactly one code path for all non-interactive
71+
// modes.
72+
type seqStep struct {
73+
kind stepKind
74+
pattern *regexp.Regexp // set when kind == stepExpect
75+
data []byte // set when kind == stepSend (empty = send nothing)
6376
}
6477

6578
// serialPort is the subset of the serial device used by Run. It is satisfied by
@@ -74,9 +87,8 @@ type Serial struct {
7487
Port string // Port is the path to the serial device on the dutagent.
7588
Baud int // Baud is the baud rate of the serial device. If unset, DefaultBaudRate is used.
7689

77-
expect *regexp.Regexp // expect is a pattern to match against the serial output (single-expect mode).
78-
pairs []expectSendPair // pairs are the expect-send pairs (expect-send mode).
79-
timeout time.Duration // timeout is the maximum time to wait for the expect pattern to match.
90+
steps []seqStep // steps is the ordered expect/send sequence (empty = interactive mode).
91+
timeout time.Duration // timeout is the maximum time to wait for the sequence to complete.
8092

8193
// dialPort opens the serial port. It defaults to openPort and is overridden
8294
// in tests to inject a fake. Set lazily in Run so the zero value works.
@@ -101,6 +113,7 @@ const abstract = `Serial connection to the DUT
101113
const usage = `
102114
ARGUMENTS:
103115
[-t <duration>] [<expect> [<response> <expect> <response> ...]]
116+
[-t <duration>] expect:<regex>|send:<data> [expect:<regex>|send:<data> ...]
104117
105118
`
106119

@@ -116,6 +129,16 @@ Modes of operation:
116129
response to the serial port. Pairs are processed in order; after the last
117130
pair matches the module reads serial output for 1 more second so the output
118131
of the triggered command is visible, then exits.
132+
- Sequence (every argument carries an "expect:" or "send:" tag): an ordered
133+
list of steps run one after another. An expect-step waits for its regex to
134+
match the serial output; a send-step writes its data to the port. Unlike
135+
expect-send pairs, the steps may appear in any order, so a sequence can
136+
begin with a send (e.g. an Enter to wake the console) or chain several
137+
sends or expects in a row. The whole sequence shares the -t deadline. If
138+
the last step is a send, the module drains output for 1 more second before
139+
exiting; if it is an expect, it exits as soon as that pattern matches.
140+
141+
e.g.: send:"\n" expect:"login:" send:"root\n" expect:"# " send:"reboot\n"
119142
120143
If the serial device disappears mid-session (e.g. an FTDI chip that powers down
121144
when the DUT loses power), the module waits for it to reappear and reconnects
@@ -124,7 +147,7 @@ automatically instead of ending the session.
124147
The expect string supports regular expressions according to [1].
125148
The optional -t flag specifies the maximum time to wait.
126149
Quote strings containing spaces or special characters. E.g.: "(?i)hello\s+world"
127-
Response strings support C-style escape sequences: \n, \r, \t, \\, \xHH.
150+
Response and send strings support C-style escape sequences: \n, \r, \t, \\, \xHH.
128151
129152
[1] https://golang.org/s/re2syntax.
130153
`
@@ -308,7 +331,7 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
308331
var (
309332
remainder []byte // partial CSI sequence carried across reads
310333
matchWindow []byte // bounded window of recent output for regex matching
311-
currentPair int
334+
currentStep int // cursor into s.steps
312335
draining bool
313336
)
314337

@@ -328,6 +351,21 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
328351
log.Printf("serial module: draining serial output for %s before closing", pairsDrain)
329352
}
330353

354+
// advanceSends fires consecutive send-steps at the cursor, writing each to
355+
// the port, until the cursor reaches an expect-step or the end of the
356+
// sequence. It is called once before the first read (so a sequence may begin
357+
// with a send) and again after every expect-step matches.
358+
advanceSends := func() {
359+
for currentStep < len(s.steps) && s.steps[currentStep].kind == stepSend {
360+
data := s.steps[currentStep].data
361+
currentStep++
362+
363+
if len(data) > 0 {
364+
writeToPort(data)
365+
}
366+
}
367+
}
368+
331369
// reconnect closes the vanished port and retries opening it until the device
332370
// reappears, the deadline (watchCtx) fires, or the session is cancelled. Once
333371
// it returns, either a fresh port is in place or watchCtx is done and the
@@ -367,6 +405,16 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
367405

368406
readBuffer := make([]byte, bufferSize)
369407

408+
// Fire any leading send-steps before the first read, so a sequence may begin
409+
// by sending (e.g. an Enter to wake the console) rather than expecting. If
410+
// the sequence is sends only, drain briefly so the DUT's response to the
411+
// final input is visible, then exit via the drain deadline below.
412+
advanceSends()
413+
414+
if len(s.steps) > 0 && currentStep >= len(s.steps) {
415+
startDrain()
416+
}
417+
370418
for {
371419
select {
372420
case <-loopCtx.Done():
@@ -389,8 +437,8 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
389437
emit([]byte("\n--- Timeout reached, no match found ---"))
390438
flushAndWait(true)
391439

392-
if s.expect != nil {
393-
return fmt.Errorf("timeout of %s reached, pattern %q not found", s.timeout, s.expect)
440+
if len(s.steps) == 1 && s.steps[0].kind == stepExpect {
441+
return fmt.Errorf("timeout of %s reached, pattern %q not found", s.timeout, s.steps[0].pattern)
394442
}
395443

396444
return fmt.Errorf("timeout of %s reached, expect-send sequence not completed", s.timeout)
@@ -446,8 +494,8 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
446494
// without a trailing newline). out is a fresh slice owned by the pump.
447495
emit(out)
448496

449-
// Matching is skipped while draining and in interactive mode.
450-
if draining || (s.expect == nil && len(s.pairs) == 0) {
497+
// Matching is skipped while draining and in interactive mode (no steps).
498+
if draining || len(s.steps) == 0 {
451499
continue
452500
}
453501

@@ -456,37 +504,38 @@ func (s *Serial) Run(ctx context.Context, session module.Session, args ...string
456504
matchWindow = matchWindow[len(matchWindow)-maxMatchWindow:]
457505
}
458506

459-
if s.expect != nil {
460-
if s.expect.Match(matchWindow) {
461-
emit([]byte("\n--- Pattern matched, connection closed ---"))
462-
flushAndWait(true)
463-
464-
return nil
465-
}
466-
467-
continue
468-
}
469-
470-
// Expect-send: fire every pair already satisfied by the current window.
471-
for currentPair < len(s.pairs) {
472-
loc := s.pairs[currentPair].pattern.FindIndex(matchWindow)
507+
// Walk the sequence: satisfy every expect-step the current window already
508+
// matches, firing the send-steps that follow each match. The cursor rests
509+
// on an expect-step here, because advanceSends consumed any leading or
510+
// trailing sends after the previous iteration.
511+
for currentStep < len(s.steps) && s.steps[currentStep].kind == stepExpect {
512+
loc := s.steps[currentStep].pattern.FindIndex(matchWindow)
473513
if loc == nil {
474514
break
475515
}
476516

477-
response := s.pairs[currentPair].response
478517
matchWindow = matchWindow[loc[1]:] // consume through the match
479-
currentPair++
518+
currentStep++
480519

481-
if len(response) > 0 {
482-
writeToPort(response)
520+
advanceSends() // fire the send-steps that follow this match
521+
522+
if currentStep < len(s.steps) {
523+
continue // more steps remain; keep matching the current window
483524
}
484525

485-
if currentPair >= len(s.pairs) {
526+
// Sequence complete. If it ended on a send, drain so the DUT's
527+
// response to the final input is visible; if it ended on an expect,
528+
// the match itself is the completion, so exit immediately.
529+
if s.steps[len(s.steps)-1].kind == stepSend {
486530
startDrain()
531+
} else {
532+
emit([]byte("\n--- Pattern matched, connection closed ---"))
533+
flushAndWait(true)
487534

488-
break
535+
return nil
489536
}
537+
538+
break
490539
}
491540
}
492541
}
@@ -568,6 +617,12 @@ func newStdoutPump(baseCtx context.Context, stdout io.Writer) (func([]byte), fun
568617
// pairStride is the number of positional arguments per expect-send pair.
569618
const pairStride = 2
570619

620+
// Tag prefixes that mark a tagged-sequence argument.
621+
const (
622+
expectTag = "expect:"
623+
sendTag = "send:"
624+
)
625+
571626
func (s *Serial) evalArgs(args []string) error {
572627
fs := flag.NewFlagSet("serial", flag.ContinueOnError)
573628
fs.SetOutput(io.Discard) // Suppress default error output
@@ -580,26 +635,39 @@ func (s *Serial) evalArgs(args []string) error {
580635

581636
positional := fs.Args()
582637

638+
// Tagged-sequence mode is selected when the first argument carries a tag.
639+
// It allows arbitrarily ordered expect/send steps (e.g. a leading send).
640+
if len(positional) > 0 && isTaggedStep(positional[0]) {
641+
return s.evalSequenceArgs(positional)
642+
}
643+
644+
return s.evalLegacyArgs(positional)
645+
}
646+
647+
// evalLegacyArgs parses the backward-compatible positional argument forms into
648+
// s.steps: no args (interactive), one arg (single expect), or an even number of
649+
// args (expect-send pairs).
650+
func (s *Serial) evalLegacyArgs(positional []string) error {
583651
switch len(positional) {
584652
case 0:
585-
// Interactive mode: no expect pattern, no pairs.
653+
// Interactive mode: no steps.
586654
case 1:
587-
// Single-expect mode (backward compatible).
655+
// Single-expect mode.
588656
log.Printf("serial module: Will wait for pattern: %q", positional[0])
589657

590658
pattern, compileErr := regexp.Compile(positional[0])
591659
if compileErr != nil {
592660
return fmt.Errorf("invalid regular expression: %w", compileErr)
593661
}
594662

595-
s.expect = pattern
663+
s.steps = []seqStep{{kind: stepExpect, pattern: pattern}}
596664
default:
597665
// Expect-send pairs mode.
598666
if len(positional)%pairStride != 0 {
599667
return fmt.Errorf("expect-send requires an even number of arguments, got %d", len(positional))
600668
}
601669

602-
s.pairs = make([]expectSendPair, 0, len(positional)/pairStride)
670+
s.steps = make([]seqStep, 0, len(positional))
603671

604672
for idx := 0; idx < len(positional); idx += pairStride {
605673
pattern, compileErr := regexp.Compile(positional[idx])
@@ -609,10 +677,49 @@ func (s *Serial) evalArgs(args []string) error {
609677

610678
log.Printf("serial module: Pair %d: pattern=%q response=%q", idx/pairStride+1, positional[idx], positional[idx+1])
611679

612-
s.pairs = append(s.pairs, expectSendPair{
613-
pattern: pattern,
614-
response: unescape(positional[idx+1]),
615-
})
680+
s.steps = append(s.steps,
681+
seqStep{kind: stepExpect, pattern: pattern},
682+
seqStep{kind: stepSend, data: unescape(positional[idx+1])},
683+
)
684+
}
685+
}
686+
687+
return nil
688+
}
689+
690+
// isTaggedStep reports whether arg carries an "expect:" or "send:" tag.
691+
func isTaggedStep(arg string) bool {
692+
return strings.HasPrefix(arg, expectTag) || strings.HasPrefix(arg, sendTag)
693+
}
694+
695+
// evalSequenceArgs parses tagged-sequence arguments into s.steps. Every
696+
// argument must carry a tag; mixing tagged and untagged arguments is rejected
697+
// so a malformed command fails loudly instead of being silently misread.
698+
func (s *Serial) evalSequenceArgs(args []string) error {
699+
s.steps = make([]seqStep, 0, len(args))
700+
701+
for idx, arg := range args {
702+
switch {
703+
case strings.HasPrefix(arg, expectTag):
704+
expr := arg[len(expectTag):]
705+
706+
pattern, compileErr := regexp.Compile(expr)
707+
if compileErr != nil {
708+
return fmt.Errorf("step %d: invalid regular expression %q: %w", idx+1, expr, compileErr)
709+
}
710+
711+
log.Printf("serial module: Step %d: expect=%q", idx+1, expr)
712+
713+
s.steps = append(s.steps, seqStep{kind: stepExpect, pattern: pattern})
714+
case strings.HasPrefix(arg, sendTag):
715+
data := arg[len(sendTag):]
716+
717+
log.Printf("serial module: Step %d: send=%q", idx+1, data)
718+
719+
s.steps = append(s.steps, seqStep{kind: stepSend, data: unescape(data)})
720+
default:
721+
return fmt.Errorf("step %d %q: in sequence mode every argument must start with %q or %q",
722+
idx+1, arg, expectTag, sendTag)
616723
}
617724
}
618725

0 commit comments

Comments
 (0)