mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-16 12:53:43 +08:00
feat: add native OpenStick 410 modem support (#23)
* build: add QMI dependency and license attribution * feat: add native OpenStick 410 modem support --------- Co-authored-by: w0x41en <[email protected]>
This commit is contained in:
@@ -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.
|
||||
@@ -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/[email protected]
|
||||
License: MIT
|
||||
Copyright: Copyright (c) 2026 iniwex5
|
||||
|
||||
The full MIT license text is included in:
|
||||
LICENSES/quectel-qmi-go-MIT.txt
|
||||
+11
-1
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")},
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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{}),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user