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:
@@ -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; };
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 class="profiler-iccid-label" data-iccid-sep=": "><\/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 = '<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400"';
|
||||
for (const label of ['STK menu', 'STATUS and Polling', 'TERMINAL PROFILE',
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
|
||||
|
||||
function extractFunc(src, name) {
|
||||
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
|
||||
const m = re.exec(src);
|
||||
if (!m) throw new Error('function ' + name + ' not found');
|
||||
let i = m.index + m[0].length - 1;
|
||||
let depth = 0;
|
||||
for (; i < src.length; i++) {
|
||||
if (src[i] === '{') depth++;
|
||||
else if (src[i] === '}') {
|
||||
depth--;
|
||||
if (depth === 0) break;
|
||||
}
|
||||
}
|
||||
return src.slice(m.index, i + 1);
|
||||
}
|
||||
|
||||
let code = '';
|
||||
for (const fn of ['netStateStatusWord', 'netStateFlattenTitle', 'netStatePlmnList',
|
||||
'netStateSummary', 'netStateRender']) {
|
||||
code += extractFunc(html, fn) + '\n';
|
||||
}
|
||||
const CONSTS = {
|
||||
NET_STATE_FILES: ['[', '];'],
|
||||
NET_STATE_STATUS: ['{', '};'],
|
||||
NET_STATE_AREA: ['{', '};'],
|
||||
NET_STATE_ROAMING: ['{', '};'],
|
||||
NET_STATE_SERVICE: ['{', '};'],
|
||||
NET_STATE_SOURCE: ['{', '};'],
|
||||
};
|
||||
for (const [name, [open, close]] of Object.entries(CONSTS)) {
|
||||
const re = new RegExp('const ' + name + ' = \\' + open + '[\\s\\S]*?\\' + close);
|
||||
const m = re.exec(html);
|
||||
if (!m) throw new Error('const ' + name + ' not found');
|
||||
code += m[0].replace(/^const /, 'var ') + '\n';
|
||||
}
|
||||
code += 'globalThis.t = s => 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'));
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user