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 } }