| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444 |
- package device
- import (
- "context"
- "errors"
- "fmt"
- "strconv"
- "strings"
- "time"
- "vocat/internal/modem"
- )
- func (manager *Manager) SendSMS(
- ctx context.Context,
- id string,
- recipient string,
- text string,
- ) (SMSSendResult, error) {
- parts, err := prepareSMSParts(recipient, text)
- if err != nil {
- return SMSSendResult{}, err
- }
- result := SMSSendResult{
- To: parts[0].to,
- Encoding: parts[0].encoding,
- DeliveryConfirmed: false,
- DeliveryStatus: "unknown",
- SubmissionStatus: "unknown",
- SubmittedAt: time.Now().UTC(),
- PartsTotal: len(parts),
- PartResults: make([]SMSPartResult, 0, len(parts)),
- }
- if parts[0].concatReference != nil {
- reference := *parts[0].concatReference
- result.ConcatReference = &reference
- }
- state, err := manager.lookup(id)
- if err != nil {
- return result, err
- }
- state.opMu.Lock()
- defer state.opMu.Unlock()
- if err := manager.validateActive(id, state); err != nil {
- return result, err
- }
- client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
- if err != nil {
- manager.setResult(id, state, nil, err)
- return result, err
- }
- if err := manager.regionBlockError(state); err != nil {
- result.SubmissionStatus = "region_blocked"
- manager.setResult(id, state, nil, err)
- return result, err
- }
- for _, command := range parts[0].setup {
- if _, err := manager.command(ctx, client, command); err != nil {
- result.SubmissionStatus = "setup_failed"
- manager.setResult(id, state, nil, err)
- return result, err
- }
- }
- for _, part := range parts {
- response, submitErr := manager.prompt(
- ctx,
- client,
- part.prompt,
- part.payload,
- )
- reference, found := parseCMGSReference(response)
- partResult := SMSPartResult{
- Part: part.part,
- Total: part.total,
- MessageReference: reference,
- ReferenceKnown: found,
- AcceptedByModem: response.OK() && found,
- SubmissionStatus: "unknown",
- ModemFinal: response.Final,
- ModemEvidence: append([]string(nil), response.Lines...),
- SubmittedAt: time.Now().UTC(),
- }
- switch {
- case submitErr != nil && found:
- partResult.SubmissionStatus = "reference_returned_without_final"
- case submitErr != nil:
- var commandErr *modem.CommandError
- if errors.As(submitErr, &commandErr) {
- partResult.SubmissionStatus = "rejected_by_modem"
- }
- case !found:
- partResult.SubmissionStatus = "unconfirmed_without_reference"
- default:
- partResult.SubmissionStatus = "accepted_by_modem"
- }
- result.PartResults = append(result.PartResults, partResult)
- result.PartsAttempted++
- if partResult.AcceptedByModem {
- result.PartsAccepted++
- }
- result.ModemFinal = response.Final
- if len(parts) == 1 {
- result.MessageReference = reference
- result.ReferenceKnown = found
- result.AcceptedByModem = partResult.AcceptedByModem
- result.ModemEvidence = append([]string(nil), response.Lines...)
- } else {
- for _, line := range response.Lines {
- result.ModemEvidence = append(
- result.ModemEvidence,
- fmt.Sprintf("part %d/%d: %s", part.part, part.total, line),
- )
- }
- }
- if submitErr == nil && !found {
- submitErr = ErrSMSReferenceMissing
- }
- if submitErr == nil {
- continue
- }
- var commandErr *modem.CommandError
- switch {
- case len(parts) == 1:
- result.SubmissionStatus = partResult.SubmissionStatus
- case result.PartsAccepted > 0:
- result.SubmissionStatus = "partially_accepted_by_modem"
- case errors.As(submitErr, &commandErr):
- result.SubmissionStatus = "rejected_by_modem"
- default:
- result.SubmissionStatus = "unknown"
- }
- if errors.Is(submitErr, modem.ErrCommandTimeout) ||
- errors.Is(submitErr, context.Canceled) ||
- errors.Is(submitErr, context.DeadlineExceeded) {
- // A timeout after Ctrl-Z has an inherently uncertain outcome. Close
- // this session so a late +CMGS/OK cannot corrupt the next command;
- // callers must decide whether it is safe to retry.
- _ = client.Close()
- state.client = nil
- }
- partErr := fmt.Errorf(
- "submit SMS part %d/%d: %w",
- part.part,
- part.total,
- submitErr,
- )
- manager.setResult(id, state, nil, partErr)
- return result, partErr
- }
- result.AcceptedByModem = true
- result.AllPartsAccepted = true
- result.SubmissionStatus = "accepted_by_modem"
- manager.setResult(id, state, nil, nil)
- return result, nil
- }
- // smsListStorages are the message storage areas ListSMS enumerates. AT+CMGL
- // only reads the single storage currently selected by CPMS mem1, so a message
- // that landed in SIM storage (SM) is invisible while the module memory (ME) is
- // selected, and vice versa. Reading both guarantees nothing is missed regardless
- // of where the network or SIM placed it. MT is the union of SM and ME, so it is
- // not listed separately.
- var smsListStorages = []string{"SM", "ME"}
- func (manager *Manager) ListSMS(
- ctx context.Context,
- id string,
- ) ([]SMSMessage, error) {
- state, err := manager.lookup(id)
- if err != nil {
- return nil, err
- }
- state.opMu.Lock()
- defer state.opMu.Unlock()
- if err := manager.validateActive(id, state); err != nil {
- return nil, err
- }
- client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
- if err != nil {
- manager.setResult(id, state, nil, err)
- return nil, err
- }
- if _, err := manager.command(ctx, client, "AT+CMGF=0"); err != nil {
- manager.setResult(id, state, nil, err)
- return nil, err
- }
- var messages []SMSMessage
- var lastErr error
- listed := false
- for _, storage := range smsListStorages {
- // Select this storage for reading (mem1 only, so send/receive routing is
- // untouched). An unsupported storage reports an error; skip it rather than
- // fail the whole listing.
- if _, err := manager.command(
- ctx,
- client,
- fmt.Sprintf("AT+CPMS=%q", storage),
- ); err != nil {
- lastErr = err
- continue
- }
- response, err := manager.command(ctx, client, "AT+CMGL=4")
- if err != nil {
- lastErr = err
- continue
- }
- listed = true
- for _, message := range parseCMGL(response) {
- message.Storage = storage
- messages = append(messages, message)
- }
- }
- if !listed && lastErr != nil {
- manager.setResult(id, state, nil, lastErr)
- return nil, lastErr
- }
- manager.setResult(id, state, nil, nil)
- return messages, nil
- }
- func (manager *Manager) ReadSMS(
- ctx context.Context,
- id string,
- index int,
- ) (SMSMessage, error) {
- if index <= 0 {
- return SMSMessage{}, ErrSMSInvalidMessageIndex
- }
- state, err := manager.lookup(id)
- if err != nil {
- return SMSMessage{}, err
- }
- state.opMu.Lock()
- defer state.opMu.Unlock()
- if err := manager.validateActive(id, state); err != nil {
- return SMSMessage{}, err
- }
- client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
- if err != nil {
- manager.setResult(id, state, nil, err)
- return SMSMessage{}, err
- }
- if _, err := manager.command(ctx, client, "AT+CMGF=0"); err != nil {
- manager.setResult(id, state, nil, err)
- return SMSMessage{}, err
- }
- response, err := manager.command(
- ctx,
- client,
- fmt.Sprintf("AT+CMGR=%d", index),
- )
- if err != nil {
- manager.setResult(id, state, nil, err)
- return SMSMessage{}, err
- }
- message, err := parseCMGR(index, response)
- manager.setResult(id, state, nil, err)
- return message, err
- }
- func (manager *Manager) DeleteSMS(
- ctx context.Context,
- id string,
- index int,
- ) error {
- if index <= 0 {
- return ErrSMSInvalidMessageIndex
- }
- state, err := manager.lookup(id)
- if err != nil {
- return err
- }
- state.opMu.Lock()
- defer state.opMu.Unlock()
- if err := manager.validateActive(id, state); err != nil {
- return err
- }
- client, err := manager.clientLocked(ctx, state, manager.candidateFor(state))
- if err != nil {
- manager.setResult(id, state, nil, err)
- return err
- }
- _, err = manager.command(ctx, client, fmt.Sprintf("AT+CMGD=%d", index))
- manager.setResult(id, state, nil, err)
- return err
- }
- func (manager *Manager) prompt(
- ctx context.Context,
- client modem.Client,
- command string,
- payload []byte,
- ) (modem.Response, error) {
- promptClient, ok := client.(modem.PromptClient)
- if !ok {
- return modem.Response{}, ErrSMSPromptUnsupported
- }
- commandCtx, cancel := manager.withTimeout(ctx, manager.smsTimeout)
- defer cancel()
- response, err := promptClient.ExecutePrompt(commandCtx, command, payload)
- if err != nil {
- return response, fmt.Errorf("%s: %w", command, err)
- }
- return response, nil
- }
- func parseCMGSReference(response modem.Response) (int, bool) {
- value := valueAfterPrefix(response, "+CMGS:")
- values := csvValues(value)
- if len(values) == 0 {
- return 0, false
- }
- reference, err := strconv.Atoi(strings.TrimSpace(values[0]))
- if err != nil || reference < 0 || reference > 255 {
- return 0, false
- }
- return reference, true
- }
- type smsRecordHeader struct {
- index int
- status SMSStorageStatus
- modemLength int
- err error
- }
- func parseCMGL(response modem.Response) []SMSMessage {
- var result []SMSMessage
- for index := 0; index < len(response.Lines); {
- line := strings.TrimSpace(response.Lines[index])
- if !strings.HasPrefix(strings.ToUpper(line), "+CMGL:") {
- index++
- continue
- }
- header := parseCMGLHeader(line)
- index++
- rawPDU := ""
- if index < len(response.Lines) &&
- !strings.HasPrefix(
- strings.ToUpper(strings.TrimSpace(response.Lines[index])),
- "+CMGL:",
- ) {
- rawPDU = strings.TrimSpace(response.Lines[index])
- index++
- }
- message := SMSMessage{
- Index: header.index,
- StorageStatus: header.status,
- ModemLength: header.modemLength,
- Direction: SMSDirectionUnknown,
- Encoding: SMSEncodingUnknown,
- RawPDU: strings.ToUpper(rawPDU),
- }
- switch {
- case header.err != nil:
- message.DecodeError = header.err.Error()
- case rawPDU == "":
- message.DecodeError = "CMGL record has no PDU"
- default:
- decoded, decodeErr := decodeSMSPDU(rawPDU)
- decoded.Index = header.index
- decoded.StorageStatus = header.status
- decoded.ModemLength = header.modemLength
- message = decoded
- if decodeErr != nil {
- message.DecodeError = decodeErr.Error()
- }
- }
- result = append(result, message)
- }
- return result
- }
- func parseCMGLHeader(line string) smsRecordHeader {
- value := strings.TrimSpace(strings.SplitN(line, ":", 2)[1])
- values := csvValues(value)
- header := smsRecordHeader{status: SMSStatusUnknown}
- if len(values) < 2 {
- header.err = errors.New("invalid CMGL header")
- return header
- }
- var ok bool
- header.index, ok = parseDecimal(values[0])
- if !ok || header.index <= 0 {
- header.err = errors.New("invalid CMGL message index")
- }
- header.status = parseSMSStorageStatus(values[1])
- if len(values) >= 3 {
- if length, found := parseDecimal(values[len(values)-1]); found {
- header.modemLength = length
- }
- }
- return header
- }
- func parseCMGR(index int, response modem.Response) (SMSMessage, error) {
- for lineIndex, line := range response.Lines {
- line = strings.TrimSpace(line)
- if !strings.HasPrefix(strings.ToUpper(line), "+CMGR:") {
- continue
- }
- values := csvValues(strings.TrimSpace(strings.SplitN(line, ":", 2)[1]))
- if len(values) < 1 {
- return SMSMessage{}, errors.New("invalid CMGR header")
- }
- status := parseSMSStorageStatus(values[0])
- modemLength := 0
- if length, found := parseDecimal(values[len(values)-1]); found {
- modemLength = length
- }
- if lineIndex+1 >= len(response.Lines) {
- return SMSMessage{}, errors.New("CMGR response has no PDU")
- }
- message, decodeErr := decodeSMSPDU(response.Lines[lineIndex+1])
- message.Index = index
- message.StorageStatus = status
- message.ModemLength = modemLength
- if decodeErr != nil {
- message.DecodeError = decodeErr.Error()
- }
- // Return the raw record even when decoding is incomplete.
- return message, nil
- }
- return SMSMessage{}, errors.New("modem did not return a CMGR record")
- }
- func parseSMSStorageStatus(value string) SMSStorageStatus {
- value = strings.ToUpper(strings.Trim(strings.TrimSpace(value), `"`))
- switch value {
- case "0", "REC UNREAD":
- return SMSStatusReceivedUnread
- case "1", "REC READ":
- return SMSStatusReceivedRead
- case "2", "STO UNSENT":
- return SMSStatusStoredUnsent
- case "3", "STO SENT":
- return SMSStatusStoredSent
- default:
- return SMSStatusUnknown
- }
- }
|