diff --git a/LICENSES/quectel-qmi-go-MIT.txt b/LICENSES/quectel-qmi-go-MIT.txt new file mode 100644 index 0000000..cbade88 --- /dev/null +++ b/LICENSES/quectel-qmi-go-MIT.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 iniwex5 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..c4a5614 --- /dev/null +++ b/NOTICE @@ -0,0 +1,10 @@ +VoCat uses the following Go module for native Qualcomm QMI support: + + github.com/iniwex5/quectel-qmi-go v0.6.0 + Distribution: https://proxy.golang.org/github.com/iniwex5/quectel-qmi-go/@v/v0.6.0.zip + Documentation and license metadata: https://pkg.go.dev/github.com/iniwex5/quectel-qmi-go@v0.6.0 + License: MIT + Copyright: Copyright (c) 2026 iniwex5 + +The full MIT license text is included in: +LICENSES/quectel-qmi-go-MIT.txt diff --git a/cmd/vocat/main.go b/cmd/vocat/main.go index 4d3ecb9..765c4ac 100644 --- a/cmd/vocat/main.go +++ b/cmd/vocat/main.go @@ -27,6 +27,7 @@ import ( "vocat/internal/extensions" "vocat/internal/httpsmode" "vocat/internal/loghub" + "vocat/internal/modem" "vocat/internal/pcsc" "vocat/internal/server" "vocat/internal/store" @@ -836,9 +837,9 @@ func provisionDiscoveredDevices( } for _, discovered := range manager.List() { candidate := discovered.Candidate + deviceType := provisionedDeviceType(candidate) backend := "at" control := candidate.ATPort.OpenPath() - deviceType := store.DeviceTypePCIeEC20EC25 esimTransport := backend if candidate.QMIControl != "" { backend = "qmi" @@ -880,6 +881,15 @@ func provisionDiscoveredDevices( return nil } +func provisionedDeviceType(candidate modem.Candidate) string { + controlName := filepath.Base(filepath.Clean(candidate.QMIControl)) + if candidate.HardwareKind == "wwan" && + strings.HasPrefix(controlName, "wwan") && strings.Contains(controlName, "qmi") { + return store.DeviceTypeWiFi410 + } + return store.DeviceTypePCIeEC20EC25 +} + // persistLogsToStore subscribes to the live log hub and durably appends every // entry to the log_events table, so runtime logs survive restarts and can be // pruned by the configured retention policy. diff --git a/cmd/vocat/main_test.go b/cmd/vocat/main_test.go index 90fc88e..cee4d23 100644 --- a/cmd/vocat/main_test.go +++ b/cmd/vocat/main_test.go @@ -217,3 +217,24 @@ func TestEnforceCardRegionIgnoresUnknownOrNotReadySIM(t *testing.T) { t.Fatalf("expected no card policies, got %d", len(policies)) } } + +func TestProvisionedDeviceTypeRecognizesNativeWWAN(t *testing.T) { + native := modem.Candidate{ + HardwareKind: "wwan", + USBPath: "/sys/devices/pci0000:00/0000:00:00.0/wwan/wwan0", + QMIControl: "/dev/wwan0qmi0", + ATPort: modem.Port{Path: "/dev/wwan0at0"}, + } + if got := provisionedDeviceType(native); got != store.DeviceTypeWiFi410 { + t.Fatalf("native WWAN type = %q, want %q", got, store.DeviceTypeWiFi410) + } + + usb := modem.Candidate{ + USBPath: "/sys/bus/usb/devices/1-6", + QMIControl: "/dev/cdc-wdm0", + ATPort: modem.Port{Path: "/dev/ttyUSB2"}, + } + if got := provisionedDeviceType(usb); got != store.DeviceTypePCIeEC20EC25 { + t.Fatalf("USB modem type = %q, want %q", got, store.DeviceTypePCIeEC20EC25) + } +} diff --git a/go.mod b/go.mod index 6cadd71..0cf11fc 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/coder/websocket v1.8.15 + github.com/iniwex5/quectel-qmi-go v0.6.0 go.bug.st/serial v1.6.4 golang.org/x/crypto v0.52.0 golang.org/x/sys v0.47.0 @@ -18,6 +19,8 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/stretchr/testify v1.10.0 // indirect + github.com/warthog618/sms v0.3.0 // indirect golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect modernc.org/libc v1.66.3 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 34c0320..c9ee80a 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,7 @@ github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNU github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0= github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -10,16 +11,25 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/iniwex5/quectel-qmi-go v0.6.0 h1:zWZc9jeNMy7+USFRBbfdShnjzSryyYnCw7NPw4ubaIg= +github.com/iniwex5/quectel-qmi-go v0.6.0/go.mod h1:6AlSY+Yj4MqJOsZ8cNrq99AzT9MlaopADnJtSRiyAfE= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/warthog618/sms v0.3.0 h1:LYAb5ngmu2qjNExgji3B7xi2tIZ9+DsuE9pC5xs4wwc= +github.com/warthog618/sms v0.3.0/go.mod h1:+bYZGeBxu003sxD5xhzsrIPBAjPBzTABsRTwSpd7ld4= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= @@ -37,6 +47,10 @@ golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.26.2 h1:991HMkLjJzYBIfha6ECZdjrIYz2/1ayr+FL8GN+CNzM= diff --git a/internal/device/controls.go b/internal/device/controls.go index c1a5c1f..dc986e4 100644 --- a/internal/device/controls.go +++ b/internal/device/controls.go @@ -277,6 +277,10 @@ func (manager *Manager) SetFlight( if manager.candidateFor(state).HardwareKind == "pcsc" { return FlightResult{PreviousMode: 4, CurrentMode: 4, FlightMode: true, RadioOff: true}, nil } + if result, handled, err := manager.setNativeQMIFlight(ctx, id, state, enabled); handled { + manager.setResult(id, state, nil, err) + return result, err + } client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) if err != nil { manager.setResult(id, state, nil, err) diff --git a/internal/device/controls_test.go b/internal/device/controls_test.go index d10d201..6eabb05 100644 --- a/internal/device/controls_test.go +++ b/internal/device/controls_test.go @@ -2,9 +2,175 @@ package device import ( "context" + "errors" "testing" + + "github.com/iniwex5/quectel-qmi-go/pkg/qmi" + + "vocat/internal/modem" ) +type fakeQMIRadioSession struct { + mode qmi.OperatingMode + getModes []qmi.OperatingMode + setModes []qmi.OperatingMode + getErr error + setErr error + closeCount int + iccid string + iccidErr error +} + +func (session *fakeQMIRadioSession) GetOperatingMode(context.Context) (qmi.OperatingMode, error) { + if len(session.getModes) > 0 { + mode := session.getModes[0] + session.getModes = session.getModes[1:] + return mode, session.getErr + } + return session.mode, session.getErr +} + +func (session *fakeQMIRadioSession) SetOperatingMode(_ context.Context, mode qmi.OperatingMode) error { + if session.setErr != nil { + return session.setErr + } + session.setModes = append(session.setModes, mode) + session.mode = mode + return nil +} + +func (session *fakeQMIRadioSession) Close() error { + session.closeCount++ + return nil +} + +func (session *fakeQMIRadioSession) GetICCID(context.Context) (string, error) { + return session.iccid, session.iccidErr +} + +func newStartedNativeQMITestManager(t *testing.T) (*Manager, *staticOpener, string) { + t.Helper() + const id = "wwan0" + opener := &staticOpener{client: &transcriptClient{}} + manager, err := NewManager(Options{ + Discoverer: staticDiscoverer{candidates: []modem.Candidate{{ + ID: id, + Product: "410 WiFi stick", + QMIControl: "/dev/wwan0qmi0", + NetworkInterface: "wwan0", + ATPort: modem.Port{ + Path: "/dev/wwan0at0", + Name: "wwan0at0", + Role: modem.PortRoleAT, + }, + }}}, + Opener: opener, + }) + if err != nil { + t.Fatalf("NewManager: %v", err) + } + if err := manager.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + manager.mu.Lock() + manager.devices[id].snapshot = &Snapshot{ + DeviceID: id, + OperatingMode: 7, + ModeKnown: true, + FlightMode: true, + RadioOff: true, + } + manager.mu.Unlock() + t.Cleanup(func() { _ = manager.Stop(context.Background()) }) + return manager, opener, id +} + +func TestSetFlightUsesQMIDMSForNativeWWAN(t *testing.T) { + manager, atOpener, id := newStartedNativeQMITestManager(t) + session := &fakeQMIRadioSession{mode: qmi.ModeOffline} + var openedPath string + manager.qmiRadioOpener = func(_ context.Context, path string) (qmiRadioSession, error) { + openedPath = path + return session, nil + } + + disabled, err := manager.SetFlight(context.Background(), id, false) + if err != nil { + t.Fatalf("disable flight mode: %v", err) + } + if !disabled.Changed || disabled.PreviousMode != 7 || disabled.CurrentMode != 1 || + disabled.FlightMode || disabled.RadioOff { + t.Fatalf("disable result = %#v", disabled) + } + enabled, err := manager.SetFlight(context.Background(), id, true) + if err != nil { + t.Fatalf("enable flight mode: %v", err) + } + if !enabled.Changed || enabled.PreviousMode != 1 || enabled.CurrentMode != 0 || + !enabled.FlightMode || !enabled.RadioOff { + t.Fatalf("enable result = %#v", enabled) + } + if openedPath != "/dev/wwan0qmi0" { + t.Fatalf("QMI path = %q", openedPath) + } + if len(session.setModes) != 2 || session.setModes[0] != qmi.ModeOnline || session.setModes[1] != qmi.ModeLowPower { + t.Fatalf("QMI modes = %v", session.setModes) + } + if session.closeCount != 2 { + t.Fatalf("QMI close count = %d", session.closeCount) + } + if atOpener.openCount != 0 { + t.Fatalf("AT opener used %d times for native QMI flight mode", atOpener.openCount) + } + entry, err := manager.Get(id) + if err != nil { + t.Fatal(err) + } + if entry.Snapshot == nil || entry.Snapshot.OperatingMode != 0 || !entry.Snapshot.FlightMode { + t.Fatalf("snapshot = %#v", entry.Snapshot) + } +} + +func TestSetFlightDoesNotFallBackToUnsupportedATWhenQMIUnavailable(t *testing.T) { + manager, atOpener, id := newStartedNativeQMITestManager(t) + wantErr := errors.New("QMI DMS unavailable") + manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) { + return nil, wantErr + } + + if _, err := manager.SetFlight(context.Background(), id, false); !errors.Is(err, wantErr) { + t.Fatalf("SetFlight error = %v, want %v", err, wantErr) + } + if atOpener.openCount != 0 { + t.Fatalf("AT opener used %d times after QMI failure", atOpener.openCount) + } +} + +func TestSetFlightWaitsForAsynchronousQMIModeTransition(t *testing.T) { + manager, atOpener, id := newStartedNativeQMITestManager(t) + session := &fakeQMIRadioSession{ + mode: qmi.ModeShutdown, + getModes: []qmi.OperatingMode{qmi.ModeShutdown, qmi.ModeShutdown, qmi.ModeOnline}, + } + manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) { + return session, nil + } + + result, err := manager.SetFlight(context.Background(), id, false) + if err != nil { + t.Fatalf("disable flight mode: %v", err) + } + if !result.Changed || result.PreviousMode != 7 || result.CurrentMode != 1 || result.FlightMode { + t.Fatalf("result = %#v", result) + } + if len(session.setModes) != 1 || session.setModes[0] != qmi.ModeOnline { + t.Fatalf("QMI modes = %v", session.setModes) + } + if atOpener.openCount != 0 { + t.Fatalf("AT opener used %d times during QMI transition", atOpener.openCount) + } +} + func TestSetFlightPreservesRawCFUNZero(t *testing.T) { client := &transcriptClient{steps: []clientStep{ {command: "AT+CFUN?", response: okResponse("+CFUN: 0")}, diff --git a/internal/device/data.go b/internal/device/data.go index ebf10b7..47542f1 100644 --- a/internal/device/data.go +++ b/internal/device/data.go @@ -99,6 +99,31 @@ func (manager *Manager) SetNetwork( if candidate.QMIControl == "" || candidate.NetworkInterface == "" { return NetworkResult{}, fmt.Errorf("%w: QMI control device and network interface are required", ErrDataBackendUnavailable) } + // OpenStick's native WWAN path must drive registration through QMI NAS. + // AT+COPS only updates the legacy AT facade on this firmware and can leave + // NAS in not-registered-searching, which then makes qmi-network report a + // generic-no-service call failure. + if request.Enabled && isNativeQMICandidate(candidate) { + registrationContext, cancel := context.WithTimeout(ctx, manager.scanTimeout) + registrationSession, openErr := manager.openNativeQMIRegistration(registrationContext, candidate) + if openErr != nil { + cancel() + manager.setResult(id, state, nil, openErr) + return NetworkResult{}, fmt.Errorf("prepare native QMI registration: %w", openErr) + } + registrationErr := ensureNativeQMIRegistration( + registrationContext, + registrationSession, + qmiRegistrationRequestAutomatic(), + true, + ) + _ = registrationSession.Close() + cancel() + if registrationErr != nil { + manager.setResult(id, state, nil, registrationErr) + return NetworkResult{}, registrationErr + } + } result, err := setQMINetwork(ctx, candidate, request.Enabled, apn, ipVersion, request.Username, request.Password, authentication) if err != nil && (request.Username != "" || request.Password != "") { // qmi-network output is outside our control and may echo values read @@ -272,6 +297,19 @@ func usbNetModeName(mode int) string { } func (manager *Manager) OperatorSelection(ctx context.Context, id string) (OperatorSelection, error) { + state, err := manager.lookup(id) + if err != nil { + return OperatorSelection{}, err + } + candidate := manager.candidateFor(state) + if isNativeQMICandidate(candidate) { + state.opMu.Lock() + defer state.opMu.Unlock() + if err := manager.validateActive(id, state); err != nil { + return OperatorSelection{}, err + } + return manager.nativeQMIOperatorSelectionLocked(ctx, candidate) + } response, err := manager.ExecuteAT(ctx, id, "AT+COPS?") if err != nil { return OperatorSelection{}, err @@ -335,6 +373,18 @@ func (manager *Manager) SetOperatorSelection( if err := manager.validateActive(id, state); err != nil { return OperatorSelection{}, err } + candidate := manager.candidateFor(state) + if isNativeQMICandidate(candidate) { + selection, err := manager.setNativeQMIOperatorSelectionLocked( + ctx, + candidate, + automatic, + plmn, + accessTechnologyValue, + ) + manager.setResult(id, state, nil, err) + return selection, err + } client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) if err != nil { manager.setResult(id, state, nil, err) @@ -435,6 +485,12 @@ func (manager *Manager) ReRegisterOperator(ctx context.Context, id string) (Oper if err := manager.validateActive(id, state); err != nil { return OperatorSelection{}, err } + candidate := manager.candidateFor(state) + if isNativeQMICandidate(candidate) { + selection, err := manager.reRegisterNativeQMIOperatorLocked(ctx, candidate) + manager.setResult(id, state, nil, err) + return selection, err + } client, err := manager.clientLocked(ctx, state, manager.candidateFor(state)) if err != nil { manager.setResult(id, state, nil, err) diff --git a/internal/device/flight_qmi.go b/internal/device/flight_qmi.go new file mode 100644 index 0000000..ce00689 --- /dev/null +++ b/internal/device/flight_qmi.go @@ -0,0 +1,375 @@ +package device + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + "github.com/iniwex5/quectel-qmi-go/pkg/qmi" + + "vocat/internal/qmiport" +) + +type qmiRadioSession interface { + GetOperatingMode(context.Context) (qmi.OperatingMode, error) + SetOperatingMode(context.Context, qmi.OperatingMode) error + Close() error +} + +type qmiRadioSessionOpener func(context.Context, string) (qmiRadioSession, error) + +type nativeQMIICCIDSession interface { + GetICCID(context.Context) (string, error) +} + +// nativeQMIControl identifies the QMI control node exposed by native WWAN +// devices. USB serial modems may also advertise a control path, but only the +// wwanN/qmiN pairing is safe to operate through the native QMI path. +func (manager *Manager) nativeQMIControl(id string) (string, bool, error) { + state, err := manager.lookup(id) + if err != nil { + return "", false, err + } + candidate := manager.candidateFor(state) + controlDevice := strings.TrimSpace(candidate.QMIControl) + deviceID := strings.TrimSpace(candidate.ID) + if !nativeQMIControlMatches(deviceID, controlDevice) { + return "", false, nil + } + return controlDevice, true, nil +} + +type productionQMIRadioSession struct { + client *qmi.Client + dms *qmi.DMSService + nas *qmi.NASService + nasErr error + uimMu sync.Mutex + uim *qmi.UIMService + lease *qmiport.Lease +} + +// The native WWAN path uses the same QMI NAS client for radio wake-up, +// operator selection, and registration. Keep these methods optional on the +// qmiRadioSession interface so the older transcript-backed tests and AT-only +// devices do not need to grow a fake NAS implementation. +func (session *productionQMIRadioSession) nasService() (*qmi.NASService, error) { + if session == nil { + return nil, errors.New("QMI NAS session is unavailable") + } + if session.nas == nil { + if session.nasErr != nil { + return nil, session.nasErr + } + return nil, errors.New("QMI NAS session is unavailable") + } + return session.nas, nil +} + +func (session *productionQMIRadioSession) GetServingSystem(ctx context.Context) (*qmi.ServingSystem, error) { + nas, err := session.nasService() + if err != nil { + return nil, err + } + return nas.GetServingSystem(ctx) +} + +func (session *productionQMIRadioSession) GetSystemSelectionPreference(ctx context.Context) (*qmi.SystemSelectionPreference, error) { + nas, err := session.nasService() + if err != nil { + return nil, err + } + return nas.GetSystemSelectionPreference(ctx) +} + +func (session *productionQMIRadioSession) SetSystemSelectionPreference(ctx context.Context, pref qmi.SystemSelectionPreference) error { + nas, err := session.nasService() + if err != nil { + return err + } + return nas.SetSystemSelectionPreference(ctx, pref) +} + +func (session *productionQMIRadioSession) InitiateNetworkRegister(ctx context.Context, req qmi.NASInitiateNetworkRegisterRequest) error { + nas, err := session.nasService() + if err != nil { + return err + } + return nas.InitiateNetworkRegister(ctx, req) +} + +func (session *productionQMIRadioSession) ForceNetworkSearch(ctx context.Context) error { + nas, err := session.nasService() + if err != nil { + return err + } + return nas.ForceNetworkSearch(ctx) +} + +func (session *productionQMIRadioSession) AttachDetach(ctx context.Context, attached bool) error { + nas, err := session.nasService() + if err != nil { + return err + } + return nas.AttachDetach(ctx, attached) +} + +// openQMIRadioSession controls native WWAN radios through QMI DMS. OpenStick +// 410 firmware rejects AT+CFUN=1 even though the equivalent DMS online request +// is supported, so native WWAN devices must not fall back to the AT path. +func openQMIRadioSession(ctx context.Context, controlDevice string) (qmiRadioSession, error) { + if ctx == nil { + ctx = context.Background() + } + openContext, cancel := context.WithTimeout(ctx, 15*time.Second) + defer cancel() + lease, err := qmiport.Acquire(openContext, controlDevice) + if err != nil { + return nil, err + } + opts := qmi.DefaultClientOptions() + opts.UseProxy = true + opts.Logf = func(qmi.ClientLogLevel, string, ...any) {} + client, err := qmi.NewClientWithOptions(openContext, controlDevice, opts) + if err != nil { + lease.Release() + return nil, err + } + dms, err := qmi.NewDMSServiceWithContext(openContext, client) + if err != nil { + _ = client.Close() + lease.Release() + return nil, err + } + // NAS is optional for ordinary radio controls. Some firmware exposes DMS + // but rejects NAS client allocation; keep radio control usable and report + // that limitation only to native registration/RF queries. + nas, nasErr := qmi.NewNASServiceWithContext(openContext, client) + return &productionQMIRadioSession{ + client: client, + dms: dms, + nas: nas, + nasErr: nasErr, + lease: lease, + }, nil +} + +func (session *productionQMIRadioSession) GetICCID(ctx context.Context) (string, error) { + if session == nil || session.client == nil { + return "", errors.New("QMI UIM session is unavailable") + } + session.uimMu.Lock() + defer session.uimMu.Unlock() + if session.uim == nil { + uim, err := qmi.NewUIMServiceWithContext(ctx, session.client) + if err != nil { + return "", err + } + session.uim = uim + } + return session.uim.GetICCID(ctx) +} + +func (session *productionQMIRadioSession) GetOperatingMode(ctx context.Context) (qmi.OperatingMode, error) { + return session.dms.GetOperatingMode(ctx) +} + +func (session *productionQMIRadioSession) SetOperatingMode(ctx context.Context, mode qmi.OperatingMode) error { + return session.dms.SetOperatingMode(ctx, mode) +} + +func (session *productionQMIRadioSession) Close() error { + if session == nil { + return nil + } + var closeErrors []error + session.uimMu.Lock() + if session.uim != nil { + closeErrors = append(closeErrors, session.uim.Close()) + session.uim = nil + } + session.uimMu.Unlock() + if session.dms != nil { + closeErrors = append(closeErrors, session.dms.Close()) + session.dms = nil + } + if session.nas != nil { + closeErrors = append(closeErrors, session.nas.Close()) + session.nas = nil + } + if session.client != nil { + closeErrors = append(closeErrors, session.client.Close()) + session.client = nil + } + if session.lease != nil { + session.lease.Release() + session.lease = nil + } + return errors.Join(closeErrors...) +} + +func (manager *Manager) setNativeQMIFlight( + ctx context.Context, + id string, + state *managedDevice, + enabled bool, +) (FlightResult, bool, error) { + controlDevice, native, err := manager.nativeQMIControl(id) + if err != nil { + return FlightResult{}, true, err + } + if !native { + return FlightResult{}, false, nil + } + if manager.qmiRadioOpener == nil { + return FlightResult{}, true, errors.New("QMI DMS radio control is unavailable") + } + if ctx == nil { + ctx = context.Background() + } + openContext, cancelOpen := manager.withTimeout(ctx, manager.commandTimeout*5) + session, err := manager.qmiRadioOpener(openContext, controlDevice) + cancelOpen() + if err != nil { + return FlightResult{}, true, fmt.Errorf("open QMI DMS radio control: %w", err) + } + defer session.Close() + + readContext, cancelRead := manager.withTimeout(ctx, manager.commandTimeout) + previousQMI, err := session.GetOperatingMode(readContext) + cancelRead() + if err != nil { + return FlightResult{}, true, fmt.Errorf("read QMI operating mode: %w", err) + } + previous := qmiModeAsCFUN(previousQMI) + targetQMI := previousQMI + if enabled { + if !isQMIRadioOffMode(previousQMI) { + targetQMI = qmi.ModeLowPower + } + } else if previousQMI != qmi.ModeOnline { + targetQMI = qmi.ModeOnline + } + changed := targetQMI != previousQMI + if changed { + setContext, cancelSet := manager.withTimeout(ctx, manager.commandTimeout) + err = session.SetOperatingMode(setContext, targetQMI) + cancelSet() + if err != nil { + return FlightResult{ + PreviousMode: previous, + CurrentMode: previous, + FlightMode: isQMIRadioOffMode(previousQMI), + RadioOff: isQMIRadioOffMode(previousQMI), + }, true, fmt.Errorf("set QMI operating mode: %w", err) + } + } + currentQMI, err := manager.waitForQMIRadioState(ctx, session, enabled, targetQMI) + if err != nil { + currentRadioOff := isQMIRadioOffMode(currentQMI) + return FlightResult{ + PreviousMode: previous, + CurrentMode: qmiModeAsCFUN(currentQMI), + Changed: changed, + FlightMode: currentRadioOff, + RadioOff: currentRadioOff, + }, true, err + } + current := qmiModeAsCFUN(currentQMI) + currentRadioOff := isQMIRadioOffMode(currentQMI) + manager.updateSnapshotMode(id, state, current) + if !enabled && !currentRadioOff { + // DMS Online is only the radio half of the recovery. Continue with a + // background NAS registration/PS-attach reconcile after the flight-mode + // transition without holding the radio QMI session open. + manager.startNativeQMIRegistrationReconcile(id) + } + return FlightResult{ + PreviousMode: previous, + CurrentMode: current, + Changed: changed, + FlightMode: currentRadioOff, + RadioOff: currentRadioOff, + }, true, nil +} + +func (manager *Manager) waitForQMIRadioState( + ctx context.Context, + session qmiRadioSession, + radioOff bool, + fallback qmi.OperatingMode, +) (qmi.OperatingMode, error) { + verifyTimeout := manager.commandTimeout * 2 + if verifyTimeout < 5*time.Second { + verifyTimeout = 5 * time.Second + } + verifyContext, cancel := manager.withTimeout(ctx, verifyTimeout) + defer cancel() + current := fallback + var lastErr error + for { + mode, err := session.GetOperatingMode(verifyContext) + if err == nil { + current = mode + lastErr = nil + if qmiModeMatchesFlight(mode, radioOff) { + return mode, nil + } + } else { + lastErr = err + } + timer := time.NewTimer(250 * time.Millisecond) + select { + case <-verifyContext.Done(): + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + if lastErr != nil { + return current, fmt.Errorf("verify QMI operating mode: %w", lastErr) + } + return current, fmt.Errorf( + "QMI operating mode did not reach requested radio state (mode %d): %w", + current, + verifyContext.Err(), + ) + case <-timer.C: + } + } +} + +func qmiModeMatchesFlight(mode qmi.OperatingMode, radioOff bool) bool { + if radioOff { + return isQMIRadioOffMode(mode) + } + return mode == qmi.ModeOnline +} + +func isQMIRadioOffMode(mode qmi.OperatingMode) bool { + switch mode { + case qmi.ModeLowPower, qmi.ModeOffline, qmi.ModeShutdown, qmi.ModePersistLow, qmi.ModeOnlyLowPower: + return true + default: + return false + } +} + +// FlightResult and Snapshot historically expose AT+CFUN values. Preserve that +// API contract while sourcing the real radio state from QMI DMS. +func qmiModeAsCFUN(mode qmi.OperatingMode) int { + switch mode { + case qmi.ModeOnline: + return 1 + case qmi.ModeLowPower, qmi.ModePersistLow: + return 0 + case qmi.ModeOffline, qmi.ModeShutdown, qmi.ModeOnlyLowPower: + return 7 + default: + return 1 + } +} diff --git a/internal/device/identity_qmi.go b/internal/device/identity_qmi.go new file mode 100644 index 0000000..5762c40 --- /dev/null +++ b/internal/device/identity_qmi.go @@ -0,0 +1,39 @@ +package device + +import ( + "context" + "errors" + "fmt" + + "vocat/internal/modem" +) + +func (manager *Manager) readNativeQMIICCID(ctx context.Context, candidate modem.Candidate) (string, error) { + if manager == nil || manager.qmiRadioOpener == nil { + return "", errors.New("QMI UIM ICCID reader is unavailable") + } + if candidate.QMIControl == "" { + return "", errors.New("QMI UIM control device is unavailable") + } + session, err := manager.qmiRadioOpener(ctx, candidate.QMIControl) + if err != nil { + return "", fmt.Errorf("open QMI UIM control: %w", err) + } + if session == nil { + return "", errors.New("QMI UIM control returned an empty session") + } + defer session.Close() + reader, ok := session.(nativeQMIICCIDSession) + if !ok { + return "", errors.New("QMI session does not expose UIM ICCID reading") + } + value, err := reader.GetICCID(ctx) + if err != nil { + return "", fmt.Errorf("read EF_ICCID: %w", err) + } + iccid := parseICCIDIdentifier(modem.Response{Lines: []string{value}}, nil, 18, 22) + if iccid == "" { + return "", errors.New("QMI UIM returned an invalid ICCID") + } + return iccid, nil +} diff --git a/internal/device/manager.go b/internal/device/manager.go index 139c5a4..ab07697 100644 --- a/internal/device/manager.go +++ b/internal/device/manager.go @@ -38,9 +38,14 @@ type Manager struct { smsTimeout time.Duration scanTimeout time.Duration cardReaders *pcsc.Service - started bool - devices map[string]*managedDevice - ussdSessions map[string]ussdSession + + qmiRadioOpener qmiRadioSessionOpener + nativeQMIRegistrationMu sync.Mutex + nativeQMIRegistrationInFlight map[string]struct{} + + started bool + devices map[string]*managedDevice + ussdSessions map[string]ussdSession } // LockUICC and UnlockUICC allow another in-process UICC client (currently the @@ -115,6 +120,10 @@ func NewManager(options Options) (*Manager, error) { smsTimeout: options.SMSTimeout, scanTimeout: options.ScanTimeout, cardReaders: options.CardReaders, + + qmiRadioOpener: openQMIRadioSession, + nativeQMIRegistrationInFlight: make(map[string]struct{}), + devices: make(map[string]*managedDevice), ussdSessions: make(map[string]ussdSession), esimRecoveries: make(map[string]chan struct{}), diff --git a/internal/device/manager_test.go b/internal/device/manager_test.go index 7a161b2..c141374 100644 --- a/internal/device/manager_test.go +++ b/internal/device/manager_test.go @@ -141,6 +141,59 @@ func TestManagerRefreshBuildsEC20Snapshot(t *testing.T) { client.assertDone(t) } +func TestManagerRefreshReadsNativeWWANICCIDThroughQMIUIM(t *testing.T) { + client := &transcriptClient{steps: []clientStep{ + {command: "ATI", response: okResponse("Qualcomm", "PCIe/MHI WWAN modem", "Revision: native-410")}, + {command: "AT+CPIN?", response: okResponse("+CPIN: READY")}, + {command: "AT+CCID", response: modem.Response{Final: "ERROR"}, err: errors.New("CCID unsupported")}, + {command: "AT+QCCID", response: modem.Response{Final: "ERROR"}, err: errors.New("QCCID unsupported")}, + {command: "AT+CIMI", response: okResponse("234159611274418")}, + {command: "AT+CRSM=176,28486,0,0,17", response: okResponse(`+CRSM: 106,130,""`)}, + {command: "AT+CRSM=192,28589,0,0,0", response: okResponse(`+CRSM: 106,130,""`)}, + {command: "AT+CRSM=192,28478,0,0,0", response: okResponse(`+CRSM: 106,130,""`)}, + {command: "AT+CRSM=192,28479,0,0,0", response: okResponse(`+CRSM: 106,130,""`)}, + {command: "AT+CSQ", response: okResponse("+CSQ: 99,99")}, + {command: `AT+QENG="servingcell"`, response: okResponse(`+QENG: "servingcell","SEARCH"`)}, + {command: "AT+COPS?", response: okResponse("+COPS: 0")}, + {command: "AT+CEREG?", response: okResponse("+CEREG: 0,2")}, + {command: "AT+CGSN", response: okResponse("867123456789012")}, + {command: "AT+CFUN?", response: okResponse("+CFUN: 1")}, + {command: "AT+CNUM", response: okResponse(`+CNUM: "","+8613800138000",145`)}, + }} + manager, err := NewManager(Options{ + Discoverer: staticDiscoverer{candidates: []modem.Candidate{{ + ID: "mhi-wwan0", + Product: "PCIe/MHI WWAN modem", + QMIControl: "/dev/wwan0qmi0", + NetworkInterface: "wwan0", + ATPort: modem.Port{Path: "/dev/wwan0at0", Name: "wwan0at0", Role: modem.PortRoleAT}, + }}}, + Opener: &staticOpener{client: client}, + }) + if err != nil { + t.Fatal(err) + } + if err := manager.Start(context.Background()); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = manager.Stop(context.Background()) }) + manager.qmiRadioOpener = func(context.Context, string) (qmiRadioSession, error) { + return &fakeQMIRadioSession{iccid: "89441000400316034372"}, nil + } + if err := manager.SetBackend("mhi-wwan0", "qmi"); err != nil { + t.Fatal(err) + } + + snapshot, err := manager.Refresh(context.Background(), "mhi-wwan0") + if err != nil { + t.Fatalf("Refresh: %v", err) + } + if snapshot.ICCID != "89441000400316034372" || !snapshot.SIMReady { + t.Fatalf("native QMI identity = %#v", snapshot) + } + client.assertDone(t) +} + func TestParseSPNASCIIAndUCS2(t *testing.T) { if got := parseSPN(okResponse(`+CRSM: 144,0,"004C6562617261FFFFFFFFFFFFFFFFFFFF"`)); got != "Lebara" { t.Fatalf("ASCII SPN = %q", got) diff --git a/internal/device/registration_native_qmi.go b/internal/device/registration_native_qmi.go new file mode 100644 index 0000000..735ca57 --- /dev/null +++ b/internal/device/registration_native_qmi.go @@ -0,0 +1,702 @@ +package device + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/iniwex5/quectel-qmi-go/pkg/qmi" + + "vocat/internal/modem" +) + +// nativeQMIRegistrationSession is the QMI NAS control surface used by +// OpenStick WWAN devices. It deliberately stays separate from +// qmiRadioSession so AT-only devices and existing radio-control fakes do not +// acquire a mandatory NAS implementation. +type nativeQMIRegistrationSession interface { + qmiRadioSession + GetServingSystem(context.Context) (*qmi.ServingSystem, error) + GetSystemSelectionPreference(context.Context) (*qmi.SystemSelectionPreference, error) + SetSystemSelectionPreference(context.Context, qmi.SystemSelectionPreference) error + InitiateNetworkRegister(context.Context, qmi.NASInitiateNetworkRegisterRequest) error + ForceNetworkSearch(context.Context) error + AttachDetach(context.Context, bool) error +} + +const ( + nativeQMIRegistrationPollInterval = 2 * time.Second + nativeQMIRegistrationMaxAttempts = 45 + nativeQMIRegistrationRadioCycleAfterAttempts = 30 + nativeQMIRegistrationUnsupportedCycleAfterTries = 3 + nativeQMIRegistrationBackgroundTimeout = 45 * time.Second +) + +func isNativeQMICandidate(candidate modem.Candidate) bool { + deviceID := strings.TrimSpace(candidate.ID) + control := strings.TrimSpace(candidate.QMIControl) + return nativeQMIControlMatches(deviceID, control) +} + +func nativeQMIControlMatches(deviceID, control string) bool { + deviceID = strings.TrimSpace(deviceID) + control = strings.TrimSpace(control) + if deviceID == "" || control == "" { + return false + } + prefix := "" + switch { + case strings.HasPrefix(deviceID, "wwan"): + prefix = deviceID + "qmi" + case strings.HasPrefix(deviceID, "mhi-wwan"): + prefix = "wwan" + strings.TrimPrefix(deviceID, "mhi-wwan") + "qmi" + default: + return false + } + return strings.HasPrefix(filepath.Base(control), prefix) +} + +func (manager *Manager) openNativeQMIRegistration( + ctx context.Context, + candidate modem.Candidate, +) (nativeQMIRegistrationSession, error) { + if manager == nil || manager.qmiRadioOpener == nil { + return nil, errors.New("QMI NAS registration is unavailable") + } + control := strings.TrimSpace(candidate.QMIControl) + if control == "" { + return nil, errors.New("QMI NAS registration control device is unavailable") + } + session, err := manager.qmiRadioOpener(ctx, control) + if err != nil { + return nil, err + } + nas, ok := session.(nativeQMIRegistrationSession) + if !ok { + _ = session.Close() + return nil, errors.New("QMI radio session does not expose NAS registration control") + } + return nas, nil +} + +// startNativeQMIRegistrationReconcile continues registration after a radio +// transition. Bringing DMS online only proves that the RF switch completed; +// NAS may still report searching or PS detached seconds later, so the +// registration sequence continues after SetFlight returns. The per-device +// guard prevents repeated UI/poll callbacks from opening competing sessions. +func (manager *Manager) startNativeQMIRegistrationReconcile(id string) bool { + if manager == nil { + return false + } + state, err := manager.lookup(id) + if err != nil { + return false + } + candidate := manager.candidateFor(state) + if !isNativeQMICandidate(candidate) { + return false + } + manager.nativeQMIRegistrationMu.Lock() + if _, running := manager.nativeQMIRegistrationInFlight[id]; running { + manager.nativeQMIRegistrationMu.Unlock() + return false + } + manager.nativeQMIRegistrationInFlight[id] = struct{}{} + manager.nativeQMIRegistrationMu.Unlock() + go func() { + defer func() { + manager.nativeQMIRegistrationMu.Lock() + delete(manager.nativeQMIRegistrationInFlight, id) + manager.nativeQMIRegistrationMu.Unlock() + }() + ctx, cancel := context.WithTimeout(context.Background(), nativeQMIRegistrationBackgroundTimeout) + defer cancel() + _, _ = manager.ReRegisterOperator(ctx, id) + }() + return true +} + +func qmiOperatorSelectionFromPreference(pref *qmi.SystemSelectionPreference) (OperatorSelection, error) { + if pref == nil { + return OperatorSelection{}, errors.New("QMI returned an empty system-selection preference") + } + accessTechnology := qmiAccessTechnologyFromModePreference(pref.ModePreference) + if pref.HasManualNetworkSelection { + mcc := fmt.Sprintf("%03d", pref.ManualNetworkSelection.MCC) + mncWidth := 2 + if pref.ManualNetworkSelection.IncludesPCSDigit { + mncWidth = 3 + } + mnc := fmt.Sprintf("%0*d", mncWidth, pref.ManualNetworkSelection.MNC) + return OperatorSelection{ + Mode: 1, + Format: 2, + Operator: mcc + mnc, + AccessTechnology: accessTechnology, + }, nil + } + return OperatorSelection{Mode: 0, AccessTechnology: accessTechnology}, nil +} + +func qmiManualRegisterRequest( + plmn string, + accessTechnologyValue *int, +) (qmi.NASInitiateNetworkRegisterRequest, error) { + mcc, mnc, includesPCSDigit, err := qmiPLMNParts(plmn) + if err != nil { + return qmi.NASInitiateNetworkRegisterRequest{}, err + } + rat := uint8(0) + if accessTechnologyValue != nil { + if *accessTechnologyValue < 0 || *accessTechnologyValue > 9 { + return qmi.NASInitiateNetworkRegisterRequest{}, errors.New("invalid operator access technology") + } + rat = qmiRATFromATCode(*accessTechnologyValue) + if rat == 0 { + return qmi.NASInitiateNetworkRegisterRequest{}, errors.New("unsupported operator access technology") + } + } + return qmi.NASInitiateNetworkRegisterRequest{ + Mode: qmi.NASNetworkRegisterManual, + MCC: mcc, + MNC: mnc, + IncludesPCSDigit: includesPCSDigit, + RadioAccessTech: rat, + ChangeDuration: qmi.NASChangeDurationPermanent, + HasChangeDuration: true, + }, nil +} + +func qmiPLMNParts(plmn string) (mcc, mnc uint16, includesPCSDigit bool, err error) { + plmn = strings.TrimSpace(plmn) + if !decimalPLMN(plmn) { + return 0, 0, false, errors.New("operator PLMN must contain 5 or 6 digits") + } + mccValue, parseErr := strconv.ParseUint(plmn[:3], 10, 16) + if parseErr != nil { + return 0, 0, false, fmt.Errorf("parse operator MCC: %w", parseErr) + } + mncValue, parseErr := strconv.ParseUint(plmn[3:], 10, 16) + if parseErr != nil { + return 0, 0, false, fmt.Errorf("parse operator MNC: %w", parseErr) + } + return uint16(mccValue), uint16(mncValue), len(plmn) == 6, nil +} + +func qmiManualSelectionPreference(plmn string) (qmi.SystemSelectionPreference, qmi.ManualNetworkSelection, error) { + return qmiManualSelectionPreferenceWithRAT(plmn, nil) +} + +func qmiManualSelectionPreferenceWithRAT( + plmn string, + accessTechnologyValue *int, +) (qmi.SystemSelectionPreference, qmi.ManualNetworkSelection, error) { + mcc, mnc, includesPCSDigit, err := qmiPLMNParts(plmn) + if err != nil { + return qmi.SystemSelectionPreference{}, qmi.ManualNetworkSelection{}, err + } + selection := qmi.ManualNetworkSelection{ + MCC: mcc, + MNC: mnc, + IncludesPCSDigit: includesPCSDigit, + } + pref := qmi.SystemSelectionPreference{ + NetworkSelectionPreference: qmi.NASNetworkSelectionManual, + HasNetworkSelectionPreference: true, + ManualNetworkSelection: selection, + HasManualNetworkSelection: true, + ChangeDuration: qmi.NASChangeDurationPermanent, + HasChangeDuration: true, + } + if accessTechnologyValue != nil { + modePreference, ok := qmiModePreferenceFromATCode(*accessTechnologyValue) + if !ok { + return qmi.SystemSelectionPreference{}, qmi.ManualNetworkSelection{}, errors.New("unsupported operator access technology") + } + pref.ModePreference = modePreference + pref.HasModePreference = true + } + return pref, selection, nil +} + +func qmiRATFromATCode(value int) uint8 { + switch value { + case 0, 3: // GSM / EDGE + return 0x04 + case 2, 4, 5, 6: // UTRAN / HSDPA / HSUPA / HSPA + return 0x05 + case 7: // LTE + return 0x08 + case 9: // NR5G + return 0x0C + default: + return 0 + } +} + +func qmiModePreferenceFromATCode(value int) (uint16, bool) { + switch value { + case 0, 3: // GSM / EDGE + return qmi.NASRatModePreferenceGSM, true + case 2, 4, 5, 6: // UTRAN / HSDPA / HSUPA / HSPA + return qmi.NASRatModePreferenceUMTS, true + case 7: // LTE + return qmi.NASRatModePreferenceLTE, true + case 9: // NR5G + return qmi.NASRatModePreferenceNR5G, true + default: + return 0, false + } +} + +func qmiRATFromServingRadioInterface(value uint8) uint8 { + switch value { + case 4, 5, 8: + return value + case 10: // NAS serving-system NR5G value + return 0x0C + default: + return 0 + } +} + +func qmiRATFromModePreference(value uint16) uint8 { + switch { + case value&qmi.NASRatModePreferenceNR5G != 0: + return 0x0C + case value&qmi.NASRatModePreferenceLTE != 0: + return 0x08 + case value&qmi.NASRatModePreferenceUMTS != 0: + return 0x05 + case value&qmi.NASRatModePreferenceGSM != 0: + return 0x04 + default: + return 0 + } +} + +func qmiAccessTechnologyFromModePreference(value uint16) string { + switch { + case value&qmi.NASRatModePreferenceNR5G != 0: + return "NR5G" + case value&qmi.NASRatModePreferenceLTE != 0: + return "LTE" + case value&qmi.NASRatModePreferenceUMTS != 0: + return "UTRAN" + case value&qmi.NASRatModePreferenceGSM != 0: + return "GSM" + default: + return "" + } +} + +func qmiRegistrationRequestAutomatic() qmi.NASInitiateNetworkRegisterRequest { + return qmi.NASInitiateNetworkRegisterRequest{ + Mode: qmi.NASNetworkRegisterAutomatic, + ChangeDuration: qmi.NASChangeDurationPermanent, + HasChangeDuration: true, + } +} + +func qmiSelectionAutomaticPreference() qmi.SystemSelectionPreference { + return qmi.SystemSelectionPreference{ + NetworkSelectionPreference: qmi.NASNetworkSelectionAutomatic, + HasNetworkSelectionPreference: true, + ChangeDuration: qmi.NASChangeDurationPermanent, + HasChangeDuration: true, + } +} + +func isUnsupportedQMIRegistrationCommand(err error, messageID uint16) bool { + qmiErr := qmi.GetQMIError(err) + if qmiErr == nil || qmiErr.Service != qmi.ServiceNAS || qmiErr.MessageID != messageID { + return false + } + switch qmiErr.ErrorCode { + case qmi.QMIErrMalformedMsg, + qmi.QMIErrInvalidRegisterAction, + qmi.QMIErrNoEffect, + qmi.QMIErrNotSupported, + qmi.QMIErrInvalidQmiCmd, + qmi.QMIErrOpDeviceUnsupported: + return true + default: + return false + } +} + +func isUnsupportedQMIForceSearch(err error) bool { + qmiErr := qmi.GetQMIError(err) + if qmiErr == nil || qmiErr.Service != qmi.ServiceNAS || qmiErr.MessageID != qmi.NASForceNetworkSearch { + return false + } + return qmiErr.ErrorCode == qmi.QMIErrNotSupported || + qmiErr.ErrorCode == qmi.QMIErrInvalidQmiCmd || + qmiErr.ErrorCode == qmi.QMIErrOpDeviceUnsupported +} + +func isUnsupportedQMISelectionCommand(err error) bool { + qmiErr := qmi.GetQMIError(err) + if qmiErr == nil || qmiErr.Service != qmi.ServiceNAS || qmiErr.MessageID != qmi.NASSetSystemSelectionPreference { + return false + } + switch qmiErr.ErrorCode { + case qmi.QMIErrMalformedMsg, + qmi.QMIErrInvalidRegisterAction, + qmi.QMIErrNoEffect, + qmi.QMIErrNotSupported, + qmi.QMIErrInvalidQmiCmd, + qmi.QMIErrOpDeviceUnsupported: + return true + default: + return false + } +} + +func qmiRegistrationStateRegistered(state qmi.RegistrationState) bool { + return state == qmi.RegStateRegistered || state == qmi.RegStateRoaming +} + +func nativeQMIRegistrationRadioCycleThreshold(forceSearchUnsupported bool) int { + if forceSearchUnsupported { + return nativeQMIRegistrationUnsupportedCycleAfterTries + } + return nativeQMIRegistrationRadioCycleAfterAttempts +} + +// triggerNativeQMIManualRegistration applies the manual preference that was +// written by the caller and starts a fresh NAS search. On the OpenStick 410 +// firmware, NAS_FORCE_NETWORK_SEARCH is the reliable trigger; sending +// NAS_INITIATE_NETWORK_REGISTER with RadioAccessTech=0 is rejected as an +// invalid profile. Older firmware may not expose force-search, so fall back +// to an explicit RAT (or the current serving RAT) when that command is not +// supported. +func triggerNativeQMIManualRegistration( + ctx context.Context, + session nativeQMIRegistrationSession, + request *qmi.NASInitiateNetworkRegisterRequest, + serving *qmi.ServingSystem, +) (forceSearchIssued bool, forceSearchUnsupported bool, err error) { + if request == nil { + return false, false, errors.New("QMI manual registration request is unavailable") + } + if err := session.ForceNetworkSearch(ctx); err == nil { + return true, false, nil + } else if !isUnsupportedQMIForceSearch(err) { + return false, false, fmt.Errorf("force QMI network search: %w", err) + } + + forceSearchUnsupported = true + if request.RadioAccessTech == 0 && serving != nil { + request.RadioAccessTech = qmiRATFromServingRadioInterface(serving.RadioInterface) + } + if request.RadioAccessTech == 0 { + return false, true, errors.New("QMI manual registration requires a supported radio access technology") + } + if err := session.InitiateNetworkRegister(ctx, *request); err != nil { + return false, true, fmt.Errorf("initiate manual QMI network registration: %w", err) + } + return false, true, nil +} + +// ensureNativeQMIRegistration runs the NAS registration sequence used on +// OpenStick. The modem's AT+COPS surface on this firmware only changes +// presentation; it does not reliably drive this NAS state machine. +func ensureNativeQMIRegistration( + ctx context.Context, + session nativeQMIRegistrationSession, + request qmi.NASInitiateNetworkRegisterRequest, + setAutomatic bool, +) error { + return ensureNativeQMIRegistrationForTarget(ctx, session, request, setAutomatic, nil) +} + +// ensureNativeQMIRegistrationForTarget is the manual-lock variant of the +// registration sequence. A modem can remain registered on the old PLMN while +// it processes a new manual request, so a successful registered/PS-attached +// state is only authoritative when it is on the requested PLMN. +func ensureNativeQMIRegistrationForTarget( + ctx context.Context, + session nativeQMIRegistrationSession, + request qmi.NASInitiateNetworkRegisterRequest, + setAutomatic bool, + target *qmi.ManualNetworkSelection, +) error { + if ctx == nil { + ctx = context.Background() + } + if session == nil { + return errors.New("QMI NAS registration session is unavailable") + } + if request.Mode == 0 { + request = qmiRegistrationRequestAutomatic() + } + + mode, err := session.GetOperatingMode(ctx) + if err != nil { + return fmt.Errorf("read QMI operating mode: %w", err) + } + if mode == qmi.ModeLowPower || mode == qmi.ModeOffline || mode == qmi.ModeShutdown || mode == qmi.ModeReset { + if err := session.SetOperatingMode(ctx, qmi.ModeOnline); err != nil { + return fmt.Errorf("restore QMI online mode: %w", err) + } + if err := waitNativeQMIRegistration(ctx); err != nil { + return fmt.Errorf("wait for QMI online mode: %w", err) + } + mode, err = session.GetOperatingMode(ctx) + if err != nil { + return fmt.Errorf("recheck QMI operating mode: %w", err) + } + if mode == qmi.ModeLowPower || mode == qmi.ModeOffline || mode == qmi.ModeShutdown || mode == qmi.ModeReset { + return fmt.Errorf("QMI operating mode remained non-online after recovery: %d", mode) + } + } + + if setAutomatic { + if err := session.SetSystemSelectionPreference(ctx, qmiSelectionAutomaticPreference()); err != nil { + // Some OpenStick firmware accepts the preference but reports an + // unsupported result for an optional NAS TLV. The explicit NAS register + // below remains the authoritative trigger. + if !isUnsupportedQMISelectionCommand(err) { + return fmt.Errorf("restore automatic QMI NAS selection: %w", err) + } + } + } + registerIssued := false + forceSearchIssued := false + radioCycleIssued := false + forceSearchUnsupported := false + manualTarget := target != nil && request.Mode == qmi.NASNetworkRegisterManual + for attempt := 1; attempt <= nativeQMIRegistrationMaxAttempts; attempt++ { + serving, servingErr := session.GetServingSystem(ctx) + if servingErr != nil { + if err := waitNativeQMIRegistration(ctx); err != nil { + return fmt.Errorf("read QMI serving system: %w", servingErr) + } + continue + } + if serving == nil { + return errors.New("QMI serving system returned no data") + } + if qmiRegistrationStateRegistered(serving.RegistrationState) { + if target == nil || qmiServingSystemMatchesTarget(serving, *target) { + if serving.PSAttached { + return nil + } + if err := session.AttachDetach(ctx, true); err != nil { + return fmt.Errorf("attach QMI packet service: %w", err) + } + } else if !registerIssued { + if manualTarget { + var triggerErr error + forceSearchIssued, forceSearchUnsupported, triggerErr = triggerNativeQMIManualRegistration( + ctx, session, &request, serving, + ) + if triggerErr != nil { + return triggerErr + } + } else if err := session.InitiateNetworkRegister(ctx, request); err != nil { + return fmt.Errorf("initiate QMI network registration: %w", err) + } + registerIssued = true + } + } else if serving.RegistrationState == qmi.RegStateDenied { + return errors.New("QMI network registration was denied") + } else if !registerIssued { + if manualTarget { + var triggerErr error + forceSearchIssued, forceSearchUnsupported, triggerErr = triggerNativeQMIManualRegistration( + ctx, session, &request, serving, + ) + if triggerErr != nil { + return triggerErr + } + } else if err := session.InitiateNetworkRegister(ctx, request); err != nil { + if !(setAutomatic && isUnsupportedQMIRegistrationCommand(err, qmi.NASInitiateNetworkRegister)) { + return fmt.Errorf("initiate QMI network registration: %w", err) + } + } + registerIssued = true + } + + searching := serving.RegistrationState == qmi.RegStateSearching + if target != nil && qmiRegistrationStateRegistered(serving.RegistrationState) && !qmiServingSystemMatchesTarget(serving, *target) { + searching = true + } + if searching && registerIssued && !forceSearchIssued && !forceSearchUnsupported && attempt >= 2 { + forceSearchIssued = true + if err := session.ForceNetworkSearch(ctx); err != nil { + if isUnsupportedQMIForceSearch(err) { + forceSearchUnsupported = true + } else { + return fmt.Errorf("force QMI network search: %w", err) + } + } + } + radioCycleAfter := nativeQMIRegistrationRadioCycleThreshold(forceSearchUnsupported) + if searching && registerIssued && !radioCycleIssued && attempt >= radioCycleAfter { + radioCycleIssued = true + if err := session.SetOperatingMode(ctx, qmi.ModeLowPower); err == nil { + _ = waitNativeQMIRegistration(ctx) + _ = session.SetOperatingMode(ctx, qmi.ModeOnline) + registerIssued = false + } + } + if err := waitNativeQMIRegistration(ctx); err != nil { + return err + } + } + return fmt.Errorf("QMI network registration/PS attach timed out after %d attempts", nativeQMIRegistrationMaxAttempts) +} + +func qmiServingSystemMatchesTarget(serving *qmi.ServingSystem, target qmi.ManualNetworkSelection) bool { + return serving != nil && serving.MCC == target.MCC && serving.MNC == target.MNC +} + +func waitNativeQMIRegistration(ctx context.Context) error { + timer := time.NewTimer(nativeQMIRegistrationPollInterval) + defer timer.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return nil + } +} + +func (manager *Manager) nativeQMIOperatorSelectionLocked( + ctx context.Context, + candidate modem.Candidate, +) (OperatorSelection, error) { + session, err := manager.openNativeQMIRegistration(ctx, candidate) + if err != nil { + return OperatorSelection{}, fmt.Errorf("open QMI NAS operator selection: %w", err) + } + defer session.Close() + pref, err := session.GetSystemSelectionPreference(ctx) + if err != nil { + return OperatorSelection{}, fmt.Errorf("read QMI system selection preference: %w", err) + } + return qmiOperatorSelectionFromPreference(pref) +} + +func (manager *Manager) setNativeQMIOperatorSelectionLocked( + ctx context.Context, + candidate modem.Candidate, + automatic bool, + plmn string, + accessTechnologyValue *int, +) (OperatorSelection, error) { + session, err := manager.openNativeQMIRegistration(ctx, candidate) + if err != nil { + return OperatorSelection{}, fmt.Errorf("open QMI NAS operator selection: %w", err) + } + defer session.Close() + + if automatic { + request := qmiRegistrationRequestAutomatic() + if err := ensureNativeQMIRegistration(ctx, session, request, true); err != nil { + return OperatorSelection{}, err + } + pref, err := session.GetSystemSelectionPreference(ctx) + if err != nil { + return OperatorSelection{}, fmt.Errorf("read QMI system selection preference: %w", err) + } + return qmiOperatorSelectionFromPreference(pref) + } + request, err := qmiManualRegisterRequest(plmn, accessTechnologyValue) + if err != nil { + return OperatorSelection{}, err + } + preference, target, err := qmiManualSelectionPreferenceWithRAT(plmn, accessTechnologyValue) + if err != nil { + return OperatorSelection{}, err + } + // InitiateNetworkRegister is only a one-shot trigger on this firmware. The + // manual preference must be written separately or the next reconcile will + // read automatic selection and undo the requested lock. + if err := session.SetSystemSelectionPreference(ctx, preference); err != nil { + return OperatorSelection{}, fmt.Errorf("set manual QMI network selection: %w", err) + } + if err := ensureNativeQMIRegistrationForTarget(ctx, session, request, false, &target); err != nil { + manager.restoreNativeQMISelectionAfterFailure(session, candidate.ID) + return OperatorSelection{}, err + } + actual, err := session.GetSystemSelectionPreference(ctx) + if err != nil { + manager.restoreNativeQMISelectionAfterFailure(session, candidate.ID) + return OperatorSelection{}, fmt.Errorf("verify manual QMI network selection: %w", err) + } + if actual == nil || !actual.HasManualNetworkSelection || actual.ManualNetworkSelection != target { + manager.restoreNativeQMISelectionAfterFailure(session, candidate.ID) + return OperatorSelection{}, fmt.Errorf("modem did not retain manual PLMN %s", strings.TrimSpace(plmn)) + } + return qmiOperatorSelectionFromPreference(actual) +} + +// restoreNativeQMISelectionAfterFailure prevents a failed manual lock from +// leaving the modem in a searching/manual state. The caller may already have +// exhausted its request deadline, so rollback uses a fresh bounded context and +// schedules the normal background reconcile as a second line of defence. +func (manager *Manager) restoreNativeQMISelectionAfterFailure( + session nativeQMIRegistrationSession, + deviceID string, +) { + if manager == nil || session == nil { + return + } + rollbackCtx, cancel := context.WithTimeout(context.Background(), manager.longTimeout) + defer cancel() + _ = session.SetSystemSelectionPreference(rollbackCtx, qmiSelectionAutomaticPreference()) + _ = session.InitiateNetworkRegister(rollbackCtx, qmiRegistrationRequestAutomatic()) + _ = session.ForceNetworkSearch(rollbackCtx) + if strings.TrimSpace(deviceID) != "" { + manager.startNativeQMIRegistrationReconcile(deviceID) + } +} + +func (manager *Manager) reRegisterNativeQMIOperatorLocked( + ctx context.Context, + candidate modem.Candidate, +) (OperatorSelection, error) { + session, err := manager.openNativeQMIRegistration(ctx, candidate) + if err != nil { + return OperatorSelection{}, fmt.Errorf("open QMI NAS re-registration: %w", err) + } + defer session.Close() + pref, err := session.GetSystemSelectionPreference(ctx) + if err != nil { + return OperatorSelection{}, fmt.Errorf("read QMI system selection preference: %w", err) + } + request := qmiRegistrationRequestAutomatic() + setAutomatic := true + selection := OperatorSelection{Mode: 0} + if pref != nil && pref.HasManualNetworkSelection { + setAutomatic = false + request.Mode = qmi.NASNetworkRegisterManual + request.MCC = pref.ManualNetworkSelection.MCC + request.MNC = pref.ManualNetworkSelection.MNC + request.IncludesPCSDigit = pref.ManualNetworkSelection.IncludesPCSDigit + request.ChangeDuration = qmi.NASChangeDurationPermanent + request.HasChangeDuration = true + if pref.HasModePreference { + request.RadioAccessTech = qmiRATFromModePreference(pref.ModePreference) + } + selection, err = qmiOperatorSelectionFromPreference(pref) + if err != nil { + return OperatorSelection{}, err + } + } + var target *qmi.ManualNetworkSelection + if pref != nil && pref.HasManualNetworkSelection { + target = &pref.ManualNetworkSelection + } + if err := ensureNativeQMIRegistrationForTarget(ctx, session, request, setAutomatic, target); err != nil { + return OperatorSelection{}, err + } + return selection, nil +} diff --git a/internal/device/registration_native_qmi_test.go b/internal/device/registration_native_qmi_test.go new file mode 100644 index 0000000..95d7865 --- /dev/null +++ b/internal/device/registration_native_qmi_test.go @@ -0,0 +1,271 @@ +package device + +import ( + "context" + "testing" + + "github.com/iniwex5/quectel-qmi-go/pkg/qmi" + + "vocat/internal/modem" +) + +type fakeNativeQMIRegistrationSession struct { + mode qmi.OperatingMode + serving []*qmi.ServingSystem + selection *qmi.SystemSelectionPreference + setModes []qmi.OperatingMode + setPreferences []qmi.SystemSelectionPreference + registerRequests []qmi.NASInitiateNetworkRegisterRequest + forceSearches int + forceSearchErr error + registerErr error + attachRequests []bool + closeCount int +} + +func (session *fakeNativeQMIRegistrationSession) GetOperatingMode(context.Context) (qmi.OperatingMode, error) { + return session.mode, nil +} + +func (session *fakeNativeQMIRegistrationSession) SetOperatingMode(_ context.Context, mode qmi.OperatingMode) error { + session.mode = mode + session.setModes = append(session.setModes, mode) + return nil +} + +func (session *fakeNativeQMIRegistrationSession) Close() error { + session.closeCount++ + return nil +} + +func (session *fakeNativeQMIRegistrationSession) GetServingSystem(context.Context) (*qmi.ServingSystem, error) { + if len(session.serving) == 0 { + return &qmi.ServingSystem{RegistrationState: qmi.RegStateSearching}, nil + } + current := session.serving[0] + if len(session.serving) > 1 { + session.serving = session.serving[1:] + } + return current, nil +} + +func (session *fakeNativeQMIRegistrationSession) GetSystemSelectionPreference(context.Context) (*qmi.SystemSelectionPreference, error) { + if session.selection == nil { + return &qmi.SystemSelectionPreference{}, nil + } + return session.selection, nil +} + +func (session *fakeNativeQMIRegistrationSession) SetSystemSelectionPreference(_ context.Context, pref qmi.SystemSelectionPreference) error { + session.selection = &pref + session.setPreferences = append(session.setPreferences, pref) + return nil +} + +func (session *fakeNativeQMIRegistrationSession) InitiateNetworkRegister(_ context.Context, req qmi.NASInitiateNetworkRegisterRequest) error { + session.registerRequests = append(session.registerRequests, req) + return session.registerErr +} + +func (session *fakeNativeQMIRegistrationSession) ForceNetworkSearch(context.Context) error { + session.forceSearches++ + return session.forceSearchErr +} + +func (session *fakeNativeQMIRegistrationSession) AttachDetach(_ context.Context, attached bool) error { + session.attachRequests = append(session.attachRequests, attached) + return nil +} + +func TestEnsureNativeQMIRegistrationDrivesNASSequence(t *testing.T) { + session := &fakeNativeQMIRegistrationSession{ + mode: qmi.ModeLowPower, + serving: []*qmi.ServingSystem{ + {RegistrationState: qmi.RegStateSearching}, + {RegistrationState: qmi.RegStateSearching}, + {RegistrationState: qmi.RegStateRegistered, PSAttached: false}, + {RegistrationState: qmi.RegStateRegistered, PSAttached: true}, + }, + } + + if err := ensureNativeQMIRegistration(context.Background(), session, qmiRegistrationRequestAutomatic(), true); err != nil { + t.Fatalf("ensure native QMI registration: %v", err) + } + if len(session.setModes) != 1 || session.setModes[0] != qmi.ModeOnline { + t.Fatalf("operating mode writes = %#v, want [online]", session.setModes) + } + if len(session.setPreferences) != 1 || !session.setPreferences[0].HasNetworkSelectionPreference || + session.setPreferences[0].NetworkSelectionPreference != qmi.NASNetworkSelectionAutomatic { + t.Fatalf("selection writes = %#v, want automatic", session.setPreferences) + } + if len(session.registerRequests) != 1 || session.registerRequests[0].Mode != qmi.NASNetworkRegisterAutomatic { + t.Fatalf("registration requests = %#v, want one automatic request", session.registerRequests) + } + if session.forceSearches != 1 { + t.Fatalf("force-search count = %d, want 1", session.forceSearches) + } + if len(session.attachRequests) != 1 || !session.attachRequests[0] { + t.Fatalf("attach requests = %#v, want one attach", session.attachRequests) + } +} + +func TestQMIManualRegisterRequestMapsPLMNAndRAT(t *testing.T) { + rat := 7 + request, err := qmiManualRegisterRequest("46001", &rat) + if err != nil { + t.Fatalf("manual request: %v", err) + } + if request.Mode != qmi.NASNetworkRegisterManual || request.MCC != 460 || request.MNC != 1 || + request.IncludesPCSDigit || request.RadioAccessTech != 0x08 || !request.HasChangeDuration || + request.ChangeDuration != qmi.NASChangeDurationPermanent { + t.Fatalf("manual request = %#v", request) + } +} + +func TestQMIManualSelectionPreferenceMapsPLMN(t *testing.T) { + pref, selection, err := qmiManualSelectionPreference("46001") + if err != nil { + t.Fatalf("manual preference: %v", err) + } + if pref.NetworkSelectionPreference != qmi.NASNetworkSelectionManual || + !pref.HasNetworkSelectionPreference || !pref.HasManualNetworkSelection || + !pref.HasChangeDuration || pref.ChangeDuration != qmi.NASChangeDurationPermanent { + t.Fatalf("manual preference = %#v", pref) + } + if selection.MCC != 460 || selection.MNC != 1 || selection.IncludesPCSDigit { + t.Fatalf("manual selection = %#v", selection) + } +} + +func TestQMIManualSelectionPreferenceMapsRAT(t *testing.T) { + rat := 7 + pref, _, err := qmiManualSelectionPreferenceWithRAT("46001", &rat) + if err != nil { + t.Fatalf("manual preference: %v", err) + } + if !pref.HasModePreference || pref.ModePreference != qmi.NASRatModePreferenceLTE { + t.Fatalf("manual preference mode = %#v, want LTE mode preference", pref) + } +} + +func TestQMIManualRegisterRequestRejectsUnknownRAT(t *testing.T) { + rat := 1 + if _, err := qmiManualRegisterRequest("46001", &rat); err == nil { + t.Fatal("manual request with unknown RAT must fail") + } +} + +func TestEnsureNativeQMIRegistrationWaitsForManualTarget(t *testing.T) { + session := &fakeNativeQMIRegistrationSession{ + mode: qmi.ModeOnline, + serving: []*qmi.ServingSystem{ + {RegistrationState: qmi.RegStateRegistered, PSAttached: true, MCC: 460, MNC: 0}, + {RegistrationState: qmi.RegStateSearching}, + {RegistrationState: qmi.RegStateRegistered, PSAttached: false, MCC: 460, MNC: 1}, + {RegistrationState: qmi.RegStateRegistered, PSAttached: true, MCC: 460, MNC: 1}, + }, + } + request, err := qmiManualRegisterRequest("46001", nil) + if err != nil { + t.Fatalf("manual request: %v", err) + } + target := qmi.ManualNetworkSelection{MCC: 460, MNC: 1} + if err := ensureNativeQMIRegistrationForTarget(context.Background(), session, request, false, &target); err != nil { + t.Fatalf("ensure manual registration: %v", err) + } + if len(session.registerRequests) != 0 { + t.Fatalf("registration requests = %#v, want force-search-only manual trigger", session.registerRequests) + } + if session.forceSearches != 1 { + t.Fatalf("force-search count = %d, want 1", session.forceSearches) + } + if len(session.attachRequests) != 1 || !session.attachRequests[0] { + t.Fatalf("attach requests = %#v, want one attach", session.attachRequests) + } +} + +func TestEnsureNativeQMIRegistrationFallsBackWhenForceSearchUnsupported(t *testing.T) { + session := &fakeNativeQMIRegistrationSession{ + serving: []*qmi.ServingSystem{ + {RegistrationState: qmi.RegStateRegistered, PSAttached: true, RadioInterface: 8, MCC: 460, MNC: 0}, + {RegistrationState: qmi.RegStateSearching}, + {RegistrationState: qmi.RegStateRegistered, PSAttached: false, MCC: 460, MNC: 1}, + {RegistrationState: qmi.RegStateRegistered, PSAttached: true, MCC: 460, MNC: 1}, + }, + forceSearchErr: &qmi.QMIError{ + Service: qmi.ServiceNAS, MessageID: qmi.NASForceNetworkSearch, + Result: 0x0001, ErrorCode: qmi.QMIErrNotSupported, + }, + } + request, err := qmiManualRegisterRequest("46001", nil) + if err != nil { + t.Fatalf("manual request: %v", err) + } + target := qmi.ManualNetworkSelection{MCC: 460, MNC: 1} + if err := ensureNativeQMIRegistrationForTarget(context.Background(), session, request, false, &target); err != nil { + t.Fatalf("ensure manual registration: %v", err) + } + if len(session.registerRequests) != 1 || session.registerRequests[0].RadioAccessTech != 8 { + t.Fatalf("registration requests = %#v, want one LTE fallback request", session.registerRequests) + } + if session.forceSearches != 1 { + t.Fatalf("force-search count = %d, want one unsupported attempt", session.forceSearches) + } +} + +func TestNativeQMIRegistrationCyclesEarlyWhenForceSearchUnsupported(t *testing.T) { + if got := nativeQMIRegistrationRadioCycleThreshold(true); got != 3 { + t.Fatalf("unsupported force-search threshold = %d, want 3", got) + } + if got := nativeQMIRegistrationRadioCycleThreshold(false); got != 30 { + t.Fatalf("supported force-search threshold = %d, want 30", got) + } +} + +func TestIsNativeQMICandidateRequiresOpenStickWWANPair(t *testing.T) { + tests := []struct { + name string + candidate modem.Candidate + want bool + }{ + { + name: "native", + candidate: modem.Candidate{ + ID: "wwan0", + QMIControl: "/dev/wwan0qmi0", + }, + want: true, + }, + { + name: "mhi native discovery id", + candidate: modem.Candidate{ + ID: "mhi-wwan0", + QMIControl: "/dev/wwan0qmi0", + }, + want: true, + }, + { + name: "different control device", + candidate: modem.Candidate{ + ID: "wwan0", + QMIControl: "/dev/cdc-wdm0", + }, + want: false, + }, + { + name: "non native id", + candidate: modem.Candidate{ + ID: "usb0", + QMIControl: "/dev/usb0qmi0", + }, + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isNativeQMICandidate(tt.candidate); got != tt.want { + t.Fatalf("isNativeQMICandidate() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/internal/device/snapshot.go b/internal/device/snapshot.go index d3a6b51..5760e39 100644 --- a/internal/device/snapshot.go +++ b/internal/device/snapshot.go @@ -56,10 +56,23 @@ func (manager *Manager) readSnapshot( if ccidErr != nil { ccid, ccidErr = manager.command(ctx, client, "AT+QCCID") } + if ccidErr != nil && strings.EqualFold(strings.TrimSpace(backend), "qmi") && isNativeQMICandidate(candidate) { + qmiContext, cancelQMI := manager.withTimeout(ctx, manager.commandTimeout*5) + qmiICCID, qmiErr := manager.readNativeQMIICCID(qmiContext, candidate) + cancelQMI() + if qmiErr == nil { + snapshot.ICCID = qmiICCID + ccidErr = nil + } else { + snapshot.Warnings = append(snapshot.Warnings, "read ICCID via QMI UIM: "+qmiErr.Error()) + } + } if ccidErr != nil { snapshot.Warnings = append(snapshot.Warnings, "read ICCID: "+ccidErr.Error()) } else { - snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22) + if snapshot.ICCID == "" { + snapshot.ICCID = parseICCIDIdentifier(ccid, []string{"+CCID:", "+QCCID:"}, 18, 22) + } } previousICCID = strings.TrimSpace(previousICCID) if previousICCID != "" && snapshot.ICCID != "" && !strings.EqualFold(previousICCID, snapshot.ICCID) { diff --git a/internal/modem/serial.go b/internal/modem/serial.go index a80a4e6..06750ed 100644 --- a/internal/modem/serial.go +++ b/internal/modem/serial.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "path/filepath" "go.bug.st/serial" ) @@ -21,6 +22,22 @@ func (opener SerialOpener) Open(ctx context.Context, port Port) (Client, error) if path == "" { return nil, errors.New("modem: candidate has no AT port") } + if isNativeWWANATPath(path) { + rawPort, err := openNativeWWANATTransport(path) + if err != nil { + return nil, fmt.Errorf("open WWAN AT port %s: %w", path, err) + } + if err := rawPort.ResetInputBuffer(); err != nil { + _ = rawPort.Close() + return nil, fmt.Errorf("reset WWAN AT input buffer %s: %w", path, err) + } + session, err := NewSession(rawPort, opener.SessionOptions) + if err != nil { + _ = rawPort.Close() + return nil, err + } + return session, nil + } baudRate := opener.BaudRate if baudRate <= 0 { baudRate = 115200 @@ -45,3 +62,8 @@ func (opener SerialOpener) Open(ctx context.Context, port Port) (Client, error) } return session, nil } + +func isNativeWWANATPath(path string) bool { + _, kind, _, ok := parseWWANPortName(filepath.Base(filepath.Clean(path))) + return ok && kind == "at" +} diff --git a/internal/modem/serial_test.go b/internal/modem/serial_test.go new file mode 100644 index 0000000..b826413 --- /dev/null +++ b/internal/modem/serial_test.go @@ -0,0 +1,24 @@ +package modem + +import "testing" + +func TestIsNativeWWANATPath(t *testing.T) { + for _, path := range []string{ + "/dev/wwan0at0", + "/dev/wwan12at3", + } { + if !isNativeWWANATPath(path) { + t.Errorf("isNativeWWANATPath(%q) = false", path) + } + } + for _, path := range []string{ + "/dev/wwan0qmi0", + "/dev/ttyUSB2", + "/tmp/wwan-at", + "/dev/wwanat0", + } { + if isNativeWWANATPath(path) { + t.Errorf("isNativeWWANATPath(%q) = true", path) + } + } +} diff --git a/internal/modem/wwan_at_linux.go b/internal/modem/wwan_at_linux.go new file mode 100644 index 0000000..148f7d5 --- /dev/null +++ b/internal/modem/wwan_at_linux.go @@ -0,0 +1,161 @@ +//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. + return nil +} + +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) +} diff --git a/internal/modem/wwan_at_other.go b/internal/modem/wwan_at_other.go new file mode 100644 index 0000000..b2aaa71 --- /dev/null +++ b/internal/modem/wwan_at_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package modem + +import "fmt" + +func openNativeWWANATTransport(path string) (Transport, error) { + return nil, fmt.Errorf("native WWAN AT ports are unsupported on this platform: %s", path) +} diff --git a/internal/qmiport/lease.go b/internal/qmiport/lease.go new file mode 100644 index 0000000..88494fc --- /dev/null +++ b/internal/qmiport/lease.go @@ -0,0 +1,145 @@ +// Package qmiport coordinates access to native Linux WWAN QMI control ports. +package qmiport + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "syscall" +) + +type portHandle interface { + Close() error + Stat() (os.FileInfo, error) +} + +type portOpener func(string) (portHandle, error) + +type entry struct { + gate chan struct{} + + keeperMu sync.Mutex + keeper portHandle +} + +type coordinator struct { + mu sync.Mutex + entries map[string]*entry + opener portOpener +} + +// Lease serializes one QMI transaction sequence for a control port. Release +// does not close the keepalive descriptor: the old OpenStick 410 WWAN driver +// removes DATA5_CNTL when the final descriptor closes, and does not reliably +// recreate it until the modem is reset. +type Lease struct { + entry *entry + once sync.Once +} + +var processCoordinator = newCoordinator(openPort) + +func newCoordinator(opener portOpener) *coordinator { + return &coordinator{ + entries: make(map[string]*entry), + opener: opener, + } +} + +func openPort(path string) (portHandle, error) { + return os.OpenFile(path, os.O_RDWR|syscall.O_NONBLOCK|syscall.O_NOCTTY, 0) +} + +// Acquire keeps path open for the process lifetime and grants exclusive QMI +// access until the returned lease is released. A modem reset replaces the +// device node; ensureKeeper detects that inode change and rearms the keepalive. +func Acquire(ctx context.Context, path string) (*Lease, error) { + return processCoordinator.acquire(ctx, path) +} + +func (coordinator *coordinator) acquire(ctx context.Context, path string) (*Lease, error) { + if ctx == nil { + ctx = context.Background() + } + path = filepath.Clean(path) + if path == "." || path == "" { + return nil, errors.New("QMI control path is required") + } + coordinator.mu.Lock() + item := coordinator.entries[path] + if item == nil { + item = &entry{gate: make(chan struct{}, 1)} + item.gate <- struct{}{} + coordinator.entries[path] = item + } + coordinator.mu.Unlock() + + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-item.gate: + } + if err := coordinator.ensureKeeper(path, item); err != nil { + item.gate <- struct{}{} + return nil, fmt.Errorf("keep QMI control port %s open: %w", path, err) + } + return &Lease{entry: item}, nil +} + +func (coordinator *coordinator) ensureKeeper(path string, item *entry) error { + item.keeperMu.Lock() + defer item.keeperMu.Unlock() + + currentInfo, err := os.Stat(path) + if err != nil { + return err + } + if item.keeper != nil { + keeperInfo, statErr := item.keeper.Stat() + if statErr == nil && os.SameFile(currentInfo, keeperInfo) { + return nil + } + _ = item.keeper.Close() + item.keeper = nil + } + keeper, err := coordinator.opener(path) + if err != nil { + return err + } + item.keeper = keeper + return nil +} + +// Release allows the next QMI-UIM operation to use this control port. +func (lease *Lease) Release() { + if lease == nil || lease.entry == nil { + return + } + lease.once.Do(func() { + lease.entry.gate <- struct{}{} + }) +} + +func (coordinator *coordinator) close() error { + coordinator.mu.Lock() + entries := make([]*entry, 0, len(coordinator.entries)) + for _, item := range coordinator.entries { + entries = append(entries, item) + } + coordinator.entries = make(map[string]*entry) + coordinator.mu.Unlock() + + var errs []error + for _, item := range entries { + item.keeperMu.Lock() + if item.keeper != nil { + errs = append(errs, item.keeper.Close()) + item.keeper = nil + } + item.keeperMu.Unlock() + } + return errors.Join(errs...) +} diff --git a/internal/qmiport/lease_test.go b/internal/qmiport/lease_test.go new file mode 100644 index 0000000..213c1b1 --- /dev/null +++ b/internal/qmiport/lease_test.go @@ -0,0 +1,77 @@ +package qmiport + +import ( + "context" + "os" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestLeaseKeepsPortOpenAndSerializesUsers(t *testing.T) { + path := filepath.Join(t.TempDir(), "wwan0qmi0") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + var opens atomic.Int32 + coordinator := newCoordinator(func(path string) (portHandle, error) { + opens.Add(1) + return os.OpenFile(path, os.O_RDWR, 0) + }) + t.Cleanup(func() { _ = coordinator.close() }) + + first, err := coordinator.acquire(context.Background(), path) + if err != nil { + t.Fatalf("first acquire: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if _, err := coordinator.acquire(ctx, path); err == nil { + t.Fatal("second acquire succeeded before the first lease was released") + } + first.Release() + + second, err := coordinator.acquire(context.Background(), path) + if err != nil { + t.Fatalf("second acquire: %v", err) + } + second.Release() + if got := opens.Load(); got != 1 { + t.Fatalf("keepalive opens = %d, want 1", got) + } +} + +func TestLeaseReopensReplacedDeviceNode(t *testing.T) { + directory := t.TempDir() + path := filepath.Join(directory, "wwan0qmi0") + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + var opens atomic.Int32 + coordinator := newCoordinator(func(path string) (portHandle, error) { + opens.Add(1) + return os.OpenFile(path, os.O_RDWR, 0) + }) + t.Cleanup(func() { _ = coordinator.close() }) + + first, err := coordinator.acquire(context.Background(), path) + if err != nil { + t.Fatal(err) + } + first.Release() + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, nil, 0o600); err != nil { + t.Fatal(err) + } + second, err := coordinator.acquire(context.Background(), path) + if err != nil { + t.Fatal(err) + } + second.Release() + if got := opens.Load(); got != 2 { + t.Fatalf("keepalive opens = %d, want 2 after node replacement", got) + } +} diff --git a/internal/server/device_api.go b/internal/server/device_api.go index 22af471..1e11928 100644 --- a/internal/server/device_api.go +++ b/internal/server/device_api.go @@ -1472,7 +1472,13 @@ func physicalMatchesConfig(entry device.Device, config store.Device) bool { return config.ModemIMEI == entry.Snapshot.IMEI } if config.USBPath != "" && candidate.USBPath != "" { - return config.USBPath == candidate.USBPath + if config.USBPath == candidate.USBPath { + return true + } + // Sysfs paths may be stored through /sys/class symlinks while a + // subsequent discovery returns the resolved device path. Keep checking + // the selected AT/QMI nodes instead of rejecting a modem whose physical + // path spelling changed but whose control plane is unchanged. } // Control and serial device nodes are allocation-order dependent. They are // only legacy fallbacks when no physical USB path or readable IMEI exists. diff --git a/internal/server/device_features_api_test.go b/internal/server/device_features_api_test.go index 2925ef1..d09dfb0 100644 --- a/internal/server/device_features_api_test.go +++ b/internal/server/device_features_api_test.go @@ -117,6 +117,27 @@ func TestPhysicalMatchesConfigRejectsDuplicateAndroidSerialAlias(t *testing.T) { } } +func TestPhysicalMatchesConfigFallsBackWhenWWANSysfsPathWasResolved(t *testing.T) { + config := store.Device{ + ID: "wwan0", + USBPath: "/sys/class/wwan/wwan0", + ATPort: "/dev/wwan0at0", + ControlDevice: "/dev/wwan0qmi0", + } + entry := device.Device{ + ID: "mhi-wwan0", + Candidate: modem.Candidate{ + USBPath: "/sys/devices/platform/soc/4080000.remoteproc/wwan/wwan0", + ATPort: modem.Port{Path: "/dev/wwan0at0"}, + QMIControl: "/dev/wwan0qmi0", + HardwareKind: "wwan", + }, + } + if !physicalMatchesConfig(entry, config) { + t.Fatal("resolved WWAN sysfs path should fall back to matching control nodes") + } +} + func TestFindDiscoveredDevicePrefersPhysicalIdentityOverSerialAlias(t *testing.T) { alias := "/dev/serial/by-id/usb-Android_Android-if02-port0" devices := []device.Device{