Files
VoCat/internal/modem/wwan_at_linux.go
T
f949001480 fix: support non-Quectel Qualcomm modems and fix 410 dongle AT timeouts (#40)
1. Vendor-neutral modem compatibility:
   - Discovery switched from a vendor-ID whitelist to detecting the QMI
     channel directly (an interface bound to the kernel qmi_wwan driver),
     so SIMCom, Sierra, Telit and other Qualcomm-based modules are found
     automatically while MBIM-only devices stay excluded
   - AT port responses now distinguish an AT command error from firmware
     incompatibility: ERROR / +CME ERROR is returned as a normal response
     (200) instead of being folded into a 502, which only a real transport
     failure produces

2. Fixed the 410 dongle's AT command timeouts:
   - Default WWAN AT port switched from wwan0at0 to wwan0at1: ModemManager
     marks the first AT port that answers its probe as primary (at1 on the
     tested UFI dongles) and closes AT ports once initialization finishes,
     so at1 is the responsive, idle channel for vocat while MM uses the
     QMI port for control
   - Drain the WWAN input buffer before each command write, discarding the
     late bytes of a previous timed-out command so they cannot pollute the
     next response's parsing
   - AT+CGSN now uses an independent short timeout instead of inheriting
     the refresh's 30s deadline (on MHI modems it returns the IMEI line
     but never a final OK). Previously every refresh held the device lock
     for the full 30s, queueing AT terminal commands behind it for 10-20s
   - The QMI UIM ICCID fallback only runs when AT+CPIN? already proved a
     READY card, so a SIM-less slot no longer blocks refresh waiting out
     its long timeout

Tests: added WWAN drain cleanup, drain-before-write ordering, CGSN timeout
bound, skip-QMI-ICCID-without-SIM, CommandError-as-200, WWAN at1 port
selection and vendor-neutral discovery cases. go vet and go test ./... pass.

Co-authored-by: Test <[email protected]>
2026-08-16 23:34:12 +08:00

181 lines
4.3 KiB
Go

//go:build linux
package modem
import (
"errors"
"fmt"
"io"
"sync"
"time"
"golang.org/x/sys/unix"
)
// nativeWWANATTransport adapts a Linux WWAN AT character device to Session's
// serial-like transport contract. WWAN ports are not TTYs, so termios ioctls
// used by ordinary serial libraries fail even though raw AT read/write works.
type nativeWWANATTransport struct {
mu sync.RWMutex
fd int
readTimeout time.Duration
closed bool
}
func openNativeWWANATTransport(path string) (Transport, error) {
fd, err := unix.Open(path, unix.O_RDWR|unix.O_NONBLOCK|unix.O_NOCTTY|unix.O_CLOEXEC, 0)
if err != nil {
return nil, err
}
return &nativeWWANATTransport{fd: fd, readTimeout: -1}, nil
}
func (transport *nativeWWANATTransport) Read(buffer []byte) (int, error) {
transport.mu.RLock()
defer transport.mu.RUnlock()
if transport.closed {
return 0, io.ErrClosedPipe
}
deadline := time.Time{}
if transport.readTimeout >= 0 {
deadline = time.Now().Add(transport.readTimeout)
}
for {
timeout := -1
if !deadline.IsZero() {
remaining := time.Until(deadline)
if remaining <= 0 {
return 0, nil
}
timeout = int((remaining + time.Millisecond - 1) / time.Millisecond)
}
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
ready, err := unix.Poll(fds, timeout)
if errors.Is(err, unix.EINTR) {
continue
}
if err != nil {
return 0, err
}
if ready == 0 {
return 0, nil
}
if fds[0].Revents&(unix.POLLERR|unix.POLLHUP|unix.POLLNVAL) != 0 &&
fds[0].Revents&unix.POLLIN == 0 {
return 0, io.EOF
}
count, err := unix.Read(transport.fd, buffer)
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
continue
}
if count < 0 {
count = 0
}
return count, err
}
}
func (transport *nativeWWANATTransport) Write(buffer []byte) (int, error) {
transport.mu.RLock()
defer transport.mu.RUnlock()
if transport.closed {
return 0, io.ErrClosedPipe
}
for {
count, err := unix.Write(transport.fd, buffer)
if errors.Is(err, unix.EINTR) {
continue
}
if errors.Is(err, unix.EAGAIN) {
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLOUT}}
if _, pollErr := unix.Poll(fds, 1000); pollErr != nil {
return 0, pollErr
}
continue
}
if count < 0 {
count = 0
}
return count, err
}
}
func (transport *nativeWWANATTransport) Drain() error {
transport.mu.RLock()
defer transport.mu.RUnlock()
if transport.closed {
return io.ErrClosedPipe
}
// WWAN character-device writes are handed to the modem synchronously and
// have no termios output queue to drain. A previous command that timed out
// can leave late bytes in the input buffer (e.g. a slow CGSN reply that
// arrives after the command deadline); discard them here so the next
// command starts from a clean stream instead of mis-parsing stale output.
buffer := make([]byte, 4096)
for {
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
ready, err := unix.Poll(fds, 0)
if err != nil {
return err
}
if ready == 0 || fds[0].Revents&unix.POLLIN == 0 {
return nil
}
if _, err := unix.Read(transport.fd, buffer); err != nil {
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
continue
}
return err
}
}
}
func (transport *nativeWWANATTransport) ResetInputBuffer() error {
transport.mu.RLock()
defer transport.mu.RUnlock()
if transport.closed {
return io.ErrClosedPipe
}
buffer := make([]byte, 4096)
for {
fds := []unix.PollFd{{Fd: int32(transport.fd), Events: unix.POLLIN}}
ready, err := unix.Poll(fds, 0)
if err != nil {
return err
}
if ready == 0 || fds[0].Revents&unix.POLLIN == 0 {
return nil
}
if _, err := unix.Read(transport.fd, buffer); err != nil {
if errors.Is(err, unix.EINTR) || errors.Is(err, unix.EAGAIN) {
continue
}
return err
}
}
}
func (transport *nativeWWANATTransport) SetReadTimeout(timeout time.Duration) error {
if timeout < -1 {
return fmt.Errorf("invalid read timeout %s", timeout)
}
transport.mu.Lock()
defer transport.mu.Unlock()
if transport.closed {
return io.ErrClosedPipe
}
transport.readTimeout = timeout
return nil
}
func (transport *nativeWWANATTransport) Close() error {
transport.mu.Lock()
defer transport.mu.Unlock()
if transport.closed {
return nil
}
transport.closed = true
return unix.Close(transport.fd)
}