feat: card-driven STATUS polling and the Poll Interval Negotiation event (v3.5.9)

- EVENT_NAMES (server + PWA) corrected against TS 102 223 v18.3.0 8.25:
  0x14 is "Access technology change (multiple)" (not Change of UICC
  Access), 0x19 Profile container, 0x1A Void, 0x1B Secured profile
  container, 0x1C Poll interval negotiation, 0x20-0x22 reserved.  Values
  the CAT spec leaves "Reserved for 3GPP" now carry the concrete TS 31.111
  event name + clause (0x11 (I-)WLAN access status, 0x12 Network
  rejection, 0x15 CSG cell selection, 0x17 IMS registration, 0x18 Incoming
  IMS data, 0x1D Data connection status change, 0x1E CAG cell selection,
  0x1F Slices status change).
- Poll Interval Negotiation (0x1C) in the Phone tab: the form proposes a
  Duration (unit + interval) and the UICC's answer (TS 102 223 8.97
  accepted / rejected / modified + optional Duration) is decoded and
  shown; a "modified" duration becomes the background poll interval.  The
  form appears when the card subscribed to the event, like every other.
- Polling emulation is card-driven: a POLL INTERVAL adopts its Duration
  (minutes/seconds/tenths -> 1..255 s, logged) for the background poll,
  is echoed in the TERMINAL RESPONSE (6.8.4) and clears a POLLING OFF
  suspension; POLLING OFF (6.4.14) suspends proactive polling until a new
  POLL INTERVAL - the manual Send STATUS button and presence detection are
  unaffected.  /api/poll-status and /api/poll-toggle report card_disabled
  (+ a warning when enabling while suspended); the PWA shows it in amber.
- Tests: tests/test_poll.py +7, test_proactive_names.py +3, frontend
  event_forms +2 and poll_ui (4).  Help EN/RU, docs/api.md, AGENTS.

573 frontend / 439 Python green; version 3.5.9; sw cache simple-v262.
This commit is contained in:
2026-09-26 12:56:18 +03:00
parent d9e6c6c9dd
commit 25f001a778
11 changed files with 455 additions and 47 deletions
+22 -3
View File
@@ -616,6 +616,21 @@ optional hex for events that carry data. Returns the SW and any response data:
{"sw": "9000", "data": "..."}
```
For **Poll Interval Negotiation** (event `0x1C`, TS 102 223 §7.5.22) the
proposal rides in `event_data` as the Duration TLV `04 02 <unit> <interval>`
(unit `00` minutes / `01` seconds / `02` tenths of seconds) and the UICC's
response is decoded into `negotiation`:
```json
{"sw": "9000", "data": "1101020402011E",
"negotiation": {"result": 2, "result_name": "modified",
"unit": 1, "interval": 30, "seconds": 30}}
```
`result` is `00` accepted / `01` rejected / `02` modified (§8.97); no response
data means *accepted*. A *modified* answer sets the background poll interval,
and a *rejected* answer may carry the closest acceptable duration.
Channel status (event `0x0A`, TS 102 223 §8.56) carries the Channel status TLV
`B8 02 <status> <info>`, where the status byte is the channel id (1–7) OR-ed
with the state bits (0x00 link not established / 0x40 TCP LISTEN / 0x80 link
@@ -755,10 +770,12 @@ Hex, even number of digits, 1–255 bytes. Response is the same shape as
### `GET /api/poll-status`
Background STATUS polling state.
Background STATUS polling state. `card_disabled` is true after the card sent
POLLING OFF (TS 102 223 §6.4.14): proactive polling stays suspended until a
POLL INTERVAL arrives, independent of the operator's `enabled` switch.
```json
{"enabled": true, "interval": 300}
{"enabled": true, "interval": 30, "card_disabled": false}
```
### `POST /api/poll-toggle`
@@ -769,7 +786,9 @@ Turns background STATUS polling on or off.
{"enabled": true}
```
Returns the new state (`{"enabled": ..., "interval": ...}`).
Returns the new state (`{"enabled": ..., "interval": ..., "card_disabled": ...}`);
while the card has disabled polling, enabling adds a `warning` explaining
that the timer stays suspended until the card sends a new POLL INTERVAL.
### `GET /api/pli-qualifiers`
+2 -1
View File
@@ -493,6 +493,7 @@
<li><strong>Location Status</strong> — выпадающий список: Normal / Limited / No service (тег <code class="font-mono text-sm">9B</code>)</li>
<li><strong>Access Technology Change</strong> — 13 типов RAT (тег <code class="font-mono text-sm">BF</code>)</li>
<li><strong>Network Rejection</strong> — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля местоположения (MCC, MNC, LAC, RAC, TAC), технология доступа и единый выпадающий список из 53 кодов причин (EMM, GMM, 5GMM и LU)</li>
<li><strong>Poll Interval Negotiation</strong> (0x1C) — предлагает интервал опроса в виде Duration (единица + значение); после отправки показывается ответ UICC (принято / отклонено / изменено + длительность), а значение <em>изменено</em> становится интервалом фонового опроса (TS 102 223 &sect;7.5.22). Форма появляется, когда карта подписана на это событие.</li>
</ul>
<p class="text-sm mb-3">Отправка события использует <code class="font-mono text-sm">ENVELOPE(Event Download)</code> по TS 102 223 / TS 131 111.</p>
@@ -503,7 +504,7 @@
<p class="text-sm mb-3">Блок <strong>TERMINAL PROFILE</strong> (рядом с «STATUS и опрос») содержит кнопки <strong>«Отправить»</strong> (повторная отправка, как «Спасение») и <strong>«Настроить»</strong>. В диалоге настройки: селектор пресетов (модели устройств, например профиль BIP-совместимого аппарата из этого проекта), поле hex и форма с флажком на каждый бит профиля по <strong>TS 102 223 §5.2</strong> / <strong>TS 31.111 §5.2</strong> (байты 1&ndash;39; для битов 3GPP используются имена из TS 31.111); каждый байт — вертикальный список битов в порядке спецификации (<strong>b1 — младший бит, первая строка таблицы</strong>); биты, образующие числовое поле по спецификации (байт 11 — число soft keys, байт 13 — число BIP-каналов, байты 14/15 — высота/ширина экрана, байт 16 — уменьшение ширины в меню, байт 19 — версия TIA/EIA-136, байт 24 — максимум кадров), показываются одним числовым полем на весь диапазон битов, а в подсказке байта 11 отмечено зарезервированное значение <code class="font-mono text-sm">'FF'</code>; блоки размещены фиксированными группами (байты 1–12 в 2 колонки, 13–16 в 4, 17–18 в 2, 19–21 в 3, 22–25 в 2, 26–28 в 3, 29–30 в 2, далее по одному в строке); переключение бита обновляет hex, а правка hex перерисовывает форму &mdash; поле hex основное, неизвестные байты и биты сохраняются. Кнопка <strong>«Применить»</strong> отправляет новое значение на сервер (и далее на карту), сбрасывая STK-сессию как «Спасение»; изменение хранится только в памяти (значение <code class="font-mono text-sm">--terminal-profile</code> — стартовое по умолчанию). Правила вывода характеристик устройства из TP (поколение связи, бездисплейные модемы, семейство стандартов) собраны в <code class="font-mono text-sm">docs/TERMINAL_PROFILE.md</code>.</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">8.5 Опрос STATUS</h3>
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Опрос</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;с, по умолчанию 30&nbsp;с, <code class="font-mono text-sm">0</code> отключает опрос) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.</p>
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Опрос</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <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> приостанавливает опрос до нового POLL INTERVAL &mdash; состояние показывается янтарным цветом, при этом ручная кнопка <strong>Отправить STATUS</strong> продолжает работать (на presence detection и ручной STATUS POLLING OFF не влияет). При извлечении карты опрос останавливается, а состояние карты сбрасывается.</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>
+2 -1
View File
@@ -492,6 +492,7 @@
<li><strong>Location Status</strong> — dropdown: Normal / Limited / No service (tag <code class="font-mono text-sm">9B</code>)</li>
<li><strong>Access Technology Change</strong> — 13 RAT types (tag <code class="font-mono text-sm">BF</code>)</li>
<li><strong>Network Rejection</strong> — full adaptive form: registration type (LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access technology, and a 53-cause unified rejection cause dropdown covering EMM, GMM, 5GMM and LU causes</li>
<li><strong>Poll Interval Negotiation</strong> (0x1C) — proposes a poll interval as a Duration (unit + value); after sending, the UICC's answer is shown (accepted / rejected / modified + duration), and a <em>modified</em> duration becomes the background poll interval (TS 102 223 &sect;7.5.22). The form appears when the card subscribed to this event.</li>
</ul>
<p class="text-sm mb-3">Sending an event uses <code class="font-mono text-sm">ENVELOPE(Event Download)</code> per TS 102 223 / TS 131 111.</p>
@@ -502,7 +503,7 @@
<p class="text-sm mb-3">The <strong>TERMINAL PROFILE</strong> block (next to STATUS and Polling) offers <strong>Send</strong> (re-sends it, like Rescue) and <strong>Configure</strong>. The Configure dialog has a preset selector (device models, e.g. this project's BIP-capable handset profile), a hex field and a form with one checkbox per profile bit decoded per <strong>TS 102 223 &sect;5.2</strong> / <strong>TS 31.111 &sect;5.2</strong> (bytes 1&ndash;39, 3GPP-defined bits use the TS 31.111 names), each byte is a vertical list of its bits in spec order (<strong>b1 is the least significant bit and the first table row</strong>); bits that form a numeric value field per the spec (byte 11 soft keys, byte 13 BIP channels, byte 14/15 screen height/width, byte 16 menu width reduction, byte 19 TIA/EIA-136 version, byte 24 max frames) are shown as a single number input for the whole bit range, with the byte 11 <code class="font-mono text-sm">'FF'</code> reserved value noted in the hint; placed in fixed column groups (bytes 1-12 in 2 columns, 13-16 in 4, 17-18 in 2, 19-21 in 3, 22-25 in 2, 26-28 in 3, 29-30 in 2, later bytes one per row); toggling a bit updates the hex and editing the hex re-renders the form &mdash; the hex field is authoritative and unknown bytes/bits are preserved. <strong>Apply</strong> sends the new value to the server (and on to the card), resetting the STK session like Rescue; the change is in-memory only (the <code class="font-mono text-sm">--terminal-profile</code> CLI value is the startup default). The rules for inferring device characteristics from a TP (radio generation, headless modems, standards family) are collected in <code class="font-mono text-sm">docs/TERMINAL_PROFILE.md</code>.</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">8.5 STATUS polling</h3>
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable 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. Polling stops and card state resets if the card is removed.</p>
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable 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 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 a <strong>POLLING OFF</strong> suspends polling until a new POLL INTERVAL &mdash; the panel shows that state in amber while the manual <strong>Send STATUS</strong> button keeps working (presence detection and manual STATUS are not affected by POLLING OFF). Polling stops and card state resets if the card is removed.</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>
+59 -18
View File
@@ -1560,7 +1560,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.5.8';
const SIMPLE_VERSION = '3.5.9';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -9208,23 +9208,34 @@ function pysimFsResetEdit() {
}
// ===== proactive UICC events =====
// Event list codings (TS 102 223 v18.3.0 8.25). Values the CAT spec leaves
// "Reserved for 3GPP" carry the concrete 3GPP event name where TS 31.111
// defines one (clause in parentheses), so the list stays informative.
const EVENT_NAMES = {
0x00: 'MT call', 0x01: 'Call connected', 0x02: 'Call disconnected',
0x03: 'Location status', 0x04: 'User activity', 0x05: 'Idle screen available',
0x06: 'Card reader status', 0x07: 'Language selection',
0x08: 'Browser termination', 0x09: 'Data available',
0x0A: 'Channel status', 0x0B: 'Access Technology Change',
0x0A: 'Channel status', 0x0B: 'Access technology change (single)',
0x0C: 'Display parameters changed', 0x0D: 'Local connection',
0x0E: 'Network Search Mode Change', 0x0F: 'Browsing status',
0x10: 'Frames Information Change', 0x11: 'I-WLAN Access Status',
0x12: 'Network Rejection', 0x13: 'HCI Connectivity',
0x14: 'Change of UICC Access', 0x15: 'CSG Cell Change',
0x16: 'Contactless state request', 0x17: 'Profile Container',
0x18: 'LTE D2D Discovery Monitoring', 0x19: 'LTE D2D Communication Monitoring',
0x1A: 'LTE D2D Announcement Response', 0x1B: 'LTE D2D Revocation',
0x1C: 'LTE D2D Application Port', 0x1D: 'LTE D2D Security Recovery',
0x1E: 'Off-net Emergency Call', 0x1F: 'ECall Over IMS',
0x20: 'EARFCN Update', 0x21: 'SCEF Channel Status',
0x0E: 'Network search mode change', 0x0F: 'Browsing status',
0x10: 'Frames information change',
0x11: 'Reserved for 3GPP — (I-)WLAN access status (TS 31.111 7.5.1)',
0x12: 'Reserved for 3GPP — Network rejection (TS 31.111 7.5.2)',
0x13: 'HCI connectivity',
0x14: 'Access technology change (multiple)',
0x15: 'Reserved for 3GPP — CSG cell selection (TS 31.111 7.5.3)',
0x16: 'Contactless state request',
0x17: 'Reserved for 3GPP — IMS registration (TS 31.111 7.5.21)',
0x18: 'Reserved for 3GPP — Incoming IMS data (TS 31.111 7.5.20)',
0x19: 'Profile container', 0x1A: 'Void', 0x1B: 'Secured profile container',
0x1C: 'Poll interval negotiation',
0x1D: 'Reserved for 3GPP — Data connection status change (TS 31.111 7.5.25)',
0x1E: 'Reserved for 3GPP — CAG cell selection (TS 31.111 7.5.26)',
0x1F: 'Reserved for 3GPP — Slices status change (TS 31.111 7.5.27)',
0x20: 'Reserved for 3GPP (future usage)',
0x21: 'Reserved for 3GPP (future usage)',
0x22: 'Reserved for 3GPP (future usage)',
};
const REJECTION_CAUSES = [
@@ -9380,14 +9391,27 @@ const EVENT_FORMS = {
}
},
0x13: null,
0x14: { title: 'Change of UICC Access',
0x14: { title: 'Access technology change (multiple)',
fields: [{ id: 'tech', label: 'Access Technology', type: 'select',
opts: [{v:0,l:'GSM'},{v:3,l:'UTRAN'},{v:8,l:'E-UTRAN'},{v:10,l:'NG-RAN'}] }],
build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); }
},
0x15: { title: 'CSG Cell Change', note: 'not_yet' },
0x15: { title: 'CSG cell selection', note: 'not_yet' },
0x16: null,
0x17: null,
0x1C: { title: 'Poll interval negotiation',
fields: [
{ id: 'unit', label: 'Time unit', type: 'select',
opts: [{v:1,l:'Seconds'},{v:0,l:'Minutes'},{v:2,l:'Tenths of seconds'}] },
{ id: 'interval', label: 'Time interval', type: 'number', min: '1', max: '255', placeholder: '1–255 (default 30 s)' },
],
build(v) {
const unit = parseInt(v.unit, 10) || 0;
let iv = parseInt(v.interval, 10);
if (!(iv >= 1 && iv <= 255)) iv = 30;
return ('0402' + unit.toString(16).padStart(2, '0') + iv.toString(16).padStart(2, '0')).toUpperCase();
}
},
};
const PLI_QUALIFIERS = [
@@ -9899,6 +9923,11 @@ async function pysimEventSend() {
const resp = await pysimFetch('/api/event-send', { event_type: _eventFormType, event_data: eventData });
if (resp.sw === '9000') {
result.textContent = (eventData ? t('Event data sent.') : t('Event sent.')) + ' SW: ' + resp.sw;
if (resp.negotiation) {
let neg = t('Poll interval negotiation:') + ' ' + t(resp.negotiation.result_name);
if (resp.negotiation.seconds) neg += ' (' + resp.negotiation.seconds + ' s)';
result.textContent += ' · ' + neg;
}
result.classList.remove('hidden');
} else {
result.textContent = 'SW: ' + resp.sw;
@@ -9949,11 +9978,11 @@ async function pysimPollToggle() {
const status = await pysimFetch('/api/poll-status');
const newEnabled = !status.enabled;
const resp = await pysimFetch('/api/poll-toggle', { enabled: newEnabled });
pysimUpdatePollUI(resp.enabled, resp.interval);
pysimUpdatePollUI(resp.enabled, resp.interval, resp.card_disabled, resp.warning);
} catch (e) { /* ignore */ }
}
function pysimUpdatePollUI(enabled, interval) {
function pysimUpdatePollUI(enabled, interval, cardDisabled, warning) {
const btn = document.getElementById('pli-pause-btn');
const stat = document.getElementById('pli-poll-stat');
if (!btn) return;
@@ -9966,13 +9995,18 @@ function pysimUpdatePollUI(enabled, interval) {
btn.classList.remove('bg-emerald-600', 'text-white');
btn.classList.add('bg-gray-200', 'dark:bg-slate-700', 'text-gray-700', 'dark:text-slate-300');
}
if (stat) stat.textContent = enabled ? '(' + interval + 's)' : '';
if (stat) {
stat.className = 'text-xs ' + (cardDisabled ? 'text-amber-600 dark:text-amber-400' : 'text-gray-400');
if (cardDisabled) stat.textContent = t('card disabled polling (POLLING OFF)');
else if (warning) stat.textContent = warning;
else stat.textContent = enabled ? '(' + interval + 's)' : '';
}
}
async function pysimPollStatusInit() {
try {
const status = await pysimFetch('/api/poll-status');
pysimUpdatePollUI(status.enabled, status.interval);
pysimUpdatePollUI(status.enabled, status.interval, status.card_disabled);
} catch (e) { /* ignore */ }
}
@@ -15387,6 +15421,13 @@ const LANG_RU = {
'STK menu': 'Меню STK',
'No menu set by the card': 'Меню не задано картой',
'Polling': 'Опрос',
'card disabled polling (POLLING OFF)': 'карта отключила опрос (POLLING OFF)',
'Time unit': 'Единица времени',
'Time interval': 'Интервал времени',
'Poll interval negotiation:': 'Согласование интервала опроса:',
'accepted': 'принято',
'rejected': 'отклонено',
'modified': 'изменено',
'STATUS...': 'STATUS...',
'Saving...': 'Сохранение...',
'Saved': 'Сохранено',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v261';
const CACHE = 'simple-v262';
const URLS = [
'index.html',
'help.html',
+22
View File
@@ -31,6 +31,7 @@ function extractFunc(src, name) {
// Rewrite top-level const -> var so the maps leak out of sloppy-mode eval.
eval(extractBlock('const CMD_NAMES = {', 'function cmdQualifierShort').replace(/^const /gm, 'var '));
eval(extractFunc(html, 'cmdQualifierShort'));
eval(extractBlock('const EVENT_NAMES = {', 'const REJECTION_CAUSES = [').replace(/^const /gm, 'var '));
eval(extractBlock('const REJECTION_CAUSES = [', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
eval(extractBlock('const EVENT_FORMS = {', 'const PLI_QUALIFIERS = [').replace(/^const /gm, 'var '));
@@ -64,6 +65,27 @@ test('cmdQualifierShort returns empty for unknown types', () => {
assert.strictEqual(cmdQualifierShort('99', 0x01), '');
});
test('EVENT_NAMES follows the pinned CAT spec and names the 3GPP events', () => {
assert.strictEqual(EVENT_NAMES[0x0B], 'Access technology change (single)');
assert.strictEqual(EVENT_NAMES[0x14], 'Access technology change (multiple)');
assert.strictEqual(EVENT_NAMES[0x19], 'Profile container');
assert.strictEqual(EVENT_NAMES[0x1C], 'Poll interval negotiation');
for (const [v, frag] of [[0x11, '(I-)WLAN access status'], [0x12, 'Network rejection'],
[0x15, 'CSG cell selection'], [0x17, 'IMS registration'], [0x18, 'Incoming IMS data'],
[0x1D, 'Data connection status change'], [0x1E, 'CAG cell selection'],
[0x1F, 'Slices status change']]) {
assert.ok(EVENT_NAMES[v].includes(frag), '0x' + v.toString(16) + ': ' + EVENT_NAMES[v]);
}
});
test('the poll interval negotiation event builds a Duration TLV', () => {
const build = EVENT_FORMS[0x1C].build;
assert.strictEqual(build({ unit: '1', interval: '45' }), '0402012D');
assert.strictEqual(build({ unit: '0', interval: '2' }), '04020002');
assert.strictEqual(build({ unit: '2', interval: '5' }), '04020205');
assert.strictEqual(build({ unit: '1', interval: '' }), '0402011E'); // default 30 s
});
test('channel status event builds the B8 channel status TLV', () => {
const build = EVENT_FORMS[0x0A].build;
assert.strictEqual(EVENT_FORMS[0x0A].note, undefined);
+62
View File
@@ -0,0 +1,62 @@
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, asyncFn) {
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 (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
eval(extractFunc(html, 'pysimUpdatePollUI'));
globalThis.t = s => s;
function setup() {
const els = {
'pli-pause-btn': { textContent: '', classList: { add: () => {}, remove: () => {} } },
'pli-poll-stat': { textContent: '', className: 'text-xs text-gray-400' },
};
globalThis.document = { getElementById: id => els[id] || null };
return els;
}
test('pysimUpdatePollUI shows the interval while polling is on', () => {
const els = setup();
pysimUpdatePollUI(true, 30);
assert.strictEqual(els['pli-pause-btn'].textContent, 'ON');
assert.strictEqual(els['pli-poll-stat'].textContent, '(30s)');
assert.ok(!els['pli-poll-stat'].className.includes('amber'));
});
test('pysimUpdatePollUI shows the card-disabled state in amber', () => {
const els = setup();
pysimUpdatePollUI(true, 30, true);
assert.ok(els['pli-poll-stat'].textContent.includes('POLLING OFF'), els['pli-poll-stat'].textContent);
assert.ok(els['pli-poll-stat'].className.includes('text-amber-600'));
});
test('pysimUpdatePollUI shows a server warning', () => {
const els = setup();
pysimUpdatePollUI(true, 30, false, 'card disabled proactive polling');
assert.strictEqual(els['pli-poll-stat'].textContent, 'card disabled proactive polling');
});
test('pysimUpdatePollUI clears the status line when off', () => {
const els = setup();
pysimUpdatePollUI(false, 30);
assert.strictEqual(els['pli-pause-btn'].textContent, 'OFF');
assert.strictEqual(els['pli-poll-stat'].textContent, '');
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
version = "3.5.8"
version = "3.5.9"
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.
+160 -21
View File
@@ -30,7 +30,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
VERSION = '3.5.8'
VERSION = '3.5.9'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -1327,6 +1327,10 @@ _SCP81_PSK_LEGACY = None
_POLL_ENABLED = False
_POLL_INTERVAL = 30
_POLL_TIMER = None
# Set when the card sent POLLING OFF (TS 102 223 6.4.14): proactive polling
# stays suspended until a POLL INTERVAL re-enables it. Presence detection and
# the manual "Send STATUS" button are not affected.
_POLL_DISABLED_BY_CARD = False
_CARD_LOCK = threading.RLock()
_CARD_CONNECTED = False
# True when the current PC/SC transport is unusable (service failure): the
@@ -1337,12 +1341,71 @@ def _set_poll_interval(seconds):
global _POLL_INTERVAL
_POLL_INTERVAL = max(0, min(255, int(seconds)))
def _duration_seconds(unit, interval):
"""Duration TLV value (TS 102 223 8.8) as seconds for the background poll,
clamped to 1..255 s (the TERMINAL RESPONSE Duration carries seconds)."""
if unit == 0x00: # minutes
secs = interval * 60
elif unit == 0x02: # tenths of seconds
secs = int(round(interval / 10.0))
else: # seconds
secs = interval
return max(1, min(255, secs))
def _duration_text(unit, interval):
if unit == 0x00:
return '%d min' % interval
if unit == 0x02:
return '%.1f s' % (interval / 10.0)
return '%d s' % interval
def _find_duration(raw):
"""(unit, interval) of the Duration TLV in a proactive command (tag '04'
or its CR-set variant '84', TS 102 223 8.8), or None."""
if not raw:
return None
off = _skip_ber_len(raw, 1) if raw[0] == 0xD0 else 0
while off + 1 < len(raw):
tag, tlen = raw[off], raw[off + 1]
val = raw[off + 2: off + 2 + tlen]
if tag in (0x04, 0x84) and len(val) >= 2:
return val[0], val[1]
off += 2 + tlen
return None
def _handle_card_poll_command(cmd_type, raw):
"""Adopt the card's polling wishes before its TERMINAL RESPONSE is built
(TS 102 223 6.4.6 POLL INTERVAL / 6.4.14 POLLING OFF)."""
global _POLL_DISABLED_BY_CARD, _POLL_TIMER
if cmd_type == 0x03:
dur = _find_duration(raw)
if dur:
unit, interval = dur
secs = _duration_seconds(unit, interval)
_set_poll_interval(secs)
sys.stderr.write('POLL INTERVAL: card requests %s -> background poll %d s\n'
% (_duration_text(unit, interval), secs))
_POLL_DISABLED_BY_CARD = False
if _POLL_ENABLED:
_reset_poll_timer()
elif cmd_type == 0x04:
if not _POLL_DISABLED_BY_CARD:
sys.stderr.write('POLLING OFF: card disabled proactive polling\n')
_POLL_DISABLED_BY_CARD = True
if _POLL_TIMER is not None:
_POLL_TIMER.cancel()
_POLL_TIMER = None
def _reset_poll_timer():
global _POLL_TIMER
if _POLL_TIMER is not None:
_POLL_TIMER.cancel()
_POLL_TIMER = None
if _POLL_ENABLED and _POLL_INTERVAL > 0:
if _POLL_ENABLED and _POLL_INTERVAL > 0 and not _POLL_DISABLED_BY_CARD:
_POLL_TIMER = threading.Timer(_POLL_INTERVAL, _do_status_poll)
_POLL_TIMER.daemon = True
_POLL_TIMER.start()
@@ -1350,7 +1413,7 @@ def _reset_poll_timer():
def _do_status_poll():
global _POLL_TIMER
_POLL_TIMER = None
if not _POLL_ENABLED:
if not _POLL_ENABLED or _POLL_DISABLED_BY_CARD:
return
with _CARD_LOCK:
try:
@@ -1420,6 +1483,53 @@ def _poll_disable():
_POLL_TIMER.cancel()
_POLL_TIMER = None
POLL_NEGOTIATION_RESULTS = {0: 'accepted', 1: 'rejected', 2: 'modified'}
def _decode_poll_negotiation(data_hex):
"""Decode the ENVELOPE response data of a Poll Interval Negotiation event
(TS 102 223 7.5.22.2 / 8.97). No response data means 'accepted'."""
s = re.sub(r'\s', '', data_hex or '')
if not s:
return {'result': 0, 'result_name': 'accepted', 'unit': None,
'interval': None, 'seconds': None}
try:
data = bytes.fromhex(s)
except ValueError:
return None
result = None
unit = interval = None
off = 0
while off + 1 < len(data):
tag, tlen = data[off], data[off + 1]
val = data[off + 2: off + 2 + tlen]
off += 2 + tlen
if tag in (0x11, 0x91) and len(val) >= 1:
result = val[0]
elif tag in (0x04, 0x84) and len(val) >= 2:
unit, interval = val[0], val[1]
if result is None:
return None
out = {'result': result, 'result_name': POLL_NEGOTIATION_RESULTS.get(result, 'unknown'),
'unit': unit, 'interval': interval, 'seconds': None}
if unit is not None:
out['seconds'] = _duration_seconds(unit, interval)
return out
def _apply_poll_negotiation(neg):
"""Act on the card's answer to our poll interval proposal: a 'modified'
result carries the duration the terminal shall use from now on."""
global _POLL_DISABLED_BY_CARD
if not neg or neg.get('result') != 2 or not neg.get('seconds'):
return
_set_poll_interval(neg['seconds'])
_POLL_DISABLED_BY_CARD = False
sys.stderr.write('POLL NEGOTIATION: modified -> background poll %d s\n' % neg['seconds'])
if _POLL_ENABLED:
_reset_poll_timer()
_server_ref = None
@@ -1438,23 +1548,34 @@ def _tr_data_only(tr_tlv):
return bytes(data)
# Event list codings (TS 102 223 v18.3.0 8.25). Values the CAT spec leaves
# "Reserved for 3GPP" carry the concrete 3GPP event name where TS 31.111
# defines one (clauses in parentheses), so the list stays informative.
EVENT_NAMES = {
0x00: 'MT call', 0x01: 'Call connected', 0x02: 'Call disconnected',
0x03: 'Location status', 0x04: 'User activity', 0x05: 'Idle screen available',
0x06: 'Card reader status', 0x07: 'Language selection',
0x08: 'Browser termination', 0x09: 'Data available',
0x0A: 'Channel status', 0x0B: 'Access Technology Change',
0x0A: 'Channel status', 0x0B: 'Access technology change (single)',
0x0C: 'Display parameters changed', 0x0D: 'Local connection',
0x0E: 'Network Search Mode Change', 0x0F: 'Browsing status',
0x10: 'Frames Information Change', 0x11: 'I-WLAN Access Status',
0x12: 'Network Rejection', 0x13: 'HCI Connectivity',
0x14: 'Change of UICC Access', 0x15: 'CSG Cell Change',
0x16: 'Contactless state request', 0x17: 'Profile Container',
0x18: 'LTE D2D Discovery Monitoring', 0x19: 'LTE D2D Communication Monitoring',
0x1A: 'LTE D2D Announcement Response', 0x1B: 'LTE D2D Revocation',
0x1C: 'LTE D2D Application Port', 0x1D: 'LTE D2D Security Recovery',
0x1E: 'Off-net Emergency Call', 0x1F: 'ECall Over IMS',
0x20: 'EARFCN Update', 0x21: 'SCEF Channel Status',
0x0E: 'Network search mode change', 0x0F: 'Browsing status',
0x10: 'Frames information change',
0x11: 'Reserved for 3GPP \u2014 (I-)WLAN access status (TS 31.111 7.5.1)',
0x12: 'Reserved for 3GPP \u2014 Network rejection (TS 31.111 7.5.2)',
0x13: 'HCI connectivity',
0x14: 'Access technology change (multiple)',
0x15: 'Reserved for 3GPP \u2014 CSG cell selection (TS 31.111 7.5.3)',
0x16: 'Contactless state request',
0x17: 'Reserved for 3GPP \u2014 IMS registration (TS 31.111 7.5.21)',
0x18: 'Reserved for 3GPP \u2014 Incoming IMS data (TS 31.111 7.5.20)',
0x19: 'Profile container', 0x1A: 'Void', 0x1B: 'Secured profile container',
0x1C: 'Poll interval negotiation',
0x1D: 'Reserved for 3GPP \u2014 Data connection status change (TS 31.111 7.5.25)',
0x1E: 'Reserved for 3GPP \u2014 CAG cell selection (TS 31.111 7.5.26)',
0x1F: 'Reserved for 3GPP \u2014 Slices status change (TS 31.111 7.5.27)',
0x20: 'Reserved for 3GPP (future usage)',
0x21: 'Reserved for 3GPP (future usage)',
0x22: 'Reserved for 3GPP (future usage)',
}
ACCESSTECH_NAMES = {
@@ -1672,9 +1793,12 @@ def _decode_cmd(cmd_type, raw, qualifier):
if not raw:
return []
if cmd_type == 0x03:
idx = raw.find(b'\x84\x02\x01')
if idx >= 0 and idx + 3 < len(raw):
return [{'label': 'Interval', 'value': '%d s' % raw[idx + 3]}]
dur = _find_duration(raw)
if dur:
text = _duration_text(dur[0], dur[1])
if dur[0] != 0x01:
text += ' (%d s)' % _duration_seconds(dur[0], dur[1])
return [{'label': 'Interval', 'value': text}]
return []
if cmd_type == 0x05:
for tag in (0x99, 0x19):
@@ -3758,6 +3882,8 @@ def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True):
else:
cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = 1, 0, 0x83, 0x81, None
entry = None
if raw and cmd_type in (0x03, 0x04):
_handle_card_poll_command(cmd_type, raw)
if on_fetch:
action = on_fetch(raw, cmd_num, cmd_type, dev_src, dev_dst)
paused = action == 'pause'
@@ -3836,6 +3962,8 @@ def _send_terminal_profile(scc, tp_hex):
if menu:
_attach_nai(items, nai)
sim_menu = menu
if fdata and cmd_type in (0x03, 0x04):
_handle_card_poll_command(cmd_type, raw)
if fdata and cmd_type:
entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num)
else:
@@ -4267,7 +4395,8 @@ class PysimHandler(BaseHTTPRequestHandler):
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/poll-status':
resp = {'enabled': _POLL_ENABLED, 'interval': _POLL_INTERVAL}
resp = {'enabled': _POLL_ENABLED, 'interval': _POLL_INTERVAL,
'card_disabled': _POLL_DISABLED_BY_CARD}
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/proactive-log':
@@ -4933,14 +5062,20 @@ class PysimHandler(BaseHTTPRequestHandler):
try:
data, sw = _send_event_download(scc, event_type, event_data)
try:
is_location = int(event_type) == netsim.EVENT_LOCATION_STATUS
ev_type = int(event_type)
except (TypeError, ValueError):
is_location = False
ev_type = -1
is_location = ev_type == netsim.EVENT_LOCATION_STATUS
if is_location:
_netstate_after_event(self.server, event_type, event_data)
resp = {'sw': sw}
if data:
resp['data'] = data
if ev_type == 0x1C:
negotiation = _decode_poll_negotiation(data)
if negotiation:
resp['negotiation'] = negotiation
_apply_poll_negotiation(negotiation)
if getattr(self.server, 'net_state', None) is not None:
resp['net_state'] = self.server.net_state
self._send_json(resp)
@@ -4973,7 +5108,11 @@ class PysimHandler(BaseHTTPRequestHandler):
_poll_enable()
else:
_poll_disable()
resp = {'enabled': _POLL_ENABLED, 'interval': _POLL_INTERVAL}
resp = {'enabled': _POLL_ENABLED, 'interval': _POLL_INTERVAL,
'card_disabled': _POLL_DISABLED_BY_CARD}
if _POLL_ENABLED and _POLL_DISABLED_BY_CARD:
resp['warning'] = ('card disabled proactive polling with POLLING OFF '
'(TS 102 223 6.4.14); it resumes after a POLL INTERVAL')
self._send_json(resp)
self._log_resp(resp)
elif self.path == '/api/send-ota':
+85
View File
@@ -51,5 +51,90 @@ class TestPollInterval(unittest.TestCase):
self.assertIsNone(S._POLL_TIMER)
POLL_CMD = 'D00D810301030082028182' # Command Details + Device Identities
MIN_2_TLV = POLL_CMD + '04020002' # Duration: 2 minutes (plain tag)
SEC_30_TLV = POLL_CMD + '8402011E' # Duration: 30 seconds (CR tag)
class TestCardDrivenPolling(unittest.TestCase):
"""TS 102 223 6.4.6/6.4.14: adopt the card's POLL INTERVAL duration and
honour POLLING OFF; TS 102 223 7.5.22/8.97: act on a negotiation answer."""
def setUp(self):
self.saved = (S._POLL_ENABLED, S._POLL_INTERVAL, S._POLL_TIMER,
S._POLL_DISABLED_BY_CARD)
def tearDown(self):
S._poll_disable()
(S._POLL_ENABLED, S._POLL_INTERVAL, S._POLL_TIMER,
S._POLL_DISABLED_BY_CARD) = self.saved
def test_duration_units_convert_to_seconds(self):
self.assertEqual(S._duration_seconds(0x01, 30), 30)
self.assertEqual(S._duration_seconds(0x00, 2), 120)
self.assertEqual(S._duration_seconds(0x02, 5), 1) # tenths, min 1 s
self.assertEqual(S._duration_seconds(0x00, 200), 255) # clamped
self.assertEqual(S._duration_text(0x00, 2), '2 min')
self.assertEqual(S._duration_text(0x01, 30), '30 s')
def test_find_duration_accepts_both_tag_styles(self):
self.assertEqual(S._find_duration(bytes.fromhex(SEC_30_TLV)), (1, 30))
self.assertEqual(S._find_duration(bytes.fromhex(MIN_2_TLV)), (0, 2))
self.assertIsNone(S._find_duration(bytes.fromhex(POLL_CMD)))
def test_poll_interval_command_is_adopted(self):
S._set_poll_interval(30)
S._handle_card_poll_command(0x03, bytes.fromhex(MIN_2_TLV))
self.assertEqual(S._POLL_INTERVAL, 120)
self.assertFalse(S._POLL_DISABLED_BY_CARD)
def test_polling_off_suspends_until_a_new_interval(self):
S._POLL_ENABLED = True
S._set_poll_interval(30)
S._handle_card_poll_command(0x04, b'')
self.assertTrue(S._POLL_DISABLED_BY_CARD)
with mock.patch.object(S.threading, 'Timer') as timer:
S._reset_poll_timer()
timer.assert_not_called()
with mock.patch.object(S.threading, 'Timer') as timer:
S._handle_card_poll_command(0x03, bytes.fromhex(SEC_30_TLV))
timer.assert_called_once_with(30, S._do_status_poll)
self.assertFalse(S._POLL_DISABLED_BY_CARD)
def test_status_poll_does_not_run_when_card_disabled(self):
S._POLL_ENABLED = True
S._POLL_DISABLED_BY_CARD = True
with mock.patch.object(S, '_send_status') as send, \
mock.patch.object(S.threading, 'Timer') as timer:
S._do_status_poll()
send.assert_not_called()
timer.assert_not_called()
def test_negotiation_decode_and_modified_apply(self):
neg = S._decode_poll_negotiation('1101020402011E') # modified, 30 s
self.assertEqual(neg['result_name'], 'modified')
self.assertEqual(neg['seconds'], 30)
self.assertEqual(S._decode_poll_negotiation('')['result_name'], 'accepted')
self.assertEqual(S._decode_poll_negotiation('110100')['result_name'], 'accepted')
self.assertEqual(S._decode_poll_negotiation('11010104020102')['result_name'], 'rejected')
self.assertIsNone(S._decode_poll_negotiation('1102')) # truncated result
# a 'modified' answer becomes the new poll interval
S._set_poll_interval(50)
S._POLL_ENABLED = True
with mock.patch.object(S.threading, 'Timer') as timer:
S._apply_poll_negotiation(neg)
timer.assert_called_once_with(30, S._do_status_poll)
self.assertEqual(S._POLL_INTERVAL, 30)
# a 'rejected' answer leaves the current interval alone
S._apply_poll_negotiation(S._decode_poll_negotiation('11010104020102'))
self.assertEqual(S._POLL_INTERVAL, 30)
def test_poll_interval_decoded_in_the_proactive_log(self):
fields = S._decode_cmd(0x03, bytes.fromhex(MIN_2_TLV), 0)
self.assertEqual(fields[0]['value'], '2 min (120 s)')
fields = S._decode_cmd(0x03, bytes.fromhex(SEC_30_TLV), 0)
self.assertEqual(fields[0]['value'], '30 s')
if __name__ == '__main__':
unittest.main()
+39 -1
View File
@@ -7,7 +7,7 @@ missing entry makes the proactive log show the generic 'Cmd 0x01' fallback.
import unittest
from pysim_simple_server.server import PROACTIVE_TYPE_NAMES
from pysim_simple_server.server import EVENT_NAMES, PROACTIVE_TYPE_NAMES
class ProactiveTypeNamesTests(unittest.TestCase):
@@ -34,5 +34,43 @@ class ProactiveTypeNamesTests(unittest.TestCase):
self.assertTrue(isinstance(name, str) and name, hex(value))
class EventNamesTests(unittest.TestCase):
"""Event list names follow TS 102 223 v18.3.0 8.25; the values the CAT
spec leaves "Reserved for 3GPP" carry the TS 31.111 event name."""
def test_reassigned_values_match_the_pinned_spec(self):
for value, name in [
(0x0B, 'Access technology change (single)'),
(0x14, 'Access technology change (multiple)'),
(0x19, 'Profile container'),
(0x1A, 'Void'),
(0x1B, 'Secured profile container'),
(0x1C, 'Poll interval negotiation'),
(0x20, 'Reserved for 3GPP (future usage)'),
]:
self.assertEqual(EVENT_NAMES[value], name, hex(value))
def test_3gpp_reserved_events_carry_their_3gpp_name(self):
for value, fragment, clause in [
(0x11, '(I-)WLAN access status', 'TS 31.111 7.5.1'),
(0x12, 'Network rejection', 'TS 31.111 7.5.2'),
(0x15, 'CSG cell selection', 'TS 31.111 7.5.3'),
(0x17, 'IMS registration', 'TS 31.111 7.5.21'),
(0x18, 'Incoming IMS data', 'TS 31.111 7.5.20'),
(0x1D, 'Data connection status change', 'TS 31.111 7.5.25'),
(0x1E, 'CAG cell selection', 'TS 31.111 7.5.26'),
(0x1F, 'Slices status change', 'TS 31.111 7.5.27'),
]:
name = EVENT_NAMES[value]
self.assertIn('Reserved for 3GPP', name, hex(value))
self.assertIn(fragment, name, hex(value))
self.assertIn(clause, name, hex(value))
def test_names_are_nonempty_strings(self):
for value, name in EVENT_NAMES.items():
self.assertIsInstance(value, int)
self.assertTrue(isinstance(name, str) and name, hex(value))
if __name__ == '__main__':
unittest.main()