diff --git a/frontend/help-ru.html b/frontend/help-ru.html index 6a3d551..e2b703a 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -489,10 +489,10 @@
  • Перенастройка CB — запись списков CBMI/CBMIR или их очистка (все FF).
  • AUTHENTICATE — команда AUTHENTICATE (3G/EPS/5G, 00 88 00 81 22) с заданными или случайными RAND/AUTN и показ ответа (успех DB или ошибка синхронизации DC с AUTS).
  • -

    Параметры (свёрнуты) задают оператора (поиск по мировому списку MCC/MNC с сервера плюс выбор случайного роуминг-оператора), LAC/Cell ID/TAC/RAC, необязательные идентификаторы (пусто = случайно: TMSI, GUTI, KSI, KASME, Kc, счётчики NAS, алгоритм, RAND/AUTN), переключатели сценария (включая «Отказ: записать FPLMN») и число циклов/задержку. Журнал шагов показывает каждую запись, ENVELOPE и AUTHENTICATE с их SW. Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE — FPLMN записывается только сценарием постоянного отказа (TS 31.102 §4.2.16), а 5GS location-файлы не записываются; записи меняют карту и видны в последующих сравнениях снимков.

    +

    Параметры (свёрнуты) задают оператора (поиск по мировому списку MCC/MNC с сервера плюс выбор случайного роуминг-оператора и кнопка «Домашняя сеть», заполняющая HPLMN карты из первой записи EF.HPLMNwAcT с откатом на IMSI), LAC/Cell ID/TAC/RAC, необязательные идентификаторы (пусто = случайно: TMSI, GUTI, KSI, KASME, Kc, счётчики NAS, алгоритм, RAND/AUTN), переключатели сценария (включая «Отказ: записать FPLMN») и число циклов/задержку. Журнал шагов показывает каждую запись, ENVELOPE и AUTHENTICATE с их SW. Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE — FPLMN записывается только сценарием постоянного отказа (TS 31.102 §4.2.16), а 5GS location-файлы не записываются; записи меняют карту и видны в последующих сравнениях снимков.

    Монитор сетевого состояния

    -

    Рядом с кнопками симуляции компактная панель «Сетевое состояние» показывает, что сейчас хранит карта и что было сэмулировано последним. В заголовке — сэмулированное состояние сервиса: Не определено, пока его не задаст сценарий или событие Location status, затем Обычный сервис (зелёный), Ограниченный сервис (жёлтый) или Нет сервиса (красный), с красной пометкой PLMN не разрешён, если location-файлы или EF.FPLMN указывают на отказ регистрации — плюс текущее местоположение: PLMN, страна и оператор (из необязательного мирового списка MCC/MNC, если он загружен), LAI/RAI/TAI и класс роуминга (Домашняя сеть, если PLMN совпадает с HPLMN; Эквивалентная домашней, если он есть в EF.EHPLMN; иначе Гостевая (роуминг)). Ниже — по одной компактной строке на контролируемый файл (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) с декодированной сводкой и признаком последнего обновления (init, write, read, refresh); при наведении — все декодированные поля; длинные списки PLMN (например, EF.HPLMNwAcT) сокращаются до первых сетей с пометкой … +N. Панель читает файлы один раз при подключении карты (только если ICCID читается), обновляет их на месте по записанным симулятором байтам, перечитывает EF.IMSI после каждого сценария и события Location status (мульти-IMSI апплеты) и никогда не опрашивает карту — кнопка «Обновить» перечитывает все файлы по требованию.

    +

    Рядом с кнопками симуляции компактная панель «Сетевое состояние» показывает, что сейчас хранит карта и что было сэмулировано последним. В заголовке — сэмулированное состояние сервиса: Не определено, пока его не задаст сценарий или событие Location status, затем Обычный сервис (зелёный), Ограниченный сервис (жёлтый) или Нет сервиса (красный), с красной пометкой PLMN не разрешён, если location-файлы или EF.FPLMN указывают на отказ регистрации — плюс текущее местоположение: PLMN, страна и оператор (из необязательного мирового списка MCC/MNC, если он загружен), LAI/RAI/TAI и класс роуминга (Домашняя сеть, если PLMN совпадает с HPLMN; Эквивалентная домашней, если он есть в EF.EHPLMN; иначе Гостевая (роуминг)). Ниже — по одной компактной строке на контролируемый файл (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) с декодированной сводкой и признаком последнего обновления (init, write, read, refresh); при наведении — все декодированные поля; длинные списки PLMN сокращаются (EF.HPLMNwAcT показывает только первую сеть и пометку … +N, а технологии доступа — в подсказке). Панель читает файлы один раз при подключении карты (только если ICCID читается), обновляет их на месте по записанным симулятором байтам, перечитывает EF.IMSI после каждого сценария и события Location status (мульти-IMSI апплеты) и никогда не опрашивает карту — кнопка «Обновить» перечитывает все файлы по требованию.

    diff --git a/frontend/help.html b/frontend/help.html index 85a61fe..4328bf9 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -489,10 +489,10 @@
  • CB reconfig — write the CBMI/CBMIR message-ID lists or clear them (all FF).
  • AUTHENTICATE — send AUTHENTICATE (3G/EPS/5G, 00 88 00 81 22) with the given or random RAND/AUTN and show the response (success DB or synchronisation failure DC with AUTS).
  • -

    Parameters (collapsed) provide the operator (searchable worldwide MCC/MNC list served from the server, plus a random roaming picker), LAC/Cell ID/TAC/RAC, optional identity values (empty = random: TMSI, GUTI, KSI, KASME, Kc, NAS counts, algorithm, RAND/AUTN), the scenario toggles (including Rejection: write FPLMN) and the churn count/delay. The step log lists every write, ENVELOPE and AUTHENTICATE with its SW. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent — FPLMN is written only by the permanent rejection scenario (TS 31.102 §4.2.16) and the 5GS location files are never written; the writes change the card and are visible to later snapshot comparisons.

    +

    Parameters (collapsed) provide the operator (searchable worldwide MCC/MNC list served from the server, plus a random roaming picker and a Home network button that fills the card’s HPLMN from EF.HPLMNwAcT’s first record, falling back to the IMSI), LAC/Cell ID/TAC/RAC, optional identity values (empty = random: TMSI, GUTI, KSI, KASME, Kc, NAS counts, algorithm, RAND/AUTN), the scenario toggles (including Rejection: write FPLMN) and the churn count/delay. The step log lists every write, ENVELOPE and AUTHENTICATE with its SW. Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent — FPLMN is written only by the permanent rejection scenario (TS 31.102 §4.2.16) and the 5GS location files are never written; the writes change the card and are visible to later snapshot comparisons.

    Network state monitor

    -

    Next to the simulation buttons a compact Network state panel shows what the card currently holds and what was last simulated. Its header carries the simulated service stateUndefined until a scenario or a Location status event sets it, then Normal service (green), Limited service (amber) or No service (red), with a red PLMN not allowed marker when the location files or EF.FPLMN show a rejection — plus the current location: PLMN, country and operator (from the optional worldwide MCC/MNC list when loaded), the LAI/RAI/TAI, and the roaming class (Home when the PLMN equals the HPLMN, Home equivalent when it is in EF.EHPLMN, otherwise Guest). Below it, one compact line per monitored file (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) with its decoded summary and how it was last updated (init, write, read, refresh); hover for the full decoded fields — long PLMN lists (e.g. EF.HPLMNwAcT) are abbreviated to the first few networks plus a … +N counter. The panel reads the files once at equip (only when the ICCID was readable), updates them in place from the bytes the simulator wrote, re-reads EF.IMSI after every scenario and Location-status event (multi-IMSI applets) and never polls the card — use Refresh to re-read all files on demand.

    +

    Next to the simulation buttons a compact Network state panel shows what the card currently holds and what was last simulated. Its header carries the simulated service stateUndefined until a scenario or a Location status event sets it, then Normal service (green), Limited service (amber) or No service (red), with a red PLMN not allowed marker when the location files or EF.FPLMN show a rejection — plus the current location: PLMN, country and operator (from the optional worldwide MCC/MNC list when loaded), the LAI/RAI/TAI, and the roaming class (Home when the PLMN equals the HPLMN, Home equivalent when it is in EF.EHPLMN, otherwise Guest). Below it, one compact line per monitored file (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) with its decoded summary and how it was last updated (init, write, read, refresh); hover for the full decoded fields — long PLMN lists are abbreviated (EF.HPLMNwAcT shows only the first network plus a … +N counter, with the access technologies in the tooltip). The panel reads the files once at equip (only when the ICCID was readable), updates them in place from the bytes the simulator wrote, re-reads EF.IMSI after every scenario and Location-status event (multi-IMSI applets) and never polls the card — use Refresh to re-read all files on demand.

    diff --git a/frontend/index.html b/frontend/index.html index 1c76954..af2d1a9 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -972,6 +972,7 @@ +
    Sets the PLMN written to LOCI/PSLOCI/EPSLOCI. Used by EPS attach, 2G attach and SMS received (real LAI/RAI/TAI; SMS only when its location rewrite is enabled), and by Cold boot, Service lost, Limited service and Roaming denied (the dummy LOCI/PSLOCI keep this PLMN, the EPSLOCI dummy wipes the TAI PLMN). Churn, CB reconfig and AUTHENTICATE don't use it.
    @@ -1413,7 +1414,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '2.7.17'; +const SIMPLE_VERSION = '2.7.18'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -12814,7 +12815,9 @@ function netStateSummary(key, entry) { } else if (key === 'ehplmn' || key === 'spdi' || key === 'fplmn') { text = netStatePlmnList(d, false, 3); } else if (key === 'hplmnwact') { - text = netStatePlmnList(d, true, 3); + // Compact row: the first network only, plus a count of the hidden + // entries (the tooltip carries the full decoded list with tech). + text = netStatePlmnList(d, false, 1); } else if (NET_STATE_AREA[key]) { const [area, statusKey] = NET_STATE_AREA[key]; const plmn = (d.mcc && d.mnc) ? d.mcc + '-' + d.mnc : null; @@ -12984,6 +12987,29 @@ async function netSimRandomOperator() { } } +// The card's home network: EF.HPLMNwAcT first record, else the IMSI (the +// server resolves it; see netstate.home_plmn). +async function netSimHomeOperator() { + const statusEl = document.getElementById('netsim-status'); + try { + const res = await pysimFetch('/api/net-state'); + const home = res && res.available && res.state && res.state.network ? res.state.network.home : null; + if (!home) { + statusEl.textContent = t('Home PLMN not available (no HPLMNwAcT/IMSI)'); + statusEl.className = 'text-xs text-yellow-600 mb-1'; + return; + } + document.getElementById('netsim-mcc').value = home.mcc; + document.getElementById('netsim-mnc').value = home.mnc; + statusEl.textContent = t('Home network') + ': ' + home.mcc + '/' + home.mnc + + ' (' + t(home.source === 'imsi' ? 'from IMSI' : 'from EF.HPLMNwAcT') + ')'; + statusEl.className = 'text-xs text-gray-500 dark:text-slate-400 mb-1'; + } catch (e) { + statusEl.textContent = t('Error') + ': ' + e.message; + statusEl.className = 'text-xs text-red-500 mb-1'; + } +} + // ===== i18n ===== const LANG_RU = { 'Generate APDU': 'Сгенерировать APDU', @@ -13281,6 +13307,10 @@ const LANG_RU = { 'No steps': 'Нет шагов', 'skip': 'пропуск', 'Operator list not loaded (server --mcc-mnc-list)': 'Список операторов не загружен (--mcc-mnc-list на сервере)', + 'Home network': 'Домашняя сеть', + 'from EF.HPLMNwAcT': 'из EF.HPLMNwAcT', + 'from IMSI': 'из IMSI', + 'Home PLMN not available (no HPLMNwAcT/IMSI)': 'Домашняя сеть недоступна (нет HPLMNwAcT/IMSI)', 'No commands logged yet.': 'Команды пока не регистрировались.', 'Send event notification to the card?': 'Отправить уведомление о событии на карту?', 'This event type is not yet supported.': 'Этот тип события пока не поддерживается.', diff --git a/frontend/sw.js b/frontend/sw.js index c82e49d..183137c 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v220'; +const CACHE = 'simple-v221'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/netsim.test.js b/frontend/tests/netsim.test.js index c6c5966..d0858c8 100644 --- a/frontend/tests/netsim.test.js +++ b/frontend/tests/netsim.test.js @@ -6,7 +6,7 @@ const path = require('node:path'); const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); function extractFunc(src, name) { - const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); + const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); const m = re.exec(src); if (!m) throw new Error('function ' + name + ' not found'); let i = m.index + m[0].length - 1; @@ -22,7 +22,7 @@ function extractFunc(src, name) { } let code = ''; -for (const f of ['netSimRandomHex', 'netSimDice', 'netSimVal', 'netSimChk', 'netSimParams', 'netSimStepText']) { +for (const f of ['netSimRandomHex', 'netSimDice', 'netSimVal', 'netSimChk', 'netSimParams', 'netSimStepText', 'netSimHomeOperator']) { code += extractFunc(html, f) + '\n'; } code += 'function t(k){return k;}\n'; @@ -84,3 +84,44 @@ test('netSimStepText renders the step log lines', () => { netSimStepText({ action: 'authenticate', parsed: { type: 'synchronisation_failure' }, sw: '9000', response: 'DC10AA' }), /^AUTHENTICATE synchronisation_failure → SW 9000 DC10AA$/); }); + +test('netSimHomeOperator fills the home PLMN and reports its source', async () => { + _fields = { + 'netsim-mcc': { value: '' }, + 'netsim-mnc': { value: '' }, + 'netsim-status': { textContent: '', className: '' }, + }; + globalThis.pysimFetch = async () => ({ + available: true, + state: { network: { home: { mcc: '250', mnc: '99', source: 'hplmnwact' } } }, + }); + await netSimHomeOperator(); + assert.strictEqual(_fields['netsim-mcc'].value, '250'); + assert.strictEqual(_fields['netsim-mnc'].value, '99'); + assert.ok(_fields['netsim-status'].textContent.includes('250/99')); + assert.ok(_fields['netsim-status'].textContent.includes('from EF.HPLMNwAcT')); + assert.ok(_fields['netsim-status'].className.includes('text-gray-500')); +}); + +test('netSimHomeOperator reports the IMSI source and warns when unavailable', async () => { + _fields = { + 'netsim-mcc': { value: '' }, + 'netsim-mnc': { value: '' }, + 'netsim-status': { textContent: '', className: '' }, + }; + globalThis.pysimFetch = async () => ({ + available: true, + state: { network: { home: { mcc: '228', mnc: '06', source: 'imsi' } } }, + }); + await netSimHomeOperator(); + assert.ok(_fields['netsim-status'].textContent.includes('from IMSI')); + // no home PLMN -> the fields stay and the status warns + _fields['netsim-mcc'].value = '001'; + _fields['netsim-mnc'].value = '01'; + globalThis.pysimFetch = async () => ({ available: true, state: { network: { home: null } } }); + await netSimHomeOperator(); + assert.strictEqual(_fields['netsim-mcc'].value, '001'); + assert.strictEqual(_fields['netsim-mnc'].value, '01'); + assert.ok(_fields['netsim-status'].textContent.includes('not available')); + assert.ok(_fields['netsim-status'].className.includes('text-yellow-600')); +}); diff --git a/frontend/tests/netstate.test.js b/frontend/tests/netstate.test.js index 0d963c8..a207763 100644 --- a/frontend/tests/netstate.test.js +++ b/frontend/tests/netstate.test.js @@ -74,7 +74,7 @@ test('netStateSummary renders decoded file contents compactly', () => { const fplmn = netStateSummary('fplmn', { present: true, kind: 'transparent', data: 'AA' }); assert.strictEqual(fplmn.text, '262-01, 246-81'); const hplmn = netStateSummary('hplmnwact', { present: true, kind: 'transparent', data: 'AA' }); - assert.strictEqual(hplmn.text, '262-01 (LTE)'); + assert.strictEqual(hplmn.text, '262-01'); const absent = netStateSummary('loci', { present: false }); assert.strictEqual(absent.text, '—'); assert.strictEqual(absent.title, 'not present'); @@ -110,7 +110,8 @@ test('netStateSummary abbreviates a long HPLMNwAcT list', () => { ] }, }); const s = netStateSummary('hplmnwact', { present: true, kind: 'transparent', data: 'AA' }); - assert.strictEqual(s.text, '250-99 (LTE), 250-32 (LTE), 250-54 (LTE) … +2'); + // first network only (no access tech in the row), plus the hidden count + assert.strictEqual(s.text, '250-99 … +4'); }); test('netStateRender paints the service badge, location and rows', () => { diff --git a/pyproject.toml b/pyproject.toml index 302e31b..03af9ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "2.7.17" +version = "2.7.18" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/netstate.py b/pysim_simple_server/netstate.py index 08e31ed..ba4d668 100644 --- a/pysim_simple_server/netstate.py +++ b/pysim_simple_server/netstate.py @@ -213,6 +213,23 @@ def _home_sets(files): return home, eq +def home_plmn(files): + """The simulator's "home network" PLMN: the first EF.HPLMNwAcT record + (the HPLMN per TS 31.102 4.2.5), falling back to the IMSI (the MNC is + taken as 2 digits — the IMSI does not encode its length).""" + f = _transparent(files, 'hplmnwact') + if f: + p = plmn_from_hex(_norm(f.get('data'))[0:6]) + if p: + return {'mcc': p['mcc'], 'mnc': p['mnc'], 'plmn': p['plmn'], + 'source': 'hplmnwact'} + imsi = parse_imsi((_transparent(files, 'imsi') or {}).get('data')) + if imsi and len(imsi) >= 5 and imsi[:5].isdigit(): + return {'mcc': imsi[:3], 'mnc': imsi[3:5], 'plmn': imsi[:5], + 'source': 'imsi'} + return None + + 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.""" @@ -284,6 +301,7 @@ def compute_network(state, mcc_mnc_data=None): 'source': loc.get('source')} network = {'service': state.get('service') or {'state': None, 'source': None, 'time': None}, - 'location': location} + 'location': location, + 'home': home_plmn(files)} state['network'] = network return network diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index bf9f8df..9f89bc6 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -26,7 +26,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '2.7.17' +VERSION = '2.7.18' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE diff --git a/tests/test_netstate.py b/tests/test_netstate.py index ffc1777..5c419e3 100644 --- a/tests/test_netstate.py +++ b/tests/test_netstate.py @@ -157,5 +157,41 @@ class NetworkTests(unittest.TestCase): self.assertNotIn(scenario, netsim.SCENARIO_SERVICE) +class HomePlmnTests(unittest.TestCase): + def test_home_plmn_prefers_the_hplmnwact_first_record(self): + st = state_with( + hplmnwact=file_entry('EF.HPLMNwAcT', '6F62', + '62F2104000' + '22F8608000'), + ehplmn=file_entry('EF.EHPLMN', '6FD9', '22F860'), + imsi=file_entry('EF.IMSI', '6F07', '082982608200002080')) + self.assertEqual(netstate.home_plmn(st['files']), + {'mcc': '262', 'mnc': '01', 'plmn': '26201', + 'source': 'hplmnwact'}) + + def test_home_plmn_falls_back_to_the_imsi_with_a_2_digit_mnc(self): + st = state_with(imsi=file_entry('EF.IMSI', '6F07', '082982608200002080')) + self.assertEqual(netstate.home_plmn(st['files']), + {'mcc': '228', 'mnc': '06', 'plmn': '22806', + 'source': 'imsi'}) + + def test_home_plmn_ignores_an_ff_first_record(self): + st = state_with( + hplmnwact=file_entry('EF.HPLMNwAcT', '6F62', 'FFFFFFFC00'), + imsi=file_entry('EF.IMSI', '6F07', '082982608200002080')) + self.assertEqual(netstate.home_plmn(st['files'])['source'], 'imsi') + + def test_home_plmn_is_none_without_sources(self): + self.assertIsNone(netstate.home_plmn({})) + st = state_with(hplmnwact=file_entry('EF.HPLMNwAcT', '6F62', 'FF')) + self.assertIsNone(netstate.home_plmn(st['files'])) + + def test_compute_network_exposes_the_home_plmn(self): + st = state_with(imsi=file_entry('EF.IMSI', '6F07', '082982608200002080')) + net = netstate.compute_network(st, OP_LIST) + self.assertEqual(net['home'], + {'mcc': '228', 'mnc': '06', 'plmn': '22806', + 'source': 'imsi'}) + + if __name__ == '__main__': unittest.main()