net-sim: network-condition simulation in the Phone tab (v2.5.0)
Server (pysim_simple_server/netsim.py + POST /api/net-sim): - pure builders for the observed phone write vocabulary (UICC_NAA.md §13): EPSNSC (A0 TLV with KSI/KASME/counts/algo, padded to the card's FCP record size; invalidate-wipe and invalidate-keep-key), LOCI/PSLOCI/EPSLOCI real and dummy forms (PLMN kept, LAC FFFE, status 01/02), Kc/KcGPRS (9-byte USIM and 33-byte GSM forms, 07 invalidates), EF.SMSstatus counter bump (read-modify-write), CBMI/CBMIR, the Location status event (9B 01 <st>, with the optional 13 07 location info for normal service) and the AUTHENTICATE APDU (TS 31.102 7.1.2.1) with DB/DC response parsing - scenario runner executes the recipes under _CARD_LOCK, picks the first existing candidate path (ADF.USIM vs DF.GSM/DF.TELECOM), reads FCP/current records where the format is card-specific, skips the event unless the card subscribed to Location status, honours the scenario toggles and returns a per-step log; only D6/DC, ENVELOPE and AUTHENTICATE are ever sent - GET /api/mcc-mnc serves the optional worldwide operator list (--mcc-mnc-list, default <workspace>/samples/mcc-mnc-list.json): ?q= search and ?random=1&exclude= for roaming; the list stays out of the repo PWA: - Phone pill gains a 'Network simulation' fieldset: one button per scenario (cold boot, EPS/2G attach, service lost, limited service, roaming denied, churn, SMS received, CB reconfig, AUTHENTICATE), a collapsed Parameters block (operator search + random roaming, LAC/Cell ID/TAC/RAC, optional identities, toggles, churn count/delay) and a step log with SWs - i18n EN/RU, help/README/api.md/AGENTS.md updated; version 2.5.0, SW cache simple-v193 Tests: tests/test_netsim.py (19), frontend/tests/netsim.test.js (3) and html guards - 423 frontend / 280 Python, all passing
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
@@ -6,12 +7,14 @@ import threading
|
||||
import traceback
|
||||
import re
|
||||
import codecs
|
||||
from urllib.parse import unquote_plus
|
||||
from datetime import datetime, timedelta
|
||||
from http.server import HTTPServer, BaseHTTPRequestHandler
|
||||
from io import StringIO
|
||||
from pySim.transport import ApduTracer, ProactiveHandler
|
||||
from pySim.cards import UiccCardBase
|
||||
from pysim_simple_server import httpota
|
||||
from pysim_simple_server import netsim
|
||||
from pysim_simple_server import scp81
|
||||
from smartcard.CardMonitoring import CardMonitor, CardObserver
|
||||
|
||||
@@ -21,7 +24,7 @@ from osmocom.construct import GsmOrUcs2Adapter
|
||||
from osmocom.tlv import BER_TLV_IE
|
||||
|
||||
|
||||
VERSION = '2.4.0'
|
||||
VERSION = '2.5.0'
|
||||
|
||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||
|
||||
@@ -435,6 +438,59 @@ def _read_iccid(app):
|
||||
cleanup()
|
||||
|
||||
|
||||
_MCC_MNC_CACHE = {'path': None, 'data': None}
|
||||
|
||||
|
||||
def _mcc_mnc_load(path):
|
||||
"""Load the optional MCC/MNC operator list (JSON) once; None if absent."""
|
||||
if not path:
|
||||
return None
|
||||
if _MCC_MNC_CACHE['path'] == path:
|
||||
return _MCC_MNC_CACHE['data']
|
||||
data = None
|
||||
try:
|
||||
with open(path, 'r', encoding='utf-8') as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, list):
|
||||
data = loaded
|
||||
except Exception:
|
||||
data = None
|
||||
_MCC_MNC_CACHE['path'] = path
|
||||
_MCC_MNC_CACHE['data'] = data
|
||||
return data
|
||||
|
||||
|
||||
def _mcc_mnc_search(data, query, limit=50):
|
||||
"""Compact substring search over country/brand/operator/MCC/MNC."""
|
||||
q = (query or '').strip().lower()
|
||||
if not q:
|
||||
return []
|
||||
out = []
|
||||
for e in data or []:
|
||||
hay = ' '.join(str(e.get(k) or '') for k in
|
||||
('countryName', 'countryCode', 'mcc', 'mnc', 'brand', 'operator')).lower()
|
||||
if q not in hay:
|
||||
continue
|
||||
out.append({k: e.get(k) for k in
|
||||
('countryName', 'countryCode', 'mcc', 'mnc', 'brand', 'operator', 'status')})
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _mcc_mnc_random(data, exclude=None):
|
||||
"""One random operator entry (optionally excluding 'mccmnc' digits)."""
|
||||
pool = [e for e in (data or [])
|
||||
if (str(e.get('mcc') or '') + str(e.get('mnc') or '')) != (exclude or '')]
|
||||
if not pool:
|
||||
pool = data or []
|
||||
if not pool:
|
||||
return None
|
||||
e = random.choice(pool)
|
||||
return {k: e.get(k) for k in
|
||||
('countryName', 'countryCode', 'mcc', 'mnc', 'brand', 'operator', 'status')}
|
||||
|
||||
|
||||
def _parse_tree_output(output):
|
||||
lines = (output or '').split('\n')
|
||||
children = []
|
||||
@@ -3137,6 +3193,32 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
self._log_req()
|
||||
self._send_json({'version': VERSION})
|
||||
self._log_resp({'version': VERSION})
|
||||
elif self.path.startswith('/api/mcc-mnc'):
|
||||
self._log_req()
|
||||
q = ''
|
||||
random_pick = False
|
||||
exclude = ''
|
||||
if '?' in self.path:
|
||||
for kv in self.path.split('?', 1)[1].split('&'):
|
||||
if kv.startswith('q='):
|
||||
q = unquote_plus(kv[2:])
|
||||
elif kv.startswith('random='):
|
||||
random_pick = kv.split('=', 1)[1] not in ('0', 'false', '')
|
||||
elif kv.startswith('exclude='):
|
||||
exclude = unquote_plus(kv[8:])
|
||||
path = getattr(self.server, 'mcc_mnc_path', None)
|
||||
data = _mcc_mnc_load(path)
|
||||
if data is None:
|
||||
resp = {'available': False}
|
||||
elif random_pick:
|
||||
resp = {'available': True, 'count': len(data),
|
||||
'result': _mcc_mnc_random(data, exclude)}
|
||||
else:
|
||||
results = _mcc_mnc_search(data, q)
|
||||
resp = {'available': True, 'count': len(data), 'results': results}
|
||||
self._send_json(resp)
|
||||
self._log_resp({'available': resp['available'],
|
||||
'results': len(resp.get('results', []))})
|
||||
elif self.path == '/api/status':
|
||||
self._log_req()
|
||||
app = self.server.app
|
||||
@@ -3374,6 +3456,36 @@ class PysimHandler(BaseHTTPRequestHandler):
|
||||
_handle_card_disconnect()
|
||||
self._send_json({'sw': None, 'error': 'card disconnected'})
|
||||
self._log_resp({'sw': None, 'error': 'card disconnected'})
|
||||
elif self.path == '/api/net-sim':
|
||||
app = self.server.app
|
||||
rs = app.rs if app else None
|
||||
if not app or not rs:
|
||||
self._send_json({'error': _err('no_card_state', lang)}, 503)
|
||||
self._log_resp({'error': _err('no_card_state', lang)})
|
||||
return
|
||||
body = self._read_body()
|
||||
self._log_req(body)
|
||||
scenario = body.get('scenario')
|
||||
if not scenario:
|
||||
self._send_json({'error': 'scenario is required'}, 400)
|
||||
self._log_resp({'error': 'scenario is required'})
|
||||
return
|
||||
try:
|
||||
result = netsim.run_scenario(
|
||||
sys.modules[__name__], app, scenario, body,
|
||||
event_list=getattr(self.server, 'event_list', None) or [])
|
||||
self._send_json(result)
|
||||
self._log_resp({'scenario': scenario,
|
||||
'success': result.get('success'),
|
||||
'steps': len(result.get('steps', []))})
|
||||
except ValueError as e:
|
||||
self._send_json({'error': str(e)}, 400)
|
||||
self._log_resp({'error': str(e)})
|
||||
except Exception as e:
|
||||
sys.stderr.write('Net-sim error: %s\n' % e)
|
||||
_handle_card_disconnect()
|
||||
self._send_json({'error': 'simulation failed: %s' % e}, 500)
|
||||
self._log_resp({'error': str(e)})
|
||||
elif self.path == '/api/rescue':
|
||||
scc = self.server.scc
|
||||
if not scc:
|
||||
|
||||
Reference in New Issue
Block a user