Verifying a script from the log was impossible: the runner wrote no card
lines, SCP80's plaintext C-APDU / secured packet / PoR landed only in the
report, and a menu selection logged nothing (the interactive path showed just
"STATUS poll (menu-select 9000)" / "STATUS -> 9000").
Every step now writes a `TEST-RUN step N:` line family to stderr, request
before response:
- generic actions: the APDU/raw event data sent and the SW/RESP received
(`APDU TX=...`, `ENVELOPE(Event Download) ... data=...`, `READ/UPDATE ... -> SW=...`);
- SCP80: `C-APDU=` (or `SP=`), `SECURED=<hex> (N B) TAR=... SPI1=... SPI2=... CNTR=...`,
the response SW, and the PoR - `PoR[inline] status=... TAR=... CNTR=... raw=...`
plus `R-APDU SW=... data=...` (with the bad-format note);
- expectations: `FETCH=<apdu> -> SW=... <raw>`, `CMD 0x.. <NAME> qual=..`,
`TR=<apdu> -> SW=...`, and `PoR[sms-submit] ...` + `R-APDU` for the SEND
SHORT MESSAGE transport;
- status: `STATUS 1/N -> <sw>` per attempt;
- menu selection: `MENU-SELECT ENVELOPE=<apdu> item=<id> ('<text>') -> SW=...`
(both the runner action and /api/menu-select; the old STATUS lines stay as
context).
The shared secured-packet sender logs each segment's answer
(`OTA SEND: ENVELOPE i/N -> <SW>`), the scp80 step's report `sent` now carries
the C-APDU/SP hex, and `--apdu-trace` remains the raw transport view.
help EN/RU and docs/test-scripts.md gained the "reading the run log" note.
Tests: 816 frontend + 718 Python; sw cache simple-v385.
The four findings from the review of the 3.15-3.18 batch:
- The event step's `_eventFieldsError` flag is cleared at the start of each
collect: fixing malformed JSON used to leave the save refused until the
editor was closed and reopened. Regression test covers invalid -> fixed.
- The script store keeps an entry that no longer validates on load (a
tightened engine rule or a hand edit): it is served, fails at run start with
the validator's message and can be fixed in the editor, instead of being
dropped from memory and erased by the next save. Mutation paths still
refuse it. (The presets store already had this behaviour.)
- Writebacks are serialized per script (`_testWriteChains`): two quick saves
of an unsaved draft no longer create two server-side scripts - the window
could be minutes while a RAM operation holds the card lock. Regression
test asserts one create + one update.
- Deleting a script cancels its pending debounced save and marks it deleted,
so a queued writeback never resurrects it; a create already in flight is
undone with a delete. Regression test covers the pending-save case.
- The event vectors moved to a shared fixture
(frontend/tests/event_vectors.json) asserted by BOTH suites - a one-sided
builder change now fails the other suite instead of drifting silently.
Tests: 816 frontend + 716 Python; sw cache simple-v383.
The events without a semantic builder (MT call, IMS registration, slices, ...)
are network-sourced, but the raw `envelope` action had no `src` override - the
only way to express them was the semantic set. `envelope` now accepts
`src` (82 terminal / 83 network) exactly like the `event` action; the step
editor exposes it and the authoring guide gets the row.
Also: the guide marks `por` as SCP80-only (the normaliser refuses it
elsewhere) and the semantic-events section names the shared src override.
Tests: 813 frontend + 717 Python; sw cache simple-v382.
The other-applet batch: a test script can send the common event downloads
semantically instead of raw hex.
- events.py: server-side mirrors of the PWA's event forms for Location status
(0x03), Access technology change (0x0B), Network rejection (0x12 - the
LA/RA/TA object selected by the registration group, the extended EMM cause
and the extended information object) and Data connection status change
(0x1D - status/type/TI plus the optional cause, host-clock 8.39 date-time,
location information, access technology, APN and PDP/PDN/PDU type). Fields
are validated before the run (a bad value is a script error, not a step
failure); `src` overrides the device-identities source (default terminal,
matching the Phone forms).
- The engine gains the `event` action kind; the runner builds and sends it.
The PWA's action-kind list stays in sync (drift-guarded - the guard itself
caught its own regex bug on the first run).
- Byte parity: the vectors are generated from the PWA's own builders and
pinned in tests/test_events.py; the frontend suites assert the same values.
PLMN and SCTS helpers are mirrored too.
- The Phone tab's event form shows the built "Event data (hex)" (updated as
the fields change), so every event without a builder can be used through
the raw `envelope` action.
- The test-step editor offers the event select + a JSON fields textarea;
malformed JSON refuses the save with a clear message.
- docs/test-scripts.md (the semantic-event table, an event example, the
format stamp), docs/api.md and help EN/RU updated.
Tests: 812 frontend + 717 Python; sw cache simple-v381.
The applet-testing batch agreed after v3.15/3.16:
- menu-select by text: the step names the item by `text` (+ `mode`
exact/contains, `case_sensitive`) instead of the install-dependent id
(both or neither is refused); the runner resolves it against the card's
cached menu and raises a step error naming the available items when nothing
(or several items) matches. The cache is refreshed on every SET UP MENU
fetch - the proactive chain and the expect step - so an applet install (new
items/ids after its REFRESH) no longer leaves a stale menu behind. The
step editor offers the id or the text + mode and the client-side pre-check
bounds them.
- TERMINAL RESPONSE object order: both builders emitted the Item identifier
before the Result (and the scripted builder its Text string before it too);
TS 102 223 6.8.0 mandates Command details, Device identities, Result,
Duration, Text string, Item identifier. `testscript.build_tr` and
`_menu_send_response` now follow it; `respond.raw` is documented as
appended after the standard objects, and the exact-byte tests pin the order.
- Text codings: the scripted response codes the GSM default alphabet via the
GSM 03.38 codec ('00' packed, '04' 8-bit) instead of latin-1, keeps '08'
UCS2 and sends the spec's null text string for an empty answer; the packing
helper is shared with the interactive path (`testscript.pack_gsm7`). A
UCS2 GET INPUT answer is documented and tested.
- docs/test-scripts.md gained the handoff brief for the applet agent: the
value-collection table, fill-in worksheets for the two applet scenarios, a
menu-by-text example, the validation checklist and a format stamp; help
EN/RU and docs/api.md updated.
Tests: 809 frontend + 705 Python; sw cache simple-v380.
The scripts lived in the browser's localStorage although only the local
server can run them. The new test_scripts.py store
(~/.pysim-simple-server/test_scripts.json, --test-scripts PATH) sits next to
the card presets: stable uuids and timestamps, atomic writes, and every
mutation is validated with the runner's own engine (the submitted shape is
kept - the normaliser's output is not re-normalisable: it fills defaults
like an 'any' PoR check that the check normaliser then rejects), so a stored
script cannot fail at run start for a validation reason. Import accepts the
old localStorage export as-is (export/import remains the migration path; the
PWA import also takes an exported array).
- /api/test/scripts (GET card-free: path/count + scripts), POST create,
/update, /delete and /import serve the store; the endpoints answer 503
without a store and 500 on a write failure, like the preset endpoints.
- /api/test/run takes a stored script_id (resolved when no inline script is
sent); the PWA sends the edited copy inline plus the id for the report,
and the run snapshot carries script_id.
- The PWA's Test script pill loads from the store, writes through on edits
(debounced name, immediate step/new/copy/delete), reports load/save/delete
failures inline, and exports the portable {name, steps} shape.
Tests: 808 frontend + 701 Python; sw cache simple-v379.
Three gaps blocked SCP80 applet testing from the PWA:
- testRunPreset() still marshalled the pre-v3.8 flat preset fields
(kic/kid/keys/counter/tar/spi1/spi2, all empty on a stored preset), so
every SCP80 run failed with "SCP80 preset is incomplete: no keyset is
defined". The PWA sends the matched preset's id now and the server
resolves it from the store (_test_run_preset_from_body; the inline
`preset` body stays for API callers), so a run always uses the stored
keysets, TARs and counters. Regression tests on both sides.
- testscript._normalise_params dropped the scp80 step's `kvn` (the editor
exposed it and the runner and validation consumed it, so scripts
silently used the preset's first keyset). kvn is validated (1-15) and
passed through; the client-side problem check bounds it too.
- The PoR delivered as a SEND SHORT MESSAGE (PoR-in-submit, SPI2 bit 0x20)
is decoded for the expect step that fetches it: the new `por` content
check asserts the response status / R-APDU SW / response data, decoded
with the preceding scp80 step's context (concatenated SMS accumulate
across expectations). The inline transport stays on the action
(check.por / check.data).
- The new `files` content check asserts the File List of a REFRESH
(TS 102 223 8.18, tag 12/92) - e.g. qualifier 00 = NAA initialization
with full file change notification - as an order-insensitive set of FID
paths.
The step editor exposes both checks; help EN/RU, docs/api.md and the new
authoring reference docs/test-scripts.md document the format. Tests: 803
frontend + 680 Python; sw cache simple-v378.
The ladder only advances on cntr_low; any other verdict stops the search.
A stop on a non-por_ok verdict after at least one cntr_low keeps the value
anyway - the earlier rejection proves it is above the card's counter - but
the response reports success: false with counter_saved, the verdict and the
saved counter ("something is likely wrong with the security settings");
the PWA shows it amber. Without a prior cntr_low nothing is saved, and a
missing PoR stays an error. actual_response_sms_submit/ussd count as
accepted.
Docs: docs/api.md, repo AGENTS. sw cache simple-v377.
Tests: 672 Python + 798 frontend green.
_counter_probe_params validates SPI2, forces b1 (PoR required; a caller's
00 becomes 01) and defaults to the RAM listing transport 21; the no-PoR
error names the effective SPI2, and ramSyncCounter posts
ramListingSpi2('40') for the RAM view (the failed operation's transport).
Live 2026-10-02 (NP BAP): the probe had sent the plain SCP80 form's 00
(no PoR) - the card answered the ENVELOPE 9000 and sent nothing, so the
probe stopped after one packet with "card verdict: no PoR".
Docs: docs/api.md, repo AGENTS. sw cache simple-v376.
Tests: 670 Python + 798 frontend green.
An audit of every event form against the pinned specs (31.111 7.5.x/9.3,
102 223 8.87, TS 101 220's master tag table) found three missing objects,
three malformed/wrong ones, and settled the slice-data scope.
- 0x00 MT call: the IMS URI object is cut at 100 bytes and now carries the
URI truncated object (8.135, F3 00) when the cut happened.
- 0x1F Slices: the form gains the Partial NSSAI object (8.154, F9, rel 18);
a single envelope carrying it is marked as the Last Envelope (8.79,
F0 00) automatically (7.5.27.2).
- 0x06 Card reader status and 0x07 Language: the builders now emit their
object tags (A0 01 ..., AD ...) - they emitted bare values before.
- 0x18 Incoming IMS data: the IARI tag is F6 (76/F6), not F7 (which is the
IMPU list tag - the two-column 9.3 table had been misread).
- Single-envelope mandate: the event data is capped at 244 bytes (inner 252;
the D6 long-form length and the APDU Lc stay in one byte). The server
refuses more with a 400 and the PWA shows the size error before sending.
Chained (multi-envelope) delivery, the More Data object and envelope
sequences are not implemented - documented in the help and the repo AGENTS.
- Tests: event form vectors (URI truncation, the restored tags, partial /
Last Envelope, the size helper) and the server guard; the release
reference and its PWA/code mirrors gain the three objects and the
corrected IARI key.
- sw cache simple-v375.
The release selector lives in the preset rather than a global chip: presets
already travel with the card (ICCID auto-selection, export/import), and the
operations that will consume the release are preset-aware.
- presets.py: the optional `release` field (4-18; empty = unspecified) joins
PRESET_FIELDS; a value outside 4-18 is refused on save/import; the store
schema version is 5.
- catrelease.py: DEFAULT_RELEASE = 12 and preset_release() - the effective
release is the preset's own value, else Rel-12 (the safe level for cards
whose release is not known; an unset release never filters anything away).
The PWA mirrors both (CAT_RELEASE_DEFAULT + presetRelease()), drift-guarded.
- Cards tab: the editor's header row gains a compact CAT release select
(unspecified / pre-Rel-6 stored as 5 / Rel-6 ... Rel-18); the empty option
names the Rel-12 default and the tooltip explains the guard; the field is
collected, cleared and restored with the other form fields.
- Nothing filters yet: the Simulator's event parameters (stage 3) will use
the effective release.
- docs/api.md + help EN/RU updated; sw cache simple-v368.
The Cards tab now shows the preset list by default; "Add preset" / "Edit"
open a separate editor view (Save stores and returns to the list, Cancel
discards; ramGoToPreset opens it too). The editor keeps the name/ICCID
header (with From card) and groups its fields into bordered blocks:
- PIN/ADM: the new optional card codes PIN1/PUK1/PIN2/PUK2 (plain text,
4-8 decimal digits) plus ADM, moved out of the top row; nothing consumes
the PIN/PUK codes yet.
- TAR: one uniform table (TAR, MSL, description); the first three rows are
the fixed role entries with the immutable English descriptions "ISD
compact format" / "UICC shared FS RFM compact format" / "ADF FS RFM
compact format"; further rows are deletable with an optional description.
- SCP80 (keyset editor) and SCP81 (PSK pair) as before.
Store: PRESET_FIELDS gains pin1/puk1/pin2/puk2 (optional; whitespace
stripped; 4-8 decimal digits validated on save and import), SCHEMA_VERSION
4, docs/help/AGENTS updated. TAR rows use unified ids (cards-tr-N-*), the
editor switches via cardsShowList/cardsShowEditor.
Tests: 665 Python + 783 frontend green; the whole inline PWA script
syntax-checked. sw cache simple-v361.
The table behind the release-aware command/parameter filter: which proactive
command, event, data object and PLI qualifier appeared in which release,
seeded from the curated reference in the workspace
docs/uicc/ETSI-3GPP-Releases.md (3GPP TS 31.111 Annex W, ETSI TS 102 223
Annex V, ETSI TS 101 220 Annex Q; the version major is the release).
- pysim_simple_server/catrelease.py: CAT_RELEASE (events/commands/objects/pli),
release_label() - everything below Rel-6 collapses to "pre-Rel-6" - and
item_release(); an item absent from the table is one the histories do not
pin down and is never filtered.
- The PWA mirrors it as CAT_RELEASE + catReleaseLabel() next to the other
release table (ETSI_TOOLKIT_RELEASE); no behaviour change yet - stage 2
(the card-release selector and REL-x badges) and stage 3 (the filtering of
the event list, PLI and push builders) consume it.
- Drift guards: tests/test_catrelease.py (module <-> PWA mirror, event-list
coverage through 0x1F minus the Void 0x1A, helper vectors) and
frontend/tests/cat_release.test.js (the PWA side).
- sw cache simple-v360.
- /api/ram-install-app referenced undefined spi1/tar (the resolution block
was only added to /api/ram-install and /api/cap-compat), so every request
answered 500. The same preset/MSL resolution is now in place, with
regression tests for both modes (install / make selectable) and for the
no-MSL refusal before any card I/O.
- esc() now escapes " and ' too: the TAR editor renders free-text
descriptions (and other values) into attribute contexts, so a crafted
preset import could inject attributes. Three tests asserting the old
unescaped output updated; a dedicated esc test added.
- _spi1_for_tar names the no-TAR case in its hint (a pre-built packet
without tar/spi1 used to report "TAR ?").
- preset free TAR rows no longer store an empty "role" key (the documented
shape); a non-list `tars` in an update is refused (400) instead of being
silently ignored.
Live: the no-MSL request answers 400 with the hint; a make_selectable with
a bogus AID runs the format probe and the INSTALL (derived SPI1 16 from the
preset MSL), the card answers remote SW 6A88 and the counter advances - no
500. Suites: 658 Python + 775 frontend.
No version bump: completes the unreleased v3.10.0 (6360dc5 was never
pushed). sw cache simple-v359.
Presets drop the card-wide SPI1/SPI2 and the fixed tar/uiccTar/usimTar
fields: a preset now carries `tars` - the three mandatory role entries
(isd/uiccRfm/usimRfm, spec defaults pre-settled) plus free TARs with an
optional description, each with its MSL (Minimum SPI1, TS 102 226
8.2.1.3.2.4). v3.9.1 presets convert on load/import (the spi1 seeds all
three MSLs); duplicate TARs and empty TAR/MSL values are refused; the
store schema version is 3.
Operations derive a packet's SPI1 from its TAR's MSL (_spi1_for_tar): the
RAM ops, the test runner (per-step overrides still win), the counter probe
and each TAR-probe row; the plain send keeps an explicit SPI1 with a
`warning` when it is below the MSL, and a TAR outside the preset without an
explicit SPI1 is refused with a hint. The TAR probe resolves per checked
TAR before sending and reports the effective spi1 per row. The secured
packet form's SPI2 default is 00 (the RAM/probe operations pin their own).
PWA: the Cards form edits the TAR table (role rows with MSL inputs plus
Add/Remove free rows with a description), the list shows a TARs (MSL)
column, the SCP80 TAR field suggests the preset's entries (datalist) and a
preset apply seeds the SPI1 from the operation TAR's MSL with an amber
warning below it; the preset-wide SPI1/SPI2 fields and the header marker's
SPI requirement are gone (completeness = a keyset + the three role entries
with their MSLs). Help EN/RU, docs/api.md and AGENTS updated; sw cache
simple-v358.
Live: TAR probe 000000 at its MSL 16 (registered, por_ok, counter
advanced); a send without spi1 derived 16 (por_ok, counter advanced); a
send with spi1 0A returned the warning and the card refused it (6200, no
counter consumed); an unknown TAR without spi1 was refused with the hint.
- The RAM step line (and the plain send's PoR line) now name a bad-format
failure ("bad format 02 (wrong length)") instead of reading "PoR ok": the
step record carries por_bad_format_name and ramStepLine renders it.
- ramSyncCounter follows the view the notice came from ('ram'/'sp' - preset
and keyset selectors alike) and refreshes the keyset labels after a sync.
- The ETSI-release line no longer hides behind the memory block (it needs
only the ELF list).
- /api/send-ota reports final_cntr only for a packet the card accepted: the
failed-envelope path used to persist the packet's own counter on a
rejection (the audit trail's small bump on cntr_low).
- _ram_detect_format's docstring corrected (both expanded codings execute on
the live ISD; the earlier bad format was a length-mismatched template).
- Tests: ramStepLine bad format, the view-aware notice, the accepted/rejected
final_cntr cases, and an end-to-end /api/counter-probe endpoint test
(400s, no write on a rejected walk, accepted+1 stored).
- sw cache simple-v356.
The explorer's card-resources block now names the card's ETSI release, using
the same mapping as the CAP analysis (capmem.ETSI_TOOLKIT_RELEASE: uicc.toolkit
version -> REL-N, basis in brackets; the live card's 1.12 -> REL-15/16/17).
The table is mirrored in the PWA (drift-guarded by tests/test_cap_memory.py,
like JC_FRAMEWORK_SDK); the version comes from the expanded registry's CE tag
and degrades to the version hint when the listing has none; SIM-only cards (no
uicc.toolkit) get no label, the same rule as SIM-only CAPs.
- cardEtsiRelease(elfs) (pure) + UICC_TOOLKIT_AID / ETSI_TOOLKIT_RELEASE in the
PWA; rendered after the free-memory lines with a tooltip naming the basis.
- Tests: helper vectors (1.12/1.11/1.5, unknown version, versionless,
SIM-only), the rendering guard, and the table/AID drift guard.
- Help EN/RU; sw cache simple-v354.
Live findings that drove this: the "card counter 0000000000" the low-counter
notice showed is not the card's counter at all (the PoR's CNTR is a copy of
the command's counter, TS 102 225 5.2 table 3, and a rejection leaves it
zeroed); and an earlier "no card accepts the expanded format" observation was
a hand-made template whose declared length did not match its body - the card
validates the lengths and answers with a Bad format TLV.
- _cntr_low_fields(status) reports the verdict as a flag; card_cntr/
suggested_cntr and the UI's "(card counter ...)" text are gone - the value
is not observable. The notice offers a bounded Sync counter probe instead.
- POST /api/counter-probe: a doubling counter ladder (start+1, +2, +4, ...)
with a read-only command until the card accepts one, then accepted+1 is
persisted (monotonic, source counter-probe). Rejected attempts change
nothing on the card; the search stops at a ceiling (default FFFFFFFF, far
below the 40-bit maximum where the counter blocks) and after max_attempts
(default 40, clamped 1-64); a non-cntr_low failure stops it at once.
- _parse_response_scripting decodes the Bad format TLV (90, TS 101 220 table
7.20; error types per TS 102 226 table 5.12) and returns a dict; the PoR
carries bad_format/bad_format_name and _ram_step_result fails the step with
"bad format 02 (wrong length)" instead of falling through to the compact
decoder (which read the AB tag as 171 commands).
- _wrap_expanded_apdu(apdu, form) emits the definite (AA) or the indefinite
(AE 80 ... 00 00) scripting template; the RAM command formats are compact/
expanded/expanded-ae and _ram_detect_format probes compact, then both
expanded codings (the expanded probes use Le='00' and no chained GET
RESPONSE, TS 102 226 5.2.1.1). The RAM form gains a Format selector.
- Live-verified: the verdict carries no value; the probe walked
0x1000001/0x1000003 (low) then 0x1000007 (accepted, stored 0x1000008); the
ISD executes both expanded codings (R-APDU 08A0000000030000000F80, SW 9000)
and a length-mismatched template answers bad_format 02 - decoded now.
- Tests: the ladder, the probe walk + persistence, params validation, both
template forms, the Bad-format TLV (definite and plain tag styles), the
live ISD response through _decode_por, the step-result failure, and the PWA
guards; help EN/RU, docs/api.md, the repo AGENTS.md; sw cache simple-v352.
Live finding (Network rejection events): the card asks the terminal for the
current date and time (PROVIDE LOCAL INFORMATION, qualifier 03) and our TR
answered "performed successfully" with no data. TS 102 223 6.4.15 says the
terminal shall return the current date and time, and the 6.8.0 TR structure
requires the Date-Time and Time zone object (8.39) for that qualifier.
- _pli_data_hex(qual): the TR Config dictionary entry wins; when it is empty
the date/time qualifier is answered from the host clock via _encode_scts
(the SMS timestamp builder, TP-SCTS coding).
- _build_tr and _DefaultProactiveHandler put the command-specific data objects
after the Result TLV per the 6.8.0 order (the POLL INTERVAL Duration echo
too; real terminal responses pin the order: 8103010300 02028281 030100
0402011E).
- _decode_tr decodes qualifier 03 with the same 8.39 coding (swapped
semi-octet BCD digits, time-zone sign bit 0x08, 'FF' = unknown); it read
plain decimal bytes and a 0x80 sign before.
- The PWA PLI dictionary codec for the date/time entry writes the same coding
(it wrote plain hex bytes and a 0x80 sign), and the TR Config panel notes
the empty-entry default. sw cache simple-v351.
Tests: builder vectors (east/west/unknown zone) + dictionary override + the
empty non-date/time qualifier + the object order (PLI and POLL INTERVAL) + the
handler path + the TR decode + a frontend codec round-trip test.
MT call, Call connected, Call disconnected, Browser termination, (I-)WLAN
access status and CSG cell selection no longer fall back to the generic
"send a bare notification" prompt:
- MT call (TS 31.111 7.5.1A.2): transaction identifier, calling party number
+ TON -> Address (EFADN BCD coding, encBcdDigits/eventAddressTlv),
subaddress, IMS URI, media type; source network.
- Call connected (7.5.2A.2) / Call disconnected (7.5.3A.2): transaction
identifier, near/far-end source select, media type, and a cause - absent,
the zero-length radio-link-timeout form or custom bytes (the hex field shows
only for "custom").
- Browser termination (TS 102 223 7.5.9.2): user / error cause.
- (I-)WLAN access status (TS 31.111 7.5.1.2): no coverage / coverage /
connection on-going.
- CSG cell selection (TS 31.111 7.5.3.2): status + selection mechanism,
optional access technology, CSG ID, HNB name and PLMN ID; source network.
The device-identities source (TS 102 223 8.7: 82 terminal / 83 network) is now
selectable: EVENT_FORMS carries a fixed `src` or a `srcField`, and
_send_event_download()/`/api/event-send` take an `event_src` override (an
invalid value falls back to the terminal).
Tests: the BCD/Address builders, each event's build vectors, the "no not_yet
left" guard and the server source override. Help EN/RU, both AGENTS files;
sw cache simple-v348.
EVENT_FORMS[0x1D] was missing, so its Send button posted event_data: null -
the ENVELOPE carried only the event list and device identities, none of the
event's data objects. The form now builds the TS 31.111 7.5.25.2 object set
(tags per TS 101 220 / TS 31.111 9.3): data connection status (9D), connection
type (AA), optional (E/5G)SM cause (AE), transaction identifier (1C), optional
location information (13, CR flag 0 as the spec requires for this command),
optional access technology (BF), location status (9B), optional APN (C7) and
PDP/PDN/PDU type (0B).
Adjacent spec fixes found while verifying:
- the Access Technology Change options (0x0B) did not match the pinned
TS 102 223 V18.3.0 8.61 coding (GSM Compact / GSM w-EGPRS / UTRAN w-HSDPA
...); they now use the shared ACCESSTECH list, whose two labels were also
corrected (TIA/EIA-553-A, TIA/EIA-136-270);
- _send_event_download() wrote the D6 length as a single byte - a long APN
would overflow it; 128..255 bytes now use the BER long form (81 <len>).
Tests: the 0x1D build (defaults, full form, fallbacks), the 8.61 option list,
the long-form D6. Help EN/RU and both AGENTS files; sw cache simple-v342.
Documentation:
- help (§1/§3): the SCP80 tab is switched by three pills - the tab list and the
section intro now name the TAR probe (RU "TAR-сканер"); §3.1/§3.2 describe the
Keyset selector next to the preset selector (and that a successful PoR
advances the counter only when SPI1 carries a counter check); §4 describes
the keyset editor rows, the derived number and the per-keyset counters; the
probe section keeps its counter rule.
- README/README_RUS: the SCP80 tab says three pills and gains a "TAR probe" /
"TAR-сканер" section (standard list, verdicts, counter persistence); the
Cards section is rewritten for the server-side store - keysets, server-owned
per-keyset counters with the audit trail, JSON export/import as the migration
path, the keyset editor, the equipped-card mismatch banner - and the field
table, "Add a card" and "Edit / remove" follow.
Terminology:
- "key version" is no longer used for SCP80 keysets in the UI, the server error
strings and the docs: the selector is "Keyset", its options "Keyset 1 - ...",
the messages say "keyset N ...". The TS 102 225 A.1/A.2 quotes and the GP
PUT KEY P1 field keep the spec's own wording.
- RU keeps the word "keyset" untranslated ('Добавить keyset', 'Номер keyset',
"не является keyset пресета", ...) and uses "пресет" instead of
"предустановка" throughout the dictionary, the help and README_RUS.
sw cache simple-v340. Tests: 742 Node / 619 Python.
The probe is not a RAM operation, so it moves out of the RAM view into a third
SCP80 pill (Secured packet | RAM | TAR probe, RU "TAR-сканер"), with its own
preset and key-version selectors, its own equipped-card mismatch banner and its
own result line; the help gains a "3.3 TAR probe" section and the anchor map
follows the pill.
The TAR field is a checklist now: pre-populated with the standard allocations
of TS 101 220 V18.3.0 Annex D, Table D.1 (each row carrying the document it
comes from, all checked by default, Select all / Select none), plus custom
TARs added through a small form (three hex bytes + an optional description,
removed with a x). Custom entries live in localStorage['simple_tar_probe'] on
a best-effort basis - losing them is harmless, the standard list is always
there. Only the checked TARs are sent; nothing checked is refused with a
message. A Python test guards the PWA's list against the server's
TAR_PROBE_TARS (the endpoint keeps its own default for API callers).
RAM leftovers removed (the tar-probe operation entry, the #ram-tar-rows block,
the ramExecute dispatch, ramTarProbe); getRamSpParams() takes the view's
selector id; tarRender/tarApplyCard/tarKeysetChanged/tarKeysetGuard mirror the
RAM view; spKeysetSync and spPresetWarningRender cover the new selectors.
sw cache simple-v338. Tests: 740 Node (the checklist rows, the custom
round-trip, the checked-list read, the renderer, the pill wiring) and 619
Python (the list drift guard).
Three things learned from probing a live card, plus the capability they enabled:
1. Counter tracking follows SPI1.b5b4 (TS 102 225 5.1.1): with 00 the counter
is "present, ignored, never updated", so a packet without a counter check
must leave every counter untouched. _counter_tracked() now guards
/api/send-ota (no final_cntr, no persistence, a log line), the RAM step
sender and the test runner; _preset_counter_persist() requires a non-zero
key version (the "single keyset" fallback could advance a keyset spuriously
for a keyless send). The PWA advances the form counter only when the server
reports final_cntr and says "counter not tracked" otherwise.
2. The spec's no-security form builds again: _ota_keyset() refused the valid
KIc/KID '00' (TS 102 225 A.2: '00' is legal with SPI1.b3=0 for KIc and
SPI1.b2b1=00 for KID) because pySim wants an algorithm. An unknown nibble
is now refused only when the algorithm is actually used (ciphering / a
ciphered PoR for KIc, RC/CC/DS / a MAC'd PoR for KID) and 'implicit' codes
the nibble as 0 otherwise - POST /api/send-ota {apdu, spi1:"00", kic:"00",
kid:"00"} now builds 0D00010000B0000000000000010000A40000, the exact packet
the card accepted (a fake 15/15 was refused with 6200). A no-security PoR
is decodable again too, and a requested-but-missing PoR sets por_missing
on the plain path as well.
3. TAR probe (POST /api/tar-probe, RAM op "Probe TARs"): one secured packet per
TAR - the standard allocations of TS 101 220 V18.3.0 Annex D, Table D.1
(TAR_PROBE_TARS), or a custom list - carrying a harmless C-APDU (SELECT MF)
with the preset's keyset and SPI 16/01. Each TAR is classified
(registered / no_answer / no_por / refused) from the ENVELOPE verdict and
the application's PoR, counters advance and persist normally, and the run is
refused when SPI1 has no counter check. Live on a card: 000000, B00000 and
B00001 registered (compact-format applications, answering 6D00 / 6B00),
the expanded-format TARs, USAT, multiplexing and CASD refused with 6200 -
with the keyless form the same card accepted every TAR and answered no PoR.
Docs (api.md, help EN/RU, AGENTS), sw cache simple-v337. Tests: 618 Python
(no-security build, the algorithm-only-when-used rule, the tracking rule, the
persist guard, the probe classification) and 736 Node (the probe renderer and
wiring, spCounterTracked).
A card usually holds several OTA key sets, and the SCP80 counter is tracked
per key version on the card, so a preset now carries one keyset per
GlobalPlatform key version - the b8..b5 nibble of KIc/KID (TS 102 225
5.1.2/A.2), the version is implicit in the bytes. The spec is explicit:
"a dedicated counter shall be associated to each key version" (Annex A.1) and
"the key version number indicated in the KIc and KID (bits b8..b5) fields
shall be identical when different from 0" - a mismatch is rejected by the card
with "Unidentified security error" (A.2).
Server (presets.py, schema 2):
- a preset carries `keysets: [{kic, kid, kicKey, kidKey, cntr}]`; validation
refuses a KIc/KID version mismatch (A.2), a version outside 01-0F ('00'
means "no security" and is a packet-level choice, not a keyset), duplicate
versions and missing keys/counters; a v3.8.0 flat preset converts into one
keyset on load and on import;
- `set_counter(..., kvn)` writes the counter of that keyset (monotonic per
keyset), the audit line carries the kvn, and `find_keyset()` serves the
lookups;
- the operations derive the key version from the request's KIc byte and
persist into that keyset: /api/send-ota, the RAM install/install-app/compat
steps (_ram_send_gp_apdu) and the test runner's SCP80 steps (which may name
a `kvn` per step and keep one working counter per keyset);
- a non-zero key version the named preset does not define, or a KIc/KID
mismatch, is refused before anything is built (_preset_keyset_check /
_keyset_guard: 400, or a step error in the test runner). Without a
preset_id only the mismatch rule applies.
PWA:
- the Cards form's KIc/KID/counter/keys inputs become a keyset editor (one row
per version: KIc, KID, both keys, that version's counter, Add/Remove; the
version is shown derived from the bytes and a mismatch is flagged);
- the SCP80 and RAM views get a Key-version selector beside the preset
selector, driving the KIc/KID bytes and loading that keyset's keys and
counter; the packet builder refuses a version the preset does not define
(spKeysetCheck/spKeysetGuard, pure and unit-tested);
- the counter syncs are per keyset (spCntrLocalSync/ramSaveCntr), the SCP80
form's counter edit posts the keysets, and the test-run write-back syncs
every counter the server reports (scp80_counters).
Docs/help/AGENTS updated; sw cache simple-v335. Tests: 610 Python (store
keysets, endpoint refusals, the counter landing in the right keyset) and 726
Node (keysets.test.js plus the updated harnesses).
- /api/send-ota no longer 500s on a counter-less body: the plain `sp` path
reports final_cntr only when the counter is usable (_counter_valid, 1-10 hex
digits) - before, the new plain-path report ran int('') and the request died
in the generic handler. The failure path is guarded the same way.
- _decode_por wraps the keyset/SPI construction in the same "never raise" rule
as the decode: a malformed counter or missing keys return None instead of
escaping the handler.
- presets.add() never accepts a taken id (a fresh uuid is assigned, like the
import path), the counter is validated as 1-10 hex digits instead of being
silently masked/kept, and set_counter() ignores a malformed value with a log
line instead of writing it.
- the preset endpoints answer a JSON 500 when the store write fails (read-only
home, disk full) instead of dropping the connection.
- tests: counter-less send-ota, store-write 500, duplicate id, counter
validation (add/update/set_counter), and the temp-file check no longer
matches the audit file; docs/api.md documents final_cntr for the plain path
and preset_id.
The presets lived in the browser's localStorage and the PWA wrote the SCP80
counter back after every operation, so an increment could be lost when the
response never reached the tab, when two browser contexts (or the demo origin
plus a local one) held separate copies, or when the tab was closed mid-run.
The counter is card state and the server performs every card operation, so the
store now lives next to the server:
- pysim_simple_server/presets.py: one JSON file
(~/.pysim-simple-server/card_presets.json, --card-presets overrides; Path.home()
resolves the same way on Windows), atomic writes (temp + fsync + os.replace),
an RLock, stable uuid ids, the ICCID normalisation the PWA uses (digits /
spaced / raw EF hex), a duplicate-ICCID refusal and a monotonic set_counter;
every counter change is appended to card_presets.json.audit.jsonl.
- API: GET /api/presets (card-free), POST /api/presets and
/api/presets/update|delete|import; the import also takes the old localStorage
export shape - that is the migration path, there is no automatic migration.
- Operations name a preset_id and the server persists the counter they
consumed: /api/send-ota (the plain SCP80 path reports final_cntr now too),
the RAM install/install-app/compat steps (_ram_send_gp_apdu) and the test
runner's SCP80 steps (_preset_counter_persist).
- PWA: the Cards tab is a thin client of the store (load on connect, CRUD via
the API, export/import), the counter write-backs became local display syncs
(the SCP80 form's counter input stays a deliberate human edit) and the
SCP80/RAM bodies carry preset_id; a new pysimFetchOk() surfaces the server's
error bodies instead of silently using them.
Policy: card keys in logs and local files are not a risk for this workbench
tool (AGENTS.md) - the store is not encrypted and no redaction is required.
Tests: tests/test_presets.py (store: ICCID forms, counter monotonicity and
wrap, atomic writes, corrupt-file recovery, import), the preset endpoints in
tests/test_fetch_routes.py (incl. the counter persistence via /api/send-ota)
and the updated PWA tests. sw cache simple-v334.
A cntr_low PoR verdict is not recoverable by retrying - every packet whose
counter is not above the card's is rejected again - so it now stops the
operation and is stated as plain data instead of a generic PoR error.
Server:
- _cntr_low_fields(status, cntr) -> {cntr_low, card_cntr, suggested_cntr}
(fixed-width wrap like the counter field); carried by /api/send-ota (plus
an 'OTA CNTR-LOW: card counter ...' log line) and by the RAM install /
install-app / cap-compat failure responses (from the failed step);
- _send_envelope's late-PoR poll now also runs after a warning ENVELOPE
answer (62xx/63xx - the live card answers 6200 for a low counter) and
_send_secured_packet passes the captured PoR on, so the verdict is named
instead of a bare 'ENVELOPE failed' (that is why it only surfaced on the
later SPI2=0x21 query in the live log).
PWA:
- spCntrLow(res) guards every flow; the Explore routes the whole run through
a checked sendOta(), so the paginate/expanded loops stop, no further
packet is sent and no counter is written back (delete and Send to Card
check it too);
- the result states the card's counter and offers 'Go to preset'
(ramGoToPreset: opens the failed operation's preset in the Cards tab for
editing and focuses the counter field) - nothing is written
automatically; the operation modal carries the same notice;
- help (EN/RU) documents the guard.
sw cache simple-v331; tests: 557 Python / 714 Node.
The live 500 in the Explore ('OTA send error: index out of range') was a
parser bug, reproduced with the live FETCH shape: _parse_proactive_header
walked the command TLVs reading the length as a single byte, so the card's
SEND SHORT MESSAGE with its 150-byte SMS TPDU (8B 81 96) was mis-walked
into the TPDU and eventually indexed past the end (val[2] on a short
slice) - the FETCH was left unanswered and the request answered 500.
- _ber_tlv_list/_proactive_body helpers: BER lengths (_ber_len_at) plus a
bounds check that stops at a truncated TLV instead of walking into the
value; _parse_proactive_header, _parse_display_text, _parse_select_item,
_parse_setup_menu_items, _tlv_map and _decode_poll_negotiation all use
them now (the same latent bug for any long-form TLV);
- _run_proactive_chain guards every step (header parse, proactive log,
on_fetch, BIP/timer handlers): an exception is logged with the offending
FETCH bytes and the TERMINAL RESPONSE is still sent - a decoder bug can
never leave a FETCH unanswered again;
- the expanded-registry report distinguishes a request error from a remote
SW: 'ELFs: request error (index out of range) - compact listing used'.
Regression tests: the live long-form FETCH shape parses (header + TLV map +
TPDU), truncated TLVs stop the walk, and a raising parser still gets a TR.
sw cache simple-v330; tests: 553 Python / 711 Node.
The compatibility test now runs in the same operation modal as the installs
(live progress from the server's ram_progress, Close disabled until done) and
its result gains a per-AID detail block instead of the comma list:
- the probe summary (probe: 2 imports -> 0.0 . +1 appended);
- the import list as tested (ramCompatImportRows): the CAP's imports with the
version the card was asked for and the CAP's own version when the probe
lowered it, appended synthetic entries and unmatched lines - each with the
resolved library name (jcAidName), so a raw AID list becomes readable rows;
- on a rejection, the failure analysis (ramCompatFailureHtml): which CAP
section the failing block covers (load-file byte range and component names,
'completes Import'), what the card checks at that point (JC VM spec 4.5.2)
or that the import list already passed, the remote status word with its
name, and what the probe state implies (every import at 0.0 -> a missing
package or another load-file check is the cause; otherwise lower the
versions and bisect).
ramCompatVerdict names the remote SW; the inline result keeps the verdict +
short probe note and the modal carries the escaped step lines + the detail
HTML (ramOpModalFinish gained an asHtml mode); the server reports
probe_unmatched as [{'aid', 'version'}] (_probe_unmatched), documented in
docs/api.md. The same failure block for the real install is the next step.
sw cache simple-v325; tests: 550 Python / 703 Node.
The compact (legacy) GET STATUS entry is <aid_len><AID><life cycle>
<privileges> + (P1=10) <module count> (<len><module AID>)*, cross-checked
against GlobalPlatformPro's populate_legacy (the 4th byte is privileges;
0x00 is required for load files). The parser read it as a 'version' one
byte late, so ELF rows showed the module count / the next entry's length as
Version: 00/10 - the compact listing has no version at all; it now comes
from the expanded CE tag (ramFmtVersion: 2 bytes -> major.minor, GP Table
11-37 Note 1) and merges (the bogus compact value no longer blocked it).
New data exposed:
- EA SCP Registry Data (TS 102 226 8.2.1.6) -> 80 Menu Parameters -> the
applet's STK menu entries: STK menu entry: 0x80 (position 1, Enabled)
- unknown registry objects are preserved (rawTlvs) and shown as
'Other registry data: 8F=752B . 90=012D ...' instead of being dropped
- 84 parses the GP coding (one Executable Module AID per TLV, repeated; the
concatenated 4F<len><aid> form stays as fallback) and the ELF expanded
queries add 84 to the tag list (with a tag list the card returns only the
listed tags - the fallback path would otherwise lose the module lists and
their Install buttons)
- life cycle names decode per object kind (GP Tables 11-3..11-6: ISD/card,
app, SD - detected via the Security Domain privilege - and ELF), with
multi-byte 9F70 (02 0700 on live cards) decoding the first byte and
surfacing non-zero extras; the R-APDU parser's LIFECYCLE_MAP labels were
aligned with the same tables
/api/ram-install measures the NV footprint: a silent GET DATA FF21 read
before and after the chain (also on failure) returns nv_before/nv_after/
nv_delta; the reads never fail the operation (no FF21 -> no fields) and the
PWA shows 'NV used: ~N B (free A -> B)' in the install result.
sw cache simple-v324; tests: 548 Python / 698 Node.
Review of v3.6.48 found six issues; the two that matter in use:
- ramProbeParse('') errored, so the basic 'is this CAP compatible with
this card at all?' run (no overrides) was refused by an alert. An empty
list is valid now: the test runs with the CAP's own versions.
- ramCompatVerdict said 'Import list rejected' for every failure. It now
distinguishes: a request error (no steps), a failed format probe (the
card not answering), an INSTALL [for load] refusal, and only then the
import list - each with its own headline.
Plus:
- the result line shows the applied probe (ramCompatProbeNote: 'probe:
7 imports -> 0.0 · +1 appended', unmatched AIDs included);
- #ram-compat-block validates 1..240 like the install's block size;
- the server reports probe_unmatched (override AIDs matching no entry,
previously silent when another override matched) and logs them;
- tests: the endpoint's probe-400 path (a mini CAP + a dummy scc),
_cap_import_aids, the verdict cases and the probe note.
sw cache simple-v322; tests: 540 Python / 687 Node.
The import probe no longer lives in 'Install Package (.cap file)': a
diagnostic that can leave a LOADED-only package does not belong in the
operation meant to produce a working applet. /api/ram-install no longer
takes probe_imports; the install form's probe fieldset is gone.
New RAM operation CAP compatibility test (POST /api/cap-compat): the
server runs the format check, INSTALL [for load] and the LOAD blocks only
up to the block that completes the Import component (_cap_import_end +
_cap_compat_blocks, computed from the load file and the C4 TLV framing),
then stops - no last-block flag, no INSTALL [for install], so nothing is
committed and nothing needs deleting. The verdict (imports_ok,
boundary_block/total_blocks) states it covers the LOAD/import gate only;
the link gate stays the real install's business. Verified on the real
RemMobileID CAP: the boundary is LOAD 1/64, 2/128, 3/240 for 240/120/64 B
blocks - exactly the blocks where the live cards rejected the load.
_probe_import_versions(load_file, overrides, additions) now supports
append-only synthetic imports: +AID=version appends a new entry, the
Import component's size field and the Directory's Import size are patched,
and existing entries keep their positions (constant-pool import indices
stay valid); an AID that is already imported becomes a version override.
This is the card-capability query ('does this card export package X at
>= Y?') without building a probe applet.
PWA: new op section (CAP file + analysis + the version list + block size),
ramCompatTest with inline results and ramCompatVerdict, ramProbeParse
understands +AID=version; help EN/RU documents the operation, the two-step
bisect and the gate scope.
sw cache simple-v321; tests: 538 Python / 686 Node.
The install modal's progress bar froze around 70% and jumped to 100% at
the end: not a math bug - the UI's other polls (/api/stk-status,
/api/poll-status, /api/proactive-log, /api/menu, ...) were card-locked in
do_GET even though they only read cached state, queued behind the install
and exhausted the browser's 6-connections-per-host budget, starving
/api/status. _CARD_FREE_GET lists every card-free GET (status, version,
stk-status, poll-status, proactive-log, menu, events, terminal-profile,
pli-*, commands, net-state, mcc-mnc, scp81/bip status|script|log);
/api/cardinfo and /api/esim/* stay locked (they touch the card).
tests/test_fetch_routes.py holds _CARD_LOCK and asserts the cached
endpoints still answer - verified to time out on the pre-fix list.
The install modal is wider (max-w-3xl) and successful step lines are
compact: '✅ Step 3: LOAD (1/240) - 6101 · 274 B / 3 SMS' (no verbose SW
name, B instead of bytes, the SMS count only when >1); failed steps keep
the full detail and the hint.
sw cache simple-v320; tests: 533 Python / 684 Node.
The CAP details' Platform row named only the Java Card level; it now also
shows the ETSI/3GPP release the UICC API imports come from, with its basis
in brackets, e.g. 'Java Card >= 2.2.2 - ETSI REL-7 (uicc.toolkit 1.4) -
CAP format 2.1'.
capmem.ETSI_TOOLKIT_RELEASE maps the uicc.toolkit export version to the
release, sourced from the workspace corpus: sim-tools/ETSI-REL.md for the
prepared trees (api21_rel7_export_files carries uicc.toolkit 1.4 = the
REL-7 jar, api21_export_files 1.11 = the REL-12 jar) and
sim-tools/etsi/102241/README.md's package-version table for the rest
(1.0 -> REL-6/7, 1.3 -> REL-8, 1.5 -> REL-9, 1.12-1.14 -> REL-15..18; the
1.11 spec range is 13.01-14.02 but the tree the builds use is REL-12).
Exposed as requires_etsi_release/requires_etsi_basis in /api/cap-info and
the CLI; SIM-only CAPs (no uicc.toolkit) get no label.
Verified: RemMobileID -> REL-7 (uicc.toolkit 1.4), AlfaDSA -> REL-12
(uicc.toolkit 1.11). sw cache simple-v317; tests: 531 Python / 677 Node.
Cards that cannot satisfy the CAP's import list reject the LOAD as soon
as the Import component is complete (live 2026-09-29: 6438/6985 in the
block containing its last byte, on several cards incl. an eUICC profile
whose ISD is not the default AID and which does not answer GET DATA 0066 -
no direct platform-version readout exists).
_probe_import_versions (pure) rewrites the Import component's two version
bytes per entry in the assembled load file: same length, component sizes,
Directory and offsets untouched; unknown AIDs and bad versions are
rejected. /api/ram-install takes probe_imports ({'all': '0.0'} or
per-AID), echoes the applied changes in the response and logs them. The
PWA's RAM install form gains a collapsed Import probe fieldset (checkbox,
AID=version lines prefilled from the CAP analysis, 'Set all 0.0') and the
result line reports the applied changes. Diagnostic only - never used for
a real install.
Also verified _cap_parse's component order against the reference TCA load
trace (Header, Directory, Import, Applet, Class, Method, StaticField,
ConstantPool, RefLocation, Descriptor - Import before Applet, not
ascending tag order), so the order is correct and the import list remains
the prime suspect.
sw cache simple-v315; tests: 530 Python / 676 Node.
pysimFetch() sends POST whenever a body is passed, but /api/test/run,
/api/test/stop and /api/test/clear were registered in _do_GET (since the
phase-1 commit 3a4a098): every Start fell through to the catch-all
404 {'error': 'Not found'} and the feature had never been exercised over
HTTP (a browser GET would not carry the JSON body either).
The three branches moved into _do_POST; safe under the RLock-based
_CARD_LOCK because _test_run_start re-acquires it before spawning the
worker.
New tests/test_fetch_routes.py:
- a PWA<->server route/method matrix (every body-carrying pysimFetch needs
a POST route, body-less calls a GET/POST route; routes are extracted from
_do_GET/_do_POST incl. the 'self.path in (...)' and startswith forms) -
verified to fail on the pre-fix routing with exactly the three endpoints;
- an HTTP smoke test on the real handler: POST /api/test/run {} -> 400,
POST stop/clear -> 200, GET /api/test/run -> 404.
sw cache simple-v314; tests: 527 Python / 675 Node.
The TERMINAL PROFILE chain parsed the SET UP MENU title with 'tag == 0x05'
only, so the live Alfa card's title (CR-set 0x85, 'Alfa Mobile') was
dropped: /api/menu returned items without a title and the PWA showed
'No menu set by the card' - card-specific only because cards pick the tag
variant (this one sends plain 0x8F items and a CR-set title in the same
command).
- _parse_setup_menu_command (pure, tested with the live FETCH bytes)
replaces the inline parse; the PWA shows a title-less menu as 'STK: Menu'
instead of 'No menu set by the card' (stkMenuLabel, pure).
- _cmd_tlv is now the single lookup helper: exact tag, then tag ^ 0x80
(bit 8 is the CR flag, TS 101 220 7.1.1); the old '| 0x80'-only helper and
the duplicate _cmd_tlv_any are gone.
- Remaining single-style checks fixed: _parse_setup_menu_items (0x8F/0x0F),
_parse_select_item title (0x85/0x05), _find_sms_tpdu (0x8B/0x0B - the
plain variant lost the SEND SHORT MESSAGE PoR TPDU), the response
scripting parsers (R-APDU 0x23/0xA3, executed count 0x80/0x00) and the
FF21 resource tags (0x81-0x83/0x01-0x03).
- tests/test_tag_variants.py flips the CR flag on every top-level tag of
real command fixtures and asserts identical parse results for all
parsers (verified to fail when a single-style lookup is reintroduced).
sw cache simple-v313; tests: 524 Python / 675 Node.
__main__ built a plain HTTPServer (single-threaded): during a RAM install
POST every other request waited in the accept queue, including the 2 s
/api/status poll that feeds the operation modal - its bar and step
headline never moved and the whole result appeared only at the end (live
2026-09-29).
_build_http_server() now returns a ThreadingHTTPServer (daemon threads).
Card access is unchanged: it is serialized by _CARD_LOCK, not by the
server - every POST is blanket-wrapped in do_POST, every /api/ GET except
/api/status and /api/test/status is wrapped in do_GET, and the background
threads (STATUS poll, _bip_data_available, test runner) take the lock;
the audit found no card-access holes. do_GET also lets non-/api (static
PWA) paths bypass the lock, so page assets stay answerable during long
operations.
Regression test tests/test_http_concurrency.py: a fast request must
complete while a slow one is in flight (fails on HTTPServer with exactly
the reported symptom: 1.01 s vs 0.20 s); test_startup_cardless patches
the new factory.
sw cache simple-v312; tests: 519 Python / 674 Node.
The menu chain paused only for DISPLAY TEXT / SELECT ITEM / SET UP MENU,
so a GET INPUT was auto-answered with a bare success TR and no Text
string - the live RemMobileID PIN prompt got an empty answer.
Server: _parse_get_inkey/_parse_get_input read the prompt, qualifier
flags (digits-only, UCS2, Yes/No, immediate, hidden, packed, help),
Response length 91 and Default Text 9D; the fetch handler pauses with
them; _menu_send_response(..., text=) validates the answer (digits set,
min/max, one char for GET INKEY) and builds the Text string TLV 8D in the
requested coding (SMS unpacked 04 / UCS2 08 / packed 00 via _pack_gsm7),
Yes/No as 01/00, empty input as the null text string, help as result 13
and the variable-timeout Duration echoed (6.8.4). /api/menu-respond
takes text; /api/stk-status carries pending_data so a card-initiated
prompt opens the panel; _decode_cmd decodes both commands.
PWA: stkInputHtml/stkInputHint/stkInputValidate render the constrained
panel (password field for hidden entry, numeric keyboard, default text,
Yes/No buttons, help, immediate-digit auto-submit); OK submits through
stkMenuOk; the poll opens the panel for card-initiated prompts.
sw cache simple-v311; tests: 517 Python / 674 Node.
_send_envelope defined its SEND SHORT MESSAGE capture handler inside the
91xx branch, but the 'STATUS poll (PoR not captured)' path used it: a
packet whose ENVELOPE answered 9000 and whose late PoR arrived on the
STATUS poll raised UnboundLocalError, returned 500 and aborted the RAM
operation mid-packet with the card's command left pending (live
2026-09-29, 120-byte LOAD blocks). The handler is now defined for every
path, and the late-PoR poll runs only after a packet's last segment
(poll_status from _send_secured_packet) - a concatenated download makes
no spurious mid-packet STATUS exchanges.
The PWA annotates every LOAD step with the CAP components its block
covers (ramStepComponents: 'completes Import' / 'inside Method' /
'Header...Import') and ramInstallFailHint names the component the failing
block completed - the live import-verification rejection lands in the
block completing Import (6438/6985 at both 240 B and 64 B blocks).
sw cache simple-v310; tests: 509 Python / 672 Node.
capmem derives the minimum platform from the CAP's Import component
(javacard.framework -> Java Card level via the SDK-kit corpus, int flag)
and /api/cap-info exposes it; the CAP details show it as a Platform row
('Java Card >= 2.2.2 - CAP format 2.1 - int support').
A failed RAM install at a LOAD / INSTALL [for load] step now appends a
hint naming the import requirement and the smaller-block fallback
(ramInstallFailHint, pure): JC VM spec 4.5.2 makes every import a
'>= version' demand on the card's own packages checked while the load
file is verified - live 2026-09-29, a CAP requiring Java Card 2.2.2 was
rejected exactly at the block containing the end of Import (6438 at
240 B blocks, 6985 at 64 B), not at a step count.
sw cache simple-v309; tests: 507 Python / 670 Node.
A CAP install is a long chain of LOAD commands whose progress and results
were only visible at the bottom of a large form - and an interrupted
install can leave a half-loaded package.
- Server: _RAM_PROGRESS (+ _ram_progress_begin/step/end) records the live
step of the running RAM operation; /api/ram-install and
/api/ram-install-app wrap their chains in it (total = steps + 1 for the
format check) and /api/status carries it as ram_progress - lock-free, so
the 2 s poll works while the request holds _CARD_LOCK.
- PWA: #ram-op-modal (the STK panel pattern) blocks everything while a RAM
operation runs: title, spinner, status line, progress bar, per-step
result lines, Close disabled until it finishes; a beforeunload guard
protects the window during the run. install-cap, install-app /
make-selectable and Explore all use the shared helpers
(ramOpModalBegin/Status/Finish); the install results stay in the form
area as before once the modal is closed. The status poll feeds the modal
via ramOpProgress (headline from the pure ramOpProgressText).
- Tests: ramOpProgressText (JS) + the progress lifecycle (Python); the
card-state and capmem suites stub the DOM-bound hooks.
- Stylesheet rebuilt; version 3.6.34; sw simple-v307.
Comparing the new TCA vendor traces (Explore_EP/ET/NC/NP/SE) with our flow
showed why only one card works:
- The PoR capture was gated on SPI2 bit 0x20. The traced cards deliver the
PoR as a proactive SEND SHORT MESSAGE; with our ISD/memory queries
(SPI2=0x01) the fetched TPDU was thrown away - the Explore died with
"no data" and the RAM install steps silently degraded to an unverified
"no_por". The capture is now unconditional (inline ENVELOPE responses and
proactive submits both work), and the STATUS-poll fallback is enabled for
every card.
- `_ram_next_cntr` still wrapped at 32 bits (`% (2 ** 32)`): a 10-digit
counter like 10000AAAC8 came back as 00000AAAC9 after a RAM/Explore step -
the server-side half of the v3.6.23 counter report. It now advances the
full 40 bits.
- RAM command format (TS 102 226 5.2.1: compact C-APDU vs the expanded
`AA` > `22` form) is detected per operation and never stored:
/api/ram-install and /api/ram-install-app always probe, /api/send-ota
probes when the caller passes `ram_format`. The probe is a read-only
GET STATUS [ISD] as the first step (compact, then wrapped) and every
command of the operation uses the detected format. `_wrap_expanded_apdu`
is pinned to the reference bytes `AA0A220880F24000024F0000`; responses
carry `ram_format` and `final_cntr` (accepted probes consume counters, so
the views use the server's next counter). Applet-directed payloads stay
byte-exact; only RAM views (and RAM/GP chain packs) opt in.
504 Python / 653 frontend green; version 3.6.24; sw simple-v297.
The event list tables carried labels like
"12 Reserved for 3GPP - Network rejection (TS 31.111 7.5.2)". The phrase is
an ETSI-to-3GPP document pointer, not a usage restriction, and it is not
what a user needs - the event name is.
- EVENT_NAMES (PWA + server mirror): the 3GPP-assigned codings carry their
plain TS 31.111 7.5 name ((I-)WLAN access status, Network rejection, CSG
cell selection, IMS registration, Incoming IMS data, Data connection
status change, CAG cell selection, Slices status change).
- 0x20-0x22: "Reserved (future usage)" (no attribution).
- 0x0B/0x14: the spec's full "(single access technology)" / "(multiple
access technologies)" wording instead of the cryptic "(single)/(multiple)".
- tests pin the exact names and that no name contains the cross-reference.
652 frontend / 500 Python green; version 3.6.22; sw simple-v295.
`_ram_send_gp_apdu` (the shared step sender of /api/ram-install and
/api/ram-install-app) now logs every step's plaintext APDU and packed
packet, like /api/send-ota's unconditional lines:
RAM C-APDU (INSTALL [for load]): 80E602000C07F0414C4641610100000000
RAM SECURED-PACKET (INSTALL [for load]): 00281516212525000000...
No flag - the step name keeps the sequence readable, and --apdu-trace stays
the card-level (ENVELOPE) trace. Tests: the logging pinned with stubbed
packet builders.
498 Python / 650 frontend green; version 3.6.19; sw simple-v292.
Iterating the final install step no longer needs a delete + re-load of the
.cap:
- `POST /api/ram-install-app` runs INSTALL [for install] (`mode: "install"`,
P1 0x0C/0x04 by make_selectable) or INSTALL [for make selectable]
(`mode: "make_selectable"`, GP 11.5.2.3.3 Table 11-44) for an already
loaded package. The APDU builders are shared with `_cap_apdu_sequence`
(`_cap_install_apdu`, `_cap_make_selectable_apdu` - one builder, no drift)
and the per-step send/PoR/counter logic is the extracted shared
`_ram_send_gp_apdu` (used by /api/ram-install too).
- PWA: the RAM op selector gains "INSTALL [for install] (loaded package)"
and "INSTALL [for make selectable]" with Load File / Module / Instance AID
inputs (instance defaults to the module AID); the result renders through
the shared `ramShowInstallResult` and `final_cntr` keeps the preset in
sync.
- Explore: an **Install** button on a package's module AIDs that have no
instance yet, and a **Make selectable** button on INSTALLED (0x03)
instances - both switch the op and pre-fill the AIDs.
- tests: the install APDU pinned to the live no-STK packet and to the
sequence tail, the make-selectable APDU, ramHasInstance, the Explore
buttons and the op wiring; docs/api.md updated.
650 frontend / 498 Python green; version 3.6.18; sw simple-v291.
INSTALL [for load]/LOAD/INSTALL [for install] carried a trailing Le and an
explicit ISD AID in the Security Domain field; the card executed the extra
byte as a second (phantom) command, so the compact response reported count=2
with SW 6700 - which the v3.6.2 remote-SW check correctly treated as a
failure, aborting at step 1.
Decrypted from the live trace (KIc/KID 25/25, 3DES): ours was
80E6020014 07F0414C46416101 08A000000003000000 00 00 00 00
the reference tool sends
80E602000C 07<aid> 00 00 00 00 (empty SD, no Le)
and its response is count=1 / 9000 while ours was count=2 / 6700.
- `_cap_apdu_sequence`: the SD AID is only sent when a custom one was
supplied (empty -> '00', the card defaults to the ISD; GP 11.5.2.3.1
Table 11-42) and all three RAM install APDUs are case 3 (no Le).
- tests: the expected INSTALL bytes updated, a no-Le assertion for every
APDU in the sequence, and a custom-SD-AID case.
615 frontend / 496 Python green; version 3.6.8; sw simple-v281.
Explore still missed the F0414C46416101 package on a card whose responses
wrap the R-APDU in the TS 102 226 5.2.2 Response Scripting template
(`AB <len> 80 <count> 23 <len> <R-APDU>`): `_decode_por` parsed that as a
compact response, so the frontend got `last_status_word` 81d0/7680 and data
starting `80 01 01 23 ...` instead of the listing.
- server: `_parse_response_scripting()` (AB definite / AF 80 ... 00 00
indefinite) extracts the executed-command count and the last R-APDU's SW
and data; `_decode_por` exposes it as `response_type: scripting` in the
same `decoded` shape as compact, so the RAM explore paging and the RAM
install `por_sw` see the real 9000/6310.
- frontend: the compact listing walk is deterministic on the AID length
(`len AID life ver`; P1=10 adds `module_count (len module_AID)*`).
`_parseRawAppEntry` no longer guesses `rawLen-1` for >8-byte AIDs (16-byte
A113 applet AIDs were truncated), and `_parseRawElfEntry` no longer scans
for `0x10` (a length/AID byte equal to 0x10 derailed the walk: a live page
parsed to 1 entry with no F0414C46416101).
- tests: exact trace fixtures - the ELF page lists F0414C46416101 (11
entries), the P1=10 page attaches its F0414C4641610101 module, the app page
keeps the 16-byte A113 AIDs; Python covers the scripting template
(definite/indefinite) against the live `AB 12` ISD and listing vectors.
610 frontend / 495 Python green; version 3.6.5; sw simple-v278.
Explore returned partial data (no ELF/module entries, the installed package
missing) and then failed with cntr_low. Two causes, both in the
actual-response SMS-SUBMIT path the card uses for big listings:
- `_find_sms_tpdu` read TLV lengths as a single byte; the FETCH carries
`8B 81 97 ...` (BER long form) for the big pages, so it returned a
corrupted, truncated TPDU.
- `_calc_ud_offset` treated the SMS-SUBMIT relative validity period (VPF=10)
as 7 bytes instead of 1, shifting the UD offset: `_parse_sms_concat` then
read a bogus UDH and reported no concatenation, so the segments never
assembled and the PoR/data was dropped ("RAM RESPONSE-PACKET: empty").
The step was marked failed, the counter did not advance, the same counter
was retried and the card answered `cntr_low`, which also blocked P1=10
(modules) where the installed package appears.
- the captured segments now always store the assembled UD (`submit_ud_hex`);
`_sms_submit_por()` rebuilds the DELIVER-style packet (`02 71 00` + UD) for
`_decode_por`. Verified against the live capture: por_ok, remote SW 6310,
438 hex chars of listing data (the exact bytes of the P1=20 page).
- `spPorAccepted` counts `actual_response_sms_submit` (0x0B) as accepted, so
the counter advances when the data follows via SMS-SUBMIT.
Explore queries follow GP Card Spec v2.3.1 11.4.2.2: compact listings
(P2.b2=0) with the chained GET RESPONSE (`ramGetStatusApdu`, paging P2=00 ->
P2=01), the malformed P2=02 attempt is gone, and the ISD-only query (P1=80)
never pages with next-occurrence (the card shall reject it).
Tests: Python SmsSubmitCaptureTest with the live FETCH bytes (TPDU length,
UD offset, concat reassembly, 027100+UD decode); ram.test.js checks the APDU
builder and the missing P2=02 attempt; cards_counter covers 0x0B.
608 frontend / 492 Python green; version 3.6.4; sw simple-v277.