From b1539d7cd5daa9f70cdd36c40ed76380d753a8b7 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?=
=?UTF-8?q?=D0=B8=D0=BD?=
Date: Sun, 20 Sep 2026 12:26:17 +0300
Subject: [PATCH] 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
---
frontend/help-ru.html | 7 +-
frontend/help.html | 7 +-
frontend/index.html | 203 ++++++++++++++++++++-
frontend/sw.js | 2 +-
frontend/tests/card_state.test.js | 1 +
frontend/tests/ef_decode.test.js | 8 +-
frontend/tests/html.test.js | 10 ++
frontend/tests/netstate.test.js | 131 ++++++++++++++
frontend/tests/phone_tabs.test.js | 3 +-
pyproject.toml | 2 +-
pysim_simple_server/netsim.py | 163 +++++++++++++++--
pysim_simple_server/netstate.py | 289 ++++++++++++++++++++++++++++++
pysim_simple_server/server.py | 162 ++++++++++++++++-
tests/test_netsim.py | 79 +++++++-
tests/test_netstate.py | 160 +++++++++++++++++
15 files changed, 1190 insertions(+), 37 deletions(-)
create mode 100644 frontend/tests/netstate.test.js
create mode 100644 pysim_simple_server/netstate.py
create mode 100644 tests/test_netstate.py
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 @@
Холодная загрузка — инвалидация EPSNSC (KSI 07, ключ стирается) и фиктивные location-файлы.
Подключение EPS / 2G — запись реального контекста EPS NAS (KSI, KASME, счётчики NAS, алгоритм) и реальных LOCI/PSLOCI/EPSLOCI (в 2G дополнительно реальные Kc/KcGPRS).
- Потеря сервиса / ограниченный сервис / запрет роуминга — событие Location status (только если карта на него подписана), инвалидация EPSNSC (по желанию с сохранением старого KASME), фиктивные location-файлы (PLMN сохраняется, LAC FFFE, статус 01/02) и инвалидация Kc.
+ Потеря сервиса / ограниченный сервис / запрет роуминга — событие Location status (только если карта на него подписана), инвалидация EPSNSC (по желанию с сохранением старого KASME), фиктивные location-файлы (PLMN сохраняется, LAC FFFE, статус 01) и инвалидация Kc. Запрет роуминга эмулирует постоянный отказ «PLMN not allowed» (NAS cause #11): location-файлы получают статус 010 (EPSLOCI стирается до 0B F6 + статус), запрещённый VPLMN дописывается в EF.FPLMN по семантике сдвига из TS 31.102 §4.2.16 (домашняя сеть не записывается), ключевой контекст стирается.
Серия переподключений — реальная → невалидная запись EPSNSC подряд (число циклов и задержка настраиваются).
Принято SMS — инкремент счётчика EF.SMSstatus (чтение-изменение-запись) и, по желанию, перезапись location-файлов.
Перенастройка 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), переключатели сценария и число циклов/задержку. Журнал шагов показывает каждую запись, 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 @@
Cold boot — invalidate EPSNSC (KSI 07, key wiped) and dummy the location files.
EPS attach / 2G attach — store a real EPS NAS context (KSI, KASME, NAS counts, algorithm) and write real LOCI/PSLOCI/EPSLOCI (2G also writes real Kc/KcGPRS).
- Service lost / Limited service / Roaming denied — send the Location status event (only when the card subscribed to it), invalidate EPSNSC (optionally keeping the old KASME), dummy the location files (PLMN kept, LAC FFFE, status 01/02) and invalidate Kc.
+ Service lost / Limited service / Roaming denied — send the Location status event (only when the card subscribed to it), invalidate EPSNSC (optionally keeping the old KASME), dummy the location files (PLMN kept, LAC FFFE, status 01) and invalidate Kc. Roaming denied emulates a permanent “PLMN not allowed” rejection (NAS cause #11): the location files carry status 010 (EPSLOCI wiped to 0B F6 + status), the denied VPLMN is appended to EF.FPLMN with the shift-list semantics of TS 31.102 §4.2.16 (never the home PLMN) and the key context is dropped.
Churn — replay real → invalid EPSNSC records back-to-back (count and delay configurable).
SMS received — bump the EF.SMSstatus counter (read-modify-write) and optionally rewrite the location files.
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 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 state — Undefined 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.
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 = ' s;\n';
+code += 'globalThis.esc = s => String(s);\n';
+code += 'globalThis.efFlatten = d => Object.entries(d || {}).map(([k, v]) => [k, String(v)]);\n';
+eval(code);
+
+function setup() {
+ const els = {};
+ for (const id of ['netstate-badge', 'netstate-location', 'netstate-area',
+ 'netstate-read', 'netstate-body']) {
+ els[id] = { className: '', textContent: '', innerHTML: '' };
+ }
+ globalThis.document = { getElementById: id => 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()