b1539d7cd5
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
290 lines
9.7 KiB
Python
290 lines
9.7 KiB
Python
# coding=utf-8
|
|
"""Network-state monitor for the SIMple lab.
|
|
|
|
Keeps a cached, per-card-session view of the network-related EFs: the files
|
|
the network simulator writes plus IMSI / EHPLMN / SPDI / HPLMNwAcT / FPLMN
|
|
(trace study: ``projects/UICC_NAA.md``). It also tracks the *simulated*
|
|
service state (normal / limited / no service, ``Undefined`` until something
|
|
is simulated) and derives the current location + roaming view for the Phone
|
|
tab's "Network state" panel.
|
|
|
|
This module is pure: the server owns all card I/O (``_netstate_read`` in
|
|
``server.py``) and passes freshly read file entries in. ``mcc_mnc_data`` is
|
|
the optional MCC/MNC operator list the server loads (``--mcc-mnc-list``).
|
|
"""
|
|
|
|
import re
|
|
import time
|
|
|
|
from pysim_simple_server import netsim
|
|
|
|
SERVICE_NORMAL = 'normal'
|
|
SERVICE_LIMITED = 'limited'
|
|
SERVICE_NONE = 'none'
|
|
|
|
|
|
def _def(key, name, fid):
|
|
return {'key': key, 'name': name, 'fid': fid,
|
|
'paths': list(netsim.FILE_PATHS.get(key) or [])}
|
|
|
|
|
|
# Display order of the monitor panel.
|
|
FILE_DEFS = [
|
|
_def('imsi', 'EF.IMSI', '6F07'),
|
|
_def('ehplmn', 'EF.EHPLMN', '6FD9'),
|
|
_def('spdi', 'EF.SPDI', '6FCD'),
|
|
_def('hplmnwact', 'EF.HPLMNwAcT', '6F62'),
|
|
_def('loci', 'EF.LOCI', '6F7E'),
|
|
_def('psloci', 'EF.PSLOCI', '6F73'),
|
|
_def('epsloci', 'EF.EPSLOCI', '6FE3'),
|
|
_def('epsnsc', 'EF.EPSNSC', '6FE4'),
|
|
_def('cbmi', 'EF.CBMI', '6F45'),
|
|
_def('cbmir', 'EF.CBMIR', '6F50'),
|
|
_def('smsstatus', 'EF.SMSS', '6F43'),
|
|
_def('fplmn', 'EF.FPLMN', '6F7B'),
|
|
]
|
|
MONITORED_KEYS = [d['key'] for d in FILE_DEFS]
|
|
FILE_BY_KEY = {d['key']: d for d in FILE_DEFS}
|
|
|
|
|
|
def _norm(value):
|
|
return re.sub(r'[^0-9a-fA-F]', '', value or '').upper()
|
|
|
|
|
|
def new_state():
|
|
"""Empty monitor state (a new card session)."""
|
|
return {'files': {},
|
|
'service': {'state': None, 'source': None, 'time': None},
|
|
'network': None, 'read_at': None}
|
|
|
|
|
|
def set_service(state, service, source=None):
|
|
"""Record the simulated service state ('normal'/'limited'/'none')."""
|
|
if not state:
|
|
return
|
|
state['service'] = {'state': service, 'source': source, 'time': time.time()}
|
|
|
|
|
|
def set_read_time(state):
|
|
if state:
|
|
state['read_at'] = time.time()
|
|
|
|
|
|
def patch_file(state, key, path, data, source='write', record=None):
|
|
"""Patch one cached file from bytes we just wrote (no re-read needed)."""
|
|
if not state or key not in FILE_BY_KEY:
|
|
return
|
|
d = FILE_BY_KEY[key]
|
|
cur = (state.get('files') or {}).get(key) or {}
|
|
if record is not None or cur.get('kind') == 'record':
|
|
records = {r.get('num'): r.get('data', '')
|
|
for r in (cur.get('records') or [])}
|
|
records[int(record or 1)] = _norm(data)
|
|
body = {'kind': 'record',
|
|
'records': [{'num': n, 'data': records[n]}
|
|
for n in sorted(records)]}
|
|
else:
|
|
body = {'kind': 'transparent', 'data': _norm(data)}
|
|
entry = {'name': d['name'], 'fid': d['fid'],
|
|
'path': path or cur.get('path') or (d['paths'][0] if d['paths'] else None),
|
|
'present': True, 'source': source, 'updated': time.time()}
|
|
entry.update(body)
|
|
state.setdefault('files', {})[key] = entry
|
|
|
|
|
|
def apply_steps(state, steps):
|
|
"""Patch the cached files from a net-sim step log (writes we performed)."""
|
|
if not state:
|
|
return
|
|
for s in steps or []:
|
|
act = s.get('action')
|
|
key = s.get('key')
|
|
if act in ('update_binary', 'update_record') and key in FILE_BY_KEY:
|
|
patch_file(state, key, s.get('path'), s.get('data') or '',
|
|
source='write',
|
|
record=s.get('record') if act == 'update_record' else None)
|
|
|
|
|
|
def merge_read(state, entries, source='read'):
|
|
"""Merge freshly read file entries (server I/O) into the state."""
|
|
if not state or not entries:
|
|
return
|
|
for key, entry in entries.items():
|
|
if not entry:
|
|
continue
|
|
entry = dict(entry)
|
|
entry['source'] = source
|
|
state.setdefault('files', {})[key] = entry
|
|
|
|
|
|
# ---- decoding helpers (mirror the client-side EF decoders) ----
|
|
|
|
def parse_imsi(data_hex):
|
|
"""EF.IMSI digits or None (TS 31.102 4.2.2)."""
|
|
return netsim.parse_imsi(data_hex)
|
|
|
|
|
|
def plmn_from_hex(hex3):
|
|
"""3-byte PLMN (TS 24.008 BCD) -> {'mcc','mnc','plmn'} or None."""
|
|
h = _norm(hex3)
|
|
if len(h) != 6 or h == 'FFFFFF':
|
|
return None
|
|
b = [int(h[i:i + 2], 16) for i in (0, 2, 4)]
|
|
d = lambda v: v & 0x0F
|
|
e = lambda v: (v >> 4) & 0x0F
|
|
if d(b[0]) > 9 or e(b[0]) > 9 or d(b[1]) > 9:
|
|
return None
|
|
if d(b[2]) > 9 or e(b[2]) > 9:
|
|
return None
|
|
mcc = '%d%d%d' % (d(b[0]), e(b[0]), d(b[1]))
|
|
mnc = '%d%d' % (d(b[2]), e(b[2]))
|
|
mnc3 = e(b[1])
|
|
if mnc3 != 0x0F:
|
|
if mnc3 > 9:
|
|
return None
|
|
mnc += '%d' % mnc3
|
|
return {'mcc': mcc, 'mnc': mnc, 'plmn': mcc + mnc}
|
|
|
|
|
|
def _plmn_hex_list(data_hex, rec=3):
|
|
"""Valid 3-byte PLMN entries of a transparent list file ('FFFFFF' gaps
|
|
are skipped, never treated as terminators)."""
|
|
h = _norm(data_hex)
|
|
step = rec * 2
|
|
return [h[i:i + 6] for i in range(0, len(h) - 5, step)
|
|
if plmn_from_hex(h[i:i + 6])]
|
|
|
|
|
|
def plmn_list(data_hex, rec=3):
|
|
return [plmn_from_hex(h) for h in _plmn_hex_list(data_hex, rec)]
|
|
|
|
|
|
def _transparent(files, key):
|
|
f = (files or {}).get(key) or {}
|
|
if f.get('present') and f.get('kind') == 'transparent':
|
|
return f
|
|
return None
|
|
|
|
|
|
def _current_location(files):
|
|
"""Current PLMN + area, EPSLOCI (TAI) -> PSLOCI (RAI) -> LOCI (LAI).
|
|
|
|
Dummies keep the PLMN in LOCI/PSLOCI while EPSLOCI is wiped, so the
|
|
fallback order keeps the location known after service loss."""
|
|
for key, area, off in (('epsloci', 'TAI', 12), ('psloci', 'RAI', 7),
|
|
('loci', 'LAI', 4)):
|
|
f = _transparent(files, key)
|
|
if not f:
|
|
continue
|
|
h = _norm(f.get('data'))
|
|
if len(h) < (off + 5) * 2:
|
|
continue
|
|
p = plmn_from_hex(h[off * 2:off * 2 + 6])
|
|
if not p:
|
|
continue
|
|
return {'plmn': p['plmn'], 'mcc': p['mcc'], 'mnc': p['mnc'],
|
|
'hex': h[off * 2:off * 2 + 6], 'area': area,
|
|
'lac': h[off * 2 + 6:off * 2 + 10], 'source': f.get('source')}
|
|
return None
|
|
|
|
|
|
def _home_sets(files):
|
|
"""(home PLMN hex, equivalent-home PLMN hex) sets from HPLMNwAcT, EHPLMN
|
|
and the IMSI (fallback), as 3-byte uppercase hex."""
|
|
home, eq = set(), set()
|
|
f = _transparent(files, 'hplmnwact')
|
|
if f:
|
|
# First 5-byte record is the HPLMN (TS 31.102 4.2.5).
|
|
head = _norm(f.get('data'))[0:6]
|
|
if plmn_from_hex(head):
|
|
home.add(head)
|
|
f = _transparent(files, 'ehplmn')
|
|
if f:
|
|
eq.update(_plmn_hex_list(f.get('data')))
|
|
if not home:
|
|
imsi = parse_imsi((_transparent(files, 'imsi') or {}).get('data'))
|
|
if imsi and len(imsi) >= 5 and imsi[:3].isdigit():
|
|
try:
|
|
home.add(netsim.plmn_bcd(imsi[0:3], imsi[3:5]))
|
|
if len(imsi) >= 6 and imsi[5].isdigit():
|
|
home.add(netsim.plmn_bcd(imsi[0:3], imsi[3:6]))
|
|
except ValueError:
|
|
pass
|
|
return home, eq
|
|
|
|
|
|
def _rejected(files):
|
|
"""A permanent 'PLMN not allowed' rejection fingerprint: status 010 in a
|
|
location file (UICC_NAA.md C3a) or a non-empty EF.FPLMN."""
|
|
off = {'loci': 10, 'psloci': 13, 'epsloci': 17}
|
|
for key, idx in off.items():
|
|
f = _transparent(files, key)
|
|
if not f:
|
|
continue
|
|
h = _norm(f.get('data'))
|
|
if len(h) >= (idx + 1) * 2 and h[idx * 2:idx * 2 + 2] == '02':
|
|
return True
|
|
f = _transparent(files, 'fplmn')
|
|
if f and _plmn_hex_list(f.get('data')):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _operator(mcc_mnc_data, mcc, mnc):
|
|
"""Exact MCC/MNC lookup in the optional operator list; a country-only
|
|
fallback (first entry of that MCC) keeps the location line useful."""
|
|
if not mcc_mnc_data:
|
|
return None
|
|
try:
|
|
mcc_i, mnc_i = int(mcc), int(mnc)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
fallback = None
|
|
for e in mcc_mnc_data:
|
|
try:
|
|
if int(str(e.get('mcc') or '').strip() or -1) != mcc_i:
|
|
continue
|
|
except ValueError:
|
|
continue
|
|
if fallback is None:
|
|
fallback = e
|
|
try:
|
|
if int(str(e.get('mnc') or '').strip() or -1) == mnc_i:
|
|
return {'country': e.get('countryName'),
|
|
'operator': e.get('brand') or e.get('operator')}
|
|
except ValueError:
|
|
continue
|
|
if fallback:
|
|
return {'country': fallback.get('countryName'), 'operator': None}
|
|
return None
|
|
|
|
|
|
def compute_network(state, mcc_mnc_data=None):
|
|
"""Derive the monitor header data from the cached state."""
|
|
state = state or {}
|
|
files = state.get('files') or {}
|
|
loc = _current_location(files)
|
|
home, eq = _home_sets(files)
|
|
roaming = None
|
|
if loc and home:
|
|
if loc['hex'] in home:
|
|
roaming = 'home'
|
|
elif loc['hex'] in eq:
|
|
roaming = 'equivalent'
|
|
else:
|
|
roaming = 'guest'
|
|
location = None
|
|
if loc:
|
|
op = _operator(mcc_mnc_data, loc['mcc'], loc['mnc'])
|
|
location = {'plmn': loc['plmn'], 'mcc': loc['mcc'], 'mnc': loc['mnc'],
|
|
'area': loc['area'], 'lac': loc['lac'],
|
|
'country': (op or {}).get('country'),
|
|
'operator': (op or {}).get('operator'),
|
|
'roaming': roaming, 'rejected': _rejected(files),
|
|
'source': loc.get('source')}
|
|
network = {'service': state.get('service') or
|
|
{'state': None, 'source': None, 'time': None},
|
|
'location': location}
|
|
state['network'] = network
|
|
return network
|