mirror of
https://github.com/MengMengCode/VoCat.git
synced 2026-08-13 03:13:43 +08:00
445 lines
12 KiB
Go
445 lines
12 KiB
Go
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
|
|
}
|
|
}
|