219 lines
9.0 KiB
Python
219 lines
9.0 KiB
Python
import serial
|
|
import time
|
|
import threading
|
|
import re
|
|
|
|
class GSMManager:
|
|
def __init__(self, port='COM1', baudrate=115200, message_callback=None):
|
|
"""
|
|
message_callback: function(sender_number, message_text)
|
|
called when a new SMS is fully read.
|
|
"""
|
|
self.port = port
|
|
self.baudrate = baudrate
|
|
self.serial_conn = None
|
|
self.is_running = False
|
|
self.read_thread = None
|
|
self.message_callback = message_callback
|
|
|
|
# Lock for thread-safe AT command execution
|
|
self.lock = threading.Lock()
|
|
|
|
def connect(self):
|
|
try:
|
|
self.serial_conn = serial.Serial(
|
|
port=self.port,
|
|
baudrate=self.baudrate,
|
|
timeout=1 # 1 second timeout for readline
|
|
)
|
|
self.is_running = True
|
|
|
|
# Setup Modem
|
|
self.send_at_cmd('AT')
|
|
self.send_at_cmd('AT+CMGF=1') # Text Mode
|
|
self.send_at_cmd('AT+CNMI=2,1,0,0,0') # Route URC to TE for +CMTI
|
|
|
|
# Start Read Thread
|
|
self.read_thread = threading.Thread(target=self._read_loop, daemon=True)
|
|
self.read_thread.start()
|
|
|
|
return True
|
|
except Exception as e:
|
|
print(f"Failed to connect GSM: {e}")
|
|
return False
|
|
|
|
def disconnect(self):
|
|
self.is_running = False
|
|
if self.serial_conn and self.serial_conn.is_open:
|
|
self.serial_conn.close()
|
|
if self.read_thread:
|
|
self.read_thread.join(timeout=2)
|
|
|
|
def send_at_cmd(self, cmd, expected_resp='OK', timeout=2):
|
|
"""Send an AT command safely and wait for a response."""
|
|
with self.lock:
|
|
if not self.serial_conn or not self.serial_conn.is_open:
|
|
return False, "Not connected"
|
|
|
|
# Flush input buffer to clear old data
|
|
self.serial_conn.reset_input_buffer()
|
|
|
|
full_cmd = cmd + '\r\n'
|
|
self.serial_conn.write(full_cmd.encode('ascii'))
|
|
|
|
start_time = time.time()
|
|
response_lines = []
|
|
|
|
while time.time() - start_time < timeout:
|
|
if self.serial_conn.in_waiting:
|
|
line = self.serial_conn.readline().decode('ascii', errors='ignore').strip()
|
|
if line:
|
|
response_lines.append(line)
|
|
if expected_resp in line or 'ERROR' in line:
|
|
break
|
|
else:
|
|
time.sleep(0.05)
|
|
|
|
return expected_resp in "\n".join(response_lines), response_lines
|
|
|
|
def send_sms(self, phone_number, message):
|
|
"""
|
|
Sends an SMS. For long messages, splits them up.
|
|
(Note: Quectel M66 supports up to 160 chars in 7-bit text mode natively.
|
|
For simplicity, we chunk it to 150 chars max per SMS if it exceeds).
|
|
"""
|
|
chunk_size = 140 # Safe limit for standard ASCII/Base64
|
|
chunks = [message[i:i+chunk_size] for i in range(0, len(message), chunk_size)]
|
|
|
|
for idx, chunk in enumerate(chunks):
|
|
# If multiple chunks, we can prepend (1/2) kind of indicator for normal texts
|
|
# But for base64 secure chunks, they might need stitching.
|
|
# For simplicity, we just send them sequentially.
|
|
# In a production resilient app, multipart PDU mode is preferred.
|
|
|
|
success = self._send_single_sms(phone_number, chunk)
|
|
if not success:
|
|
return False
|
|
|
|
if len(chunks) > 1:
|
|
time.sleep(2) # Add some delay between multi-part sends
|
|
|
|
return True
|
|
|
|
def _send_single_sms(self, phone_number, text):
|
|
with self.lock:
|
|
try:
|
|
self.serial_conn.reset_input_buffer()
|
|
|
|
# Start SMS prompt
|
|
self.serial_conn.write(f'AT+CMGS="{phone_number}"\r\n'.encode('ascii'))
|
|
|
|
# Wait for '>'
|
|
start_time = time.time()
|
|
prompt_ready = False
|
|
while time.time() - start_time < 2:
|
|
if self.serial_conn.in_waiting:
|
|
char = self.serial_conn.read().decode('ascii', errors='ignore')
|
|
if char == '>':
|
|
prompt_ready = True
|
|
break
|
|
time.sleep(0.05)
|
|
|
|
if not prompt_ready:
|
|
print("SMS prompt '>' not received.")
|
|
# Send ESC just in case
|
|
self.serial_conn.write(chr(27).encode('ascii'))
|
|
return False
|
|
|
|
# Write text and send Ctrl+Z (ASCII 26)
|
|
self.serial_conn.write(text.encode('ascii') + chr(26).encode('ascii'))
|
|
|
|
# Wait for +CMGS and OK
|
|
start_time = time.time()
|
|
success = False
|
|
response_lines = []
|
|
while time.time() - start_time < 10: # SMS sending can take time
|
|
if self.serial_conn.in_waiting:
|
|
line = self.serial_conn.readline().decode('ascii', errors='ignore').strip()
|
|
if line:
|
|
response_lines.append(line)
|
|
if 'OK' in line:
|
|
success = True
|
|
break
|
|
elif 'ERROR' in line:
|
|
break
|
|
else:
|
|
time.sleep(0.1)
|
|
|
|
return success
|
|
except Exception as e:
|
|
print(f"Error sending SMS: {e}")
|
|
return False
|
|
|
|
def _read_loop(self):
|
|
"""Background thread continuously reading URCs like +CMTI"""
|
|
while self.is_running:
|
|
try:
|
|
if self.serial_conn and self.serial_conn.in_waiting:
|
|
# We only read if we can acquire the lock without waiting,
|
|
# otherwise it means AT command is in progress and it handles reading.
|
|
# Wait, AT command reading in `send_at_cmd` reads synchronously. URCs might mix.
|
|
# Standard practice for URCs: the AT command sender ignores unexpected URCs,
|
|
# OR we have a dedicated thread that reads everything and routes URCs vs command responses.
|
|
# For simplicity, we try to grab the lock. If locked, AT comm is happening.
|
|
if self.lock.acquire(blocking=False):
|
|
try:
|
|
line = self.serial_conn.readline().decode('ascii', errors='ignore').strip()
|
|
if line.startswith('+CMTI:'):
|
|
# Incoming message: +CMTI: "SM",1
|
|
match = re.search(r'\+CMTI:\s*".*?",(\d+)', line)
|
|
if match:
|
|
msg_index = int(match.group(1))
|
|
# Process it in a new thread or queue to avoid blocking this loop
|
|
threading.Thread(target=self.process_incoming_sms, args=(msg_index,), daemon=True).start()
|
|
finally:
|
|
self.lock.release()
|
|
except Exception as e:
|
|
pass
|
|
time.sleep(0.1)
|
|
|
|
def process_incoming_sms(self, index):
|
|
"""Reads the SMS payload from memory and deletes it."""
|
|
# read_sms uses AT command, so it uses the lock
|
|
# wait a bit ensuring any ongoing AT comm finishes
|
|
time.sleep(1)
|
|
|
|
success, response = self.send_at_cmd(f'AT+CMGR={index}', expected_resp='OK', timeout=3)
|
|
if success:
|
|
sender = "Unknown"
|
|
text_lines = []
|
|
is_text_block = False
|
|
|
|
for line in response:
|
|
if line.startswith('+CMGR:'):
|
|
# +CMGR: "REC UNREAD","+989123456789",,"23/05/26,10:30:00+14"
|
|
parts = line.split(',')
|
|
if len(parts) >= 2:
|
|
sender = parts[1].strip('"')
|
|
is_text_block = True
|
|
elif is_text_block and line not in ['OK', 'ERROR'] and not line.startswith('+CMGR:'):
|
|
text_lines.append(line)
|
|
|
|
full_text = "\n".join(text_lines)
|
|
|
|
if self.message_callback:
|
|
self.message_callback(sender, full_text)
|
|
|
|
# Delete message to free space
|
|
self.send_at_cmd(f'AT+CMGD={index}')
|
|
|
|
if __name__ == "__main__":
|
|
def on_sms(sender, text):
|
|
print(f"\n[NEW SMS] From: {sender}\nText:{text}\n")
|
|
|
|
gsm = GSMManager(port='COM3')
|
|
print("Testing locally...")
|
|
# This will obviously fail without actual M66 module on COM3, but acts as boilerplate check.
|
|
# gsm.connect()
|
|
# gsm.disconnect()
|