diff --git a/frontend/help-ru.html b/frontend/help-ru.html index f12193f..8348b4f 100644 --- a/frontend/help-ru.html +++ b/frontend/help-ru.html @@ -483,13 +483,16 @@ -

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

+

Параметры (свёрнуты) задают оператора (поиск по мировому списку 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-файлы не записываются; записи меняют карту и видны в последующих сравнениях снимков.

+ +

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

+

Рядом с кнопками симуляции компактная панель «Сетевое состояние» показывает, что сейчас хранит карта и что было сэмулировано последним. В заголовке — сэмулированное состояние сервиса: Не определено, пока его не задаст сценарий или событие 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); при наведении — все декодированные поля. Панель читает файлы один раз при подключении карты (только если ICCID читается), обновляет их на месте по записанным симулятором байтам, перечитывает EF.IMSI после каждого сценария и события Location status (мульти-IMSI апплеты) и никогда не опрашивает карту — кнопка «Обновить» перечитывает все файлы по требованию.

diff --git a/frontend/help.html b/frontend/help.html index d266398..9e5c2f3 100644 --- a/frontend/help.html +++ b/frontend/help.html @@ -483,13 +483,16 @@ -

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 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 and 5GS location files are never written (no observed phone does); 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), 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. 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 d016f3d..7a70e68 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -939,9 +939,10 @@
-
+
+
Network simulation -
Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE). Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN and 5GS location files are never touched.
+
Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE). Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN is written only by the permanent "PLMN not allowed" rejection (TS 31.102 4.2.16), and the 5GS location files are never touched.
@@ -1017,6 +1018,7 @@ + @@ -1027,6 +1029,18 @@
+
+ Network state +
+ + +
+
+
+
+
+
+
Fetched proactive commands:
@@ -1400,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.6.2'; +const SIMPLE_VERSION = '2.7.0'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== Tab switching ===== @@ -1478,6 +1492,7 @@ function phoneSwitchSubtab(name) { stkCheckMenu(); pysimEventsRender(); pysimProactiveLogRender(); + netStateFetch(); pysimPollStatusInit(); tpRefresh(); } @@ -4993,6 +5008,7 @@ function pysimSetServerAvailable(available) { pysimUpdateAdmIndicator(null); pysimUpdateIccidIndicator(null); pysimUpdatePresetIndicators(null); + netStateRender(null); } pysimApplyAvailability(); } @@ -7953,6 +7969,8 @@ async function pysimEventSend() { result.classList.remove('hidden'); result.classList.add('text-amber-600'); } + if (resp.net_state) netStateRender(resp.net_state); + else netStateFetch(); } catch (e2) { err.textContent = 'Error: ' + e2.message; err.classList.remove('hidden'); @@ -8031,6 +8049,7 @@ async function pysimResetCardData(refreshStatus) { pysimProactiveLogRender(); pysimPliRender(); if (refreshStatus) await pysimRefresh(); + if (isViewVisible('tab-phone')) netStateFetch(); } function pysimCardStateUpdate(status) { @@ -11907,6 +11926,19 @@ function efDecPlmnList(b) { return { plmns: plmns }; } +// EF.FPLMN: 3-byte PLMN entries; an FFFFFF empty slot may appear in any +// position and must not terminate the list (TS 31.102 4.2.16). +function efDecFplmn(b) { + const plmns = []; + for (let i = 0; i + 2 < b.length; i += 3) { + const chunk = b.subarray(i, i + 3); + if (efAllFf(chunk)) continue; + const p = efPlmn(chunk); + if (p) plmns.push(p); + } + return { plmns: plmns }; +} + function efDecPlmnWact(b) { const plmns = []; for (let i = 0; i + 4 < b.length; i += 5) { @@ -12254,6 +12286,7 @@ const EF_DECODERS = [ { name: 'EF.PLMNwAcT', fid: '6f60', fn: efDecPlmnWact }, { name: 'EF.OPLMNwAcT', fid: '6f61', fn: efDecPlmnWact }, { name: 'EF.HPLMNwAcT', fid: '6f62', fn: efDecPlmnWact }, + { name: 'EF.EHPLMN', fid: '6fd9', fn: efDecPlmnList }, { name: 'EF.OPL', fid: '6fc6', fn: efDecOpl }, { name: 'EF.SPDI', fid: '6fcd', fn: efDecSpdi }, { name: 'EF.PNN', fid: '6fc5', fn: efDecPnn }, @@ -12261,6 +12294,7 @@ const EF_DECODERS = [ { name: 'EF.PSLOCI', fid: '6f73', fn: efDecPsLoci }, { name: 'EF.EPSLOCI', fid: '6fe3', fn: efDecEpsLoci }, { name: 'EF.EPSNSC', fid: '6fe4', fn: efDecEpsNsc }, + { name: 'EF.FPLMN', fid: '6f7b', fn: efDecFplmn }, { name: 'EF.LOCIGPRS', fid: '6f53', fn: efDecPsLoci }, { name: 'EF.Kc', fid: '6f20', fn: efDecKc }, { name: 'EF.KcGPRS', fid: '6f52', fn: efDecKc }, @@ -12477,6 +12511,7 @@ function netSimParams() { write_kc: netSimChk('netsim-write-kc'), sms_location: netSimChk('netsim-sms-location'), cb_clear: netSimChk('netsim-cb-clear'), + write_fplmn: netSimChk('netsim-write-fplmn'), }; const map = { mcc: 'netsim-mcc', mnc: 'netsim-mnc', lac: 'netsim-lac', cell_id: 'netsim-cell', @@ -12492,6 +12527,151 @@ function netSimParams() { return p; } +// ===== Network state monitor ===== +// Cached server-side view of the network-related EFs (read at equip, patched +// from net-sim writes, refreshed on demand) plus the simulated service state +// and the derived location/roaming view. No card polling: the panel fetches +// the cached state when the Phone tab opens and after net-sim/event actions. +const NET_STATE_FILES = [ + ['imsi', 'EF.IMSI'], ['ehplmn', 'EF.EHPLMN'], ['spdi', 'EF.SPDI'], + ['hplmnwact', 'EF.HPLMNwAcT'], ['loci', 'EF.LOCI'], ['psloci', 'EF.PSLOCI'], + ['epsloci', 'EF.EPSLOCI'], ['epsnsc', 'EF.EPSNSC'], ['cbmi', 'EF.CBMI'], + ['cbmir', 'EF.CBMIR'], ['smsstatus', 'EF.SMSS'], ['fplmn', 'EF.FPLMN'], +]; +const NET_STATE_STATUS = { + loci: { 0: 'updated', 1: 'not updated', 2: 'PLMN not allowed', 3: 'LA not allowed' }, + psloci: { 0: 'updated', 1: 'not updated', 2: 'PLMN not allowed' }, + epsloci: { 0: 'updated', 1: 'not updated', 2: 'roaming not allowed' }, +}; +const NET_STATE_AREA = { + loci: ['LAI', 'location_update_status'], + psloci: ['RAI', 'update_status'], + epsloci: ['TAI', 'eps_update_status'], +}; +const NET_STATE_ROAMING = { home: 'Home', equivalent: 'Home equivalent', guest: 'Guest (roaming)' }; +const NET_STATE_SERVICE = { + normal: ['text-emerald-600 dark:text-emerald-400', '● ', 'Normal service'], + limited: ['text-amber-600 dark:text-amber-500', '⚠ ', 'Limited service'], + none: ['text-red-500', '⛔ ', 'No service'], +}; +const NET_STATE_SOURCE = { init: 'init', write: 'write', read: 'read', refresh: 'refresh' }; + +function netStateFetch() { + return pysimFetch('/api/net-state').then(res => { + netStateRender(res && res.available ? res.state : null); + }).catch(() => {}); +} + +function netStateRefresh() { + return pysimFetch('/api/net-state-refresh', {}).then(res => { + netStateRender(res ? res.state : null); + }).catch(() => {}); +} + +function netStateStatusWord(key, raw) { + const v = parseInt(String(raw || '').replace(/^0x/i, ''), 16); + const word = (NET_STATE_STATUS[key] || {})[v]; + return word ? t(word) : (raw || ''); +} + +function netStateFlattenTitle(decoded) { + const data = decoded && decoded.data ? decoded.data : {}; + return efFlatten(data).map(r => (r[0] ? r[0] + ': ' : '') + r[1]).join(' · '); +} + +function netStatePlmnList(d, withTech) { + return (d.plmns || []).map(p => p.mcc + '-' + p.mnc + + (withTech && p.access_tech ? ' (' + p.access_tech + ')' : '')).join(', '); +} + +function netStateSummary(key, entry) { + if (!entry || !entry.present) { + return { text: '—', title: t('not present'), cls: 'text-gray-400' }; + } + const name = (NET_STATE_FILES.find(f => f[0] === key) || [])[1]; + const content = entry.kind === 'record' + ? { kind: 'record', records: entry.records || [] } + : { kind: 'transparent', data: entry.data || '' }; + const dec = name ? efDecodeFile(name, null, content) : null; + if (!dec) { + const raw = entry.kind === 'record' ? '' : (entry.data || ''); + return raw ? { text: raw, title: raw, cls: '' } + : { text: '—', title: t('empty'), cls: 'text-gray-400' }; + } + const d = dec.data; + if (!d || d.empty) return { text: '—', title: t('empty'), cls: 'text-gray-400' }; + let text = ''; + if (key === 'imsi') { + text = d.imsi || ''; + } else if (key === 'ehplmn' || key === 'spdi' || key === 'fplmn') { + text = netStatePlmnList(d); + } else if (key === 'hplmnwact') { + text = netStatePlmnList(d, true); + } else if (NET_STATE_AREA[key]) { + const [area, statusKey] = NET_STATE_AREA[key]; + const plmn = (d.mcc && d.mnc) ? d.mcc + '-' + d.mnc : null; + const lac = d.lac ? String(d.lac).replace(/^0x/i, '') : ''; + const status = d[statusKey] !== undefined ? netStateStatusWord(key, d[statusKey]) : ''; + text = (plmn ? area + ' ' + plmn + (lac ? '/' + lac : '') : area + ' —') + + (status ? ' · ' + status : ''); + } else if (key === 'epsnsc') { + const parts = []; + if (d.ksi_asme !== undefined) parts.push('KSI ' + d.ksi_asme); + if (d.uplink_nas_count !== undefined || d.downlink_nas_count !== undefined) { + parts.push('UL/DL ' + (d.uplink_nas_count || 0) + '/' + (d.downlink_nas_count || 0)); + } + if (d.nas_algorithms) parts.push('algo ' + d.nas_algorithms); + text = parts.join(' · '); + } else if (key === 'cbmi') { + text = (d.message_ids || []).map(v => v.toString(16).toUpperCase().padStart(4, '0')).join(', '); + } else if (key === 'cbmir') { + text = (d.ranges || []).map(r => r[0] + '–' + r[1]).join(', '); + } else if (key === 'smsstatus') { + text = entry.data || ''; + } + return { text: text || '—', title: netStateFlattenTitle(dec) || (entry.data || ''), cls: '' }; +} + +function netStateRender(state) { + const badge = document.getElementById('netstate-badge'); + if (!badge) return; + const svc = (state && state.service) || {}; + const known = NET_STATE_SERVICE[svc.state]; + badge.className = 'text-sm font-semibold ' + + (known ? known[0] : 'text-gray-400 dark:text-slate-500'); + badge.textContent = known ? known[1] + t(known[2]) : '○ ' + t('Undefined'); + const loc = state && state.network ? state.network.location : null; + const locEl = document.getElementById('netstate-location'); + if (loc) { + let h = '' + esc(loc.mcc + '-' + loc.mnc) + ''; + if (loc.country) h += ' · ' + esc(loc.country); + if (loc.operator) h += ' · ' + esc(loc.operator); + if (loc.roaming) h += ' · ' + esc(t(NET_STATE_ROAMING[loc.roaming] || loc.roaming)); + if (loc.rejected) h += ' ⛔ ' + esc(t('PLMN not allowed')) + ''; + locEl.innerHTML = h; + } else { + locEl.innerHTML = ''; + } + document.getElementById('netstate-area').textContent = loc ? (loc.area + ' ' + loc.lac) : ''; + document.getElementById('netstate-read').textContent = + state && state.read_at ? new Date(state.read_at * 1000).toLocaleTimeString() : ''; + const files = (state && state.files) || {}; + let html = ''; + for (const [key, name] of NET_STATE_FILES) { + const entry = files[key]; + const s = netStateSummary(key, entry); + const src = entry && entry.present + ? (NET_STATE_SOURCE[entry.source] || entry.source || '') : ''; + html += '
' + + '' + esc(name) + '' + + '' + esc(s.text) + '' + + '' + esc(src) + '' + + '
'; + } + document.getElementById('netstate-body').innerHTML = html || + '' + esc(t('No data')) + ''; +} + async function netSimRun(scenario) { const statusEl = document.getElementById('netsim-status'); statusEl.textContent = t('Running') + '...'; @@ -12509,6 +12689,7 @@ async function netSimRun(scenario) { statusEl.className = 'text-xs text-red-500 mb-1'; } pysimEventsRender(); + if (res.net_state) netStateRender(res.net_state); } catch (e) { statusEl.textContent = t('Error') + ': ' + e.message; statusEl.className = 'text-xs text-red-500 mb-1'; @@ -12848,7 +13029,7 @@ const LANG_RU = { 'No events configured.': 'Нет настроенных событий.', 'Fetched proactive commands:': 'Извлечённые проактивные команды:', 'Network simulation': 'Симуляция сети', - 'Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE). Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN and 5GS location files are never touched.': 'Воспроизводит шаблоны записи реального телефона при смене сетевых условий (подключение EPS, потеря сервиса, запрет роуминга, откат на 2G, приём SMS, перенастройка cell broadcast, AUTHENTICATE). Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE; FPLMN и 5GS location-файлы не затрагиваются.', + 'Replays the write patterns a real phone performs on network-condition changes (EPS attach, service loss, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration, AUTHENTICATE). Only UPDATE BINARY/RECORD, ENVELOPE and AUTHENTICATE are sent; FPLMN is written only by the permanent "PLMN not allowed" rejection (TS 31.102 4.2.16), and the 5GS location files are never touched.': 'Воспроизводит шаблоны записи реального телефона при смене сетевых условий (подключение EPS, потеря сервиса, запрет роуминга, откат на 2G, приём SMS, перенастройка cell broadcast, AUTHENTICATE). Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE; FPLMN записывается только при постоянном отказе «PLMN not allowed» (TS 31.102 4.2.16), а 5GS location-файлы не затрагиваются.', 'Cold boot': 'Холодная загрузка', 'EPS attach': 'Подключение EPS', '2G attach': 'Подключение 2G', @@ -13126,6 +13307,20 @@ const LANG_RU = { 'No ADM key in the card preset — not verified': 'Ключа ADM в предустановке нет — не подтверждён', 'SCP80 preset complete': 'Предустановка SCP80 заполнена', 'SCP81 preset complete': 'Предустановка SCP81 заполнена', + 'Network state': 'Сетевое состояние', + 'Undefined': 'Не определено', + 'PLMN not allowed': 'PLMN не разрешён', + 'roaming not allowed': 'роуминг не разрешён', + 'LA not allowed': 'LA не разрешён', + 'updated': 'обновлён', + 'not updated': 'не обновлён', + 'not present': 'отсутствует', + 'empty': 'пусто', + 'No data': 'Нет данных', + 'Home': 'Домашняя сеть', + 'Home equivalent': 'Эквивалентная домашней', + 'Guest (roaming)': 'Гостевая (роуминг)', + 'Rejection: write FPLMN': 'Отказ: записать FPLMN', 'TERMINAL PROFILE': 'TERMINAL PROFILE', 'Preset (device model)': 'Пресет (модель устройства)', 'Profile (hex)': 'Профиль (hex)', diff --git a/frontend/sw.js b/frontend/sw.js index 4dc6c78..6f18509 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v202'; +const CACHE = 'simple-v203'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/card_state.test.js b/frontend/tests/card_state.test.js index 1c9ff69..b42b073 100644 --- a/frontend/tests/card_state.test.js +++ b/frontend/tests/card_state.test.js @@ -90,6 +90,7 @@ function setup() { globalThis.pysimSetConnected = v => calls.connected.push(v); globalThis.pysimResetCardData = refresh => calls.resets.push(refresh); globalThis.pysimApplyAvailability = () => {}; + globalThis.netStateRender = () => {}; globalThis.isViewVisible = () => true; globalThis.pysimProactiveLogRender = () => { calls.proactive++; }; globalThis.cardsAutoSelectByIccid = iccid => { calls.autoIccid.push(iccid); return -1; }; diff --git a/frontend/tests/ef_decode.test.js b/frontend/tests/ef_decode.test.js index db35b56..2fa49d0 100644 --- a/frontend/tests/ef_decode.test.js +++ b/frontend/tests/ef_decode.test.js @@ -24,7 +24,7 @@ function extractFunc(src, name) { const FNS = [ 'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be', 'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText', - 'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecPlmnList', 'efDecPlmnWact', + 'efDecIccid', 'efDecImsi', 'efDecLi', 'efDecServiceTable', 'efDecPlmnList', 'efDecFplmn', 'efDecPlmnWact', 'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc', 'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl', 'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn', @@ -84,6 +84,12 @@ test('EF decoders match the pinned spec / pySim test vectors', () => { assert.deepStrictEqual(efDecEcc(efBytes('19f101')), { number: '911', esc: '0x01' }); assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 }); assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] }); + // EF.FPLMN: a FFFFFF gap in any position is skipped, not a terminator + assert.deepStrictEqual(efDecFplmn(efBytes('22F860FFFFFF62F210')), + { plmns: [{ mcc: '228', mnc: '06' }, { mcc: '262', mnc: '01' }] }); + assert.deepStrictEqual(efDecFplmn(efBytes('FFFFFFFF')), { plmns: [] }); + assert.strictEqual(efFindDecoder('EF.EHPLMN', null).fn, efDecPlmnList); + assert.strictEqual(efFindDecoder('EF.FPLMN', null).fn, efDecFplmn); assert.deepStrictEqual(efDecCbmi(efBytes('0010FFFF0020')), { message_ids: [16, 32] }); assert.deepStrictEqual(efDecCbmir(efBytes('0000FFFEFFFF')), { ranges: [['0000', 'FFFE']] }); assert.deepStrictEqual(efDecPhase(efBytes('03')), { phase: 'phase 2 and higher' }); diff --git a/frontend/tests/html.test.js b/frontend/tests/html.test.js index 086f3d5..2664d78 100644 --- a/frontend/tests/html.test.js +++ b/frontend/tests/html.test.js @@ -70,6 +70,16 @@ test('card-dependent profiler buttons show the ICCID and need a readable one', ( assert.match(html, /id="snapshot-new-btn"[\s\S]*?<\/span><\/button>/); }); +test('network state monitor panel lives next to the network simulation', () => { + assert.match(html, /data-l10n="Network state"/); + assert.match(html, /id="netstate-badge"/); + assert.match(html, /id="netstate-refresh-btn" data-needs="card"/); + assert.match(html, /id="netstate-body"/); + assert.match(html, /id="netsim-write-fplmn"/); + // the monitor is rendered from the cached server state (no file polling) + assert.ok(html.includes("netStateFetch()")); +}); + test('labelled containers use fieldsets with embedded legends', () => { const legendCls = ' els[id] || null }; + return els; +} + +function decoder(map) { + globalThis.efDecodeFile = (name, fid, content) => + map[name] ? { data: map[name] } : null; +} + +test('netStateSummary renders decoded file contents compactly', () => { + setup(); + decoder({ + 'EF.LOCI': { tmsi: '3E905D6B', mcc: '262', mnc: '01', lac: '0x6CD7', + tmsi_time: '0xFF', location_update_status: '0x00' }, + 'EF.FPLMN': { plmns: [{ mcc: '262', mnc: '01' }, { mcc: '246', mnc: '81' }] }, + 'EF.HPLMNwAcT': { plmns: [{ mcc: '262', mnc: '01', access_tech: 'LTE' }] }, + }); + const loci = netStateSummary('loci', { present: true, kind: 'transparent', data: 'AA' }); + assert.strictEqual(loci.text, 'LAI 262-01/6CD7 · updated'); + assert.ok(loci.title.includes('3E905D6B')); + 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)'); + const absent = netStateSummary('loci', { present: false }); + assert.strictEqual(absent.text, '—'); + assert.strictEqual(absent.title, 'not present'); +}); + +test('netStateSummary marks rejection statuses', () => { + setup(); + decoder({ + 'EF.LOCI': { mcc: '246', mnc: '81', lac: '0xFFFE', location_update_status: '0x02' }, + }); + const loci = netStateSummary('loci', { present: true, kind: 'transparent', data: 'AA' }); + assert.strictEqual(loci.text, 'LAI 246-81/FFFE · PLMN not allowed'); +}); + +test('netStateRender paints the service badge, location and rows', () => { + const els = setup(); + decoder({ 'EF.IMSI': { imsi: '262011234567890' } }); + netStateRender({ + service: { state: 'limited', source: 'net-sim:roaming_denied' }, + read_at: 1700000000, + files: { imsi: { present: true, kind: 'transparent', data: 'AA', source: 'write' } }, + network: { location: { mcc: '262', mnc: '01', country: 'Germany', + operator: 'Telekom', roaming: 'guest', rejected: true, + area: 'LAI', lac: '6CD7', source: 'write' } }, + }); + assert.strictEqual(els['netstate-badge'].textContent, '⚠ Limited service'); + assert.ok(els['netstate-badge'].className.includes('text-amber-600')); + assert.ok(els['netstate-location'].innerHTML.includes('262-01')); + assert.ok(els['netstate-location'].innerHTML.includes('Germany')); + assert.ok(els['netstate-location'].innerHTML.includes('Guest (roaming)')); + assert.ok(els['netstate-location'].innerHTML.includes('PLMN not allowed')); + assert.strictEqual(els['netstate-area'].textContent, 'LAI 6CD7'); + assert.ok(els['netstate-body'].innerHTML.includes('EF.IMSI')); + assert.ok(els['netstate-body'].innerHTML.includes('262011234567890')); + assert.ok(els['netstate-body'].innerHTML.includes('>write<')); +}); + +test('netStateRender shows Undefined until something is simulated', () => { + const els = setup(); + decoder({}); + netStateRender(null); + assert.strictEqual(els['netstate-badge'].textContent, '○ Undefined'); + assert.ok(els['netstate-badge'].className.includes('text-gray-400')); + assert.ok(els['netstate-body'].innerHTML.includes('EF.LOCI')); + assert.ok(els['netstate-body'].innerHTML.includes('—')); +}); + +test('netStateRender shows the green normal-service badge', () => { + const els = setup(); + decoder({}); + netStateRender({ service: { state: 'normal' }, files: {} }); + assert.strictEqual(els['netstate-badge'].textContent, '● Normal service'); + assert.ok(els['netstate-badge'].className.includes('text-emerald-600')); +}); diff --git a/frontend/tests/phone_tabs.test.js b/frontend/tests/phone_tabs.test.js index 5cc6e34..babd22c 100644 --- a/frontend/tests/phone_tabs.test.js +++ b/frontend/tests/phone_tabs.test.js @@ -28,7 +28,8 @@ const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' + 'globalThis.pysimProactiveLogRender = () => { globalThis._log = (globalThis._log || 0) + 1; };\n' + 'globalThis.pysimPollStatusInit = () => { globalThis._poll = (globalThis._poll || 0) + 1; };\n' + 'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n' + - 'globalThis.tpRefresh = () => { globalThis._tp = (globalThis._tp || 0) + 1; };\n'; + 'globalThis.tpRefresh = () => { globalThis._tp = (globalThis._tp || 0) + 1; };\n' + + 'globalThis.netStateFetch = () => { globalThis._netstate = (globalThis._netstate || 0) + 1; };\n'; eval(code); function makeClassList() { diff --git a/pyproject.toml b/pyproject.toml index 8ce66e2..87415fb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "2.6.2" +version = "2.7.0" 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/netsim.py b/pysim_simple_server/netsim.py index 06a7605..3a094ec 100644 --- a/pysim_simple_server/netsim.py +++ b/pysim_simple_server/netsim.py @@ -24,10 +24,15 @@ import time # Candidate paths per logical file. The first existing one is used, so a # USIM card is served from ADF.USIM and a GSM SIM from DF.GSM/DF.TELECOM. FILE_PATHS = { + 'imsi': ['ADF.USIM/6F07', 'DF.GSM/6F07'], + 'ehplmn': ['ADF.USIM/6FD9'], + 'spdi': ['ADF.USIM/6FCD'], + 'hplmnwact': ['ADF.USIM/6F62', 'DF.GSM/6F62'], 'epsnsc': ['ADF.USIM/6FE4'], 'loci': ['ADF.USIM/6F7E', 'DF.GSM/6F7E'], 'psloci': ['ADF.USIM/6F73', 'DF.GSM/6F73'], 'epsloci': ['ADF.USIM/6FE3'], + 'fplmn': ['ADF.USIM/6F7B', 'DF.GSM/6F7B'], 'kc': ['ADF.USIM/4F20', 'DF.GSM/6F08'], 'kcgprs': ['ADF.USIM/4F52', 'DF.GSM/6F09'], 'smsstatus': ['ADF.USIM/6F43', 'DF.TELECOM/6F43', 'DF.GSM/6F43'], @@ -35,6 +40,19 @@ FILE_PATHS = { 'cbmir': ['ADF.USIM/6F50', 'DF.GSM/6F50'], } +# Which simulated service state each scenario establishes (Network state +# panel). Scenarios not listed (churn, cb_reconfig, authenticate) do not +# touch the network registration and leave the previous state. +SCENARIO_SERVICE = { + 'cold_boot': 'none', + 'attach_eps': 'normal', + 'attach_2g': 'normal', + 'service_lost': 'none', + 'limited_service': 'limited', + 'roaming_denied': 'limited', + 'sms_received': 'normal', +} + # File status codes (LOCI/PSLOCI/EPSLOCI update status). ST_UPDATED = 0x00 ST_NOT_UPDATED = 0x01 @@ -91,6 +109,19 @@ def _plmn_bytes(plmn_hex): return bytes.fromhex(_norm_hex(plmn_hex, 3)) +def parse_imsi(data_hex): + """EF.IMSI content -> IMSI digits, or None. Byte 1 is the length; the + high nibble of byte 2 is the parity/identity nibble (TS 31.102 4.2.2).""" + h = _norm_hex(data_hex) + if len(h) < 4: + return None + body = h[2:] + swapped = ''.join(body[i + 1] + body[i] + for i in range(0, len(body) - 1, 2)) + digits = re.sub(r'F+$', '', swapped) + return digits[1:] or None + + # ---- EPS NAS Security Context (EF.EPSNSC, TS 31.102 4.2.92) ---- @@ -150,9 +181,10 @@ def build_loci(tmsi_hex, plmn_hex, lac_hex, status=ST_UPDATED, rfu=0xFF): + bytes([rfu & 0xFF, status & 0xFF])).hex().upper() -def build_loci_dummy(plmn_hex): - """Service lost: TMSI FF, PLMN kept, LAC FFFE, status 01.""" - return build_loci('FFFFFFFF', plmn_hex, 'FFFE', ST_NOT_UPDATED) +def build_loci_dummy(plmn_hex, status=ST_NOT_UPDATED): + """Service lost: TMSI FF, PLMN kept, LAC FFFE, status 01 (010 = PLMN not + allowed on a permanent rejection; UICC_NAA.md C3/C3a).""" + return build_loci('FFFFFFFF', plmn_hex, 'FFFE', status) def build_psloci(ptmsi_hex, sig_hex, plmn_hex, lac_hex, rac_hex, @@ -165,9 +197,8 @@ def build_psloci(ptmsi_hex, sig_hex, plmn_hex, lac_hex, rac_hex, + bytes([status & 0xFF])).hex().upper() -def build_psloci_dummy(plmn_hex): - return build_psloci('FFFFFFFF', 'FFFFFF', plmn_hex, 'FFFE', 'FF', - ST_NOT_UPDATED) +def build_psloci_dummy(plmn_hex, status=ST_NOT_UPDATED): + return build_psloci('FFFFFFFF', 'FFFFFF', plmn_hex, 'FFFE', 'FF', status) def build_epsloci(guti_hex, plmn_hex, tac_hex, status=ST_UPDATED): @@ -177,10 +208,39 @@ def build_epsloci(guti_hex, plmn_hex, tac_hex, status=ST_UPDATED): + bytes([status & 0xFF])).hex().upper() -def build_epsloci_dummy(plmn_hex): - """GUTI header 0B F6 + PLMN, identity wiped; TAI PLMN + FFFF; status 01.""" - return build_epsloci('0BF6' + plmn_hex + 'FF' * 7, plmn_hex, 'FFFF', - ST_NOT_UPDATED) +def build_epsloci_dummy(status=None): + """EPSLOCI dummy: the EPS-mobile-identity pair `0B F6` (content length + + GUTI type octet) is kept, the GUTI/TAI/status bytes are wiped + (UICC_NAA.md C3). A rejection status (010 = roaming not allowed) is the + only byte written after the wipe (C3a).""" + out = '0BF6' + 'FF' * (15 if status is not None else 16) + if status is not None: + out += '%02X' % (status & 0xFF) + return out + + +def fplmn_entries(data_hex): + """EF.FPLMN content as 3-byte PLMN entries; 'FFFFFF' marks an empty slot + (TS 31.102 4.2.16: valid in any position, never a terminator).""" + h = _norm_hex(data_hex) + return [h[i:i + 6] for i in range(0, len(h) - 5, 6)] + + +def insert_fplmn(data_hex, plmn_hex): + """Store a denied PLMN per TS 31.102 4.2.16: fill the first empty slot, + otherwise shift the list left and append (the longest-held entry is + lost). Returns the full updated EF content.""" + plmn = _norm_hex(plmn_hex, 3) + entries = fplmn_entries(data_hex) + if not entries: + return plmn + try: + idx = entries.index('FFFFFF') + except ValueError: + entries = entries[1:] + [plmn] + else: + entries[idx] = plmn + return ''.join(entries) # ---- Ciphering keys and CB/SMS files ---- @@ -419,7 +479,7 @@ class NetSimRunner: data = _pad_ff(bytes.fromhex(data_hex), size).hex().upper() _out, sw = self.lchan.update_binary(data) return self._check(data, sw, action='update_binary', file=label or key, - path=path) + key=key, path=path) def write_record(self, key, data_hex, record=1, pad=True, label=None, optional=False): try: @@ -435,12 +495,68 @@ class NetSimRunner: data = _pad_ff(bytes.fromhex(data_hex), size).hex().upper() _out, sw = self.lchan.update_record(record, data) return self._check(data, sw, action='update_record', file=label or key, - path=path, record=record) + key=key, path=path, record=record) def read_binary_current(self): data, sw = self.lchan.read_binary() return (data or ''), sw + # -- forbidden PLMNs (permanent #11 rejection, UICC_NAA.md C3a) + + def home_plmns(self): + """3-byte HPLMN/EHPLMN entries from the cached Network-state monitor; + TS 23.122: the home network is never stored in EF.FPLMN.""" + out = set() + files = ((getattr(self.srv, 'net_state', None) or {}).get('files') or {}) + + def flat(key): + f = files.get(key) or {} + if f.get('present') and f.get('kind') == 'transparent': + return _norm_hex(f.get('data')) + return None + + hp = flat('hplmnwact') + if hp: + # HPLMNwAcT records are 5 bytes (PLMN + access technology); the + # first record is the HPLMN (TS 31.102 4.2.5). + out.add(hp[0:6]) + ehp = flat('ehplmn') + if ehp: + out.update(fplmn_entries(ehp)) + if not out: + # Fallback: the HPLMN is the IMSI's MCC/MNC. The IMSI does not + # encode the MNC length, so both interpretations are guarded. + imsi = parse_imsi(flat('imsi')) + if imsi and len(imsi) >= 5 and imsi[:3].isdigit(): + try: + out.add(plmn_bcd(imsi[0:3], imsi[3:5])) + if len(imsi) >= 6 and imsi[5].isdigit(): + out.add(plmn_bcd(imsi[0:3], imsi[3:6])) + except ValueError: + pass + return {h for h in out if h and h != 'FFFFFF'} + + def write_fplmn(self, plmn_hex, optional=True): + path = None + try: + path = self._open('fplmn') + except StepError as e: + if optional: + self._add('skip', file='fplmn', note=str(e)) + return None + raise + plmn = _norm_hex(plmn_hex, 3) + if plmn in self.home_plmns(): + self._add('skip', file='fplmn', + note='home PLMN is never stored (TS 23.122)') + return None + size = self.lchan.selected_file_size() + data, sw = self.read_binary_current() + if sw != '9000' or not data: + data = 'FF' * (size or 12) + new_data = insert_fplmn(data, plmn) + return self.write_binary('fplmn', new_data, pad=False, label='fplmn') + def read_record_current(self, record=1): data, sw = self.lchan.read_record(record) return (data or ''), sw @@ -499,11 +615,16 @@ class NetSimRunner: label='epsloci', optional=True) def write_dummy_locations(self, status=ST_NOT_UPDATED): - self.write_binary('loci', build_loci_dummy(self.plmn), label='loci', - optional=True) - self.write_binary('psloci', build_psloci_dummy(self.plmn), label='psloci', - optional=True) - self.write_binary('epsloci', build_epsloci_dummy(self.plmn), + """Service loss: LOCI/PSLOCI keep the PLMN with the dummy status 01; + EPSLOCI is wiped to `0B F6` + FF (UICC_NAA.md C3). A rejection status + (010 = PLMN not allowed) is written to all three; EPSLOCI then carries + that status byte as the only byte after the wipe (C3a).""" + eps_status = None if status == ST_NOT_UPDATED else status + self.write_binary('loci', build_loci_dummy(self.plmn, status), + label='loci', optional=True) + self.write_binary('psloci', build_psloci_dummy(self.plmn, status), + label='psloci', optional=True) + self.write_binary('epsloci', build_epsloci_dummy(eps_status), label='epsloci', optional=True) def invalidate_kc(self): @@ -571,12 +692,18 @@ class NetSimRunner: self.write_dummy_locations() def sc_roaming_denied(self): + # Permanent rejection (NAS cause #11): the Location status event is + # indistinguishable from limited service (TS 102 223 8.27) - the + # difference is the 010 status bytes and the EF.FPLMN entry + # (UICC_NAA.md C3a). if self.p('send_event', True): self.send_location_status(self.status(LOC_STATUS_LIMITED)) if self.p('invalidate_epsnsc', True): - self.invalidate_epsnsc(keep_key=bool(self.p('keep_kasme', True))) + self.invalidate_epsnsc(keep_key=False) if self.p('dummy_locations', True): self.write_dummy_locations(status=self.status(ST_PLMN_NOT_ALLOWED)) + if self.p('write_fplmn', True): + self.write_fplmn(self.plmn) def sc_churn(self): count = int(self.p('churn_count', 3)) diff --git a/pysim_simple_server/netstate.py b/pysim_simple_server/netstate.py new file mode 100644 index 0000000..08e31ed --- /dev/null +++ b/pysim_simple_server/netstate.py @@ -0,0 +1,289 @@ +# 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 diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 325af93..510411e 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -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: diff --git a/tests/test_netsim.py b/tests/test_netsim.py index 377806c..c97c31e 100644 --- a/tests/test_netsim.py +++ b/tests/test_netsim.py @@ -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') diff --git a/tests/test_netstate.py b/tests/test_netstate.py new file mode 100644 index 0000000..94fd34b --- /dev/null +++ b/tests/test_netstate.py @@ -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()