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
+5 -2
View File
@@ -483,13 +483,16 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Холодная загрузка</strong> — инвалидация EPSNSC (KSI 07, ключ стирается) и фиктивные location-файлы.</li> <li><strong>Холодная загрузка</strong> — инвалидация EPSNSC (KSI 07, ключ стирается) и фиктивные location-файлы.</li>
<li><strong>Подключение EPS / 2G</strong> — запись реального контекста EPS NAS (KSI, KASME, счётчики NAS, алгоритм) и реальных LOCI/PSLOCI/EPSLOCI (в 2G дополнительно реальные Kc/KcGPRS).</li> <li><strong>Подключение EPS / 2G</strong> — запись реального контекста EPS NAS (KSI, KASME, счётчики NAS, алгоритм) и реальных LOCI/PSLOCI/EPSLOCI (в 2G дополнительно реальные Kc/KcGPRS).</li>
<li><strong>Потеря сервиса / ограниченный сервис / запрет роуминга</strong> — событие Location status (только если карта на него подписана), инвалидация EPSNSC (по желанию с сохранением старого KASME), фиктивные location-файлы (PLMN сохраняется, LAC <code class="font-mono text-sm">FFFE</code>, статус <code class="font-mono text-sm">01</code>/<code class="font-mono text-sm">02</code>) и инвалидация Kc.</li> <li><strong>Потеря сервиса / ограниченный сервис / запрет роуминга</strong> — событие Location status (только если карта на него подписана), инвалидация EPSNSC (по желанию с сохранением старого KASME), фиктивные location-файлы (PLMN сохраняется, LAC <code class="font-mono text-sm">FFFE</code>, статус <code class="font-mono text-sm">01</code>) и инвалидация Kc. <strong>Запрет роуминга</strong> эмулирует постоянный отказ &laquo;PLMN not allowed&raquo; (NAS cause #11): location-файлы получают статус <code class="font-mono text-sm">010</code> (EPSLOCI стирается до <code class="font-mono text-sm">0B F6</code> + статус), запрещённый VPLMN дописывается в <strong>EF.FPLMN</strong> по семантике сдвига из TS 31.102 §4.2.16 (домашняя сеть не записывается), ключевой контекст стирается.</li>
<li><strong>Серия переподключений</strong> — реальная → невалидная запись EPSNSC подряд (число циклов и задержка настраиваются).</li> <li><strong>Серия переподключений</strong> — реальная → невалидная запись EPSNSC подряд (число циклов и задержка настраиваются).</li>
<li><strong>Принято SMS</strong> — инкремент счётчика EF.SMSstatus (чтение-изменение-запись) и, по желанию, перезапись location-файлов.</li> <li><strong>Принято SMS</strong> — инкремент счётчика EF.SMSstatus (чтение-изменение-запись) и, по желанию, перезапись location-файлов.</li>
<li><strong>Перенастройка CB</strong> — запись списков CBMI/CBMIR или их очистка (все FF).</li> <li><strong>Перенастройка CB</strong> — запись списков CBMI/CBMIR или их очистка (все FF).</li>
<li><strong>AUTHENTICATE</strong> — команда AUTHENTICATE (3G/EPS/5G, <code class="font-mono text-sm">00 88 00 81 22</code>) с заданными или случайными RAND/AUTN и показ ответа (успех <code class="font-mono text-sm">DB</code> или ошибка синхронизации <code class="font-mono text-sm">DC</code> с AUTS).</li> <li><strong>AUTHENTICATE</strong> — команда AUTHENTICATE (3G/EPS/5G, <code class="font-mono text-sm">00 88 00 81 22</code>) с заданными или случайными RAND/AUTN и показ ответа (успех <code class="font-mono text-sm">DB</code> или ошибка синхронизации <code class="font-mono text-sm">DC</code> с AUTS).</li>
</ul> </ul>
<p class="text-sm mb-3"><strong>Параметры</strong> (свёрнуты) задают оператора (поиск по мировому списку 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-файлы никогда не записываются (ни один наблюдаемый телефон этого не делает); записи меняют карту и видны в последующих сравнениях снимков.</p> <p class="text-sm mb-3"><strong>Параметры</strong> (свёрнуты) задают оператора (поиск по мировому списку MCC/MNC с сервера плюс выбор случайного роуминг-оператора), LAC/Cell ID/TAC/RAC, необязательные идентификаторы (пусто = случайно: TMSI, GUTI, KSI, KASME, Kc, счётчики NAS, алгоритм, RAND/AUTN), переключатели сценария (включая <strong>&laquo;Отказ: записать FPLMN&raquo;</strong>) и число циклов/задержку. Журнал шагов показывает каждую запись, ENVELOPE и AUTHENTICATE с их SW. Отправляются только UPDATE BINARY/RECORD, ENVELOPE и AUTHENTICATE — FPLMN записывается только сценарием постоянного отказа (TS 31.102 §4.2.16), а 5GS location-файлы не записываются; записи меняют карту и видны в последующих сравнениях снимков.</p>
<h4 id="network-state" class="font-medium mb-1">Монитор сетевого состояния</h4>
<p class="text-sm mb-3">Рядом с кнопками симуляции компактная панель <strong>&laquo;Сетевое состояние&raquo;</strong> показывает, что сейчас хранит карта и что было сэмулировано последним. В заголовке — <strong>сэмулированное состояние сервиса</strong>: <em>Не определено</em>, пока его не задаст сценарий или событие Location status, затем <em>Обычный сервис</em> (зелёный), <em>Ограниченный сервис</em> (жёлтый) или <em>Нет сервиса</em> (красный), с красной пометкой <em>PLMN не разрешён</em>, если location-файлы или EF.FPLMN указывают на отказ регистрации — плюс текущее <strong>местоположение</strong>: PLMN, страна и оператор (из необязательного мирового списка MCC/MNC, если он загружен), LAI/RAI/TAI и <strong>класс роуминга</strong> (<em>Домашняя сеть</em>, если PLMN совпадает с HPLMN; <em>Эквивалентная домашней</em>, если он есть в EF.EHPLMN; иначе <em>Гостевая (роуминг)</em>). Ниже — по одной компактной строке на контролируемый файл (IMSI, EHPLMN, SPDI, HPLMNwAcT, LOCI, PSLOCI, EPSLOCI, EPSNSC, CBMI, CBMIR, SMSstatus, FPLMN) с декодированной сводкой и признаком последнего обновления (<code class="font-mono text-sm">init</code>, <code class="font-mono text-sm">write</code>, <code class="font-mono text-sm">read</code>, <code class="font-mono text-sm">refresh</code>); при наведении — все декодированные поля. Панель читает файлы один раз при подключении карты (только если ICCID читается), обновляет их на месте по записанным симулятором байтам, перечитывает EF.IMSI после каждого сценария и события Location status (мульти-IMSI апплеты) и никогда не опрашивает карту — кнопка <strong>&laquo;Обновить&raquo;</strong> перечитывает все файлы по требованию.</p>
</section> </section>
+5 -2
View File
@@ -483,13 +483,16 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Cold boot</strong> — invalidate EPSNSC (KSI 07, key wiped) and dummy the location files.</li> <li><strong>Cold boot</strong> — invalidate EPSNSC (KSI 07, key wiped) and dummy the location files.</li>
<li><strong>EPS attach / 2G attach</strong> — store a real EPS NAS context (KSI, KASME, NAS counts, algorithm) and write real LOCI/PSLOCI/EPSLOCI (2G also writes real Kc/KcGPRS).</li> <li><strong>EPS attach / 2G attach</strong> — store a real EPS NAS context (KSI, KASME, NAS counts, algorithm) and write real LOCI/PSLOCI/EPSLOCI (2G also writes real Kc/KcGPRS).</li>
<li><strong>Service lost / Limited service / Roaming denied</strong> — 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 <code class="font-mono text-sm">FFFE</code>, status <code class="font-mono text-sm">01</code>/<code class="font-mono text-sm">02</code>) and invalidate Kc.</li> <li><strong>Service lost / Limited service / Roaming denied</strong> — 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 <code class="font-mono text-sm">FFFE</code>, status <code class="font-mono text-sm">01</code>) and invalidate Kc. <strong>Roaming denied</strong> emulates a permanent &ldquo;PLMN not allowed&rdquo; rejection (NAS cause #11): the location files carry status <code class="font-mono text-sm">010</code> (EPSLOCI wiped to <code class="font-mono text-sm">0B F6</code> + status), the denied VPLMN is appended to <strong>EF.FPLMN</strong> with the shift-list semantics of TS 31.102 §4.2.16 (never the home PLMN) and the key context is dropped.</li>
<li><strong>Churn</strong> — replay real → invalid EPSNSC records back-to-back (count and delay configurable).</li> <li><strong>Churn</strong> — replay real → invalid EPSNSC records back-to-back (count and delay configurable).</li>
<li><strong>SMS received</strong> — bump the EF.SMSstatus counter (read-modify-write) and optionally rewrite the location files.</li> <li><strong>SMS received</strong> — bump the EF.SMSstatus counter (read-modify-write) and optionally rewrite the location files.</li>
<li><strong>CB reconfig</strong> — write the CBMI/CBMIR message-ID lists or clear them (all FF).</li> <li><strong>CB reconfig</strong> — write the CBMI/CBMIR message-ID lists or clear them (all FF).</li>
<li><strong>AUTHENTICATE</strong> — send AUTHENTICATE (3G/EPS/5G, <code class="font-mono text-sm">00 88 00 81 22</code>) with the given or random RAND/AUTN and show the response (success <code class="font-mono text-sm">DB</code> or synchronisation failure <code class="font-mono text-sm">DC</code> with AUTS).</li> <li><strong>AUTHENTICATE</strong> — send AUTHENTICATE (3G/EPS/5G, <code class="font-mono text-sm">00 88 00 81 22</code>) with the given or random RAND/AUTN and show the response (success <code class="font-mono text-sm">DB</code> or synchronisation failure <code class="font-mono text-sm">DC</code> with AUTS).</li>
</ul> </ul>
<p class="text-sm mb-3"><strong>Parameters</strong> (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.</p> <p class="text-sm mb-3"><strong>Parameters</strong> (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 <strong>Rejection: write FPLMN</strong>) 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.</p>
<h4 id="network-state" class="font-medium mb-1">Network state monitor</h4>
<p class="text-sm mb-3">Next to the simulation buttons a compact <strong>Network state</strong> panel shows what the card currently holds and what was last simulated. Its header carries the <strong>simulated service state</strong><em>Undefined</em> until a scenario or a Location status event sets it, then <em>Normal service</em> (green), <em>Limited service</em> (amber) or <em>No service</em> (red), with a red <em>PLMN not allowed</em> marker when the location files or EF.FPLMN show a rejection — plus the current <strong>location</strong>: PLMN, country and operator (from the optional worldwide MCC/MNC list when loaded), the LAI/RAI/TAI, and the <strong>roaming class</strong> (<em>Home</em> when the PLMN equals the HPLMN, <em>Home equivalent</em> when it is in EF.EHPLMN, otherwise <em>Guest</em>). 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 (<code class="font-mono text-sm">init</code>, <code class="font-mono text-sm">write</code>, <code class="font-mono text-sm">read</code>, <code class="font-mono text-sm">refresh</code>); 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 <strong>Refresh</strong> to re-read all files on demand.</p>
</section> </section>
+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> <div id="pysim-event-list" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
</fieldset> </fieldset>
</div> </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> <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"> <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('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> <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-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-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-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-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"><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> <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-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> <div id="netsim-log" class="text-xs font-mono text-gray-600 dark:text-slate-400 max-h-[30vh] overflow-auto"></div>
</fieldset> </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"> <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> <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> <div id="pysim-proactive-log" class="text-sm font-mono text-gray-600 dark:text-slate-400"></div>
@@ -1400,7 +1414,7 @@
// ===== Version ===== // ===== Version =====
// Single source of truth for the PWA version: shown in the header and used // Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect(). // 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; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching ===== // ===== Tab switching =====
@@ -1478,6 +1492,7 @@ function phoneSwitchSubtab(name) {
stkCheckMenu(); stkCheckMenu();
pysimEventsRender(); pysimEventsRender();
pysimProactiveLogRender(); pysimProactiveLogRender();
netStateFetch();
pysimPollStatusInit(); pysimPollStatusInit();
tpRefresh(); tpRefresh();
} }
@@ -4993,6 +5008,7 @@ function pysimSetServerAvailable(available) {
pysimUpdateAdmIndicator(null); pysimUpdateAdmIndicator(null);
pysimUpdateIccidIndicator(null); pysimUpdateIccidIndicator(null);
pysimUpdatePresetIndicators(null); pysimUpdatePresetIndicators(null);
netStateRender(null);
} }
pysimApplyAvailability(); pysimApplyAvailability();
} }
@@ -7953,6 +7969,8 @@ async function pysimEventSend() {
result.classList.remove('hidden'); result.classList.remove('hidden');
result.classList.add('text-amber-600'); result.classList.add('text-amber-600');
} }
if (resp.net_state) netStateRender(resp.net_state);
else netStateFetch();
} catch (e2) { } catch (e2) {
err.textContent = 'Error: ' + e2.message; err.textContent = 'Error: ' + e2.message;
err.classList.remove('hidden'); err.classList.remove('hidden');
@@ -8031,6 +8049,7 @@ async function pysimResetCardData(refreshStatus) {
pysimProactiveLogRender(); pysimProactiveLogRender();
pysimPliRender(); pysimPliRender();
if (refreshStatus) await pysimRefresh(); if (refreshStatus) await pysimRefresh();
if (isViewVisible('tab-phone')) netStateFetch();
} }
function pysimCardStateUpdate(status) { function pysimCardStateUpdate(status) {
@@ -11907,6 +11926,19 @@ function efDecPlmnList(b) {
return { plmns: plmns }; 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) { function efDecPlmnWact(b) {
const plmns = []; const plmns = [];
for (let i = 0; i + 4 < b.length; i += 5) { 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.PLMNwAcT', fid: '6f60', fn: efDecPlmnWact },
{ name: 'EF.OPLMNwAcT', fid: '6f61', fn: efDecPlmnWact }, { name: 'EF.OPLMNwAcT', fid: '6f61', fn: efDecPlmnWact },
{ name: 'EF.HPLMNwAcT', fid: '6f62', 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.OPL', fid: '6fc6', fn: efDecOpl },
{ name: 'EF.SPDI', fid: '6fcd', fn: efDecSpdi }, { name: 'EF.SPDI', fid: '6fcd', fn: efDecSpdi },
{ name: 'EF.PNN', fid: '6fc5', fn: efDecPnn }, { name: 'EF.PNN', fid: '6fc5', fn: efDecPnn },
@@ -12261,6 +12294,7 @@ const EF_DECODERS = [
{ name: 'EF.PSLOCI', fid: '6f73', fn: efDecPsLoci }, { name: 'EF.PSLOCI', fid: '6f73', fn: efDecPsLoci },
{ name: 'EF.EPSLOCI', fid: '6fe3', fn: efDecEpsLoci }, { name: 'EF.EPSLOCI', fid: '6fe3', fn: efDecEpsLoci },
{ name: 'EF.EPSNSC', fid: '6fe4', fn: efDecEpsNsc }, { name: 'EF.EPSNSC', fid: '6fe4', fn: efDecEpsNsc },
{ name: 'EF.FPLMN', fid: '6f7b', fn: efDecFplmn },
{ name: 'EF.LOCIGPRS', fid: '6f53', fn: efDecPsLoci }, { name: 'EF.LOCIGPRS', fid: '6f53', fn: efDecPsLoci },
{ name: 'EF.Kc', fid: '6f20', fn: efDecKc }, { name: 'EF.Kc', fid: '6f20', fn: efDecKc },
{ name: 'EF.KcGPRS', fid: '6f52', fn: efDecKc }, { name: 'EF.KcGPRS', fid: '6f52', fn: efDecKc },
@@ -12477,6 +12511,7 @@ function netSimParams() {
write_kc: netSimChk('netsim-write-kc'), write_kc: netSimChk('netsim-write-kc'),
sms_location: netSimChk('netsim-sms-location'), sms_location: netSimChk('netsim-sms-location'),
cb_clear: netSimChk('netsim-cb-clear'), cb_clear: netSimChk('netsim-cb-clear'),
write_fplmn: netSimChk('netsim-write-fplmn'),
}; };
const map = { const map = {
mcc: 'netsim-mcc', mnc: 'netsim-mnc', lac: 'netsim-lac', cell_id: 'netsim-cell', mcc: 'netsim-mcc', mnc: 'netsim-mnc', lac: 'netsim-lac', cell_id: 'netsim-cell',
@@ -12492,6 +12527,151 @@ function netSimParams() {
return p; 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) { async function netSimRun(scenario) {
const statusEl = document.getElementById('netsim-status'); const statusEl = document.getElementById('netsim-status');
statusEl.textContent = t('Running') + '...'; statusEl.textContent = t('Running') + '...';
@@ -12509,6 +12689,7 @@ async function netSimRun(scenario) {
statusEl.className = 'text-xs text-red-500 mb-1'; statusEl.className = 'text-xs text-red-500 mb-1';
} }
pysimEventsRender(); pysimEventsRender();
if (res.net_state) netStateRender(res.net_state);
} catch (e) { } catch (e) {
statusEl.textContent = t('Error') + ': ' + e.message; statusEl.textContent = t('Error') + ': ' + e.message;
statusEl.className = 'text-xs text-red-500 mb-1'; statusEl.className = 'text-xs text-red-500 mb-1';
@@ -12848,7 +13029,7 @@ const LANG_RU = {
'No events configured.': 'Нет настроенных событий.', 'No events configured.': 'Нет настроенных событий.',
'Fetched proactive commands:': 'Извлечённые проактивные команды:', 'Fetched proactive commands:': 'Извлечённые проактивные команды:',
'Network simulation': 'Симуляция сети', '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': 'Холодная загрузка', 'Cold boot': 'Холодная загрузка',
'EPS attach': 'Подключение EPS', 'EPS attach': 'Подключение EPS',
'2G attach': 'Подключение 2G', '2G attach': 'Подключение 2G',
@@ -13126,6 +13307,20 @@ const LANG_RU = {
'No ADM key in the card preset — not verified': 'Ключа ADM в предустановке нет — не подтверждён', 'No ADM key in the card preset — not verified': 'Ключа ADM в предустановке нет — не подтверждён',
'SCP80 preset complete': 'Предустановка SCP80 заполнена', 'SCP80 preset complete': 'Предустановка SCP80 заполнена',
'SCP81 preset complete': 'Предустановка SCP81 заполнена', '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', 'TERMINAL PROFILE': 'TERMINAL PROFILE',
'Preset (device model)': 'Пресет (модель устройства)', 'Preset (device model)': 'Пресет (модель устройства)',
'Profile (hex)': 'Профиль (hex)', 'Profile (hex)': 'Профиль (hex)',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v202'; const CACHE = 'simple-v203';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+1
View File
@@ -90,6 +90,7 @@ function setup() {
globalThis.pysimSetConnected = v => calls.connected.push(v); globalThis.pysimSetConnected = v => calls.connected.push(v);
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh); globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
globalThis.pysimApplyAvailability = () => {}; globalThis.pysimApplyAvailability = () => {};
globalThis.netStateRender = () => {};
globalThis.isViewVisible = () => true; globalThis.isViewVisible = () => true;
globalThis.pysimProactiveLogRender = () => { calls.proactive++; }; globalThis.pysimProactiveLogRender = () => { calls.proactive++; };
globalThis.cardsAutoSelectByIccid = iccid => { calls.autoIccid.push(iccid); return -1; }; globalThis.cardsAutoSelectByIccid = iccid => { calls.autoIccid.push(iccid); return -1; };
+7 -1
View File
@@ -24,7 +24,7 @@ function extractFunc(src, name) {
const FNS = [ const FNS = [
'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be', 'efBytes', 'efHex', 'efSwapNibbles', 'efAllFf', 'efRstripFf', 'efTlv', 'efUcs2Be',
'efGsm7Octets', 'efAnnexA', 'efBcdAddress', 'efBcdAddressLen', 'efPlmn', 'efPlmnAct', 'efPnnText', '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', 'efDecSpn', 'efDecLoci', 'efDecPsLoci', 'efDecEpsLoci', 'efDecEpsNsc', 'efDecKc', 'efDecAcc',
'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl', 'efDecPhase', 'efDecCbmi', 'efDecCbmir', 'efDecEcc', 'efDecOpl', 'efDecAd', 'efDecAcl',
'efDecSmsp', 'efDecSpdi', 'efDecNai', 'efDecDir', 'efDecArr', 'efDecPnn', 'efDecAdn', '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(efDecEcc(efBytes('19f101')), { number: '911', esc: '0x01' });
assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 }); assert.deepStrictEqual(efDecKc(efBytes('837d783609a3858f05')), { kc: '837D783609A3858F', ksi: 0 });
assert.deepStrictEqual(efDecPlmnList(efBytes('22F860')), { plmns: [{ mcc: '228', mnc: '06' }] }); 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(efDecCbmi(efBytes('0010FFFF0020')), { message_ids: [16, 32] });
assert.deepStrictEqual(efDecCbmir(efBytes('0000FFFEFFFF')), { ranges: [['0000', 'FFFE']] }); assert.deepStrictEqual(efDecCbmir(efBytes('0000FFFEFFFF')), { ranges: [['0000', 'FFFE']] });
assert.deepStrictEqual(efDecPhase(efBytes('03')), { phase: 'phase 2 and higher' }); assert.deepStrictEqual(efDecPhase(efBytes('03')), { phase: 'phase 2 and higher' });
+10
View File
@@ -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>/); 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', () => { 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"'; 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', for (const label of ['STK menu', 'STATUS and Polling', 'TERMINAL PROFILE',
+131
View File
@@ -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'));
});
+2 -1
View File
@@ -28,7 +28,8 @@ const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
'globalThis.pysimProactiveLogRender = () => { globalThis._log = (globalThis._log || 0) + 1; };\n' + 'globalThis.pysimProactiveLogRender = () => { globalThis._log = (globalThis._log || 0) + 1; };\n' +
'globalThis.pysimPollStatusInit = () => { globalThis._poll = (globalThis._poll || 0) + 1; };\n' + 'globalThis.pysimPollStatusInit = () => { globalThis._poll = (globalThis._poll || 0) + 1; };\n' +
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 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); eval(code);
function makeClassList() { function makeClassList() {
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-simple-server" name = "pysim-simple-server"
version = "2.6.2" version = "2.7.0"
description = "HTTP REST server wrapping pysim for the SIMple PWA" description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+145 -18
View File
@@ -24,10 +24,15 @@ import time
# Candidate paths per logical file. The first existing one is used, so a # 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. # USIM card is served from ADF.USIM and a GSM SIM from DF.GSM/DF.TELECOM.
FILE_PATHS = { 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'], 'epsnsc': ['ADF.USIM/6FE4'],
'loci': ['ADF.USIM/6F7E', 'DF.GSM/6F7E'], 'loci': ['ADF.USIM/6F7E', 'DF.GSM/6F7E'],
'psloci': ['ADF.USIM/6F73', 'DF.GSM/6F73'], 'psloci': ['ADF.USIM/6F73', 'DF.GSM/6F73'],
'epsloci': ['ADF.USIM/6FE3'], 'epsloci': ['ADF.USIM/6FE3'],
'fplmn': ['ADF.USIM/6F7B', 'DF.GSM/6F7B'],
'kc': ['ADF.USIM/4F20', 'DF.GSM/6F08'], 'kc': ['ADF.USIM/4F20', 'DF.GSM/6F08'],
'kcgprs': ['ADF.USIM/4F52', 'DF.GSM/6F09'], 'kcgprs': ['ADF.USIM/4F52', 'DF.GSM/6F09'],
'smsstatus': ['ADF.USIM/6F43', 'DF.TELECOM/6F43', 'DF.GSM/6F43'], 'smsstatus': ['ADF.USIM/6F43', 'DF.TELECOM/6F43', 'DF.GSM/6F43'],
@@ -35,6 +40,19 @@ FILE_PATHS = {
'cbmir': ['ADF.USIM/6F50', 'DF.GSM/6F50'], '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). # File status codes (LOCI/PSLOCI/EPSLOCI update status).
ST_UPDATED = 0x00 ST_UPDATED = 0x00
ST_NOT_UPDATED = 0x01 ST_NOT_UPDATED = 0x01
@@ -91,6 +109,19 @@ def _plmn_bytes(plmn_hex):
return bytes.fromhex(_norm_hex(plmn_hex, 3)) 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) ---- # ---- 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() + bytes([rfu & 0xFF, status & 0xFF])).hex().upper()
def build_loci_dummy(plmn_hex): def build_loci_dummy(plmn_hex, status=ST_NOT_UPDATED):
"""Service lost: TMSI FF, PLMN kept, LAC FFFE, status 01.""" """Service lost: TMSI FF, PLMN kept, LAC FFFE, status 01 (010 = PLMN not
return build_loci('FFFFFFFF', plmn_hex, 'FFFE', ST_NOT_UPDATED) 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, 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() + bytes([status & 0xFF])).hex().upper()
def build_psloci_dummy(plmn_hex): def build_psloci_dummy(plmn_hex, status=ST_NOT_UPDATED):
return build_psloci('FFFFFFFF', 'FFFFFF', plmn_hex, 'FFFE', 'FF', return build_psloci('FFFFFFFF', 'FFFFFF', plmn_hex, 'FFFE', 'FF', status)
ST_NOT_UPDATED)
def build_epsloci(guti_hex, plmn_hex, tac_hex, status=ST_UPDATED): 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() + bytes([status & 0xFF])).hex().upper()
def build_epsloci_dummy(plmn_hex): def build_epsloci_dummy(status=None):
"""GUTI header 0B F6 + PLMN, identity wiped; TAI PLMN + FFFF; status 01.""" """EPSLOCI dummy: the EPS-mobile-identity pair `0B F6` (content length +
return build_epsloci('0BF6' + plmn_hex + 'FF' * 7, plmn_hex, 'FFFF', GUTI type octet) is kept, the GUTI/TAI/status bytes are wiped
ST_NOT_UPDATED) (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 ---- # ---- Ciphering keys and CB/SMS files ----
@@ -419,7 +479,7 @@ class NetSimRunner:
data = _pad_ff(bytes.fromhex(data_hex), size).hex().upper() data = _pad_ff(bytes.fromhex(data_hex), size).hex().upper()
_out, sw = self.lchan.update_binary(data) _out, sw = self.lchan.update_binary(data)
return self._check(data, sw, action='update_binary', file=label or key, 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): def write_record(self, key, data_hex, record=1, pad=True, label=None, optional=False):
try: try:
@@ -435,12 +495,68 @@ class NetSimRunner:
data = _pad_ff(bytes.fromhex(data_hex), size).hex().upper() data = _pad_ff(bytes.fromhex(data_hex), size).hex().upper()
_out, sw = self.lchan.update_record(record, data) _out, sw = self.lchan.update_record(record, data)
return self._check(data, sw, action='update_record', file=label or key, 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): def read_binary_current(self):
data, sw = self.lchan.read_binary() data, sw = self.lchan.read_binary()
return (data or ''), sw 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): def read_record_current(self, record=1):
data, sw = self.lchan.read_record(record) data, sw = self.lchan.read_record(record)
return (data or ''), sw return (data or ''), sw
@@ -499,11 +615,16 @@ class NetSimRunner:
label='epsloci', optional=True) label='epsloci', optional=True)
def write_dummy_locations(self, status=ST_NOT_UPDATED): def write_dummy_locations(self, status=ST_NOT_UPDATED):
self.write_binary('loci', build_loci_dummy(self.plmn), label='loci', """Service loss: LOCI/PSLOCI keep the PLMN with the dummy status 01;
optional=True) EPSLOCI is wiped to `0B F6` + FF (UICC_NAA.md C3). A rejection status
self.write_binary('psloci', build_psloci_dummy(self.plmn), label='psloci', (010 = PLMN not allowed) is written to all three; EPSLOCI then carries
optional=True) that status byte as the only byte after the wipe (C3a)."""
self.write_binary('epsloci', build_epsloci_dummy(self.plmn), 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) label='epsloci', optional=True)
def invalidate_kc(self): def invalidate_kc(self):
@@ -571,12 +692,18 @@ class NetSimRunner:
self.write_dummy_locations() self.write_dummy_locations()
def sc_roaming_denied(self): 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): if self.p('send_event', True):
self.send_location_status(self.status(LOC_STATUS_LIMITED)) self.send_location_status(self.status(LOC_STATUS_LIMITED))
if self.p('invalidate_epsnsc', True): 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): if self.p('dummy_locations', True):
self.write_dummy_locations(status=self.status(ST_PLMN_NOT_ALLOWED)) 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): def sc_churn(self):
count = int(self.p('churn_count', 3)) count = int(self.p('churn_count', 3))
+289
View File
@@ -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
+160 -2
View File
@@ -15,6 +15,7 @@ from pySim.transport import ApduTracer, ProactiveHandler
from pySim.cards import UiccCardBase from pySim.cards import UiccCardBase
from pysim_simple_server import httpota from pysim_simple_server import httpota
from pysim_simple_server import netsim from pysim_simple_server import netsim
from pysim_simple_server import netstate
from pysim_simple_server import scp81 from pysim_simple_server import scp81
from smartcard.CardMonitoring import CardMonitor, CardObserver from smartcard.CardMonitoring import CardMonitor, CardObserver
@@ -24,7 +25,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE 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 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')} ('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): def _parse_tree_output(output):
lines = (output or '').split('\n') lines = (output or '').split('\n')
children = [] children = []
@@ -2350,6 +2450,7 @@ def _handle_card_disconnect():
_server_ref.event_list = None _server_ref.event_list = None
_server_ref.sim_menu = None _server_ref.sim_menu = None
_server_ref.iccid = None _server_ref.iccid = None
_server_ref.net_state = None
_server_ref.equipping = False _server_ref.equipping = False
_server_ref.card_session = getattr(_server_ref, 'card_session', 0) + 1 _server_ref.card_session = getattr(_server_ref, 'card_session', 0) + 1
_reset_proactive_log() _reset_proactive_log()
@@ -2377,8 +2478,21 @@ def _apply_equipped_card(server):
server.iccid = _read_iccid(server.app) server.iccid = _read_iccid(server.app)
if server.iccid: if server.iccid:
_tlog('equip: ICCID %s' % 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: else:
_tlog('equip: ICCID not readable') server.net_state = None
_tlog('equip: ICCID not readable - network state skipped')
_poll_enable() _poll_enable()
sm, el = _send_terminal_profile(server.scc, server.terminal_profile) sm, el = _send_terminal_profile(server.scc, server.terminal_profile)
server.sim_menu = sm server.sim_menu = sm
@@ -3219,6 +3333,16 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp) self._send_json(resp)
self._log_resp({'available': resp['available'], self._log_resp({'available': resp['available'],
'results': len(resp.get('results', []))}) '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': elif self.path == '/api/status':
self._log_req() self._log_req()
app = self.server.app app = self.server.app
@@ -3474,6 +3598,8 @@ class PysimHandler(BaseHTTPRequestHandler):
result = netsim.run_scenario( result = netsim.run_scenario(
sys.modules[__name__], app, scenario, body, sys.modules[__name__], app, scenario, body,
event_list=getattr(self.server, 'event_list', None) or []) 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._send_json(result)
self._log_resp({'scenario': scenario, self._log_resp({'scenario': scenario,
'success': result.get('success'), 'success': result.get('success'),
@@ -3486,6 +3612,30 @@ class PysimHandler(BaseHTTPRequestHandler):
_handle_card_disconnect() _handle_card_disconnect()
self._send_json({'error': 'simulation failed: %s' % e}, 500) self._send_json({'error': 'simulation failed: %s' % e}, 500)
self._log_resp({'error': str(e)}) 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': elif self.path == '/api/rescue':
scc = self.server.scc scc = self.server.scc
if not scc: if not scc:
@@ -3878,9 +4028,17 @@ class PysimHandler(BaseHTTPRequestHandler):
event_data = bytes.fromhex(event_data_hex) if event_data_hex else None event_data = bytes.fromhex(event_data_hex) if event_data_hex else None
try: try:
data, sw = _send_event_download(scc, event_type, event_data) 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} resp = {'sw': sw}
if data: if data:
resp['data'] = 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._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
except Exception as e: except Exception as e:
+74 -5
View File
@@ -39,6 +39,8 @@ class BuilderTests(unittest.TestCase):
dummy = netsim.build_loci_dummy('52F002') dummy = netsim.build_loci_dummy('52F002')
self.assertEqual(dummy, 'FFFFFFFF52F002FFFEFF01') self.assertEqual(dummy, 'FFFFFFFF52F002FFFEFF01')
# PLMN kept, LAC FFFE, status "not updated" # 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): def test_psloci_real_and_dummy(self):
real = netsim.build_psloci('F9236619', 'FFFFFF', '52F002', '6CD7', 'CA', 0x00) real = netsim.build_psloci('F9236619', 'FFFFFF', '52F002', '6CD7', 'CA', 0x00)
@@ -46,17 +48,53 @@ class BuilderTests(unittest.TestCase):
self.assertEqual(real[-2:], '00') self.assertEqual(real[-2:], '00')
dummy = netsim.build_psloci_dummy('52F002') dummy = netsim.build_psloci_dummy('52F002')
self.assertEqual(dummy, 'FFFFFFFFFFFFFF52F002FFFEFF01') 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): def test_epsloci_real_and_dummy(self):
real = netsim.build_epsloci('AB' * 12, '52F099', '8001', 0x00) real = netsim.build_epsloci('AB' * 12, '52F099', '8001', 0x00)
self.assertEqual(len(real) // 2, 18) self.assertEqual(len(real) // 2, 18)
self.assertEqual(real[24:30], '52F099') # TAI PLMN after the 12-byte GUTI self.assertEqual(real[24:30], '52F099') # TAI PLMN after the 12-byte GUTI
self.assertEqual(real[-2:], '00') self.assertEqual(real[-2:], '00')
dummy = netsim.build_epsloci_dummy('52F099') # dummy: the EPS-mobile-identity pair 0B F6 stays, the rest is wiped
self.assertEqual(len(dummy) // 2, 18) dummy = netsim.build_epsloci_dummy()
self.assertTrue(dummy.startswith('0BF652F099')) self.assertEqual(dummy, '0BF6' + 'FF' * 16)
self.assertEqual(dummy[10:24], 'FF' * 7) # identity wiped # permanent rejection: the status byte is the only written byte
self.assertTrue(dummy.endswith('52F099FFFF01')) 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): def test_epsnsc_record_layout_and_padding(self):
rec = netsim.build_epsnsc(0x03, 'AB' * 32, 0x0E, 0x08, 0x02) rec = netsim.build_epsnsc(0x03, 'AB' * 32, 0x0E, 0x08, 0x02)
@@ -216,6 +254,7 @@ FILES = {
'6F43': FakeFileInfo(size=2, data='27FF'), '6F43': FakeFileInfo(size=2, data='27FF'),
'6F45': FakeFileInfo(size=20), '6F45': FakeFileInfo(size=20),
'6F50': FakeFileInfo(size=40), '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] kc = [w for w in lchan.writes if w[1] == '4F20'][0][2]
self.assertEqual(kc, 'FFFFFFFFFFFFFFFF07') 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): def test_event_step_skipped_when_not_subscribed(self):
runner, lchan, srv = make_runner(event_list=[]) runner, lchan, srv = make_runner(event_list=[])
out = runner.run('service_lost') out = runner.run('service_lost')
+160
View File
@@ -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()