├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── at ├── README.md ├── at.go └── at_test.go ├── cmd ├── modeminfo │ ├── .gitignore │ └── modeminfo.go ├── phonebook │ ├── .gitignore │ └── phonebook.go ├── sendsms │ ├── .gitignore │ └── sendsms.go ├── ussd │ └── ussd.go └── waitsms │ ├── .gitignore │ └── waitsms.go ├── go.mod ├── go.sum ├── gsm ├── README.md ├── gsm.go └── gsm_test.go ├── info ├── info.go └── info_test.go ├── serial ├── serial.go ├── serial_darwin.go ├── serial_linux.go ├── serial_test.go └── serial_windows.go └── trace ├── trace.go └── trace_test.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - "1.13" 5 | - "1.17" 6 | 7 | os: 8 | - linux 9 | - osx 10 | 11 | matrix: 12 | allow_failures: 13 | - go: tip 14 | fast_finish: true 15 | 16 | before_install: 17 | - go get github.com/mattn/goveralls 18 | 19 | script: 20 | - go test $(go list ./... | grep -v /cmd/) -coverprofile=gover.coverprofile 21 | - $GOPATH/bin/goveralls -coverprofile gover.coverprofile -service=travis-ci 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Kent Gibson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GOCMD=go 2 | GOBUILD=$(GOCMD) build 3 | GOCLEAN=$(GOCMD) clean 4 | 5 | VERSION ?= $(shell git describe --tags --always --dirty 2> /dev/null ) 6 | LDFLAGS=-ldflags "-X=main.version=$(VERSION)" 7 | 8 | cmds=$(patsubst %.go, %, $(wildcard cmd/*/*.go)) 9 | 10 | all: $(cmds) 11 | 12 | $(cmds) : % : %.go 13 | cd $(@D); \ 14 | $(GOBUILD) $(LDFLAGS) 15 | 16 | clean: 17 | $(GOCLEAN) ./... 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # modem 2 | 3 | A low level Go driver for AT modems. 4 | 5 | [![Build Status](https://app.travis-ci.com/warthog618/modem.svg)](https://app.travis-ci.com/warthog618/modem) 6 | [![Coverage Status](https://coveralls.io/repos/github/warthog618/modem/badge.svg?branch=master)](https://coveralls.io/github/warthog618/modem?branch=master) 7 | [![Go Report Card](https://goreportcard.com/badge/github.com/warthog618/modem)](https://goreportcard.com/report/github.com/warthog618/modem) 8 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/warthog618/modem/blob/master/LICENSE) 9 | 10 | modem is a Go library for interacting with AT based modems. 11 | 12 | The initial impetus was to provide functionality to send and receive SMSs via a 13 | GSM modem, but the library may be generally useful for any device controlled by 14 | AT commands. 15 | 16 | The [at](at) package provides a low level driver which sits between an 17 | io.ReadWriter, representing the physical modem, and a higher level driver or 18 | application. 19 | 20 | The AT driver provides the ability to issue AT commands to the modem, and to 21 | receive the info and status returned by the modem, as synchronous function 22 | calls. 23 | 24 | Handlers for asynchronous indications from the modem, such as received SMSs, 25 | can be registered with the driver. 26 | 27 | The [gsm](gsm) package wraps the AT driver to add higher level functions to 28 | send and receive SMS messages, including long messages split into multiple 29 | parts, without any knowledge of the underlying AT commands. 30 | 31 | The [info](info) package provides utility functions to manipulate the info 32 | returned in the responses from the modem. 33 | 34 | The [serial](serial) package provides a simple wrapper around a third party 35 | serial driver, so you don't have to find one yourself. 36 | 37 | The [trace](trace) package provides a driver, which may be inserted between the 38 | AT driver and the underlying modem, to log interactions with the modem for 39 | debugging purposes. 40 | 41 | The [cmd](cmd) directory contains basic commands to exercise the library and a 42 | modem, including [retrieving details](cmd/modeminfo/modeminfo.go) from the 43 | modem, [sending](cmd/sendsms/sendsms.go) and 44 | [receiving](cmd/waitsms/waitsms.go) SMSs, and 45 | [retrieving](cmd/phonebook/phonebook.go) the SIM phonebook. 46 | 47 | ## Features 48 | 49 | Supports the following functionality: 50 | 51 | - Simple synchronous interface for AT commands 52 | - Serialises access to the modem from multiple goroutines 53 | - Asynchronous indication handling 54 | - Tracing of messages to and from the modem 55 | - Pluggable serial driver - any io.ReadWriter will suffice 56 | 57 | ## Usage 58 | 59 | The [at](at) package allows you to issue commands to the modem and receive the 60 | response. e.g.: 61 | 62 | ```go 63 | modem := at.New(ioWR) 64 | info, err := modem.Command("I") 65 | ``` 66 | 67 | produces the following interaction with the modem (exact results will differ for your modem): 68 | 69 | ```shell 70 | 2018/05/17 20:39:56 w: ATI 71 | 2018/05/17 20:39:56 r: 72 | Manufacturer: huawei 73 | Model: E173 74 | Revision: 21.017.09.00.314 75 | IMEI: 1234567 76 | +GCAP: +CGSM,+DS,+ES 77 | 78 | OK 79 | ``` 80 | 81 | and returns this info: 82 | 83 | ```go 84 | info = []string{ 85 | "Manufacturer: huawei", 86 | "Model: E173", 87 | "Revision: 21.017.09.00.314", 88 | "IMEI: 1234567", 89 | "+GCAP: +CGSM,+DS,+ES", 90 | } 91 | ``` 92 | 93 | Refer to the [modeminfo](cmd/modeminfo/modeminfo.go) for an example of how to create a modem object such as the one used in this example. 94 | 95 | For more information, refer to package documentation, tests and example commands. 96 | 97 | Package | Documentation | Tests | Example code 98 | ------- | ------------- | ----- | ------------ 99 | [at](at) | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/warthog618/modem/at) | [at_test](at/at_test.go) | [modeminfo](cmd/modeminfo/modeminfo.go) 100 | [gsm](gsm) | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/warthog618/modem/gsm) | [gsm_test](gsm/gsm_test.go) | [sendsms](cmd/sendsms/sendsms.go), [waitsms](cmd/waitsms/waitsms.go) 101 | [info](info) | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/warthog618/modem/info) | [info_test](info/info_test.go) | [phonebook](cmd/phonebook/phonebook.go) 102 | [serial](serial) | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/warthog618/modem/serial) | [serial_test](serial/serial_test.go) | [modeminfo](cmd/modeminfo/modeminfo.go), [sendsms](cmd/sendsms/sendsms.go), [waitsms](cmd/waitsms/waitsms.go) 103 | [trace](trace) | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/warthog618/modem/trace) | [trace_test](trace/trace_test.go) | [sendsms](cmd/sendsms/sendsms.go), [waitsms](cmd/waitsms/waitsms.go) 104 | -------------------------------------------------------------------------------- /at/README.md: -------------------------------------------------------------------------------- 1 | # at 2 | 3 | A low level Go driver for AT modems. 4 | 5 | [![Build Status](https://travis-ci.org/warthog618/modem.svg)](https://travis-ci.org/warthog618/modem) 6 | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/warthog618/modem/at) 7 | [![Coverage Status](https://coveralls.io/repos/github/warthog618/modem/badge.svg?branch=master)](https://coveralls.io/github/warthog618/modem?branch=master) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/warthog618/modem)](https://goreportcard.com/report/github.com/warthog618/modem) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/warthog618/modem/blob/master/LICENSE) 10 | 11 | The **at** package provides a low level driver which sits between an 12 | io.ReadWriter, representing the physical modem, and a higher level driver or 13 | application. 14 | 15 | The AT driver provides the ability to issue AT commands to the modem, and to 16 | receive the info and status returned by the modem, as synchronous function 17 | calls. 18 | 19 | Handlers for asynchronous indications from the modem, such as received SMSs, 20 | can be registered with the driver. 21 | 22 | ## Features 23 | 24 | Supports the following functionality: 25 | 26 | - Simple synchronous interface for AT commands 27 | - Serialises access to the modem from multiple goroutines 28 | - Asynchronous indication handling 29 | - Pluggable serial driver - any io.ReadWriter will suffice 30 | 31 | ## Usage 32 | 33 | ### Construction 34 | 35 | The modem is constructed with *New*: 36 | 37 | ```go 38 | modem := at.New(ioWR) 39 | ``` 40 | 41 | Some modem behaviour can be controlled using optional parameters. This example sets the default timeout for AT commands to one second: 42 | 43 | ```go 44 | modem := at.New(ioWR, at.WithTimeout(time.Second)) 45 | ``` 46 | 47 | ### Modem Init 48 | 49 | The modem can be initialised to a known state using *Init*: 50 | 51 | ```go 52 | err := modem.Init() 53 | ``` 54 | 55 | By default the Init issues the **ATZ** and **ATE0** commands. The set of 56 | commands performed can be replaced using the optional *WithCmds* parameter. This example replaces the **ATE0** with **AT^CURC=0**: 57 | 58 | ```go 59 | err := modem.Init(at.WithCmds("Z","^CURC=0")) 60 | ``` 61 | 62 | ### AT Commands 63 | 64 | Issue AT commands to the modem and receive the response using *Command*: 65 | 66 | ```go 67 | info, err := modem.Command("I") 68 | ``` 69 | 70 | This produces the following interaction with the modem (exact results will differ for your modem): 71 | 72 | ```shell 73 | 2018/05/17 20:39:56 w: ATI 74 | 2018/05/17 20:39:56 r: 75 | Manufacturer: huawei 76 | Model: E173 77 | Revision: 21.017.09.00.314 78 | IMEI: 1234567 79 | +GCAP: +CGSM,+DS,+ES 80 | 81 | OK 82 | ``` 83 | 84 | and returns this info: 85 | 86 | ```go 87 | info = []string{ 88 | "Manufacturer: huawei", 89 | "Model: E173", 90 | "Revision: 21.017.09.00.314", 91 | "IMEI: 1234567", 92 | "+GCAP: +CGSM,+DS,+ES", 93 | } 94 | ``` 95 | 96 | ### SMS Commands 97 | 98 | SMS commands are a special case as they are a two stage process, with the modem 99 | prompting between stages. The *SMSCommand* performs the two stage handshake 100 | with the modem and returns any resulting info. This example sends an SMS with the modem in text mode: 101 | 102 | ```go 103 | info, err := modem.SMSCommand("+CMGS=\"12345\"", "hello world") 104 | ``` 105 | 106 | ### Asynchronous Indications 107 | 108 | Handlers can be provided for asynchronous indications using *AddIndication*. This example provides a handler for **+CMT** events: 109 | 110 | ```go 111 | handler := func(info []string) { 112 | // handle CMT info here 113 | } 114 | err := modem.AddIndication("+CMT:", handler) 115 | ``` 116 | 117 | The handler can be removed using *CancelIndication*: 118 | 119 | ```go 120 | modem.CancelIndication("+CMT:") 121 | ``` 122 | 123 | ### Options 124 | 125 | A number of the modem methods accept optional parameters. The following table comprises a list of the available options: 126 | 127 | Option | Method | Description 128 | ---|---|--- 129 | WithTimeout(time.duration)|New, Init, Command, SMSCommand| Specify the timeout for commands. A value provided to New becomes the default for the other methods. 130 | WithCmds([]string)|New, Init| Override the set of commands issued by Init. 131 | WithEscTime(time.Duration)|New|Specifies the minimum period between issuing an escape and a subsequent command. 132 | WithIndication(prefix, handler)|New| Adds an indication handler at construction time. 133 | WithTrailingLines(int)|AddIndication, WithIndication| Specifies the number of lines to collect following the indicationline itself. 134 | WithTrailingLine|AddIndication, WithIndication| Simple case of one trailing line. 135 | -------------------------------------------------------------------------------- /at/at.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // Package at provides a low level driver for AT modems. 6 | package at 7 | 8 | import ( 9 | "bufio" 10 | "fmt" 11 | "io" 12 | "strings" 13 | "time" 14 | 15 | "github.com/pkg/errors" 16 | ) 17 | 18 | // AT represents a modem that can be managed using AT commands. 19 | // 20 | // Commands can be issued to the modem using the Command and SMSCommand methods. 21 | // 22 | // The AT closes the closed channel when the connection to the underlying 23 | // modem is broken (Read returns EOF). 24 | // 25 | // When closed, all outstanding commands return ErrClosed and the state of the 26 | // underlying modem becomes unknown. 27 | // 28 | // Once closed the AT cannot be re-opened - it must be recreated. 29 | type AT struct { 30 | // channel for commands issued to the modem 31 | // 32 | // Handled by the cmdLoop. 33 | cmdCh chan func() 34 | 35 | // channel for changes to inds 36 | // 37 | // Handled by the indLoop. 38 | indCh chan func() 39 | 40 | // closed when modem is closed 41 | closed chan struct{} 42 | 43 | // channel for all lines read from the modem 44 | // 45 | // Handled by the indLoop. 46 | iLines chan string 47 | 48 | // channel for lines read from the modem after indications removed 49 | // 50 | // Handled by the cmdLoop. 51 | cLines chan string 52 | 53 | // the underlying modem 54 | // 55 | // Only accessed from the cmdLoop. 56 | modem io.ReadWriter 57 | 58 | // the minimum time between an escape command and the subsequent command 59 | escTime time.Duration 60 | 61 | // time to wait for individual commands to complete 62 | cmdTimeout time.Duration 63 | 64 | // indications mapped by prefix 65 | // 66 | // Only accessed from the indLoop 67 | inds map[string]Indication 68 | 69 | // commands issued by Init. 70 | initCmds []string 71 | 72 | // if not-nil, the timer that must expire before the subsequent command is issued 73 | // 74 | // Only accessed from the cmdLoop. 75 | escGuard *time.Timer 76 | } 77 | 78 | // Option is a construction option for an AT. 79 | type Option interface { 80 | applyOption(*AT) 81 | } 82 | 83 | // CommandOption defines a behaviouralk option for Command and SMSCommand. 84 | type CommandOption interface { 85 | applyCommandOption(*commandConfig) 86 | } 87 | 88 | // InitOption defines a behaviouralk option for Init. 89 | type InitOption interface { 90 | applyInitOption(*initConfig) 91 | } 92 | 93 | // New creates a new AT modem. 94 | func New(modem io.ReadWriter, options ...Option) *AT { 95 | a := &AT{ 96 | modem: modem, 97 | cmdCh: make(chan func()), 98 | indCh: make(chan func()), 99 | iLines: make(chan string), 100 | cLines: make(chan string), 101 | closed: make(chan struct{}), 102 | escTime: 20 * time.Millisecond, 103 | cmdTimeout: time.Second, 104 | inds: make(map[string]Indication), 105 | } 106 | for _, option := range options { 107 | option.applyOption(a) 108 | } 109 | if a.initCmds == nil { 110 | a.initCmds = []string{ 111 | "Z", // reset to factory defaults (also clears the escape from the rx buffer) 112 | "E0", // disable echo 113 | } 114 | } 115 | go lineReader(a.modem, a.iLines) 116 | go a.indLoop(a.indCh, a.iLines, a.cLines) 117 | go cmdLoop(a.cmdCh, a.cLines, a.closed) 118 | return a 119 | } 120 | 121 | const ( 122 | sub = "\x1a" 123 | esc = "\x1b" 124 | ) 125 | 126 | // WithEscTime sets the guard time for the modem. 127 | // 128 | // The escape time is the minimum time between an escape command being sent to 129 | // the modem and any subsequent commands. 130 | // 131 | // The default guard time is 20msec. 132 | func WithEscTime(d time.Duration) EscTimeOption { 133 | return EscTimeOption(d) 134 | } 135 | 136 | // EscTimeOption defines the escape guard time for the modem. 137 | type EscTimeOption time.Duration 138 | 139 | func (o EscTimeOption) applyOption(a *AT) { 140 | a.escTime = time.Duration(o) 141 | } 142 | 143 | // InfoHandler receives indication info. 144 | type InfoHandler func([]string) 145 | 146 | // WithIndication adds an indication during construction. 147 | func WithIndication(prefix string, handler InfoHandler, options ...IndicationOption) Indication { 148 | return newIndication(prefix, handler, options...) 149 | } 150 | 151 | func (o Indication) applyOption(a *AT) { 152 | a.inds[o.prefix] = o 153 | } 154 | 155 | // CmdsOption specifies the set of AT commands issued by Init. 156 | type CmdsOption []string 157 | 158 | func (o CmdsOption) applyOption(a *AT) { 159 | a.initCmds = []string(o) 160 | } 161 | 162 | func (o CmdsOption) applyInitOption(i *initConfig) { 163 | i.cmds = []string(o) 164 | } 165 | 166 | // WithCmds specifies the set of AT commands issued by Init. 167 | // 168 | // The default commands are ATZ. 169 | func WithCmds(cmds ...string) CmdsOption { 170 | return CmdsOption(cmds) 171 | } 172 | 173 | // WithTimeout specifies the maximum time allowed for the modem to complete a 174 | // command. 175 | func WithTimeout(d time.Duration) TimeoutOption { 176 | return TimeoutOption(d) 177 | } 178 | 179 | // TimeoutOption specifies the maximum time allowed for the modem to complete a 180 | // command. 181 | type TimeoutOption time.Duration 182 | 183 | func (o TimeoutOption) applyOption(a *AT) { 184 | a.cmdTimeout = time.Duration(o) 185 | } 186 | 187 | func (o TimeoutOption) applyInitOption(i *initConfig) { 188 | i.cmdOpts = append(i.cmdOpts, o) 189 | } 190 | 191 | func (o TimeoutOption) applyCommandOption(c *commandConfig) { 192 | c.timeout = time.Duration(o) 193 | } 194 | 195 | // AddIndication adds a handler for a set of lines beginning with the prefixed 196 | // line and the following trailing lines. 197 | func (a *AT) AddIndication(prefix string, handler InfoHandler, options ...IndicationOption) (err error) { 198 | ind := newIndication(prefix, handler, options...) 199 | errs := make(chan error) 200 | indf := func() { 201 | if _, ok := a.inds[ind.prefix]; ok { 202 | errs <- ErrIndicationExists 203 | return 204 | } 205 | a.inds[ind.prefix] = ind 206 | close(errs) 207 | } 208 | select { 209 | case <-a.closed: 210 | err = ErrClosed 211 | case a.indCh <- indf: 212 | err = <-errs 213 | } 214 | return 215 | } 216 | 217 | // CancelIndication removes any indication corresponding to the prefix. 218 | // 219 | // If any such indication exists its return channel is closed and no further 220 | // indications will be sent to it. 221 | func (a *AT) CancelIndication(prefix string) { 222 | done := make(chan struct{}) 223 | indf := func() { 224 | delete(a.inds, prefix) 225 | close(done) 226 | } 227 | select { 228 | case <-a.closed: 229 | case a.indCh <- indf: 230 | <-done 231 | } 232 | } 233 | 234 | // Closed returns a channel which will block while the modem is not closed. 235 | func (a *AT) Closed() <-chan struct{} { 236 | return a.closed 237 | } 238 | 239 | // Command issues the command to the modem and returns the result. 240 | // 241 | // The command should NOT include the AT prefix, nor suffix which is 242 | // automatically added. 243 | // 244 | // The return value includes the info (the lines returned by the modem between 245 | // the command and the status line), or an error if the command did not 246 | // complete successfully. 247 | func (a *AT) Command(cmd string, options ...CommandOption) ([]string, error) { 248 | cfg := commandConfig{timeout: a.cmdTimeout} 249 | for _, option := range options { 250 | option.applyCommandOption(&cfg) 251 | } 252 | done := make(chan response) 253 | cmdf := func() { 254 | info, err := a.processReq(cmd, cfg.timeout) 255 | done <- response{info: info, err: err} 256 | } 257 | select { 258 | case <-a.closed: 259 | return nil, ErrClosed 260 | case a.cmdCh <- cmdf: 261 | rsp := <-done 262 | return rsp.info, rsp.err 263 | } 264 | } 265 | 266 | // Escape issues an escape sequence to the modem. 267 | // 268 | // It does not wait for any response, but it does inhibit subsequent commands 269 | // until the escTime has elapsed. 270 | // 271 | // The escape sequence is "\x1b\r\n". Additional characters may be added to 272 | // the sequence using the b parameter. 273 | func (a *AT) Escape(b ...byte) { 274 | done := make(chan struct{}) 275 | cmdf := func() { 276 | a.escape(b...) 277 | close(done) 278 | } 279 | select { 280 | case <-a.closed: 281 | case a.cmdCh <- cmdf: 282 | <-done 283 | } 284 | } 285 | 286 | // Init initialises the modem by escaping any outstanding SMS commands and 287 | // resetting the modem to factory defaults. 288 | // 289 | // The Init is intended to be called after creation and before any other 290 | // commands are issued in order to get the modem into a known state. It can 291 | // also be used subsequently to return the modem to a known state. 292 | // 293 | // The default init commands can be overridden by the options parameter. 294 | func (a *AT) Init(options ...InitOption) error { 295 | // escape any outstanding SMS operations then CR to flush the command 296 | // buffer 297 | a.Escape([]byte("\r\n")...) 298 | 299 | cfg := initConfig{cmds: a.initCmds} 300 | for _, option := range options { 301 | option.applyInitOption(&cfg) 302 | } 303 | for _, cmd := range cfg.cmds { 304 | _, err := a.Command(cmd, cfg.cmdOpts...) 305 | switch err { 306 | case nil: 307 | case ErrDeadlineExceeded: 308 | return err 309 | default: 310 | return fmt.Errorf("AT%s returned error: %w", cmd, err) 311 | } 312 | } 313 | return nil 314 | } 315 | 316 | // SMSCommand issues an SMS command to the modem, and returns the result. 317 | // 318 | // An SMS command is issued in two steps; first the command line: 319 | // 320 | // AT 321 | // 322 | // which the modem responds to with a ">" prompt, after which the SMS PDU is 323 | // sent to the modem: 324 | // 325 | // 326 | // 327 | // The modem then completes the command as per other commands, such as those 328 | // issued by Command. 329 | // 330 | // The format of the sms may be a text message or a hex coded SMS PDU, 331 | // depending on the configuration of the modem (text or PDU mode). 332 | func (a *AT) SMSCommand(cmd string, sms string, options ...CommandOption) (info []string, err error) { 333 | cfg := commandConfig{timeout: a.cmdTimeout} 334 | for _, option := range options { 335 | option.applyCommandOption(&cfg) 336 | } 337 | done := make(chan response) 338 | cmdf := func() { 339 | info, err := a.processSmsReq(cmd, sms, cfg.timeout) 340 | done <- response{info: info, err: err} 341 | } 342 | select { 343 | case <-a.closed: 344 | return nil, ErrClosed 345 | case a.cmdCh <- cmdf: 346 | rsp := <-done 347 | return rsp.info, rsp.err 348 | } 349 | } 350 | 351 | // cmdLoop is responsible for the interface to the modem. 352 | // 353 | // It serialises the issuing of commands and awaits the responses. 354 | // If no command is pending then any lines received are discarded. 355 | // 356 | // The cmdLoop terminates when the downstream closes. 357 | func cmdLoop(cmds chan func(), in <-chan string, out chan struct{}) { 358 | for { 359 | select { 360 | case cmd := <-cmds: 361 | cmd() 362 | case _, ok := <-in: 363 | if !ok { 364 | close(out) 365 | return 366 | } 367 | } 368 | } 369 | } 370 | 371 | // lineReader takes lines from m and redirects them to out. 372 | // 373 | // lineReader exits when m closes. 374 | func lineReader(m io.Reader, out chan string) { 375 | scanner := bufio.NewScanner(m) 376 | scanner.Split(scanLines) 377 | for scanner.Scan() { 378 | out <- scanner.Text() 379 | } 380 | close(out) // tell pipeline we're done - end of pipeline will close the AT. 381 | } 382 | 383 | // indLoop is responsible for pulling indications from the stream of lines read 384 | // from the modem, and forwarding them to handlers. 385 | // 386 | // Non-indication lines are passed upstream. Indication trailing lines are 387 | // assumed to arrive in a contiguous block immediately after the indication. 388 | // 389 | // indLoop exits when the in channel closes. 390 | func (a *AT) indLoop(cmds chan func(), in <-chan string, out chan string) { 391 | defer close(out) 392 | for { 393 | select { 394 | case cmd := <-cmds: 395 | cmd() 396 | case line, ok := <-in: 397 | if !ok { 398 | return 399 | } 400 | for prefix, ind := range a.inds { 401 | if strings.HasPrefix(line, prefix) { 402 | n := make([]string, ind.lines) 403 | n[0] = line 404 | for i := 1; i < ind.lines; i++ { 405 | t, ok := <-in 406 | if !ok { 407 | return 408 | } 409 | n[i] = t 410 | } 411 | go ind.handler(n) 412 | continue 413 | } 414 | } 415 | out <- line 416 | } 417 | } 418 | } 419 | 420 | // issue an escape command 421 | // 422 | // This should only be called from within the cmdLoop. 423 | func (a *AT) escape(b ...byte) { 424 | cmd := append([]byte(esc+"\r\n"), b...) 425 | a.modem.Write(cmd) 426 | a.escGuard = time.NewTimer(a.escTime) 427 | } 428 | 429 | // perform a request - issuing the command and awaiting the response. 430 | func (a *AT) processReq(cmd string, timeout time.Duration) (info []string, err error) { 431 | a.waitEscGuard() 432 | err = a.writeCommand(cmd) 433 | if err != nil { 434 | return 435 | } 436 | 437 | cmdID := parseCmdID(cmd) 438 | var expChan <-chan time.Time 439 | if timeout >= 0 { 440 | expiry := time.NewTimer(timeout) 441 | expChan = expiry.C 442 | defer expiry.Stop() 443 | } 444 | for { 445 | select { 446 | case <-expChan: 447 | err = ErrDeadlineExceeded 448 | return 449 | case line, ok := <-a.cLines: 450 | if !ok { 451 | return nil, ErrClosed 452 | } 453 | if line == "" { 454 | continue 455 | } 456 | lt := parseRxLine(line, cmdID) 457 | i, done, perr := a.processRxLine(lt, line) 458 | if i != nil { 459 | info = append(info, *i) 460 | } 461 | if perr != nil { 462 | err = perr 463 | return 464 | } 465 | if done { 466 | return 467 | } 468 | } 469 | } 470 | } 471 | 472 | // perform a SMS request - issuing the command, awaiting the prompt, sending 473 | // the data and awaiting the response. 474 | func (a *AT) processSmsReq(cmd string, sms string, timeout time.Duration) (info []string, err error) { 475 | a.waitEscGuard() 476 | err = a.writeSMSCommand(cmd) 477 | if err != nil { 478 | return 479 | } 480 | cmdID := parseCmdID(cmd) 481 | var expChan <-chan time.Time 482 | if timeout >= 0 { 483 | expiry := time.NewTimer(timeout) 484 | expChan = expiry.C 485 | defer expiry.Stop() 486 | } 487 | for { 488 | select { 489 | case <-expChan: 490 | // cancel outstanding SMS request 491 | a.escape() 492 | err = ErrDeadlineExceeded 493 | return 494 | case line, ok := <-a.cLines: 495 | if !ok { 496 | err = ErrClosed 497 | return 498 | } 499 | if line == "" { 500 | continue 501 | } 502 | lt := parseRxLine(line, cmdID) 503 | i, done, perr := a.processSmsRxLine(lt, line, sms) 504 | if i != nil { 505 | info = append(info, *i) 506 | } 507 | if perr != nil { 508 | err = perr 509 | return 510 | } 511 | if done { 512 | return 513 | } 514 | } 515 | } 516 | } 517 | 518 | // processRxLine parses a line received from the modem and determines how it 519 | // adds to the response for the current command. 520 | // 521 | // The return values are: 522 | // - a line of info to be added to the response (optional) 523 | // - a flag indicating if the command is complete. 524 | // - an error detected while processing the command. 525 | func (a *AT) processRxLine(lt rxl, line string) (info *string, done bool, err error) { 526 | switch lt { 527 | case rxlStatusOK: 528 | done = true 529 | case rxlStatusError: 530 | err = newError(line) 531 | case rxlUnknown, rxlInfo: 532 | info = &line 533 | case rxlConnect: 534 | info = &line 535 | done = true 536 | case rxlConnectError: 537 | err = ConnectError(line) 538 | } 539 | return 540 | } 541 | 542 | // processSmsRxLine parses a line received from the modem and determines how it 543 | // adds to the response for the current command. 544 | // 545 | // The return values are: 546 | // - a line of info to be added to the response (optional) 547 | // - a flag indicating if the command is complete. 548 | // - an error detected while processing the command. 549 | func (a *AT) processSmsRxLine(lt rxl, line string, sms string) (info *string, done bool, err error) { 550 | switch lt { 551 | case rxlUnknown: 552 | if strings.HasSuffix(line, sub) && strings.HasPrefix(line, sms) { 553 | // swallow echoed SMS PDU 554 | return 555 | } 556 | info = &line 557 | case rxlSMSPrompt: 558 | if err = a.writeSMS(sms); err != nil { 559 | // escape SMS 560 | a.escape() 561 | } 562 | default: 563 | return a.processRxLine(lt, line) 564 | } 565 | return 566 | } 567 | 568 | // waitEscGuard waits for a write guard to allow a write to the modem. 569 | // 570 | // This should only be called from within the cmdLoop. 571 | func (a *AT) waitEscGuard() { 572 | if a.escGuard == nil { 573 | return 574 | } 575 | Loop: 576 | for { 577 | select { 578 | case _, ok := <-a.cLines: 579 | if !ok { 580 | a.escGuard.Stop() 581 | break Loop 582 | } 583 | case <-a.escGuard.C: 584 | break Loop 585 | } 586 | } 587 | a.escGuard = nil 588 | } 589 | 590 | // writeCommand writes a one line command to the modem. 591 | // 592 | // This should only be called from within the cmdLoop. 593 | func (a *AT) writeCommand(cmd string) error { 594 | cmdLine := "AT" + cmd + "\r\n" 595 | _, err := a.modem.Write([]byte(cmdLine)) 596 | return err 597 | } 598 | 599 | // writeSMSCommand writes a the first line of an SMS command to the modem. 600 | // 601 | // This should only be called from within the cmdLoop. 602 | func (a *AT) writeSMSCommand(cmd string) error { 603 | cmdLine := "AT" + cmd + "\r" 604 | _, err := a.modem.Write([]byte(cmdLine)) 605 | return err 606 | } 607 | 608 | // writeSMS writes the first line of a two line SMS command to the modem. 609 | // 610 | // This should only be called from within the cmdLoop. 611 | func (a *AT) writeSMS(sms string) error { 612 | _, err := a.modem.Write([]byte(sms + string(sub))) 613 | return err 614 | } 615 | 616 | // CMEError indicates a CME Error was returned by the modem. 617 | // 618 | // The value is the error value, in string form, which may be the numeric or 619 | // textual, depending on the modem configuration. 620 | type CMEError string 621 | 622 | // CMSError indicates a CMS Error was returned by the modem. 623 | // 624 | // The value is the error value, in string form, which may be the numeric or 625 | // textual, depending on the modem configuration. 626 | type CMSError string 627 | 628 | // ConnectError indicates an attempt to dial failed. 629 | // 630 | // The value of the error is the failure indication returned by the modem. 631 | type ConnectError string 632 | 633 | func (e CMEError) Error() string { 634 | return string("CME Error: " + e) 635 | } 636 | 637 | func (e CMSError) Error() string { 638 | return string("CMS Error: " + e) 639 | } 640 | 641 | func (e ConnectError) Error() string { 642 | return string("Connect: " + e) 643 | } 644 | 645 | var ( 646 | // ErrClosed indicates an operation cannot be performed as the modem has 647 | // been closed. 648 | ErrClosed = errors.New("closed") 649 | 650 | // ErrDeadlineExceeded indicates the modem failed to complete an operation 651 | // within the required time. 652 | ErrDeadlineExceeded = errors.New("deadline exceeded") 653 | 654 | // ErrError indicates the modem returned a generic AT ERROR in response to 655 | // an operation. 656 | ErrError = errors.New("ERROR") 657 | 658 | // ErrIndicationExists indicates there is already a indication registered 659 | // for a prefix. 660 | ErrIndicationExists = errors.New("indication exists") 661 | ) 662 | 663 | // newError parses a line and creates an error corresponding to the content. 664 | func newError(line string) error { 665 | var err error 666 | switch { 667 | case strings.HasPrefix(line, "ERROR"): 668 | err = ErrError 669 | case strings.HasPrefix(line, "+CMS ERROR:"): 670 | err = CMSError(strings.TrimSpace(line[11:])) 671 | case strings.HasPrefix(line, "+CME ERROR:"): 672 | err = CMEError(strings.TrimSpace(line[11:])) 673 | } 674 | return err 675 | } 676 | 677 | // response represents the result of a request operation performed on the 678 | // modem. 679 | // 680 | // info is the collection of lines returned between the command and the status 681 | // line. err corresponds to any error returned by the modem or while 682 | // interacting with the modem. 683 | type response struct { 684 | info []string 685 | err error 686 | } 687 | 688 | // Received line types. 689 | type rxl int 690 | 691 | const ( 692 | rxlUnknown rxl = iota 693 | rxlEchoCmdLine 694 | rxlInfo 695 | rxlStatusOK 696 | rxlStatusError 697 | rxlAsync 698 | rxlSMSPrompt 699 | rxlConnect 700 | rxlConnectError 701 | ) 702 | 703 | // Indication represents an unsolicited result code (URC) from the modem, such 704 | // as a received SMS message. 705 | // 706 | // Indications are lines prefixed with a particular pattern, and may include a 707 | // number of trailing lines. The matching lines are bundled into a slice and 708 | // sent to the handler. 709 | type Indication struct { 710 | prefix string 711 | lines int 712 | handler InfoHandler 713 | } 714 | 715 | func newIndication(prefix string, handler InfoHandler, options ...IndicationOption) Indication { 716 | ind := Indication{ 717 | prefix: prefix, 718 | handler: handler, 719 | lines: 1, 720 | } 721 | for _, option := range options { 722 | option.applyIndicationOption(&ind) 723 | } 724 | return ind 725 | } 726 | 727 | // IndicationOption alters the behavior of the indication. 728 | type IndicationOption interface { 729 | applyIndicationOption(*Indication) 730 | } 731 | 732 | // TrailingLinesOption specifies the number of trailing lines expected after an 733 | // indication line. 734 | type TrailingLinesOption int 735 | 736 | func (o TrailingLinesOption) applyIndicationOption(ind *Indication) { 737 | ind.lines = int(o) + 1 738 | 739 | } 740 | 741 | // WithTrailingLines indicates the number of lines after the line containing 742 | // the indication that arew to be collected as part of the indication. 743 | // 744 | // The default is 0 - only the indication line itself is collected and returned. 745 | func WithTrailingLines(l int) TrailingLinesOption { 746 | return TrailingLinesOption(l) 747 | } 748 | 749 | // WithTrailingLine indicates the indication includes one line after the line 750 | // containing the indication. 751 | var WithTrailingLine = TrailingLinesOption(1) 752 | 753 | // parseCmdID returns the identifier component of the command. 754 | // 755 | // This is the section prior to any '=' or '?' and is generally, but not 756 | // always, used to prefix info lines corresponding to the command. 757 | func parseCmdID(cmdLine string) string { 758 | if idx := strings.IndexAny(cmdLine, "=?"); idx != -1 { 759 | return cmdLine[0:idx] 760 | } 761 | return cmdLine 762 | } 763 | 764 | // parseRxLine parses a received line and identifies the line type. 765 | func parseRxLine(line string, cmdID string) rxl { 766 | switch { 767 | case line == "OK": 768 | return rxlStatusOK 769 | case strings.HasPrefix(line, "ERROR"), 770 | strings.HasPrefix(line, "+CME ERROR:"), 771 | strings.HasPrefix(line, "+CMS ERROR:"): 772 | return rxlStatusError 773 | case strings.HasPrefix(line, cmdID+":"): 774 | return rxlInfo 775 | case line == ">": 776 | return rxlSMSPrompt 777 | case strings.HasPrefix(line, "AT"+cmdID): 778 | return rxlEchoCmdLine 779 | case len(cmdID) == 0 || cmdID[0] != 'D': 780 | // Short circuit non-ATD commands. 781 | // No attempt to identify SMS PDUs at this level, so they will 782 | // be caught here, along with other unidentified lines. 783 | return rxlUnknown 784 | case strings.HasPrefix(line, "CONNECT"): 785 | return rxlConnect 786 | case line == "BUSY", 787 | line == "NO ANSWER", 788 | line == "NO CARRIER", 789 | line == "NO DIALTONE": 790 | return rxlConnectError 791 | default: 792 | // No attempt to identify SMS PDUs at this level, so they will 793 | // be caught here, along with other unidentified lines. 794 | return rxlUnknown 795 | } 796 | } 797 | 798 | // scanLines is a custom line scanner for lineReader that recognises the prompt 799 | // returned by the modem in response to SMS commands such as +CMGS. 800 | func scanLines(data []byte, atEOF bool) (advance int, token []byte, err error) { 801 | // handle SMS prompt special case - no CR at prompt 802 | if len(data) >= 1 && data[0] == '>' { 803 | i := 1 804 | // there may be trailing space, so swallow that... 805 | for ; i < len(data) && data[i] == ' '; i++ { 806 | } 807 | return i, data[0:1], nil 808 | } 809 | return bufio.ScanLines(data, atEOF) 810 | } 811 | 812 | type commandConfig struct { 813 | timeout time.Duration 814 | } 815 | 816 | type initConfig struct { 817 | cmds []string 818 | cmdOpts []CommandOption 819 | } 820 | -------------------------------------------------------------------------------- /at/at_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // Test suite for AT module. 6 | // 7 | // Note that these tests provide a mockModem which does not attempt to emulate 8 | // a serial modem, but which provides responses required to exercise at.go So, 9 | // while the commands may follow the structure of the AT protocol they most 10 | // certainly are not AT commands - just patterns that elicit the behaviour 11 | // required for the test. 12 | 13 | package at_test 14 | 15 | import ( 16 | "errors" 17 | "fmt" 18 | "io" 19 | "testing" 20 | "time" 21 | 22 | "github.com/stretchr/testify/assert" 23 | "github.com/stretchr/testify/require" 24 | "github.com/warthog618/modem/at" 25 | "github.com/warthog618/modem/trace" 26 | ) 27 | 28 | const ( 29 | sub = "\x1a" 30 | esc = "\x1b" 31 | ) 32 | 33 | func TestNew(t *testing.T) { 34 | patterns := []struct { 35 | name string 36 | options []at.Option 37 | }{ 38 | { 39 | "default", 40 | nil, 41 | }, 42 | { 43 | "escTime", 44 | []at.Option{at.WithEscTime(100 * time.Millisecond)}, 45 | }, 46 | { 47 | "timeout", 48 | []at.Option{at.WithTimeout(10 * time.Millisecond)}, 49 | }, 50 | } 51 | for _, p := range patterns { 52 | f := func(t *testing.T) { 53 | // mocked 54 | mm := mockModem{cmdSet: nil, echo: false, r: make(chan []byte, 10)} 55 | defer teardownModem(&mm) 56 | a := at.New(&mm, p.options...) 57 | require.NotNil(t, a) 58 | select { 59 | case <-a.Closed(): 60 | t.Error("modem closed") 61 | default: 62 | } 63 | } 64 | t.Run(p.name, f) 65 | } 66 | } 67 | 68 | func TestWithescTime(t *testing.T) { 69 | cmdSet := map[string][]string{ 70 | // for init 71 | esc + "\r\n\r\n": {"\r\n"}, 72 | "ATZ\r\n": {"OK\r\n"}, 73 | "ATE0\r\n": {"OK\r\n"}, 74 | } 75 | patterns := []struct { 76 | name string 77 | options []at.Option 78 | d time.Duration 79 | }{ 80 | { 81 | "default", 82 | nil, 83 | 20 * time.Millisecond, 84 | }, 85 | { 86 | "100ms", 87 | []at.Option{at.WithEscTime(100 * time.Millisecond)}, 88 | 100 * time.Millisecond, 89 | }, 90 | } 91 | for _, p := range patterns { 92 | f := func(t *testing.T) { 93 | mm := mockModem{cmdSet: cmdSet, echo: false, r: make(chan []byte, 10)} 94 | defer teardownModem(&mm) 95 | a := at.New(&mm, p.options...) 96 | require.NotNil(t, a) 97 | 98 | start := time.Now() 99 | err := a.Init() 100 | assert.Nil(t, err) 101 | end := time.Now() 102 | assert.GreaterOrEqual(t, int64(end.Sub(start)), int64(p.d)) 103 | } 104 | t.Run(p.name, f) 105 | } 106 | } 107 | 108 | func TestWithCmds(t *testing.T) { 109 | cmdSet := map[string][]string{ 110 | // for init 111 | esc + "\r\n\r\n": {"\r\n"}, 112 | "ATZ\r\n": {"OK\r\n"}, 113 | "ATE0\r\n": {"OK\r\n"}, 114 | "AT^CURC=0\r\n": {"OK\r\n"}, 115 | } 116 | patterns := []struct { 117 | name string 118 | options []at.Option 119 | }{ 120 | { 121 | "default", 122 | nil, 123 | }, 124 | { 125 | "cmd", 126 | []at.Option{at.WithCmds("Z")}, 127 | }, 128 | { 129 | "cmds", 130 | []at.Option{at.WithCmds("Z", "Z", "^CURC=0")}, 131 | }, 132 | } 133 | for _, p := range patterns { 134 | f := func(t *testing.T) { 135 | mm := mockModem{cmdSet: cmdSet, echo: false, r: make(chan []byte, 10)} 136 | defer teardownModem(&mm) 137 | a := at.New(&mm, p.options...) 138 | require.NotNil(t, a) 139 | 140 | err := a.Init() 141 | assert.Nil(t, err) 142 | } 143 | t.Run(p.name, f) 144 | } 145 | } 146 | 147 | func TestInit(t *testing.T) { 148 | // mocked 149 | cmdSet := map[string][]string{ 150 | // for init 151 | esc + "\r\n\r\n": {"\r\n"}, 152 | "ATZ\r\n": {"OK\r\n"}, 153 | "ATE0\r\n": {"OK\r\n"}, 154 | "AT^CURC=0\r\n": {"OK\r\n"}, 155 | } 156 | mm := mockModem{cmdSet: cmdSet, echo: false, r: make(chan []byte, 10)} 157 | defer teardownModem(&mm) 158 | a := at.New(&mm) 159 | require.NotNil(t, a) 160 | err := a.Init() 161 | require.Nil(t, err) 162 | select { 163 | case <-a.Closed(): 164 | t.Error("modem closed") 165 | default: 166 | } 167 | 168 | // residual OKs 169 | mm.r <- []byte("\r\nOK\r\nOK\r\n") 170 | err = a.Init() 171 | assert.Nil(t, err) 172 | 173 | // residual ERRORs 174 | mm.r <- []byte("\r\nERROR\r\nERROR\r\n") 175 | err = a.Init() 176 | assert.Nil(t, err) 177 | 178 | // customised commands 179 | err = a.Init(at.WithCmds("Z", "Z", "^CURC=0")) 180 | assert.Nil(t, err) 181 | } 182 | 183 | func TestInitFailure(t *testing.T) { 184 | cmdSet := map[string][]string{ 185 | // for init 186 | esc + "\r\n\r\n": {"\r\n"}, 187 | "ATZ\r\n": {"ERROR\r\n"}, 188 | "ATE0\r\n": {"OK\r\n"}, 189 | } 190 | mm := mockModem{cmdSet: cmdSet, echo: false, r: make(chan []byte, 10)} 191 | defer teardownModem(&mm) 192 | a := at.New(&mm) 193 | require.NotNil(t, a) 194 | err := a.Init() 195 | assert.NotNil(t, err) 196 | select { 197 | case <-a.Closed(): 198 | t.Error("modem closed") 199 | default: 200 | } 201 | 202 | // lone E0 should work 203 | err = a.Init(at.WithCmds("E0")) 204 | assert.Nil(t, err) 205 | } 206 | 207 | func TestCloseInInitTimeout(t *testing.T) { 208 | cmdSet := map[string][]string{ 209 | // for init 210 | esc + "\r\n\r\n": {"\r\n"}, 211 | "ATZ\r\n": {""}, 212 | } 213 | mm := mockModem{cmdSet: cmdSet, echo: false, r: make(chan []byte, 10)} 214 | defer teardownModem(&mm) 215 | a := at.New(&mm) 216 | require.NotNil(t, a) 217 | err := a.Init(at.WithTimeout(10 * time.Millisecond)) 218 | assert.Equal(t, at.ErrDeadlineExceeded, err) 219 | } 220 | 221 | func TestCommand(t *testing.T) { 222 | cmdSet := map[string][]string{ 223 | "AT\r\n": {"OK\r\n"}, 224 | "ATPASS\r\n": {"OK\r\n"}, 225 | "ATINFO=1\r\n": {"info1\r\n", "info2\r\n", "INFO: info3\r\n", "\r\n", "OK\r\n"}, 226 | "ATCMS\r\n": {"+CMS ERROR: 204\r\n"}, 227 | "ATCME\r\n": {"+CME ERROR: 42\r\n"}, 228 | "ATD1\r\n": {"CONNECT: 57600\r\n"}, 229 | "ATD2\r\n": {"info1\r\n", "BUSY\r\n"}, 230 | "ATD3\r\n": {"NO ANSWER\r\n"}, 231 | "ATD4\r\n": {"NO CARRIER\r\n"}, 232 | "ATD5\r\n": {"NO DIALTONE\r\n"}, 233 | } 234 | m, mm := setupModem(t, cmdSet) 235 | defer teardownModem(mm) 236 | patterns := []struct { 237 | name string 238 | options []at.CommandOption 239 | cmd string 240 | mutator func() 241 | info []string 242 | err error 243 | }{ 244 | { 245 | "empty", 246 | nil, 247 | "", 248 | nil, 249 | nil, 250 | nil, 251 | }, 252 | { 253 | "pass", 254 | nil, 255 | "PASS", 256 | nil, 257 | nil, 258 | nil, 259 | }, 260 | { 261 | "info", 262 | nil, 263 | "INFO=1", 264 | nil, 265 | []string{"info1", "info2", "INFO: info3"}, 266 | nil, 267 | }, 268 | { 269 | "err", 270 | nil, 271 | "ERR", 272 | nil, 273 | nil, 274 | at.ErrError, 275 | }, 276 | { 277 | "cms", 278 | nil, 279 | "CMS", 280 | nil, 281 | nil, 282 | at.CMSError("204"), 283 | }, 284 | { 285 | "cme", 286 | nil, 287 | "CME", 288 | nil, 289 | nil, 290 | at.CMEError("42"), 291 | }, 292 | { 293 | "dial ok", 294 | nil, 295 | "D1", 296 | nil, 297 | []string{"CONNECT: 57600"}, 298 | nil, 299 | }, 300 | { 301 | "dial busy", 302 | nil, 303 | "D2", 304 | nil, 305 | []string{"info1"}, 306 | at.ConnectError("BUSY"), 307 | }, 308 | { 309 | "dial no answer", 310 | nil, 311 | "D3", 312 | nil, 313 | nil, 314 | at.ConnectError("NO ANSWER"), 315 | }, 316 | { 317 | "dial no carrier", 318 | nil, 319 | "D4", 320 | nil, 321 | nil, 322 | at.ConnectError("NO CARRIER"), 323 | }, 324 | { 325 | "dial no dialtone", 326 | nil, 327 | "D5", 328 | nil, 329 | nil, 330 | at.ConnectError("NO DIALTONE"), 331 | }, 332 | { 333 | "no echo", 334 | nil, 335 | "INFO=1", 336 | func() { mm.echo = false }, 337 | []string{"info1", "info2", "INFO: info3"}, 338 | nil, 339 | }, 340 | { 341 | "timeout", 342 | []at.CommandOption{at.WithTimeout(0)}, 343 | "", 344 | func() { mm.readDelay = time.Millisecond }, 345 | nil, 346 | at.ErrDeadlineExceeded, 347 | }, 348 | { 349 | "write error", 350 | nil, 351 | "PASS", 352 | func() { 353 | m, mm = setupModem(t, cmdSet) 354 | mm.errOnWrite = true 355 | }, 356 | nil, 357 | errors.New("Write error"), 358 | }, 359 | { 360 | "closed before response", 361 | nil, 362 | "NULL", 363 | func() { 364 | mm.closeOnWrite = true 365 | }, 366 | nil, 367 | at.ErrClosed, 368 | }, 369 | { 370 | "closed before request", 371 | nil, 372 | "PASS", 373 | func() { <-m.Closed() }, 374 | nil, 375 | at.ErrClosed, 376 | }, 377 | } 378 | for _, p := range patterns { 379 | f := func(t *testing.T) { 380 | if p.mutator != nil { 381 | p.mutator() 382 | } 383 | info, err := m.Command(p.cmd, p.options...) 384 | assert.Equal(t, p.err, err) 385 | assert.Equal(t, p.info, info) 386 | } 387 | t.Run(p.name, f) 388 | } 389 | } 390 | 391 | func TestCommandClosedIdle(t *testing.T) { 392 | // retest this case separately to catch closure while cmdProcessor is idle. 393 | // (otherwise that code path can be skipped) 394 | m, mm := setupModem(t, nil) 395 | defer teardownModem(mm) 396 | mm.Close() 397 | select { 398 | case <-m.Closed(): 399 | case <-time.Tick(10 * time.Millisecond): 400 | t.Error("Timeout waiting for modem to close") 401 | } 402 | } 403 | 404 | func TestCommandClosedOnWrite(t *testing.T) { 405 | // retest this case separately to catch closure on the write to modem. 406 | m, mm := setupModem(t, nil) 407 | defer teardownModem(mm) 408 | mm.closeOnWrite = true 409 | info, err := m.Command("PASS") 410 | assert.Equal(t, at.ErrClosed, err) 411 | assert.Nil(t, info) 412 | 413 | // closed before request 414 | info, err = m.Command("PASS") 415 | assert.Equal(t, at.ErrClosed, err) 416 | assert.Nil(t, info) 417 | } 418 | 419 | func TestCommandClosedPreWrite(t *testing.T) { 420 | // retest this case separately to catch closure on the write to modem. 421 | m, mm := setupModem(t, nil) 422 | defer teardownModem(mm) 423 | mm.Close() 424 | // closed before request 425 | info, err := m.Command("PASS") 426 | assert.Equal(t, at.ErrClosed, err) 427 | assert.Nil(t, info) 428 | } 429 | 430 | func TestSMSCommand(t *testing.T) { 431 | cmdSet := map[string][]string{ 432 | "ATCMS\r": {"\r\n+CMS ERROR: 204\r\n"}, 433 | "ATCME\r": {"\r\n+CME ERROR: 42\r\n"}, 434 | "ATSMS\r": {"\n>"}, 435 | "ATSMS2\r": {"\n> "}, 436 | "info" + sub: {"\r\n", "info1\r\n", "info2\r\n", "INFO: info3\r\n", "\r\n", "OK\r\n"}, 437 | "sms+" + sub: {"\r\n", "info4\r\n", "info5\r\n", "INFO: info6\r\n", "\r\n", "OK\r\n"}, 438 | } 439 | m, mm := setupModem(t, cmdSet) 440 | defer teardownModem(mm) 441 | patterns := []struct { 442 | name string 443 | options []at.CommandOption 444 | cmd1 string 445 | cmd2 string 446 | mutator func() 447 | info []string 448 | err error 449 | }{ 450 | { 451 | "empty", 452 | nil, 453 | "", 454 | "", 455 | nil, 456 | nil, 457 | at.ErrError, 458 | }, 459 | { 460 | "ok", 461 | nil, 462 | "SMS", 463 | "sms+", 464 | nil, 465 | []string{"info4", "info5", "INFO: info6"}, 466 | nil, 467 | }, 468 | { 469 | "info", 470 | nil, 471 | "SMS", 472 | "info", 473 | nil, 474 | []string{"info1", "info2", "INFO: info3"}, 475 | nil, 476 | }, 477 | { 478 | "err", 479 | nil, 480 | "ERR", 481 | "errsms", 482 | nil, 483 | nil, 484 | at.ErrError, 485 | }, 486 | { 487 | "cms", 488 | nil, 489 | "CMS", 490 | "cmssms", 491 | nil, 492 | nil, 493 | at.CMSError("204"), 494 | }, 495 | { 496 | "cme", 497 | nil, 498 | "CME", 499 | "cmesms", 500 | nil, 501 | nil, 502 | at.CMEError("42"), 503 | }, 504 | { 505 | "no echo", 506 | nil, 507 | "SMS2", 508 | "info", 509 | func() { mm.echo = false }, 510 | []string{"info1", "info2", "INFO: info3"}, 511 | nil, 512 | }, 513 | { 514 | "timeout", 515 | []at.CommandOption{at.WithTimeout(0)}, 516 | "SMS2", 517 | "info", 518 | func() { mm.readDelay = time.Millisecond }, 519 | nil, 520 | at.ErrDeadlineExceeded, 521 | }, 522 | { 523 | "write error", 524 | nil, 525 | "EoW", 526 | "errOnWrite", 527 | func() { 528 | m, mm = setupModem(t, cmdSet) 529 | mm.errOnWrite = true 530 | }, 531 | nil, 532 | errors.New("Write error"), 533 | }, 534 | { 535 | "closed before response", 536 | nil, 537 | "CoW", 538 | "closeOnWrite", 539 | func() { 540 | mm.closeOnWrite = true 541 | }, 542 | nil, 543 | at.ErrClosed, 544 | }, 545 | { 546 | "closed before request", 547 | nil, 548 | "C", 549 | "closed", 550 | func() { <-m.Closed() }, 551 | nil, 552 | at.ErrClosed, 553 | }, 554 | } 555 | for _, p := range patterns { 556 | f := func(t *testing.T) { 557 | if p.mutator != nil { 558 | p.mutator() 559 | } 560 | info, err := m.SMSCommand(p.cmd1, p.cmd2, p.options...) 561 | assert.Equal(t, p.err, err) 562 | assert.Equal(t, p.info, info) 563 | } 564 | t.Run(p.name, f) 565 | } 566 | } 567 | 568 | func TestSMSCommandClosedPrePDU(t *testing.T) { 569 | // test case where modem closes between SMS prompt and PDU. 570 | cmdSet := map[string][]string{ 571 | "ATSMS\r": {"\n>"}, 572 | } 573 | m, mm := setupModem(t, cmdSet) 574 | defer teardownModem(mm) 575 | mm.echo = false 576 | mm.closeOnSMSPrompt = true 577 | done := make(chan struct{}) 578 | // Need to queue multiple commands to check queued commands code path. 579 | go func() { 580 | info, err := m.SMSCommand("SMS", "closed") 581 | assert.NotNil(t, err) 582 | assert.Nil(t, info) 583 | close(done) 584 | }() 585 | info, err := m.SMSCommand("SMS", "closed") 586 | assert.NotNil(t, err) 587 | assert.Nil(t, info) 588 | <-done 589 | } 590 | 591 | func TestAddIndication(t *testing.T) { 592 | m, mm := setupModem(t, nil) 593 | defer teardownModem(mm) 594 | 595 | c := make(chan []string) 596 | handler := func(info []string) { 597 | c <- info 598 | } 599 | err := m.AddIndication("notify", handler) 600 | assert.Nil(t, err) 601 | select { 602 | case n := <-c: 603 | t.Errorf("got notification without write: %v", n) 604 | default: 605 | } 606 | mm.r <- []byte("notify: :yfiton\r\n") 607 | select { 608 | case n := <-c: 609 | assert.Equal(t, []string{"notify: :yfiton"}, n) 610 | case <-time.After(100 * time.Millisecond): 611 | t.Errorf("no notification received") 612 | } 613 | err = m.AddIndication("notify", handler) 614 | assert.Equal(t, at.ErrIndicationExists, err) 615 | 616 | err = m.AddIndication("foo", handler, at.WithTrailingLines(2)) 617 | assert.Nil(t, err) 618 | mm.r <- []byte("foo:\r\nbar\r\nbaz\r\n") 619 | select { 620 | case n := <-c: 621 | assert.Equal(t, []string{"foo:", "bar", "baz"}, n) 622 | case <-time.After(100 * time.Millisecond): 623 | t.Errorf("no notification received") 624 | } 625 | } 626 | 627 | func TestWithIndication(t *testing.T) { 628 | c := make(chan []string) 629 | handler := func(info []string) { 630 | c <- info 631 | } 632 | m, mm := setupModem(t, 633 | nil, 634 | at.WithIndication("notify", handler), 635 | at.WithIndication("foo", handler, at.WithTrailingLines(2))) 636 | defer teardownModem(mm) 637 | 638 | select { 639 | case n := <-c: 640 | t.Errorf("got notification without write: %v", n) 641 | default: 642 | } 643 | mm.r <- []byte("notify: :yfiton\r\n") 644 | select { 645 | case n := <-c: 646 | assert.Equal(t, []string{"notify: :yfiton"}, n) 647 | case <-time.After(100 * time.Millisecond): 648 | t.Errorf("no notification received") 649 | } 650 | err := m.AddIndication("notify", handler) 651 | assert.Equal(t, at.ErrIndicationExists, err) 652 | 653 | mm.r <- []byte("foo:\r\nbar\r\nbaz\r\n") 654 | select { 655 | case n := <-c: 656 | assert.Equal(t, []string{"foo:", "bar", "baz"}, n) 657 | case <-time.After(100 * time.Millisecond): 658 | t.Errorf("no notification received") 659 | } 660 | } 661 | 662 | func TestCancelIndication(t *testing.T) { 663 | m, mm := setupModem(t, nil) 664 | defer teardownModem(mm) 665 | 666 | c := make(chan []string) 667 | handler := func(info []string) { 668 | c <- info 669 | } 670 | err := m.AddIndication("notify", handler) 671 | assert.Nil(t, err) 672 | 673 | err = m.AddIndication("foo", handler, at.WithTrailingLines(2)) 674 | assert.Nil(t, err) 675 | 676 | m.CancelIndication("notify") 677 | mm.r <- []byte("foo:\r\nbar\r\nbaz\r\n") 678 | select { 679 | case n := <-c: 680 | assert.Equal(t, []string{"foo:", "bar", "baz"}, n) 681 | case <-time.After(100 * time.Millisecond): 682 | t.Errorf("no notification received") 683 | } 684 | 685 | mm.Close() 686 | select { 687 | case <-time.After(10 * time.Millisecond): 688 | t.Fatal("modem failed to close") 689 | case <-m.Closed(): 690 | } 691 | // for coverage of cancel while closed 692 | m.CancelIndication("foo") 693 | } 694 | 695 | func TestAddIndicationClose(t *testing.T) { 696 | handler := func(info []string) { 697 | t.Error("returned partial info") 698 | } 699 | m, mm := setupModem(t, nil, 700 | at.WithIndication("foo:", handler, at.WithTrailingLines(2))) 701 | defer teardownModem(mm) 702 | 703 | mm.r <- []byte("foo:\r\nbar\r\n") 704 | mm.Close() 705 | select { 706 | case <-m.Closed(): 707 | case <-time.After(100 * time.Millisecond): 708 | t.Error("modem still open") 709 | } 710 | } 711 | 712 | func TestAddIndicationClosed(t *testing.T) { 713 | m, mm := setupModem(t, nil) 714 | defer teardownModem(mm) 715 | 716 | handler := func(info []string) { 717 | } 718 | mm.Close() 719 | select { 720 | case <-time.After(10 * time.Millisecond): 721 | t.Fatal("modem failed to close") 722 | case <-m.Closed(): 723 | } 724 | err := m.AddIndication("notify", handler) 725 | assert.Equal(t, at.ErrClosed, err) 726 | } 727 | 728 | func TestCMEError(t *testing.T) { 729 | patterns := []string{"1", "204", "42"} 730 | for _, p := range patterns { 731 | f := func(t *testing.T) { 732 | err := at.CMEError(p) 733 | expected := fmt.Sprintf("CME Error: %s", string(err)) 734 | assert.Equal(t, expected, err.Error()) 735 | } 736 | t.Run(fmt.Sprintf("%x", p), f) 737 | } 738 | } 739 | 740 | func TestCMSError(t *testing.T) { 741 | patterns := []string{"1", "204", "42"} 742 | for _, p := range patterns { 743 | f := func(t *testing.T) { 744 | err := at.CMSError(p) 745 | expected := fmt.Sprintf("CMS Error: %s", string(err)) 746 | assert.Equal(t, expected, err.Error()) 747 | } 748 | t.Run(fmt.Sprintf("%x", p), f) 749 | } 750 | } 751 | 752 | func TestConnectError(t *testing.T) { 753 | patterns := []string{"1", "204", "42"} 754 | for _, p := range patterns { 755 | f := func(t *testing.T) { 756 | err := at.ConnectError(p) 757 | expected := fmt.Sprintf("Connect: %s", string(err)) 758 | assert.Equal(t, expected, err.Error()) 759 | } 760 | t.Run(fmt.Sprintf("%x", p), f) 761 | } 762 | } 763 | 764 | type mockModem struct { 765 | cmdSet map[string][]string 766 | closeOnWrite bool 767 | closeOnSMSPrompt bool 768 | errOnWrite bool 769 | echo bool 770 | closed bool 771 | readDelay time.Duration 772 | // The buffer emulating characters emitted by the modem. 773 | r chan []byte 774 | } 775 | 776 | func (m *mockModem) Read(p []byte) (n int, err error) { 777 | data, ok := <-m.r 778 | if data == nil { 779 | return 0, at.ErrClosed 780 | } 781 | time.Sleep(m.readDelay) 782 | copy(p, data) // assumes p is empty 783 | if !ok { 784 | return len(data), errors.New("closed with data") 785 | } 786 | return len(data), nil 787 | } 788 | 789 | func (m *mockModem) Write(p []byte) (n int, err error) { 790 | if m.closed { 791 | return 0, at.ErrClosed 792 | } 793 | if m.closeOnWrite { 794 | m.closeOnWrite = false 795 | m.Close() 796 | return len(p), nil 797 | } 798 | if m.errOnWrite { 799 | return 0, errors.New("Write error") 800 | } 801 | if m.echo { 802 | m.r <- p 803 | } 804 | v := m.cmdSet[string(p)] 805 | if len(v) == 0 { 806 | m.r <- []byte("\r\nERROR\r\n") 807 | } else { 808 | for _, l := range v { 809 | if len(l) == 0 { 810 | continue 811 | } 812 | m.r <- []byte(l) 813 | if m.closeOnSMSPrompt && len(l) > 1 && l[1] == '>' { 814 | m.Close() 815 | } 816 | } 817 | } 818 | return len(p), nil 819 | } 820 | 821 | func (m *mockModem) Close() error { 822 | if m.closed == false { 823 | m.closed = true 824 | close(m.r) 825 | } 826 | return nil 827 | } 828 | 829 | func setupModem(t *testing.T, cmdSet map[string][]string, options ...at.Option) (*at.AT, *mockModem) { 830 | mm := &mockModem{cmdSet: cmdSet, echo: true, r: make(chan []byte, 10)} 831 | var modem io.ReadWriter = mm 832 | debug := false // set to true to enable tracing of the flow to the mockModem. 833 | if debug { 834 | modem = trace.New(modem) 835 | } 836 | a := at.New(modem, options...) 837 | require.NotNil(t, a) 838 | return a, mm 839 | } 840 | 841 | func teardownModem(m *mockModem) { 842 | m.Close() 843 | } 844 | -------------------------------------------------------------------------------- /cmd/modeminfo/.gitignore: -------------------------------------------------------------------------------- 1 | modeminfo 2 | -------------------------------------------------------------------------------- /cmd/modeminfo/modeminfo.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // modeminfo collects and displays information related to the modem and its 6 | // current configuration. 7 | // 8 | // This serves as an example of how interact with a modem, as well as 9 | // providing information which may be useful for debugging. 10 | package main 11 | 12 | import ( 13 | "flag" 14 | "fmt" 15 | "io" 16 | "log" 17 | "os" 18 | "time" 19 | 20 | "github.com/warthog618/modem/at" 21 | "github.com/warthog618/modem/serial" 22 | "github.com/warthog618/modem/trace" 23 | ) 24 | 25 | var version = "undefined" 26 | 27 | func main() { 28 | dev := flag.String("d", "/dev/ttyUSB0", "path to modem device") 29 | baud := flag.Int("b", 115200, "baud rate") 30 | timeout := flag.Duration("t", 400*time.Millisecond, "command timeout period") 31 | verbose := flag.Bool("v", false, "log modem interactions") 32 | vsn := flag.Bool("version", false, "report version and exit") 33 | flag.Parse() 34 | if *vsn { 35 | fmt.Printf("%s %s\n", os.Args[0], version) 36 | os.Exit(0) 37 | } 38 | m, err := serial.New(serial.WithPort(*dev), serial.WithBaud(*baud)) 39 | if err != nil { 40 | log.Println(err) 41 | return 42 | } 43 | defer m.Close() 44 | var mio io.ReadWriter = m 45 | if *verbose { 46 | mio = trace.New(m) 47 | } 48 | a := at.New(mio, at.WithTimeout(*timeout)) 49 | err = a.Init() 50 | if err != nil { 51 | log.Println(err) 52 | return 53 | } 54 | cmds := []string{ 55 | "I", 56 | "+GCAP", 57 | "+CMEE=2", 58 | "+CGMI", 59 | "+CGMM", 60 | "+CGMR", 61 | "+CGSN", 62 | "+CSQ", 63 | "+CIMI", 64 | "+CREG?", 65 | "+CNUM", 66 | "+CPIN?", 67 | "+CEER", 68 | "+CSCA?", 69 | "+CSMS?", 70 | "+CSMS=?", 71 | "+CPMS=?", 72 | "+CCID?", 73 | "+CCID=?", 74 | "^ICCID?", 75 | "+CNMI?", 76 | "+CNMI=?", 77 | "+CNMA=?", 78 | "+CMGF?", 79 | "+CMGF=?", 80 | "+CUSD?", 81 | "+CUSD=?", 82 | "^USSDMODE?", 83 | "^USSDMODE=?", 84 | } 85 | for _, cmd := range cmds { 86 | info, err := a.Command(cmd) 87 | fmt.Println("AT" + cmd) 88 | if err != nil { 89 | fmt.Printf(" %s\n", err) 90 | continue 91 | } 92 | for _, l := range info { 93 | fmt.Printf(" %s\n", l) 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /cmd/phonebook/.gitignore: -------------------------------------------------------------------------------- 1 | phonebook 2 | -------------------------------------------------------------------------------- /cmd/phonebook/phonebook.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // phonebook dumps the contents of the modem SIM phonebook. 6 | // 7 | // This provides an example of processing the info returned by the modem. 8 | package main 9 | 10 | import ( 11 | "encoding/hex" 12 | "flag" 13 | "fmt" 14 | "io" 15 | "log" 16 | "os" 17 | "strings" 18 | "time" 19 | 20 | "github.com/warthog618/modem/at" 21 | "github.com/warthog618/modem/gsm" 22 | "github.com/warthog618/modem/info" 23 | "github.com/warthog618/modem/serial" 24 | "github.com/warthog618/modem/trace" 25 | ) 26 | 27 | var version = "undefined" 28 | 29 | func main() { 30 | dev := flag.String("d", "/dev/ttyUSB0", "path to modem device") 31 | baud := flag.Int("b", 115200, "baud rate") 32 | timeout := flag.Duration("t", 400*time.Millisecond, "command timeout period") 33 | verbose := flag.Bool("v", false, "log modem interactions") 34 | vsn := flag.Bool("version", false, "report version and exit") 35 | flag.Parse() 36 | if *vsn { 37 | fmt.Printf("%s %s\n", os.Args[0], version) 38 | os.Exit(0) 39 | } 40 | m, err := serial.New(serial.WithPort(*dev), serial.WithBaud(*baud)) 41 | if err != nil { 42 | log.Println(err) 43 | return 44 | } 45 | var mio io.ReadWriter = m 46 | if *verbose { 47 | mio = trace.New(m) 48 | } 49 | g := gsm.New(at.New(mio, at.WithTimeout(*timeout))) 50 | err = g.Init() 51 | if err != nil { 52 | log.Println(err) 53 | return 54 | } 55 | i, err := g.Command("+CPBR=1,99") 56 | if err != nil { 57 | log.Println(err) 58 | return 59 | } 60 | for _, l := range i { 61 | if !info.HasPrefix(l, "+CPBR") { 62 | continue 63 | } 64 | entry := strings.Split(info.TrimPrefix(l, "+CPBR"), ",") 65 | nameh := []byte(strings.Trim(entry[3], "\"")) 66 | name := make([]byte, hex.DecodedLen(len(nameh))) 67 | n, err := hex.Decode(name, nameh) 68 | if err != nil { 69 | log.Fatal("decode error ", err) 70 | } 71 | fmt.Printf("%2s %-10s %s\n", entry[0], strings.Trim(entry[1], "\""), name[:n]) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /cmd/sendsms/.gitignore: -------------------------------------------------------------------------------- 1 | sendsms 2 | -------------------------------------------------------------------------------- /cmd/sendsms/sendsms.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // sendsms sends an SMS using the modem. 6 | // 7 | // This provides an example of using the SendSMS command, as well as a test 8 | // that the library works with the modem. 9 | package main 10 | 11 | import ( 12 | "flag" 13 | "fmt" 14 | "io" 15 | "log" 16 | "os" 17 | "time" 18 | 19 | "github.com/warthog618/modem/at" 20 | "github.com/warthog618/modem/gsm" 21 | "github.com/warthog618/modem/serial" 22 | "github.com/warthog618/modem/trace" 23 | "github.com/warthog618/sms" 24 | ) 25 | 26 | var version = "undefined" 27 | 28 | func main() { 29 | dev := flag.String("d", "/dev/ttyUSB0", "path to modem device") 30 | baud := flag.Int("b", 115200, "baud rate") 31 | num := flag.String("n", "+12345", "number to send to, in international format") 32 | msg := flag.String("m", "Zoot Zoot", "the message to send") 33 | timeout := flag.Duration("t", 5*time.Second, "command timeout period") 34 | verbose := flag.Bool("v", false, "log modem interactions") 35 | pdumode := flag.Bool("p", false, "send in PDU mode") 36 | hex := flag.Bool("x", false, "hex dump modem responses") 37 | vsn := flag.Bool("version", false, "report version and exit") 38 | flag.Parse() 39 | if *vsn { 40 | fmt.Printf("%s %s\n", os.Args[0], version) 41 | os.Exit(0) 42 | } 43 | m, err := serial.New(serial.WithPort(*dev), serial.WithBaud(*baud)) 44 | if err != nil { 45 | log.Fatal(err) 46 | } 47 | var mio io.ReadWriter = m 48 | if *hex { 49 | mio = trace.New(m, trace.WithReadFormat("r: %v")) 50 | } else if *verbose { 51 | mio = trace.New(m) 52 | } 53 | gopts := []gsm.Option{} 54 | if !*pdumode { 55 | gopts = append(gopts, gsm.WithTextMode) 56 | } 57 | g := gsm.New(at.New(mio, at.WithTimeout(*timeout)), gopts...) 58 | if err = g.Init(); err != nil { 59 | log.Fatal(err) 60 | } 61 | if *pdumode { 62 | sendPDU(g, *num, *msg) 63 | return 64 | } 65 | mr, err := g.SendShortMessage(*num, *msg) 66 | // !!! check CPIN?? on failure to determine root cause?? If ERROR 302 67 | log.Printf("%v %v\n", mr, err) 68 | } 69 | 70 | func sendPDU(g *gsm.GSM, number string, msg string) { 71 | pdus, err := sms.Encode([]byte(msg), sms.To(number), sms.WithAllCharsets) 72 | if err != nil { 73 | log.Fatal(err) 74 | } 75 | for i, p := range pdus { 76 | tp, err := p.MarshalBinary() 77 | if err != nil { 78 | log.Fatal(err) 79 | } 80 | mr, err := g.SendPDU(tp) 81 | if err != nil { 82 | // !!! check CPIN?? on failure to determine root cause?? If ERROR 302 83 | log.Fatal(err) 84 | } 85 | log.Printf("PDU %d: %v\n", i+1, mr) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /cmd/ussd/ussd.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2020 Kent Gibson . 4 | 5 | // ussd sends an USSD message using the modem. 6 | // 7 | // This provides an example of using commands and indications. 8 | package main 9 | 10 | import ( 11 | "encoding/hex" 12 | "flag" 13 | "fmt" 14 | "io" 15 | "log" 16 | "os" 17 | "strings" 18 | "time" 19 | 20 | "github.com/warthog618/modem/at" 21 | "github.com/warthog618/modem/info" 22 | "github.com/warthog618/modem/serial" 23 | "github.com/warthog618/modem/trace" 24 | "github.com/warthog618/sms/encoding/gsm7" 25 | ) 26 | 27 | var version = "undefined" 28 | 29 | func main() { 30 | dev := flag.String("d", "/dev/ttyUSB0", "path to modem device") 31 | baud := flag.Int("b", 115200, "baud rate") 32 | dcs := flag.Int("n", 15, "DCS field") 33 | msg := flag.String("m", "*101#", "the message to send") 34 | timeout := flag.Duration("t", 5*time.Second, "command timeout period") 35 | verbose := flag.Bool("v", false, "log modem interactions") 36 | vsn := flag.Bool("version", false, "report version and exit") 37 | flag.Parse() 38 | if *vsn { 39 | fmt.Printf("%s %s\n", os.Args[0], version) 40 | os.Exit(0) 41 | } 42 | m, err := serial.New(serial.WithPort(*dev), serial.WithBaud(*baud)) 43 | if err != nil { 44 | log.Fatal(err) 45 | } 46 | var mio io.ReadWriter = m 47 | if *verbose { 48 | mio = trace.New(m) 49 | } 50 | a := at.New(mio, at.WithTimeout(*timeout)) 51 | if err = a.Init(); err != nil { 52 | log.Fatal(err) 53 | } 54 | rspChan := make(chan string) 55 | handler := func(info []string) { 56 | rspChan <- info[0] 57 | } 58 | a.AddIndication("+CUSD:", handler) 59 | hmsg := strings.ToUpper(hex.EncodeToString(gsm7.Pack7BitUSSD([]byte(*msg), 0))) 60 | cmd := fmt.Sprintf("+CUSD=1,\"%s\",%d", hmsg, *dcs) 61 | _, err = a.Command(cmd) 62 | if err != nil { 63 | log.Fatal(err) 64 | } 65 | select { 66 | case <-time.After(*timeout): 67 | fmt.Println("No response...") 68 | case rsp := <-rspChan: 69 | fields := strings.Split(info.TrimPrefix(rsp, "+CUSD"), ",") 70 | rspb, _ := hex.DecodeString(strings.Trim(fields[1], "\"")) 71 | rspb = gsm7.Unpack7BitUSSD(rspb, 0) 72 | fmt.Println(string(rspb)) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /cmd/waitsms/.gitignore: -------------------------------------------------------------------------------- 1 | waitsms 2 | -------------------------------------------------------------------------------- /cmd/waitsms/waitsms.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // waitsms waits for SMSs to be received by the modem, and dumps them to 6 | // stdout. 7 | // 8 | // This provides an example of using indications, as well as a test that the 9 | // library works with the modem. 10 | // 11 | // The modem device provided must support notifications or no SMSs will be seen. 12 | // (the notification port is typically USB2, hence the default) 13 | package main 14 | 15 | import ( 16 | "flag" 17 | "fmt" 18 | "io" 19 | "log" 20 | "os" 21 | "time" 22 | 23 | "github.com/warthog618/modem/at" 24 | "github.com/warthog618/modem/gsm" 25 | "github.com/warthog618/modem/serial" 26 | "github.com/warthog618/modem/trace" 27 | ) 28 | 29 | var version = "undefined" 30 | 31 | func main() { 32 | dev := flag.String("d", "/dev/ttyUSB2", "path to modem device") 33 | baud := flag.Int("b", 115200, "baud rate") 34 | period := flag.Duration("p", 10*time.Minute, "period to wait") 35 | timeout := flag.Duration("t", 400*time.Millisecond, "command timeout period") 36 | verbose := flag.Bool("v", false, "log modem interactions") 37 | hex := flag.Bool("x", false, "hex dump modem responses") 38 | vsn := flag.Bool("version", false, "report version and exit") 39 | flag.Parse() 40 | if *vsn { 41 | fmt.Printf("%s %s\n", os.Args[0], version) 42 | os.Exit(0) 43 | } 44 | m, err := serial.New(serial.WithPort(*dev), serial.WithBaud(*baud)) 45 | if err != nil { 46 | log.Println(err) 47 | return 48 | } 49 | defer m.Close() 50 | var mio io.ReadWriter = m 51 | if *hex { 52 | mio = trace.New(m, trace.WithReadFormat("r: %v")) 53 | } else if *verbose { 54 | mio = trace.New(m) 55 | } 56 | g := gsm.New(at.New(mio, at.WithTimeout(*timeout))) 57 | err = g.Init() 58 | if err != nil { 59 | log.Println(err) 60 | return 61 | } 62 | 63 | go pollSignalQuality(g, timeout) 64 | 65 | err = g.StartMessageRx( 66 | func(msg gsm.Message) { 67 | log.Printf("%s: %s\n", msg.Number, msg.Message) 68 | }, 69 | func(err error) { 70 | log.Printf("err: %v\n", err) 71 | }) 72 | if err != nil { 73 | log.Println(err) 74 | return 75 | } 76 | defer g.StopMessageRx() 77 | 78 | for { 79 | select { 80 | case <-time.After(*period): 81 | log.Println("exiting...") 82 | return 83 | case <-g.Closed(): 84 | log.Fatal("modem closed, exiting...") 85 | } 86 | } 87 | } 88 | 89 | // pollSignalQuality polls the modem to read signal quality every minute. 90 | // 91 | // This is run in parallel to SMS reception to demonstrate separate goroutines 92 | // interacting with the modem. 93 | func pollSignalQuality(g *gsm.GSM, timeout *time.Duration) { 94 | for { 95 | select { 96 | case <-time.After(time.Minute): 97 | i, err := g.Command("+CSQ") 98 | if err != nil { 99 | log.Println(err) 100 | } else { 101 | log.Printf("Signal quality: %v\n", i) 102 | } 103 | case <-g.Closed(): 104 | return 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/warthog618/modem 2 | 3 | require ( 4 | github.com/pkg/errors v0.9.1 5 | github.com/stretchr/testify v1.4.0 6 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 7 | github.com/warthog618/sms v0.3.0 8 | golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 // indirect 9 | ) 10 | 11 | go 1.13 12 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 6 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 7 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 8 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 9 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 10 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 11 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 12 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 13 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 14 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 15 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 16 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 17 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07 h1:UyzmZLoiDWMRywV4DUYb9Fbt8uiOSooupjTq10vpvnU= 18 | github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA= 19 | github.com/warthog618/sms v0.3.0 h1:LYAb5ngmu2qjNExgji3B7xi2tIZ9+DsuE9pC5xs4wwc= 20 | github.com/warthog618/sms v0.3.0/go.mod h1:+bYZGeBxu003sxD5xhzsrIPBAjPBzTABsRTwSpd7ld4= 21 | golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HXxFpW2sdCiP8MJSKLQY= 22 | golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 23 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 24 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= 25 | gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 26 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 27 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= 28 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 29 | -------------------------------------------------------------------------------- /gsm/README.md: -------------------------------------------------------------------------------- 1 | # gsm 2 | 3 | A high level Go driver for GSM modems. 4 | 5 | [![Build Status](https://travis-ci.org/warthog618/modem.svg)](https://travis-ci.org/warthog618/modem) 6 | [![go.dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/github.com/warthog618/modem/gsm) 7 | [![Coverage Status](https://coveralls.io/repos/github/warthog618/modem/badge.svg?branch=master)](https://coveralls.io/github/warthog618/modem?branch=master) 8 | [![Go Report Card](https://goreportcard.com/badge/github.com/warthog618/modem)](https://goreportcard.com/report/github.com/warthog618/modem) 9 | [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/warthog618/modem/blob/master/LICENSE) 10 | 11 | The **gsm** package provides a wrapper around [**at**](../at) that supports sending and receiving SMS messages, including long multi-part messages. 12 | 13 | ## Features 14 | 15 | Supports the following functionality: 16 | 17 | - Simple synchronous interface for sending messages 18 | - Both text and PDU mode interface to GSM modem 19 | - Asynchronous handling of received messages 20 | 21 | ## Usage 22 | 23 | ### Construction 24 | 25 | The GSM modem is constructed with *New*: 26 | 27 | ```go 28 | modem := gsm.New(atmodem) 29 | ``` 30 | 31 | Some modem behaviour can be controlled using optional parameters. This example 32 | sets the puts the modem into PDU mode: 33 | 34 | ```go 35 | modem := gsm.New(atmodem, gsm.WithPDUMode) 36 | ``` 37 | 38 | ### Modem Init 39 | 40 | The modem is reset into a known state and checked that is supports GSM functionality using the *Init* method: 41 | 42 | ```go 43 | err := modem.Init() 44 | ``` 45 | 46 | The *Init* method is a wrapper around the [**at**](../at) *Init* method, and accepts the same options: 47 | 48 | ```go 49 | err := modem.Init(at.WithCmds("Z","^CURC=0")) 50 | ``` 51 | 52 | ### Sending Short Messages 53 | 54 | Send a simple short message that will fit within a single SMS TPDU using 55 | *SendShortMessage*: 56 | 57 | ```go 58 | mr, err := modem.SendShortMessage("+12345","hello") 59 | ``` 60 | 61 | The modem may be in either text or PDU mode. 62 | 63 | ### Sending Long Messages 64 | 65 | This example sends an SMS with the modem in text mode: 66 | 67 | ```go 68 | mrs, err := modem.SendLongMessage("+12345", apotentiallylongmessage) 69 | ``` 70 | 71 | ### Sending PDUs 72 | 73 | Arbitrary SMS TPDUs can be sent using the *SendPDU* method: 74 | 75 | ```go 76 | mr, err := modem.SendPDU(tpdu) 77 | ``` 78 | 79 | ### Receiving Messages 80 | 81 | A handler can be provided for received SMS messages using *StartMessageRx*: 82 | 83 | ```go 84 | handler := func(msg gsm.Message) { 85 | // handle message here 86 | } 87 | err := modem.StartMessageRx(handler) 88 | ``` 89 | 90 | The handler can be removed using *StopMessageRx*: 91 | 92 | ```go 93 | modem.StopMessageRx() 94 | ``` 95 | 96 | ### Options 97 | 98 | A number of the modem methods accept optional parameters. The following table comprises a list of the available options: 99 | 100 | Option | Method | Description 101 | ---|---|--- 102 | *WithCollector(Collector)*|StartMessageRx| Provide a custom collector to reassemble multi-part SMSs. 103 | *WithEncoderOption(sms.EncoderOption)*|New| Specify options for encoding outgoing messages. 104 | *WithPDUMode*|New|Configure the modem into PDU mode (default). 105 | *WithReassemblyTimeout(time.Duration)*|StartMessageRx| Overrides the time allowed to wait for all the parts of a multi-part message to be received and reassembled. The default is 24 hours. This option is ignored if *WithCollector* is also applied. 106 | *WithSCA(pdumode.SMSCAddress)*|New| Override the SCA when sending messages. 107 | *WithTextMode*|New|Configure the modem into text mode. This is only required to send short messages in text mode, and conflicts with sending long messages or PDUs, as well as receiving messages. 108 | -------------------------------------------------------------------------------- /gsm/gsm.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // Package gsm provides a driver for GSM modems. 6 | package gsm 7 | 8 | import ( 9 | "errors" 10 | "fmt" 11 | "strconv" 12 | "strings" 13 | "time" 14 | 15 | "github.com/warthog618/modem/at" 16 | "github.com/warthog618/modem/info" 17 | "github.com/warthog618/sms" 18 | "github.com/warthog618/sms/encoding/pdumode" 19 | "github.com/warthog618/sms/encoding/tpdu" 20 | ) 21 | 22 | // GSM modem decorates the AT modem with GSM specific functionality. 23 | type GSM struct { 24 | *at.AT 25 | sca pdumode.SMSCAddress 26 | pduMode bool 27 | textualErrors bool 28 | eOpts []sms.EncoderOption 29 | } 30 | 31 | // Option is a construction option for the GSM. 32 | type Option interface { 33 | applyOption(*GSM) 34 | } 35 | 36 | // RxOption is a construction option for the GSM. 37 | type RxOption interface { 38 | applyRxOption(*rxConfig) 39 | } 40 | 41 | // New creates a new GSM modem. 42 | func New(a *at.AT, options ...Option) *GSM { 43 | g := GSM{AT: a, pduMode: true, textualErrors: true} 44 | for _, option := range options { 45 | option.applyOption(&g) 46 | } 47 | return &g 48 | } 49 | 50 | type collectorOption struct { 51 | Collector 52 | } 53 | 54 | func (o collectorOption) applyRxOption(c *rxConfig) { 55 | c.c = Collector(o) 56 | } 57 | 58 | // WithCollector overrides the collector to be used to reassemble long messages. 59 | // 60 | // The default is an sms.Collector. 61 | func WithCollector(c Collector) RxOption { 62 | return collectorOption{c} 63 | } 64 | 65 | type encoderOption struct { 66 | sms.EncoderOption 67 | } 68 | 69 | func (o encoderOption) applyOption(g *GSM) { 70 | g.eOpts = append(g.eOpts, o) 71 | } 72 | 73 | // WithEncoderOption applies the encoder option when converting from text 74 | // messages to SMS TPDUs. 75 | // 76 | func WithEncoderOption(eo sms.EncoderOption) Option { 77 | return encoderOption{eo} 78 | } 79 | 80 | type pduModeOption bool 81 | 82 | func (o pduModeOption) applyOption(g *GSM) { 83 | g.pduMode = bool(o) 84 | } 85 | 86 | // WithPDUMode specifies that the modem is to be used in PDU mode. 87 | // 88 | // This is the default mode. 89 | var WithPDUMode = pduModeOption(true) 90 | 91 | // WithTextMode specifies that the modem is to be used in text mode. 92 | // 93 | // This overrides is the default PDU mode. 94 | var WithTextMode = pduModeOption(false) 95 | 96 | type textualErrorsOption bool 97 | 98 | func (o textualErrorsOption) applyOption(g *GSM) { 99 | g.textualErrors = bool(o) 100 | } 101 | 102 | // WithTextualErrors specifies that the modem should return textual errors rather than numeric. 103 | // 104 | // This is the default mode. 105 | var WithTextualErrors = textualErrorsOption(true) 106 | 107 | // WithNumericErrors specifies that the modem should return numeric errors rather than textual. 108 | // 109 | // This overrides is the default textual mode. 110 | var WithNumericErrors = textualErrorsOption(false) 111 | 112 | type scaOption pdumode.SMSCAddress 113 | 114 | // WithSCA sets the SCA used when transmitting SMSs in PDU mode. 115 | // 116 | // This overrides the default set in the SIM. 117 | // 118 | // The SCA is only relevant in PDU mode, so this option also enables PDU mode. 119 | func WithSCA(sca pdumode.SMSCAddress) Option { 120 | return scaOption(sca) 121 | } 122 | 123 | func (o scaOption) applyOption(g *GSM) { 124 | g.pduMode = true 125 | g.sca = pdumode.SMSCAddress(o) 126 | } 127 | 128 | type timeoutOption time.Duration 129 | 130 | func (o timeoutOption) applyRxOption(c *rxConfig) { 131 | c.timeout = time.Duration(o) 132 | } 133 | 134 | // WithReassemblyTimeout specifies the maximum time allowed for all segments in 135 | // a long message to be received. 136 | // 137 | // The default is 24 hours. 138 | // 139 | // This option is overridden by WithCollector. 140 | func WithReassemblyTimeout(d time.Duration) RxOption { 141 | return timeoutOption(d) 142 | } 143 | 144 | type cmdsOption []string 145 | 146 | func (o cmdsOption) applyRxOption(c *rxConfig) { 147 | c.initCmds = []string(o) 148 | } 149 | 150 | // WithInitCmds overrides the commands required to setup the modem to notify when SMSs are received. 151 | // 152 | // The default is {"+CSMS=1","+CNMI=1,2,0,0,0"} 153 | func WithInitCmds(c ...string) RxOption { 154 | return cmdsOption(c) 155 | } 156 | 157 | // Init initialises the GSM modem. 158 | func (g *GSM) Init(options ...at.InitOption) (err error) { 159 | if err = g.AT.Init(options...); err != nil { 160 | return 161 | } 162 | // test GCAP response to ensure +GSM support, and modem sync. 163 | var i []string 164 | i, err = g.Command("+GCAP") 165 | if err != nil { 166 | return 167 | } 168 | capabilities := make(map[string]bool) 169 | for _, l := range i { 170 | if info.HasPrefix(l, "+GCAP") { 171 | caps := strings.Split(info.TrimPrefix(l, "+GCAP"), ",") 172 | for _, cap := range caps { 173 | capabilities[cap] = true 174 | } 175 | } 176 | } 177 | if !capabilities["+CGSM"] { 178 | return ErrNotGSMCapable 179 | } 180 | cmds := []string{ 181 | "+CMGF=1", // text mode 182 | "+CMEE=2", // textual errors 183 | } 184 | if g.pduMode { 185 | cmds[0] = "+CMGF=0" // pdu mode 186 | } 187 | if !g.textualErrors { 188 | cmds[1] = "+CMEE=1" // numeric errors 189 | } 190 | for _, cmd := range cmds { 191 | _, err = g.Command(cmd) 192 | if err != nil { 193 | return 194 | } 195 | } 196 | return 197 | } 198 | 199 | // SendShortMessage sends an SMS message to the number. 200 | // 201 | // If the modem is in PDU mode then the message is converted to a single SMS 202 | // PDU. 203 | // 204 | // The mr is returned on success, else an error. 205 | func (g *GSM) SendShortMessage(number string, message string, options ...at.CommandOption) (rsp string, err error) { 206 | if g.pduMode { 207 | var pdus []tpdu.TPDU 208 | eOpts := append(g.eOpts, sms.To(number)) 209 | pdus, err = sms.Encode([]byte(message), eOpts...) 210 | if err != nil { 211 | return 212 | } 213 | if len(pdus) > 1 { 214 | err = ErrOverlength 215 | return 216 | } 217 | var tp []byte 218 | tp, err = pdus[0].MarshalBinary() 219 | if err != nil { 220 | return 221 | } 222 | return g.SendPDU(tp, options...) 223 | } 224 | var i []string 225 | i, err = g.SMSCommand("+CMGS=\""+number+"\"", message, options...) 226 | if err != nil { 227 | return 228 | } 229 | // parse response, ignoring any lines other than well-formed. 230 | for _, l := range i { 231 | if info.HasPrefix(l, "+CMGS") { 232 | rsp = info.TrimPrefix(l, "+CMGS") 233 | return 234 | } 235 | } 236 | err = ErrMalformedResponse 237 | return 238 | } 239 | 240 | // SendLongMessage sends an SMS message to the number. 241 | // 242 | // The modem must be in PDU mode. 243 | // The message is split into concatenated SMS PDUs, if necessary. 244 | // 245 | // The mr of send PDUs is returned on success, else an error. 246 | func (g *GSM) SendLongMessage(number string, message string, options ...at.CommandOption) (rsp []string, err error) { 247 | if !g.pduMode { 248 | err = ErrWrongMode 249 | return 250 | } 251 | var pdus []tpdu.TPDU 252 | eOpts := append(g.eOpts, sms.To(number)) 253 | pdus, err = sms.Encode([]byte(message), eOpts...) 254 | if err != nil { 255 | return 256 | } 257 | for _, p := range pdus { 258 | var tp []byte 259 | tp, err = p.MarshalBinary() 260 | if err != nil { 261 | return 262 | } 263 | var mr string 264 | mr, err = g.SendPDU(tp, options...) 265 | if len(mr) > 0 { 266 | rsp = append(rsp, mr) 267 | } 268 | if err != nil { 269 | return 270 | } 271 | } 272 | return 273 | } 274 | 275 | // SendPDU sends an SMS PDU. 276 | // 277 | // tpdu is the binary TPDU to be sent. 278 | // The mr is returned on success, else an error. 279 | func (g *GSM) SendPDU(tpdu []byte, options ...at.CommandOption) (rsp string, err error) { 280 | if !g.pduMode { 281 | return "", ErrWrongMode 282 | } 283 | pdu := pdumode.PDU{SMSC: g.sca, TPDU: tpdu} 284 | var s string 285 | s, err = pdu.MarshalHexString() 286 | if err != nil { 287 | return 288 | } 289 | var i []string 290 | i, err = g.SMSCommand(fmt.Sprintf("+CMGS=%d", len(tpdu)), s, options...) 291 | if err != nil { 292 | return 293 | } 294 | // parse response, ignoring any lines other than well-formed. 295 | for _, l := range i { 296 | if info.HasPrefix(l, "+CMGS") { 297 | rsp = info.TrimPrefix(l, "+CMGS") 298 | return 299 | } 300 | } 301 | err = ErrMalformedResponse 302 | return 303 | } 304 | 305 | // Message encapsulates the details of a received message. 306 | // 307 | // The message is composed of one or more SMS-DELIVER TPDUs. 308 | // 309 | // Commonly required fields are extracted for easy access. 310 | type Message struct { 311 | Number string 312 | Message string 313 | SCTS tpdu.Timestamp 314 | TPDUs []*tpdu.TPDU 315 | } 316 | 317 | // MessageHandler receives a decoded SMS message from the modem. 318 | type MessageHandler func(Message) 319 | 320 | // ErrorHandler receives asynchronous errors. 321 | type ErrorHandler func(error) 322 | 323 | // Collector is the interface required to collect and reassemble TPDUs. 324 | // 325 | // By default this is implemented by an sms.Collector. 326 | type Collector interface { 327 | Collect(tpdu.TPDU) ([]*tpdu.TPDU, error) 328 | } 329 | 330 | type rxConfig struct { 331 | timeout time.Duration 332 | c Collector 333 | initCmds []string 334 | } 335 | 336 | // StartMessageRx sets up the modem to receive SMS messages and pass them to 337 | // the message handler. 338 | // 339 | // The message may have been concatenated over several SMS PDUs, but if so is 340 | // reassembled into a complete message before being passed to the message 341 | // handler. 342 | // 343 | // Errors detected while receiving messages are passed to the error handler. 344 | // 345 | // Requires the modem to be in PDU mode. 346 | func (g *GSM) StartMessageRx(mh MessageHandler, eh ErrorHandler, options ...RxOption) error { 347 | if !g.pduMode { 348 | return ErrWrongMode 349 | } 350 | cfg := rxConfig{ 351 | timeout: 24 * time.Hour, 352 | initCmds: []string{"+CSMS=1", "+CNMI=1,2,0,0,0"}, 353 | } 354 | for _, option := range options { 355 | option.applyRxOption(&cfg) 356 | } 357 | if cfg.c == nil { 358 | rto := func(tpdus []*tpdu.TPDU) { 359 | eh(ErrReassemblyTimeout{tpdus}) 360 | } 361 | cfg.c = sms.NewCollector(sms.WithReassemblyTimeout(cfg.timeout, rto)) 362 | } 363 | cmtHandler := func(info []string) { 364 | tp, err := UnmarshalTPDU(info) 365 | if err != nil { 366 | eh(ErrUnmarshal{info, err}) 367 | return 368 | } 369 | g.Command("+CNMA") 370 | tpdus, err := cfg.c.Collect(tp) 371 | if err != nil { 372 | eh(ErrCollect{tp, err}) 373 | return 374 | } 375 | if tpdus == nil { 376 | return 377 | } 378 | m, err := sms.Decode(tpdus) 379 | if err != nil { 380 | eh(ErrDecode{tpdus, err}) 381 | } 382 | if m != nil { 383 | mh(Message{ 384 | Number: tpdus[0].OA.Number(), 385 | Message: string(m), 386 | SCTS: tpdus[0].SCTS, 387 | TPDUs: tpdus, 388 | }) 389 | } 390 | } 391 | err := g.AddIndication("+CMT:", cmtHandler, at.WithTrailingLine) 392 | if err != nil { 393 | return err 394 | } 395 | // tell the modem to forward SMS-DELIVERs via +CMT indications... 396 | for _, cmd := range cfg.initCmds { 397 | if _, err = g.Command(cmd); err != nil { 398 | g.CancelIndication("+CMT:") 399 | return err 400 | } 401 | } 402 | return nil 403 | } 404 | 405 | // StopMessageRx ends the reception of messages started by StartMessageRx, 406 | func (g *GSM) StopMessageRx() { 407 | // tell the modem to stop forwarding SMSs to us. 408 | g.Command("+CNMI=0,0,0,0,0") 409 | // and detach the handler 410 | g.CancelIndication("+CMT:") 411 | } 412 | 413 | // UnmarshalTPDU converts +CMT info into the corresponding SMS TPDU. 414 | func UnmarshalTPDU(info []string) (tp tpdu.TPDU, err error) { 415 | if len(info) < 2 { 416 | err = ErrUnderlength 417 | return 418 | } 419 | lstr := strings.Split(info[0], ",") 420 | var l int 421 | l, err = strconv.Atoi(lstr[len(lstr)-1]) 422 | if err != nil { 423 | return 424 | } 425 | var pdu *pdumode.PDU 426 | pdu, err = pdumode.UnmarshalHexString(info[1]) 427 | if err != nil { 428 | return 429 | } 430 | if int(l) != len(pdu.TPDU) { 431 | err = fmt.Errorf("length mismatch - expected %d, got %d", l, len(pdu.TPDU)) 432 | return 433 | } 434 | err = tp.UnmarshalBinary(pdu.TPDU) 435 | return 436 | } 437 | 438 | // ErrCollect indicates that an error occured that prevented the TPDU from 439 | // being collected. 440 | type ErrCollect struct { 441 | TPDU tpdu.TPDU 442 | Err error 443 | } 444 | 445 | func (e ErrCollect) Error() string { 446 | return fmt.Sprintf("error '%s' collecting TPDU: %+v", e.Err, e.TPDU) 447 | } 448 | 449 | // ErrDecode indicates that an error occured that prevented the TPDUs from 450 | // being cdecoded. 451 | type ErrDecode struct { 452 | TPDUs []*tpdu.TPDU 453 | Err error 454 | } 455 | 456 | func (e ErrDecode) Error() string { 457 | str := fmt.Sprintf("error '%s' decoding: ", e.Err) 458 | for _, tpdu := range e.TPDUs { 459 | str += fmt.Sprintf("%+v", tpdu) 460 | } 461 | return str 462 | } 463 | 464 | // ErrReassemblyTimeout indicates that one or more segments of a long message 465 | // are missing, preventing the complete message being reassembled. 466 | // 467 | // The missing segments are the nil entries in the array. 468 | type ErrReassemblyTimeout struct { 469 | TPDUs []*tpdu.TPDU 470 | } 471 | 472 | func (e ErrReassemblyTimeout) Error() string { 473 | str := "timeout reassembling: " 474 | for _, tpdu := range e.TPDUs { 475 | str += fmt.Sprintf("%+v", tpdu) 476 | } 477 | return str 478 | } 479 | 480 | // ErrUnmarshal indicates an error occured while trying to unmarshal the TPDU 481 | // received from the modem. 482 | type ErrUnmarshal struct { 483 | Info []string 484 | Err error 485 | } 486 | 487 | func (e ErrUnmarshal) Error() string { 488 | str := fmt.Sprintf("error '%s' unmarshalling: ", e.Err) 489 | for _, i := range e.Info { 490 | str += fmt.Sprintf("%s\n", i) 491 | } 492 | return str 493 | } 494 | 495 | var ( 496 | // ErrMalformedResponse indicates the modem returned a badly formed 497 | // response. 498 | ErrMalformedResponse = errors.New("modem returned malformed response") 499 | 500 | // ErrNotGSMCapable indicates that the modem does not support the GSM 501 | // command set, as determined from the GCAP response. 502 | ErrNotGSMCapable = errors.New("modem is not GSM capable") 503 | 504 | // ErrNotPINReady indicates the modem SIM card is not ready to perform 505 | // operations. 506 | ErrNotPINReady = errors.New("modem is not PIN Ready") 507 | 508 | // ErrOverlength indicates the message is too long for a single PDU and 509 | // must be split into multiple PDUs. 510 | ErrOverlength = errors.New("message too long for one SMS") 511 | 512 | // ErrUnderlength indicates that two few lines of info were provided to 513 | // decode a PDU. 514 | ErrUnderlength = errors.New("insufficient info") 515 | 516 | // ErrWrongMode indicates the GSM modem is operating in the wrong mode and 517 | // so cannot support the command. 518 | ErrWrongMode = errors.New("modem is in the wrong mode") 519 | ) 520 | -------------------------------------------------------------------------------- /gsm/gsm_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // 6 | // Test suite for GSM module. 7 | // 8 | // Note that these tests provide a mockModem which does not attempt to emulate 9 | // a serial modem, but which provides responses required to exercise gsm.go So, 10 | // while the commands may follow the structure of the AT protocol they most 11 | // certainly are not AT commands - just patterns that elicit the behaviour 12 | // required for the test. 13 | 14 | package gsm_test 15 | 16 | import ( 17 | "encoding/hex" 18 | "fmt" 19 | "io" 20 | "strconv" 21 | "testing" 22 | "time" 23 | 24 | "github.com/pkg/errors" 25 | "github.com/stretchr/testify/assert" 26 | "github.com/stretchr/testify/require" 27 | "github.com/warthog618/modem/at" 28 | "github.com/warthog618/modem/gsm" 29 | "github.com/warthog618/modem/trace" 30 | "github.com/warthog618/sms" 31 | "github.com/warthog618/sms/encoding/pdumode" 32 | "github.com/warthog618/sms/encoding/semioctet" 33 | "github.com/warthog618/sms/encoding/tpdu" 34 | "github.com/warthog618/sms/encoding/ucs2" 35 | ) 36 | 37 | var debug = false // set to true to enable tracing of the flow to the mockModem. 38 | 39 | const ( 40 | sub = "\x1a" 41 | esc = "\x1b" 42 | ) 43 | 44 | func TestNew(t *testing.T) { 45 | mm := mockModem{cmdSet: nil, echo: false, r: make(chan []byte, 10)} 46 | defer teardownModem(&mm) 47 | a := at.New(&mm) 48 | patterns := []struct { 49 | name string 50 | options []gsm.Option 51 | success bool 52 | }{ 53 | { 54 | "default", 55 | nil, 56 | true, 57 | }, 58 | { 59 | "WithPDUMode", 60 | []gsm.Option{gsm.WithPDUMode}, 61 | true, 62 | }, 63 | { 64 | "WithTextMode", 65 | []gsm.Option{gsm.WithTextMode}, 66 | true, 67 | }, 68 | { 69 | "WithNumericErrors", 70 | []gsm.Option{gsm.WithNumericErrors}, 71 | true, 72 | }, 73 | { 74 | "WithTextualErrors", 75 | []gsm.Option{gsm.WithTextualErrors}, 76 | true, 77 | }, 78 | } 79 | for _, p := range patterns { 80 | f := func(t *testing.T) { 81 | g := gsm.New(a, p.options...) 82 | if p.success { 83 | assert.NotNil(t, g) 84 | } else { 85 | assert.Nil(t, g) 86 | } 87 | } 88 | t.Run(p.name, f) 89 | } 90 | } 91 | 92 | func TestInit(t *testing.T) { 93 | // mocked 94 | cmdSet := map[string][]string{ 95 | // for init (AT) 96 | esc + "\r\n\r\n": {"\r\n"}, 97 | "ATZ\r\n": {"OK\r\n"}, 98 | "ATE0\r\n": {"OK\r\n"}, 99 | // for init (GSM) 100 | "AT+CMEE=2\r\n": {"OK\r\n"}, 101 | "AT+CMEE=1\r\n": {"OK\r\n"}, 102 | "AT+CMGF=1\r\n": {"OK\r\n"}, 103 | "AT+GCAP\r\n": {"+GCAP: +CGSM,+DS,+ES\r\n", "OK\r\n"}, 104 | } 105 | patterns := []struct { 106 | name string 107 | options []at.InitOption 108 | residual []byte 109 | key string 110 | value []string 111 | gopts []gsm.Option 112 | err error 113 | }{ 114 | { 115 | "vanilla", 116 | nil, 117 | nil, 118 | "", 119 | nil, 120 | []gsm.Option{gsm.WithTextMode}, 121 | nil, 122 | }, 123 | { 124 | "residual OKs", 125 | nil, 126 | []byte("\r\nOK\r\nOK\r\n"), 127 | "", 128 | nil, 129 | []gsm.Option{gsm.WithTextMode}, 130 | nil, 131 | }, 132 | { 133 | "residual ERRORs", 134 | nil, 135 | []byte("\r\nERROR\r\nERROR\r\n"), 136 | "", 137 | nil, 138 | []gsm.Option{gsm.WithTextMode}, 139 | nil, 140 | }, 141 | { 142 | "cruft", 143 | nil, 144 | nil, 145 | "AT+GCAP\r\n", 146 | []string{"cruft\r\n", "+GCAP: +CGSM,+DS,+ES\r\n", "OK\r\n"}, 147 | []gsm.Option{gsm.WithTextMode}, 148 | nil, 149 | }, 150 | { 151 | "CMEE textual error", 152 | nil, 153 | nil, 154 | "AT+CMEE=2\r\n", 155 | []string{"ERROR\r\n"}, 156 | []gsm.Option{gsm.WithTextMode}, 157 | at.ErrError, 158 | }, 159 | { 160 | "CMEE numeric error", 161 | nil, 162 | nil, 163 | "AT+CMEE=1\r\n", 164 | []string{"ERROR\r\n"}, 165 | []gsm.Option{gsm.WithTextMode, gsm.WithNumericErrors}, 166 | at.ErrError, 167 | }, 168 | { 169 | "GCAP error", 170 | nil, 171 | nil, 172 | "AT+GCAP\r\n", 173 | []string{"ERROR\r\n"}, 174 | []gsm.Option{gsm.WithTextMode}, 175 | at.ErrError, 176 | }, 177 | { 178 | "not GSM capable", 179 | nil, 180 | nil, 181 | "AT+GCAP\r\n", 182 | []string{"+GCAP: +DS,+ES\r\n", "OK\r\n"}, 183 | []gsm.Option{gsm.WithTextMode}, 184 | gsm.ErrNotGSMCapable, 185 | }, 186 | { 187 | "AT init failure", 188 | nil, 189 | nil, 190 | "ATZ\r\n", 191 | []string{"ERROR\r\n"}, 192 | []gsm.Option{gsm.WithTextMode}, 193 | fmt.Errorf("ATZ returned error: %w", at.ErrError), 194 | }, 195 | { 196 | "timeout", 197 | []at.InitOption{at.WithTimeout(0)}, 198 | nil, 199 | "", 200 | nil, 201 | []gsm.Option{gsm.WithPDUMode}, 202 | at.ErrDeadlineExceeded, 203 | }, 204 | { 205 | "unsupported PDU mode", 206 | nil, 207 | nil, 208 | "", 209 | nil, 210 | []gsm.Option{gsm.WithPDUMode}, 211 | at.ErrError, 212 | }, 213 | { 214 | "PDU mode", 215 | nil, 216 | nil, 217 | "AT+CMGF=0\r\n", 218 | []string{"OK\r\n"}, 219 | []gsm.Option{gsm.WithPDUMode}, 220 | nil, 221 | }, 222 | } 223 | for _, p := range patterns { 224 | f := func(t *testing.T) { 225 | mm := mockModem{ 226 | cmdSet: cmdSet, 227 | echo: false, 228 | r: make(chan []byte, 10), 229 | readDelay: time.Millisecond, 230 | } 231 | defer teardownModem(&mm) 232 | a := at.New(&mm) 233 | g := gsm.New(a, p.gopts...) 234 | require.NotNil(t, g) 235 | var oldvalue []string 236 | if p.residual != nil { 237 | mm.r <- p.residual 238 | } 239 | if p.key != "" { 240 | oldvalue = cmdSet[p.key] 241 | cmdSet[p.key] = p.value 242 | } 243 | err := g.Init(p.options...) 244 | if oldvalue != nil { 245 | cmdSet[p.key] = oldvalue 246 | } 247 | assert.Equal(t, p.err, err) 248 | } 249 | t.Run(p.name, f) 250 | } 251 | } 252 | 253 | func TestSendShortMessage(t *testing.T) { 254 | // mocked 255 | cmdSet := map[string][]string{ 256 | "AT+CMGS=\"+123456789\"\r": {"\n>"}, 257 | "AT+CMGS=23\r": {"\n>"}, 258 | "test message" + sub: {"\r\n", "+CMGS: 42\r\n", "\r\nOK\r\n"}, 259 | "cruft test message" + sub: {"\r\n", "pad\r\n", "+CMGS: 43\r\n", "\r\nOK\r\n"}, 260 | "000101099121436587f900000cf4f29c0e6a97e7f3f0b90c" + sub: {"\r\n", "+CMGS: 44\r\n", "\r\nOK\r\n"}, 261 | "malformed test message" + sub: {"\r\n", "pad\r\n", "\r\nOK\r\n"}, 262 | } 263 | patterns := []struct { 264 | name string 265 | options []at.CommandOption 266 | goptions []gsm.Option 267 | number string 268 | message string 269 | err error 270 | mr string 271 | }{ 272 | { 273 | "ok", 274 | nil, 275 | []gsm.Option{gsm.WithTextMode}, 276 | "+123456789", 277 | "test message", 278 | nil, 279 | "42", 280 | }, 281 | { 282 | "error", 283 | nil, 284 | []gsm.Option{gsm.WithTextMode}, 285 | "+1234567890", 286 | "test message", 287 | at.ErrError, 288 | "", 289 | }, 290 | { 291 | "cruft", 292 | nil, 293 | []gsm.Option{gsm.WithTextMode}, 294 | "+123456789", 295 | "cruft test message", 296 | nil, 297 | "43", 298 | }, 299 | { 300 | "malformed", 301 | nil, 302 | []gsm.Option{gsm.WithTextMode}, 303 | "+123456789", 304 | "malformed test message", 305 | gsm.ErrMalformedResponse, 306 | "", 307 | }, 308 | { 309 | "timeout", 310 | []at.CommandOption{at.WithTimeout(0)}, 311 | nil, 312 | "+123456789", 313 | "test message", 314 | at.ErrDeadlineExceeded, 315 | "", 316 | }, 317 | { 318 | "pduMode", 319 | nil, 320 | []gsm.Option{gsm.WithPDUMode}, 321 | "+123456789", 322 | "test message", 323 | nil, 324 | "44", 325 | }, 326 | { 327 | "overlength", 328 | nil, 329 | nil, 330 | "+123456789", 331 | "a very long test message that will not fit within one SMS PDU as it is just too long for one PDU even with GSM encoding, though you can fit more in one PDU than you may initially expect", 332 | gsm.ErrOverlength, 333 | "", 334 | }, 335 | { 336 | "encode error", 337 | nil, 338 | []gsm.Option{ 339 | gsm.WithEncoderOption(sms.WithTemplateOption(tpdu.DCS(0x80))), 340 | }, 341 | "+123456789", 342 | "test message", 343 | sms.ErrDcsConflict, 344 | "", 345 | }, 346 | { 347 | "marshal error", 348 | nil, 349 | []gsm.Option{gsm.WithPDUMode, gsm.WithEncoderOption(sms.AsUCS2)}, 350 | "+123456789", 351 | "an odd length string!", 352 | tpdu.EncodeError("SmsSubmit.ud.sm", tpdu.ErrOddUCS2Length), 353 | "", 354 | }, 355 | } 356 | for _, p := range patterns { 357 | f := func(t *testing.T) { 358 | g, mm := setupModem(t, cmdSet, p.goptions...) 359 | defer teardownModem(mm) 360 | 361 | mr, err := g.SendShortMessage(p.number, p.message, p.options...) 362 | assert.Equal(t, p.err, err) 363 | assert.Equal(t, p.mr, mr) 364 | } 365 | t.Run(p.name, f) 366 | } 367 | } 368 | 369 | func TestSendLongMessage(t *testing.T) { 370 | // mocked 371 | cmdSet := map[string][]string{ 372 | "AT+CMGS=23\r": {"\n>"}, 373 | "AT+CMGS=152\r": {"\n>"}, 374 | "AT+CMGS=47\r": {"\n>"}, 375 | "AT+CMGS=32\r": {"\r\n", "pad\r\n", "\r\nOK\r\n"}, 376 | "000101099121436587f900000cf4f29c0e6a97e7f3f0b90c" + sub: {"\r\n", "+CMGS: 42\r\n", "\r\nOK\r\n"}, 377 | "004101099121436587f90000a0050003010201c2207b599e07b1dfee33885e9ed341edf27c1e3e97417474980ebaa7d96c90fb4d0799d374d03d4d47a7dda0b7bb0c9a36a72028b10a0acf41693a283d07a9eb733a88fe7e83d86ff719647ecb416f771904255641657bd90dbaa7e968d071da0495dde33739ed3eb34074f4bb7e4683f2ef3a681c7683cc693aa8fd9697416937e8ed2e83a0" + sub: {"\r\n", "+CMGS: 43\r\n", "\r\nOK\r\n"}, 378 | "004102099121436587f90000270500030102028855101d1d7683f2ef3aa81dce83d2ee343d1d66b3f3a0321e5e1ed301" + sub: {"\r\n", "+CMGS: 44\r\n", "\r\nOK\r\n"}, 379 | } 380 | patterns := []struct { 381 | name string 382 | options []at.CommandOption 383 | goptions []gsm.Option 384 | number string 385 | message string 386 | err error 387 | mr []string 388 | }{ 389 | { 390 | "text mode", 391 | nil, 392 | []gsm.Option{gsm.WithTextMode}, 393 | "+123456789", 394 | "test message", 395 | gsm.ErrWrongMode, 396 | nil, 397 | }, 398 | { 399 | "error", 400 | nil, 401 | nil, 402 | "+1234567890", 403 | "test message", 404 | at.ErrError, 405 | nil, 406 | }, 407 | { 408 | "malformed", 409 | nil, 410 | nil, 411 | "+123456789", 412 | "malformed test message", 413 | gsm.ErrMalformedResponse, 414 | nil, 415 | }, 416 | { 417 | "timeout", 418 | []at.CommandOption{at.WithTimeout(0)}, 419 | nil, 420 | "+123456789", 421 | "test message", 422 | at.ErrDeadlineExceeded, 423 | nil, 424 | }, 425 | { 426 | "one pdu", 427 | nil, 428 | nil, 429 | "+123456789", 430 | "test message", 431 | nil, 432 | []string{"42"}, 433 | }, 434 | { 435 | "two pdu", 436 | nil, 437 | nil, 438 | "+123456789", 439 | "a very long test message that will not fit within one SMS PDU as it is just too long for one PDU even with GSM encoding, though you can fit more in one PDU than you may initially expect", 440 | nil, 441 | []string{"43", "44"}, 442 | }, 443 | { 444 | "encode error", 445 | nil, 446 | []gsm.Option{ 447 | gsm.WithPDUMode, 448 | gsm.WithEncoderOption(sms.WithTemplateOption(tpdu.DCS(0x80))), 449 | }, 450 | "+123456789", 451 | "test message", 452 | sms.ErrDcsConflict, 453 | nil, 454 | }, 455 | { 456 | "marshal error", 457 | nil, 458 | []gsm.Option{gsm.WithEncoderOption(sms.AsUCS2)}, 459 | "+123456789", 460 | "an odd length string!", 461 | tpdu.EncodeError("SmsSubmit.ud.sm", tpdu.ErrOddUCS2Length), 462 | nil, 463 | }, 464 | } 465 | for _, p := range patterns { 466 | f := func(t *testing.T) { 467 | g, mm := setupModem(t, cmdSet, p.goptions...) 468 | defer teardownModem(mm) 469 | 470 | mr, err := g.SendLongMessage(p.number, p.message, p.options...) 471 | assert.Equal(t, p.err, err) 472 | assert.Equal(t, p.mr, mr) 473 | } 474 | t.Run(p.name, f) 475 | } 476 | } 477 | 478 | func TestSendPDU(t *testing.T) { 479 | // mocked 480 | cmdSet := map[string][]string{ 481 | "AT+CMGS=6\r": {"\n>"}, 482 | "00010203040506" + sub: {"\r\n", "+CMGS: 42\r\n", "\r\nOK\r\n"}, 483 | "00110203040506" + sub: {"\r\n", "pad\r\n", "+CMGS: 43\r\n", "\r\nOK\r\n"}, 484 | "00210203040506" + sub: {"\r\n", "pad\r\n", "\r\nOK\r\n"}, 485 | } 486 | patterns := []struct { 487 | name string 488 | options []at.CommandOption 489 | tpdu []byte 490 | err error 491 | mr string 492 | }{ 493 | { 494 | "ok", 495 | nil, 496 | []byte{1, 2, 3, 4, 5, 6}, 497 | nil, 498 | "42", 499 | }, 500 | { 501 | "error", 502 | nil, 503 | []byte{1}, 504 | at.ErrError, 505 | "", 506 | }, 507 | { 508 | "cruft", 509 | nil, 510 | []byte{0x11, 2, 3, 4, 5, 6}, 511 | nil, 512 | "43", 513 | }, 514 | { 515 | "malformed", 516 | nil, 517 | []byte{0x21, 2, 3, 4, 5, 6}, 518 | gsm.ErrMalformedResponse, 519 | "", 520 | }, 521 | { 522 | "timeout", 523 | []at.CommandOption{at.WithTimeout(0)}, 524 | []byte{1, 2, 3, 4, 5, 6}, 525 | at.ErrDeadlineExceeded, 526 | "", 527 | }, 528 | } 529 | g, mm := setupModem(t, cmdSet, gsm.WithPDUMode) 530 | defer teardownModem(mm) 531 | 532 | for _, p := range patterns { 533 | f := func(t *testing.T) { 534 | mr, err := g.SendPDU(p.tpdu, p.options...) 535 | assert.Equal(t, p.err, err) 536 | assert.Equal(t, p.mr, mr) 537 | } 538 | t.Run(p.name, f) 539 | } 540 | 541 | // wrong mode 542 | g, mm = setupModem(t, cmdSet, gsm.WithTextMode) 543 | defer teardownModem(mm) 544 | p := patterns[0] 545 | omr, oerr := g.SendPDU(p.tpdu) 546 | assert.Equal(t, gsm.ErrWrongMode, oerr) 547 | assert.Equal(t, "", omr) 548 | } 549 | 550 | func TestStartMessageRx(t *testing.T) { 551 | cmdSet := map[string][]string{ 552 | "AT+CNMA\r\n": {"\r\nOK\r\n"}, 553 | } 554 | g, mm := setupModem(t, cmdSet, gsm.WithTextMode) 555 | teardownModem(mm) 556 | 557 | msgChan := make(chan gsm.Message, 3) 558 | errChan := make(chan error, 3) 559 | mh := func(msg gsm.Message) { 560 | msgChan <- msg 561 | } 562 | eh := func(err error) { 563 | errChan <- err 564 | } 565 | 566 | // wrong mode 567 | err := g.StartMessageRx(mh, eh) 568 | require.Equal(t, gsm.ErrWrongMode, err) 569 | 570 | g, mm = setupModem(t, cmdSet) 571 | defer teardownModem(mm) 572 | 573 | // fails CNMA 574 | err = g.StartMessageRx(mh, eh) 575 | require.Equal(t, at.ErrError, err) 576 | 577 | cmdSet["AT+CNMI=1,2,0,0,0\r\n"] = []string{"\r\nOK\r\n"} 578 | cmdSet["AT+CSMS=1\r\n"] = []string{"\r\nOK\r\n"} 579 | 580 | // pass 581 | err = g.StartMessageRx(mh, eh) 582 | require.Nil(t, err) 583 | 584 | // already exists 585 | err = g.StartMessageRx(mh, eh) 586 | require.Equal(t, at.ErrIndicationExists, err) 587 | 588 | // CMT patterns to exercise cmtHandler 589 | patterns := []struct { 590 | rx string 591 | msg gsm.Message 592 | err error 593 | }{ 594 | { 595 | "+CMT: ,24\r\n00040B911234567890F000000250100173832305C8329BFD06\r\n", 596 | gsm.Message{ 597 | Number: "+21436587090", 598 | Message: "Hello", 599 | SCTS: tpdu.Timestamp{ 600 | Time: time.Date(2020, time.May, 1, 10, 37, 38, 0, time.FixedZone("any", 8*3600))}, 601 | }, 602 | nil, 603 | }, 604 | { 605 | "+CMT: ,2X\r\n00040B911234567JUNK000000250100173832305C8329BFD06\r\n", 606 | gsm.Message{Message: "no message received"}, 607 | gsm.ErrUnmarshal{ 608 | Info: []string{ 609 | "+CMT: ,2X", 610 | "00040B911234567JUNK000000250100173832305C8329BFD06", 611 | }, 612 | Err: &strconv.NumError{Func: "Atoi", Num: "2X", Err: strconv.ErrSyntax}, 613 | }, 614 | }, 615 | { 616 | "+CMT: ,27\r\n004400000000101010000000000f050003030206906174181d468701\r\n", 617 | gsm.Message{Message: "no message received"}, 618 | gsm.ErrCollect{ 619 | TPDU: tpdu.TPDU{ 620 | FirstOctet: 0x44, 621 | SCTS: tpdu.Timestamp{ 622 | Time: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC)}, 623 | UDH: tpdu.UserDataHeader{ 624 | tpdu.InformationElement{ID: 0, Data: []byte{3, 2, 6}}, 625 | }, 626 | UD: []byte("Hahahaha"), 627 | }, 628 | Err: sms.ErrReassemblyInconsistency, 629 | }, 630 | }, 631 | { 632 | "+CMT: ,19\r\n0004000000081010100000000006d83dde01d83d\r\n", 633 | gsm.Message{Message: "no message received"}, 634 | gsm.ErrDecode{ 635 | TPDUs: []*tpdu.TPDU{ 636 | { 637 | FirstOctet: 4, 638 | DCS: 0x08, 639 | SCTS: tpdu.Timestamp{ 640 | Time: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.FixedZone("any", 0))}, 641 | UD: []byte{0xd8, 0x3d, 0xde, 0x01, 0xd8, 0x3d}, 642 | }, 643 | }, 644 | Err: ucs2.ErrDanglingSurrogate([]byte{0xd8, 0x3d}), 645 | }, 646 | }, 647 | } 648 | for _, p := range patterns { 649 | mm.r <- []byte(p.rx) 650 | select { 651 | case msg := <-msgChan: 652 | assert.Equal(t, p.msg.Number, msg.Number) 653 | assert.Equal(t, p.msg.Message, msg.Message) 654 | assert.Equal(t, p.msg.SCTS.Unix(), msg.SCTS.Unix()) 655 | case err := <-errChan: 656 | require.IsType(t, p.err, err) 657 | switch v := err.(type) { 658 | case gsm.ErrCollect: 659 | xerr := p.err.(gsm.ErrCollect) 660 | assert.Equal(t, xerr.TPDU, v.TPDU) 661 | assert.Equal(t, xerr.Err, v.Err) 662 | case gsm.ErrDecode: 663 | xerr := p.err.(gsm.ErrDecode) 664 | assert.Equal(t, xerr.Err, v.Err) 665 | case gsm.ErrUnmarshal: 666 | xerr := p.err.(gsm.ErrUnmarshal) 667 | assert.Equal(t, xerr.Info, v.Info) 668 | assert.Equal(t, xerr.Err, v.Err) 669 | } 670 | case <-time.After(100 * time.Millisecond): 671 | t.Errorf("no notification received") 672 | } 673 | } 674 | } 675 | 676 | func TestStartMessageRxOptions(t *testing.T) { 677 | cmdSet := map[string][]string{ 678 | "AT+CSMS=1\r\n": {"\r\nOK\r\n"}, 679 | "AT+CNMI=1,2,0,0,0\r\n": {"\r\nOK\r\n"}, 680 | "AT+CNMI=1,2,0,0,1\r\n": {"\r\nOK\r\n"}, 681 | "AT+CNMA\r\n": {"\r\nOK\r\n"}, 682 | } 683 | 684 | msgChan := make(chan gsm.Message, 3) 685 | errChan := make(chan error, 3) 686 | mh := func(msg gsm.Message) { 687 | msgChan <- msg 688 | } 689 | eh := func(err error) { 690 | errChan <- err 691 | } 692 | 693 | mc := mockCollector{ 694 | errChan: errChan, 695 | err: errors.New("mock collector expiry"), 696 | } 697 | sfs := tpdu.TPDU{ 698 | FirstOctet: tpdu.FoUDHI, 699 | OA: tpdu.Address{Addr: "1234", TOA: 0x91}, 700 | SCTS: tpdu.Timestamp{ 701 | Time: time.Date(2017, time.August, 31, 11, 21, 54, 0, time.FixedZone("any", 8*3600)), 702 | }, 703 | UDH: tpdu.UserDataHeader{ 704 | tpdu.InformationElement{ID: 0, Data: []byte{2, 2, 1}}, 705 | }, 706 | UD: []byte("a short first segment"), 707 | } 708 | sfsb, _ := sfs.MarshalBinary() 709 | sfsh := hex.EncodeToString(sfsb) 710 | sfsi := fmt.Sprintf("+CMT: ,%d\r\n00%s\r\n", len(sfsh)/2, sfsh) 711 | patterns := []struct { 712 | name string 713 | options []gsm.RxOption 714 | err error 715 | expire bool 716 | }{ 717 | { 718 | "default", 719 | nil, 720 | nil, 721 | false, 722 | }, 723 | { 724 | "timeout", 725 | []gsm.RxOption{gsm.WithReassemblyTimeout(time.Microsecond)}, 726 | gsm.ErrReassemblyTimeout{TPDUs: []*tpdu.TPDU{&sfs, nil}}, 727 | true, 728 | }, 729 | { 730 | "collector", 731 | []gsm.RxOption{gsm.WithCollector(mc)}, 732 | mc.err, 733 | true, 734 | }, 735 | { 736 | "initCmds", 737 | []gsm.RxOption{gsm.WithInitCmds("+CNMI=1,2,0,0,1")}, 738 | nil, 739 | false, 740 | }, 741 | } 742 | for _, p := range patterns { 743 | f := func(t *testing.T) { 744 | g, mm := setupModem(t, cmdSet) 745 | defer teardownModem(mm) 746 | err := g.StartMessageRx(mh, eh, p.options...) 747 | require.Nil(t, err) 748 | mm.r <- []byte(sfsi) 749 | select { 750 | case msg := <-msgChan: 751 | t.Errorf("received message: %v", msg) 752 | case err := <-errChan: 753 | require.IsType(t, p.err, err) 754 | if _, ok := err.(gsm.ErrReassemblyTimeout); !ok { 755 | assert.Equal(t, p.err, err) 756 | } else { 757 | assert.Equal(t, p.err.Error(), err.Error()) 758 | } 759 | case <-time.After(100 * time.Millisecond): 760 | assert.False(t, p.expire) 761 | } 762 | } 763 | t.Run(p.name, f) 764 | } 765 | } 766 | 767 | func TestStopMessageRx(t *testing.T) { 768 | cmdSet := map[string][]string{ 769 | "AT+CSMS=1\r\n": {"\r\nOK\r\n"}, 770 | "AT+CNMI=1,2,0,0,0\r\n": {"\r\nOK\r\n"}, 771 | "AT+CNMI=0,0,0,0,0\r\n": {"\r\nOK\r\n"}, 772 | "AT+CNMA\r\n": {"\r\nOK\r\n"}, 773 | } 774 | g, mm := setupModem(t, cmdSet) 775 | mm.echo = false 776 | defer teardownModem(mm) 777 | 778 | msgChan := make(chan gsm.Message, 3) 779 | errChan := make(chan error, 3) 780 | mh := func(msg gsm.Message) { 781 | msgChan <- msg 782 | } 783 | eh := func(err error) { 784 | errChan <- err 785 | } 786 | err := g.StartMessageRx(mh, eh) 787 | require.Nil(t, err) 788 | mm.r <- []byte("+CMT: ,24\r\n00040B911234567890F000000250100173832305C8329BFD06\r\n") 789 | select { 790 | case <-msgChan: 791 | case <-time.After(100 * time.Millisecond): 792 | t.Errorf("no notification received") 793 | } 794 | 795 | // stop 796 | g.StopMessageRx() 797 | 798 | // would return a msg 799 | mm.r <- []byte("+CMT: ,24\r\n00040B911234567890F000000250100173832305C8329BFD06\r\n") 800 | select { 801 | case msg := <-msgChan: 802 | t.Errorf("msg received: %v", msg) 803 | case err := <-errChan: 804 | t.Errorf("error received: %v", err) 805 | case <-time.After(100 * time.Millisecond): 806 | } 807 | 808 | // would return an error 809 | mm.r <- []byte("+CMT: ,13\r\n00040B911234567890\r\n") 810 | select { 811 | case msg := <-msgChan: 812 | t.Errorf("msg received: %v", msg) 813 | case err := <-errChan: 814 | t.Errorf("error received: %v", err) 815 | case <-time.After(100 * time.Millisecond): 816 | } 817 | } 818 | 819 | func TestUnmarshalTPDU(t *testing.T) { 820 | patterns := []struct { 821 | name string 822 | info []string 823 | tpdu tpdu.TPDU 824 | err error 825 | }{ 826 | { 827 | "ok", 828 | []string{ 829 | "+CMT: ,24", 830 | "00040B911234567890F000000250100173832305C8329BFD06", 831 | }, 832 | tpdu.TPDU{ 833 | FirstOctet: 0x04, 834 | OA: tpdu.Address{TOA: 145, Addr: "21436587090"}, 835 | UD: []byte("Hello"), 836 | }, 837 | nil, 838 | }, 839 | { 840 | "underlength", 841 | []string{ 842 | "+CMT: ,2X", 843 | }, 844 | tpdu.TPDU{}, 845 | gsm.ErrUnderlength, 846 | }, 847 | { 848 | "bad length", 849 | []string{ 850 | "+CMT: ,2X", 851 | "00040B911234567JUNK000000250100173832305C8329BFD06", 852 | }, 853 | tpdu.TPDU{}, 854 | &strconv.NumError{Func: "Atoi", Num: "2X", Err: strconv.ErrSyntax}, 855 | }, 856 | { 857 | "mismatched length", 858 | []string{ 859 | "+CMT: ,24", 860 | "00040B911234567890F00000", 861 | }, 862 | tpdu.TPDU{}, 863 | fmt.Errorf("length mismatch - expected %d, got %d", 24, 11), 864 | }, 865 | { 866 | "not hex", 867 | []string{ 868 | "+CMT: ,24", 869 | "00040B911234567JUNK000000250100173832305C8329BFD06", 870 | }, 871 | tpdu.TPDU{}, 872 | hex.InvalidByteError(0x4a), 873 | }, 874 | } 875 | for _, p := range patterns { 876 | f := func(t *testing.T) { 877 | tp, err := gsm.UnmarshalTPDU(p.info) 878 | tp.SCTS = tpdu.Timestamp{} 879 | assert.Equal(t, p.tpdu, tp) 880 | assert.Equal(t, p.err, err) 881 | } 882 | t.Run(p.name, f) 883 | } 884 | } 885 | 886 | func TestWithSCA(t *testing.T) { 887 | var sca pdumode.SMSCAddress 888 | sca.Addr = "text" 889 | gopts := []gsm.Option{gsm.WithSCA(sca)} 890 | g, mm := setupModem(t, nil, gopts...) 891 | defer teardownModem(mm) 892 | 893 | tp := []byte{1, 2, 3, 4, 5, 6} 894 | omr, oerr := g.SendPDU(tp) 895 | assert.Equal(t, tpdu.EncodeError("addr", semioctet.ErrInvalidDigit(0x74)), oerr) 896 | assert.Equal(t, "", omr) 897 | } 898 | 899 | func TestErrors(t *testing.T) { 900 | patterns := []struct { 901 | name string 902 | err error 903 | errStr string 904 | }{ 905 | { 906 | "collect", 907 | gsm.ErrCollect{ 908 | TPDU: tpdu.TPDU{ 909 | FirstOctet: 0x44, 910 | DCS: 0, 911 | SCTS: tpdu.Timestamp{ 912 | Time: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC)}, 913 | UDH: tpdu.UserDataHeader{ 914 | tpdu.InformationElement{ID: 0, Data: []byte{3, 2, 6}}, 915 | }, 916 | UD: []byte("Hahahaha"), 917 | }, 918 | Err: errors.New("twisted"), 919 | }, 920 | "error 'twisted' collecting TPDU: {Direction:0 FirstOctet:68 OA:{TOA:0 Addr:} FCS:0 MR:0 CT:0 MN:0 DA:{TOA:0 Addr:} RA:{TOA:0 Addr:} PI:0 SCTS:2001-01-01 00:00:00 +0000 DT:0001-01-01 00:00:00 +0000 ST:0 PID:0 DCS:0x00 7bit VP:{Format:Not Present Time:0001-01-01 00:00:00 +0000 Duration:0s EFI:0} UDH:[{ID:0 Data:[3 2 6]}] UD:[72 97 104 97 104 97 104 97]}", 921 | }, 922 | { 923 | "decode", 924 | gsm.ErrDecode{ 925 | TPDUs: []*tpdu.TPDU{ 926 | { 927 | 928 | FirstOctet: 4, 929 | DCS: 0x08, 930 | SCTS: tpdu.Timestamp{ 931 | Time: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.FixedZone("any", 0))}, 932 | UD: []byte{0xd8, 0x3d, 0xde, 0x01, 0xd8, 0x3d}, 933 | }, 934 | }, 935 | Err: errors.New("dangling surrogate"), 936 | }, 937 | "error 'dangling surrogate' decoding: &{Direction:0 FirstOctet:4 OA:{TOA:0 Addr:} FCS:0 MR:0 CT:0 MN:0 DA:{TOA:0 Addr:} RA:{TOA:0 Addr:} PI:0 SCTS:2001-01-01 00:00:00 +0000 DT:0001-01-01 00:00:00 +0000 ST:0 PID:0 DCS:0x08 UCS-2 VP:{Format:Not Present Time:0001-01-01 00:00:00 +0000 Duration:0s EFI:0} UDH:[] UD:[216 61 222 1 216 61]}", 938 | }, 939 | { 940 | 941 | "reassemblyTimeout", 942 | gsm.ErrReassemblyTimeout{ 943 | TPDUs: []*tpdu.TPDU{ 944 | { 945 | 946 | FirstOctet: 4, 947 | DCS: 0x08, 948 | SCTS: tpdu.Timestamp{ 949 | Time: time.Date(2001, time.January, 1, 0, 0, 0, 0, time.FixedZone("any", 0))}, 950 | UD: []byte{0xd8, 0x3d, 0xde, 0x01, 0xd8, 0x3d}, 951 | }, 952 | }, 953 | }, 954 | "timeout reassembling: &{Direction:0 FirstOctet:4 OA:{TOA:0 Addr:} FCS:0 MR:0 CT:0 MN:0 DA:{TOA:0 Addr:} RA:{TOA:0 Addr:} PI:0 SCTS:2001-01-01 00:00:00 +0000 DT:0001-01-01 00:00:00 +0000 ST:0 PID:0 DCS:0x08 UCS-2 VP:{Format:Not Present Time:0001-01-01 00:00:00 +0000 Duration:0s EFI:0} UDH:[] UD:[216 61 222 1 216 61]}", 955 | }, 956 | { 957 | "unmarshal", 958 | gsm.ErrUnmarshal{ 959 | Info: []string{ 960 | "+CMT: ,2X", 961 | "00040B911234567JUNK000000250100173832305C8329BFD06", 962 | }, 963 | Err: errors.New("bent"), 964 | }, 965 | "error 'bent' unmarshalling: +CMT: ,2X\n00040B911234567JUNK000000250100173832305C8329BFD06\n", 966 | }, 967 | } 968 | for _, p := range patterns { 969 | f := func(t *testing.T) { 970 | assert.Equal(t, p.errStr, p.err.Error()) 971 | } 972 | t.Run(p.name, f) 973 | } 974 | } 975 | 976 | type mockModem struct { 977 | cmdSet map[string][]string 978 | echo bool 979 | closed bool 980 | readDelay time.Duration 981 | // The buffer emulating characters emitted by the modem. 982 | r chan []byte 983 | } 984 | 985 | func (mm *mockModem) Read(p []byte) (n int, err error) { 986 | data, ok := <-mm.r 987 | if data == nil { 988 | return 0, at.ErrClosed 989 | } 990 | time.Sleep(mm.readDelay) 991 | copy(p, data) // assumes p is empty 992 | if !ok { 993 | return len(data), fmt.Errorf("closed with data") 994 | } 995 | return len(data), nil 996 | } 997 | 998 | func (mm *mockModem) Write(p []byte) (n int, err error) { 999 | if mm.closed { 1000 | return 0, at.ErrClosed 1001 | } 1002 | if mm.echo { 1003 | mm.r <- p 1004 | } 1005 | v := mm.cmdSet[string(p)] 1006 | if len(v) == 0 { 1007 | mm.r <- []byte("\r\nERROR\r\n") 1008 | } else { 1009 | for _, l := range v { 1010 | if len(l) == 0 { 1011 | continue 1012 | } 1013 | mm.r <- []byte(l) 1014 | } 1015 | } 1016 | return len(p), nil 1017 | } 1018 | 1019 | func (mm *mockModem) Close() error { 1020 | if mm.closed == false { 1021 | mm.closed = true 1022 | close(mm.r) 1023 | } 1024 | return nil 1025 | } 1026 | 1027 | func setupModem(t *testing.T, cmdSet map[string][]string, gopts ...gsm.Option) (*gsm.GSM, *mockModem) { 1028 | mm := &mockModem{ 1029 | cmdSet: cmdSet, 1030 | echo: true, 1031 | r: make(chan []byte, 10), 1032 | readDelay: time.Millisecond, 1033 | } 1034 | var modem io.ReadWriter = mm 1035 | if debug { 1036 | modem = trace.New(modem) 1037 | } 1038 | g := gsm.New(at.New(modem), gopts...) 1039 | require.NotNil(t, g) 1040 | return g, mm 1041 | } 1042 | 1043 | func teardownModem(mm *mockModem) { 1044 | mm.Close() 1045 | } 1046 | 1047 | type mockCollector struct { 1048 | errChan chan<- error 1049 | err error 1050 | } 1051 | 1052 | func (c mockCollector) Collect(t tpdu.TPDU) ([]*tpdu.TPDU, error) { 1053 | c.errChan <- c.err 1054 | return nil, nil 1055 | } 1056 | -------------------------------------------------------------------------------- /info/info.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // Package info provides utility functions for manipulating info lines returned 6 | // by the modem in response to AT commands. 7 | package info 8 | 9 | import "strings" 10 | 11 | // HasPrefix returns true if the line begins with the info prefix for the 12 | // command. 13 | func HasPrefix(line, cmd string) bool { 14 | return strings.HasPrefix(line, cmd+":") 15 | } 16 | 17 | // TrimPrefix removes the command prefix, if any, and any intervening space 18 | // from the info line. 19 | func TrimPrefix(line, cmd string) string { 20 | return strings.TrimLeft(strings.TrimPrefix(line, cmd+":"), " ") 21 | } 22 | -------------------------------------------------------------------------------- /info/info_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | package info_test 6 | 7 | import ( 8 | "testing" 9 | 10 | "github.com/stretchr/testify/assert" 11 | "github.com/warthog618/modem/info" 12 | ) 13 | 14 | func TestHasPrefix(t *testing.T) { 15 | l := "cmd: blah" 16 | assert.True(t, info.HasPrefix(l, "cmd")) 17 | assert.False(t, info.HasPrefix(l, "cmd:")) 18 | } 19 | 20 | func TestTrimPrefix(t *testing.T) { 21 | // no prefix 22 | i := info.TrimPrefix("info line", "cmd") 23 | assert.Equal(t, "info line", i) 24 | 25 | // prefix 26 | i = info.TrimPrefix("cmd:info line", "cmd") 27 | assert.Equal(t, "info line", i) 28 | 29 | // prefix and space 30 | i = info.TrimPrefix("cmd: info line", "cmd") 31 | assert.Equal(t, "info line", i) 32 | } 33 | -------------------------------------------------------------------------------- /serial/serial.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // Package serial provides a serial port, which provides the io.ReadWriter interface, 6 | // that provides the connection between the at or gsm packages and the physical modem. 7 | package serial 8 | 9 | import ( 10 | "github.com/tarm/serial" 11 | ) 12 | 13 | // New creates a serial port. 14 | // 15 | // This is currently a simple wrapper around tarm serial. 16 | func New(options ...Option) (*serial.Port, error) { 17 | cfg := defaultConfig 18 | for _, option := range options { 19 | option.applyConfig(&cfg) 20 | } 21 | config := serial.Config{Name: cfg.port, Baud: cfg.baud} 22 | p, err := serial.OpenPort(&config) 23 | if err != nil { 24 | return nil, err 25 | } 26 | return p, nil 27 | } 28 | 29 | // WithBaud sets the baud rate for the serial port. 30 | func WithBaud(b int) Baud { 31 | return Baud(b) 32 | } 33 | 34 | // WithPort specifies the port for the serial port. 35 | func WithPort(p string) Port { 36 | return Port(p) 37 | } 38 | 39 | // Option is a construction option that modifies the behaviour of the serial port. 40 | type Option interface { 41 | applyConfig(*Config) 42 | } 43 | 44 | // Config contains the configuration parameters of the serial port. 45 | type Config struct { 46 | port string 47 | baud int 48 | } 49 | 50 | // Baud is the bit rate for the serial line. 51 | type Baud int 52 | 53 | func (b Baud) applyConfig(c *Config) { 54 | c.baud = int(b) 55 | } 56 | 57 | // Port identifies the serial port on the plaform. 58 | type Port string 59 | 60 | func (p Port) applyConfig(c *Config) { 61 | c.port = string(p) 62 | } 63 | -------------------------------------------------------------------------------- /serial/serial_darwin.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2020 Kent Gibson . 4 | 5 | // +build darwin 6 | 7 | package serial 8 | 9 | var defaultConfig = Config{ 10 | port: "/dev/tty.usbserial", 11 | baud: 115200, 12 | } 13 | -------------------------------------------------------------------------------- /serial/serial_linux.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2020 Kent Gibson . 4 | 5 | // +build linux 6 | 7 | package serial 8 | 9 | var defaultConfig = Config{ 10 | port: "/dev/ttyUSB0", 11 | baud: 115200, 12 | } 13 | -------------------------------------------------------------------------------- /serial/serial_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | package serial_test 6 | 7 | import ( 8 | "errors" 9 | "os" 10 | "syscall" 11 | "testing" 12 | 13 | "github.com/stretchr/testify/require" 14 | "github.com/warthog618/modem/serial" 15 | ) 16 | 17 | func modemExists(name string) func(t *testing.T) { 18 | return func(t *testing.T) { 19 | if _, err := os.Stat(name); os.IsNotExist(err) { 20 | t.Skip("no modem available") 21 | } 22 | } 23 | } 24 | func TestNew(t *testing.T) { 25 | patterns := []struct { 26 | name string 27 | prereq func(t *testing.T) 28 | options []serial.Option 29 | err error 30 | }{ 31 | { 32 | "default", 33 | modemExists("/dev/ttyUSB0"), 34 | nil, 35 | nil, 36 | }, 37 | { 38 | "empty", 39 | modemExists("/dev/ttyUSB0"), 40 | []serial.Option{}, 41 | nil, 42 | }, 43 | { 44 | "baud", 45 | modemExists("/dev/ttyUSB0"), 46 | []serial.Option{serial.WithBaud(9600)}, 47 | nil, 48 | }, 49 | { 50 | "port", 51 | modemExists("/dev/ttyUSB0"), 52 | []serial.Option{serial.WithPort("/dev/ttyUSB0")}, 53 | nil, 54 | }, 55 | { 56 | "bad port", 57 | nil, 58 | []serial.Option{serial.WithPort("nosuchmodem")}, 59 | &os.PathError{Op: "open", Path: "nosuchmodem", Err: syscall.Errno(2)}, 60 | }, 61 | { 62 | "bad baud", 63 | modemExists("/dev/ttyUSB0"), 64 | []serial.Option{serial.WithBaud(1234)}, 65 | errors.New("Unrecognized baud rate"), 66 | }, 67 | } 68 | for _, p := range patterns { 69 | f := func(t *testing.T) { 70 | if p.prereq != nil { 71 | p.prereq(t) 72 | } 73 | m, err := serial.New(p.options...) 74 | require.Equal(t, p.err, err) 75 | require.Equal(t, err == nil, m != nil) 76 | if m != nil { 77 | m.Close() 78 | } 79 | } 80 | t.Run(p.name, f) 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /serial/serial_windows.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2020 Kent Gibson . 4 | 5 | // build +windows 6 | 7 | package serial 8 | 9 | var defaultConfig = Config{ 10 | port: "COM1", 11 | baud: 115200, 12 | } 13 | -------------------------------------------------------------------------------- /trace/trace.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | // Package trace provides a decorator for io.ReadWriter that logs all reads 6 | // and writes. 7 | package trace 8 | 9 | import ( 10 | "io" 11 | "log" 12 | "os" 13 | ) 14 | 15 | // Trace is a trace log on an io.ReadWriter. 16 | // 17 | // All reads and writes are written to the logger. 18 | type Trace struct { 19 | rw io.ReadWriter 20 | l Logger 21 | wfmt string 22 | rfmt string 23 | } 24 | 25 | // Logger defines the interface used to log trace messages. 26 | type Logger interface { 27 | Printf(format string, v ...interface{}) 28 | } 29 | 30 | // Option modifies a Trace object created by New. 31 | type Option func(*Trace) 32 | 33 | // New creates a new trace on the io.ReadWriter. 34 | func New(rw io.ReadWriter, options ...Option) *Trace { 35 | t := &Trace{ 36 | rw: rw, 37 | wfmt: "w: %s", 38 | rfmt: "r: %s", 39 | } 40 | for _, option := range options { 41 | option(t) 42 | } 43 | if t.l == nil { 44 | t.l = log.New(os.Stdout, "", log.LstdFlags) 45 | } 46 | return t 47 | } 48 | 49 | // WithReadFormat sets the format used for read logs. 50 | func WithReadFormat(format string) Option { 51 | return func(t *Trace) { 52 | t.rfmt = format 53 | } 54 | } 55 | 56 | // WithWriteFormat sets the format used for write logs. 57 | func WithWriteFormat(format string) Option { 58 | return func(t *Trace) { 59 | t.wfmt = format 60 | } 61 | } 62 | 63 | // WithLogger specifies the logger to be used to log trace messages. 64 | // 65 | // By default traces are logged to Stdout. 66 | func WithLogger(l Logger) Option { 67 | return func(t *Trace) { 68 | t.l = l 69 | } 70 | } 71 | 72 | func (t *Trace) Read(p []byte) (n int, err error) { 73 | n, err = t.rw.Read(p) 74 | if n > 0 { 75 | t.l.Printf(t.rfmt, p[:n]) 76 | } 77 | return n, err 78 | } 79 | 80 | func (t *Trace) Write(p []byte) (n int, err error) { 81 | n, err = t.rw.Write(p) 82 | if n > 0 { 83 | t.l.Printf(t.wfmt, p[:n]) 84 | } 85 | return n, err 86 | } 87 | -------------------------------------------------------------------------------- /trace/trace_test.go: -------------------------------------------------------------------------------- 1 | // SPDX-License-Identifier: MIT 2 | // 3 | // Copyright © 2018 Kent Gibson . 4 | 5 | package trace_test 6 | 7 | import ( 8 | "bytes" 9 | "log" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | "github.com/stretchr/testify/require" 14 | "github.com/warthog618/modem/trace" 15 | ) 16 | 17 | func TestNew(t *testing.T) { 18 | mrw := bytes.NewBufferString("one") 19 | b := bytes.Buffer{} 20 | l := log.New(&b, "", log.LstdFlags) 21 | // vanilla 22 | tr := trace.New(mrw) 23 | assert.NotNil(t, tr) 24 | 25 | // with options 26 | tr = trace.New(mrw, trace.WithLogger(l), trace.WithReadFormat("r: %v")) 27 | assert.NotNil(t, tr) 28 | } 29 | 30 | func TestRead(t *testing.T) { 31 | mrw := bytes.NewBufferString("one") 32 | b := bytes.Buffer{} 33 | l := log.New(&b, "", 0) 34 | tr := trace.New(mrw, trace.WithLogger(l)) 35 | require.NotNil(t, tr) 36 | i := make([]byte, 10) 37 | n, err := tr.Read(i) 38 | assert.Nil(t, err) 39 | assert.Equal(t, 3, n) 40 | assert.Equal(t, []byte("r: one\n"), b.Bytes()) 41 | } 42 | 43 | func TestWrite(t *testing.T) { 44 | mrw := bytes.NewBufferString("one") 45 | b := bytes.Buffer{} 46 | l := log.New(&b, "", 0) 47 | tr := trace.New(mrw, trace.WithLogger(l)) 48 | require.NotNil(t, tr) 49 | n, err := tr.Write([]byte("two")) 50 | assert.Nil(t, err) 51 | assert.Equal(t, 3, n) 52 | assert.Equal(t, []byte("w: two\n"), b.Bytes()) 53 | } 54 | 55 | func TestReadFormat(t *testing.T) { 56 | mrw := bytes.NewBufferString("one") 57 | b := bytes.Buffer{} 58 | l := log.New(&b, "", 0) 59 | tr := trace.New(mrw, trace.WithLogger(l), trace.WithReadFormat("R: %v")) 60 | require.NotNil(t, tr) 61 | i := make([]byte, 10) 62 | n, err := tr.Read(i) 63 | assert.Nil(t, err) 64 | assert.Equal(t, 3, n) 65 | assert.Equal(t, []byte("R: [111 110 101]\n"), b.Bytes()) 66 | } 67 | 68 | func TestWriteFormat(t *testing.T) { 69 | mrw := bytes.NewBufferString("one") 70 | b := bytes.Buffer{} 71 | l := log.New(&b, "", 0) 72 | tr := trace.New(mrw, trace.WithLogger(l), trace.WithWriteFormat("W: %v")) 73 | require.NotNil(t, tr) 74 | n, err := tr.Write([]byte("two")) 75 | assert.Nil(t, err) 76 | assert.Equal(t, 3, n) 77 | assert.Equal(t, []byte("W: [116 119 111]\n"), b.Bytes()) 78 | } 79 | --------------------------------------------------------------------------------