529 lines
19 KiB
Python
Executable file
529 lines
19 KiB
Python
Executable file
#!/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()
|