net: network-state monitor and permanent-rejection FPLMN (v2.7.0)

server:
- netsim: LOCI/PSLOCI dummy builders take a status; the EPSLOCI dummy is
  wiped to 0B F6 + FF per UICC_NAA.md C3; roaming_denied now emulates the
  permanent 'PLMN not allowed' rejection (C3a): status 010, EPSNSC dropped,
  EF.FPLMN append with TS 31.102 4.2.16 shift semantics and a home-PLMN
  guard (HPLMNwAcT/EHPLMN, IMSI fallback), optional 'Rejection: write FPLMN'
  toggle; write steps carry the logical key; SCENARIO_SERVICE map;
  insert_fplmn/fplmn_entries/parse_imsi helpers
- netstate.py: cached per-session monitor state for the 12 network EFs,
  step-based patch, simulated service state, derived location with country/
  operator (optional MCC/MNC list) and roaming class
- server: monitor read at equip right after a readable ICCID (skipped
  otherwise), cleared on card removal; GET /api/net-state and POST
  /api/net-state-refresh; net-sim patches the cache from the written bytes
  and re-reads EF.IMSI; Location-status events set the service state and
  re-read EF.IMSI (multi-IMSI applets)

frontend:
- Phone tab: 'Network state' panel next to Network simulation with the
  simulated service badge (Undefined until simulated; normal/limited/no
  service + rejection marker), location/roaming line, compact per-file
  summaries with full-decode tooltips and a Refresh button; no card polling
- EF decoders: EF.FPLMN (FFFFFF gaps are not terminators) and EF.EHPLMN
- i18n EN/RU, help updated; SW cache simple-v203

tests: 295 Python / 453 frontend
This commit is contained in:
2026-09-20 12:26:17 +03:00
parent 81b50199cc
commit b1539d7cd5
15 changed files with 1190 additions and 37 deletions
+160 -2
View File
@@ -15,6 +15,7 @@ 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 netstate
from pysim_simple_server import scp81
from smartcard.CardMonitoring import CardMonitor, CardObserver
@@ -24,7 +25,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE
VERSION = '2.6.2'
VERSION = '2.7.0'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -491,6 +492,105 @@ def _mcc_mnc_random(data, exclude=None):
('countryName', 'countryCode', 'mcc', 'mnc', 'brand', 'operator', 'status')}
def _netstate_read(server, keys=None):
"""Read the monitored network-state EFs (best effort per file).
The Network state panel caches them: the full set is read once at equip
(after a readable ICCID), on demand via /api/net-state-refresh, and only
the card-side files (EF.IMSI) after net-sim / Location-status operations.
"""
app = getattr(server, 'app', None)
rs = getattr(app, 'rs', None) if app else None
if not app or not rs:
return {}
lchan = rs.lchan[0]
out = {}
for d in netstate.FILE_DEFS:
key = d['key']
if keys and key not in keys:
continue
entry = {'name': d['name'], 'fid': d['fid'], 'present': False,
'kind': None, 'source': 'refresh', 'updated': time.time()}
for path in d['paths']:
cleanup = None
try:
_, cleanup = _select_path(lchan, path, app)
ft = _get_file_type(lchan, lchan.selected_file)
entry['path'] = path
if ft in ('linear_fixed', 'cyclic'):
records = []
n = lchan.selected_file_num_of_rec() or 1
for i in range(1, n + 1):
rv = lchan.read_record(i)
data = rv[0] if isinstance(rv, tuple) else rv
records.append({'num': i,
'data': (data or '').upper()})
entry.update({'present': True, 'kind': 'record',
'records': records})
else:
rv = lchan.read_binary()
data = rv[0] if isinstance(rv, tuple) else rv
entry.update({'present': True, 'kind': 'transparent',
'data': (data or '').upper()})
break
except Exception:
continue
finally:
if cleanup:
try:
cleanup()
except Exception:
pass
out[key] = entry
return out
def _netstate_compute(server):
state = getattr(server, 'net_state', None)
if state is None:
return None
data = _mcc_mnc_load(getattr(server, 'mcc_mnc_path', None))
return netstate.compute_network(state, data)
def _netstate_after_net_sim(server, scenario, result):
"""Update the cached Network state after a scenario run: the bytes we
wrote are known without re-reading; the card may update EF.IMSI itself."""
state = getattr(server, 'net_state', None)
if state is None:
return
netstate.apply_steps(state, (result or {}).get('steps') or [])
service = netsim.SCENARIO_SERVICE.get(scenario)
if service and (result or {}).get('success'):
netstate.set_service(state, service, 'net-sim:' + scenario)
netstate.merge_read(state, _netstate_read(server, ['imsi']), source='read')
_netstate_compute(server)
def _netstate_after_event(server, event_type, event_data):
"""Location status changes the simulated service state; the card may also
switch EF.IMSI (multi-IMSI applets) after such an event."""
state = getattr(server, 'net_state', None)
if state is None:
return
try:
ev = int(event_type)
except (TypeError, ValueError):
ev = None
if ev == netsim.EVENT_LOCATION_STATUS and event_data:
status = None
if (len(event_data) >= 3 and event_data[0] == 0x9B
and event_data[1] == 0x01):
status = event_data[2]
service = {0x00: netstate.SERVICE_NORMAL,
0x01: netstate.SERVICE_LIMITED,
0x02: netstate.SERVICE_NONE}.get(status)
if service:
netstate.set_service(state, service, 'event:location-status')
netstate.merge_read(state, _netstate_read(server, ['imsi']), source='read')
_netstate_compute(server)
def _parse_tree_output(output):
lines = (output or '').split('\n')
children = []
@@ -2350,6 +2450,7 @@ def _handle_card_disconnect():
_server_ref.event_list = None
_server_ref.sim_menu = None
_server_ref.iccid = None
_server_ref.net_state = None
_server_ref.equipping = False
_server_ref.card_session = getattr(_server_ref, 'card_session', 0) + 1
_reset_proactive_log()
@@ -2377,8 +2478,21 @@ def _apply_equipped_card(server):
server.iccid = _read_iccid(server.app)
if server.iccid:
_tlog('equip: ICCID %s' % server.iccid)
# Network state monitor: read the network-related EFs right after the
# ICCID (still before the TERMINAL PROFILE opens a CAT session). A
# card without a readable ICCID is considered unusable - give up.
try:
server.net_state = netstate.new_state()
netstate.merge_read(server.net_state, _netstate_read(server),
source='init')
netstate.set_read_time(server.net_state)
_netstate_compute(server)
except Exception as e:
server.net_state = None
_tlog('equip: network state read failed: %s' % e)
else:
_tlog('equip: ICCID not readable')
server.net_state = None
_tlog('equip: ICCID not readable - network state skipped')
_poll_enable()
sm, el = _send_terminal_profile(server.scc, server.terminal_profile)
server.sim_menu = sm
@@ -3219,6 +3333,16 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp)
self._log_resp({'available': resp['available'],
'results': len(resp.get('results', []))})
elif self.path == '/api/net-state':
self._log_req()
state = getattr(self.server, 'net_state', None)
if state is None:
self._send_json({'available': False, 'state': None})
self._log_resp({'available': False})
return
_netstate_compute(self.server)
self._send_json({'available': True, 'state': state})
self._log_resp({'available': True})
elif self.path == '/api/status':
self._log_req()
app = self.server.app
@@ -3474,6 +3598,8 @@ class PysimHandler(BaseHTTPRequestHandler):
result = netsim.run_scenario(
sys.modules[__name__], app, scenario, body,
event_list=getattr(self.server, 'event_list', None) or [])
_netstate_after_net_sim(self.server, scenario, result)
result['net_state'] = getattr(self.server, 'net_state', None)
self._send_json(result)
self._log_resp({'scenario': scenario,
'success': result.get('success'),
@@ -3486,6 +3612,30 @@ class PysimHandler(BaseHTTPRequestHandler):
_handle_card_disconnect()
self._send_json({'error': 'simulation failed: %s' % e}, 500)
self._log_resp({'error': str(e)})
elif self.path == '/api/net-state-refresh':
if not self.server.app or not getattr(self.server, 'scc', None):
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)
keys = body.get('files') if isinstance(body, dict) else None
state = getattr(self.server, 'net_state', None)
if state is None:
state = netstate.new_state()
self.server.net_state = state
try:
files = _netstate_read(self.server, keys)
except Exception as e:
self._send_json({'error': str(e)}, 500)
self._log_resp({'error': str(e)})
return
netstate.merge_read(state, files, source='refresh')
netstate.set_read_time(state)
_netstate_compute(self.server)
resp = {'available': True, 'state': state}
self._send_json(resp)
self._log_resp({'available': True, 'files': len(files or {})})
elif self.path == '/api/rescue':
scc = self.server.scc
if not scc:
@@ -3878,9 +4028,17 @@ class PysimHandler(BaseHTTPRequestHandler):
event_data = bytes.fromhex(event_data_hex) if event_data_hex else None
try:
data, sw = _send_event_download(scc, event_type, event_data)
try:
is_location = int(event_type) == netsim.EVENT_LOCATION_STATUS
except (TypeError, ValueError):
is_location = False
if is_location:
_netstate_after_event(self.server, event_type, event_data)
resp = {'sw': sw}
if data:
resp['data'] = data
if getattr(self.server, 'net_state', None) is not None:
resp['net_state'] = self.server.net_state
self._send_json(resp)
self._log_resp(resp)
except Exception as e: