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 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.
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.