#!/usr/bin/env python3 # -*- coding: utf-8 -*- import logging import time from typing import Tuple, Union, Optional, ContextManager, List from m2m.misc.at_parser import AtParser from m2m.serial.serial_port import SerialPort from m2m.exceptions import NetworkError logger = logging.getLogger('m2m.module') class ModuleBase: """ Base class for all IoT modules. Defines the standard 3GPP interface and the contract for vendor-specific implementations. """ # SIM Elementary File IDs (3GPP TS 31.102 / 51.011) EF_ICCID = 0x2FE2 EF_IMSI = 0x6F07 EF_AD = 0x6FAD EF_LOCI = 0x6F7E EF_PSLOCI = 0x6F73 EF_FPLMN = 0x6F7B EF_EHPLMN = 0x6F43 EF_EPLMN = 0x6F66 EF_PLMNsel = 0x6F30 EF_PLMNwAcT = 0x6F60 EF_OPLMNwAcT = 0x6F61 EF_HPLMNwAcT = 0x6F62 EF_CAG = 0x6FD9 def __init__(self, serial_port: str, baudrate: int = 9600, **kwargs): self.s_port = SerialPort(serial_port, baudrate=baudrate, **kwargs) self.at = AtParser(self.s_port) self.cereg_level = 0 def __enter__(self): return self def __exit__(self, exc_type, exc_val, exc_tb): self.close() def close(self): """Closes the serial port connection.""" if self.s_port: self.s_port.close() @property def transaction(self) -> ContextManager: """Returns a context manager for atomic transactions (thread locking).""" return self.s_port.lock # --- Low Level AT --- def send_at_command(self, cmd: Union[bytes, str], timeout: float = 5.0) -> bytes: """Sends an AT command and returns the raw response.""" if isinstance(cmd, str): cmd = cmd.encode('ascii') with self.transaction: self.s_port.send_cmd(cmd) return self.s_port.read_until(timeout=timeout) def check_state(self) -> bool: """Checks if module responds to AT.""" with self.transaction: self.s_port.send_cmd(b'AT') return self.at.read_ok() def set_echo_mode(self, enable: bool = False) -> bool: """Sets command echo (ATE0/ATE1).""" cmd = b'ATE1' if enable else b'ATE0' with self.transaction: self.s_port.send_cmd(cmd) return self.at.read_ok() def reboot(self) -> bool: """Performs a software reboot (AT+CFUN=1,1).""" with self.transaction: self.s_port.send_cmd(b'AT+CFUN=1,1') return self.at.read_ok(timeout=15) # --- Info & Identification --- def read_firmware_version(self) -> Optional[str]: with self.transaction: self.s_port.send_cmd(b'AT+CGMR') response = self.s_port.read_until() for line in response.splitlines(): line = line.strip() if line and line not in (b'OK', b'AT+CGMR', b'ERROR'): return line.decode('ascii', errors='ignore') return None def read_imei(self) -> Optional[str]: with self.transaction: self.s_port.send_cmd(b'AT+CGSN') response = self.s_port.read_until() for line in response.splitlines(): line = line.strip() if line.isdigit(): return line.decode('ascii') return None def read_imsi(self) -> Optional[str]: with self.transaction: self.s_port.send_cmd(b'AT+CIMI') response = self.s_port.read_until() for line in response.splitlines(): line = line.strip() if line.isdigit(): return line.decode('ascii') return None def read_iccid(self) -> Optional[str]: with self.transaction: self.s_port.send_cmd(b'AT+CCID') response = self.s_port.read_until() for line in response.splitlines(): line = line.strip() if line.startswith(b'+CCID:'): return line.split(b':')[1].strip().decode('ascii') if line.isdigit() and len(line) > 15: return line.decode('ascii') return None # --- Power & Radio --- def set_phone_functionality(self, fun: int = 1, rst: Optional[int] = None) -> bool: """Controls radio functionality (AT+CFUN).""" cmd = f'AT+CFUN={fun}'.encode() if rst is not None: cmd += f',{rst}'.encode() with self.transaction: self.s_port.send_cmd(cmd) return self.at.read_ok(timeout=15) def radio_on(self) -> bool: return self.set_phone_functionality(1) def radio_off(self) -> bool: return self.set_phone_functionality(0) def is_radio_on(self) -> bool: return self.at.is_on(b'AT+CFUN?', b'+CFUN:') # --- Network Registration --- def attach_network(self) -> bool: """Attaches to packet domain (AT+CGATT).""" with self.transaction: self.s_port.send_cmd(b'AT+CGATT=1') return self.at.read_ok(timeout=75) def detach_network(self) -> bool: with self.transaction: self.s_port.send_cmd(b'AT+CGATT=0') return self.at.read_ok(timeout=40) def is_attached(self) -> bool: return self.at.is_on(b'AT+CGATT?', b'+CGATT:') def _read_reg_status(self, cmd: bytes, prefix: bytes) -> int: with self.transaction: self.s_port.send_cmd(cmd) response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(prefix): try: parts = line.split(b',') return int(parts[1].strip()) except (IndexError, ValueError): pass return -1 def read_creg_status(self) -> int: """Reads CS registration status (AT+CREG).""" return self._read_reg_status(b'AT+CREG?', b'+CREG:') def read_cereg_status(self) -> int: """Reads EPS registration status (AT+CEREG).""" return self._read_reg_status(b'AT+CEREG?', b'+CEREG:') def is_registered(self) -> bool: """Checks if registered (Home or Roaming) on CREG or CEREG.""" creg = self.read_creg_status() cereg = self.read_cereg_status() return creg in (1, 5) or cereg in (1, 5) def get_current_operator(self) -> Optional[str]: """Returns the currently selected operator and AcT (AT+COPS?).""" with self.transaction: self.s_port.send_cmd(b'AT+COPS?') response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+COPS:'): return line.decode('ascii', errors='ignore').split(':', 1)[1].strip() return None def set_operator(self, plmn: str, format: int = 2, act: Optional[int] = None) -> bool: """Forces operator selection (AT+COPS).""" cmd = f'AT+COPS=1,{format},"{plmn}"' if act is not None: cmd += f',{act}' with self.transaction: self.s_port.send_cmd(cmd.encode()) return self.at.read_ok(timeout=120) def get_signal_quality(self) -> Tuple[int, int]: """Returns (rssi, ber) via AT+CSQ.""" with self.transaction: self.s_port.send_cmd(b'AT+CSQ') response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+CSQ:'): try: parts = line.split(b':')[1].strip().split(b',') return int(parts[0]), int(parts[1]) except (IndexError, ValueError): pass return 99, 99 def get_extended_error_report(self) -> str: """Retrieves extended error report (AT+CEER).""" with self.transaction: self.s_port.send_cmd(b'AT+CEER') response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+CEER:'): return line.decode('ascii', errors='ignore').strip() return "" # --- PSM (Power Saving Mode) --- def set_psm_settings(self, mode: int = 1, tau: str = None, active_time: str = None): cmd = f'AT+CPSMS={mode}'.encode() if mode == 1 and tau and active_time: cmd += f',,,"{tau}","{active_time}"'.encode() with self.transaction: self.s_port.send_cmd(cmd) return self.at.read_ok() def read_psm_status(self) -> Optional[str]: """Reads current PSM settings (AT+CPSMS?).""" with self.transaction: self.s_port.send_cmd(b'AT+CPSMS?') response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+CPSMS:'): return line.decode('ascii', errors='ignore').strip() return None def disable_psm(self): return self.set_psm_settings(0) def configure_psm_auto(self, active_time_s: int, tau_s: int) -> bool: """Configures PSM by automatically encoding seconds into 3GPP bits.""" from m2m.utils import encode_psm_timer t3324 = encode_psm_timer(active_time_s, is_t3324=True) t3412 = encode_psm_timer(tau_s, is_t3324=False) return self.set_psm_settings(mode=1, tau=t3412, active_time=t3324) # --- SIM Diagnostics --- def unlock_sim(self, pin: str) -> bool: """Unlocks SIM card (AT+CPIN).""" with self.transaction: self.s_port.send_cmd(b'AT+CPIN?') if b'READY' in self.s_port.read_until(): return True self.s_port.send_cmd(f'AT+CPIN="{pin}"'.encode()) return self.at.read_ok(timeout=5) def restricted_sim_access(self, command: int, file_id: int, p1: int = 0, p2: int = 0, p3: int = 0) -> Tuple[int, int, bytes]: """Generic Restricted SIM Access (AT+CRSM).""" cmd = f'AT+CRSM={command},{file_id},{p1},{p2},{p3}'.encode() with self.transaction: self.s_port.send_cmd(cmd) response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+CRSM:'): try: parts = line.split(b',') sw1 = int(parts[0].split(b':')[1].strip()) sw2 = int(parts[1].strip()) data = parts[2].strip(b' "') if len(parts) > 2 else b'' return sw1, sw2, data except (IndexError, ValueError): pass return -1, -1, b'' def read_sim_file(self, file_id: int, length: int) -> Optional[bytes]: """Reads raw bytes from a SIM Elementary File. Returns None on error.""" import binascii sw1, sw2, data_hex = self.restricted_sim_access(176, file_id, 0, 0, length) if sw1 == 144: # 0x9000 Success return binascii.unhexlify(data_hex) return None # --- SIM Helpers --- def get_registered_plmn(self) -> Optional[str]: from m2m.utils import decode_plmn data = self.read_sim_file(self.EF_LOCI, 11) if data and len(data) >= 7: return decode_plmn(data[4:7]) return None def get_mnc_length(self) -> int: data = self.read_sim_file(self.EF_AD, 4) if data and len(data) >= 4: return data[3] & 0x0F return 2 def get_home_plmn(self) -> Optional[str]: imsi = self.read_imsi() if imsi and len(imsi) >= 5: mnc_len = self.get_mnc_length() return f"{imsi[:3]}-{imsi[3:3+mnc_len]}" return None def read_forbidden_plmns(self) -> List[str]: from m2m.utils import decode_plmn_list data = self.read_sim_file(self.EF_FPLMN, 12) return decode_plmn_list(data) if data else [] def read_eplmns(self) -> List[str]: from m2m.utils import decode_plmn_list data = self.read_sim_file(self.EF_EPLMN, 15) return decode_plmn_list(data) if data else [] def read_ehplmns(self) -> List[str]: from m2m.utils import decode_plmn_list data = self.read_sim_file(self.EF_EHPLMN, 15) return decode_plmn_list(data) if data else [] def read_user_plmn_act(self) -> List[str]: from m2m.utils import decode_plmn_list data = self.read_sim_file(self.EF_PLMNwAcT, 40) return decode_plmn_list(data, has_act=True) if data else [] def read_operator_plmn_act(self) -> List[str]: from m2m.utils import decode_plmn_list data = self.read_sim_file(self.EF_OPLMNwAcT, 40) return decode_plmn_list(data, has_act=True) if data else [] def read_hplmn_act(self) -> List[str]: from m2m.utils import decode_plmn_list data = self.read_sim_file(self.EF_HPLMNwAcT, 10) return decode_plmn_list(data, has_act=True) if data else [] def read_plmn_selector(self) -> List[str]: from m2m.utils import decode_plmn_list data = self.read_sim_file(self.EF_PLMNsel, 24) return decode_plmn_list(data) if data else [] def read_cag_info(self) -> Optional[str]: data = self.read_sim_file(self.EF_CAG, 10) if data: return data.hex() if any(b != 0xFF for b in data) else None return None # --- Abstract / Contract Methods --- def configure_pdp_context(self, context_id: int, context_type: str, apn: str) -> bool: raise NotImplementedError("Subclass must implement configure_pdp_context") def activate_pdp_context(self, context_id: int) -> bool: raise NotImplementedError("Subclass must implement activate_pdp_context") def get_ip_address(self, cid: int = 1) -> Optional[str]: with self.transaction: self.s_port.send_cmd(f'AT+CGPADDR={cid}'.encode()) response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+CGPADDR:'): parts = line.split(b',') if len(parts) > 1: return parts[1].strip(b' "').decode('ascii') return None def ping(self, host: str, context_id: int = 1, timeout: int = 20, num: int = 4) -> List[str]: raise NotImplementedError("Ping not implemented for this module.") def dns_query(self, host: str, context_id: int = 1, timeout: int = 30) -> List[str]: raise NotImplementedError("DNS query not implemented for this module.") # --- High Level Automation --- def connect_network(self, apn: str, pin: Optional[str] = None, timeout: int = 120, force: bool = False) -> bool: if not force and self.is_registered(): ip = self.get_ip_address(1) if ip: logger.info(f"Already connected! IP: {ip}") return True logger.info(f"Starting network connection sequence (APN: {apn})...") if pin and not self.unlock_sim(pin): logger.error("Failed to unlock SIM.") return False if not self.is_radio_on(): if not self.radio_on(): logger.error("Failed to turn on radio.") return False start_time = time.time() while time.time() - start_time < timeout: if self.is_registered(): break time.sleep(2) else: logger.info("Not registered yet. Triggering automatic selection (AT+COPS=0)...") self.send_at_command(b'AT+COPS=0') time.sleep(5) if not self.is_registered(): logger.error("Timed out waiting for network registration.") if not self.is_attached(): self.attach_network() try: if hasattr(self, 'is_pdp_active') and self.is_pdp_active(1): logger.info("Deactivating existing PDP context to apply new settings...") self.deactivate_pdp_context(1) if not self.configure_pdp_context(1, "IP", apn): logger.warning("PDP Configuration failed or not supported.") if not self.activate_pdp_context(1): logger.error("Failed to activate PDP context.") return False except NotImplementedError: logger.warning("PDP context methods not implemented in this module.") ip = self.get_ip_address(1) if ip: logger.info(f"Connected! IP: {ip}") return True return False def poll(self, timeout: float = 1.0) -> bytes: return self.s_port.read_any(timeout=timeout) # --- Standard SMS --- def set_sms_format(self, text_mode: bool = True) -> bool: mode = 1 if text_mode else 0 with self.transaction: self.s_port.send_cmd(f'AT+CMGF={mode}'.encode()) return self.at.read_ok() def send_sms(self, phone_number: str, message: str) -> bool: """Sends an SMS message (AT+CMGS).""" with self.transaction: # 1. Ensure Text Mode if not self.set_sms_format(True): logger.error("SMS: Failed to set text mode") return False # 2. Clear any stale data self.s_port.read_any(timeout=0.1) # 3. Send command self.s_port.send_cmd(f'AT+CMGS="{phone_number}"'.encode()) # 4. Wait for prompt '> ' prompt = self.s_port.read_until(terminator=b'>', timeout=10) if b'>' not in prompt: logger.error(f"SMS: Did not receive prompt '>', got: {prompt!r}") return False # 5. Small settling delay for the modem to be ready for the body time.sleep(0.2) # 6. Send message and Ctrl+Z try: self.s_port._serial.write(message.encode('ascii')) self.s_port._serial.write(b'\x1A') except Exception as e: logger.error(f"SMS: Write failed: {e}") return False # 7. Wait for result resp = self.s_port.read_until(terminator=b'OK', timeout=60) if b'OK' in resp: return True logger.error(f"SMS: Failed to get OK response, got: {resp!r}") return False def list_sms(self, stat: str = "ALL") -> List[str]: with self.transaction: self.s_port.send_cmd(f'AT+CMGL="{stat}"'.encode()) return self.s_port.read_until().decode('ascii', errors='ignore').splitlines() def delete_sms(self, index: int, delflag: int = 0) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+CMGD={index},{delflag}'.encode()) return self.at.read_ok() def get_battery_status(self) -> Tuple[int, int, int]: with self.transaction: self.s_port.send_cmd(b'AT+CBC') response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+CBC:'): try: p = [int(x) for x in line.split(b':')[1].split(b',')] return p[0], p[1], p[2] except (IndexError, ValueError): pass return 0, 0, 0 def get_clock(self) -> Optional[str]: with self.transaction: self.s_port.send_cmd(b'AT+CCLK?') response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+CCLK:'): return line.split(b':')[1].strip(b' "').decode('ascii') return None def set_clock(self, time_str: str) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+CCLK="{time_str}"'.encode()) return self.at.read_ok()