Files
2026-08-09 05:33:21 +08:00

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()
}