diff --git a/frontend/help-ru.html b/frontend/help-ru.html
index 02f6fba..b7553c9 100644
--- a/frontend/help-ru.html
+++ b/frontend/help-ru.html
@@ -321,7 +321,7 @@
| Операция | Описание |
| Обзор карты (все данные GP) | Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 (расширенные ресурсы карты: число установленных приложений и свободная энергонезависимая/энергозависимая память). Если карта отвергает компактный формат листинга (P2.b2=0, устаревший по GP), запросы повторяются в расширенном формате E3; флажок Запрашивать расширенные данные реестра (E3) дополнительно запрашивает их и объединяет расширенные поля (привилегии, связи ELF/SD, списки модулей). Результаты отображаются в обзоре с кнопками Удалить для каждого элемента и определённым форматом команд RAM. |
- | Установка пакета (.cap файл) | Отправка .cap файла на карту через сервер: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера размер блока LOAD (1–240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой .cap просто занимает несколько SMS. Сразу после выбора файл проверяется и в форме показывается оценка требований к памяти: требование NVRAM (образ кода + постоянные данные, сумма в стиле C6+C8 из GP Card Spec) и оценка RAM. Там же показаны библиотеки, с которыми слинкован CAP (его компонент Import: имя библиотеки, AID и минимальная версия ≥ x.y — имена стандартных библиотек и семейства подставляются, а где известно, даётся подсказка о версии Java Card), идентификаторы пакета и апплетов и разбивка по компонентам (размеры и доли в порядке load-файла) — всё сгруппировано по разделам Package, Requires, Memory и Components. Строка Platform показывает минимальный уровень Java Card, который должна предоставить карта (выводится из импорта javacard.framework; флаг int в заголовке дополнительно требует поддержки int — Java Card 2.2+): JCRE проверяет каждый импорт по своим пакетам при верификации load-файла (JC VM spec 4.5.2), поэтому карта со старой платформой отклоняет LOAD сразу после завершения компонента Import (в строке шага виден remote SW карты) — меньший размер блока LOAD помогает только если блок превышает буфер карты. Повреждённый файл не проходит анализ, и Execute остаётся недоступной до успешного анализа. Оценка носит справочный характер и никогда не используется при установке: NVRAM — это образ кода плюс постоянные данные (сумма в стиле C6+C8 по GP Table 11-48; управление памятью карты, округление и запись в реестре не учитываются, а образ статических полей входит в обе части); рекомендуемый C6 следует за load-файлом, а рекомендуемые квоты — это десятичные значения в байтах (шестнадцатеричное значение в скобках — это кодирование параметров установки C7/C8: C6 — энергонезависимый код, C7 — энергозависимая RAM, C8 — энергонезависимые данные). |
+ | Установка пакета (.cap файл) | Отправка .cap файла на карту через сервер: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера размер блока LOAD (1–240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой .cap просто занимает несколько SMS. Сразу после выбора файл проверяется и в форме показывается оценка требований к памяти: требование NVRAM (образ кода + постоянные данные, сумма в стиле C6+C8 из GP Card Spec) и оценка RAM. Там же показаны библиотеки, с которыми слинкован CAP (его компонент Import: имя библиотеки, AID и минимальная версия ≥ x.y — имена стандартных библиотек и семейства подставляются, а где известно, даётся подсказка о версии Java Card), идентификаторы пакета и апплетов и разбивка по компонентам (размеры и доли в порядке load-файла) — всё сгруппировано по разделам Package, Requires, Memory и Components. Строка Platform показывает минимальный уровень Java Card, который должна предоставить карта (выводится из импорта javacard.framework; флаг int в заголовке дополнительно требует поддержки int — Java Card 2.2+): JCRE проверяет каждый импорт по своим пакетам при верификации load-файла (JC VM spec 4.5.2), поэтому карта со старой платформой отклоняет LOAD сразу после завершения компонента Import (в строке шага виден remote SW карты и компонент, который завершил блок, например завершает Import) — меньший размер блока LOAD помогает только если блок превышает буфер карты. Повреждённый файл не проходит анализ, и Execute остаётся недоступной до успешного анализа. Оценка носит справочный характер и никогда не используется при установке: NVRAM — это образ кода плюс постоянные данные (сумма в стиле C6+C8 по GP Table 11-48; управление памятью карты, округление и запись в реестре не учитываются, а образ статических полей входит в обе части); рекомендуемый C6 следует за load-файлом, а рекомендуемые квоты — это десятичные значения в байтах (шестнадцатеричное значение в скобках — это кодирование параметров установки C7/C8: C6 — энергонезависимый код, C7 — энергозависимая RAM, C8 — энергонезависимые данные). |
diff --git a/frontend/help.html b/frontend/help.html
index 03004b1..ad07ba8 100644
--- a/frontend/help.html
+++ b/frontend/help.html
@@ -320,7 +320,7 @@
| Explore Card (all GP data) | Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 (Extended Card Resources: the number of installed applications and the free non-volatile/volatile memory). If a card rejects the compact listing (P2.b2=0, deprecated by GP) the queries are retried in the expanded E3 form; the Query expanded registry details (E3) checkbox also requests it as an extra pass and merges the expanded-only fields (privileges, ELF/SD associations, module lists). Results appear in an explorer view with per-item Delete buttons and the detected RAM command format. |
- | Install Package (.cap file) | Sends a .cap file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the LOAD block size (1–240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large .cap simply takes several SMS. As soon as the file is selected it is validated and its memory requirements are estimated in the form: the NVRAM requirement (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the libraries the CAP is linked against (its Import component: library name, AID and the minimum version ≥ x.y, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) — grouped under the Package, Requires, Memory and Components headings. The Platform line names the minimum Java Card level the card must provide (derived from the javacard.framework import; the header int flag additionally requires int support, Java Card 2.2+): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the Import component is complete (the step line carries the card's remote SW) — a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and Execute stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 — card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested C6 follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data). |
+ | Install Package (.cap file) | Sends a .cap file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the LOAD block size (1–240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large .cap simply takes several SMS. As soon as the file is selected it is validated and its memory requirements are estimated in the form: the NVRAM requirement (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the libraries the CAP is linked against (its Import component: library name, AID and the minimum version ≥ x.y, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) — grouped under the Package, Requires, Memory and Components headings. The Platform line names the minimum Java Card level the card must provide (derived from the javacard.framework import; the header int flag additionally requires int support, Java Card 2.2+): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the Import component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. completes Import) — a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and Execute stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 — card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested C6 follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data). |
diff --git a/frontend/index.html b/frontend/index.html
index 905e212..529ce1b 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1732,7 +1732,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.6.36';
+const SIMPLE_VERSION = '3.6.37';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -9358,12 +9358,42 @@ async function ramDeleteFromExplorer(aid, withCascade) {
// consumed was saved above.
}
+// Which CAP components a LOAD step's payload covers (pure, diagnostic). The
+// server sends the component list in load-file order with sizes that include
+// each component's 3-byte header (capmem), so the ranges accumulate directly.
+// `completed` lists the components whose last byte falls inside the block -
+// the card has just seen them whole, which is where a verifier rejection
+// lands (a CAP whose Import list the card cannot satisfy is refused exactly
+// in the block completing Import).
+function ramStepComponents(name, components, blockSize) {
+ const m = /^LOAD \((\d+)\/(\d+)\)$/.exec(String(name || '').trim());
+ const comps = components || [];
+ const size = Number(blockSize) || 0;
+ if (!m || !comps.length || size <= 0) return null;
+ const start = (Number(m[1]) - 1) * size;
+ const end = Number(m[1]) * size;
+ let off = 0;
+ const names = [];
+ const completed = [];
+ for (const c of comps) {
+ const cstart = off, cend = off + (Number(c.size) || 0);
+ off = cend;
+ if (cend <= start) continue; // entirely before this block
+ if (cstart >= end) break; // entirely after it
+ names.push(c.name);
+ if (cend > start && cend <= end) completed.push(c.name);
+ }
+ if (!names.length) return null;
+ return { names: names, completed: completed };
+}
+
// One RAM install step line: the PoR verdict plus the remote command's
// status word (9000/61xx/62xx/63xx/CAFE are success; anything else is the
// card's error) - the details that used to be lost behind a bare
// "por_error_" label. The envelope/GET RESPONSE SW is only shown when it is
-// not a plain 9000.
-function ramStepLine(s, idx) {
+// not a plain 9000. With the CAP analysis at hand the line also names the
+// component the block completed (see ramStepComponents).
+function ramStepLine(s, idx, comps, blockSize) {
const ok = !s.por_error && (s.por_status === 'por_ok' || s.por_status === 'no_por');
let line = (ok ? '\u2705' : '\u274c') + ' ' + t('Step') + ' ' + (idx + 1) + ': ' +
s.name + ' \u2014 ';
@@ -9382,6 +9412,12 @@ function ramStepLine(s, idx) {
if (s.bytes && s.segments) {
line += ' \u00b7 ' + s.bytes + ' ' + t('bytes') + ' / ' + s.segments + ' SMS';
}
+ const sc = ramStepComponents(s.name, comps, blockSize);
+ if (sc) {
+ if (sc.completed.length) line += ' \u00b7 ' + t('completes') + ' ' + sc.completed[sc.completed.length - 1];
+ else if (sc.names.length === 1) line += ' \u00b7 ' + t('inside') + ' ' + sc.names[0];
+ else line += ' \u00b7 ' + sc.names[0] + '\u2026' + sc.names[sc.names.length - 1];
+ }
return line;
}
@@ -9398,7 +9434,10 @@ function ramInstallFailHint(data, mem) {
const step = steps[(Number(data.failed_step) || 1) - 1] || null;
const name = step ? String(step.name || '') : '';
if (!/^(LOAD |INSTALL \[for load\])/.test(name)) return '';
- let hint = t('a LOAD rejected here means the card refused the CAP: every import version under Requires must be present on the card');
+ let hint = '';
+ const sc = ramStepComponents(name, mem && mem.components, data.load_block_size);
+ if (sc && sc.completed.length) hint += t('the failed step completes') + ' ' + sc.completed[sc.completed.length - 1] + '. ';
+ hint += t('a LOAD rejected here means the card refused the CAP: every import version under Requires must be present on the card');
if (mem && mem.requires_java_card) hint += ' (Java Card \u2265 ' + mem.requires_java_card + ')';
hint += t('. If the CAP needs only versions this card has, retry with a smaller LOAD block (e.g. 64 B)');
return hint;
@@ -9507,8 +9546,9 @@ function ramShowInstallResult(data, okText, capMem) {
const resultEl = document.getElementById('ram-result');
const stepsEl = document.getElementById('ram-steps');
stepsEl.classList.remove('hidden');
+ const comps = capMem && capMem.components;
let txt = '';
- (data.steps || []).forEach((s, idx) => { txt += ramStepLine(s, idx) + '\n'; });
+ (data.steps || []).forEach((s, idx) => { txt += ramStepLine(s, idx, comps, data.load_block_size) + '\n'; });
stepsEl.textContent = txt;
// Persist the counter the card actually consumed (accepted packets only):
// even a failed install leaves the accepted steps behind, and replaying
@@ -17176,6 +17216,9 @@ const LANG_RU = {
'Install OK': 'Установка OK',
'Install FAILED at step:': 'Установка не удалась на шаге:',
'Hint:': 'Подсказка:',
+ 'completes': 'завершает',
+ 'inside': 'внутри',
+ 'the failed step completes': 'неудавшийся шаг завершает',
'a LOAD rejected here means the card refused the CAP: every import version under Requires must be present on the card': 'отклонение LOAD здесь означает, что карта отклонила CAP: все версии импортов из «Требуются» должны присутствовать на карте',
'. If the CAP needs only versions this card has, retry with a smaller LOAD block (e.g. 64 B)': '. Если CAP требует только доступные этой карте версии, повторите с меньшим размером блока LOAD (например, 64 Б)',
'Profile from snapshot': 'Профиль из снимка',
diff --git a/frontend/sw.js b/frontend/sw.js
index 0176db5..390018e 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v309';
+const CACHE = 'simple-v310';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js
index 947423f..37300b8 100644
--- a/frontend/tests/ram.test.js
+++ b/frontend/tests/ram.test.js
@@ -22,7 +22,7 @@ function extractFunc(src, name) {
}
// Extract chain builder functions and dependencies
-const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramInstallFailHint', 'ramGetStatusApdu', 'ramDeleteApdu',
+const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramGetStatusApdu', 'ramDeleteApdu',
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu',
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'parseTLV', '_parseE3Entry',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
@@ -390,6 +390,46 @@ test('ramStepLine shows the PoR verdict and the remote status word', () => {
assert.ok(noPor.startsWith('\u2705') && noPor.includes('no PoR'), noPor);
});
+test('ramStepComponents maps a LOAD step to the CAP components it covers', () => {
+ // RemMobileID-like layout: sizes include each component's 3-byte header
+ const comps = [
+ { name: 'Header', size: 20 }, { name: 'Directory', size: 34 },
+ { name: 'Applet', size: 15 }, { name: 'Import', size: 101 },
+ { name: 'ConstantPool', size: 1121 }, { name: 'Method', size: 8506 },
+ ];
+ // 240 B block 1 (0..239) completes the Import component (its end is 170)
+ let sc = ramStepComponents('LOAD (1/64)', comps, 240);
+ assert.deepStrictEqual(sc.completed, ['Header', 'Directory', 'Applet', 'Import']);
+ assert.deepStrictEqual(sc.names.slice(0, 2), ['Header', 'Directory']);
+ // 64 B blocks: 1 completes Header/Directory, 3 completes Import
+ assert.deepStrictEqual(ramStepComponents('LOAD (1/240)', comps, 64).completed, ['Header', 'Directory']);
+ sc = ramStepComponents('LOAD (2/240)', comps, 64);
+ assert.deepStrictEqual(sc.names, ['Applet', 'Import']);
+ assert.deepStrictEqual(sc.completed, ['Applet']);
+ sc = ramStepComponents('LOAD (3/240)', comps, 64);
+ assert.deepStrictEqual(sc.names, ['Import', 'ConstantPool']);
+ assert.deepStrictEqual(sc.completed, ['Import']);
+ // other step names and missing data produce nothing
+ assert.strictEqual(ramStepComponents('INSTALL [for load]', comps, 64), null);
+ assert.strictEqual(ramStepComponents('LOAD (1/64)', comps, 0), null);
+ assert.strictEqual(ramStepComponents('LOAD (1/64)', null, 64), null);
+});
+
+test('ramStepLine annotates a LOAD step with the completing component', () => {
+ globalThis.t = s => s;
+ globalThis.lookupSw = () => '';
+ const comps = [{ name: 'Header', size: 20 }, { name: 'Import', size: 101 },
+ { name: 'Method', size: 8506 }];
+ const line = ramStepLine({ name: 'LOAD (1/64)', por_status: 'por_ok', por_sw: '9000' }, 0, comps, 240);
+ assert.ok(line.includes('completes Import'), line);
+ // block 2 of 40 B blocks (40..79) lies strictly inside Import
+ const inside = ramStepLine({ name: 'LOAD (2/64)', por_status: 'por_ok', por_sw: '9000' }, 1, comps, 40);
+ assert.ok(inside.includes('inside Import'), inside);
+ // without a component list the line is unchanged
+ const plain = ramStepLine({ name: 'LOAD (1/64)', por_status: 'por_ok', por_sw: '9000' }, 0);
+ assert.ok(!plain.includes('completes') && !plain.includes('inside'), plain);
+});
+
test('ramInstallFailHint names the CAP import requirement for a rejected LOAD', () => {
globalThis.t = s => s;
const failedLoad = {
@@ -408,6 +448,14 @@ test('ramInstallFailHint names the CAP import requirement for a rejected LOAD',
assert.strictEqual(ramInstallFailHint(
{ success: false, failed_step: 2, steps: [{ name: 'INSTALL [for load]' }, { name: 'INSTALL [for install]' }] }, null), '');
assert.strictEqual(ramInstallFailHint({ success: true, failed_step: 1, steps: [] }, null), '');
+ // with the CAP analysis the completing component of the failing block is
+ // named (the card check lands on the component boundary)
+ const comps = [{ name: 'Header', size: 20 }, { name: 'Import', size: 101 }];
+ const withBoundary = ramInstallFailHint(
+ { success: false, failed_step: 2, load_block_size: 64,
+ steps: [{ name: 'INSTALL [for load]' }, { name: 'LOAD (2/240)', por_sw: '6438' }] },
+ { requires_java_card: '2.2.2', components: comps });
+ assert.ok(withBoundary.startsWith('the failed step completes Import. '), withBoundary);
});
test('ramOpChanged clears the executed status only on a real op change', () => {
diff --git a/pyproject.toml b/pyproject.toml
index b95099a..8177aeb 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.6.36"
+version = "3.6.37"
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.
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index 6703d45..7257b5e 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -31,7 +31,7 @@ from osmocom.tlv import BER_TLV_IE
from osmocom.utils import rpad
-VERSION = '3.6.36'
+VERSION = '3.6.37'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -870,7 +870,7 @@ def _build_sms_tpdu(chunk_hex, chunk_total=1, chunk_num=1, oa_number='12345', in
def _send_envelope(tpdu_hex, scc, sm_sc='12345678912', submit_handler=None,
- handle_proactive=True):
+ handle_proactive=True, poll_status=True):
from pySim.ts_31_102 import SMSPPDownload
from pySim.cat import DeviceIdentities, Address
from osmocom.tlv import COMPR_TLV_IE
@@ -884,6 +884,30 @@ def _send_envelope(tpdu_hex, scc, sm_sc='12345678912', submit_handler=None,
def to_bytes(self, context={}):
return self._raw
+ # Handler for a proactive chain that carries the PoR: the ENVELOPE may
+ # answer 9000 and the card delivers SEND SHORT MESSAGE on a later exchange
+ # (the STATUS poll below). v3.6.37: this used to be defined inside the
+ # 91xx branch, so the poll path raised UnboundLocalError and the RAM
+ # operation died mid-packet with the command left pending (live 500).
+ def _capture_sms_tpdu(raw, cmd_num, cmd_type, dev_src, dev_dst):
+ if submit_handler:
+ tpdu_hex = _find_sms_tpdu(raw)
+ if tpdu_hex:
+ ref, total, num, payload = _parse_sms_concat(bytes.fromhex(tpdu_hex))
+ if total is not None and num is not None:
+ submit_handler.sms_segments.append((ref, total, num, payload.hex()))
+ matching = [s for s in submit_handler.sms_segments if s[0] == ref]
+ if len(matching) >= total:
+ sorted_segs = sorted(matching, key=lambda s: s[2])
+ assembled = b''.join(bytes.fromhex(s[3]) for s in sorted_segs)
+ submit_handler.submit_ud_hex = assembled.hex()
+ submit_handler.submit_tpdu_hex = submit_handler.submit_tpdu_hex or tpdu_hex
+ sys.stderr.write('SMS concat: assembled %d segments (ref=%s, %d B)\n' % (
+ total, ref, len(assembled)))
+ else:
+ submit_handler.submit_ud_hex = payload.hex()
+ submit_handler.submit_tpdu_hex = tpdu_hex
+
address = Address()
oa_raw = _encode_sms_oa(sm_sc)
address.from_bytes(oa_raw[1:])
@@ -897,27 +921,12 @@ def _send_envelope(tpdu_hex, scc, sm_sc='12345678912', submit_handler=None,
get_len = int(sw[2:], 16) if len(sw) == 4 else 0x100
data, sw = scc._tp.send_apdu('00c00000%02x' % get_len)
elif handle_proactive and sw.startswith('91'):
- def _capture_sms_tpdu(raw, cmd_num, cmd_type, dev_src, dev_dst):
- if submit_handler:
- tpdu_hex = _find_sms_tpdu(raw)
- if tpdu_hex:
- ref, total, num, payload = _parse_sms_concat(bytes.fromhex(tpdu_hex))
- if total is not None and num is not None:
- submit_handler.sms_segments.append((ref, total, num, payload.hex()))
- matching = [s for s in submit_handler.sms_segments if s[0] == ref]
- if len(matching) >= total:
- sorted_segs = sorted(matching, key=lambda s: s[2])
- assembled = b''.join(bytes.fromhex(s[3]) for s in sorted_segs)
- submit_handler.submit_ud_hex = assembled.hex()
- submit_handler.submit_tpdu_hex = submit_handler.submit_tpdu_hex or tpdu_hex
- sys.stderr.write('SMS concat: assembled %d segments (ref=%s, %d B)\n' % (
- total, ref, len(assembled)))
- else:
- submit_handler.submit_ud_hex = payload.hex()
- submit_handler.submit_tpdu_hex = tpdu_hex
_handle_proactive_chain(scc, sw, _capture_sms_tpdu)
data, sw = '', '9000'
- if (handle_proactive and sw == '9000' and submit_handler
+ # Late-PoR fallback: only after the last segment of a packet (a
+ # concatenated download cannot have produced its PoR before the card got
+ # every part, and polling mid-packet made spurious STATUS exchanges).
+ if (handle_proactive and poll_status and sw == '9000' and submit_handler
and not submit_handler.submit_tpdu_hex and not data):
sys.stderr.write('STATUS poll (PoR not captured)\n')
st_data, st_sw = _send_status(scc)
@@ -1115,7 +1124,8 @@ def _send_secured_packet(scc, sp_hex, oa_number, sm_sc=None, include_cpi=True,
i + 1, total, len(part), ' + CPI' if i == 0 and include_cpi else ''))
data, sw = _send_envelope(tpdu, scc, sm_sc=sm_sc or '12345678912',
submit_handler=submit_handler,
- handle_proactive=handle_proactive)
+ handle_proactive=handle_proactive,
+ poll_status=(i == total - 1))
if sw != '9000' and not sw.startswith('91'):
return {'success': False, 'sw': sw, 'bytes': len(pkt),
'segments': total,
diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py
index 9b75d33..20952c0 100644
--- a/tests/test_ota_helpers.py
+++ b/tests/test_ota_helpers.py
@@ -304,6 +304,67 @@ class TestSmsConcatenation(unittest.TestCase):
else:
self.assertNotIn(concat + '7000', tpdu)
+ def test_send_secured_packet_polls_for_a_late_por_only_at_the_last_segment(self):
+ import pysim_simple_server.server as srv
+ apdu = '80E80000F0' + '00' * 240 + '00'
+ sp_hex, _ = _build_secured_packet('16', '01', '15', '15', 'b00000',
+ '0000000001', apdu, K, K)
+ polls = []
+
+ def fake_envelope(tpdu_hex, scc, sm_sc=None, submit_handler=None,
+ poll_status=True, **kwargs):
+ polls.append(poll_status)
+ return '', '9000'
+
+ with mock.patch.object(srv, '_send_envelope', side_effect=fake_envelope):
+ result = _send_secured_packet(object(), sp_hex, oa_number='12345')
+ self.assertTrue(result['success'], result)
+ self.assertGreater(result['segments'], 1)
+ self.assertEqual(polls, [False] * (result['segments'] - 1) + [True])
+ # a single-SMS packet keeps the immediate late-PoR poll
+ polls.clear()
+ with mock.patch.object(srv, '_send_envelope', side_effect=fake_envelope):
+ _send_secured_packet(object(), '00' * 10, oa_number='12345')
+ self.assertEqual(polls, [True])
+
+ def test_envelope_status_poll_carries_the_capture_handler(self):
+ # A late PoR (ENVELOPE 9000, then STATUS 91xx) must reach the
+ # proactive chain with the SEND SHORT MESSAGE capture handler. It
+ # used to be defined inside the 91xx branch only, so this path raised
+ # UnboundLocalError and killed the operation mid-packet with the
+ # command left pending (live 2026-09-29).
+ import pysim_simple_server.server as srv
+
+ class FakeTp:
+ def send_apdu(self, apdu):
+ return '', '9000'
+
+ class FakeScc:
+ cat_cla = '80'
+ _tp = FakeTp()
+
+ class FakeSubmit:
+ sms_segments = []
+ submit_tpdu_hex = None
+ submit_ud_hex = None
+
+ calls = []
+ with mock.patch.object(srv, '_send_status', return_value=('', '910b')), \
+ mock.patch.object(srv, '_handle_proactive_chain',
+ side_effect=lambda scc, sw, cb=None: calls.append((sw, cb))):
+ data, sw = srv._send_envelope('00', FakeScc(), submit_handler=FakeSubmit())
+ self.assertEqual(sw, '9000')
+ self.assertEqual(len(calls), 1)
+ self.assertEqual(calls[0][0], '910b')
+ self.assertTrue(callable(calls[0][1]))
+ # a mid-packet segment (poll_status=False) skips the poll entirely
+ with mock.patch.object(srv, '_send_status') as status, \
+ mock.patch.object(srv, '_handle_proactive_chain') as chain:
+ srv._send_envelope('00', FakeScc(), submit_handler=FakeSubmit(),
+ poll_status=False)
+ self.assertFalse(status.called)
+ self.assertFalse(chain.called)
+
def test_send_secured_packet_refuses_more_than_the_card_buffer(self):
length = SCP80_FIRST_BYTES + SCP80_NEXT_BYTES * (SCP80_MAX_SEGMENTS - 1) + 1
result = _send_secured_packet(object(), '00' * length, oa_number='12345')