m2m-python/m2m/nbiot/module.py
2026-02-19 08:14:53 +01:00

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