#!/usr/bin/env python3 # -*- coding: utf-8 -*- import logging import binascii import time import re from typing import Optional, Union, Tuple, List, Dict from m2m.nbiot.module import ModuleBase logger = logging.getLogger('m2m.quectel') class QuectelModule(ModuleBase): """ Base class for Quectel modules implementing shared AT commands. """ def __init__(self, serial_port: str, baudrate: int = 115200, **kwargs): super().__init__(serial_port, baudrate, **kwargs) self._setup_urcs() def _setup_urcs(self): # Socket events self.s_port.register_urc(b'+QIURC: "pdpdeact",', True, lambda u: logger.info(f"PDP Deactivated: {u.decode()}")) self.s_port.register_urc(b'+QIURC: "recv",', True, lambda u: logger.debug(f"Data Received: {u.decode()}")) self.s_port.register_urc(b'+QIOPEN:', True, lambda u: logger.debug(f"Socket Open: {u.decode()}")) # Service URCs self.s_port.register_urc(b'+QMTRECV:', True, lambda u: logger.info(f"MQTT Message: {u.decode()}")) self.s_port.register_urc(b'+CUSD:', True, lambda u: logger.info(f"USSD Response: {u.decode()}")) self.s_port.register_urc(b'+QPING:', True) self.s_port.register_urc(b'+QIURC: "dnsgip",', True) self.s_port.register_urc(b'+QHTTPGET:', True) self.s_port.register_urc(b'+QHTTPPOST:', True) self.s_port.register_urc(b'+QHTTPREAD:', True) self.s_port.register_urc(b'+QNTP:', True) # Extended URCs self.s_port.register_urc(b'+QIND: "nipd",', True, lambda u: logger.info(f"NIDD: {u.decode()}")) self.s_port.register_urc(b'+QIND: "GEOFENCE",', True, lambda u: logger.info(f"Geofence: {u.decode()}")) # --- Extended Configuration (AT+QCFG) --- def set_qcfg(self, parameter: str, value: Union[int, str, List[int]], effect_immediately: bool = False) -> bool: val_str = ",".join(map(str, value)) if isinstance(value, list) else str(value) cmd = f'AT+QCFG="{parameter}",{val_str}' if effect_immediately: cmd += ",1" with self.transaction: self.s_port.send_cmd(cmd.encode()) return self.at.read_ok(timeout=10) def get_qcfg(self, parameter: str) -> Optional[str]: with self.transaction: self.s_port.send_cmd(f'AT+QCFG="{parameter}"'.encode()) response = self.s_port.read_until() for line in response.splitlines(): if b'+QCFG:' in line: return line.decode('ascii', errors='ignore') return None def set_iot_op_mode(self, mode: str = "NB-IoT") -> bool: modes = {"eMTC": 0, "NB-IoT": 1, "Both": 2} return self.set_qcfg("iotopmode", modes.get(mode, 1), True) def set_nw_scan_priority(self, first: str = "NB-IoT", second: str = "eMTC", third: str = "GSM") -> bool: seq_map = {"GSM": "01", "eMTC": "02", "NB-IoT": "03"} try: val = seq_map[first] + seq_map[second] + seq_map[third] return self.set_qcfg("nwscanseq", val, True) except KeyError: return False def lock_bands(self, gsm_bands: List[int] = [], catm_bands: List[int] = [], nb_bands: List[int] = []) -> bool: from m2m.utils import bands_to_hex_mask val_gsm = bands_to_hex_mask(gsm_bands) if gsm_bands else "0" val_catm = bands_to_hex_mask(catm_bands) if catm_bands else "0" val_nb = bands_to_hex_mask(nb_bands) if nb_bands else "0" return self.set_qcfg("band", [val_gsm, val_catm, val_nb], True) def get_locked_bands(self) -> Dict[str, str]: resp = self.get_qcfg("band") if resp and '+QCFG: "band",' in resp: parts = [p.strip(' "') for p in resp.split(',')] if len(parts) >= 4: return {'gsm': parts[1], 'catm': parts[2], 'nb': parts[3]} return {} def get_nw_scan_mode(self) -> int: resp = self.get_qcfg("nwscanmode") if resp and '+QCFG: "nwscanmode",' in resp: try: return int(resp.split(',')[-1].strip()) except: pass return -1 def set_nw_scan_mode(self, mode: int = 0) -> bool: """ Sets network scan mode (AT+QCFG="nwscanmode"). 0: Auto (GSM + LTE), 1: GSM Only, 3: LTE Only """ return self.set_qcfg("nwscanmode", mode, True) def set_connectivity_mode(self, mode: str) -> bool: """ High-level helper to set connectivity preference. Modes: 'gsm', 'emtc', 'nb', 'nb+emtc', 'all' """ mode = mode.lower() res = False if mode == 'gsm': res = self.set_nw_scan_mode(1) elif mode == 'emtc': # LTE Only (3) + eMTC category (0) r1 = self.set_nw_scan_mode(3) r2 = self.set_iot_op_mode("eMTC") res = r1 and r2 elif mode == 'nb': # LTE Only (3) + NB-IoT category (1) r1 = self.set_nw_scan_mode(3) r2 = self.set_iot_op_mode("NB-IoT") res = r1 and r2 elif mode == 'nb+emtc': # LTE Only (3) + Both categories (2) r1 = self.set_nw_scan_mode(3) r2 = self.set_iot_op_mode("Both") res = r1 and r2 elif mode == 'all': # Auto (0) + Both categories (2) + Priority Sequence r1 = self.set_nw_scan_mode(0) r2 = self.set_iot_op_mode("Both") r3 = self.set_nw_scan_priority("eMTC", "NB-IoT", "GSM") res = r1 and r2 and r3 return res def get_iot_op_mode_val(self) -> int: resp = self.get_qcfg("iotopmode") if resp and '+QCFG: "iotopmode",' in resp: try: return int(resp.split(',')[-1].strip()) except: pass return -1 # --- PDP & Sockets --- def configure_pdp_context(self, context_id: int = 1, context_type: str = "IP", apn: str = "") -> bool: type_map = {"IP": 1, "IPV6": 2, "IPV4V6": 3} t_val = type_map.get(context_type.upper(), 1) with self.transaction: self.s_port.send_cmd(f'AT+QICSGP={context_id},{t_val},"{apn}"'.encode()) return self.at.read_ok() def activate_pdp_context(self, context_id: int = 1) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+QIACT={context_id}'.encode()) return self.at.read_ok(timeout=150) def deactivate_pdp_context(self, context_id: int = 1) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+QIDEACT={context_id}'.encode()) return self.at.read_ok(timeout=40) def is_pdp_active(self, context_id: int = 1) -> bool: with self.transaction: self.s_port.send_cmd(b'AT+QIACT?') response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+QIACT:'): try: parts = line.split(b',') cid = int(parts[0].split(b':')[1].strip()) state = int(parts[1]) if cid == context_id: return state == 1 except: pass return False def open_socket(self, context_id: int = 1, connect_id: int = 0, service_type: str = "TCP", remote_ip: str = "", remote_port: int = 0, access_mode: int = 0, local_port: int = 0) -> int: cmd = f'AT+QIOPEN={context_id},{connect_id},"{service_type}","{remote_ip}",{remote_port},{local_port},{access_mode}' with self.transaction: self.s_port.send_cmd(cmd.encode()) if not self.at.read_ok(): return -1 response = self.s_port.read_until_notify(b'+QIOPEN:', timeout=150) if response: try: parts = response.split(b',') return int(parts[1]) if int(parts[1]) == 0 else -int(parts[1]) except: pass return -1 def close_socket(self, connect_id: int = 0) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+QICLOSE={connect_id}'.encode()) return self.at.read_ok() def get_socket_state(self, connect_id: Optional[int] = None) -> List[Dict[str, str]]: cmd = b'AT+QISTATE' if connect_id is not None: cmd = f'AT+QISTATE=1,{connect_id}'.encode() with self.transaction: self.s_port.send_cmd(cmd) response = self.s_port.read_until() sockets = [] for line in response.splitlines(): if line.startswith(b'+QISTATE:'): line_str = line.decode('ascii', errors='ignore') _, rest = line_str.split(':', 1) p = [x.strip(' "') for x in rest.split(',')] if len(p) >= 6: sockets.append({ 'id': p[0], 'type': p[1], 'remote': f"{p[2]}:{p[3]}", 'local_port': p[4], 'state': p[5] }) return sockets def send_data_hex(self, connect_id: int, data: bytes) -> bool: hex_data = binascii.hexlify(data).decode('ascii') with self.transaction: self.s_port.send_cmd(f'AT+QISENDEX={connect_id},"{hex_data}"'.encode()) response = self.s_port.read_until() return b'SEND OK' in response def receive_data(self, connect_id: int, read_length: int = 1500) -> bytes: with self.transaction: self.s_port.send_cmd(f'AT+QIRD={connect_id},{read_length}'.encode()) response = self.s_port.read_until() data = b'' reading = False for line in response.splitlines(): if line.startswith(b'+QIRD:'): try: if int(line.split(b':')[1].strip()) == 0: return b'' reading = True; continue except: pass if reading: if line.strip() == b'OK': break data += line return data # --- Network Services --- def ping(self, host: str, context_id: int = 1, timeout: int = 20, num: int = 4) -> List[str]: with self.transaction: self.s_port.send_cmd(f'AT+QPING={context_id},"{host}",{timeout},{num}'.encode()) if not self.at.read_ok(): return [] responses = [] end_time = time.time() + (timeout * num) + 10 while time.time() < end_time and len(responses) < num + 1: urc = self.s_port.read_until_notify(b'+QPING:', timeout=timeout + 2) if not urc: break line = urc.decode('ascii', errors='ignore').strip() responses.append(line) if line.count(',') >= 5: break return responses def parse_ping_summary(self, lines: List[str]) -> Dict[str, Union[int, str]]: if not lines: return {} parts = lines[-1].split(',') if len(parts) >= 7 and parts[0] == '0': try: return { 'sent': int(parts[1]), 'rcvd': int(parts[2]), 'lost': int(parts[3]), 'min': int(parts[4]), 'max': int(parts[5]), 'avg': int(parts[6]) } except: pass return {} def dns_query(self, host: str, context_id: int = 1, timeout: int = 30) -> List[str]: with self.transaction: self.s_port.send_cmd(f'AT+QIDNSGIP={context_id},"{host}"'.encode()) if not self.at.read_ok(): return [] ips = [] urc = self.s_port.read_until_notify(b'+QIURC: "dnsgip",', timeout=timeout) if urc and urc.startswith(b'0'): try: count = int(urc.split(b',')[1]) for _ in range(count): ip_urc = self.s_port.read_until_notify(b'+QIURC: "dnsgip",', timeout=5) if ip_urc: ips.append(ip_urc.strip(b' "').decode()) except: pass return ips def search_operators(self, timeout: int = 180) -> List[Dict[str, str]]: with self.transaction: self.s_port.send_cmd(b'AT+COPS=?') response = self.s_port.read_until(b'OK', timeout=timeout) operators = [] for line in response.splitlines(): if line.startswith(b'+COPS:'): matches = re.findall(r'\((\d+),"([^"]*)","([^"]*)","([^"]*)",(\d+)\)', line.decode()) for m in matches: operators.append({'status': m[0], 'long': m[1], 'short': m[2], 'mccmnc': m[3], 'act': m[4]}) return operators # --- HTTP(S) --- def http_configure(self, context_id: int = 1, response_header: bool = False) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+QHTTPCFG="contextid",{context_id}'.encode()) if not self.at.read_ok(): return False self.s_port.send_cmd(f'AT+QHTTPCFG="responseheader",{1 if response_header else 0}'.encode()) return self.at.read_ok() def http_set_url(self, url: str, timeout: int = 60) -> bool: url_bytes = url.encode() with self.transaction: self.s_port.send_cmd(f'AT+QHTTPURL={len(url_bytes)},{timeout}'.encode()) if b'CONNECT' in self.s_port.read_until(b'CONNECT', timeout=5): self.s_port._serial.write(url_bytes) return self.at.read_ok() return False def http_get(self, timeout: int = 60) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+QHTTPGET={timeout}'.encode()) if not self.at.read_ok(timeout=5): return False urc = self.s_port.read_until_notify(b'+QHTTPGET:', timeout=timeout + 5) return urc and urc.startswith(b'0') def http_post(self, content: bytes, timeout: int = 60) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+QHTTPPOST={len(content)},{timeout},{timeout}'.encode()) if b'CONNECT' not in self.s_port.read_until(b'CONNECT', timeout=5): return False self.s_port._serial.write(content) if not self.at.read_ok(): return False urc = self.s_port.read_until_notify(b'+QHTTPPOST:', timeout=timeout + 5) return urc and urc.startswith(b'0') def http_read_response(self, wait_time: int = 60) -> str: with self.transaction: self.s_port.send_cmd(f'AT+QHTTPREAD={wait_time}'.encode()) if b'CONNECT' not in self.s_port.read_until(b'CONNECT', timeout=5): return "" buffer = bytearray() end_time = time.time() + wait_time while time.time() < end_time: line = self.s_port._read_line() if not line: continue if line.strip() == b'OK': break buffer.extend(line) return buffer.decode('ascii', errors='ignore') # --- Advanced Features --- def configure_psm_optimization(self, enter_immediately: bool = True, enable_urc: bool = True) -> bool: r1 = self.set_qcfg("psm/enter", 1 if enter_immediately else 0) r2 = self.set_qcfg("psm/urc", 1 if enable_urc else 0) return r1 and r2 def set_gpio(self, mode: int, pin: int, val: int = 0, save: int = 0) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+QCFG="gpio",{mode},{pin},{val},{save}'.encode()) return self.at.read_ok() def get_gpio(self, pin: int) -> int: with self.transaction: self.s_port.send_cmd(f'AT+QCFG="gpio",2,{pin}'.encode()) response = self.s_port.read_until() for line in response.splitlines(): if b'+QCFG: "gpio"' in line: try: return int(line.split(b',')[-1].strip()) except: pass return -1 def list_files(self, pattern: str = "*") -> List[Dict[str, Union[str, int]]]: with self.transaction: self.s_port.send_cmd(f'AT+QFLST="{pattern}"'.encode()) response = self.s_port.read_until() files = [] for line in response.splitlines(): if line.startswith(b'+QFLST:'): try: p = line.decode('ascii', errors='ignore').split(':', 1)[1].strip().split(',') files.append({'name': p[0].strip(' "'), 'size': int(p[1])}) except: pass return files def power_off(self, mode: int = 1) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+QPOWD={mode}'.encode()) return self.at.read_ok(timeout=10) def get_temperature(self) -> float: with self.transaction: self.s_port.send_cmd(b'AT+QTEMP') response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+QTEMP:'): try: parts = line.split(b':')[1].strip().split(b',') return max([float(p) for p in parts]) if parts else 0.0 except: pass return 0.0 def upload_file(self, filename: str, content: bytes, storage: str = "RAM") -> bool: """Uploads a file to module storage (AT+QFUPL).""" with self.transaction: self.s_port.send_cmd(f'AT+QFUPL="{storage}:{filename}",{len(content)}'.encode()) resp = self.s_port.read_until(b'CONNECT', timeout=5) if b'CONNECT' not in resp: return False self.s_port._serial.write(content) # After write, modem sends OK return self.at.read_ok(timeout=10) def delete_file(self, filename: str, storage: str = "RAM") -> bool: """Deletes a file from module storage (AT+QFDEL).""" with self.transaction: self.s_port.send_cmd(f'AT+QFDEL="{storage}:{filename}"'.encode()) return self.at.read_ok() def ntp_sync(self, server: str = "pool.ntp.org", port: int = 123, context_id: int = 1) -> bool: with self.transaction: self.s_port.send_cmd(f'AT+QNTP={context_id},"{server}",{port}'.encode()) if not self.at.read_ok(): return False urc = self.s_port.read_until_notify(b'+QNTP:', timeout=75) return urc and urc.startswith(b'0') def set_edrx(self, mode: int = 1, act_type: int = 5, value: str = "0010") -> bool: """Sets eDRX parameters (AT+CEDRXS).""" cmd = f'AT+CEDRXS={mode},{act_type},"{value}"'.encode() with self.transaction: self.s_port.send_cmd(cmd) return self.at.read_ok() def get_neighbor_cells(self) -> List[Dict[str, Union[str, int]]]: with self.transaction: self.s_port.send_cmd(b'AT+QENG="neighbourcell"') response = self.s_port.read_until() neighbors = [] for line in response.splitlines(): if b'+QENG: "neighbourcell' in line: try: p = [x.strip(' "') for x in line.decode('ascii', errors='ignore').split(',')] if len(p) > 5: neighbors.append({'tech': p[1], 'earfcn': p[2], 'pci': p[3], 'rsrq': p[4], 'rsrp': p[5]}) except: pass return neighbors def get_serving_cell_info(self) -> Dict[str, Union[str, int]]: with self.transaction: self.s_port.send_cmd(b'AT+QENG="servingcell"') response = self.s_port.read_until() res = {} for line in response.splitlines(): if line.startswith(b'+QENG:'): try: p = [x.strip(' "') for x in line.decode('ascii', errors='ignore').split(',')] if len(p) > 2: res['tech'] = p[2] if p[2] in ("LTE", "eMTC"): res.update({'mcc-mnc': f"{p[4]}-{p[5]}", 'cellid': p[6], 'rsrp': int(p[13]), 'rsrq': int(p[14])}) elif p[2] == "NB-IoT": res.update({'mcc-mnc': f"{p[4]}-{p[5]}", 'cellid': p[6], 'rsrp': int(p[10]), 'rsrq': int(p[11])}) except: pass return res def get_network_info(self) -> Optional[str]: with self.transaction: self.s_port.send_cmd(b'AT+QNWINFO') response = self.s_port.read_until() for line in response.splitlines(): if line.startswith(b'+QNWINFO:'): return line.decode('ascii', errors='ignore').strip().split(':', 1)[1].strip() return None # --- Extended Configuration (AT+QCFGEXT) --- def set_qcfg_ext(self, parameter: str, value: str) -> bool: cmd = f'AT+QCFGEXT="{parameter}",{value}' with self.transaction: self.s_port.send_cmd(cmd.encode()) return self.at.read_ok() def nidd_configure(self, apn: str, account: str = "", pwd: str = "") -> bool: return self.set_qcfg_ext("nipdcfg", f'"{apn}","{account}","{pwd}"') def nidd_open(self, enable: bool = True) -> bool: return self.set_qcfg_ext("nipd", "1" if enable else "0") def nidd_send(self, data: bytes) -> bool: hex_data = binascii.hexlify(data).decode('ascii') return self.set_qcfg_ext("nipds", f'"{hex_data}"') def nidd_receive(self) -> bytes: with self.transaction: self.s_port.send_cmd(b'AT+QCFGEXT="nipdr"') response = self.s_port.read_until() for line in response.splitlines(): if b'+QCFGEXT: "nipdr",' in line: try: return binascii.unhexlify(line.split(b',')[-1].strip(b' "')) except: pass return b'' def add_geofence(self, id: int, shape: int, coords: List[float], radius: int = 0) -> bool: val = f"{id},{shape}," + ",".join(map(str, coords)) if shape == 0: val += f",{radius}" return self.set_qcfg_ext("addgeo", val) def delete_geofence(self, id: int) -> bool: return self.set_qcfg_ext("deletegeo", str(id)) def query_geofence(self, id: int) -> Optional[str]: with self.transaction: self.s_port.send_cmd(f'AT+QCFGEXT="querygeo",{id}'.encode()) return self.s_port.read_until().decode('ascii', errors='ignore') def set_usb_power(self, enable: bool = True) -> bool: return self.set_qcfg_ext("disusb", "0" if enable else "1") def set_pwm(self, pin: int, freq: int, duty: int) -> bool: return self.set_qcfg_ext("pwm", f"{pin},{freq},{duty}")