net: network-state monitor and permanent-rejection FPLMN (v2.7.0)

server:
- netsim: LOCI/PSLOCI dummy builders take a status; the EPSLOCI dummy is
  wiped to 0B F6 + FF per UICC_NAA.md C3; roaming_denied now emulates the
  permanent 'PLMN not allowed' rejection (C3a): status 010, EPSNSC dropped,
  EF.FPLMN append with TS 31.102 4.2.16 shift semantics and a home-PLMN
  guard (HPLMNwAcT/EHPLMN, IMSI fallback), optional 'Rejection: write FPLMN'
  toggle; write steps carry the logical key; SCENARIO_SERVICE map;
  insert_fplmn/fplmn_entries/parse_imsi helpers
- netstate.py: cached per-session monitor state for the 12 network EFs,
  step-based patch, simulated service state, derived location with country/
  operator (optional MCC/MNC list) and roaming class
- server: monitor read at equip right after a readable ICCID (skipped
  otherwise), cleared on card removal; GET /api/net-state and POST
  /api/net-state-refresh; net-sim patches the cache from the written bytes
  and re-reads EF.IMSI; Location-status events set the service state and
  re-read EF.IMSI (multi-IMSI applets)

frontend:
- Phone tab: 'Network state' panel next to Network simulation with the
  simulated service badge (Undefined until simulated; normal/limited/no
  service + rejection marker), location/roaming line, compact per-file
  summaries with full-decode tooltips and a Refresh button; no card polling
- EF decoders: EF.FPLMN (FFFFFF gaps are not terminators) and EF.EHPLMN
- i18n EN/RU, help updated; SW cache simple-v203

tests: 295 Python / 453 frontend
This commit is contained in:
2026-09-20 12:26:17 +03:00
parent 81b50199cc
commit b1539d7cd5
15 changed files with 1190 additions and 37 deletions
+199 -4
View File
@@ -939,9 +939,10 @@
<div id="pysim-event-list" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
</fieldset>
</div>
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
<div class="flex flex-wrap items-start gap-4 mb-4">
<fieldset class="flex-1 border border-gray-200 dark:border-slate-700 rounded p-3" style="min-width:30rem">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Network simulation">Network simulation</legend>
<div class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="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 and 5GS location files are never touched.</div>
<div class="text-xs text-gray-500 dark:text-slate-400 mb-2" data-l10n="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 &quot;PLMN not allowed&quot; rejection (TS 31.102 4.2.16), and the 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 &quot;PLMN not allowed&quot; rejection (TS 31.102 4.2.16), and the 5GS location files are never touched.</div>
<div class="flex flex-wrap gap-1.5 mb-2">
<button data-needs="card" onclick="netSimRun('cold_boot')" class="px-2.5 py-1 text-xs rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Cold boot">Cold boot</button>
<button data-needs="card" onclick="netSimRun('attach_eps')" class="px-2.5 py-1 text-xs rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="EPS attach">EPS attach</button>
@@ -1017,6 +1018,7 @@
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-invalidate-epsnsc" checked class="rounded"> <span data-l10n="Invalidate EPSNSC">Invalidate EPSNSC</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-keep-kasme" checked class="rounded"> <span data-l10n="Keep KASME (KSI 07 only)">Keep KASME (KSI 07 only)</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-write-kc" checked class="rounded"> <span data-l10n="Write/invalidate Kc">Write/invalidate Kc</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-write-fplmn" checked class="rounded"> <span data-l10n="Rejection: write FPLMN">Rejection: write FPLMN</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-sms-location" checked class="rounded"> <span data-l10n="SMS: rewrite location too">SMS: rewrite location too</span></label>
<label class="flex items-center gap-1"><input type="checkbox" id="netsim-cb-clear" class="rounded"> <span data-l10n="CB: clear lists instead of writing IDs">CB: clear lists instead of writing IDs</span></label>
<label class="flex items-center gap-1"><span data-l10n="Churn count">Churn count</span><input id="netsim-churn-count" type="number" value="3" min="1" max="20" class="w-14 border border-gray-300 dark:border-slate-600 text-xs rounded px-1 py-0.5 dark:bg-slate-800"></label>
@@ -1027,6 +1029,18 @@
<div id="netsim-status" class="text-xs text-gray-400 dark:text-slate-500 mb-1"></div>
<div id="netsim-log" class="text-xs font-mono text-gray-600 dark:text-slate-400 max-h-[30vh] overflow-auto"></div>
</fieldset>
<fieldset class="w-72 shrink-0 border border-gray-200 dark:border-slate-700 rounded p-3">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Network state">Network state</legend>
<div class="flex items-center justify-between gap-2 mb-1">
<span id="netstate-badge" class="text-sm font-semibold text-gray-400 dark:text-slate-500"></span>
<button id="netstate-refresh-btn" data-needs="card" onclick="netStateRefresh()" class="px-2 py-0.5 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Refresh">Refresh</button>
</div>
<div id="netstate-location" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
<div id="netstate-area" class="text-xs font-mono text-gray-400 dark:text-slate-500"></div>
<div id="netstate-read" class="text-xs text-gray-400 dark:text-slate-500 mt-1"></div>
<div id="netstate-body" class="mt-2 text-xs font-mono text-gray-600 dark:text-slate-400 max-h-[45vh] overflow-auto"></div>
</fieldset>
</div>
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mt-4">
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Fetched proactive commands:">Fetched proactive commands:</legend>
<div id="pysim-proactive-log" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
@@ -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 = '<span class="font-mono">' + esc(loc.mcc + '-' + loc.mnc) + '</span>';
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 += ' <span class="text-red-500">⛔ ' + esc(t('PLMN not allowed')) + '</span>';
locEl.innerHTML = h;
} else {
locEl.innerHTML = '<span class="text-gray-400"></span>';
}
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 += '<div class="flex items-baseline gap-1" title="' + esc(s.title || '') + '">' +
'<span class="w-24 shrink-0 text-gray-500 dark:text-slate-400">' + esc(name) + '</span>' +
'<span class="flex-1 min-w-0 truncate ' + (s.cls || '') + '">' + esc(s.text) + '</span>' +
'<span class="text-gray-300 dark:text-slate-600">' + esc(src) + '</span>' +
'</div>';
}
document.getElementById('netstate-body').innerHTML = html ||
'<span class="text-gray-400">' + esc(t('No data')) + '</span>';
}
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 &quot;PLMN not allowed&quot; 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)',