diff --git a/frontend/index.html b/frontend/index.html
index cd7049d..dedb103 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1750,7 +1750,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.7.0';
+const SIMPLE_VERSION = '3.7.1';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -9257,6 +9257,22 @@ function ramElfVersionHint(elfForm) {
return t('Package versions come from the expanded registry query \u2014 this card did not return them.');
}
+// The applet instance that belongs to a package (pure). The package's module
+// AIDs are the applet instance AIDs - they can differ from the package AID in
+// the last byte (live 2026-09-30: package A113...0200 with modules ...0208 /
+// ...494D4508, all present as applet instances); a shared AID prefix is the
+// fallback rule. Applets that already carry a version are skipped.
+function ramElfAppletCandidate(elf, apps) {
+ if (!elf || !elf.aid) return null;
+ const cands = (elf.moduleAids || []).concat([elf.aid])
+ .map(c => String(c).toUpperCase());
+ const list = (apps || []).filter(a => a && a.aid && !a.version);
+ const exact = list.find(a => cands.indexOf(String(a.aid).toUpperCase()) >= 0);
+ if (exact) return exact;
+ const pkg = String(elf.aid).toUpperCase();
+ return list.find(a => String(a.aid).toUpperCase().indexOf(pkg) === 0) || null;
+}
+
// Fill a missing ELF version from an application entry: cards commonly return
// the load file version (CE) on the app entry together with its package AID
// (C4), so the package's version can be read from its applet instance (live
@@ -9668,21 +9684,35 @@ async function ramExplore(sp) {
// Still missing versions: query the apps listing once per applet AID (the
// AID search qualifier selects one entity). The apps tag list works even
// where the ELF one is refused, and an applet's CE is its package's
- // version - the applet <-> package mapping is the AID prefix (v3.7.0).
+ // version - the applet instance AIDs are the package's module AIDs
+ // (ramElfAppletCandidate). The single-AID form answers 61xx (the card
+ // holds the data), so it is retried with the GET RESPONSE chained into the
+ // compact command string, like the ladder (v3.7.1).
async function elfPerAidVersions() {
const missing = elfs.filter(e => !e.version && e.aid);
for (const e of missing.slice(0, 6)) {
if (cntrLow) break;
- const app = apps.find(a => a.aid && !a.version &&
- String(a.aid).toUpperCase().indexOf(String(e.aid).toUpperCase()) === 0);
+ const app = ramElfAppletCandidate(e, apps);
if (!app) continue;
ramShowProgress(t('Version') + ' ' + e.aid + '...');
- const res = await sendOta(ramExpandedQueryApdu('40', 'tags', false, app.aid),
- Object.assign({}, sp, { cntr, spi2: ramListingSpi2('40') }));
- if (res.ram_format) sp.ram_format = res.ram_format;
- if (res.final_cntr) cntr = res.final_cntr;
- else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
+ const apdu = ramExpandedQueryApdu('40', 'tags', false, app.aid);
+ const send = async a => {
+ const res = await sendOta(a, Object.assign({}, sp, { cntr, spi2: ramListingSpi2('40') }));
+ if (res.ram_format) sp.ram_format = res.ram_format;
+ if (res.final_cntr) cntr = res.final_cntr;
+ else if (res.success && spPorAccepted(res.por)) cntr = ramIncrementCntr(cntr);
+ return res;
+ };
+ let res = await send(apdu);
if (!res.success || !res.por || !spPorAccepted(res.por)) continue;
+ let sw = ((res.por.decoded && res.por.decoded.last_status_word) || '').toUpperCase();
+ if (/^61[0-9A-F]{2}$/.test(sw) && sp.ram_format !== 'expanded') {
+ // the card holds the response data: the GET RESPONSE must ride
+ // in the same compact command string (a standalone one answers
+ // 6700 on the live card)
+ res = await send(ramChainGetResponse(apdu));
+ if (!res.success || !res.por || !spPorAccepted(res.por)) continue;
+ }
const data = res.por.decoded ? (res.por.decoded.last_response_data || '') : '';
const parsed = data ? ramParseAppStatus(data) : [];
const hit = parsed.find(x => x.version);
diff --git a/frontend/sw.js b/frontend/sw.js
index 5f5baf3..292e837 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v332';
+const CACHE = 'simple-v333';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/ram.test.js b/frontend/tests/ram.test.js
index c81053e..7c60eae 100644
--- a/frontend/tests/ram.test.js
+++ b/frontend/tests/ram.test.js
@@ -25,7 +25,7 @@ function extractFunc(src, name) {
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtVersion', 'ramMenuState', 'ramEntryKind', 'ramMenuHtml', 'ramRawTlvsHtml', 'ramNvLine', 'ramFmtPrivileges', 'ramRenderExploreHtml', 'ramMergeExpanded', 'ramStepLine', 'ramStepComponents', 'ramInstallFailHint', 'ramProbeParse', 'ramCompatVerdict', 'ramCompatProbeNote', 'ramCompatImportRows', 'ramCompatRowsHtml', 'ramCompatFailureHtml', 'ramCompatDetailHtml', 'lookupSw', 'ramExpandedDetailsInit', 'ramExpandedDetailsChanged', 'ramGetStatusApdu', 'ramDeleteApdu',
'stkParamsBuild', 'ramRemoteSwOk', 'spPorAccepted', 'ramIncrementCntr', 'ramDeleteFromExplorer', 'ramListingSpi2', 'ramRemoveFromExplorer', 'ramHasInstance', 'ramExpandedQueryApdu',
'_parseRawElfEntry', '_parseRawAppEntry', 'ramParseElfStatus', 'ramParseAppStatus', 'ramParseModuleAids', 'parseTLV', '_parseE3Entry', '_parseMenuEntries',
- 'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramChainGetResponse', 'ramDeriveElfVersions',
+ 'ramExpandedReport', 'ramExpandedTags', 'ramExpandedGroups', 'ramExpandedElfForm', 'ramElfVersionHint', 'ramChainGetResponse', 'ramDeriveElfVersions', 'ramElfAppletCandidate',
'spCntrLow', 'ramCntrLowHtml', 'ramCntrLowPresetIdx', 'ramShowCntrLow',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute', 'decodePrivileges', 'ramActionBtn', 'ramCapToolkitMode', 'ramOpProgressText',
'jcAidNorm', 'jcAidName', 'jcAidSuffix', 'jcAidHtml'];
@@ -618,6 +618,31 @@ test('ramDeriveElfVersions reads a package version from its applet instance', ()
assert.strictEqual(elfs[2].version, undefined);
});
+test('ramElfAppletCandidate maps a package to its applet instance', () => {
+ // live 2026-09-30: the applet instance AIDs are the package's module AIDs
+ // (they differ from the package AID in the last byte)
+ const apps = [
+ { aid: 'A1130001180002FFF7100E8904000208' },
+ { aid: 'A1130001180002FFF7100E89494D4508' },
+ { aid: 'F0414C4641600101' },
+ { aid: 'A1130001180001FFFFFFFF89A1003908', version: '0903' },
+ ];
+ assert.strictEqual(ramElfAppletCandidate({ aid: 'A1130001180002FFF7100E8904000200',
+ moduleAids: ['A1130001180002FFF7100E8904000208', 'A1130001180002FFF7100E89494D4508'] }, apps).aid,
+ 'A1130001180002FFF7100E8904000208');
+ assert.strictEqual(ramElfAppletCandidate({ aid: 'F0414C46416001',
+ moduleAids: ['F0414C4641600101'] }, apps).aid, 'F0414C4641600101');
+ // an applet that already carries a version is skipped, and a package whose
+ // module is that applet has no other candidate
+ assert.strictEqual(ramElfAppletCandidate({ aid: 'A1130001180001FFFFFFFF89A1003900',
+ moduleAids: ['A1130001180001FFFFFFFF89A1003908'] }, apps), null);
+ // a package with no applet at all
+ assert.strictEqual(ramElfAppletCandidate({ aid: 'A00000006203010101', moduleAids: [] }, apps), null);
+ assert.strictEqual(ramElfAppletCandidate(null, apps), null);
+ // the shared-prefix rule is the fallback
+ assert.strictEqual(ramElfAppletCandidate({ aid: 'ABCD' }, [{ aid: 'ABCDEF' }]).aid, 'ABCDEF');
+});
+
test('the explorer annotates a derived package version', () => {
globalThis.t = s => s;
const out = ramRenderExploreHtml(null, [], [],
@@ -762,6 +787,10 @@ test('the Explore retries the ELF tag lists with P1=20 and per applet AID', () =
assert.ok(src.includes('elfPerAidVersions'), 'the per-AID version queries must exist');
assert.ok(src.includes("ramExpandedQueryApdu('40', 'tags', false, app.aid)"),
'the per-AID query must use the AID filter');
+ assert.ok(src.includes('ramElfAppletCandidate(e, apps)'),
+ 'the module->applet mapping must be used');
+ assert.ok(src.includes('ramChainGetResponse(apdu)'),
+ 'the single-AID 61xx answer must be fetched with a chained GET RESPONSE');
});
test('every SCP80/RAM flow checks the low counter and stops', () => {
diff --git a/pyproject.toml b/pyproject.toml
index fa80550..5aed374 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "pysim-simple-server"
-version = "3.7.0"
+version = "3.7.1"
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 128d5f5..854fc8d 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.7.0'
+VERSION = '3.7.1'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE