initial commit
This commit is contained in:
parent
006098e47c
commit
1e701e3926
40 changed files with 3363 additions and 2 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -154,7 +154,7 @@ dmypy.json
|
|||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
.DS_Store
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
|
|
|
|||
97
README.md
97
README.md
|
|
@ -1,3 +1,98 @@
|
|||
# m2m-python
|
||||
|
||||
A versatile Python library for controlling IoT modules (Quectel BG9x/BC66, HiSilicon) via AT commands. Features a thread-safe serial engine, real-time URC handling, SIM forensics, and an interactive diagnostic shell.
|
||||
Python library for controlling IoT modules (Quectel BG96, BG95, BC66, HiSilicon, etc.).
|
||||
|
||||
## Features
|
||||
|
||||
- **Robust AT Command Parser**: Handles URCs, multi-line responses, and timeouts gracefully.
|
||||
- **Interactive Shell**: A powerful CLI for manual control, diagnostics, and testing.
|
||||
- **Multiple Connectivity Options**: Supports Serial ports (`/dev/ttyUSB0`), TCP sockets (`socket://`), and RFC2217.
|
||||
- **Vendor Agnostic Core**: Abstract base class with specialized implementations for Quectel and HiSilicon.
|
||||
- **Advanced Diagnostics**: Signal quality, cell info, SIM forensics, and network scanning.
|
||||
- **Network Services**: Integrated Ping, DNS, HTTP(S), and Socket (UDP/TCP) support.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install .
|
||||
```
|
||||
|
||||
## Quick Start: Interactive Shell
|
||||
|
||||
The easiest way to get started is using the built-in interactive shell:
|
||||
|
||||
```bash
|
||||
# Connect to a physical serial port
|
||||
m2m-shell /dev/ttyUSB0 --type BG96
|
||||
|
||||
# Connect to a remote modem via TCP (e.g., using socat)
|
||||
m2m-shell socket://192.168.1.50:3000 --type BG95
|
||||
```
|
||||
|
||||
### Available Commands
|
||||
- `info`: Display module IMEI, IMSI, Firmware, and configuration.
|
||||
- `status`: Check registration, signal quality, and current operator.
|
||||
- `nw_info`: Show detailed network and serving cell info.
|
||||
- `cells`: Show serving and neighbor cell information.
|
||||
- `forensics`: Run a deep SIM card analysis.
|
||||
- `scan`: Search for available network operators.
|
||||
- `connect [apn]`: Establish a PDP context.
|
||||
- `disconnect`: Deactivate PDP context.
|
||||
- `operator <mccmnc> [act]`: Select operator manually.
|
||||
- `ping <host> [num]`: Ping a remote host.
|
||||
- `dns <host>`: Resolve a hostname.
|
||||
- `http_get <url>`: Perform an HTTP GET request.
|
||||
- `udp_send <host> <port> <data>`: Send a UDP packet.
|
||||
- `udp_listen <port>`: Open a local UDP listener.
|
||||
- `tcp_client <host> <port>`: Connect to a TCP server.
|
||||
- `sockets`: List active sockets and their states.
|
||||
- `close <id>`: Close a socket by ID.
|
||||
- `recv <id> [len]`: Read data from a socket.
|
||||
- `sms_send <num> <msg>`: Send an SMS message.
|
||||
- `sms_list [ALL|REC UNREAD]`: List SMS messages.
|
||||
- `radio <on|off>`: Toggle module radio.
|
||||
- `reboot`: Reboot the module.
|
||||
- `mode <gsm|emtc|nb|all>`: Set connectivity preference.
|
||||
- `psm <status|on|off|opt>`: Control Power Saving Mode.
|
||||
- `edrx <mode> <act> <bits>`: Configure eDRX settings.
|
||||
- `bands [gsm|catm|nb]`: Show or lock frequency bands.
|
||||
- `gpio set <pin> <val>`: Control GPIO pins.
|
||||
- `at <command>`: Send raw AT command to the module.
|
||||
- `clock [show|sync]`: Display or sync system clock via NTP.
|
||||
- `temp`: Read module internal temperature.
|
||||
- `ls [pattern]`: List files on the module filesystem.
|
||||
- `help`: Show all available commands with descriptions.
|
||||
|
||||
## Programmatic Usage
|
||||
|
||||
```python
|
||||
from m2m.nbiot import factory
|
||||
|
||||
# Initialize module
|
||||
module = factory("BG95", "/dev/ttyUSB0")
|
||||
|
||||
with module:
|
||||
# Check signal
|
||||
rssi, ber = module.get_signal_quality()
|
||||
print(f"Signal Strength: {rssi}")
|
||||
|
||||
# Connect to network
|
||||
if module.connect_network("iot.telefonica.de"):
|
||||
print("Connected!")
|
||||
|
||||
# Perform DNS lookup
|
||||
ips = module.dns_query("google.com")
|
||||
print(f"Google IP: {ips[0]}")
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Install dev dependencies:
|
||||
```bash
|
||||
pip install -e .[dev]
|
||||
```
|
||||
|
||||
Run tests:
|
||||
```bash
|
||||
pytest tests/
|
||||
```
|
||||
|
|
|
|||
40
examples/01_diagnostics.py
Normal file
40
examples/01_diagnostics.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import argparse
|
||||
from m2m.nbiot import factory
|
||||
|
||||
# Setup logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
def run_diagnostics(port):
|
||||
# Instantiate a BG96 module (works the same for BG95)
|
||||
module = factory('BG96', port, baudrate=115200)
|
||||
|
||||
with module:
|
||||
print("--- Module Info ---")
|
||||
print(f"Firmware: {module.read_firmware_version()}")
|
||||
print(f"IMEI: {module.read_imei()}")
|
||||
print(f"ICCID: {module.read_iccid()}")
|
||||
|
||||
print("\n--- SIM Status ---")
|
||||
# Check if PIN is needed
|
||||
if module.unlock_sim("1234"):
|
||||
print("SIM Unlocked/Ready")
|
||||
|
||||
print(f"Registered PLMN: {module.get_registered_plmn()}")
|
||||
print(f"Forbidden PLMNs: {module.read_forbidden_plmns()}")
|
||||
|
||||
print("\n--- Network Info ---")
|
||||
rssi, ber = module.get_signal_quality()
|
||||
print(f"Signal: RSSI {rssi}, BER {ber}")
|
||||
print(f"Registration: {module.read_cereg_status()}")
|
||||
|
||||
# Deep engineering info
|
||||
cell_info = module.get_serving_cell_info()
|
||||
print(f"Serving Cell: {cell_info}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run module diagnostics.")
|
||||
parser.add_argument("port", nargs="?", default="/dev/ttyUSB0", help="Serial port (e.g. /dev/ttyUSB0 or MOCK)")
|
||||
args = parser.parse_args()
|
||||
run_diagnostics(args.port)
|
||||
47
examples/02_mqtt_secure.py
Normal file
47
examples/02_mqtt_secure.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import argparse
|
||||
from m2m.nbiot import factory
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
def run_mqtt_demo(port):
|
||||
module = factory('BG95', port, baudrate=115200)
|
||||
|
||||
with module:
|
||||
# 1. Initialization
|
||||
module.radio_on()
|
||||
module.activate_pdp_context(context_id=1)
|
||||
|
||||
# 2. Upload Certificates (Assume they are locally available)
|
||||
# module.upload_file("ca.crt", b"-----BEGIN CERTIFICATE-----\n...")
|
||||
|
||||
# 3. Configure SSL Context
|
||||
module.ssl_configure(ssl_ctx_id=0, cacert="ca.crt")
|
||||
|
||||
# 4. Configure MQTT to use SSL Context 0
|
||||
module.set_qcfg("mqtt/ssl", 1) # Specific QCFG for some firmwares
|
||||
module.mqtt_configure(client_idx=0, keepalive=120)
|
||||
|
||||
# 5. Connect to Broker
|
||||
print(f"Connecting to MQTT Broker via {port}...")
|
||||
if module.mqtt_open(client_idx=0, host="your-iot-endpoint.amazonaws.com", port=8883) == 0:
|
||||
if module.mqtt_connect(client_idx=0, client_id="my_device_01") == 0:
|
||||
print("Connected! Publishing data...")
|
||||
|
||||
# 6. Publish data
|
||||
module.mqtt_publish(client_idx=0, msg_id=1, qos=1, retain=0,
|
||||
topic="sensors/temperature", message='{"value": 24.5}')
|
||||
|
||||
module.mqtt_subscribe(client_idx=0, msg_id=2, topic="commands/#", qos=1)
|
||||
print("Subscribed to commands/#. Waiting for messages...")
|
||||
|
||||
# Poll for 10 seconds to catch incoming MQTT messages via URC
|
||||
for _ in range(10):
|
||||
module.poll(1.0)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run MQTT demo.")
|
||||
parser.add_argument("port", nargs="?", default="/dev/ttyUSB0", help="Serial port (e.g. /dev/ttyUSB0 or MOCK)")
|
||||
args = parser.parse_args()
|
||||
run_mqtt_demo(args.port)
|
||||
31
examples/03_http_get.py
Normal file
31
examples/03_http_get.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
from m2m.nbiot import factory
|
||||
|
||||
def run_http_get(port):
|
||||
module = factory('BG96', port)
|
||||
|
||||
with module:
|
||||
module.radio_on()
|
||||
module.activate_pdp_context(context_id=1)
|
||||
|
||||
print("Configuring HTTP...")
|
||||
module.http_configure(context_id=1, response_header=True)
|
||||
|
||||
print("Setting URL...")
|
||||
if module.http_set_url("http://httpbin.org/get"):
|
||||
print("Sending GET request...")
|
||||
if module.http_get():
|
||||
print("\n--- Response Content ---")
|
||||
content = module.http_read_response(wait_time=30)
|
||||
print(content)
|
||||
else:
|
||||
print("HTTP GET failed.")
|
||||
else:
|
||||
print("Failed to set URL.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run HTTP GET demo.")
|
||||
parser.add_argument("port", nargs="?", default="/dev/ttyUSB0", help="Serial port (e.g. /dev/ttyUSB0 or MOCK)")
|
||||
args = parser.parse_args()
|
||||
run_http_get(args.port)
|
||||
39
examples/04_psm_gnss.py
Normal file
39
examples/04_psm_gnss.py
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
from m2m.nbiot import factory
|
||||
from m2m.utils import encode_psm_timer
|
||||
|
||||
def run_low_power_location(port):
|
||||
module = factory('BG95', port)
|
||||
|
||||
with module:
|
||||
# 1. Configure Power Saving Mode
|
||||
# Example: 1 minute Active Time (T3324) and 24 hours Periodic TAU (T3412)
|
||||
t3324_bin = encode_psm_timer(60, is_t3324=True)
|
||||
t3412_bin = encode_psm_timer(24 * 3600, is_t3324=False)
|
||||
|
||||
print(f"Setting PSM: T3324={t3324_bin}, T3412={t3412_bin}")
|
||||
module.set_psm_settings(mode=1, tau=t3412_bin, active_time=t3324_bin)
|
||||
|
||||
# 2. GNSS Location
|
||||
print("Enabling GNSS...")
|
||||
if module.enable_gnss(True):
|
||||
print("Waiting for fix (may take up to 60s)...")
|
||||
# Poll location until success
|
||||
for i in range(10):
|
||||
import time
|
||||
time.sleep(5)
|
||||
loc = module.get_gnss_location()
|
||||
if loc:
|
||||
print(f"Location Found: {loc}")
|
||||
break
|
||||
else:
|
||||
print(f"Attempt {i+1}: No fix yet...")
|
||||
|
||||
module.enable_gnss(False) # Save power
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run PSM and GNSS demo.")
|
||||
parser.add_argument("port", nargs="?", default="/dev/ttyUSB0", help="Serial port (e.g. /dev/ttyUSB0 or MOCK)")
|
||||
args = parser.parse_args()
|
||||
run_low_power_location(args.port)
|
||||
41
examples/05_async_urc_handler.py
Normal file
41
examples/05_async_urc_handler.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env python3
|
||||
import threading
|
||||
import time
|
||||
import logging
|
||||
import argparse
|
||||
from m2m.nbiot import factory
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='[%(threadName)s] %(message)s')
|
||||
|
||||
def urc_listener(module):
|
||||
"""Background thread to continuously poll for URCs."""
|
||||
while True:
|
||||
# poll() uses the internal lock, so it's safe to run alongside the main thread
|
||||
module.poll(timeout=1.0)
|
||||
time.sleep(0.1)
|
||||
|
||||
def main(port):
|
||||
module = factory('BG95', port)
|
||||
|
||||
with module:
|
||||
module.radio_on()
|
||||
|
||||
# Start background listener
|
||||
listener = threading.Thread(target=urc_listener, args=(module,), name="URC-Pump", daemon=True)
|
||||
listener.start()
|
||||
|
||||
# Main thread performs transactions
|
||||
for i in range(10):
|
||||
with module.transaction:
|
||||
rssi, ber = module.get_signal_quality()
|
||||
status = module.read_cereg_status()
|
||||
logging.info(f"Check {i}: Signal={rssi}, RegStatus={status}")
|
||||
|
||||
# The background thread can still process incoming data here
|
||||
time.sleep(5)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run async URC handler demo.")
|
||||
parser.add_argument("port", nargs="?", default="/dev/ttyUSB0", help="Serial port (e.g. /dev/ttyUSB0 or MOCK)")
|
||||
args = parser.parse_args()
|
||||
main(args.port)
|
||||
50
examples/06_aws_iot_secure_lifecycle.py
Normal file
50
examples/06_aws_iot_secure_lifecycle.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import argparse
|
||||
from m2m.nbiot import factory
|
||||
|
||||
def setup_aws_iot(port):
|
||||
module = factory('BG95', port)
|
||||
|
||||
# Configuration
|
||||
AWS_ENDPOINT = "a3xxxxxxxxxxxx-ats.iot.us-east-1.amazonaws.com"
|
||||
CLIENT_ID = "Sensor_Node_01"
|
||||
|
||||
with module:
|
||||
module.radio_on()
|
||||
module.activate_pdp_context(1)
|
||||
|
||||
# 1. Provision Certificates to Module RAM
|
||||
print("Uploading Security Assets...")
|
||||
module.upload_file("aws_ca.crt", b"CERT_DATA_HERE...")
|
||||
module.upload_file("client.crt", b"CERT_DATA_HERE...")
|
||||
module.upload_file("private.key", b"KEY_DATA_HERE...")
|
||||
|
||||
# 2. Map Assets to SSL Context 1
|
||||
print("Configuring SSL Engine...")
|
||||
module.ssl_configure(
|
||||
ssl_ctx_id=1,
|
||||
cacert="aws_ca.crt",
|
||||
clientcert="client.crt",
|
||||
clientkey="private.key"
|
||||
)
|
||||
|
||||
# 3. Configure MQTT for SSL
|
||||
module.set_qcfg("mqtt/ssl", 1)
|
||||
module.mqtt_configure(client_idx=0, keepalive=60)
|
||||
|
||||
# 4. Connect and Publish
|
||||
if module.mqtt_open(0, AWS_ENDPOINT, 8883) == 0:
|
||||
if module.mqtt_connect(0, CLIENT_ID) == 0:
|
||||
print("AWS IoT Connected!")
|
||||
module.mqtt_publish(0, 1, 1, 0, "dt/sensor/data", '{"status": "ok", "uptime": 1200}')
|
||||
|
||||
# Stay connected to receive shadows/commands
|
||||
module.mqtt_subscribe(0, 2, "cmd/sensor/01", 1)
|
||||
module.poll(30)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run AWS IoT lifecycle demo.")
|
||||
parser.add_argument("port", nargs="?", default="/dev/ttyUSB0", help="Serial port (e.g. /dev/ttyUSB0 or MOCK)")
|
||||
args = parser.parse_args()
|
||||
setup_aws_iot(args.port)
|
||||
42
examples/07_network_diagnostic_recovery.py
Normal file
42
examples/07_network_diagnostic_recovery.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import argparse
|
||||
from m2m.nbiot import factory
|
||||
|
||||
def recover_connection(port):
|
||||
module = factory('BG96', port)
|
||||
|
||||
with module:
|
||||
print("Attempting to connect...")
|
||||
module.radio_on()
|
||||
module.attach_network()
|
||||
|
||||
status = module.read_cereg_status()
|
||||
if status not in [1, 5]: # Not Home or Roaming
|
||||
print("Registration failed. Starting diagnostics...")
|
||||
|
||||
# 1. Check Extended Error
|
||||
error = module.get_extended_error_report()
|
||||
print(f"3GPP Error: {error}")
|
||||
|
||||
# 2. Check for Forbidden Networks
|
||||
fplmns = module.read_forbidden_plmns()
|
||||
print(f"Forbidden Networks: {fplmns}")
|
||||
|
||||
# 3. Optimization: Force NB-IoT priority and re-scan
|
||||
print("Switching strategy: Prioritizing NB-IoT and clearing scan sequence...")
|
||||
module.set_iot_op_mode("NB-IoT")
|
||||
module.set_nw_scan_priority("NB-IoT", "eMTC", "GSM")
|
||||
|
||||
# 4. Deep Reboot to apply logic
|
||||
module.reboot()
|
||||
|
||||
# Retry
|
||||
module.attach_network()
|
||||
print(f"New Status: {module.read_cereg_status()}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run network recovery demo.")
|
||||
parser.add_argument("port", nargs="?", default="/dev/ttyUSB0", help="Serial port (e.g. /dev/ttyUSB0 or MOCK)")
|
||||
args = parser.parse_args()
|
||||
recover_connection(args.port)
|
||||
41
examples/08_power_optimized_sensor.py
Normal file
41
examples/08_power_optimized_sensor.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
#!/usr/bin/env python3
|
||||
import time
|
||||
import argparse
|
||||
from m2m.nbiot import factory
|
||||
from m2m.utils import encode_psm_timer
|
||||
|
||||
def industrial_sensor_cycle(port):
|
||||
# Setup for ultra-low power
|
||||
module = factory('BG95', port)
|
||||
|
||||
with module:
|
||||
# 1. Wake up and check GNSS
|
||||
module.enable_gnss(True)
|
||||
location = module.get_gnss_location()
|
||||
module.enable_gnss(False) # Turn off immediately to save mA
|
||||
|
||||
# 2. Attach and send payload
|
||||
module.radio_on()
|
||||
module.activate_pdp_context(1)
|
||||
|
||||
payload = f'{{"loc": "{location}", "batt": 3.8, "alert": false}}'
|
||||
module.send_data_hex(0, payload.encode('ascii'))
|
||||
|
||||
# 3. Calculate optimized sleep
|
||||
# T3324 (Active Time): 10 seconds (time to wait for incoming commands)
|
||||
# T3412 (Periodic TAU): 12 hours (heartbeat interval)
|
||||
t3324 = encode_psm_timer(10, is_t3324=True)
|
||||
t3412 = encode_psm_timer(12 * 3600, is_t3324=False)
|
||||
|
||||
print(f"Entering deep sleep (PSM). T3324={t3324}, T3412={t3412}")
|
||||
module.set_psm_settings(mode=1, tau=t3412, active_time=t3324)
|
||||
|
||||
# 4. Graceful Power Down
|
||||
module.radio_off()
|
||||
print("Device is now in hibernate. Power consumption < 5uA.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run power optimized sensor demo.")
|
||||
parser.add_argument("port", nargs="?", default="/dev/ttyUSB0", help="Serial port (e.g. /dev/ttyUSB0 or MOCK)")
|
||||
args = parser.parse_args()
|
||||
industrial_sensor_cycle(args.port)
|
||||
51
examples/09_sim_forensics.py
Normal file
51
examples/09_sim_forensics.py
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import argparse
|
||||
from m2m.nbiot import factory
|
||||
from m2m.utils import fmt_val
|
||||
|
||||
# Configure logging to hide noisy internal AT command logs for this report
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
def sim_deep_forensics(port):
|
||||
# Supports BG96, BG95, or BC66
|
||||
module = factory('BG95', port)
|
||||
|
||||
with module:
|
||||
print("="*50)
|
||||
print(" SIM CARD NETWORK FORENSICS REPORT")
|
||||
print("="*50)
|
||||
|
||||
# Basic Identification
|
||||
print(f"{'ICCID:':<25} {fmt_val(module.read_iccid())}")
|
||||
print(f"{'IMSI:':<25} {fmt_val(module.read_imsi())}")
|
||||
|
||||
# Core PLMN Information
|
||||
print("\n--- HOME & REGISTRATION ---")
|
||||
print(f"{'Home PLMN (HPLMN):':<25} {fmt_val(module.get_home_plmn())}")
|
||||
print(f"{'Last Registered (RPLMN):':<25} {fmt_val(module.get_registered_plmn())}")
|
||||
|
||||
# Priority Lists
|
||||
print("\n--- PRIORITY LISTS (EHPLMN / EPLMN) ---")
|
||||
print(f"{'Equivalent Home:':<25} {fmt_val(module.read_ehplmns())}")
|
||||
print(f"{'Equivalent PLMNs:':<25} {fmt_val(module.read_eplmns())}")
|
||||
|
||||
# Advanced Selection Lists (with Access Technology)
|
||||
print("\n--- SELECTOR LISTS (with Access Tech) ---")
|
||||
print(f"{'User Controlled:':<25} {fmt_val(module.read_user_plmn_act())}")
|
||||
print(f"{'Operator Controlled:':<25} {fmt_val(module.read_operator_plmn_act())}")
|
||||
print(f"{'HPLMN Selector:':<25} {fmt_val(module.read_hplmn_act())}")
|
||||
print(f"{'Legacy PLMN Selector:':<25} {fmt_val(module.read_plmn_selector())}")
|
||||
|
||||
# Restrictions & Special Groups
|
||||
print("\n--- RESTRICTIONS ---")
|
||||
print(f"{'Forbidden PLMNs:':<25} {fmt_val(module.read_forbidden_plmns())}")
|
||||
print(f"{'Closed Access Group:':<25} {fmt_val(module.read_cag_info())}")
|
||||
|
||||
print("\n" + "="*50)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run SIM forensics report.")
|
||||
parser.add_argument("port", nargs="?", default="/dev/ttyUSB0", help="Serial port (e.g. /dev/ttyUSB0 or MOCK)")
|
||||
args = parser.parse_args()
|
||||
sim_deep_forensics(args.port)
|
||||
42
examples/10_mock_simulation.py
Normal file
42
examples/10_mock_simulation.py
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import argparse
|
||||
from m2m.nbiot import factory
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(name)s - %(levelname)s - %(message)s')
|
||||
|
||||
def run_simulation(port):
|
||||
print(f"Starting Simulation on {port}...")
|
||||
|
||||
# Initialize with the provided port (e.g. 'MOCK')
|
||||
module = factory('BG95', port)
|
||||
|
||||
with module:
|
||||
print("\n--- Basic Checks ---")
|
||||
if module.check_state():
|
||||
print("Module is responsive (AT -> OK)")
|
||||
|
||||
print(f"Firmware: {module.read_firmware_version()}")
|
||||
print(f"IMEI: {module.read_imei()}")
|
||||
print(f"IMSI: {module.read_imsi()}")
|
||||
|
||||
print("\n--- Network Simulation ---")
|
||||
# The mock has default responses for these if 'MOCK' is used
|
||||
if module.attach_network():
|
||||
print("Attached to network")
|
||||
|
||||
rssi, ber = module.get_signal_quality()
|
||||
print(f"Signal Quality: RSSI={rssi}, BER={ber}")
|
||||
|
||||
reg_status = module.read_cereg_status()
|
||||
print(f"Registration Status: {reg_status}")
|
||||
|
||||
print("\n--- Extended Info ---")
|
||||
print(f"Network Info: {module.get_network_info()}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Run simulation.")
|
||||
parser.add_argument("port", nargs="?", default="MOCK", help="Serial port (default: MOCK)")
|
||||
args = parser.parse_args()
|
||||
run_simulation(args.port)
|
||||
164
examples/11_comprehensive_test.py
Executable file
164
examples/11_comprehensive_test.py
Executable file
|
|
@ -0,0 +1,164 @@
|
|||
#!/usr/bin/env python3
|
||||
import logging
|
||||
import argparse
|
||||
import time
|
||||
from m2m.nbiot import factory
|
||||
|
||||
# Configure clean logging output
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||
datefmt='%H:%M:%S'
|
||||
)
|
||||
logger = logging.getLogger('comprehensive_test')
|
||||
|
||||
def run_suite(module, apn):
|
||||
"""Runs the core network test suite and returns metrics."""
|
||||
results = {'rssi': 99, 'dl_kbps': 0.0, 'ul_kbps': 0.0}
|
||||
|
||||
print(" - Signal Quality...")
|
||||
rssi, ber = module.get_signal_quality()
|
||||
results['rssi'] = rssi
|
||||
print(f" RSSI: {rssi}, BER: {ber}")
|
||||
|
||||
print(" - DNS Resolution (google.com)...")
|
||||
ips = module.dns_query("google.com")
|
||||
print(f" IPs: {ips if ips else 'Failed'}")
|
||||
|
||||
if ips:
|
||||
print(" - Ping (8.8.8.8)...")
|
||||
ping_res = module.ping("8.8.8.8", num=3)
|
||||
summary = module.parse_ping_summary(ping_res)
|
||||
if summary:
|
||||
print(f" Summary: {summary.get('rcvd')}/{summary.get('sent')} received, RTT avg: {summary.get('avg')}ms")
|
||||
else:
|
||||
print(" Ping failed.")
|
||||
|
||||
# Symmetrical Speed Tests (150KB each)
|
||||
test_size_kb = 150
|
||||
|
||||
# Download
|
||||
print(f" - Speed Test (Download {test_size_kb}KB)...")
|
||||
module.http_configure(context_id=1, response_header=False)
|
||||
if module.http_set_url(f"http://httpbin.org/bytes/{test_size_kb * 1024}"):
|
||||
start = time.time()
|
||||
if module.http_get(timeout=300):
|
||||
resp = module.http_read_response(wait_time=120)
|
||||
duration = time.time() - start
|
||||
actual_size_kb = len(resp) / 1024
|
||||
if actual_size_kb > 1:
|
||||
speed = (actual_size_kb * 8) / duration
|
||||
results['dl_kbps'] = speed
|
||||
print(f" DL: {actual_size_kb:.1f} KB in {duration:.1f}s ({speed:.1f} kbps)")
|
||||
else: print(" DL test failed.")
|
||||
|
||||
# Upload
|
||||
print(f" - Speed Test (Upload {test_size_kb}KB)...")
|
||||
dummy_data = b"x" * (test_size_kb * 1024)
|
||||
if module.http_set_url("http://httpbin.org/post"):
|
||||
start = time.time()
|
||||
if module.http_post(dummy_data, timeout=300):
|
||||
duration = time.time() - start
|
||||
speed = (test_size_kb * 8) / duration
|
||||
results['ul_kbps'] = speed
|
||||
print(f" UL: {test_size_kb:.1f} KB in {duration:.1f}s ({speed:.1f} kbps)")
|
||||
else: print(" UL test failed.")
|
||||
|
||||
return results
|
||||
|
||||
def run_comprehensive_test(port, module_type='BG96', apn='iot.telefonica.de'):
|
||||
print("\n" + "="*80)
|
||||
print(f" MULTI-OPERATOR BENCHMARK: {module_type} on {port}")
|
||||
print("="*80)
|
||||
|
||||
module = factory(module_type, port)
|
||||
all_results = []
|
||||
|
||||
with module:
|
||||
# 1. Basic Health & Identification
|
||||
print("\n[1/5] Modem Identification...")
|
||||
if not module.check_state():
|
||||
print("ERROR: Modem not responding!")
|
||||
return
|
||||
module.set_echo_mode(True)
|
||||
imei = module.read_imei()
|
||||
print(f" IMEI: {imei}")
|
||||
|
||||
# 2. Operator Search
|
||||
print("\n[2/5] Scanning for available operators (this takes a while)...")
|
||||
operators = module.search_operators(timeout=180)
|
||||
if not operators:
|
||||
print(" No operators found or scan failed.")
|
||||
return
|
||||
|
||||
print(f" Found {len(operators)} operators.")
|
||||
|
||||
# 3. Test Each Operator
|
||||
print("\n[3/5] Starting Multi-Operator Test Suite...")
|
||||
for i, op in enumerate(operators, 1):
|
||||
stat_map = {'0': 'Unknown', '1': 'Available', '2': 'Current', '3': 'Forbidden'}
|
||||
op_name = f"{op['long']} ({op['mccmnc']})"
|
||||
print(f"\n ({i}/{len(operators)}) Testing Operator: {op_name} [Status: {stat_map.get(op['status'], op['status'])}]")
|
||||
|
||||
if op['status'] == '3':
|
||||
print(" Skipping forbidden operator.")
|
||||
continue
|
||||
|
||||
if module.set_operator(op['mccmnc'], format=2, act=int(op['act'])):
|
||||
print(" - Waiting for registration...")
|
||||
start_time = time.time()
|
||||
registered = False
|
||||
while time.time() - start_time < 90:
|
||||
if module.is_registered():
|
||||
registered = True
|
||||
break
|
||||
time.sleep(2)
|
||||
|
||||
if registered:
|
||||
print(f" - Registered! Attaching and activating context with APN '{apn}'...")
|
||||
if module.attach_network() and module.activate_pdp_context(1):
|
||||
res = run_suite(module, apn)
|
||||
res['name'] = op_name
|
||||
all_results.append(res)
|
||||
else:
|
||||
print(" - Failed to attach or activate PDP.")
|
||||
else:
|
||||
print(" - Failed to register on this operator.")
|
||||
else:
|
||||
print(" - Failed to select operator.")
|
||||
|
||||
# 4. Summary & Ranking
|
||||
print("\n" + "="*80)
|
||||
print(" FINAL BENCHMARK SUMMARY")
|
||||
print("="*80)
|
||||
if not all_results:
|
||||
print("No network tests were successful.")
|
||||
else:
|
||||
# Rank by DL Speed + UL Speed
|
||||
ranked = sorted(all_results, key=lambda x: (x['dl_kbps'] + x['ul_kbps']), reverse=True)
|
||||
|
||||
for i, res in enumerate(ranked, 1):
|
||||
star = "★ " if i == 1 else " "
|
||||
print(f"{star}#{i} {res['name']:<25} | DL: {res['dl_kbps']:>6.1f} kbps | UL: {res['ul_kbps']:>6.1f} kbps | RSSI: {res['rssi']}")
|
||||
|
||||
best = ranked[0]
|
||||
print("\nWINNER: " + best['name'] + " is the best performing operator!")
|
||||
|
||||
# 5. Hardware & Time
|
||||
print("\n[5/5] Final Hardware Health...")
|
||||
print(f" Internal Temp: {module.get_temperature()} C")
|
||||
bat = module.get_battery_status()
|
||||
print(f" Battery: {bat[1]}%, {bat[2]}mV | Module Clock: {module.get_clock()}")
|
||||
|
||||
print("\n" + "="*80)
|
||||
print(" MULTI-OPERATOR BENCHMARK FINISHED")
|
||||
print("="*80)
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Multi-operator benchmark test.")
|
||||
parser.add_argument("port", help="Serial port or socket URL")
|
||||
parser.add_argument("--type", default="BG95", help="Module type (BG95, BG96, BC66)")
|
||||
parser.add_argument("--apn", default="iot.telefonica.de", help="APN for connection")
|
||||
args = parser.parse_args()
|
||||
|
||||
run_comprehensive_test(args.port, args.type, args.apn)
|
||||
43
examples/12_disable_psm.py
Normal file
43
examples/12_disable_psm.py
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import logging
|
||||
from m2m.nbiot import factory
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(level=logging.INFO, format='%(levelname)s: %(message)s')
|
||||
|
||||
def disable_psm(port, module_type='BG95'):
|
||||
module = factory(module_type, port)
|
||||
|
||||
with module:
|
||||
print(f"--- PSM Deactivation Utility for {module_type} ---")
|
||||
|
||||
# 1. Read current status
|
||||
print("Reading current PSM status...")
|
||||
status = module.read_psm_status()
|
||||
print(f"Current Status: {status if status else 'Not set or error'}")
|
||||
|
||||
# 2. Deactivate PSM
|
||||
print("Sending deactivation command (AT+CPSMS=0)...")
|
||||
if module.disable_psm():
|
||||
print("OK: PSM has been successfully deactivated.")
|
||||
else:
|
||||
print("ERROR: Failed to deactivate PSM.")
|
||||
|
||||
# 3. Verify final status
|
||||
print("Verifying final status...")
|
||||
final_status = module.read_psm_status()
|
||||
print(f"Final Status: {final_status}")
|
||||
|
||||
if "+CPSMS: 0" in final_status:
|
||||
print("SUCCESS: PSM is confirmed OFF.")
|
||||
else:
|
||||
print("WARNING: PSM status might still be active or returned unexpected response.")
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="Deactivate PSM (Power Saving Mode) on the modem.")
|
||||
parser.add_argument("port", help="Serial port (e.g. /dev/ttyUSB0, socket://host:port, or MOCK)")
|
||||
parser.add_argument("--type", default="BG95", help="Module type (BG95, BG96, BC66)")
|
||||
args = parser.parse_args()
|
||||
|
||||
disable_psm(args.port, args.type)
|
||||
10
m2m/__init__.py
Normal file
10
m2m/__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
__version__ = '1.0.0'
|
||||
|
||||
from m2m.nbiot import factory, ModuleBG96, ModuleBG95, ModuleBC66, ModuleHiSi
|
||||
from m2m.serial import SerialPort
|
||||
from m2m.interactive import ModemShell
|
||||
|
||||
__all__ = ['factory', 'SerialPort', 'ModuleBG96', 'ModuleBG95', 'ModuleBC66', 'ModuleHiSi', 'ModemShell']
|
||||
1
m2m/__main__.py
Normal file
1
m2m/__main__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from m2m.cli import main; main()
|
||||
25
m2m/cli.py
Normal file
25
m2m/cli.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import logging
|
||||
from m2m.interactive import ModemShell
|
||||
|
||||
# Hide logs to keep the shell clean, unless requested
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Interactive Modem Control Shell")
|
||||
parser.add_argument("port", help="Serial port (e.g. /dev/ttyUSB0, socket://host:port, or MOCK)")
|
||||
parser.add_argument("--type", default="BG95", help="Module type (BG95, BG96, BC66)")
|
||||
parser.add_argument("--apn", default="iot.telefonica.de", help="Default APN for connection")
|
||||
parser.add_argument("--verbose", action="store_true", help="Enable verbose logging")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.verbose:
|
||||
logging.getLogger().setLevel(logging.INFO)
|
||||
|
||||
shell = ModemShell(args.type, args.port, args.apn)
|
||||
shell.run()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
26
m2m/exceptions.py
Normal file
26
m2m/exceptions.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Custom exceptions for the m2m library.
|
||||
"""
|
||||
|
||||
class M2MError(Exception):
|
||||
"""Base class for all m2m library exceptions."""
|
||||
pass
|
||||
|
||||
class SerialError(M2MError):
|
||||
"""Raised when a serial communication error occurs."""
|
||||
pass
|
||||
|
||||
class ATCommandError(M2MError):
|
||||
"""Raised when an AT command returns an error or times out."""
|
||||
pass
|
||||
|
||||
class TimeoutError(M2MError):
|
||||
"""Raised when an operation times out."""
|
||||
pass
|
||||
|
||||
class NetworkError(M2MError):
|
||||
"""Raised when a network operation fails (e.g. attach, context activation)."""
|
||||
pass
|
||||
481
m2m/interactive.py
Normal file
481
m2m/interactive.py
Normal file
|
|
@ -0,0 +1,481 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import sys
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
import threading
|
||||
from typing import Optional, List, Dict, Union
|
||||
|
||||
from prompt_toolkit import PromptSession
|
||||
from prompt_toolkit.completion import WordCompleter
|
||||
from prompt_toolkit.shortcuts import print_formatted_text, clear as clear_screen
|
||||
from prompt_toolkit.formatted_text import HTML
|
||||
from prompt_toolkit.styles import Style
|
||||
from prompt_toolkit.patch_stdout import patch_stdout
|
||||
|
||||
from m2m.nbiot import factory
|
||||
from m2m.utils import fmt_val
|
||||
from m2m.exceptions import SerialError, NetworkError
|
||||
|
||||
logger = logging.getLogger('m2m.interactive')
|
||||
|
||||
class ModemShell:
|
||||
"""
|
||||
Interactive shell for controlling IoT modules.
|
||||
"""
|
||||
def __init__(self, module_type: str, port: str, apn: str = 'iot.telefonica.de'):
|
||||
self.module_type = module_type
|
||||
self.port = port
|
||||
self.apn = apn
|
||||
try:
|
||||
self.module = factory(module_type, port)
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<red>Error: Could not initialize module on {port}: {e}</red>'))
|
||||
sys.exit(1)
|
||||
|
||||
# Command Dispatch Table
|
||||
self.commands = {
|
||||
'info': self.do_info,
|
||||
'status': self.do_status,
|
||||
'nw_info': self.do_nw_info,
|
||||
'cells': self.do_cells,
|
||||
'forensics': self.do_forensics,
|
||||
'scan': self.do_scan,
|
||||
'connect': self.do_connect,
|
||||
'disconnect': self.do_disconnect,
|
||||
'operator': self.do_operator,
|
||||
'ping': self.do_ping,
|
||||
'dns': self.do_dns,
|
||||
'http_get': self.do_http_get,
|
||||
'udp_send': self.do_udp_send,
|
||||
'udp_listen': self.do_udp_listen,
|
||||
'tcp_client': self.do_tcp_client,
|
||||
'sockets': self.do_sockets,
|
||||
'close': self.do_close,
|
||||
'recv': self.do_recv,
|
||||
'sms_send': self.do_sms_send,
|
||||
'sms_list': self.do_sms_list,
|
||||
'radio': self.do_radio,
|
||||
'reboot': self.do_reboot,
|
||||
'psm': self.do_psm,
|
||||
'mode': self.do_mode,
|
||||
'edrx': self.do_edrx,
|
||||
'bands': self.do_bands,
|
||||
'gpio': self.do_gpio,
|
||||
'ri': self.do_ri,
|
||||
'urc_delay': self.do_urc_delay,
|
||||
'at': self.do_at,
|
||||
'error': self.do_error,
|
||||
'ussd': self.do_ussd,
|
||||
'clock': self.do_clock,
|
||||
'temp': self.do_temp,
|
||||
'ls': self.do_ls,
|
||||
'power_off': self.do_power_off,
|
||||
'help': self.do_help,
|
||||
'clear': self.do_clear,
|
||||
'exit': self.do_exit,
|
||||
'quit': self.do_exit
|
||||
}
|
||||
|
||||
self.completer = WordCompleter(
|
||||
list(self.commands.keys()) +
|
||||
['on', 'off', 'loc', 'status', 'disable', 'sync', 'show', 'gsm', 'catm', 'nb', 'nb+emtc', 'all', 'opt'],
|
||||
ignore_case=True
|
||||
)
|
||||
self.style = Style.from_dict({
|
||||
'prompt': '#00ff00 bold',
|
||||
'command': '#ffffff',
|
||||
'error': '#ff0000',
|
||||
'info': '#00ffff',
|
||||
'success': '#00ff00',
|
||||
'header': '#ffff00 bold',
|
||||
'alert': '#ff8800 bold',
|
||||
})
|
||||
|
||||
self._stop_bg = threading.Event()
|
||||
self._bg_thread = None
|
||||
|
||||
# Register callbacks
|
||||
if hasattr(self.module.s_port, 'add_callback'):
|
||||
self.module.s_port.add_callback(b'+QIURC: "recv",', self._on_data_received)
|
||||
self.module.s_port.add_callback(b'+QIURC: "pdpdeact",', self._on_pdp_deact)
|
||||
|
||||
def _on_data_received(self, payload: bytes):
|
||||
try:
|
||||
conn_id = payload.decode().strip()
|
||||
print_formatted_text(HTML(f'\n<info>[DATA] Socket {conn_id} received data.</info> Type "recv {conn_id}"'))
|
||||
except: pass
|
||||
|
||||
def _on_pdp_deact(self, payload: bytes):
|
||||
print_formatted_text(HTML('\n<alert>[NETWORK] PDP Context Deactivated by network.</alert>'))
|
||||
|
||||
def _bg_worker(self):
|
||||
"""Polls for URCs when idle without blocking the main lock indefinitely."""
|
||||
while not self._stop_bg.is_set():
|
||||
try:
|
||||
if self.module.s_port._serial.in_waiting > 0:
|
||||
if self.module.s_port.lock.acquire(blocking=False):
|
||||
try:
|
||||
self.module.s_port.read_any(timeout=0.1)
|
||||
finally:
|
||||
self.module.s_port.lock.release()
|
||||
except: pass
|
||||
self._stop_bg.wait(0.5)
|
||||
|
||||
def run(self):
|
||||
print_formatted_text(HTML(f'<b>Interactive Modem Shell</b> ({self.module_type} on {self.port})'))
|
||||
print_formatted_text(HTML('Type <u>help</u> for available commands, <u>exit</u> to quit.\n'))
|
||||
|
||||
session = PromptSession(completer=self.completer, style=self.style)
|
||||
self._bg_thread = threading.Thread(target=self._bg_worker, daemon=True)
|
||||
self._bg_thread.start()
|
||||
|
||||
with patch_stdout():
|
||||
with self.module:
|
||||
while True:
|
||||
try:
|
||||
text = session.prompt(HTML('<prompt>m2m></prompt> '))
|
||||
except (KeyboardInterrupt, EOFError): break
|
||||
|
||||
if not (stripped := text.strip()): continue
|
||||
|
||||
try:
|
||||
parts = shlex.split(stripped)
|
||||
except ValueError as e:
|
||||
print_formatted_text(HTML(f'<error>Parse error: {e}</error>')); continue
|
||||
|
||||
cmd, args = parts[0].lower(), parts[1:]
|
||||
if handler := self.commands.get(cmd):
|
||||
try: handler(args)
|
||||
except Exception as e:
|
||||
print_formatted_text(HTML(f'<error>Error: {e}</error>'))
|
||||
else:
|
||||
print_formatted_text(HTML(f'<error>Unknown command: {cmd}</error>'))
|
||||
|
||||
self._stop_bg.set()
|
||||
|
||||
def do_help(self, args):
|
||||
"""Show available commands."""
|
||||
print_formatted_text(HTML('<header>Available Commands:</header>'))
|
||||
for c in sorted(self.commands.keys()):
|
||||
doc = (self.commands[c].__doc__ or "No description.").replace('<', '<').replace('>', '>')
|
||||
print_formatted_text(HTML(f' <info>{c:<12}</info> {doc}'))
|
||||
|
||||
def do_clear(self, _):
|
||||
"""Clear the terminal screen."""
|
||||
clear_screen()
|
||||
|
||||
def do_info(self, _):
|
||||
"""Read detailed module identification and configuration."""
|
||||
print_formatted_text(HTML('<info>Reading identification...</info>'))
|
||||
print(f" IMEI: {self.module.read_imei() or 'N/A'}")
|
||||
print(f" IMSI: {self.module.read_imsi() or 'N/A'}")
|
||||
print(f" ICCID: {self.module.read_iccid() or 'N/A'}")
|
||||
print(f" FW: {self.module.read_firmware_version() or 'N/A'}")
|
||||
|
||||
if hasattr(self.module, 'get_nw_scan_mode'):
|
||||
mode_map = {0: "Automatic", 1: "GSM Only", 2: "LTE Only", 3: "GSM + LTE"}
|
||||
print(f" ScanMode: {mode_map.get(self.module.get_nw_scan_mode(), 'Unknown')}")
|
||||
|
||||
if hasattr(self.module, 'get_iot_op_mode_val'):
|
||||
op_map = {0: "eMTC", 1: "NB-IoT", 2: "eMTC + NB-IoT"}
|
||||
print(f" IoTMode: {op_map.get(self.module.get_iot_op_mode_val(), 'Unknown')}")
|
||||
|
||||
def do_status(self, _):
|
||||
"""Show registration and signal status."""
|
||||
print_formatted_text(HTML('<info>Checking status...</info>'))
|
||||
rssi, ber = self.module.get_signal_quality()
|
||||
print(f" Signal: RSSI={rssi}, BER={ber}")
|
||||
print(f" Registered: {'Yes' if self.module.is_registered() else 'No'}")
|
||||
print(f" Operator: {self.module.get_current_operator() or 'None'}")
|
||||
print(f" IP Address: {self.module.get_ip_address(1) or 'None'}")
|
||||
|
||||
def do_nw_info(self, _):
|
||||
"""Show detailed network and serving cell info."""
|
||||
if hasattr(self.module, 'get_network_info'):
|
||||
print(f" Network: {self.module.get_network_info()}")
|
||||
if hasattr(self.module, 'get_serving_cell_info'):
|
||||
print(f" Cell: {self.module.get_serving_cell_info()}")
|
||||
|
||||
def do_cells(self, _):
|
||||
"""Show serving and neighbor cell information."""
|
||||
if hasattr(self.module, 'get_serving_cell_info'):
|
||||
print(f" Serving: {self.module.get_serving_cell_info()}")
|
||||
if hasattr(self.module, 'get_neighbor_cells'):
|
||||
for n in self.module.get_neighbor_cells():
|
||||
print(f" - {n}")
|
||||
|
||||
def do_forensics(self, _):
|
||||
"""Full SIM card forensics report."""
|
||||
print_formatted_text(HTML('<header>SIM CARD NETWORK FORENSICS</header>'))
|
||||
print(f"{'ICCID:':<25} {fmt_val(self.module.read_iccid())}")
|
||||
print(f"{'IMSI:':<25} {fmt_val(self.module.read_imsi())}")
|
||||
print(f"{'Home PLMN:':<25} {fmt_val(self.module.get_home_plmn())}")
|
||||
print(f"{'Registered PLMN:':<25} {fmt_val(self.module.get_registered_plmn())}")
|
||||
print(f"{'Forbidden PLMNs:':<25} {fmt_val(self.module.read_forbidden_plmns())}")
|
||||
print(f"{'Equivalent Home:':<25} {fmt_val(self.module.read_ehplmns())}")
|
||||
|
||||
def do_scan(self, _):
|
||||
"""Scan for available network operators."""
|
||||
print_formatted_text(HTML('<info>Scanning for operators...</info>'))
|
||||
ops = self.module.search_operators(timeout=180)
|
||||
for op in (ops or []):
|
||||
s_map = {'0': 'Unknown', '1': 'Available', '2': 'Current', '3': 'Forbidden'}
|
||||
print(f" - {op['long']} ({op['mccmnc']}) [{s_map.get(op['status'], op['status'])}, Tech: {op['act']}]")
|
||||
|
||||
def do_connect(self, args):
|
||||
"""Connect to network. Usage: connect [apn] [--force]"""
|
||||
force = '--force' in args
|
||||
clean_args = [a for a in args if a != '--force']
|
||||
apn = clean_args[0] if clean_args else self.apn
|
||||
print_formatted_text(HTML(f'<info>Connecting with APN: {apn}...</info>'))
|
||||
if self.module.connect_network(apn=apn, force=force):
|
||||
print_formatted_text(HTML('<success>Connected!</success>'))
|
||||
else: print_formatted_text(HTML('<error>Failed.</error>'))
|
||||
|
||||
def do_disconnect(self, _):
|
||||
"""Deactivate PDP context."""
|
||||
if hasattr(self.module, 'deactivate_pdp_context') and self.module.deactivate_pdp_context(1):
|
||||
print_formatted_text(HTML('<success>Disconnected.</success>'))
|
||||
else: print_formatted_text(HTML('<error>Failed or not supported.</error>'))
|
||||
|
||||
def do_operator(self, args):
|
||||
"""Select operator manually. Usage: operator <mccmnc> [act]"""
|
||||
if not args: print("Usage: operator <mccmnc> [act]"); return
|
||||
plmn, act = args[0], int(args[1]) if len(args) > 1 else 8
|
||||
if self.module.set_operator(plmn, act=act):
|
||||
print_formatted_text(HTML('<success>Done.</success>'))
|
||||
else: print_formatted_text(HTML('<error>Failed.</error>'))
|
||||
|
||||
def do_ping(self, args):
|
||||
"""Ping a host. Usage: ping <host> [num]"""
|
||||
if not args: print("Usage: ping <host> [num]"); return
|
||||
host, num = args[0], int(args[1]) if len(args) > 1 else 4
|
||||
res = self.module.ping(host, num=num)
|
||||
for r in res: print(f" {r}")
|
||||
if (s := self.module.parse_ping_summary(res)):
|
||||
print_formatted_text(HTML(f'<success>RTT avg: {s["avg"]}ms</success>'))
|
||||
|
||||
def do_dns(self, args):
|
||||
"""Resolve a hostname. Usage: dns <host>"""
|
||||
if not args: print("Usage: dns <host>"); return
|
||||
if (ips := self.module.dns_query(args[0])):
|
||||
print(f" IPs: {', '.join(ips)}")
|
||||
else: print_formatted_text(HTML('<error>Failed.</error>'))
|
||||
|
||||
def do_http_get(self, args):
|
||||
"""Perform HTTP GET. Usage: http_get <url>"""
|
||||
if not args: print("Usage: http_get <url>"); return
|
||||
url = args[0] if args[0].startswith('http') else f'http://{args[0]}'
|
||||
if self.module.http_set_url(url) and self.module.http_get():
|
||||
print("-" * 40 + f"\n{self.module.http_read_response()}\n" + "-" * 40)
|
||||
else: print_formatted_text(HTML('<error>Failed.</error>'))
|
||||
|
||||
def do_udp_send(self, args):
|
||||
"""Send a UDP packet. Usage: udp_send <host> <port> <data>"""
|
||||
if len(args) < 3: print("Usage: udp_send <host> <port> <data>"); return
|
||||
host, port, data = args[0], int(args[1]), " ".join(args[2:])
|
||||
sock_id = -1
|
||||
if hasattr(self.module, 'get_socket_state'):
|
||||
for s in self.module.get_socket_state():
|
||||
if s['type'] == 'UDP' and s['remote'] == f"{host}:{port}":
|
||||
sock_id = int(s['id']); break
|
||||
if sock_id == -1:
|
||||
sock_id = self.module.open_socket(service_type="UDP", remote_ip=host, remote_port=port)
|
||||
if sock_id >= 0 and self.module.send_data_hex(sock_id, data.encode()):
|
||||
print_formatted_text(HTML('<success>Sent!</success>'))
|
||||
else: print_formatted_text(HTML('<error>Failed.</error>'))
|
||||
|
||||
def do_udp_listen(self, args):
|
||||
"""Open a local UDP listener. Usage: udp_listen <port>"""
|
||||
if not args: print("Usage: udp_listen <port>"); return
|
||||
sid = self.module.open_socket(service_type="UDP SERVICE", remote_ip="127.0.0.1", local_port=int(args[0]))
|
||||
if sid >= 0: print_formatted_text(HTML(f'<success>Listening on socket {sid}.</success>'))
|
||||
else: print_formatted_text(HTML('<error>Failed.</error>'))
|
||||
|
||||
def do_tcp_client(self, args):
|
||||
"""Connect to a TCP server. Usage: tcp_client <host> <port>"""
|
||||
if len(args) < 2: print("Usage: tcp_client <host> <port>"); return
|
||||
sid = self.module.open_socket(service_type="TCP", remote_ip=args[0], remote_port=int(args[1]))
|
||||
if sid >= 0: print_formatted_text(HTML(f'<success>Socket {sid} connected.</success>'))
|
||||
else: print_formatted_text(HTML('<error>Failed.</error>'))
|
||||
|
||||
def do_sockets(self, _):
|
||||
"""List active sockets."""
|
||||
if hasattr(self.module, 'get_socket_state'):
|
||||
socks = self.module.get_socket_state()
|
||||
for s in socks: print(f" ID {s['id']}: {s['type']:<8} | {s['remote']:<20} | {s['state']}")
|
||||
else: print("Not supported.")
|
||||
|
||||
def do_close(self, args):
|
||||
"""Close a socket. Usage: close <id>"""
|
||||
if args and self.module.close_socket(int(args[0])):
|
||||
print_formatted_text(HTML('<success>Closed.</success>'))
|
||||
else: print_formatted_text(HTML('<error>Failed.</error>'))
|
||||
|
||||
def do_recv(self, args):
|
||||
"""Read socket data. Usage: recv <id> [len]"""
|
||||
if not args: print("Usage: recv <id> [len]"); return
|
||||
data = self.module.receive_data(int(args[0]), int(args[1]) if len(args) > 1 else 1500)
|
||||
if data: print("-" * 20 + f"\n{data.decode(errors='replace')}\n" + "-" * 20)
|
||||
else: print(" No data.")
|
||||
|
||||
def do_sms_send(self, args):
|
||||
"""Send an SMS. Usage: sms_send <number> <message>"""
|
||||
if len(args) < 2:
|
||||
print("Usage: sms_send <number> <message>")
|
||||
return
|
||||
|
||||
number, message = args[0], " ".join(args[1:])
|
||||
print_formatted_text(HTML(f'<info>Sending SMS to {number}...</info>'))
|
||||
if self.module.send_sms(number, message):
|
||||
print_formatted_text(HTML('<success>Sent!</success>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<error>Failed to send SMS.</error>'))
|
||||
|
||||
def do_sms_list(self, args):
|
||||
"""List SMS. Usage: sms_list [ALL|REC UNREAD]"""
|
||||
for msg in self.module.list_sms(args[0] if args else "ALL"): print(msg)
|
||||
|
||||
def do_radio(self, args):
|
||||
"""Toggle radio. Usage: radio <on|off>"""
|
||||
if not args: print(f"Radio is {'ON' if self.module.is_radio_on() else 'OFF'}"); return
|
||||
self.module.radio_on() if args[0].lower() == 'on' else self.module.radio_off()
|
||||
|
||||
def do_reboot(self, _):
|
||||
"""Reboot the module."""
|
||||
self.module.reboot()
|
||||
|
||||
def do_mode(self, args):
|
||||
"""Set connectivity preference. Usage: mode <gsm|emtc|nb|nb+emtc|all>"""
|
||||
if not args:
|
||||
print("Usage: mode <gsm|emtc|nb|nb+emtc|all>")
|
||||
return
|
||||
|
||||
mode = args[0].lower()
|
||||
print_formatted_text(HTML(f'<info>Setting connectivity mode to {mode}...</info>'))
|
||||
if hasattr(self.module, 'set_connectivity_mode'):
|
||||
if self.module.set_connectivity_mode(mode):
|
||||
print_formatted_text(HTML('<success>Mode set successfully.</success>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<error>Failed to set mode.</error>'))
|
||||
else:
|
||||
print("Mode configuration not supported on this module.")
|
||||
|
||||
def do_psm(self, args):
|
||||
"""Control PSM. Usage: psm <status|on|off|set|opt>"""
|
||||
sub = args[0].lower() if args else 'status'
|
||||
if sub == 'status': print(f" PSM: {self.module.read_psm_status() or 'Unknown'}")
|
||||
elif sub == 'off': self.module.disable_psm()
|
||||
elif sub == 'on' and len(args) >= 3: self.module.configure_psm_auto(int(args[1]), int(args[2]))
|
||||
elif sub == 'opt': self.module.configure_psm_optimization()
|
||||
|
||||
def do_edrx(self, args):
|
||||
"""Configure eDRX. Usage: edrx <mode> <act> <bits>"""
|
||||
if len(args) >= 3 and self.module.set_edrx(int(args[0]), int(args[1]), args[2]):
|
||||
print_formatted_text(HTML('<success>Done.</success>'))
|
||||
|
||||
def do_bands(self, args):
|
||||
"""Show or lock frequency bands. Usage: bands [gsm|catm|nb] [add|remove] <band_num>"""
|
||||
from m2m.utils import hex_mask_to_bands, bands_to_hex_mask
|
||||
|
||||
if not hasattr(self.module, 'get_locked_bands'):
|
||||
print("Band control not supported."); return
|
||||
|
||||
current = self.module.get_locked_bands()
|
||||
# Map to actual lists of integers
|
||||
band_lists = {k: hex_mask_to_bands(v) for k, v in current.items()}
|
||||
|
||||
if not args:
|
||||
print_formatted_text(HTML('<info>Currently Active Bands:</info>'))
|
||||
for rat, blist in band_lists.items():
|
||||
print(f" {rat.upper():<5}: {blist}")
|
||||
return
|
||||
|
||||
if len(args) < 3:
|
||||
print("Usage: bands <gsm|catm|nb> <add|remove> <band_number>"); return
|
||||
|
||||
rat, action, bnum = args[0].lower(), args[1].lower(), int(args[2])
|
||||
if rat not in band_lists:
|
||||
print(f"Invalid RAT: {rat}. Use gsm, catm, or nb."); return
|
||||
|
||||
if action == 'add':
|
||||
if bnum not in band_lists[rat]: band_lists[rat].append(bnum)
|
||||
elif action == 'remove':
|
||||
if bnum in band_lists[rat]: band_lists[rat].remove(bnum)
|
||||
else:
|
||||
print(f"Invalid action: {action}. Use add or remove."); return
|
||||
|
||||
# Apply changes
|
||||
print_formatted_text(HTML(f'<info>Updating {rat.upper()} bands...</info>'))
|
||||
if self.module.lock_bands(
|
||||
gsm_bands=band_lists.get('gsm', []),
|
||||
catm_bands=band_lists.get('catm', []),
|
||||
nb_bands=band_lists.get('nb', [])
|
||||
):
|
||||
print_formatted_text(HTML('<success>Bands updated successfully.</success>'))
|
||||
else:
|
||||
print_formatted_text(HTML('<error>Failed to update bands.</error>'))
|
||||
|
||||
def do_gpio(self, args):
|
||||
"""Control GPIO. Usage: gpio set <pin> <val> | get <pin>"""
|
||||
if len(args) >= 2:
|
||||
if args[0] == 'set' and len(args) >= 3: self.module.set_gpio(3, int(args[1]), int(args[2]))
|
||||
elif args[0] == 'get': print(f"GPIO {args[1]}: {self.module.get_gpio(int(args[1]))}")
|
||||
|
||||
def do_ri(self, args):
|
||||
"""Configure Ring Indicator. Usage: ri <type> [duration]"""
|
||||
if len(args) >= 1:
|
||||
dur = int(args[1]) if len(args) > 1 else 120
|
||||
if hasattr(self.module, 'configure_ri_pin') and self.module.configure_ri_pin(args[0], dur):
|
||||
print_formatted_text(HTML('<success>Done.</success>'))
|
||||
|
||||
def do_urc_delay(self, args):
|
||||
"""Configure URC delay. Usage: urc_delay <on|off>"""
|
||||
if args and hasattr(self.module, 'configure_urc_delay'):
|
||||
if self.module.configure_urc_delay(args[0].lower() == 'on'):
|
||||
print_formatted_text(HTML('<success>Done.</success>'))
|
||||
|
||||
def do_at(self, args):
|
||||
"""Send raw AT command."""
|
||||
if args:
|
||||
cmd = " ".join(args)
|
||||
if not cmd.upper().startswith('AT'): cmd = f'AT{cmd}'
|
||||
print(self.module.send_at_command(cmd).decode(errors='ignore'))
|
||||
|
||||
def do_error(self, _):
|
||||
"""Show extended error report (AT+CEER)."""
|
||||
print(f" Report: {self.module.get_extended_error_report()}")
|
||||
|
||||
def do_ussd(self, args):
|
||||
"""Send USSD code. Usage: ussd <code>"""
|
||||
if args: print(f" Response: {self.module.send_ussd(args[0])}")
|
||||
|
||||
def do_clock(self, args):
|
||||
"""Show or sync clock. Usage: clock [show|sync]"""
|
||||
sub = args[0].lower() if args else 'show'
|
||||
if sub == 'show': print(f" Clock: {self.module.get_clock()}")
|
||||
elif sub == 'sync':
|
||||
if self.module.ntp_sync(): print(f" New Clock: {self.module.get_clock()}")
|
||||
else: print_formatted_text(HTML('<error>Sync failed.</error>'))
|
||||
|
||||
def do_temp(self, _):
|
||||
"""Read module temperature."""
|
||||
if hasattr(self.module, 'get_temperature'):
|
||||
print(f" Temp: {self.module.get_temperature()} C")
|
||||
|
||||
def do_ls(self, args):
|
||||
"""List files. Usage: ls [pattern]"""
|
||||
files = self.module.list_files(args[0] if args else "*")
|
||||
print(f"{'Filename':<30} | {'Size':>10}")
|
||||
for f in files: print(f"{f['name']:<30} | {f['size']:>10,}")
|
||||
|
||||
def do_power_off(self, _):
|
||||
"""Power off."""
|
||||
self.module.power_off()
|
||||
|
||||
def do_exit(self, _):
|
||||
"""Exit shell."""
|
||||
sys.exit(0)
|
||||
6
m2m/misc/__init__.py
Normal file
6
m2m/misc/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
m2m.misc package initialization.
|
||||
"""
|
||||
69
m2m/misc/at_parser.py
Normal file
69
m2m/misc/at_parser.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Helper for parsing AT command responses.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from m2m.serial.serial_port import SerialPort
|
||||
|
||||
logger = logging.getLogger('atparser')
|
||||
|
||||
|
||||
class AtParser:
|
||||
"""
|
||||
Parses responses from the serial port to determine command success/failure.
|
||||
"""
|
||||
|
||||
def __init__(self, serial_port: 'SerialPort'):
|
||||
self._port = serial_port
|
||||
self._last_response = b''
|
||||
|
||||
@property
|
||||
def last_response(self) -> bytes:
|
||||
return self._last_response
|
||||
|
||||
def read_ok(self, timeout: float = 1.0) -> bool:
|
||||
"""
|
||||
Reads until a terminator is found and checks if it was 'OK'.
|
||||
"""
|
||||
self._last_response = self._port.read_until(terminator=b'OK', timeout=timeout)
|
||||
# Check if the buffer *ends* with OK, or contains it on a line.
|
||||
# Strict check:
|
||||
lines = self._last_response.splitlines()
|
||||
for line in reversed(lines):
|
||||
line = line.strip()
|
||||
if line == b'OK':
|
||||
return True
|
||||
if line == b'ERROR' or b'CME ERROR' in line:
|
||||
return False
|
||||
return False
|
||||
|
||||
def is_on(self, query_cmd: bytes, response_prefix: bytes, timeout: float = 1.0) -> bool:
|
||||
"""
|
||||
Sends a query and checks if the value is '1'.
|
||||
Example: is_on(b'AT+CFUN?', b'+CFUN:')
|
||||
"""
|
||||
self._port.send_cmd(query_cmd)
|
||||
response = self._port.read_until(timeout=timeout)
|
||||
self._last_response = response
|
||||
|
||||
# Parse for prefix
|
||||
for line in response.splitlines():
|
||||
line = line.strip()
|
||||
if line.startswith(response_prefix):
|
||||
# +CFUN: 1
|
||||
try:
|
||||
# Remove prefix, then split by comma if multiple params
|
||||
# We assume the first param is the status
|
||||
payload = line[len(response_prefix):].strip()
|
||||
# Handle cases like "1" or "1,0"
|
||||
val_str = payload.split(b',')[0]
|
||||
return int(val_str) == 1
|
||||
except ValueError:
|
||||
pass
|
||||
return False
|
||||
50
m2m/nbiot/__init__.py
Normal file
50
m2m/nbiot/__init__.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
m2m.nbiot package initialization.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Type, Optional
|
||||
|
||||
from m2m.nbiot.module import ModuleBase
|
||||
from m2m.nbiot.module_bg96 import ModuleBG96
|
||||
from m2m.nbiot.module_bg95 import ModuleBG95
|
||||
from m2m.nbiot.module_bc66 import ModuleBC66
|
||||
from m2m.nbiot.module_hisi import ModuleHiSi
|
||||
|
||||
# Establish parent logger
|
||||
logging.getLogger('m2m').addHandler(logging.NullHandler())
|
||||
|
||||
# Chipset Registry
|
||||
_MODULE_REGISTRY: Dict[str, Type[ModuleBase]] = {
|
||||
'HiSi': ModuleHiSi,
|
||||
'BG96': ModuleBG96,
|
||||
'BG95': ModuleBG95,
|
||||
'BC66': ModuleBC66,
|
||||
}
|
||||
|
||||
def factory(chipset: str, serial_port: str, **kwargs) -> ModuleBase:
|
||||
"""
|
||||
Factory for creating module instances based on chipset name.
|
||||
|
||||
Args:
|
||||
chipset: Name of the chipset (e.g., 'BG96', 'BG95', 'HiSi').
|
||||
serial_port: Device path (e.g., '/dev/ttyUSB0').
|
||||
**kwargs: Passed to the module constructor.
|
||||
|
||||
Returns:
|
||||
An instance of a ModuleBase subclass.
|
||||
|
||||
Raises:
|
||||
ValueError: If the chipset is not supported.
|
||||
"""
|
||||
module_class = _MODULE_REGISTRY.get(chipset)
|
||||
if not module_class:
|
||||
supported = ", ".join(_MODULE_REGISTRY.keys())
|
||||
raise ValueError(f"Unsupported chipset '{chipset}'. Supported: {supported}")
|
||||
|
||||
return module_class(serial_port, **kwargs)
|
||||
|
||||
__all__ = ['factory', 'ModuleBase', 'ModuleBG96', 'ModuleBG95', 'ModuleBC66', 'ModuleHiSi']
|
||||
529
m2m/nbiot/module.py
Executable file
529
m2m/nbiot/module.py
Executable file
|
|
@ -0,0 +1,529 @@
|
|||
#!/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()
|
||||
48
m2m/nbiot/module_bc66.py
Normal file
48
m2m/nbiot/module_bc66.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
from m2m.nbiot.quectel import QuectelModule
|
||||
|
||||
logger = logging.getLogger('m2m.bc66')
|
||||
|
||||
class ModuleBC66(QuectelModule):
|
||||
"""
|
||||
Implementation for Quectel BC66 NB-IoT modules.
|
||||
"""
|
||||
|
||||
def __init__(self, serial_port: str, baudrate: int = 9600, **kwargs):
|
||||
super().__init__(serial_port, baudrate, **kwargs)
|
||||
self._setup()
|
||||
|
||||
def _setup(self):
|
||||
self.set_echo_mode(False)
|
||||
# Disable sleep lock and initialize network URCs
|
||||
self.send_at_command('AT+QSCLK=0')
|
||||
self.send_at_command('AT+CEREG=0')
|
||||
|
||||
def reboot(self) -> bool:
|
||||
"""Reboots the module (AT+QRST=1)."""
|
||||
return self.send_at_command('AT+QRST=1') == b'OK'
|
||||
|
||||
def lwm2m_configure(self, server_ip: str, port: int = 5683, endpoint: str = "",
|
||||
bootstrap: bool = False, security_mode: int = 3,
|
||||
psk_id: str = "", psk: str = "") -> bool:
|
||||
"""Configures LwM2M settings (AT+QLWCONFIG)."""
|
||||
bs = 1 if bootstrap else 0
|
||||
cmd = f'AT+QLWCONFIG={bs},"{server_ip}",{port},"{endpoint}",60,{security_mode}'
|
||||
if security_mode != 3 and psk_id and psk:
|
||||
cmd += f',"{psk_id}","{psk}"'
|
||||
return self.send_at_command(cmd) == b'OK'
|
||||
|
||||
def lwm2m_register(self, timeout: int = 60) -> bool:
|
||||
"""Registers to LwM2M server (AT+QLWREG)."""
|
||||
resp = self.send_at_command('AT+QLWREG', timeout=timeout)
|
||||
return b'QLWREG: 0' in resp or b'OK' in resp
|
||||
|
||||
def ping(self, host: str, context_id: int = 1, timeout: int = 4, num: int = 4) -> List[str]:
|
||||
raise NotImplementedError("BC66 Ping parsing not fully standardized.")
|
||||
|
||||
def dns_query(self, host: str, context_id: int = 1, timeout: int = 30) -> List[str]:
|
||||
raise NotImplementedError("BC66 DNS query not fully standardized.")
|
||||
27
m2m/nbiot/module_bg95.py
Normal file
27
m2m/nbiot/module_bg95.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from m2m.nbiot.quectel import QuectelModule
|
||||
|
||||
logger = logging.getLogger('m2m.bg95')
|
||||
|
||||
class ModuleBG95(QuectelModule):
|
||||
"""
|
||||
Implementation for Quectel BG95 series modules.
|
||||
"""
|
||||
|
||||
def __init__(self, serial_port: str, baudrate: int = 115200, **kwargs):
|
||||
super().__init__(serial_port, baudrate, **kwargs)
|
||||
self._setup()
|
||||
|
||||
def _setup(self):
|
||||
self.set_echo_mode(False)
|
||||
# Enable Connection Status URC
|
||||
self.send_at_command('AT+QCSCON=1')
|
||||
|
||||
def set_nb2_mode(self, enable: bool = True) -> bool:
|
||||
"""Enables LTE Cat NB2 support (Release 14)."""
|
||||
# This typically involves specific band configuration or scan priority
|
||||
# which is already covered by high-level 'mode' and 'bands' commands.
|
||||
return True
|
||||
21
m2m/nbiot/module_bg96.py
Normal file
21
m2m/nbiot/module_bg96.py
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from m2m.nbiot.quectel import QuectelModule
|
||||
|
||||
logger = logging.getLogger('m2m.bg96')
|
||||
|
||||
class ModuleBG96(QuectelModule):
|
||||
"""
|
||||
Implementation for Quectel BG96 series modules.
|
||||
"""
|
||||
|
||||
def __init__(self, serial_port: str, baudrate: int = 115200, **kwargs):
|
||||
super().__init__(serial_port, baudrate, **kwargs)
|
||||
self._setup()
|
||||
|
||||
def _setup(self):
|
||||
self.set_echo_mode(False)
|
||||
# Enable Connection Status URC
|
||||
self.send_at_command('AT+QCSCON=1')
|
||||
72
m2m/nbiot/module_hisi.py
Normal file
72
m2m/nbiot/module_hisi.py
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import binascii
|
||||
from typing import Optional, List
|
||||
from m2m.nbiot.module import ModuleBase
|
||||
|
||||
logger = logging.getLogger('m2m.hisi')
|
||||
|
||||
class ModuleHiSi(ModuleBase):
|
||||
"""
|
||||
Implementation for HiSilicon Boudica based modules (e.g. BC95).
|
||||
"""
|
||||
|
||||
def __init__(self, serial_port: str, baudrate: int = 9600, **kwargs):
|
||||
super().__init__(serial_port, baudrate, **kwargs)
|
||||
self._setup()
|
||||
|
||||
def _setup(self):
|
||||
self.reboot()
|
||||
|
||||
def reboot(self) -> bool:
|
||||
"""Performs a software reboot via AT+NRB."""
|
||||
with self.transaction:
|
||||
self.s_port.send_cmd(b'AT+NRB')
|
||||
return self.at.read_ok(timeout=15)
|
||||
|
||||
def set_nconfig(self, key: str, value: str) -> bool:
|
||||
"""Sets configuration via AT+NCONFIG."""
|
||||
return self.send_at_command(f'AT+NCONFIG="{key}","{value}"') == b'OK'
|
||||
|
||||
def configure_pdp_context(self, context_id: int, context_type: str, apn: str) -> bool:
|
||||
"""Configures PDP context using AT+CGDCONT."""
|
||||
return self.send_at_command(f'AT+CGDCONT={context_id},"{context_type}","{apn}"') == b'OK'
|
||||
|
||||
def activate_pdp_context(self, context_id: int) -> bool:
|
||||
"""Activates PDP context via AT+CGACT."""
|
||||
if self.send_at_command(f'AT+CGACT=1,{context_id}') == b'OK':
|
||||
return True
|
||||
return bool(self.get_ip_address(context_id))
|
||||
|
||||
def open_socket(self, local_port: int = 0, protocol: str = "UDP") -> int:
|
||||
"""Opens a socket via AT+NSOCR."""
|
||||
proto_val = 17 if protocol.upper() == "UDP" else 6
|
||||
resp = self.send_at_command(f'AT+NSOCR=DGRAM,{proto_val},{local_port}')
|
||||
for line in resp.splitlines():
|
||||
line = line.strip()
|
||||
if line.isdigit(): return int(line)
|
||||
return -1
|
||||
|
||||
def send_data_hex(self, socket_id: int, data: bytes, remote_ip: str = "", remote_port: int = 0) -> bool:
|
||||
"""Sends data (UDP) via AT+NSOST."""
|
||||
if not remote_ip or not remote_port: return False
|
||||
hex_data = binascii.hexlify(data).decode('ascii')
|
||||
cmd = f'AT+NSOST={socket_id},{remote_ip},{remote_port},{len(data)},{hex_data}'
|
||||
return self.send_at_command(cmd) == b'OK'
|
||||
|
||||
def close_socket(self, socket_id: int) -> bool:
|
||||
"""Closes socket via AT+NSOCL."""
|
||||
return self.send_at_command(f'AT+NSOCL={socket_id}') == b'OK'
|
||||
|
||||
def receive_data(self, socket_id: int, length: int = 512) -> bytes:
|
||||
"""Reads data via AT+NSORF."""
|
||||
resp = self.send_at_command(f'AT+NSORF={socket_id},{length}')
|
||||
for line in resp.splitlines():
|
||||
if b',' in line:
|
||||
p = line.split(b',')
|
||||
if len(p) >= 5 and p[0].strip() == str(socket_id).encode():
|
||||
try: return binascii.unhexlify(p[4])
|
||||
except: pass
|
||||
return b''
|
||||
518
m2m/nbiot/quectel.py
Normal file
518
m2m/nbiot/quectel.py
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
#!/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}")
|
||||
6
m2m/serial/__init__.py
Normal file
6
m2m/serial/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from m2m.serial.serial_port import SerialPort
|
||||
|
||||
__all__ = ['SerialPort']
|
||||
222
m2m/serial/mock_serial.py
Normal file
222
m2m/serial/mock_serial.py
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Mock serial port for testing and development without hardware.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Dict, Optional, Callable, List
|
||||
|
||||
logger = logging.getLogger('mock_serial')
|
||||
|
||||
class MockSerial:
|
||||
"""
|
||||
Simulates a serial port behavior with internal state management.
|
||||
"""
|
||||
|
||||
def __init__(self, port: str = "MOCK", baudrate: int = 9600, timeout: float = 1.0, **kwargs):
|
||||
self.port = port
|
||||
self.baudrate = baudrate
|
||||
self.timeout = timeout
|
||||
self.is_open = True
|
||||
self._rx_buffer = b""
|
||||
self._tx_buffer = b""
|
||||
self._raw_mode = False
|
||||
|
||||
# Internal State
|
||||
self.state = {
|
||||
"radio_on": False,
|
||||
"sim_status": "READY",
|
||||
"attached": False,
|
||||
"registered": False,
|
||||
"pdp_active": False,
|
||||
"sockets": {},
|
||||
"files": {},
|
||||
"mqtt_session": False
|
||||
}
|
||||
|
||||
# Default responses for query/read commands
|
||||
self._query_responses: Dict[bytes, bytes] = {
|
||||
b'AT': b'OK\r\n',
|
||||
b'ATE0': b'OK\r\n',
|
||||
b'ATE1': b'OK\r\n',
|
||||
b'AT+CGMR': b'Mock_Modem_v2.0\r\nOK\r\n',
|
||||
b'AT+CGSN': b'864215030000000\r\nOK\r\n',
|
||||
b'AT+CIMI': b'460001234567890\r\nOK\r\n',
|
||||
b'AT+CCID': b'+CCID: 89860012345678901234\r\nOK\r\n',
|
||||
b'AT+CSQ': b'+CSQ: 25,99\r\nOK\r\n',
|
||||
b'AT+QNWINFO': b'+QNWINFO: "FDD LTE","46000","LTE BAND 3",1300\r\nOK\r\n',
|
||||
b'AT+CEER': b'+CEER: No Error\r\nOK\r\n',
|
||||
b'AT+CBC': b'+CBC: 0,85,3900\r\nOK\r\n',
|
||||
b'AT+CCLK?': b'+CCLK: "23/01/01,12:00:00+32"\r\nOK\r\n',
|
||||
b'AT+QTEMP': b'+QTEMP: 35\r\nOK\r\n',
|
||||
}
|
||||
|
||||
# Command Handlers
|
||||
self._handlers: Dict[bytes, Callable[[bytes], bytes]] = {}
|
||||
self._register_default_handlers()
|
||||
|
||||
def _register_default_handlers(self):
|
||||
self.add_handler(b'AT+CFUN=', self._handle_cfun)
|
||||
self.add_handler(b'AT+CFUN?', self._handle_cfun_query)
|
||||
self.add_handler(b'AT+CPIN?', self._handle_cpin_query)
|
||||
self.add_handler(b'AT+CPIN=', self._handle_cpin_set)
|
||||
self.add_handler(b'AT+CRSM=', self._handle_crsm)
|
||||
self.add_handler(b'AT+CREG?', self._handle_creg)
|
||||
self.add_handler(b'AT+CEREG?', self._handle_cereg)
|
||||
self.add_handler(b'AT+CGATT=', self._handle_cgatt)
|
||||
self.add_handler(b'AT+CGATT?', self._handle_cgatt_query)
|
||||
self.add_handler(b'AT+CGPADDR', self._handle_cgpaddr)
|
||||
self.add_handler(b'AT+QENG="servingcell"', self._handle_qeng)
|
||||
self.add_handler(b'AT+QIACT=', self._handle_qiact)
|
||||
self.add_handler(b'AT+QIOPEN=', self._handle_qiopen)
|
||||
self.add_handler(b'AT+QICLOSE=', self._handle_qiclose)
|
||||
self.add_handler(b'AT+QISENDEX=', self._handle_qisendex)
|
||||
self.add_handler(b'AT+QIRD=', self._handle_qird)
|
||||
self.add_handler(b'AT+QFUPL=', self._handle_qfupl)
|
||||
self.add_handler(b'AT+QFDEL=', self._handle_qfdel)
|
||||
self.add_handler(b'AT+QICSGP=', self._handle_qicsgp)
|
||||
|
||||
def _handle_cfun(self, cmd: bytes) -> bytes:
|
||||
val = cmd.split(b'=')[1].strip()
|
||||
if val == b'1':
|
||||
self.state["radio_on"] = True
|
||||
self.state["registered"] = True
|
||||
elif val == b'0':
|
||||
self.state["radio_on"] = False
|
||||
self.state["registered"] = False
|
||||
self.state["attached"] = False
|
||||
return b'OK\r\n'
|
||||
|
||||
def _handle_cfun_query(self, cmd: bytes) -> bytes:
|
||||
val = 1 if self.state["radio_on"] else 0
|
||||
return f'+CFUN: {val}\r\nOK\r\n'.encode()
|
||||
|
||||
def _handle_cpin_query(self, cmd: bytes) -> bytes:
|
||||
return f'+CPIN: {self.state["sim_status"]}\r\nOK\r\n'.encode()
|
||||
|
||||
def _handle_cpin_set(self, cmd: bytes) -> bytes:
|
||||
self.state["sim_status"] = "READY"
|
||||
return b'OK\r\n'
|
||||
|
||||
def _handle_creg(self, cmd: bytes) -> bytes:
|
||||
stat = 1 if self.state["registered"] else 0
|
||||
return f'+CREG: 0,{stat}\r\nOK\r\n'.encode()
|
||||
|
||||
def _handle_cereg(self, cmd: bytes) -> bytes:
|
||||
stat = 1 if self.state["registered"] else 0
|
||||
return f'+CEREG: 0,{stat}\r\nOK\r\n'.encode()
|
||||
|
||||
def _handle_cgatt(self, cmd: bytes) -> bytes:
|
||||
val = cmd.split(b'=')[1].strip()
|
||||
self.state["attached"] = (val == b'1')
|
||||
return b'OK\r\n'
|
||||
|
||||
def _handle_cgatt_query(self, cmd: bytes) -> bytes:
|
||||
val = 1 if self.state["attached"] else 0
|
||||
return f'+CGATT: {val}\r\nOK\r\n'.encode()
|
||||
|
||||
def _handle_cgpaddr(self, cmd: bytes) -> bytes:
|
||||
if self.state["pdp_active"]:
|
||||
return b'+CGPADDR: 1,"10.0.0.5"\r\nOK\r\n'
|
||||
return b'+CGPADDR: 1,""\r\nOK\r\n'
|
||||
|
||||
def _handle_qeng(self, cmd: bytes) -> bytes:
|
||||
# Realistic LTE response with enough fields for index 13
|
||||
# +QENG: "servingcell",<state>,"LTE",<is_tdd>,<mcc>,<mnc>,<cellid>,<pcid>,<earfcn>,<freq_band_ind>,<ul_bandwidth>,<dl_bandwidth>,<tac>,<rsrp>,<rsrq>,...
|
||||
return b'+QENG: "servingcell","NOCONN","LTE","FDD",460,00,1A2B3C,123,2500,3,"5M","5M",1234,-105,-15,-70,10,20\r\nOK\r\n'
|
||||
|
||||
def _handle_qiact(self, cmd: bytes) -> bytes:
|
||||
self.state["pdp_active"] = True
|
||||
return b'OK\r\n'
|
||||
|
||||
def _handle_qiopen(self, cmd: bytes) -> bytes:
|
||||
parts = cmd.split(b',')
|
||||
connect_id = int(parts[1])
|
||||
self.state["sockets"][connect_id] = "OPEN"
|
||||
self._rx_buffer += f'+QIOPEN: {connect_id},0\r\n'.encode()
|
||||
return b'OK\r\n'
|
||||
|
||||
def _handle_qiclose(self, cmd: bytes) -> bytes:
|
||||
return b'OK\r\n'
|
||||
|
||||
def _handle_qisendex(self, cmd: bytes) -> bytes:
|
||||
return b'SEND OK\r\n'
|
||||
|
||||
def _handle_qird(self, cmd: bytes) -> bytes:
|
||||
data = b"Hello from Mock!"
|
||||
return f'+QIRD: {len(data)}\r\n'.encode() + data + b'\r\nOK\r\n'
|
||||
|
||||
def _handle_crsm(self, cmd: bytes) -> bytes:
|
||||
if b'28539' in cmd:
|
||||
return b'+CRSM: 144,0,"64F01064F020FFFFFFFFFFFF"\r\nOK\r\n'
|
||||
return b'+CRSM: 144,0,""\r\nOK\r\n'
|
||||
|
||||
def _handle_qfupl(self, cmd: bytes) -> bytes:
|
||||
self._raw_mode = True
|
||||
return b'CONNECT\r\n'
|
||||
|
||||
def _handle_qfdel(self, cmd: bytes) -> bytes:
|
||||
return b'OK\r\n'
|
||||
|
||||
def _handle_qicsgp(self, cmd: bytes) -> bytes:
|
||||
return b'OK\r\n'
|
||||
|
||||
def add_handler(self, command_prefix: bytes, handler: Callable[[bytes], bytes]):
|
||||
self._handlers[command_prefix] = handler
|
||||
|
||||
def close(self):
|
||||
self.is_open = False
|
||||
|
||||
def write(self, data: bytes):
|
||||
if not self.is_open: raise Exception("Port is closed")
|
||||
if self._raw_mode:
|
||||
self._raw_mode = False
|
||||
self._rx_buffer += b'OK\r\n'
|
||||
return
|
||||
self._tx_buffer += data
|
||||
while b'\r' in self._tx_buffer:
|
||||
cmd_full, rest = self._tx_buffer.split(b'\r', 1)
|
||||
self._tx_buffer = rest
|
||||
cmd = cmd_full.strip()
|
||||
if not cmd: continue
|
||||
response = self._process_command(cmd)
|
||||
if response:
|
||||
self._rx_buffer += response
|
||||
|
||||
def _process_command(self, cmd: bytes) -> bytes:
|
||||
if cmd in self._query_responses:
|
||||
return self._query_responses[cmd]
|
||||
for prefix, handler in self._handlers.items():
|
||||
if cmd.startswith(prefix):
|
||||
return handler(cmd)
|
||||
return b'ERROR\r\n'
|
||||
|
||||
def read(self, size: int = 1) -> bytes:
|
||||
if not self.is_open: return b""
|
||||
if size > len(self._rx_buffer):
|
||||
data = self._rx_buffer
|
||||
self._rx_buffer = b""
|
||||
return data
|
||||
data = self._rx_buffer[:size]
|
||||
self._rx_buffer = self._rx_buffer[size:]
|
||||
return data
|
||||
|
||||
def readline(self) -> bytes:
|
||||
if not self.is_open: return b""
|
||||
if b'\n' in self._rx_buffer:
|
||||
line, rest = self._rx_buffer.split(b'\n', 1)
|
||||
self._rx_buffer = rest
|
||||
return line + b'\n'
|
||||
|
||||
# If we have data but no newline, returns empty to simulate partial read?
|
||||
# Standard readline blocks. Here we just return empty if incomplete.
|
||||
if not self._rx_buffer:
|
||||
time.sleep(0.001) # Reduced sleep for faster tests
|
||||
return b""
|
||||
|
||||
@property
|
||||
def in_waiting(self):
|
||||
return len(self._rx_buffer)
|
||||
136
m2m/serial/serial_port.py
Normal file
136
m2m/serial/serial_port.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
import time
|
||||
import threading
|
||||
from typing import Optional, Union
|
||||
|
||||
import serial
|
||||
|
||||
from m2m.serial.urc_handler import UrcHandler
|
||||
from m2m.serial.mock_serial import MockSerial
|
||||
from m2m.exceptions import SerialError
|
||||
|
||||
logger = logging.getLogger('m2m.serial')
|
||||
|
||||
class SerialPort(UrcHandler):
|
||||
"""
|
||||
Serial port class tailored for AT-command communication.
|
||||
Thread-safe using RLock.
|
||||
"""
|
||||
|
||||
def __init__(self, port: str, baudrate: int = 9600, timeout: float = 1.0, delimiter: bytes = b'\r', **kwargs):
|
||||
super().__init__()
|
||||
self._delimiter = delimiter
|
||||
self._port_name = port
|
||||
self.lock = threading.RLock()
|
||||
|
||||
try:
|
||||
if port.upper() == 'MOCK':
|
||||
self._serial = MockSerial(port=port, baudrate=baudrate, timeout=timeout, **kwargs)
|
||||
else:
|
||||
self._serial = serial.serial_for_url(url=port, baudrate=baudrate, timeout=timeout, **kwargs)
|
||||
if port.startswith(('socket://', 'rfc2217://')):
|
||||
time.sleep(0.5) # Settling time for network sockets
|
||||
except (serial.SerialException, ValueError) as e:
|
||||
logger.error(f"Failed to open port {port}: {e}")
|
||||
raise SerialError(f"Could not open {port}") from e
|
||||
|
||||
logger.debug(f"Opened serial port: {port} @ {baudrate}")
|
||||
|
||||
def __enter__(self): return self
|
||||
def __exit__(self, *args): self.close()
|
||||
|
||||
def close(self):
|
||||
"""Closes the serial port."""
|
||||
if self._serial and self._serial.is_open:
|
||||
self._serial.close()
|
||||
logger.debug(f"Closed serial port: {self._port_name}")
|
||||
|
||||
def send_cmd(self, cmd: Union[bytes, str]) -> None:
|
||||
"""Sends a command appended with the delimiter."""
|
||||
if isinstance(cmd, str):
|
||||
cmd = cmd.encode('ascii')
|
||||
full_cmd = cmd + self._delimiter
|
||||
with self.lock:
|
||||
try:
|
||||
logger.debug(f"TX: {full_cmd!r}")
|
||||
self._serial.write(full_cmd)
|
||||
except (serial.SerialException, ConnectionError, OSError) as e:
|
||||
raise SerialError(f"Write failed: {e}") from e
|
||||
|
||||
def read_until(self, terminator: bytes = b'OK', timeout: float = 5.0) -> bytes:
|
||||
"""Reads until the terminator is found or timeout occurs."""
|
||||
buffer = bytearray()
|
||||
end_time = time.time() + timeout
|
||||
|
||||
with self.lock:
|
||||
while time.time() < end_time:
|
||||
# We read whatever is available to catch partial lines or prompts
|
||||
try:
|
||||
in_waiting = self._serial.in_waiting
|
||||
except (AttributeError, TypeError):
|
||||
in_waiting = 0
|
||||
|
||||
if isinstance(in_waiting, int) and in_waiting > 0:
|
||||
data = self._serial.read(in_waiting)
|
||||
if data:
|
||||
buffer.extend(data)
|
||||
# Process URCs inside the buffer
|
||||
for line in bytes(buffer).splitlines(keepends=True):
|
||||
if line.endswith((b'\r', b'\n')):
|
||||
self.check_urc(line.strip())
|
||||
if terminator in buffer: break
|
||||
else:
|
||||
time.sleep(0.01)
|
||||
|
||||
res = bytes(buffer)
|
||||
if res: logger.debug(f"RX: {res!r}")
|
||||
return res
|
||||
|
||||
def read_until_notify(self, notification: bytes, timeout: float = 5.0) -> Optional[bytes]:
|
||||
"""Waits specifically for a URC notification."""
|
||||
end_time = time.time() + timeout
|
||||
while time.time() < end_time:
|
||||
self._read_line() # Pumping the reader
|
||||
if val := self.pop_urc_value(notification):
|
||||
return val
|
||||
time.sleep(0.01)
|
||||
return None
|
||||
|
||||
def read_any(self, timeout: float = 5.0) -> bytes:
|
||||
"""Reads anything available within the timeout."""
|
||||
buffer = bytearray()
|
||||
end_time = time.time() + timeout
|
||||
with self.lock:
|
||||
while time.time() < end_time:
|
||||
try:
|
||||
in_waiting = self._serial.in_waiting
|
||||
except (AttributeError, TypeError):
|
||||
in_waiting = 0
|
||||
|
||||
if isinstance(in_waiting, int) and in_waiting > 0:
|
||||
buffer.extend(self._serial.read(in_waiting))
|
||||
else:
|
||||
if timeout < 0.2: break
|
||||
time.sleep(0.01)
|
||||
return bytes(buffer)
|
||||
|
||||
def _read_line(self) -> bytes:
|
||||
"""Internal method to read a single line and process URCs."""
|
||||
try:
|
||||
try:
|
||||
in_waiting = self._serial.in_waiting
|
||||
except (AttributeError, TypeError):
|
||||
in_waiting = 0
|
||||
|
||||
line = self._serial.readline()
|
||||
if line:
|
||||
logger.debug(f"RX Line: {line!r}")
|
||||
if self.check_urc(line.strip()): return b''
|
||||
elif self._port_name.startswith(('socket://', 'rfc2217://')) and (isinstance(in_waiting, int) and in_waiting == 0):
|
||||
time.sleep(0.01)
|
||||
return line
|
||||
except (serial.SerialException, ConnectionError, OSError) as e:
|
||||
raise SerialError(f"Read failed: {e}") from e
|
||||
91
m2m/serial/urc_handler.py
Normal file
91
m2m/serial/urc_handler.py
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Callable, Optional, Union, Any
|
||||
|
||||
logger = logging.getLogger('m2m.urc')
|
||||
|
||||
class UrcHandler:
|
||||
"""
|
||||
Manages Unsolicited Result Codes (URC).
|
||||
Registered URCs are tracked in an internal registry with optional callbacks.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
logger.debug("Initialized URC registry")
|
||||
# Registry structure: { prefix: {'values': [], 'callbacks': [], 'store': bool} }
|
||||
self.__urc_registry: Dict[bytes, Dict[str, Any]] = {}
|
||||
|
||||
def register_urc(self, urc: Union[bytes, str], store_values: bool = True,
|
||||
callbacks: Optional[Union[Callable[[bytes], None], List[Callable[[bytes], None]]]] = None) -> None:
|
||||
"""Registers a URC prefix to the handler."""
|
||||
if isinstance(urc, str):
|
||||
urc = urc.encode('ascii')
|
||||
|
||||
if callbacks is None:
|
||||
cb_list = []
|
||||
elif isinstance(callbacks, list):
|
||||
cb_list = callbacks
|
||||
else:
|
||||
cb_list = [callbacks]
|
||||
|
||||
if urc in self.__urc_registry:
|
||||
logger.error(f"Duplicate URC: {urc!r}")
|
||||
raise KeyError(f"URC {urc!r} already registered.")
|
||||
|
||||
self.__urc_registry[urc] = {
|
||||
'values': [],
|
||||
'callbacks': cb_list,
|
||||
'store': store_values
|
||||
}
|
||||
logger.debug(f"Registered URC: {urc!r}")
|
||||
|
||||
def unregister_urc(self, urc: bytes) -> None:
|
||||
"""Removes the URC from the registry."""
|
||||
if urc in self.__urc_registry:
|
||||
del self.__urc_registry[urc]
|
||||
else:
|
||||
logger.warning(f"Attempted to unregister unknown URC: {urc!r}")
|
||||
|
||||
def pop_urc_value(self, urc: bytes) -> Optional[bytes]:
|
||||
"""Retrieves and removes the oldest stored value for a URC."""
|
||||
if item := self.__urc_registry.get(urc):
|
||||
values = item['values']
|
||||
return values.pop(0) if values else None
|
||||
return None
|
||||
|
||||
def get_urc_values(self, urc: bytes) -> List[bytes]:
|
||||
"""Get all stored values for a URC."""
|
||||
return self.__urc_registry.get(urc, {}).get('values', [])
|
||||
|
||||
def clear_urc_values(self, urc: bytes) -> None:
|
||||
"""Clears stored values for a URC."""
|
||||
if item := self.__urc_registry.get(urc):
|
||||
item['values'].clear()
|
||||
|
||||
def check_urc(self, msg: bytes) -> bool:
|
||||
"""Checks if msg starts with any registered URC and fires callbacks."""
|
||||
# We still iterate because prefixes can be varying lengths
|
||||
# and we don't have a fixed separator for all possible URCs.
|
||||
for urc, entry in self.__urc_registry.items():
|
||||
if msg.startswith(urc):
|
||||
payload = msg[len(urc):].strip()
|
||||
logger.debug(f"URC Match: {urc!r} -> {payload!r}")
|
||||
|
||||
if entry['store']:
|
||||
entry['values'].append(payload)
|
||||
|
||||
for cb in entry['callbacks']:
|
||||
try: cb(payload)
|
||||
except Exception as e:
|
||||
logger.error(f"URC Callback Error ({urc!r}): {e}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def add_callback(self, urc: bytes, callback: Callable[[bytes], None]) -> None:
|
||||
"""Adds a callback to an existing URC registration."""
|
||||
if item := self.__urc_registry.get(urc):
|
||||
item['callbacks'].append(callback)
|
||||
else:
|
||||
raise KeyError(f"URC {urc!r} not registered.")
|
||||
94
m2m/utils.py
Normal file
94
m2m/utils.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
"""
|
||||
Utilities for data encoding/decoding (PSM timers, SIM BCD, PLMN lists).
|
||||
"""
|
||||
|
||||
from typing import List, Union, Optional
|
||||
|
||||
def encode_psm_timer(seconds: int, is_t3324: bool = False) -> str:
|
||||
"""Encodes seconds into 3GPP binary string."""
|
||||
if is_t3324:
|
||||
if seconds <= 2 * 31: return f"000{seconds // 2:05b}"
|
||||
if seconds <= 60 * 31: return f"001{seconds // 60:05b}"
|
||||
if seconds <= 360 * 31: return f"010{seconds // 360:05b}"
|
||||
return "11100000"
|
||||
else:
|
||||
if seconds <= 30 * 31: return f"100{seconds // 30:05b}"
|
||||
if seconds <= 60 * 31: return f"101{seconds // 60:05b}"
|
||||
if seconds <= 3600 * 31: return f"001{seconds // 3600:05b}"
|
||||
return f"010{min(31, seconds // 36000):05b}"
|
||||
|
||||
def decode_plmn(bcd_data: bytes) -> Optional[str]:
|
||||
"""Decodes a 3-byte BCD PLMN (MCC/MNC) from SIM storage."""
|
||||
if len(bcd_data) < 3 or bcd_data == b'\xff\xff\xff':
|
||||
return None
|
||||
|
||||
mcc = f"{(bcd_data[0] & 0x0F)}{(bcd_data[0] >> 4)}{(bcd_data[1] & 0x0F)}"
|
||||
mnc_digit3 = (bcd_data[1] >> 4)
|
||||
mnc = f"{(bcd_data[2] & 0x0F)}{(bcd_data[2] >> 4)}"
|
||||
if mnc_digit3 != 0xF:
|
||||
mnc = f"{(bcd_data[2] & 0x0F)}{(bcd_data[2] >> 4)}{mnc_digit3}"
|
||||
return f"{mcc}-{mnc}"
|
||||
|
||||
def decode_act(act_bytes: bytes) -> str:
|
||||
"""Decodes Access Technology bitmask (2 bytes)."""
|
||||
if len(act_bytes) < 2: return "Unknown"
|
||||
techs = []
|
||||
b1, b2 = act_bytes[0], act_bytes[1]
|
||||
if b1 & 0x80: techs.append("UTRAN")
|
||||
if b1 & 0x40: techs.append("E-UTRAN")
|
||||
if b1 & 0x20: techs.append("NG-RAN")
|
||||
if b2 & 0x80: techs.append("GSM")
|
||||
if b2 & 0x40: techs.append("cdma2000 HRPD")
|
||||
if b2 & 0x20: techs.append("cdma2000 1xRTT")
|
||||
return "/".join(techs) if techs else "None"
|
||||
|
||||
def decode_plmn_list(data: bytes, has_act: bool = False) -> List[str]:
|
||||
"""Decodes a list of PLMNs, optionally with Access Technology."""
|
||||
stride = 5 if has_act else 3
|
||||
results = []
|
||||
for i in range(0, len(data), stride):
|
||||
chunk = data[i:i+stride]
|
||||
plmn = decode_plmn(chunk[:3])
|
||||
if plmn:
|
||||
if has_act:
|
||||
act = decode_act(chunk[3:5])
|
||||
results.append(f"{plmn} [{act}]")
|
||||
else:
|
||||
results.append(plmn)
|
||||
return results
|
||||
|
||||
def decode_iccid(bcd_data: bytes) -> str:
|
||||
"""Decodes raw ICCID bytes (swapped nibbles)."""
|
||||
return "".join([f"{(b & 0x0F)}{(b >> 4):X}" for b in bcd_data]).replace("F", "")
|
||||
|
||||
def bands_to_hex_mask(bands: List[int]) -> str:
|
||||
"""Converts a list of band numbers into a hex bitmask string."""
|
||||
mask = 0
|
||||
for b in bands:
|
||||
if b > 0:
|
||||
mask |= (1 << (b - 1))
|
||||
return f"{mask:X}"
|
||||
|
||||
def hex_mask_to_bands(hex_mask: str) -> List[int]:
|
||||
"""Converts a hex bitmask string back into a list of band numbers."""
|
||||
try:
|
||||
mask = int(hex_mask, 16)
|
||||
except (ValueError, TypeError):
|
||||
return []
|
||||
|
||||
bands = []
|
||||
for i in range(128): # Support up to 128 bands
|
||||
if (mask >> i) & 1:
|
||||
bands.append(i + 1)
|
||||
return bands
|
||||
|
||||
def fmt_val(val: Union[str, List, None]) -> str:
|
||||
"""Helper to format values for display in reports and shells."""
|
||||
if val is None or val == "" or val == []:
|
||||
return "None (Not set on SIM)"
|
||||
if isinstance(val, list):
|
||||
return ", ".join(val)
|
||||
return str(val)
|
||||
36
setup.py
Normal file
36
setup.py
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
from setuptools import setup, find_packages
|
||||
|
||||
with open("README.md", "r", encoding="utf-8") as fh:
|
||||
long_description = fh.read()
|
||||
|
||||
setup(
|
||||
name='m2m-python',
|
||||
version='0.2.0',
|
||||
description='Python library for controlling IoT modules (Quectel BG96, BG95, BC66, HiSilicon)',
|
||||
long_description=long_description,
|
||||
long_description_content_type="text/markdown",
|
||||
author='Yassine Amraue',
|
||||
url='https://github.com/yassine/m2m-python',
|
||||
packages=find_packages(exclude=["tests*", "examples*"]),
|
||||
classifiers=[
|
||||
"Programming Language :: Python :: 3",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Topic :: Software Development :: Embedded Systems",
|
||||
"Topic :: System :: Hardware :: Hardware Drivers",
|
||||
"Intended Audience :: Developers",
|
||||
],
|
||||
python_requires='>=3.6',
|
||||
install_requires=[
|
||||
'pyserial>=3.5',
|
||||
'prompt_toolkit>=3.0',
|
||||
],
|
||||
extras_require={
|
||||
'dev': ['pytest', 'mock'],
|
||||
},
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'm2m-shell=m2m.cli:main',
|
||||
],
|
||||
},
|
||||
)
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import pytest
|
||||
from m2m.nbiot import factory
|
||||
|
||||
@pytest.fixture
|
||||
def bg95_mock():
|
||||
"""Returns a BG95 module instance connected to the MockSerial engine."""
|
||||
module = factory('BG95', 'MOCK')
|
||||
with module:
|
||||
yield module
|
||||
22
tests/test_factory.py
Normal file
22
tests/test_factory.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import pytest
|
||||
from unittest.mock import MagicMock, patch
|
||||
from m2m.nbiot import factory
|
||||
from m2m.nbiot.module_bg96 import ModuleBG96
|
||||
|
||||
@patch('m2m.serial.serial_port.serial.Serial')
|
||||
def test_factory_creates_bg96(mock_serial_cls):
|
||||
"""Verify that the factory correctly instantiates a BG96 module with a mocked serial port."""
|
||||
# Setup mock to avoid hardware dependency
|
||||
mock_inst = MagicMock()
|
||||
mock_serial_cls.return_value = mock_inst
|
||||
|
||||
module = factory('BG96', '/dev/ttyUSB0')
|
||||
|
||||
assert isinstance(module, ModuleBG96)
|
||||
# Corrected attribute access path
|
||||
assert module.s_port._port_name == '/dev/ttyUSB0'
|
||||
|
||||
def test_factory_invalid_chipset():
|
||||
"""Verify that an invalid chipset raises a ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
factory('INVALID_CHIPSET', '/dev/ttyUSB0')
|
||||
40
tests/test_module_base.py
Normal file
40
tests/test_module_base.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import pytest
|
||||
|
||||
def test_initial_state(bg95_mock):
|
||||
"""Verify initial state of the mock module."""
|
||||
assert bg95_mock.check_state() is True
|
||||
assert bg95_mock.read_firmware_version() == "Mock_Modem_v2.0"
|
||||
assert bg95_mock.read_imei() == "864215030000000"
|
||||
|
||||
def test_radio_control(bg95_mock):
|
||||
"""Test enabling/disabling radio functionality."""
|
||||
# Turn Off
|
||||
assert bg95_mock.radio_off() is True
|
||||
assert bg95_mock.is_radio_on() is False
|
||||
|
||||
# Turn On
|
||||
assert bg95_mock.radio_on() is True
|
||||
assert bg95_mock.is_radio_on() is True
|
||||
|
||||
def test_network_attachment(bg95_mock):
|
||||
"""Test network attach/detach sequence."""
|
||||
bg95_mock.radio_on()
|
||||
|
||||
# Detach
|
||||
assert bg95_mock.detach_network() is True
|
||||
assert bg95_mock.is_attached() is False
|
||||
|
||||
# Attach
|
||||
assert bg95_mock.attach_network() is True
|
||||
assert bg95_mock.is_attached() is True
|
||||
|
||||
def test_sim_unlock(bg95_mock):
|
||||
"""Test SIM PIN unlocking."""
|
||||
# Mock starts in READY or some state, unlock should return True
|
||||
assert bg95_mock.unlock_sim("1234") is True
|
||||
|
||||
def test_signal_quality(bg95_mock):
|
||||
"""Test reading signal quality."""
|
||||
rssi, ber = bg95_mock.get_signal_quality()
|
||||
assert rssi == 25
|
||||
assert ber == 99
|
||||
50
tests/test_quectel_module.py
Normal file
50
tests/test_quectel_module.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import pytest
|
||||
|
||||
def test_pdp_context(bg95_mock):
|
||||
"""Test PDP context configuration and activation."""
|
||||
# Configure
|
||||
assert bg95_mock.configure_pdp_context(1, "IP", "iot.apn") is True
|
||||
|
||||
# Activate
|
||||
assert bg95_mock.activate_pdp_context(1) is True
|
||||
|
||||
# Check IP
|
||||
ip = bg95_mock.get_ip_address(1)
|
||||
assert ip == "10.0.0.5"
|
||||
|
||||
def test_socket_lifecycle(bg95_mock):
|
||||
"""Test opening, sending, and closing a socket."""
|
||||
# Open
|
||||
# Note: open_socket returns -1 on failure, or 0/result on success/URC
|
||||
# In our mock, it returns 0 via URC simulation.
|
||||
# The synchronous open_socket method waits for it.
|
||||
res = bg95_mock.open_socket(1, 0, "TCP", "8.8.8.8", 80)
|
||||
assert res == 0
|
||||
|
||||
# Send
|
||||
assert bg95_mock.send_data_hex(0, b"Hello") is True
|
||||
|
||||
# Receive
|
||||
data = bg95_mock.receive_data(0, 100)
|
||||
assert data == b"Hello from Mock!"
|
||||
|
||||
# Close
|
||||
assert bg95_mock.close_socket(0) is True
|
||||
|
||||
def test_file_operations(bg95_mock):
|
||||
"""Test file upload and delete."""
|
||||
assert bg95_mock.upload_file("test.txt", b"content") is True
|
||||
assert bg95_mock.delete_file("test.txt") is True
|
||||
|
||||
def test_engineering_info(bg95_mock):
|
||||
"""Test retrieving serving cell info."""
|
||||
info = bg95_mock.get_serving_cell_info()
|
||||
assert info['tech'] == "LTE"
|
||||
assert info['cellid'] == "1A2B3C"
|
||||
assert info['rsrp'] == -105
|
||||
|
||||
def test_sim_diagnostics(bg95_mock):
|
||||
"""Test reading forbidden PLMNs."""
|
||||
fplmns = bg95_mock.read_forbidden_plmns()
|
||||
# Mock returns "64F010..." -> 460-01
|
||||
assert "460-01" in fplmns
|
||||
46
tests/test_serial_port.py
Normal file
46
tests/test_serial_port.py
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
import serial
|
||||
from m2m.serial.serial_port import SerialPort
|
||||
from m2m.exceptions import SerialError
|
||||
|
||||
def test_serial_port_standard():
|
||||
with patch('serial.serial_for_url') as mock_for_url:
|
||||
mock_serial = MagicMock()
|
||||
mock_for_url.return_value = mock_serial
|
||||
|
||||
with SerialPort(port='/dev/ttyUSB0', baudrate=115200) as sp:
|
||||
mock_for_url.assert_called_once_with(
|
||||
url='/dev/ttyUSB0',
|
||||
baudrate=115200,
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
def test_serial_port_tcp_url():
|
||||
with patch('serial.serial_for_url') as mock_for_url:
|
||||
mock_serial = MagicMock()
|
||||
mock_for_url.return_value = mock_serial
|
||||
|
||||
url = 'socket://1.2.3.4:5678'
|
||||
with SerialPort(port=url, baudrate=9600) as sp:
|
||||
mock_for_url.assert_called_once_with(
|
||||
url=url,
|
||||
baudrate=9600,
|
||||
timeout=1.0
|
||||
)
|
||||
|
||||
def test_serial_port_invalid_url():
|
||||
with patch('serial.serial_for_url') as mock_for_url:
|
||||
mock_for_url.side_effect = ValueError("invalid URL")
|
||||
|
||||
with pytest.raises(SerialError) as excinfo:
|
||||
SerialPort(port='invalid://port')
|
||||
assert "Could not open invalid://port" in str(excinfo.value)
|
||||
|
||||
def test_serial_port_mock():
|
||||
# Verify 'MOCK' still uses MockSerial and NOT serial_for_url
|
||||
with patch('m2m.serial.serial_port.MockSerial') as mock_mock:
|
||||
with patch('serial.serial_for_url') as mock_for_url:
|
||||
sp = SerialPort(port='MOCK')
|
||||
mock_mock.assert_called_once()
|
||||
mock_for_url.assert_not_called()
|
||||
Loading…
Add table
Reference in a new issue