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