Saba-python/GSM_Manager.py
2026-03-23 19:29:24 +03:30

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()