mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
573 lines
14 KiB
Go
573 lines
14 KiB
Go
package modem
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Transport interface {
|
|
io.ReadWriteCloser
|
|
Drain() error
|
|
ResetInputBuffer() error
|
|
SetReadTimeout(time.Duration) error
|
|
}
|
|
|
|
type SessionOptions struct {
|
|
ReadTimeout time.Duration
|
|
CommandTimeout time.Duration
|
|
MaxURCs int
|
|
}
|
|
|
|
func (options SessionOptions) withDefaults() SessionOptions {
|
|
if options.ReadTimeout <= 0 {
|
|
options.ReadTimeout = 100 * time.Millisecond
|
|
}
|
|
if options.CommandTimeout <= 0 {
|
|
options.CommandTimeout = 3 * time.Second
|
|
}
|
|
if options.MaxURCs <= 0 {
|
|
options.MaxURCs = 256
|
|
}
|
|
return options
|
|
}
|
|
|
|
// Session serializes commands for one physical AT port. Reading is intentionally
|
|
// performed under the same mutex as writing: this prevents two callers from
|
|
// consuming each other's responses while still allowing interleaved URCs to be
|
|
// separated and queued.
|
|
type Session struct {
|
|
mu sync.Mutex
|
|
transport Transport
|
|
options SessionOptions
|
|
readBuf []byte
|
|
urcs []string
|
|
closed bool
|
|
poisoned bool
|
|
}
|
|
|
|
// PoisonedClient is implemented by Session. A poisoned session has hit a
|
|
// transport-fatal error (a failed write/drain/read or a closed serial line);
|
|
// the underlying fd is wedged and every subsequent command reuses the corpse.
|
|
// AT-level failures (CommandError, command timeout) do NOT poison — the
|
|
// transport is still healthy there, so reopening would only destroy a good
|
|
// session over a transient +CME ERROR.
|
|
type PoisonedClient interface {
|
|
Poisoned() bool
|
|
}
|
|
|
|
func NewSession(transport Transport, options SessionOptions) (*Session, error) {
|
|
if transport == nil {
|
|
return nil, errors.New("modem: transport is required")
|
|
}
|
|
options = options.withDefaults()
|
|
if err := transport.SetReadTimeout(options.ReadTimeout); err != nil {
|
|
return nil, fmt.Errorf("set serial read timeout: %w", err)
|
|
}
|
|
return &Session{
|
|
transport: transport,
|
|
options: options,
|
|
}, nil
|
|
}
|
|
|
|
// Poisoned reports whether this session has hit a transport-fatal error and
|
|
// should be discarded rather than reused. It is safe to call concurrently.
|
|
func (session *Session) Poisoned() bool {
|
|
session.mu.Lock()
|
|
defer session.mu.Unlock()
|
|
return session.poisoned || session.closed
|
|
}
|
|
|
|
func (session *Session) Execute(ctx context.Context, command string) (Response, error) {
|
|
command, err := normalizeATCommand(command)
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
ctx, cancel := session.commandContext(ctx)
|
|
defer cancel()
|
|
|
|
session.mu.Lock()
|
|
defer session.mu.Unlock()
|
|
if session.closed {
|
|
return Response{}, ErrSessionClosed
|
|
}
|
|
return session.executeLocked(ctx, command)
|
|
}
|
|
|
|
// ExecutePrompt executes the controlled two-phase AT+CMGS transaction. It does
|
|
// not release the session mutex between the command, the '>' prompt, the
|
|
// payload terminator, and the final result.
|
|
func (session *Session) ExecutePrompt(
|
|
ctx context.Context,
|
|
command string,
|
|
payload []byte,
|
|
) (Response, error) {
|
|
command, err := normalizeATCommand(command)
|
|
if err != nil {
|
|
return Response{}, err
|
|
}
|
|
if !strings.HasPrefix(strings.ToUpper(command), "AT+CMGS=") {
|
|
return Response{}, errors.New("modem: prompt command must be AT+CMGS")
|
|
}
|
|
if len(payload) > 8192 {
|
|
return Response{}, errors.New("modem: prompt payload exceeds 8192 bytes")
|
|
}
|
|
if bytes.IndexByte(payload, 0x1a) >= 0 || bytes.IndexByte(payload, 0x1b) >= 0 {
|
|
return Response{}, errors.New("modem: prompt payload contains a terminator")
|
|
}
|
|
ctx, cancel := session.commandContext(ctx)
|
|
defer cancel()
|
|
|
|
session.mu.Lock()
|
|
defer session.mu.Unlock()
|
|
if session.closed {
|
|
return Response{}, ErrSessionClosed
|
|
}
|
|
return session.executePromptLocked(ctx, command, payload)
|
|
}
|
|
|
|
func (session *Session) commandContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
if _, ok := ctx.Deadline(); ok || session.options.CommandTimeout <= 0 {
|
|
return context.WithCancel(ctx)
|
|
}
|
|
return context.WithTimeout(ctx, session.options.CommandTimeout)
|
|
}
|
|
|
|
func (session *Session) executeLocked(ctx context.Context, command string) (Response, error) {
|
|
started := time.Now()
|
|
response := Response{Command: command}
|
|
if err := ctx.Err(); err != nil {
|
|
return response, err
|
|
}
|
|
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
|
session.poisonLocked()
|
|
return response, fmt.Errorf("write %s: %w", command, err)
|
|
}
|
|
if err := session.transport.Drain(); err != nil {
|
|
session.poisonLocked()
|
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
|
}
|
|
return session.readFinalLocked(ctx, started, command, "", response)
|
|
}
|
|
|
|
// poisonLocked marks the session unusable after a transport-fatal error. Held
|
|
// under session.mu by the caller; idempotent.
|
|
func (session *Session) poisonLocked() {
|
|
session.poisoned = true
|
|
}
|
|
|
|
func (session *Session) executePromptLocked(
|
|
ctx context.Context,
|
|
command string,
|
|
payload []byte,
|
|
) (Response, error) {
|
|
started := time.Now()
|
|
response := Response{Command: command}
|
|
if err := ctx.Err(); err != nil {
|
|
return response, err
|
|
}
|
|
if err := writeAll(session.transport, []byte(command+"\r")); err != nil {
|
|
session.poisonLocked()
|
|
return response, fmt.Errorf("write %s: %w", command, err)
|
|
}
|
|
if err := session.transport.Drain(); err != nil {
|
|
session.poisonLocked()
|
|
return response, fmt.Errorf("drain %s: %w", command, err)
|
|
}
|
|
if err := session.waitPromptLocked(ctx, command, &response); err != nil {
|
|
response.Duration = time.Since(started)
|
|
return response, session.normalizeReadError(command, err)
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
session.abortPromptLocked()
|
|
response.Duration = time.Since(started)
|
|
return response, err
|
|
}
|
|
if err := writeAll(session.transport, payload); err != nil {
|
|
session.poisonLocked()
|
|
session.abortPromptLocked()
|
|
response.Duration = time.Since(started)
|
|
return response, fmt.Errorf("write %s payload: %w", command, err)
|
|
}
|
|
if err := writeAll(session.transport, []byte{0x1a}); err != nil {
|
|
session.poisonLocked()
|
|
session.abortPromptLocked()
|
|
response.Duration = time.Since(started)
|
|
return response, fmt.Errorf("terminate %s payload: %w", command, err)
|
|
}
|
|
if err := session.transport.Drain(); err != nil {
|
|
session.poisonLocked()
|
|
response.Duration = time.Since(started)
|
|
return response, fmt.Errorf("drain %s payload: %w", command, err)
|
|
}
|
|
return session.readFinalLocked(ctx, started, command, string(payload), response)
|
|
}
|
|
|
|
func (session *Session) readFinalLocked(
|
|
ctx context.Context,
|
|
started time.Time,
|
|
command string,
|
|
payloadEcho string,
|
|
response Response,
|
|
) (Response, error) {
|
|
expectedPrefix := expectedResponsePrefix(command)
|
|
for {
|
|
line, err := session.readLineLocked(ctx)
|
|
if err != nil {
|
|
response.Duration = time.Since(started)
|
|
return response, session.normalizeReadError(command, err)
|
|
}
|
|
line = strings.TrimSpace(strings.Trim(line, "\x00"))
|
|
if line == "" || strings.EqualFold(line, command) ||
|
|
(payloadEcho != "" && line == payloadEcho) {
|
|
continue
|
|
}
|
|
if isFinalResult(line) {
|
|
response.Final = line
|
|
response.Duration = time.Since(started)
|
|
if response.OK() {
|
|
return response, nil
|
|
}
|
|
return response, &CommandError{
|
|
Command: command,
|
|
Final: line,
|
|
Lines: append([]string(nil), response.Lines...),
|
|
}
|
|
}
|
|
if isURC(line) && !strings.HasPrefix(strings.ToUpper(line), expectedPrefix) {
|
|
response.URCs = append(response.URCs, line)
|
|
session.enqueueURCLocked(line)
|
|
continue
|
|
}
|
|
response.Lines = append(response.Lines, line)
|
|
}
|
|
}
|
|
|
|
func (session *Session) waitPromptLocked(
|
|
ctx context.Context,
|
|
command string,
|
|
response *Response,
|
|
) error {
|
|
expectedPrefix := expectedResponsePrefix(command)
|
|
for {
|
|
if index := promptIndex(session.readBuf); index >= 0 {
|
|
prefix := string(session.readBuf[:index])
|
|
session.readBuf = session.readBuf[index+1:]
|
|
for len(session.readBuf) > 0 &&
|
|
(session.readBuf[0] == ' ' || session.readBuf[0] == '\t') {
|
|
session.readBuf = session.readBuf[1:]
|
|
}
|
|
for _, line := range strings.FieldsFunc(prefix, func(character rune) bool {
|
|
return character == '\r' || character == '\n'
|
|
}) {
|
|
if err := session.consumePromptLineLocked(
|
|
command,
|
|
expectedPrefix,
|
|
line,
|
|
response,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
if line, ok := popLine(&session.readBuf); ok {
|
|
if err := session.consumePromptLineLocked(
|
|
command,
|
|
expectedPrefix,
|
|
line,
|
|
response,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
buffer := make([]byte, 1024)
|
|
count, err := session.transport.Read(buffer)
|
|
if count > 0 {
|
|
session.readBuf = append(session.readBuf, buffer[:count]...)
|
|
continue
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) && session.closed {
|
|
return ErrSessionClosed
|
|
}
|
|
session.poisonLocked()
|
|
return fmt.Errorf("read serial prompt: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func promptIndex(buffer []byte) int {
|
|
for index, character := range buffer {
|
|
if character != '>' {
|
|
continue
|
|
}
|
|
if index == 0 || buffer[index-1] == '\r' || buffer[index-1] == '\n' {
|
|
return index
|
|
}
|
|
}
|
|
return -1
|
|
}
|
|
|
|
func (session *Session) consumePromptLineLocked(
|
|
command string,
|
|
expectedPrefix string,
|
|
line string,
|
|
response *Response,
|
|
) error {
|
|
line = strings.TrimSpace(strings.Trim(line, "\x00"))
|
|
if line == "" || strings.EqualFold(line, command) {
|
|
return nil
|
|
}
|
|
if isFinalResult(line) {
|
|
response.Final = line
|
|
if response.OK() {
|
|
return fmt.Errorf("%w: %s", ErrPromptNotReceived, command)
|
|
}
|
|
return &CommandError{
|
|
Command: command,
|
|
Final: line,
|
|
Lines: append([]string(nil), response.Lines...),
|
|
}
|
|
}
|
|
if isURC(line) && !strings.HasPrefix(strings.ToUpper(line), expectedPrefix) {
|
|
response.URCs = append(response.URCs, line)
|
|
session.enqueueURCLocked(line)
|
|
return nil
|
|
}
|
|
response.Lines = append(response.Lines, line)
|
|
return nil
|
|
}
|
|
|
|
func (session *Session) normalizeReadError(command string, err error) error {
|
|
if errors.Is(err, context.DeadlineExceeded) ||
|
|
errors.Is(err, context.Canceled) {
|
|
_ = session.transport.ResetInputBuffer()
|
|
session.readBuf = nil
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
return fmt.Errorf("%w: %s", ErrCommandTimeout, command)
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (session *Session) abortPromptLocked() {
|
|
_ = writeAll(session.transport, []byte{0x1b})
|
|
_ = session.transport.Drain()
|
|
_ = session.transport.ResetInputBuffer()
|
|
session.readBuf = nil
|
|
}
|
|
|
|
// WaitURC waits for an unsolicited result matching predicate. Non-matching URCs
|
|
// remain queued for another consumer.
|
|
func (session *Session) WaitURC(
|
|
ctx context.Context,
|
|
predicate func(string) bool,
|
|
) (string, error) {
|
|
if predicate == nil {
|
|
return "", errors.New("modem: URC predicate is required")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
session.mu.Lock()
|
|
defer session.mu.Unlock()
|
|
if session.closed {
|
|
return "", ErrSessionClosed
|
|
}
|
|
|
|
for index, line := range session.urcs {
|
|
if predicate(line) {
|
|
session.urcs = append(session.urcs[:index], session.urcs[index+1:]...)
|
|
return line, nil
|
|
}
|
|
}
|
|
for {
|
|
line, err := session.readLineLocked(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
line = strings.TrimSpace(strings.Trim(line, "\x00"))
|
|
if line == "" {
|
|
continue
|
|
}
|
|
if predicate(line) {
|
|
return line, nil
|
|
}
|
|
session.enqueueURCLocked(line)
|
|
}
|
|
}
|
|
|
|
func (session *Session) enqueueURCLocked(line string) {
|
|
if len(session.urcs) >= session.options.MaxURCs {
|
|
copy(session.urcs, session.urcs[1:])
|
|
session.urcs[len(session.urcs)-1] = line
|
|
return
|
|
}
|
|
session.urcs = append(session.urcs, line)
|
|
}
|
|
|
|
func (session *Session) readLineLocked(ctx context.Context) (string, error) {
|
|
for {
|
|
if line, ok := popLine(&session.readBuf); ok {
|
|
return line, nil
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
buffer := make([]byte, 1024)
|
|
count, err := session.transport.Read(buffer)
|
|
if count > 0 {
|
|
session.readBuf = append(session.readBuf, buffer[:count]...)
|
|
continue
|
|
}
|
|
if err != nil {
|
|
if errors.Is(err, io.EOF) && session.closed {
|
|
return "", ErrSessionClosed
|
|
}
|
|
session.poisonLocked()
|
|
return "", fmt.Errorf("read serial response: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func popLine(buffer *[]byte) (string, bool) {
|
|
data := *buffer
|
|
for index, character := range data {
|
|
if character != '\r' && character != '\n' {
|
|
continue
|
|
}
|
|
line := string(data[:index])
|
|
next := index + 1
|
|
for next < len(data) && (data[next] == '\r' || data[next] == '\n') {
|
|
next++
|
|
}
|
|
*buffer = data[next:]
|
|
return line, true
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
func normalizeATCommand(command string) (string, error) {
|
|
command = strings.TrimSpace(command)
|
|
if command == "" {
|
|
return "", errors.New("modem: AT command is empty")
|
|
}
|
|
if len(command) > 512 {
|
|
return "", errors.New("modem: AT command exceeds 512 bytes")
|
|
}
|
|
if strings.ContainsAny(command, "\r\n\x00") {
|
|
return "", errors.New("modem: AT command contains a control delimiter")
|
|
}
|
|
if !strings.HasPrefix(strings.ToUpper(command), "AT") {
|
|
return "", errors.New("modem: command must start with AT")
|
|
}
|
|
return command, nil
|
|
}
|
|
|
|
func expectedResponsePrefix(command string) string {
|
|
upper := strings.ToUpper(strings.TrimSpace(command))
|
|
if strings.HasPrefix(upper, "AT+CUSD=") {
|
|
// +CUSD is asynchronous even when it arrives before the command's OK.
|
|
return "\x00"
|
|
}
|
|
body := strings.TrimPrefix(upper, "AT")
|
|
if body == "" || body == "I" {
|
|
return "\x00"
|
|
}
|
|
end := len(body)
|
|
for index, character := range body {
|
|
if character == '?' || character == '=' || character == ',' {
|
|
end = index
|
|
break
|
|
}
|
|
}
|
|
name := body[:end]
|
|
if name == "" {
|
|
return "\x00"
|
|
}
|
|
return name + ":"
|
|
}
|
|
|
|
func isFinalResult(line string) bool {
|
|
upper := strings.ToUpper(strings.TrimSpace(line))
|
|
return upper == "OK" ||
|
|
upper == "ERROR" ||
|
|
upper == "NO CARRIER" ||
|
|
upper == "BUSY" ||
|
|
upper == "NO ANSWER" ||
|
|
strings.HasPrefix(upper, "+CME ERROR:") ||
|
|
strings.HasPrefix(upper, "+CMS ERROR:")
|
|
}
|
|
|
|
func isURC(line string) bool {
|
|
upper := strings.ToUpper(strings.TrimSpace(line))
|
|
if upper == "RING" ||
|
|
upper == "RDY" ||
|
|
upper == "CALL READY" ||
|
|
upper == "SMS READY" ||
|
|
upper == "PB DONE" {
|
|
return true
|
|
}
|
|
for _, prefix := range []string{
|
|
"+CMTI:",
|
|
"+CMT:",
|
|
"+CDS:",
|
|
"+CREG:",
|
|
"+CGREG:",
|
|
"+CEREG:",
|
|
"+CUSD:",
|
|
"+CLIP:",
|
|
"+CRING:",
|
|
"+QIND:",
|
|
"+QIURC:",
|
|
"+QSIMSTAT:",
|
|
"+QUSIM:",
|
|
"+QNWINFO:",
|
|
} {
|
|
if strings.HasPrefix(upper, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func writeAll(writer io.Writer, payload []byte) error {
|
|
for len(payload) > 0 {
|
|
count, err := writer.Write(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if count <= 0 {
|
|
return io.ErrShortWrite
|
|
}
|
|
if count > len(payload) {
|
|
return io.ErrShortWrite
|
|
}
|
|
payload = payload[count:]
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (session *Session) Close() error {
|
|
session.mu.Lock()
|
|
defer session.mu.Unlock()
|
|
if session.closed {
|
|
return nil
|
|
}
|
|
session.closed = true
|
|
return session.transport.Close()
|
|
}
|