v3.25.6: the PLI dictionary save feedback + the timer wording

The v3.25.5 report's follow-ups: a field the server could not use was
silently dropped, and the PWA also filtered non-hex fields client-side
(never sending them).  The POST now collects them and names them in the
response - `invalid` (a malformed key/value: not hex, an odd number of
digits, a non-string value) and `unknown` (a well-formed code outside the
qualifier list), both omitted when everything was applied - and the PWA's
Save sends every field, composes "Not saved: 0A (invalid value), 18
(unknown qualifier)" (`pysimPliRejectMsg`) into a new #pli-save-msg line
under the button and shows "Not saved" briefly.

The guide's timer exception now says "card-initiated automatic action"
(the timer retries every 5 s; polling/menu are suspended, not retrying).

Docs: api.md's POST contract, help EN/RU - which also said "22 PLI
qualifiers" while the list has 25.  Tests: the rejection lists per case
(odd length, non-hex value/key, non-string, unknown code) and the
valid-save absence, the PWA message helper and the wiring pins.
887 frontend + 875 Python; sw cache simple-v430.
This commit is contained in:
2026-10-11 16:51:30 +03:00
parent f2046d7f0a
commit 9fd8238051
10 changed files with 152 additions and 36 deletions
+10 -4
View File
@@ -1520,11 +1520,17 @@ override is set.
### `POST /api/pli-dict`
Updates dictionary entries. Body is a map of qualifier code to hex value;
keys must be known qualifiers and values valid hex, otherwise they are
ignored. A non-empty value becomes an override; an **empty value clears the
Updates dictionary entries. Body is a map of qualifier code to hex value.
A non-empty value becomes an override; an **empty value clears the
override**, so the built-in default (if any) applies again. Returns the
updated effective dictionary.
updated effective dictionary plus, when any field was not saved, the lists
that name them: `invalid` (a malformed key or value — not hex, an odd number
of digits, a non-string value) and `unknown` (a well-formed code outside the
qualifier list); both keys are omitted when everything was applied (v3.25.6).
```json
{"00": "130500F1100000", "0A": "630102", "invalid": ["0G"], "unknown": ["18"]}
```
### `POST /api/scp81/bip`
+1 -1
View File
@@ -534,7 +534,7 @@ the data really flows to/from the configured BIP target, and the BIP events
(Data available, Channel status) flow for the channels the card itself opened -
so an applet's own HTTP/BIP conversation completes inside a scripted run. The
BIP **control** API stays blocked while a run is active (409), like the
terminal's other automatic actions (polling, menu). The one remaining
terminal's other automatic actions (polling, menu). The one card-initiated
automatic action that still waits is the **timer expiration** event: a
TIMER MANAGEMENT timer the card armed is not fired during a run (it retries
every 5 s and fires when the run ends) - a deliberate exception to the
+2 -2
View File
@@ -527,14 +527,14 @@
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Фоновый <strong>опрос включён по умолчанию</strong> (CAT-терминал опрашивает карту в режиме ожидания, TS 102 221 &sect;14.6.2): после интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;с, по умолчанию 30&nbsp;с, <code class="font-mono text-sm">0</code> отключает опрос) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. Переключатель включает и выключает опрос; команда карты <strong>POLL INTERVAL</strong> обновляет интервал (TS 102 223 &sect;6.4.6; TERMINAL RESPONSE сообщает фактически используемый интервал), а после <strong>POLLING OFF</strong> переключатель показывает <strong>OFF</strong> янтарным цветом до нового POLL INTERVAL &mdash; ручная кнопка <strong>Отправить STATUS</strong> продолжает работать (на presence detection и ручной STATUS POLLING OFF не влияет). При извлечении карты опрос отключается и включается снова при следующем equip.</p>
<h3 id="pli-dict" class="text-lg font-medium mb-2">8.6 &laquo;Конфигурация TR&raquo; &mdash; данные ответа PROVIDE LOCAL INFORMATION</h3>
<p class="text-sm mb-2">Редактируемые hex-значения для всех 22 квалификаторов PLI (TS 102 223 &sect;8.6 + TS 131 111). У десяти квалификаторов есть встроенные формы декодирования/кодирования:</p>
<p class="text-sm mb-2">Редактируемые hex-значения для всех 25 квалификаторов PLI (TS 102 223 &sect;8.6 + TS 131 111). У десяти квалификаторов есть встроенные формы декодирования/кодирования:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
<li><strong>01</strong> IMEI &middot; <strong>03</strong> Дата/время/TZ &middot; <strong>04</strong> Язык &middot; <strong>05</strong> Timing Advance</li>
<li><strong>06</strong> Access Technology &middot; <strong>08</strong> IMEISV &middot; <strong>09</strong> Search Mode</li>
<li><strong>0A</strong> Battery &middot; <strong>0E</strong> Multiple Access Technologies</li>
</ul>
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Для квалификаторов, определённых в спецификациях ETSI, заранее заданы разумные значения по умолчанию (местоположение — тестовая сеть 001/01, тестовый IMEI 490154203237518 и его IMEISV, язык «en», технология доступа E-UTRAN, автоматический поиск сети, полный заряд батареи): заданное значение переопределяет умолчание, а <strong>очистка поля возвращает умолчание</strong>; квалификатор даты/времени, пока пуст, отвечается часами хоста, а специфичные для 3GPP/3GPP2 квалификаторы (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, …) остаются пустыми — спецификации ETSI не определяют для них содержимое. Когда карта выдаёт PLI, сервер вставляет значения в TERMINAL RESPONSE — в том числе в ответ тест-скрипта.</p>
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Для квалификаторов, определённых в спецификациях ETSI, заранее заданы разумные значения по умолчанию (местоположение — тестовая сеть 001/01, тестовый IMEI 490154203237518 и его IMEISV, язык «en», технология доступа E-UTRAN, автоматический поиск сети, полный заряд батареи): заданное значение переопределяет умолчание, а <strong>очистка поля возвращает умолчание</strong>; квалификатор даты/времени, пока пуст, отвечается часами хоста, а специфичные для 3GPP/3GPP2 квалификаторы (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, …) остаются пустыми — спецификации ETSI не определяют для них содержимое. Поле, которое сервер не может использовать (значение не hex, нечётное число цифр, код вне списка квалификаторов), указывается под кнопкой «Сохранить», а не отбрасывается молча. Когда карта выдаёт PLI, сервер вставляет значения в TERMINAL RESPONSE — в том числе в ответ тест-скрипта.</p>
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Симуляция сети</h3>
<p class="text-sm mb-2">Воспроизводит шаблоны записи реального телефона при смене сетевых условий (исследование трасс в <code class="font-mono text-sm">projects/UICC_NAA.md</code>): подключение EPS, потеря сервиса / ограниченный сервис, запрет роуминга, откат на 2G, приём SMS, перенастройка cell broadcast и AUTHENTICATE. По кнопке на сценарий; карта должна быть подключена.</p>
+2 -2
View File
@@ -526,14 +526,14 @@
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). Background <strong>Polling</strong> is on by default (a CAT terminal polls during idle, TS 102 221 &sect;14.6.2): after the idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;s, default 30&nbsp;s, <code class="font-mono text-sm">0</code> disables polling) the server sends STATUS and handles any pending proactive command. The toggle turns it off/on; the card's <strong>POLL INTERVAL</strong> command updates the interval (TS 102 223 &sect;6.4.6; the TERMINAL RESPONSE reports the interval actually used), and after a <strong>POLLING OFF</strong> the toggle shows <strong>OFF</strong> in amber until a new POLL INTERVAL arrives &mdash; the manual <strong>Send STATUS</strong> button keeps working (presence detection and manual STATUS are not affected by POLLING OFF). Polling is disabled when the card is removed and re-enabled by the next equip.</p>
<h3 id="pli-dict" class="text-lg font-medium mb-2">8.6 TR Config &mdash; PROVIDE LOCAL INFORMATION response data</h3>
<p class="text-sm mb-2">Editable hex values for all 22 PLI qualifiers (TS 102 223 &sect;8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
<p class="text-sm mb-2">Editable hex values for all 25 PLI qualifiers (TS 102 223 &sect;8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
<li><strong>01</strong> IMEI &middot; <strong>03</strong> Date/Time/TZ &middot; <strong>04</strong> Language &middot; <strong>05</strong> Timing Advance</li>
<li><strong>06</strong> Access Technology &middot; <strong>08</strong> IMEISV &middot; <strong>09</strong> Search Mode</li>
<li><strong>0A</strong> Battery &middot; <strong>0E</strong> Multiple Access Technologies</li>
</ul>
<p class="text-sm mb-3">Values persist server-side until restart. Sensible defaults are pre-settled for the qualifiers the ETSI specs define (location as the test network 001/01, the test IMEI 490154203237518 and its IMEISV, language &lsquo;en&rsquo;, E-UTRAN access technology, automatic search mode, a full battery): a value you set overrides its default, and <strong>clearing the field restores it</strong>; the date/time qualifier is answered from the host clock while empty, and the 3GPP/3GPP2-specific qualifiers (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, &hellip;) stay empty - the ETSI specs define no contents for them. When the card issues PLI, the server injects the values into the TERMINAL RESPONSE - also into a test-script expectation's answer.</p>
<p class="text-sm mb-3">Values persist server-side until restart. Sensible defaults are pre-settled for the qualifiers the ETSI specs define (location as the test network 001/01, the test IMEI 490154203237518 and its IMEISV, language &lsquo;en&rsquo;, E-UTRAN access technology, automatic search mode, a full battery): a value you set overrides its default, and <strong>clearing the field restores it</strong>; the date/time qualifier is answered from the host clock while empty, and the 3GPP/3GPP2-specific qualifiers (NMR, ESN, MEID, CSG/HNB, WLAN, slices, CAG, &hellip;) stay empty - the ETSI specs define no contents for them. A field the server cannot use (a value that is not hex, an odd number of digits, a code outside the qualifier list) is named under the Save button instead of being silently dropped. When the card issues PLI, the server injects the values into the TERMINAL RESPONSE - also into a test-script expectation's answer.</p>
<h3 id="network-simulation" class="text-lg font-medium mb-2">8.7 Network simulation</h3>
<p class="text-sm mb-2">Replays the write patterns a real phone performs when the network condition changes (trace study in <code class="font-mono text-sm">projects/UICC_NAA.md</code>): EPS attach, service loss / limited service, roaming denial, 2G fallback, SMS delivery, cell-broadcast reconfiguration and AUTHENTICATE. One button per scenario; the card must be equipped.</p>
+31 -10
View File
@@ -1302,6 +1302,7 @@
<div id="phone-sub-tr" class="hidden">
<div class="flex justify-between items-center mb-2">
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="PROVIDE LOCAL INFORMATION response data">PROVIDE LOCAL INFORMATION response data</span>
<span id="pli-save-msg" class="flex-1 mx-2 text-right text-xs text-red-600"></span>
<button id="pli-save-btn" data-needs="server" onclick="pysimPliSave()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Save">Save</button>
</div>
<div class="text-xs text-gray-400 dark:text-slate-500 mb-1" data-l10n="The date/time qualifier is answered from the host clock while its entry is empty.">The date/time qualifier is answered from the host clock while its entry is empty.</div>
@@ -1905,7 +1906,7 @@
// ===== Version =====
// Single source of truth for the PWA version: shown in the header and used
// by the server version check in pysimConnect().
const SIMPLE_VERSION = '3.25.5';
const SIMPLE_VERSION = '3.25.6';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== view history (the browser Back/Forward buttons) =====
@@ -18404,25 +18405,42 @@ function pysimPliApplyRow(code) {
if (tog) tog.textContent = '▶';
}
// The server names the fields it could not use (invalid value/key, unknown
// qualifier) - show them instead of silently dropping the edit (v3.25.6).
function pysimPliRejectMsg(resp) {
const bad = [];
((resp && resp.invalid) || []).forEach(c => bad.push(c + ' (' + t('invalid value') + ')'));
((resp && resp.unknown) || []).forEach(c => bad.push(c + ' (' + t('unknown qualifier') + ')'));
return bad.length ? t('Not saved') + ': ' + bad.join(', ') : '';
}
async function pysimPliSave() {
const btn = document.getElementById('pli-save-btn');
const msg = document.getElementById('pli-save-msg');
btn.disabled = true;
btn.textContent = t('Saving...');
if (msg) msg.textContent = '';
const data = {};
PLI_QUALIFIERS.forEach(q => {
const el = document.getElementById('pli-v-' + q.code);
if (el) {
const v = el.value.trim().replace(/\s/g, '');
if (v && !/^[0-9A-Fa-f]*$/.test(v)) return;
data[q.code] = v;
}
// every field is sent - the server validates and names what it cannot
// use (a bad value is no longer skipped silently, v3.25.6)
if (el) data[q.code] = el.value.trim().replace(/\s/g, '');
});
try {
await pysimFetch('/api/pli-dict', data);
const resp = await pysimFetch('/api/pli-dict', data);
const reject = pysimPliRejectMsg(resp);
pysimPliRender(); // a cleared field shows its default again
btn.textContent = t('Saved');
btn.classList.add('bg-emerald-700');
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-emerald-700'); btn.disabled = false; }, 1500);
if (msg) msg.textContent = reject;
if (reject) {
btn.textContent = t('Not saved');
btn.classList.add('bg-red-600');
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-red-600'); btn.disabled = false; }, 2500);
} else {
btn.textContent = t('Saved');
btn.classList.add('bg-emerald-700');
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-emerald-700'); btn.disabled = false; }, 1500);
}
} catch (e) {
btn.textContent = 'Error';
btn.classList.add('bg-red-600');
@@ -23435,6 +23453,9 @@ const LANG_RU = {
'Saving...': 'Сохранение...',
'Saved': 'Сохранено',
'Save': 'Сохранить',
'Not saved': 'Не сохранено',
'invalid value': 'недопустимое значение',
'unknown qualifier': 'неизвестный квалификатор',
'Profiler': 'Профайлер',
'New profile': 'Новый профиль',
'Profile from card': 'Профиль с карты',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v429';
const CACHE = 'simple-v430';
const URLS = [
'index.html',
'help.html',
+40
View File
@@ -87,3 +87,43 @@ test('the server PLI defaults decode through their codecs', () => {
assert.deepStrictEqual(PLI_CODECS['09'].decode('650101'), { mode: '1' });
assert.deepStrictEqual(PLI_CODECS['0A'].decode('630104'), { charge: '4' });
});
// the same extraction the profiler tests use
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);
}
test('a rejected PLI save is reported, not silently dropped', () => {
// the server names the fields it could not use (invalid/unknown) and the
// PWA shows them under the Save button (v3.25.6)
global.t = s => s;
eval(extractFunc(html, 'pysimPliRejectMsg'));
assert.strictEqual(pysimPliRejectMsg({}), '');
assert.strictEqual(pysimPliRejectMsg({ '0A': '630104' }), '');
assert.strictEqual(pysimPliRejectMsg({ invalid: ['0A'] }),
'Not saved: 0A (invalid value)');
assert.strictEqual(pysimPliRejectMsg({ unknown: ['18'] }),
'Not saved: 18 (unknown qualifier)');
assert.strictEqual(pysimPliRejectMsg({ invalid: ['0A'], unknown: ['18'] }),
'Not saved: 0A (invalid value), 18 (unknown qualifier)');
// the save posts every field (no silent client-side skip) and wires the
// message element + the reject branch
assert.ok(html.includes('if (el) data[q.code] = el.value.trim()'),
'every field is sent to the server for validation');
assert.ok(html.includes('id="pli-save-msg"'), 'the message element exists');
assert.ok(html.includes('const reject = pysimPliRejectMsg(resp);'),
'the save reports the rejection');
delete global.t;
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.25.5"
version = "3.25.6"
description = "HTTP REST server wrapping pysim for the SIMple PWA"
requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+31 -15
View File
@@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.25.5'
VERSION = '3.25.6'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -9103,25 +9103,41 @@ class PysimHandler(BaseHTTPRequestHandler):
elif self.path == '/api/pli-dict':
body = self._read_body()
self._log_req(body)
invalid, unknown = [], []
if isinstance(body, dict):
for k, v in body.items():
if isinstance(v, str):
if not isinstance(v, str):
invalid.append(k)
continue
try:
code = int(k, 16)
except ValueError:
invalid.append(k)
continue
# a known qualifier - never "an override already exists":
# a new override must stick, and a clear must not poison a
# later set (v3.25.5, live report)
if code not in PLI_QUALIFIER_NAMES:
unknown.append(k)
continue
if v:
try:
code = int(k, 16)
# a known qualifier - never "an override already
# exists": a new override must stick, and a clear
# must not poison a later set (v3.25.5, live report)
if code in PLI_QUALIFIER_NAMES:
if v:
bytes.fromhex(v) # valid hex only
_PLI_DATA[code] = v
else:
# clearing the field restores the default
_PLI_DATA.pop(code, None)
except (ValueError, KeyError):
pass
bytes.fromhex(v) # valid hex only
except ValueError:
invalid.append(k)
continue
_PLI_DATA[code] = v
else:
# clearing the field restores the default
_PLI_DATA.pop(code, None)
resp = {('%02X' % q): (_PLI_DATA.get(q) or PLI_DEFAULTS.get(q, ''))
for q in PLI_QUALIFIER_NAMES}
# a field the server could not use is named in the response, never
# silently dropped (v3.25.6): the PWA shows it under the Save button
if invalid:
resp['invalid'] = invalid
if unknown:
resp['unknown'] = unknown
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/poll-toggle':
+33
View File
@@ -199,11 +199,44 @@ class PliDictHttpTests(unittest.TestCase):
status, resp = self._req('POST', '/api/pli-dict', {'18': 'AABB'})
self.assertEqual(status, 200, resp)
self.assertNotIn(0x18, srv._PLI_DATA)
self.assertEqual(resp['unknown'], ['18'])
self.assertEqual(resp['0A'], '630103')
finally:
srv._PLI_DATA.clear()
srv._PLI_DATA.update(saved)
def test_rejected_values_are_reported(self):
# a field the server cannot use is named in the response, never
# silently dropped (v3.25.6): the PWA shows it under the Save button
import pysim_simple_server.server as srv
saved = dict(srv._PLI_DATA)
try:
# an odd-length hex value
status, resp = self._req('POST', '/api/pli-dict', {'0A': '63010'})
self.assertEqual(status, 200, resp)
self.assertEqual(resp['invalid'], ['0A'])
self.assertNotIn(0x0A, srv._PLI_DATA)
self.assertEqual(resp['0A'], srv.PLI_DEFAULTS[0x0A])
# a non-hex value and a non-hex key
status, resp = self._req('POST', '/api/pli-dict', {'0A': 'ZZ', '0G': 'AA'})
self.assertEqual(sorted(resp['invalid']), ['0A', '0G'])
self.assertNotIn('unknown', resp)
# a non-string value
status, resp = self._req('POST', '/api/pli-dict', {'0A': 123})
self.assertEqual(resp['invalid'], ['0A'])
# an unknown (well-formed) qualifier
status, resp = self._req('POST', '/api/pli-dict', {'18': 'AABB'})
self.assertEqual(resp['unknown'], ['18'])
self.assertNotIn('invalid', resp)
# a valid save carries neither rejection list
status, resp = self._req('POST', '/api/pli-dict', {'0A': '630102'})
self.assertEqual(resp['0A'], '630102')
self.assertNotIn('invalid', resp)
self.assertNotIn('unknown', resp)
finally:
srv._PLI_DATA.clear()
srv._PLI_DATA.update(saved)
class CardFreeGetTests(unittest.TestCase):
"""Cached GETs must answer while the card lock is held.