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
+74 -5
View File
@@ -39,6 +39,8 @@ class BuilderTests(unittest.TestCase):
dummy = netsim.build_loci_dummy('52F002')
self.assertEqual(dummy, 'FFFFFFFF52F002FFFEFF01')
# PLMN kept, LAC FFFE, status "not updated"
rejected = netsim.build_loci_dummy('52F002', netsim.ST_PLMN_NOT_ALLOWED)
self.assertEqual(rejected, 'FFFFFFFF52F002FFFEFF02')
def test_psloci_real_and_dummy(self):
real = netsim.build_psloci('F9236619', 'FFFFFF', '52F002', '6CD7', 'CA', 0x00)
@@ -46,17 +48,53 @@ class BuilderTests(unittest.TestCase):
self.assertEqual(real[-2:], '00')
dummy = netsim.build_psloci_dummy('52F002')
self.assertEqual(dummy, 'FFFFFFFFFFFFFF52F002FFFEFF01')
rejected = netsim.build_psloci_dummy('52F002', netsim.ST_PLMN_NOT_ALLOWED)
self.assertEqual(rejected, 'FFFFFFFFFFFFFF52F002FFFEFF02')
def test_epsloci_real_and_dummy(self):
real = netsim.build_epsloci('AB' * 12, '52F099', '8001', 0x00)
self.assertEqual(len(real) // 2, 18)
self.assertEqual(real[24:30], '52F099') # TAI PLMN after the 12-byte GUTI
self.assertEqual(real[-2:], '00')
dummy = netsim.build_epsloci_dummy('52F099')
self.assertEqual(len(dummy) // 2, 18)
self.assertTrue(dummy.startswith('0BF652F099'))
self.assertEqual(dummy[10:24], 'FF' * 7) # identity wiped
self.assertTrue(dummy.endswith('52F099FFFF01'))
# dummy: the EPS-mobile-identity pair 0B F6 stays, the rest is wiped
dummy = netsim.build_epsloci_dummy()
self.assertEqual(dummy, '0BF6' + 'FF' * 16)
# permanent rejection: the status byte is the only written byte
rejected = netsim.build_epsloci_dummy(netsim.ST_PLMN_NOT_ALLOWED)
self.assertEqual(rejected, '0BF6' + 'FF' * 15 + '02')
def test_fplmn_insert_fills_empty_slots_and_shifts(self):
# empty list: the new PLMN goes into the first slot
self.assertEqual(netsim.insert_fplmn('FF' * 12, '00F110'),
'00F110' + 'FF' * 9)
# a gap in any position is filled, never treated as a terminator
self.assertEqual(netsim.insert_fplmn('AABBCCFFFFFF112233445566', '00F110'),
'AABBCC00F110112233445566')
# full list: the longest-held entry is dropped, new PLMN appended at n
self.assertEqual(netsim.insert_fplmn('AABBCCDDEEFF112233445566', '00F110'),
'DDEEFF11223344556600F110')
self.assertEqual(netsim.insert_fplmn('', '00F110'), '00F110')
self.assertEqual(netsim.fplmn_entries('FF' * 9),
['FFFFFF', 'FFFFFF', 'FFFFFF'])
self.assertEqual(netsim.fplmn_entries(''), [])
def test_parse_imsi_matches_the_pysim_vector(self):
self.assertEqual(netsim.parse_imsi('082982608200002080'),
'228062800000208')
self.assertIsNone(netsim.parse_imsi('08'))
self.assertIsNone(netsim.parse_imsi(''))
def test_roaming_denied_fplmn_guard_falls_back_to_the_imsi(self):
runner, lchan, srv = make_runner({'mcc': '228', 'mnc': '06'})
srv.net_state = {'files': {
'imsi': {'present': True, 'kind': 'transparent',
'data': '082982608200002080'},
}}
out = runner.run('roaming_denied')
self.assertTrue(out['success'])
self.assertFalse(any(w[1] == '6F7B' for w in lchan.writes))
self.assertTrue(any('home PLMN' in s.get('note', '')
for s in out['steps']))
def test_epsnsc_record_layout_and_padding(self):
rec = netsim.build_epsnsc(0x03, 'AB' * 32, 0x0E, 0x08, 0x02)
@@ -216,6 +254,7 @@ FILES = {
'6F43': FakeFileInfo(size=2, data='27FF'),
'6F45': FakeFileInfo(size=20),
'6F50': FakeFileInfo(size=40),
'6F7B': FakeFileInfo(size=12, data='FF' * 12),
}
@@ -257,6 +296,36 @@ class RunnerTests(unittest.TestCase):
kc = [w for w in lchan.writes if w[1] == '4F20'][0][2]
self.assertEqual(kc, 'FFFFFFFFFFFFFFFF07')
def test_roaming_denied_writes_rejection_status_and_fplmn(self):
runner, lchan, srv = make_runner()
out = runner.run('roaming_denied')
self.assertTrue(out['success'])
self.assertEqual(srv.events[0], (3, '9B0101'))
loci = [w for w in lchan.writes if w[1] == '6F7E'][0][2]
self.assertEqual(loci, 'FFFFFFFF00F110FFFEFF02')
psloci = [w for w in lchan.writes if w[1] == '6F73'][0][2]
self.assertTrue(psloci.endswith('02'), psloci)
epsloci = [w for w in lchan.writes if w[1] == '6FE3'][0][2]
self.assertEqual(epsloci, '0BF6' + 'FF' * 15 + '02')
# the denied VPLMN 001-01 (00 F1 10) is appended to EF.FPLMN
fplmn = [w for w in lchan.writes if w[1] == '6F7B'][0][2]
self.assertEqual(fplmn, '00F110' + 'FF' * 9)
# C3a drops the key context (KSI 07 + wiped KASME)
epsnsc = [w for w in lchan.writes if w[1] == '6FE4'][0][2]
self.assertTrue(epsnsc.startswith('A0348001078120' + 'FF' * 32))
def test_roaming_denied_never_stores_the_home_plmn(self):
runner, lchan, srv = make_runner()
srv.net_state = {'files': {
'hplmnwact': {'present': True, 'kind': 'transparent',
'data': '00F110' + '0000'},
}}
out = runner.run('roaming_denied')
self.assertTrue(out['success'])
self.assertFalse(any(w[1] == '6F7B' for w in lchan.writes))
self.assertTrue(any('home PLMN' in s.get('note', '')
for s in out['steps']))
def test_event_step_skipped_when_not_subscribed(self):
runner, lchan, srv = make_runner(event_list=[])
out = runner.run('service_lost')
+160
View File
@@ -0,0 +1,160 @@
# coding=utf-8
"""Tests for the Network-state monitor (pure helpers)."""
import unittest
from pysim_simple_server import netsim, netstate
def file_entry(name, fid, data, kind='transparent', source='init'):
e = {'name': name, 'fid': fid, 'present': True, 'kind': kind,
'source': source, 'updated': 1.0}
if kind == 'record':
e['records'] = [{'num': i + 1, 'data': d} for i, d in enumerate(data)]
else:
e['data'] = data
return e
def state_with(**files):
st = netstate.new_state()
for key, entry in files.items():
st['files'][key] = entry
return st
OP_LIST = [
{'mcc': '262', 'mnc': '01', 'countryName': 'Germany', 'brand': 'Telekom',
'operator': 'Telekom Deutschland GmbH'},
{'mcc': '262', 'mnc': '02', 'countryName': 'Germany', 'brand': 'Vodafone',
'operator': 'Vodafone GmbH'},
{'mcc': '246', 'mnc': '81', 'countryName': 'Finland', 'brand': 'Elisa',
'operator': 'Elisa Oyj'},
]
class DecodeTests(unittest.TestCase):
def test_parse_imsi_matches_the_pysim_vector(self):
# pySim/EF vector: 228-06 IMSI
self.assertEqual(netstate.parse_imsi('082982608200002080'),
'228062800000208')
self.assertEqual(netstate.parse_imsi(''), None)
def test_plmn_from_hex(self):
self.assertEqual(netstate.plmn_from_hex('22F860'),
{'mcc': '228', 'mnc': '06', 'plmn': '22806'})
self.assertEqual(netstate.plmn_from_hex('62F210'),
{'mcc': '262', 'mnc': '01', 'plmn': '26201'})
self.assertIsNone(netstate.plmn_from_hex('62F2F0'))
self.assertIsNone(netstate.plmn_from_hex('FFFFFF'))
self.assertIsNone(netstate.plmn_from_hex('FE'))
def test_plmn_list_skips_gaps_without_terminating(self):
# TS 31.102 4.2.16: FFFFFF may appear in any position, not as an end
entries = netstate._plmn_hex_list('22F860' + 'FFFFFF' + '62F210')
self.assertEqual(entries, ['22F860', '62F210'])
class NetworkTests(unittest.TestCase):
def test_location_prefers_epsloci_and_falls_back_to_loci(self):
st = state_with(
loci=file_entry('EF.LOCI', '6F7E', 'FFFFFFFF62F2106CD7FF01'),
psloci=file_entry('EF.PSLOCI', '6F73',
'FFFFFFFFFFFFFF62F2106CD7CA01'),
epsloci=file_entry('EF.EPSLOCI', '6FE3', '0BF6' + 'FF' * 16))
loc = netstate._current_location(st['files'])
# wiped EPSLOCI -> RAI from PSLOCI
self.assertEqual(loc['plmn'], '26201')
self.assertEqual(loc['area'], 'RAI')
self.assertEqual(loc['lac'], '6CD7')
st['files']['psloci'] = file_entry('EF.PSLOCI', '6F73', 'FF' * 14)
loc = netstate._current_location(st['files'])
self.assertEqual(loc['area'], 'LAI')
def test_network_home_equivalent_and_guest(self):
# HPLMN 262-01 (HPLMNwAcT first record), EHPLMN 228-06
base = dict(
hplmnwact=file_entry('EF.HPLMNwAcT', '6F62', '62F210' + '0000'),
ehplmn=file_entry('EF.EHPLMN', '6FD9', '22F860'))
st = state_with(epsloci=file_entry('EF.EPSLOCI', '6FE3',
'AB' * 12 + '62F210' + '8001' + '00'),
**base)
net = netstate.compute_network(st, OP_LIST)
self.assertEqual(net['location']['plmn'], '26201')
self.assertEqual(net['location']['country'], 'Germany')
self.assertEqual(net['location']['operator'], 'Telekom')
self.assertEqual(net['location']['roaming'], 'home')
self.assertFalse(net['location']['rejected'])
# equivalent home
st['files']['epsloci'] = file_entry('EF.EPSLOCI', '6FE3',
'AB' * 12 + '22F860' + '8001' + '00')
net = netstate.compute_network(st, OP_LIST)
self.assertEqual(net['location']['roaming'], 'equivalent')
# guest
st['files']['epsloci'] = file_entry('EF.EPSLOCI', '6FE3',
'AB' * 12 + '00F110' + '8001' + '00')
net = netstate.compute_network(st, OP_LIST)
self.assertEqual(net['location']['roaming'], 'guest')
self.assertIsNone(net['location']['country']) # 001 not in the list
def test_home_falls_back_to_the_imsi(self):
st = state_with(
imsi=file_entry('EF.IMSI', '6F07', '082982608200002080'),
epsloci=file_entry('EF.EPSLOCI', '6FE3',
'AB' * 12 + '22F860' + '8001' + '00'))
home, eq = netstate._home_sets(st['files'])
# the IMSI does not encode the MNC length, so both interpretations
# are kept as home candidates
self.assertIn('22F860', home)
self.assertEqual(eq, set())
net = netstate.compute_network(st, OP_LIST)
self.assertEqual(net['location']['roaming'], 'home')
def test_rejection_fingerprint(self):
st = state_with(loci=file_entry('EF.LOCI', '6F7E',
'FFFFFFFF62F210FFFEFF02'))
net = netstate.compute_network(st, OP_LIST)
self.assertTrue(net['location']['rejected'])
# non-empty FPLMN also marks a rejection
st = state_with(
loci=file_entry('EF.LOCI', '6F7E', 'FFFFFFFF62F210FFFEFF01'),
fplmn=file_entry('EF.FPLMN', '6F7B', '22F860' + 'FF' * 9))
net = netstate.compute_network(st, OP_LIST)
self.assertTrue(net['location']['rejected'])
# ... but an empty FPLMN with update status 01 is just "no service"
st = state_with(
loci=file_entry('EF.LOCI', '6F7E', 'FFFFFFFF62F210FFFEFF01'),
fplmn=file_entry('EF.FPLMN', '6F7B', 'FF' * 12))
net = netstate.compute_network(st, OP_LIST)
self.assertFalse(net['location']['rejected'])
def test_service_passthrough_and_steps(self):
st = state_with()
netstate.set_service(st, netstate.SERVICE_LIMITED, 'net-sim:limited_service')
self.assertEqual(st['service']['state'], 'limited')
netstate.apply_steps(st, [
{'action': 'update_binary', 'key': 'loci', 'path': 'ADF.USIM/6F7E',
'data': 'aabb'},
{'action': 'update_record', 'key': 'epsnsc', 'path': 'ADF.USIM/6FE4',
'data': 'ccdd', 'record': 1},
{'action': 'skip', 'file': 'kc'},
])
self.assertEqual(st['files']['loci']['data'], 'AABB')
self.assertEqual(st['files']['loci']['source'], 'write')
self.assertEqual(st['files']['epsnsc']['kind'], 'record')
self.assertEqual(st['files']['epsnsc']['records'],
[{'num': 1, 'data': 'CCDD'}])
net = netstate.compute_network(st, OP_LIST)
self.assertEqual(net['service']['state'], 'limited')
def test_scenario_service_mapping_covers_the_location_scenarios(self):
for scenario in ('cold_boot', 'attach_eps', 'attach_2g',
'service_lost', 'limited_service', 'roaming_denied',
'sms_received'):
self.assertIn(scenario, netsim.SCENARIO_SERVICE)
for scenario in ('churn', 'cb_reconfig', 'authenticate'):
self.assertNotIn(scenario, netsim.SCENARIO_SERVICE)
if __name__ == '__main__':
unittest.main()