Compare commits

...

36 Commits

Author SHA1 Message Date
catarrh fb6f80ef1d ui: translate the RAM operations hint
The data-l10n key for 'RAM operations perform atomic GlobalPlatform
commands over SCP80. Card keys are taken from the saved preset.' was
missing from LANG_RU, so the hint stayed English. SW cache v127 -> v128.
2026-09-12 23:25:42 +03:00
catarrh 2494e04984 ui: clear stale RAM status on op change, remember the card preset
- ramOpChanged() now clears the previous execution status (result line,
  steps, explorer, progress) only when the Operation value actually
  changes, so switching Explore -> Install Package no longer shows the
  old 'Partial - ELF Modules: no data' until Execute; re-entering the
  RAM subtab still preserves the last result
- ramClearResults() also hides the progress caption
- the Card preset dropdown no longer resets to the placeholder after
  every executed operation (ramSaveCntr -> ramRender rebuilt it) or on
  subtab re-entry: ramRender keeps the current/remembered index,
  ramApplyCard and ramExecute remember the selection for the session,
  and cardsRemove keeps _ramCardIdx aligned via ramCardIdxAfterRemove
- tests: op-change clearing, selection preservation across rebuilds,
  pick/execute remembering, index bookkeeping; SW cache v126 -> v127.
2026-09-12 23:20:26 +03:00
catarrh b49e6728b4 ui: translate SCP80/RAM explorer labels, buttons and result strings
The RAM explorer HTML is generated after load, so its data-l10n sections
and Delete/Delete All buttons stayed English until a language switch, and
the memory/AID/lifecycle labels were hardcoded with no key at all.

- ramRenderExploreHtml() now builds every label and button with t()
  (reusing the existing Delete/Delete All/heading/AID keys and wiring the
  three previously-unused Application/Instance, Load File and Module AID
  keys); new RU keys cover Applications:, Free NV:, Free Volatile:, AID:,
  Lifecycle:, Privileges:, SD AID:, Implicit sel:, Version: and (none)
- the explorer dataset is cached in _ramExplorerData and re-rendered by
  refreshDynamicI18n() on language switch (cleared on op change/results
  reset)
- RAM flow strings localized too: Partial/OK summaries, error labels
  (Memory/ISD/Apps/ELFs/ELF Modules/no data), GET DATA progress, delete
  confirm/labels, install alerts/progress/steps and Error:
- tests: ram.test.js asserts every explorer label/button goes through t()
  and the (none) placeholder; SW cache v125 -> v126.
2026-09-12 23:07:28 +03:00
catarrh dfb9551eb7 release: v2.1.1
Version bumped in server.py, pyproject.toml, the PWA header and the
docs/api.md example; SW cache v124 -> v125.
2026-09-12 22:25:07 +03:00
catarrh 8459040856 ui: auto-refresh the proactive log and STK menu state
The proactive command list only re-rendered on tab/pill switches, so new
fetched commands (background STATUS polling, menu traffic) stayed
invisible while the Phone view was open.

- /api/status now exposes proactive_seq (_PROACTIVE_ENTRY_ID, monotonic
  and not reset with the log), so the existing 2s status poll detects
  changes with no extra request; pysimCardStateUpdate() re-renders the
  log when the sequence changed and the Phone/Phone view is visible
- the 5s backend timer no longer fetches the log (it was discarded) and
  now uses the already-fetched stk-status: when active/pending changes
  while the Phone tab is visible, stkCheckMenu() refreshes the menu
  button without a tab switch
- pysimProactiveLogRender() keeps its scroll position and only shows
  Loading... on a first/empty paint
- tests: proactive seq + STK signature helpers and poll-driven render
  behaviour in card_state.test.js; SW cache v123 -> v124.
2026-09-12 22:22:55 +03:00
catarrh a5cccee17c release: v2.1.0
Version bumped in server.py, pyproject.toml, the PWA header and the
docs/api.md example; SW cache v122 -> v123.
2026-09-12 22:16:46 +03:00
catarrh 2a559371ae ui: keep the selected file in state, drop the File: title line
The File: EF.DIR [LF] line doubled as the selection store: pysimFsClickFile
wrote a formatted display string there and pysimFsRead/Save parsed it back
(stripping '✗ ' and ' [TYPE]'). The pane now keeps the name in
pysimFsSelected, the redundant title is gone (FID/type/size/FCI remain in
the info block), and the dead pysimFsSelect() plus the unused 'File:' RU
entry are removed. Read/Save still re-select on the server first: pySim's
current selection is a shared cursor that tree expansion, scans and
commands move, so it cannot identify the file shown in the pane.

SW cache v121 -> v122; structural test updated.
2026-09-12 22:12:25 +03:00
catarrh e0e86e31af ui: drop the Decoded FCI caption in the file manager info block
The decoded FCI items now follow the FID/type/size line directly; the
snapshot detail view keeps its caption. Test updated to assert the
caption is absent; SW cache v120 -> v121.
2026-09-12 21:59:41 +03:00
catarrh 2ec7a2d6c0 ui: show FID, layout and decoded FCI in the file manager detail pane
- selecting an EF now renders an info block between the File: title and
  the contents: FID, file type, size, record length/count (nulls skipped)
  followed by the decoded FCI via the existing profilerFciPreviewItems()
  decoder (no raw hex), matching the snapshot detail view
- pysimFsInfoHtml() is pure and reused by pysimFsClickFile(); the [LF]
  shorthand stays in the title; custom files get the same metadata from
  the temporary select probe
- tests: pysimFsInfoHtml in profiler.test.js (full response, missing
  fci_hex, skipped nulls) and a structural check for #pysim-fs-info;
  help EN/RU and READMEs updated; SW cache v119 -> v120.
2026-09-12 21:54:06 +03:00
catarrh 26f0063696 ui: reword the custom files hint
- EN: Custom files are added in file manager tree and included in card scan.
- RU: Пользовательские файлы добавляются в менеджер файлов и учитываются
  при чтении карт.
- SW cache v118 -> v119.
2026-09-12 21:47:23 +03:00
catarrh ddc6cb8f9d fix: parent-scoped file selection; never mutate the pySim model
pySim's lchan.select() resolves names against global selectables (self +
parent chain + MF children + applications) and falls back to probe_file(),
which blindly SELECTs an unknown FID and permanently injects a dynamically
named DF.XXXX/EF.XXXX into the running filesystem model. Probing a whole tree
or scanning a snapshot with custom files therefore polluted the model, made
tree branches show children of the wrong object, and could persist phantom
files into snapshots.

- server: new _select_with_parent()/_select_path() walk the requested parent
  path (new parent_path field, parent_sel kept as legacy fallback) strictly
  through the model and call lchan.select_file() only; model-unknown 4-hex
  segments are probed only with allow_probe and the temporary child pySim
  adds is detached again via the cleanup callable that the four handlers
  (/api/tree|select|read|write) now run in a finally block
- frontend: getParentPath() builds the segment chain (MF, ADF names, FIDs)
  and all tree/select/read/write bodies plus the snapshot/profile walker send
  parent_path; allow_probe is set only for custom files; the blind retries
  in the file manager were dropped
- tests: tests/test_select_scope.py (duplicate-FID resolution, no APDU for
  unknown non-custom files, probe+detach, model unchanged); fs_load/fs_probe
  assertions for parent_path and allow_probe; docs/api.md and AGENTS.md
  document the contract; SW cache v117 -> v118.
2026-09-12 21:45:57 +03:00
catarrh 85a66af7a5 ui: hide, don't clear, children of non-selectable DFs
- pysimFsLoadChildren failure now only marks exists=false and re-renders;
  loaded children stay in the node and are hidden by the renderer (red
  cross, no toggle, no (empty)), so a failed DF is re-attempted on the
  next probe/refresh instead of being short-circuited
- dropped the parent_sel-less /api/tree retry: error payloads now carry
  exists:false, so it doubled requests for every absent DF and re-selected
  the same FID without its parent (pySim probe_file fallback)
- probe walks strictly on exists===true and never collects files under a
  non-existing DF; no longer clears children on a failed EF select
- tests updated: single request per failed load, children untouched,
  200 exists:false marks absent without retry, render hides stale
  children, probe never fetches/selects them; SW cache v116 -> v117.
2026-09-12 21:23:31 +03:00
catarrh 92271d6726 ui: probe-all-files walk and honest file tree coloring
- pysimFsLoadChildren now treats {success:false,error} (and any error
  payload) as a failed listing: absent DFs/ADFs render as a red cross
  without an expand arrow instead of silently opening empty; the
  /api/tree 500 body also carries exists:false for shape consistency
  with /api/select
- an expanded directory with zero children shows a gray (empty)
  placeholder
- new Probe all files button (data-needs=card) in the tree header:
  walks the whole model tree from MF, verifies every DF/ADF via
  /api/tree and every file (incl. custom entries) via /api/select,
  skips subtrees of absent dirs, shows N/total progress, toggles to
  Stop, and reports present/absent counts with elapsed time; results
  colour the current tree only
- tests: fs_load (error/retry/empty), fs_render (red cross, (empty)),
  fs_probe (flags, absent-dir skip, custom files, stop, summary);
  html structural check; help EN/RU, READMEs, AGENTS updated;
  SW cache v115 -> v116.
2026-09-12 21:14:09 +03:00
catarrh 4598a70db2 ui: edit/delete buttons for custom files
- each row now shows Edit and Delete buttons (same styling as the
  profiler list buttons) instead of the red X glyph
- Edit reloads the entry into the top form: the Add button becomes
  Save (data-l10n updated too) and a Cancel button appears; submit
  updates the entry in place, recomputing fid/parentFid and excluding
  the edited row from the duplicate-path check
- Delete removes without confirmation; deleting the row being edited
  cancels the edit and an earlier deletion shifts the edit index
- pysimCustomAdd renamed to pysimCustomSubmit; tests for the whole
  flow; SW cache v114 -> v115.
2026-09-12 20:14:24 +03:00
catarrh 82fb2c8800 ui: move the Profiler tab next to SCP80
- top-level tab order is now Remote APDU, SCP80, Profiler, Card reader,
  Phone simulator; the profiler view moved with its button so the DOM
  order matches the tab strip
- docs (help EN/RU, READMEs) and the tab-order test updated; SW cache
  v113 -> v114.
2026-09-12 20:09:47 +03:00
catarrh f16e24ba11 ui: enlarge the header sim/nosim icon to 30px
- the SVG artwork occupies only ~46% x 63% of its 183x183 canvas, so
  the 18px render showed ~8x11px of ink; 30px roughly doubles the
  visible glyph while staying under the 32px h1 line-height, so the
  header row height is unchanged
- guard test asserts the inline size stays within the 24-32px budget;
  SW cache v112 -> v113.
2026-09-12 20:03:03 +03:00
catarrh 8b814eb2f8 ui: status indicator no longer uses the text cursor
- the wrapper is select-none with an inline cursor:default (the compiled
  CSS has no cursor-default utility), so hovering the dot/icons no
  longer shows the I-beam or allows text selection
- SW cache v111 -> v112.
2026-09-12 19:57:05 +03:00
catarrh 56c0e60462 ui: tooltip on the status dot shows the server state
- the dot carries its own title (Connecting... / No server connection),
  matching the wrapper tooltip; it is cleared when the sim icons replace
  the dot
- tests assert the dot title in both server states and its removal once
  the server is up; SW cache v110 -> v111.
2026-09-12 19:55:14 +03:00
catarrh 68aa37f093 ui: header indicator shows sim/nosim icons once the server is up
- #state-indicator is now a wrapper with a dot plus an 18px dark:invert
  image; no new compiled Tailwind classes (h-4/w-5 are not in the
  prebuilt CSS), so sizing uses an inline style and the header height
  is unchanged
- gray dot while probing, red dot when the server is unreachable;
  server up -> nosim.svg, animated sim_anim.svg while equipping, and
  sim.svg when the card is equipped (`_pysimEquipping` tracked from
  status.equipping, refreshed on every 2s poll)
- i18n reuses existing strings; tests for all five states, dot color
  transitions and the markup; SW cache v109 -> v110.
2026-09-12 19:53:29 +03:00
catarrh 5485d61390 ui: sort the file manager tree by FID or symbolic name
- pysimFsSortChildren() groups DFs above EFs, then sorts each group by
  FID (default) or case-insensitive symbolic name; missing names fall
  back to the FID as the sort key; deterministic tie-break; the input
  array is not mutated
- render uses the sorted copy, so lazily loaded directories and custom
  file injections are sorted too
- FID / Name pills above the tree (pysimFsSetSort) persist the choice in
  localStorage (otaman_fs_sort) and re-render without refetching
- tests for the comparator (DF priority, both keys, fallbacks, custom
  entries, no mutation) and the pill wiring; help/README updated;
  SW cache v108 -> v109.
2026-09-12 19:41:49 +03:00
catarrh 45c72d874f stk: never shadow a paused command with a new menu selection
The stuck-card pattern: after a Back TR the card re-issues the parent menu
as a new SELECT ITEM (91XX -> FETCH, paused, awaiting TR), but the UI's
back/timeout branch ignored that response and showed the cached top menu;
the next item click then sent ENVELOPE(Menu Selection) while the card was
waiting for the TR. The card answers such an ENVELOPE with 9000 (not the
usual 91XX), and menu-select cleared the pending command without a TR,
leaving an unfinished proactive session until reset/equip.

- server: _finish_pending_menu() answers a paused command with a cancel TR
  (0x10) before /api/menu-select sends its ENVELOPE and drains a 91XX
  follow-up, so a new selection can never shadow an unanswered FETCH
- frontend: stkMenuRespond('back'|'timeout') renders the follow-up command
  from the server response (SELECT ITEM / DISPLAY TEXT) and shows the
  cached top menu only when the TR answer carries no command (9000)
- tests: finish-pending cancel TR + chain drain (Python); stkMenuRespond
  back/timeout/cancel/ok rendering (frontend); help/AGENTS updated;
  SW cache v107 -> v108.
2026-09-12 19:34:27 +03:00
catarrh 1fe5c6347d ui: do not show 'initializing' when no card is inserted
The message branch treated the static auto_equip config flag as if
initialization were in progress, so with auto-equip on (default) a
cardless server showed 'Card inserted — initializing...' instead of the
no-card message. The initializing state now requires equipping or an
actually present card with auto-equip enabled; regression test added.
SW cache v106 -> v107.
2026-09-12 18:09:06 +03:00
catarrh c4ed064318 ui: center second-level pill rows on every page
SCP80, Card reader and Profiler list pill rows now use the same centered
layout as Remote APDU and Phone simulator (flex-wrap justify-center).
SW cache v105 -> v106.
2026-09-12 18:05:58 +03:00
catarrh 2a43424ae6 ui: use pills for the Profiler list tabs
Profiles / Card snapshots / Custom files switch from top-bar style tabs
(border-b, rounded-t) to the rounded-full pill style used by the other
second-level switchers. SW cache v104 -> v105.
2026-09-12 18:03:37 +03:00
catarrh 5b9e821d6d ui: gate controls by server/card state; move Custom files to Profiler
Availability model: server-down / server-up-no-card / card-equipped.
- header shows a symbolic indicator dot (red/yellow/green, tooltip)
- a 5s probe tracks /api/version+/api/status while not fully ready; the
  2s status poll takes over once connected
- controls declare data-needs='server' or 'card'; disabled + tooltip when
  the state does not satisfy them: equip (server), status/reset, file
  manager read/save/edit, raw APDU, profile-from-card, new snapshot,
  profiler list Check card, event Send, RAM install/explore delete,
  STK menu, Send STATUS, Verify vs pySim (server), pySim execute (server),
  PLI save/poll toggle (server)
- pysimConnect no longer conflates server and card: it records both and
  Connect stays usable without a card
- ramSendOta/ramInstallCap now use pysimFetch instead of raw fetch
  (relative /api URLs broke custom server URLs)

Custom files moved from the Card reader sub-tabs to the Profiler list
tabs (Profiles / Card snapshots / Custom files); help/README/AGENTS
updated. Tests: availability gating, indicator structure, moved tab.
SW cache v103 -> v104.
2026-09-12 17:58:30 +03:00
catarrh f1ed6a2a48 release: v2.0.0 2026-09-12 15:08:04 +03:00
catarrh 4b19b58bb0 ui: label comparison mismatches with snapshot names
Snapshot comparison results now use the master/checked snapshot names
where profile checks keep expected/actual: the raw-data field rows, the
generic mismatch line and the decoded FCI comparison table headers
(profilerRenderReport/fcpDiffHtml take an optional {expected, actual}
labels object; the name column widens to w-40 and wraps). Labels are
stored with the results so the language switch and the Only mismatches
filter re-render correctly; Check card snapshot and live checks are
unchanged. Tests for both labeled and default rendering; help/README
updated; SW cache v101 -> v102.
2026-09-12 15:06:51 +03:00
catarrh 6f63b5172c ui: show total scan time in the card snapshots list
Each snapshot row now renders '(N files, scanned in X.XX sec)' when the
snapshot has timing data (profilerSnapshotCountLabel); old/imported
snapshots without timing keep the plain '(N files)'. RU wording 'за X.XX
сек'. SW cache v100 -> v101.
2026-09-12 15:00:58 +03:00
catarrh 6d30e7095e snapshots: measure SELECT / READ commands and show timing stats
Server measures every classified APDU (A4 select, B0 read binary, B2 read
record) from command to response: _collect_apdu_times() enables the tracer
(reattaching it if pySim nulled it) and /api/select + /api/read return
'apdu_times': [{type, ms}]. Collection is safe: handlers hold _CARD_LOCK.

Snapshots store per-file {select_ms, read_ms}, a ms value per record and
snapshot-level stats {select, read_binary, read_record}: {min, max, avg,
count} plus total_ms (wall time of the scan). The snapshot view gets a
summary under the title (files/records counts, scan time, min/avg/max per
command type) and shows select/read per file and read time per record.
Timings are display-only: checks, snapshots comparison and imports ignore
them (old snapshots simply show 'No timing data').

Tests: Python classifier/collection (tests/test_apdu_timing.py) and
frontend stats/accumulator/format/build-file/summary. SW cache v99 ->
v100; help, README and docs/api.md updated.
2026-09-12 14:56:59 +03:00
catarrh a261675aad ui: focus scan name input and start scanning on Enter
'Profile from card' and 'New snapshot' now focus the name field when the
dialog opens, and Enter in that field starts the scan
(profilerScanNameKeydown, ignored while the Scan button is disabled).
Tests for the key handler and the input wiring; SW cache v98 -> v99.
2026-09-12 14:41:35 +03:00
catarrh 450f1de68a sw: never resolve respondWith to undefined on offline navigation
When a navigation fetch failed and the cache had no entry for the exact
URL (e.g. '/' while only 'index.html' is precached, common while the
local server restarts), the fetch handler resolved respondWith() to
undefined, producing 'TypeError: Failed to convert value to Response'.

The navigate fallback now chains caches.match(request) -> cached
index.html -> an explicit 503 offline Response. sw.js requests are
network-only with the same offline Response (previously they fell into
the navigate branch), and a new unit test loads sw.js in a VM sandbox
covering navigate/cache/asset/api paths. SW cache v97 -> v98.
2026-09-12 14:33:31 +03:00
catarrh 74efc20bf5 theme: brighten normal dark-mode text above the muted gray level
After the gray text was raised to #cbd5e1 in dark mode, elements using
dark:text-slate-300 (inactive tabs, labels, list text) matched the muted
level. Map dark:text-slate-300 to #e2e8f0 (slate-200, the body's dark
text color) so normal text stays one step brighter than muted gray.
SW cache v96 -> v97.
2026-09-12 14:31:27 +03:00
catarrh d2bd2ed798 theme: brighten gray text and borders in dark mode
Elements using bare text-gray-300/400/500/600 (proactive log ids and
timestamps, expand markers, event codes, ...) kept the light-theme gray
values in dark mode, which made them hard to read on slate-800/900.

- dark .text-gray-*:not([class*='dark:text-']) rules map them to the same
  level as the other dark muted text (slate-300 #cbd5e1, slate-200/100 for
  gray-700/800)
- dark:text-gray-400/600 and dark:text-slate-500 normalized to #cbd5e1
- dark:border-slate-600/700/800 (+ /50) brightened one step
- light theme unchanged
- the whole contrast block moved to src/contrast.css, appended by the
  npm build scripts after Tailwind so it stays reproducible; SW cache
  v95 -> v96
2026-09-12 14:28:41 +03:00
catarrh ccd0b6018c ui: separate STK menu, STATUS and events blocks in Phone view
Wrap each block of the phone row in a bordered card (border, rounded,
p-3) with items-start and gap-4 so they read as distinct panels and wrap
cleanly. SW cache v94 -> v95.
2026-09-12 14:20:35 +03:00
catarrh 9363f209ee ui: split Phone simulator into Phone and TR Config pills
- Phone pill: STK menu, STATUS and polling and the subscribed-events list
  in one row, proactive command log full-width below (room for more
  elements)
- TR Config pill: response data injected into TERMINAL RESPONSEs;
  currently the PROVIDE LOCAL INFORMATION dictionary, structured as
  heading + body blocks for future proactive-command responses
- phoneSwitchSubtab() mirrors the other sub-tab switchers and sets help
  anchors stk-menu / pli-dict; switchTab('phone') always opens Phone
- refreshDynamicI18n renders only the visible phone panel
- help EN/RU section 6 regrouped (6.4 STATUS polling under Phone, 6.5
  TR Config response data), READMEs and AGENTS.md updated
- structural and behavioral tests (html.test.js, phone_tabs.test.js);
  SW cache v93 -> v94
2026-09-12 14:17:48 +03:00
catarrh 33443d4f28 ui: regroup tabs — Remote APDU, Card reader, Profiler, Phone simulator
- C-APDU tab renamed Remote APDU (label untranslated EN/RU); Response
  parser moved from a top-level tab to a pill under it
- Profiler moved from the Card reader sub-tabs to a top-level tab
- Proactive UICC moved to a top-level tab and renamed Phone simulator
- Card reader keeps File manager, Custom files, pySim command line,
  Raw APDU
- modals (event send, profiler scan/snapshot, STK menu) moved outside
  the tab containers so they can open from any tab
- STK overlay disables the top-level tabs while waiting for user input
- OTA PoR jump now goes to Remote APDU > Response parser
- help EN/RU restructured (2.8 Response parser, 5 Profiler, 6 Phone
  simulator, subsequent sections renumbered, anchors kept); READMEs and
  AGENTS.md updated
- html.test.js asserts the new tab/pill structure; SW cache v92 -> v93
2026-09-12 14:01:28 +03:00
28 changed files with 3536 additions and 771 deletions
+74 -62
View File
@@ -31,13 +31,13 @@ npm run build
## Interface ## Interface
Four top-level tabs: **C-APDU**, **SCP80**, **Response parser**, **Card reader**. The **C-APDU** and **SCP80** tabs use pill sub-tabs, and the Card reader tab has six sub-tabs: **File manager**, **Custom files**, **Profiler**, **pySim command line**, **Raw APDU**, and **Proactive UICC**. Five top-level tabs: **Remote APDU**, **SCP80**, **Profiler**, **Card reader**, and **Phone simulator**. **Remote APDU** and **SCP80** use pill sub-tabs; the Card reader tab has three sub-tabs: **File manager**, **pySim command line**, and **Raw APDU**; the Profiler tab lists **Profiles**, **Card snapshots**, and **Custom files**.
--- ---
## C-APDU tab ## Remote APDU tab
Builds command APDUs (C-APDUs). Six sub-tabs cover different card generations and command sets. Builds command APDUs (C-APDUs). Seven sub-tabs cover different card generations, command sets and decoding tools: **SIM RFM**, **USIM RFM**, **Expanded Script**, **RAM/GP**, **HTTP OTA**, **C-APDU Parser**, and **Response parser**.
### SIM RFM ### SIM RFM
@@ -334,6 +334,19 @@ The **Command Scripting template** checkbox wraps the whole `81` triggering comm
--- ---
### Response parser
Decodes a raw command response: pick the command that was sent, enter the SW (e.g. `9000`) and the response data hex, then press **Decode**.
- **Command** — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).
- **SW decode** — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.
- **Privilege decode** — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.
- **Response data** — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).
---
## SCP80 tab ## SCP80 tab
The **SCP80** top-level tab groups SCP80-related views, switched by three pills: **Secured Packet**, **Cards**, and **RAM**. Assembles secured packets per ETSI TS 102 225. The **SCP80** top-level tab groups SCP80-related views, switched by three pills: **Secured Packet**, **Cards**, and **RAM**. Assembles secured packets per ETSI TS 102 225.
@@ -463,17 +476,6 @@ Delete confirms via a browser prompt before sending the GP `DELETE` command via
--- ---
## Response parser tab
Decodes a raw command response: pick the command that was sent, enter the SW (e.g. `9000`) and the response data hex, then press **Decode**.
- **Command** — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).
- **SW decode** — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.
- **Privilege decode** — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.
- **Response data** — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).
---
## Card Reader (pySim integration) ## Card Reader (pySim integration)
Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live card operations. Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live card operations.
@@ -484,60 +486,21 @@ Connects to the bundled [`pysim-otaman-server`](pysim_otaman_server/) for live c
Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Click to read contents. Browse the UICC filesystem in a tree view. Files are shown with names, FIDs, and AIDs (for ADFs). Click to read contents.
- Entries are grouped with DFs above EFs and sorted by **FID** or symbolic **Name** (pills above the tree, remembered in `localStorage`)
- **Read** — reads the selected file (auto-detects transparent vs record files) - **Read** — reads the selected file (auto-detects transparent vs record files)
- **Edit** — switch to edit mode, modify hex data, click **Save** to write back - **Edit** — switch to edit mode, modify hex data, click **Save** to write back
- **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view - **Raw / Decoded** — toggle between hex dump and pysim-decoded JSON view
- Selecting a file shows its FID, file type, size / record layout and the decoded FCI above the contents
### Custom Files - Missing files are shown in red (✗); a present but empty DF shows `(empty)`
- **Probe all files** — walks the whole tree (incl. custom files), marks every entry present/absent with *N / total* progress, stoppable, and ends with a summary; browsing itself stays lazy
Files not in pysim's model can be added manually:
1. Switch to the **Custom files** sub-tab
2. Enter the file path (e.g., `3F00/6F46`) and an alias (e.g., `EF.SPN`)
3. Click **Add** — the file appears in the tree in italics (unverified)
4. Click the file to verify existence — on success, it behaves like a model file
Custom files persist in `localStorage` across sessions. Export/import as JSON for sharing.
### Proactive UICC Pill
The **Proactive UICC** sub-tab in the Card Reader provides real-time CAT session interaction:
**Subscribed Events** — the card's SET UP EVENT LIST is displayed with per-event **Send** buttons. Clicking opens a form specific to the event type:
- **No-data events** (User Activity, Idle Screen, etc.) — single-click confirmation
- **Location Status** — dropdown for Normal / Limited / No service
- **Access Technology Change** — dropdown for all 13 RAT types
- **Card Reader Status, Language, UICC Access** — appropriate inputs
- **Network Rejection** — full adaptive form with registration type dropdown
(LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access
technology selection, and 53-cause unified rejection cause code dropdown
covering EMM, GMM, 5GMM, and LU causes
**Proactive Command Log** — chronological list of proactive commands encountered (seconds elapsed, type code, name, byte count). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, and PROVIDE LOCAL INFORMATION.
**PLI Data Dictionary** — editable per-qualifier hex values for all 22 PROVIDE LOCAL INFORMATION qualifiers (TS 102 223 + TS 131 111). 10 qualifiers have inline decode/encode forms (toggle):
| Code | Decoded fields |
|------|--------------|
| 00 | MCC, MNC, LAC/TAC |
| 01 | IMEI (15 digits) |
| 03 | Date, Time, TZ offset |
| 04 | Language (2-char code) |
| 05 | ME Status, Timing Advance |
| 06 | Access Technology (dropdown) |
| 08 | IMEISV (16 digits) |
| 09 | Search Mode (Auto/Manual) |
| 0A | Battery charge (%) |
| 0E | Multiple Access Technologies (comma-list) |
Values persist on the server until restart. Apply → hex updates; Save → POSTs to server. The server will use these values to populate TERMINAL RESPONSE data for future PLI proactive commands.
### Command Hints ### Command Hints
Type a command name in the **pySim command line** input. Usage hints appear as a tooltip after 300ms. Command autocomplete suggestions appear above the input. Type a command name in the **pySim command line** input. Usage hints appear as a tooltip after 300ms. Command autocomplete suggestions appear above the input.
### Profiler ---
## Profiler
Verifies that a card matches a named **profile** — an ordered set of rules describing the expected file system and, optionally, file contents. Profiles are stored in `localStorage`. Verifies that a card matches a named **profile** — an ordered set of rules describing the expected file system and, optionally, file contents. Profiles are stored in `localStorage`.
@@ -559,15 +522,64 @@ The scan dialog asks for a profile name and offers the FCP/FCI mode described ab
#### Card snapshots #### Card snapshots
The list view has two tabs — **Profiles** and **Card snapshots**. A snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The list view has two tabs — **Profiles** and **Card snapshots**. A snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures every card command (SELECT / READ BINARY / READ RECORD) from command to response and stores min/avg/max per type plus the total scan time; the snapshot view shows these in the summary and the select/read time per file (read time per record). Timings are display-only and ignored by checks/comparisons.
- **New snapshot** scans the card; **Import snapshot** loads JSON. - **New snapshot** scans the card; **Import snapshot** loads JSON.
- Each snapshot row has **Open** (all captured data read-only, raw FCI with decoded FCI and contents; only the name is editable), **Export**, and **Delete**. - Each snapshot row has **Open** (all captured data read-only, raw FCI with decoded FCI and contents; only the name is editable), **Export**, and **Delete**.
- **Check card snapshot** on a profile row runs the profile rules against a snapshot picked from the list, without a card reader. Files whose contents were not captured are reported as unverifiable errors. - **Check card snapshot** on a profile row runs the profile rules against a snapshot picked from the list, without a card reader. Files whose contents were not captured are reported as unverifiable errors.
- **Compare snapshots** compares two snapshots offline exactly like a profile check: pick the *master* snapshot and the *snapshot to check*, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same report. Every file must match exactly (exact FCI, contents); files present only in the checked snapshot are reported as extra files. - **Compare snapshots** compares two snapshots offline exactly like a profile check: pick the *master* snapshot and the *snapshot to check*, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same report. Every file must match exactly (exact FCI, contents); files present only in the checked snapshot are reported as extra files. In the comparison report the mismatch fields and FCI comparison columns are labeled with the master/checked snapshot names instead of expected/actual.
--- ---
---
#### Custom files
Files not in pysim's model can be added manually:
1. Switch to the **Custom files** tab in the Profiler list
2. Enter the file path (e.g., `3F00/6F46`) and an alias (e.g., `EF.SPN`)
3. Click **Add** — the file appears in the tree in italics (unverified)
4. Use **Edit** on a row to reload it into the form (the button becomes **Save** and a **Cancel** button appears) or **Delete** to remove it
5. Click the file to verify existence — on success, it behaves like a model file
Custom files persist in `localStorage` across sessions. Export/import as JSON for sharing.
## Phone simulator
The **Phone simulator** tab provides real-time CAT session interaction. It has two pills: **Phone** (STK menu, STATUS and polling, subscribed events, proactive command log) and **TR Config** (response data injected into TERMINAL RESPONSEs for proactive commands).
**Subscribed Events** — the card's SET UP EVENT LIST is displayed with per-event **Send** buttons. Clicking opens a form specific to the event type:
- **No-data events** (User Activity, Idle Screen, etc.) — single-click confirmation
- **Location Status** — dropdown for Normal / Limited / No service
- **Access Technology Change** — dropdown for all 13 RAT types
- **Card Reader Status, Language, UICC Access** — appropriate inputs
- **Network Rejection** — full adaptive form with registration type dropdown
(LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access
technology selection, and 53-cause unified rejection cause code dropdown
covering EMM, GMM, 5GMM, and LU causes
**Proactive Command Log** — chronological list of proactive commands encountered (seconds elapsed, type code, name, byte count). Covers SET UP MENU, SET UP EVENT LIST, POLL INTERVAL, DISPLAY TEXT, SELECT ITEM, and PROVIDE LOCAL INFORMATION.
**TR Config: PLI data dictionary** — editable per-qualifier hex values for all 22 PROVIDE LOCAL INFORMATION qualifiers (TS 102 223 + TS 131 111). 10 qualifiers have inline decode/encode forms (toggle):
| Code | Decoded fields |
|------|--------------|
| 00 | MCC, MNC, LAC/TAC |
| 01 | IMEI (15 digits) |
| 03 | Date, Time, TZ offset |
| 04 | Language (2-char code) |
| 05 | ME Status, Timing Advance |
| 06 | Access Technology (dropdown) |
| 08 | IMEISV (16 digits) |
| 09 | Search Mode (Auto/Manual) |
| 0A | Battery charge (%) |
| 0E | Multiple Access Technologies (comma-list) |
Values persist on the server until restart. Apply → hex updates; Save → POSTs to server. The server will use these values to populate TERMINAL RESPONSE data for future PLI proactive commands.
## PWA ## PWA
OTAMan is a Progressive Web App and can be installed for offline use. Use the **INSTALL PWA** button in the header, or use the browser's install prompt. OTAMan is a Progressive Web App and can be installed for offline use. Use the **INSTALL PWA** button in the header, or use the browser's install prompt.
+70 -58
View File
@@ -31,13 +31,13 @@ npm run build
## Интерфейс ## Интерфейс
Четыре вкладки: **C-APDU**, **SCP80**, **Response parser**, **Card reader**. Вкладки C-APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader шесть подвкладок: **File manager**, **Custom files**, **Profiler**, **pySim command line**, **Raw APDU** и **Proactive UICC**. Пять вкладок: **Remote APDU**, **SCP80**, **Profiler**, **Card reader** и **Phone simulator**. Вкладки Remote APDU и SCP80 используют пиллы-подвкладки; во вкладке Card reader три подвкладки: **File manager**, **pySim command line** и **Raw APDU**; во вкладке Profiler — **Profiles**, **Card snapshots** и **Custom files**.
--- ---
## Вкладка C-APDU ## Вкладка Remote APDU
Построение команд APDU (C-APDU). Шесть подвкладок для разных поколений карт и наборов команд. Построение команд APDU (C-APDU). Семь подвкладок для разных поколений карт, наборов команд и инструментов разбора: **SIM RFM**, **USIM RFM**, **Expanded Script**, **RAM/GP**, **HTTP OTA**, **Разбор C-APDU** и **«Парсер ответов»**.
### SIM RFM ### SIM RFM
@@ -308,6 +308,19 @@ CLA = `80` (GlobalPlatform v2.3.1). Удалённое управление со
--- ---
### Парсер ответов
Декодирование ответа команды: выберите отправленную команду, введите SW (например, `9000`) и данные ответа в hex, затем нажмите **Decode**.
- **Команда** — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, операции с PIN, CAT-команды TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, аутентификация, команды SCP).
- **Декодирование SW** — статусные слова по картам generic, UICC (TS 102 221) и GlobalPlatform с автоопределением контекста.
- **Декодирование привилегий** — байты привилегий из ответов GET DATA / INSTALL в читаемые флаги.
- **Данные ответа** — hex с интерпретацией по команде (например, шаблоны FCP из SELECT).
---
## Вкладка SCP80 ## Вкладка SCP80
Вкладка **SCP80** группирует SCP80-виды, переключаемые тремя пиллами: **Secured Packet**, **Cards** и **RAM**. Сборка защищённых пакетов по ETSI TS 102 225. Вкладка **SCP80** группирует SCP80-виды, переключаемые тремя пиллами: **Secured Packet**, **Cards** и **RAM**. Сборка защищённых пакетов по ETSI TS 102 225.
@@ -437,17 +450,6 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
--- ---
## Вкладка Response parser
Декодирование ответа команды: выберите отправленную команду, введите SW (например, `9000`) и данные ответа в hex, затем нажмите **Decode**.
- **Команда** — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, операции с PIN, CAT-команды TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, ...) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, аутентификация, команды SCP).
- **Декодирование SW** — статусные слова по картам generic, UICC (TS 102 221) и GlobalPlatform с автоопределением контекста.
- **Декодирование привилегий** — байты привилегий из ответов GET DATA / INSTALL в читаемые флаги.
- **Данные ответа** — hex с интерпретацией по команде (например, шаблоны FCP из SELECT).
---
## Card Reader (интеграция с pySim) ## Card Reader (интеграция с pySim)
Подключение к встроенному [`pysim-otaman-server`](pysim_otaman_server/) для работы с картой. Подключение к встроенному [`pysim-otaman-server`](pysim_otaman_server/) для работы с картой.
@@ -458,56 +460,21 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). Клик для чтения содержимого. Дерево файлов UICC. Отображаются имена, FID и AID (для ADF). Клик для чтения содержимого.
- Элементы сгруппированы (DF выше EF) и отсортированы по **FID** или символьному **имени** (пиллы над деревом, выбор сохраняется в `localStorage`)
- **Read** — чтение файла (автоопределение transparent/record) - **Read** — чтение файла (автоопределение transparent/record)
- **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи - **Edit** — режим редактирования, измените hex-данные и нажмите **Save** для записи
- **Raw / Decoded** — переключение между hex-дампом и декодированным JSON - **Raw / Decoded** — переключение между hex-дампом и декодированным JSON
- При выборе файла над содержимым показываются FID, тип файла, размер / структура записей и декодированный FCI
### Пользовательские файлы - Отсутствующие файлы показаны красным (✗); существующий пустой DF — `(пусто)`
- **Проверить все файлы** — обход всего дерева (включая пользовательские) с пометкой «есть/нет», прогрессом *N / всего*, возможностью остановки и сводкой в конце; сам просмотр остаётся ленивым
Файлы, отсутствующие в модели pysim, можно добавить вручную:
1. Перейдите на вкладку **Custom files**
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
4. Кликните для проверки существования — при успехе работает как обычный файл
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
### Proactive UICC
Подраздел **Proactive UICC** во вкладке Card Reader обеспечивает взаимодействие с CAT-сессией в реальном времени:
**Subscribed Events** — список событий SET UP EVENT LIST с кнопками **Send**. Клик открывает форму для конкретного типа события:
- **События без данных** (User Activity, Idle Screen и др.) — однократное уведомление
- **Location Status** — выпадающий список: Normal / Limited / No service
- **Access Technology Change** — 13 типов RAT
- **Network Rejection** — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля локации (MCC, MNC, LAC, RAC, TAC), доступные технологии, 53-позиционный выпадающий список причин отказа (EMM, GMM, 5GMM, LU)
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор.
**PLI Data Dictionary** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
| Код | Декодированные поля |
|------|--------------|
| 00 | MCC, MNC, LAC/TAC |
| 01 | IMEI (15 цифр) |
| 03 | Дата, время, TZ |
| 04 | Язык (2-символьный код) |
| 05 | ME Status, Timing Advance |
| 06 | Access Technology (выпадающий список) |
| 08 | IMEISV (16 цифр) |
| 09 | Search Mode (Auto/Manual) |
| 0A | Battery charge (%) |
| 0E | Multiple Access Technologies (список через запятую) |
Значения сохраняются на сервере до перезапуска. Apply → hex обновляется; Save → POST на сервер.
### Подсказки команд ### Подсказки команд
Введите имя команды в **pySim command line**. Подсказки по использованию появляются через 300 мс. Автодополнение команд — над полем ввода. Введите имя команды в **pySim command line**. Подсказки по использованию появляются через 300 мс. Автодополнение команд — над полем ввода.
### Профайлер ---
## Профайлер
Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`. Проверка соответствия карты именованному **профилю** — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в `localStorage`.
@@ -529,15 +496,60 @@ Delivery PoR (SPI2 `01`) проще — карта возвращает PoR на
#### Снимки карт #### Снимки карт
Представление списка имеет две вкладки — **«Профили»** и **«Снимки карт»**. Снимок — неизменяемая фиксация файловой системы: путь, символьное имя, тип, размер (или длина/число записей), сырой FCI и содержимое (если читается) каждого существующего файла. ICCID декодируется из EF.ICCID и показывается рядом с именем. Представление списка имеет две вкладки — **«Профили»** и **«Снимки карт»**. Снимок — неизменяемая фиксация файловой системы: путь, символьное имя, тип, размер (или длина/число записей), сырой FCI и содержимое (если читается) каждого существующего файла. ICCID декодируется из EF.ICCID и показывается рядом с именем. При сканировании также измеряется время каждой команды карты (SELECT / READ BINARY / READ RECORD) от отправки до ответа; сохраняются min/сред/max по типам и общее время сканирования — они показываются в сводке снимка и по файлам/записям. Время носит информационный характер и не используется при проверках и сравнении.
- **Новый снимок** сканирует карту; **Импорт снимка** загружает JSON. - **Новый снимок** сканирует карту; **Импорт снимка** загружает JSON.
- В строке снимка: **Открыть** (все данные только для чтения, сырой FCI с декодированным и содержимое; редактируется только имя), **Экспорт**, **Удалить**. - В строке снимка: **Открыть** (все данные только для чтения, сырой FCI с декодированным и содержимое; редактируется только имя), **Экспорт**, **Удалить**.
- **Проверить снимок карты** в строке профиля выполняет правила профиля на выбранном снимке без картридера. Файлы без захваченного содержимого помечаются как непроверяемые ошибки. - **Проверить снимок карты** в строке профиля выполняет правила профиля на выбранном снимке без картридера. Файлы без захваченного содержимого помечаются как непроверяемые ошибки.
- **Сравнить снимки** сравнивает два снимка offline так же, как проверка профиля: выберите *эталонный* снимок и *снимок для проверки*, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию). Всё должно совпадать точно (FCI, содержимое); файлы только в проверяемом снимке помечаются как лишние. - **Сравнить снимки** сравнивает два снимка offline так же, как проверка профиля: выберите *эталонный* снимок и *снимок для проверки*, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию). Всё должно совпадать точно (FCI, содержимое); файлы только в проверяемом снимке помечаются как лишние. В отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual.
--- ---
---
#### Пользовательские файлы
Файлы, отсутствующие в модели pysim, можно добавить вручную:
1. Перейдите на вкладку **Custom files** в списке профайлера
2. Введите путь (например, `3F00/6F46`) и псевдоним (например, `EF.SPN`)
3. Нажмите **Add** — файл появится в дереве курсивом (непроверенный)
4. Кнопка **Edit** загружает запись в форму (кнопка становится **Save**, появляется **Cancel**), **Delete** удаляет запись без подтверждения
5. Кликните для проверки существования — при успехе работает как обычный файл
Пользовательские файлы сохраняются в `localStorage`. Экспорт/импорт в JSON для обмена.
## Симулятор телефона
Вкладка **«Симулятор телефона»** обеспечивает взаимодействие с CAT-сессией в реальном времени. Две подвкладки: **«Телефон»** (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и **«Конфигурация TR»** (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).
**Subscribed Events** — список событий SET UP EVENT LIST с кнопками **Send**. Клик открывает форму для конкретного типа события:
- **События без данных** (User Activity, Idle Screen и др.) — однократное уведомление
- **Location Status** — выпадающий список: Normal / Limited / No service
- **Access Technology Change** — 13 типов RAT
- **Network Rejection** — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля локации (MCC, MNC, LAC, RAC, TAC), доступные технологии, 53-позиционный выпадающий список причин отказа (EMM, GMM, 5GMM, LU)
**Proactive Command Log** — хронологический список проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор.
**Конфигурация TR: словарь PLI** — редактируемые hex-значения для всех 22 квалификаторов PROVIDE LOCAL INFORMATION (TS 102 223 + TS 131 111). 10 квалификаторов имеют встроенные формы декодирования/кодирования:
| Код | Декодированные поля |
|------|--------------|
| 00 | MCC, MNC, LAC/TAC |
| 01 | IMEI (15 цифр) |
| 03 | Дата, время, TZ |
| 04 | Язык (2-символьный код) |
| 05 | ME Status, Timing Advance |
| 06 | Access Technology (выпадающий список) |
| 08 | IMEISV (16 цифр) |
| 09 | Search Mode (Auto/Manual) |
| 0A | Battery charge (%) |
| 0E | Multiple Access Technologies (список через запятую) |
Значения сохраняются на сервере до перезапуска. Apply → hex обновляется; Save → POST на сервер.
## PWA ## PWA
OTAMan — Progressive Web App. Можно установить для offline-использования через кнопку **INSTALL PWA** или через браузер. OTAMan — Progressive Web App. Можно установить для offline-использования через кнопку **INSTALL PWA** или через браузер.
+29 -8
View File
@@ -55,7 +55,7 @@ Returns server version for compatibility checking.
**Example response:** **Example response:**
```json ```json
{"version": "1.9.28"} {"version": "2.1.1"}
``` ```
### `GET /api/status` ### `GET /api/status`
@@ -272,31 +272,38 @@ Returns the current STK session state.
Read file content. Auto-detects transparent vs record files. Read file content. Auto-detects transparent vs record files.
```json ```json
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00", "mode": "raw"} {"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"], "mode": "raw"}
``` ```
Returns transparent data: Returns transparent data:
```json ```json
{"success": true, "sw": "9000", "file_type": "transparent", "data": "..."} {"success": true, "sw": "9000", "file_type": "transparent", "data": "...",
"apdu_times": [{"type": "select", "ms": 12}, {"type": "read_binary", "ms": 9}]}
``` ```
Returns records: Returns records:
```json ```json
{"success": true, "sw": "9000", "file_type": "linear_fixed", {"success": true, "sw": "9000", "file_type": "linear_fixed",
"records": [{"num": 1, "data": "..."}, {"num": 2, "data": "..."}]} "records": [{"num": 1, "data": "..."}, {"num": 2, "data": "..."}],
"apdu_times": [{"type": "select", "ms": 12},
{"type": "read_record", "ms": 11}, {"type": "read_record", "ms": 13}]}
``` ```
`apdu_times` reports each command's duration (command sent to response
received) classified as `select`, `read_binary` or `read_record`; the PWA uses
it for snapshot timing statistics. Other commands are not reported.
### `POST /api/write` ### `POST /api/write`
Write raw hex data to a file. Write raw hex data to a file.
```json ```json
{"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_sel": "3F00"} {"name": "EF.ICCID", "fid": "2FE2", "data": "A0A1A2...", "parent_path": ["MF"]}
``` ```
For record files: For record files:
```json ```json
{"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_sel": "7F10"} {"name": "EF.ADN", "fid": "6F3A", "data": "A0A1...", "record_nr": 1, "parent_path": ["MF", "7F10"]}
``` ```
Returns: Returns:
@@ -309,14 +316,25 @@ Returns:
Select a file by name or FID, with optional parent selection. Select a file by name or FID, with optional parent selection.
```json ```json
{"name": "EF.ICCID", "fid": "2FE2", "parent_sel": "3F00"} {"name": "EF.ICCID", "fid": "2FE2", "parent_path": ["MF"]}
``` ```
`parent_path` lists the path segments from MF to the parent (ADF names or
FIDs); the legacy single-segment `parent_sel` is still accepted but is only
unambiguous for ADFs. Resolution is strictly parent-scoped: model-known files
are selected through the requested parent only (pySim `select_file()`), never
via pySim's global selectables or its `probe_file()` model injection, so a
same-FID file under another parent is never picked and the filesystem model
is not modified. `allow_probe: true` (PWA custom files) additionally allows a
model-unknown 4-hex FID to be selected directly; any temporary model object
created for it is detached again before the response is sent.
Returns: Returns:
```json ```json
{"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent", {"name": "EF.ICCID", "fid": "2FE2", "file_type": "transparent",
"file_size": 10, "record_len": null, "num_of_rec": null, "file_size": 10, "record_len": null, "num_of_rec": null,
"fci_hex": "621082024021...", "exists": true} "fci_hex": "621082024021...",
"apdu_times": [{"type": "select", "ms": 12}], "exists": true}
``` ```
`fci_hex` is the raw FCP template (`'62'`) from the SELECT response, used by `fci_hex` is the raw FCP template (`'62'`) from the SELECT response, used by
@@ -332,6 +350,9 @@ Get directory listing with typed children.
{"name": "MF", "fid": "3F00"} {"name": "MF", "fid": "3F00"}
``` ```
Use `parent_path` (or the legacy `parent_sel`) to list a subdirectory, e.g.
`{"name": "DF.GSM-ACCESS", "fid": "5F3B", "parent_path": ["MF", "ADF.USIM"]}`.
Returns: Returns:
```json ```json
{"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]} {"exists": true, "name": "MF", "fid": "3F00", "file_type": "df", "children": [{"name": "EF.ICCID", "fid": "2fe2", "isDir": false}]}
+106 -95
View File
@@ -44,13 +44,14 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>.</li> <li><strong>Шапка</strong> — версия приложения, кнопка <strong>INSTALL PWA</strong> (появляется, когда браузер предлагает установку, для офлайн-работы), ссылки на проект на GitHub и на эту справку, переключатель языка <strong>EN/RU</strong> и переключатель тёмной/светлой <strong>темы</strong>.</li>
<li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li> <li>Выбор языка и темы хранится в <code class="font-mono text-sm">localStorage</code> и сохраняется между перезагрузками.</li>
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, подвкладка &laquo;Профайлер&raquo; открывает &sect;5.6).</li> <li>Вкладки верхнего уровня: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong>, <strong>&laquo;Парсер ответов&raquo;</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Карты</strong>, <strong>RAM</strong>), <strong>&laquo;Профайлер&raquo;</strong> (вкладки <strong>&laquo;Профили&raquo;</strong>, <strong>&laquo;Снимки карт&raquo;</strong>, <strong>&laquo;Пользовательские файлы&raquo;</strong>), <strong>&laquo;Картридер&raquo;</strong> (<strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong>) и <strong>&laquo;Симулятор телефона&raquo;</strong>.</li>
<li>Ссылка <strong>справка</strong> открывает эту документацию на разделе, соответствующем текущему представлению (например, вкладка &laquo;Профайлер&raquo; открывает &sect;5).</li>
</ul> </ul>
<section class="mb-10"> <section class="mb-10">
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Вкладка C-APDU</h2> <h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Вкладка Remote APDU</h2>
<p class="mb-3">Построение командных APDU (C-APDU). Шесть подвкладок охватывают разные поколения карт и наборы команд: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong> и <strong>Разбор C-APDU</strong>.</p> <p class="mb-3">Построение командных APDU (C-APDU). Семь подвкладок охватывают разные поколения карт, наборы команд и инструменты разбора: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>Разбор C-APDU</strong> и <strong>&laquo;Парсер ответов&raquo;</strong>.</p>
<h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3> <h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3>
<p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Удалённое управление файлами классических SIM-карт.</p> <p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Удалённое управление файлами классических SIM-карт.</p>
@@ -138,7 +139,7 @@
</ul> </ul>
<h4 id="expanded-response" class="font-medium mb-2 text-base">Декодирование ответов (TS 102 226 §5.2.2)</h4> <h4 id="expanded-response" class="font-medium mb-2 text-base">Декодирование ответов (TS 102 226 §5.2.2)</h4>
<p class="text-sm mb-2">Входящие ответы Proof of Receipt декодируются сервером — формат expanded Remote Application response data (TS 102 226 §5.2.2) или компактный формат. Представление Secured Packet показывает результат после <strong>Отправить на карту</strong> (см. <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;3.1</a>): статус PoR (TAR, счётчик, сырой PoR), а статусное слово и данные ответа последней команды подставляются на вкладку <strong>&laquo;Парсер ответов&raquo;</strong>.</p> <p class="text-sm mb-2">Входящие ответы Proof of Receipt декодируются сервером — формат expanded Remote Application response data (TS 102 226 §5.2.2) или компактный формат. Представление Secured Packet показывает результат после <strong>Отправить на карту</strong> (см. <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;3.1</a>): статус PoR (TAR, счётчик, сырой PoR), а статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Парсер ответов&raquo;</strong> (Remote APDU).</p>
<h3 id="ram-gp" class="text-lg font-medium mb-2">2.4 RAM/GP</h3> <h3 id="ram-gp" class="text-lg font-medium mb-2">2.4 RAM/GP</h3>
<p class="mb-2">CLA = <code class="font-mono text-sm">80</code> (GlobalPlatform Card Specification v2.3.1). Команды удалённого управления приложениями. Строятся тем же сборщиком цепочки, что и SIM/USIM.</p> <p class="mb-2">CLA = <code class="font-mono text-sm">80</code> (GlobalPlatform Card Specification v2.3.1). Команды удалённого управления приложениями. Строятся тем же сборщиком цепочки, что и SIM/USIM.</p>
@@ -227,6 +228,17 @@
<p class="text-sm mb-2"><strong>Упаковать в Secured packet</strong> отправляет готовый payload на вкладку SCP80 для заполнения SPI/счётчика — там укажите TAR, который слушает SD (обычно TAR OTASD).</p> <p class="text-sm mb-2"><strong>Упаковать в Secured packet</strong> отправляет готовый payload на вкладку SCP80 для заполнения SPI/счётчика — там укажите TAR, который слушает SD (обычно TAR OTASD).</p>
<h3 id="response-parser" class="text-lg font-medium mb-2">2.8 &laquo;Парсер ответов&raquo;</h3>
<p class="mb-3">Декодирует raw-ответ команды: выберите отправленную команду, введите SW (например, <code class="font-mono text-sm">9000</code>) и hex данных ответа, затем нажмите <strong>Декодировать</strong>. Поля также автоматически заполняются статусным словом и данными ответа последней команды после успешного нажатия <strong>Отправить на карту</strong> (см. <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;3.1</a>).</p>
<ul class="list-disc list-inside text-sm space-y-1">
<li><strong>Команда</strong> — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, PIN-операции, CAT-команды типа TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, &hellip;) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP-команды).</li>
<li><strong>Декодирование SW</strong> — статусные слова разрешаются по generic-, UICC- (TS 102 221) и GlobalPlatform-таблицам, контекст определяется автоматически.</li>
<li><strong>Декодирование привилегий</strong> — ответы GET DATA / INSTALL декодируют байты привилегий в читаемые флаги.</li>
<li><strong>Данные ответа</strong> — raw hex отображается и интерпретируется согласно команде (например, FCP-шаблоны SELECT).</li>
</ul>
<section class="mb-10"> <section class="mb-10">
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. Вкладка SCP80</h2> <h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. Вкладка SCP80</h2>
<p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — тремя переключателями: <strong>Secured Packet</strong>, <strong>Карты</strong> и <strong>RAM</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</p> <p class="mb-3">Верхнеуровневая вкладка <strong>SCP80</strong> объединяет разделы, связанные с SCP80. Переключение — тремя переключателями: <strong>Secured Packet</strong>, <strong>Карты</strong> и <strong>RAM</strong>. Собирает защищённые пакеты SCP80 по ETSI TS 102 225.</p>
@@ -260,7 +272,7 @@
<li>AES требует счётчик с защитой от повтора: биты SPI1 b5&nbsp;b4 должны быть <code class="font-mono text-sm">10</code> (счётчик больше) или <code class="font-mono text-sm">11</code> (счётчик +1) согласно TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li> <li>AES требует счётчик с защитой от повтора: биты SPI1 b5&nbsp;b4 должны быть <code class="font-mono text-sm">10</code> (счётчик больше) или <code class="font-mono text-sm">11</code> (счётчик +1) согласно TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li>
<li>Байт паддинга настраивается (<code class="font-mono text-sm">00</code> по умолчанию или <code class="font-mono text-sm">FF</code>)</li> <li>Байт паддинга настраивается (<code class="font-mono text-sm">00</code> по умолчанию или <code class="font-mono text-sm">FF</code>)</li>
</ul> </ul>
<p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются на вкладку <strong>&laquo;Парсер ответов&raquo;</strong>, а успешный PoR увеличивает счётчик повторов и очищает пакет.</p> <p class="text-sm mb-3">Кнопка <strong>Проверить в pySim</strong> сверяет собранный пакет с эталонной реализацией <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. Кнопка <strong>Отправить на карту</strong> доставляет пакет через ENVELOPE SMS-PP-DOWNLOAD (при подключении к серверу). Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Парсер ответов&raquo;</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p>
<h3 id="cards" class="text-lg font-medium mb-2">3.2 Карты</h3> <h3 id="cards" class="text-lg font-medium mb-2">3.2 Карты</h3>
<p class="mb-2">Хранит предустановки карт локально в браузере (<code class="font-mono text-sm">localStorage</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры.</p> <p class="mb-2">Хранит предустановки карт локально в браузере (<code class="font-mono text-sm">localStorage</code>), чтобы представление Secured Packet могло автоматически подставлять ключи и параметры.</p>
@@ -301,70 +313,64 @@
<section class="mb-10"> <section class="mb-10">
<h2 id="response-parser" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка &laquo;Парсер ответов&raquo;</h2> <h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Вкладка &laquo;Картридер&raquo; (pySim)</h2>
<p class="mb-3">Декодирует raw-ответ команды: выберите отправленную команду, введите SW (например, <code class="font-mono text-sm">9000</code>) и hex данных ответа, затем нажмите <strong>Декодировать</strong>. Поля также автоматически заполняются статусным словом и данными ответа последней команды после успешного нажатия <strong>Отправить на карту</strong> (см. <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;3.1</a>).</p> <p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Командная строка pySim</strong> и <strong>Отправка APDU</strong>. <strong>&laquo;Профайлер&raquo;</strong> и <strong>&laquo;Симулятор телефона&raquo;</strong> — отдельные вкладки верхнего уровня.</p>
<ul class="list-disc list-inside text-sm space-y-1">
<li><strong>Команда</strong> — группа SIM/USIM (SELECT, STATUS, READ/UPDATE, PIN-операции, CAT-команды типа TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, &hellip;) или группа RAM/GP (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP-команды).</li>
<li><strong>Декодирование SW</strong> — статусные слова разрешаются по generic-, UICC- (TS 102 221) и GlobalPlatform-таблицам, контекст определяется автоматически.</li>
<li><strong>Декодирование привилегий</strong> — ответы GET DATA / INSTALL декодируют байты привилегий в читаемые флаги.</li>
<li><strong>Данные ответа</strong> — raw hex отображается и интерпретируется согласно команде (например, FCP-шаблоны SELECT).</li>
</ul>
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 Файловый менеджер</h3>
<section class="mb-10"> <p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа. Элементы сгруппированы: DF выше EF, сортировка по <strong>FID</strong> или символьному <strong>имени</strong> (пиллы над деревом; выбор сохраняется в <code class="font-mono text-sm">localStorage</code>). При выборе файла над содержимым также показываются FID, тип файла, размер / структура записей и декодированный FCI.</p>
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Вкладка &laquo;Картридер&raquo; (pySim)</h2>
<p class="mb-3">Подключение к локальному <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> для работы с картой: введите URL сервера (по умолчанию <code class="font-mono text-sm">http://127.0.0.1:8080</code>) и нажмите <strong>Подключиться</strong>. Область статуса показывает состояние ридера/карты, а <strong>Подключить карту</strong> (пере)инициализирует карту после вставки. Подвкладки: <strong>Файловый менеджер</strong>, <strong>Пользовательские файлы</strong>, <strong>Профайлер</strong>, <strong>Командная строка pySim</strong>, <strong>Отправка APDU</strong> и <strong>Проактивный UICC</strong>.</p>
<h3 id="file-manager" class="text-lg font-medium mb-2">5.1 Файловый менеджер</h3>
<p class="text-sm mb-2">Дерево файловой системы отображается слева; выбор файла открывает панель деталей справа.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li> <li><strong>Прочитать</strong> — чтение файла (автоопределение transparent/record)</li>
<li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li> <li><strong>Редактировать</strong> — изменение hex-данных, <strong>Сохранить</strong> для записи (или <strong>Отмена</strong>)</li>
<li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li> <li><strong>Данные как на карте / Декодированные данные</strong> — переключение между hex-дампом и декодированным JSON</li>
<li><strong>Проверить все файлы</strong> — обход всего дерева (включая пользовательские файлы) с пометкой каждого элемента: есть (обычный вид) или нет (красный ✗, без стрелки разворачивания); существующие пустые DF показывают <code class="font-mono text-sm">(пусто)</code>. Отображается прогресс <em>N / всего</em>, обход можно остановить; в конце — сводка «есть/нет». Файлы проверяются только при разворачивании или проверке — просмотр остаётся ленивым.</li>
</ul> </ul>
<h3 id="custom-files" class="text-lg font-medium mb-2">5.2 Пользовательские файлы</h3> <h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 Командная строка pySim</h3>
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim: введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>), затем нажмите <strong>Добавить</strong>; добавленные файлы появляются в дереве &laquo;Файловый менеджер&raquo;. Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен — <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> и <strong>Импорт из файла</strong> / <strong>Вставить и импортировать</strong> / <strong>Импорт JSON из буфера</strong>.</p>
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">5.3 Командная строка pySim</h3>
<p class="text-sm mb-3">Выполнение любых команд pySim-shell с подсказками (300&nbsp;мс) и автодополнением.</p> <p class="text-sm mb-3">Выполнение любых команд pySim-shell с подсказками (300&nbsp;мс) и автодополнением.</p>
<h3 id="raw-apdu" class="text-lg font-medium mb-2">5.4 Отправка APDU</h3> <h3 id="raw-apdu" class="text-lg font-medium mb-2">4.3 Отправка APDU</h3>
<p class="text-sm mb-3">Отправка произвольного APDU и просмотр ответа.</p> <p class="text-sm mb-3">Отправка произвольного APDU и просмотр ответа.</p>
<h3 id="proactive-uicc" class="text-lg font-medium mb-2">5.5 Проактивный UICC</h3> <h3 id="usage-scenarios" class="text-lg font-medium mb-2">4.4 Сценарии использования</h3>
<p class="text-sm mb-3">Работа с сессией Card Application Toolkit: меню STK, подписанные события, журнал проактивных команд, словарь данных PROVIDE LOCAL INFORMATION и опрос STATUS.</p>
<h4 id="stk-menu" class="font-medium mb-1">5.5.1 Меню STK</h4> <h4 id="scenario-a" class="font-medium mb-1">Сценарий A &mdash; Работа с файлами, не входящими в модель pySim (&laquo;Пользовательские файлы&raquo;)</h4>
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает).</p> <ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Получите FID целевого файла (документация вендора или анализ ATR/файловой системы; такие файлы часто отсутствуют в открытых спецификациях).</li>
<li>Откройте вкладку <strong>&laquo;Картридер&raquo;</strong> &rarr; подвкладку <strong>&laquo;Пользовательские файлы&raquo;</strong>.</li>
<li>Введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>).</li>
<li>Нажмите <strong>Добавить</strong> — файл появится в дереве курсивом (непроверенный).</li>
<li>Кликните по файлу для проверки существования; при успехе (<code class="font-mono text-sm">9000</code>) он работает как обычный файл.</li>
<li>Читайте, редактируйте и сохраняйте hex-данные; переключайте <strong>Данные как на карте</strong> / <strong>Декодированные данные</strong>.</li>
<li>Экспортируйте список пользовательских файлов в JSON для переноса на другие машины.</li>
</ol>
<h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Подписанные события (SET UP EVENT LIST)</h4> <h4 id="scenario-b" class="font-medium mb-1">Сценарий B &mdash; Симуляция реальной сетевой среды для тестирования SIM</h4>
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p> <p class="text-sm mb-1"><strong>B.1 Ответы на PROVIDE LOCAL INFORMATION (PLI)</strong></p>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Откройте <strong>Симулятор телефона</strong> &rarr; <strong>Данные для PROVIDE LOCAL INFORMATION</strong>.</li>
<li>Используйте формы декодирования/кодирования для IMEI (<code class="font-mono text-sm">01</code>), Location Info (<code class="font-mono text-sm">00</code>), Access Technology (<code class="font-mono text-sm">06</code>) и т.д.</li>
<li>Нажмите <strong>Сохранить</strong> — значения сохранятся на сервере.</li>
<li>Включите <strong>Опрос</strong> (интервал 30&nbsp;с), чтобы карта периодически выдавала PLI.</li>
<li>Сервер вставляет значения словаря в каждый TERMINAL RESPONSE.</li>
<li>Проверьте в журнале проактивных команд: запись PLI покажет декодированный ответ.</li>
</ol>
<p class="text-sm mb-1"><strong>B.2 Симуляция сетевых действий через ENVELOPE (event download)</strong></p>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Проверьте список <strong>подписанных событий</strong> (из SET UP EVENT LIST).</li>
<li>Нажмите <strong>Отправить</strong> на событии (например, Location Status) и заполните форму; будет отправлен <code class="font-mono text-sm">ENVELOPE(Event Download)</code>.</li>
<li>Для <strong>Network Rejection</strong> выберите тип регистрации &rarr; поля местоположения &rarr; технологию доступа &rarr; причину отклонения.</li>
<li>Карта может ответить проактивной командой, которую обработчик цепочки зарегистрирует и обработает автоматически.</li>
</ol>
<p class="text-sm mb-1"><strong>B.3 Проверка симулированной среды</strong></p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>События без данных</strong> (User Activity, Idle Screen, Data Available, &hellip;) — уведомление в один клик</li> <li>Журнал проактивных команд показывает полный цикл (команда + байты TERMINAL RESPONSE).</li>
<li><strong>Location Status</strong> — выпадающий список: Normal / Limited / No service (тег <code class="font-mono text-sm">9B</code>)</li> <li>Кнопка <strong>Отправить STATUS</strong> / автопросмотр поддерживают сессию CAT (цикл дренажа).</li>
<li><strong>Access Technology Change</strong> — 13 типов RAT (тег <code class="font-mono text-sm">BF</code>)</li>
<li><strong>Network Rejection</strong> — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля местоположения (MCC, MNC, LAC, RAC, TAC), технология доступа и единый выпадающий список из 53 кодов причин (EMM, GMM, 5GMM и LU)</li>
</ul> </ul>
<p class="text-sm mb-3">Отправка события использует <code class="font-mono text-sm">ENVELOPE(Event Download)</code> по TS 102 223 / TS 131 111.</p>
<h4 id="proactive-log" class="font-medium mb-1">5.5.3 Журнал проактивных команд</h4>
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Для команд с данными ответа показывается строка <code class="font-mono text-sm">Ответ:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
<h4 id="pli-dict" class="font-medium mb-1">5.5.4 Словарь данных PROVIDE LOCAL INFORMATION</h4>
<p class="text-sm mb-2">Редактируемые hex-значения для всех 22 квалификаторов PLI (TS 102 223 &sect;8.6 + TS 131 111). У десяти квалификаторов есть встроенные формы декодирования/кодирования:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
<li><strong>01</strong> IMEI &middot; <strong>03</strong> Дата/время/TZ &middot; <strong>04</strong> Язык &middot; <strong>05</strong> Timing Advance</li>
<li><strong>06</strong> Access Technology &middot; <strong>08</strong> IMEISV &middot; <strong>09</strong> Search Mode</li>
<li><strong>0A</strong> Battery &middot; <strong>0E</strong> Multiple Access Technologies</li>
</ul>
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Когда карта выдаёт PLI, сервер вставляет значения словаря в TERMINAL RESPONSE.</p>
<h4 id="status-polling" class="font-medium mb-1">5.5.5 Опрос STATUS</h4> <section class="mb-10">
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Опрос</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;с, по умолчанию 30&nbsp;с, <code class="font-mono text-sm">0</code> отключает опрос) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.</p> <h2 id="profiler" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Профайлер</h2>
<h3 id="profiler" class="text-lg font-medium mb-2">5.6 Профайлер</h3>
<p class="text-sm mb-2">Проверяет соответствие карты именованному <strong>профилю</strong> — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в <code class="font-mono text-sm">localStorage</code>.</p> <p class="text-sm mb-2">Проверяет соответствие карты именованному <strong>профилю</strong> — упорядоченному набору правил, описывающих ожидаемую файловую систему и (опционально) содержимое файлов. Профили хранятся в <code class="font-mono text-sm">localStorage</code>.</p>
<h4 class="font-medium mb-1">Список профилей</h4> <h4 class="font-medium mb-1">Список профилей</h4>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
@@ -386,57 +392,62 @@
<p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования.</p> <p class="text-sm mb-2">Диалог сканирования запрашивает имя профиля и предлагает селектор <strong>&laquo;Проверка FCP/FCI&raquo;</strong> (те же три режима, по умолчанию <strong>Тип файла + размер (FCP)</strong>), применяемый ко всем создаваемым правилам, а также список <strong>&laquo;Игнорировать содержимое файлов&raquo;</strong> (все отмечены по умолчанию, кроме <code class="font-mono text-sm">EF.ARR</code>; флажок в заголовке отмечает или снимает весь список) часто перезаписываемых файлов, содержимое которых пропускается: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Ещё две отмеченные по умолчанию опции <strong>&laquo;Сравнивать первые 4 байта для&raquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> и <code class="font-mono text-sm">EF.ICCID</code> захватывают содержимое этих файлов как маску только первых 4 байт (снимите для точного сравнения). Строка прогресса показывает <em>N / всего файлов</em> с текущим путём файла во время сканирования; при сканировании опции скрываются, а кнопки блокируются. Правила создаются только для файлов, которые реально существуют на карте (возвращён FCP-шаблон); отсутствующие файлы пропускаются. Пользовательские файлы из подвкладки <strong>&laquo;Пользовательские файлы&raquo;</strong> включаются с той же проверкой существования.</p>
<h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4> <h4 id="card-snapshots" class="font-medium mb-1">Снимки карт</h4>
<p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка.</p> <p class="text-sm mb-2">Представление списка имеет две вкладки &mdash; <strong>&laquo;Профили&raquo;</strong> и <strong>&laquo;Снимки карт&raquo;</strong>. Снимок карты — неизменяемая фиксация файловой системы карты: для каждого существующего файла сохраняются путь, символьное имя, тип, размер (или длина/число записей), сырой FCI из ответа SELECT и содержимое, если файл читается (без списка игнорирования и без масок). ICCID декодируется из EF.ICCID и показывается рядом с именем снимка. При сканировании измеряется время каждой команды карты (SELECT, READ BINARY, READ RECORD) от отправки до ответа; снимок хранит min/сред/max по каждому типу команд и общее время сканирования, а в представлении эти значения показываются в сводке под заголовком, время select/read — для каждого файла и время чтения — для каждой записи. Время носит информационный характер и не используется при проверках и сравнении.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li> <li><strong>Новый снимок</strong> &mdash; запрашивает имя и сканирует карту, затем возвращает к списку.</li>
<li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li> <li><strong>Импорт снимка</strong> &mdash; загружает снимок из JSON-файла.</li>
<li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li> <li>В каждой строке снимка — <strong>Открыть</strong>, <strong>Экспорт</strong> и <strong>Удалить</strong>. <strong>Открыть</strong> показывает все захваченные данные только для чтения (сырой FCI с декодированным FCI, содержимое); редактируется только имя снимка.</li>
<li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li> <li><strong>Проверить снимок карты</strong> в строке профиля выполняет правила профиля на выбранном из списка снимке, без картридера. Отчёт такой же, как при проверке карты; файлы, содержимое которых не было захвачено при сканировании, помечаются как непроверяемые ошибки.</li>
<li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li> <li><strong>Сравнить снимки</strong> сравнивает два снимка без картридера так же, как проверка профиля: выберите <em>эталонный</em> снимок и <em>снимок для проверки</em>, при необходимости включите маску первых 4 байт EF.IMSI/EF.ICCID (включена по умолчанию) и получите такой же отчёт; в этом отчёте поля расхождений и колонки сравнения FCI подписаны именами эталонного и проверяемого снимков вместо expected/actual. Файлы, которые есть только в проверяемом снимке, помечаются как лишние. «К списку» возвращает на вкладку «Снимки карт».</li>
</ul> </ul>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Сценарии использования</h3> </section>
<h4 id="scenario-a" class="font-medium mb-1">Сценарий A &mdash; Работа с файлами, не входящими в модель pySim (&laquo;Пользовательские файлы&raquo;)</h4>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Получите FID целевого файла (документация вендора или анализ ATR/файловой системы; такие файлы часто отсутствуют в открытых спецификациях).</li>
<li>Откройте вкладку <strong>&laquo;Картридер&raquo;</strong> &rarr; подвкладку <strong>&laquo;Пользовательские файлы&raquo;</strong>.</li>
<li>Введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>).</li>
<li>Нажмите <strong>Добавить</strong> — файл появится в дереве курсивом (непроверенный).</li>
<li>Кликните по файлу для проверки существования; при успехе (<code class="font-mono text-sm">9000</code>) он работает как обычный файл.</li>
<li>Читайте, редактируйте и сохраняйте hex-данные; переключайте <strong>Данные как на карте</strong> / <strong>Декодированные данные</strong>.</li>
<li>Экспортируйте список пользовательских файлов в JSON для переноса на другие машины.</li>
</ol>
<h4 id="scenario-b" class="font-medium mb-1">Сценарий B &mdash; Симуляция реальной сетевой среды для тестирования SIM</h4>
<p class="text-sm mb-1"><strong>B.1 Ответы на PROVIDE LOCAL INFORMATION (PLI)</strong></p>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Откройте <strong>Проактивный UICC</strong> &rarr; <strong>Данные для PROVIDE LOCAL INFORMATION</strong>.</li>
<li>Используйте формы декодирования/кодирования для IMEI (<code class="font-mono text-sm">01</code>), Location Info (<code class="font-mono text-sm">00</code>), Access Technology (<code class="font-mono text-sm">06</code>) и т.д.</li>
<li>Нажмите <strong>Сохранить</strong> — значения сохранятся на сервере.</li>
<li>Включите <strong>Опрос</strong> (интервал 30&nbsp;с), чтобы карта периодически выдавала PLI.</li>
<li>Сервер вставляет значения словаря в каждый TERMINAL RESPONSE.</li>
<li>Проверьте в журнале проактивных команд: запись PLI покажет декодированный ответ.</li>
</ol>
<p class="text-sm mb-1"><strong>B.2 Симуляция сетевых действий через ENVELOPE (event download)</strong></p>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Проверьте список <strong>подписанных событий</strong> (из SET UP EVENT LIST).</li>
<li>Нажмите <strong>Отправить</strong> на событии (например, Location Status) и заполните форму; будет отправлен <code class="font-mono text-sm">ENVELOPE(Event Download)</code>.</li>
<li>Для <strong>Network Rejection</strong> выберите тип регистрации &rarr; поля местоположения &rarr; технологию доступа &rarr; причину отклонения.</li>
<li>Карта может ответить проактивной командой, которую обработчик цепочки зарегистрирует и обработает автоматически.</li>
</ol>
<p class="text-sm mb-1"><strong>B.3 Проверка симулированной среды</strong></p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li>Журнал проактивных команд показывает полный цикл (команда + байты TERMINAL RESPONSE).</li>
<li>Кнопка <strong>Отправить STATUS</strong> / автопросмотр поддерживают сессию CAT (цикл дренажа).</li>
</ul>
<h4 id="custom-files" class="font-medium mb-1">Пользовательские файлы</h4>
<p class="text-sm mb-3">Добавление файлов, не покрытых моделью pySim: введите полный путь (например, <code class="font-mono text-sm">3F00/7F20/6F46</code>) и псевдоним (например, <code class="font-mono text-sm">EF.SPN</code>), затем нажмите <strong>Добавить</strong>; добавленные файлы появляются в дереве &laquo;Файловый менеджер&raquo;. В каждой строке есть кнопки <strong>&laquo;Редактировать&raquo;</strong> (загружает запись в форму — кнопка становится <strong>&laquo;Сохранить&raquo;</strong>, появляется <strong>&laquo;Отмена&raquo;</strong>) и <strong>&laquo;Удалить&raquo;</strong> (без подтверждения). Список сохраняется в <code class="font-mono text-sm">localStorage</code>; обмен — <strong>Экспорт в JSON</strong> / <strong>Экспорт в файл</strong> и <strong>Импорт из файла</strong> / <strong>Вставить и импортировать</strong> / <strong>Импорт JSON из буфера</strong>.</p>
<section class="mb-10"> <section class="mb-10">
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Установка сервера</h2> <h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Симулятор телефона</h2>
<p class="mb-3">Для работы с картой (вкладка &laquo;Картридер&raquo;, &laquo;Проактивный UICC&raquo;, доставка OTA) нужен локальный <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — встроенный в OTAMan HTTP-сервер, оборачивающий pySim, работающий с ридером через PC/SC или serial и раздающий сам PWA (откройте <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p> <p class="text-sm mb-3">Работа с сессией Card Application Toolkit. Две подвкладки: <strong>&laquo;Телефон&raquo;</strong> (меню STK, STATUS и опрос, подписанные события, журнал проактивных команд) и <strong>&laquo;Конфигурация TR&raquo;</strong> (данные ответов, подставляемые в TERMINAL RESPONSE для проактивных команд).</p>
<h3 id="prerequisites" class="text-lg font-medium mb-2">6.1 Требования</h3> <h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 Меню STK</h3>
<p class="text-sm mb-3">Если карта выдала команду SET UP MENU, вверху этого представления появляется блок &laquo;Меню STK&raquo; с изумрудной кнопкой <strong>STK: &lt;название&gt;</strong>, открывающей оверлей меню (браузер STK-меню карты). Если карта не задала меню, вместо кнопки показывается &laquo;Меню не задано картой&raquo;. Состояние меню обновляется при каждом открытии представления. Интерактивные проактивные команды всегда получают TERMINAL RESPONSE: оверлей ждёт вашего выбора, и если вы не ответили и не нажали <strong>Timeout</strong>, сервер сам отвечает результатом timeout через <code class="font-mono text-sm">--menu-timeout</code> секунд (по умолчанию 60, <code class="font-mono text-sm">0</code> отключает). <strong>Назад</strong> и <strong>Timeout</strong> продолжают диалог с картой: если карта в ответ выдаёт следующую проактивную команду (SELECT ITEM или DISPLAY TEXT), панель показывает её; кэшированное верхнее меню появляется только когда карте больше нечего выполнять.</p>
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Подписанные события (SET UP EVENT LIST)</h3>
<p class="text-sm mb-2">События, которые отслеживает карта. У каждого события есть кнопка <strong>Отправить</strong>, открывающая форму, специфичную для типа события:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>События без данных</strong> (User Activity, Idle Screen, Data Available, &hellip;) — уведомление в один клик</li>
<li><strong>Location Status</strong> — выпадающий список: Normal / Limited / No service (тег <code class="font-mono text-sm">9B</code>)</li>
<li><strong>Access Technology Change</strong> — 13 типов RAT (тег <code class="font-mono text-sm">BF</code>)</li>
<li><strong>Network Rejection</strong> — полная адаптивная форма: тип регистрации (LU / GPRS / EPS / 5GS), поля местоположения (MCC, MNC, LAC, RAC, TAC), технология доступа и единый выпадающий список из 53 кодов причин (EMM, GMM, 5GMM и LU)</li>
</ul>
<p class="text-sm mb-3">Отправка события использует <code class="font-mono text-sm">ENVELOPE(Event Download)</code> по TS 102 223 / TS 131 111.</p>
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Журнал проактивных команд</h3>
<p class="text-sm mb-2">Хронологический список извлечённых проактивных команд. Каждая строка показывает время, код типа, имя и декодированный квалификатор (для команд, у которых он есть). Для команд с данными ответа показывается строка <code class="font-mono text-sm">Ответ:</code> с байтами TERMINAL RESPONSE (без служебных TLV); ответы PROVIDE LOCAL INFORMATION декодируются через словарь данных PLI.</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 Опрос STATUS</h3>
<p class="text-sm mb-3">Кнопка <strong>Отправить STATUS</strong> отправляет STATUS (F2) вручную. Переключатель <strong>Опрос</strong> включает фоновый опрос: после настраиваемого интервала бездействия (аргумент сервера <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;с, по умолчанию 30&nbsp;с, <code class="font-mono text-sm">0</code> отключает опрос) сервер отправляет STATUS и обрабатывает любую ожидающую проактивную команду. При извлечении карты опрос останавливается, а состояние карты сбрасывается.</p>
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 &laquo;Конфигурация TR&raquo; &mdash; данные ответа PROVIDE LOCAL INFORMATION</h3>
<p class="text-sm mb-2">Редактируемые hex-значения для всех 22 квалификаторов PLI (TS 102 223 &sect;8.6 + TS 131 111). У десяти квалификаторов есть встроенные формы декодирования/кодирования:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
<li><strong>01</strong> IMEI &middot; <strong>03</strong> Дата/время/TZ &middot; <strong>04</strong> Язык &middot; <strong>05</strong> Timing Advance</li>
<li><strong>06</strong> Access Technology &middot; <strong>08</strong> IMEISV &middot; <strong>09</strong> Search Mode</li>
<li><strong>0A</strong> Battery &middot; <strong>0E</strong> Multiple Access Technologies</li>
</ul>
<p class="text-sm mb-3">Значения хранятся на сервере до перезапуска. Когда карта выдаёт PLI, сервер вставляет значения словаря в TERMINAL RESPONSE.</p>
</section>
<section class="mb-10">
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Установка сервера</h2>
<p class="mb-3">Для работы с картой (вкладка &laquo;Картридер&raquo;, &laquo;Симулятор телефона&raquo;, доставка OTA) нужен локальный <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — встроенный в OTAMan HTTP-сервер, оборачивающий pySim, работающий с ридером через PC/SC или serial и раздающий сам PWA (откройте <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
<h3 id="prerequisites" class="text-lg font-medium mb-2">7.1 Требования</h3>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Python 3.8+</strong> с <code class="font-mono text-sm">pip</code></li> <li><strong>Python 3.8+</strong> с <code class="font-mono text-sm">pip</code></li>
<li><strong>Git</strong></li> <li><strong>Git</strong></li>
@@ -444,20 +455,20 @@
<li><strong>Только Windows</strong> — используйте <strong>Python 3.10&ndash;3.13</strong> (рекомендуется 3.13): <code class="font-mono text-sm">pyscard</code> (обёртка драйвера PC/SC) поставляет готовые wheels для этих версий. На Python 3.9 / 3.14 pip собирает <code class="font-mono text-sm">pyscard</code> из исходников, для чего требуются Microsoft C++ Build Tools (&laquo;Desktop development with C++&raquo;). Мост SMPP (<code class="font-mono text-sm">smpp.twisted3</code>) на Windows намеренно не устанавливается, поэтому для Python 3.10&ndash;3.13 C++ Build Tools не нужны.</li> <li><strong>Только Windows</strong> — используйте <strong>Python 3.10&ndash;3.13</strong> (рекомендуется 3.13): <code class="font-mono text-sm">pyscard</code> (обёртка драйвера PC/SC) поставляет готовые wheels для этих версий. На Python 3.9 / 3.14 pip собирает <code class="font-mono text-sm">pyscard</code> из исходников, для чего требуются Microsoft C++ Build Tools (&laquo;Desktop development with C++&raquo;). Мост SMPP (<code class="font-mono text-sm">smpp.twisted3</code>) на Windows намеренно не устанавливается, поэтому для Python 3.10&ndash;3.13 C++ Build Tools не нужны.</li>
</ul> </ul>
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">6.2 Быстрый старт — Linux / macOS</h3> <h3 id="quickstart-linux" class="text-lg font-medium mb-2">7.2 Быстрый старт — Linux / macOS</h3>
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git <pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
cd otaman cd otaman
chmod +x setup.sh start.sh chmod +x setup.sh start.sh
./setup.sh # создаёт .venv, устанавливает pysim + сервер (однократно) ./setup.sh # создаёт .venv, устанавливает pysim + сервер (однократно)
./start.sh # запускает сервер (PWA + API, автоопределение ридера)</pre> ./start.sh # запускает сервер (PWA + API, автоопределение ридера)</pre>
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">6.3 Быстрый старт — Windows</h3> <h3 id="quickstart-windows" class="text-lg font-medium mb-2">7.3 Быстрый старт — Windows</h3>
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git <pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
cd otaman cd otaman
setup.bat # создаёт .venv, устанавливает pysim + сервер (однократно) setup.bat # создаёт .venv, устанавливает pysim + сервер (однократно)
start.bat # запускает сервер (PWA + API)</pre> start.bat # запускает сервер (PWA + API)</pre>
<h3 id="helper-scripts" class="text-lg font-medium mb-2">6.4 Вспомогательные скрипты</h3> <h3 id="helper-scripts" class="text-lg font-medium mb-2">7.4 Вспомогательные скрипты</h3>
<table class="w-full text-sm mb-3 border-collapse"> <table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Скрипт</th><th class="text-left py-1 px-2">Назначение</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Скрипт</th><th class="text-left py-1 px-2">Назначение</th></tr></thead>
<tbody> <tbody>
@@ -466,7 +477,7 @@ start.bat # запускает сервер (PWA + API)</pre>
</tbody> </tbody>
</table> </table>
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">6.5 Автоопределение ридера</h3> <h3 id="reader-autodetect" class="text-lg font-medium mb-2">7.5 Автоопределение ридера</h3>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>PC/SC (Linux)</strong><code class="font-mono text-sm">start.sh</code> передаёт <code class="font-mono text-sm">-p 0</code>, если запущен демон <code class="font-mono text-sm">pcscd</code></li> <li><strong>PC/SC (Linux)</strong><code class="font-mono text-sm">start.sh</code> передаёт <code class="font-mono text-sm">-p 0</code>, если запущен демон <code class="font-mono text-sm">pcscd</code></li>
<li><strong>PC/SC (Windows)</strong><code class="font-mono text-sm">start.bat</code> всегда использует <code class="font-mono text-sm">-p 0</code> (PC/SC встроен в Windows)</li> <li><strong>PC/SC (Windows)</strong><code class="font-mono text-sm">start.bat</code> всегда использует <code class="font-mono text-sm">-p 0</code> (PC/SC встроен в Windows)</li>
@@ -475,7 +486,7 @@ start.bat # запускает сервер (PWA + API)</pre>
</ul> </ul>
<p class="text-sm mb-3">Если карта отсутствует, вкладка &laquo;Картридер&raquo; показывает &laquo;Карта не обнаружена. Вставьте карту и нажмите Подключить карту&raquo;.</p> <p class="text-sm mb-3">Если карта отсутствует, вкладка &laquo;Картридер&raquo; показывает &laquo;Карта не обнаружена. Вставьте карту и нажмите Подключить карту&raquo;.</p>
<h3 id="manual-install" class="text-lg font-medium mb-2">6.6 Ручная установка</h3> <h3 id="manual-install" class="text-lg font-medium mb-2">7.6 Ручная установка</h3>
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Создать и активировать venv <pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Создать и активировать venv
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate # Linux/macOS source .venv/bin/activate # Linux/macOS
@@ -494,7 +505,7 @@ pysim-otaman-server --http-port 8080</pre>
<section class="mb-10"> <section class="mb-10">
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Совместимость версий</h2> <h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">8. Совместимость версий</h2>
<table class="w-full text-sm mb-3 border-collapse"> <table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">PWA (OTAMan)</th><th class="text-left py-1 px-2">Сервер</th><th class="text-left py-1 px-2">Статус</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">PWA (OTAMan)</th><th class="text-left py-1 px-2">Сервер</th><th class="text-left py-1 px-2">Статус</th></tr></thead>
<tbody> <tbody>
+105 -94
View File
@@ -44,13 +44,14 @@
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle.</li> <li><strong>Header</strong> — the app version, an <strong>INSTALL PWA</strong> button (shown when the browser offers installation, enabling offline use), links to the project on GitHub and to this help, an <strong>EN/RU</strong> language toggle, and a dark/light <strong>theme</strong> toggle.</li>
<li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li> <li>Language and theme choices are stored in <code class="font-mono text-sm">localStorage</code> and persist across reloads.</li>
<li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler sub-tab opens &sect;5.6).</li> <li>Top-level tabs: <strong>Remote APDU</strong> (<strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, <strong>Response parser</strong>), <strong>SCP80</strong> (<strong>Secured Packet</strong>, <strong>Cards</strong>, <strong>RAM</strong>), <strong>Profiler</strong> (list tabs <strong>Profiles</strong>, <strong>Card snapshots</strong>, <strong>Custom files</strong>), <strong>Card reader</strong> (<strong>File manager</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>), and <strong>Phone simulator</strong>.</li>
<li>The <strong>help</strong> link opens this documentation at the section matching the current view (e.g. the Profiler tab opens &sect;5).</li>
</ul> </ul>
<section class="mb-10"> <section class="mb-10">
<h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. C-APDU tab</h2> <h2 id="c-apdu" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">2. Remote APDU tab</h2>
<p class="mb-3">Builds command APDUs (C-APDUs). Six sub-tabs cover different card generations and command sets: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, and <strong>C-APDU Parser</strong>.</p> <p class="mb-3">Builds command APDUs (C-APDUs). Seven sub-tabs cover different card generations, command sets and decoding tools: <strong>SIM RFM</strong>, <strong>USIM RFM</strong>, <strong>Expanded Script</strong>, <strong>RAM/GP</strong>, <strong>HTTP OTA</strong>, <strong>C-APDU Parser</strong>, and <strong>Response parser</strong>.</p>
<h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3> <h3 id="sim-rfm" class="text-lg font-medium mb-2">2.1 SIM RFM</h3>
<p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Remote File Management for classic SIM cards.</p> <p class="mb-2">CLA = <code class="font-mono text-sm">A0</code> (GSM 11.11 / TS 151 011, ISO 7816-4). Remote File Management for classic SIM cards.</p>
@@ -138,7 +139,7 @@
</ul> </ul>
<h4 id="expanded-response" class="font-medium mb-2 text-base">Response decoding (TS 102 226 §5.2.2)</h4> <h4 id="expanded-response" class="font-medium mb-2 text-base">Response decoding (TS 102 226 §5.2.2)</h4>
<p class="text-sm mb-2">Incoming Proof-of-Receipt responses are decoded by the server — expanded Remote Application response data (TS 102 226 §5.2.2) or the compact format. The Secured Packet view shows the outcome after <strong>Send to Card</strong> (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;3.1</a>): the PoR status (TAR, counter, raw PoR), with the last command&rsquo;s status word and response data filled into the <strong>Response parser</strong> tab.</p> <p class="text-sm mb-2">Incoming Proof-of-Receipt responses are decoded by the server — expanded Remote Application response data (TS 102 226 §5.2.2) or the compact format. The Secured Packet view shows the outcome after <strong>Send to Card</strong> (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;3.1</a>): the PoR status (TAR, counter, raw PoR), with the last command&rsquo;s status word and response data filled into the <strong>Response parser</strong> pill under Remote APDU.</p>
<h3 id="ram-gp" class="text-lg font-medium mb-2">2.4 RAM/GP</h3> <h3 id="ram-gp" class="text-lg font-medium mb-2">2.4 RAM/GP</h3>
<p class="mb-2">CLA = <code class="font-mono text-sm">80</code> (GlobalPlatform Card Specification v2.3.1). Remote Application Management commands for card content management. Built with the same chain builder as SIM/USIM: add rows, fill fields, and the chain preview updates automatically.</p> <p class="mb-2">CLA = <code class="font-mono text-sm">80</code> (GlobalPlatform Card Specification v2.3.1). Remote Application Management commands for card content management. Built with the same chain builder as SIM/USIM: add rows, fill fields, and the chain preview updates automatically.</p>
@@ -227,6 +228,17 @@
<p class="text-sm mb-2"><strong>Pack into Secured packet</strong> sends the built payload to the SCP80 tab for SPI/counter filling &mdash; insert the TAR the SD listens on (typically the OTASD TAR) there.</p> <p class="text-sm mb-2"><strong>Pack into Secured packet</strong> sends the built payload to the SCP80 tab for SPI/counter filling &mdash; insert the TAR the SD listens on (typically the OTASD TAR) there.</p>
<h3 id="response-parser" class="text-lg font-medium mb-2">2.8 Response parser</h3>
<p class="mb-3">Decodes a raw command response: pick the command that was sent, enter the SW (e.g. <code class="font-mono text-sm">9000</code>) and the response data hex, then press <strong>Decode</strong>. The fields are also auto-filled with the last command&rsquo;s status word and response data after a successful &ldquo;Send to Card&rdquo; (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;3.1</a>).</p>
<ul class="list-disc list-inside text-sm space-y-1">
<li><strong>Command</strong> — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, &hellip;) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).</li>
<li><strong>SW decode</strong> — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.</li>
<li><strong>Privilege decode</strong> — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.</li>
<li><strong>Response data</strong> — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).</li>
</ul>
<section class="mb-10"> <section class="mb-10">
<h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. SCP80 tab</h2> <h2 id="scp80" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">3. SCP80 tab</h2>
<p class="mb-3">The <strong>SCP80</strong> top-level tab groups the SCP80-related views. It is switched by three pills: <strong>Secured Packet</strong>, <strong>Cards</strong>, and <strong>RAM</strong>. Assembles SCP80 secured packets per ETSI TS 102 225.</p> <p class="mb-3">The <strong>SCP80</strong> top-level tab groups the SCP80-related views. It is switched by three pills: <strong>Secured Packet</strong>, <strong>Cards</strong>, and <strong>RAM</strong>. Assembles SCP80 secured packets per ETSI TS 102 225.</p>
@@ -301,70 +313,64 @@
<section class="mb-10"> <section class="mb-10">
<h2 id="response-parser" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Response parser tab</h2> <h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">4. Card reader (pySim) tab</h2>
<p class="mb-3">Decodes a raw command response: pick the command that was sent, enter the SW (e.g. <code class="font-mono text-sm">9000</code>) and the response data hex, then press <strong>Decode</strong>. The fields are also auto-filled with the last command&rsquo;s status word and response data after a successful &ldquo;Send to Card&rdquo; (see <a href="#secured-packet" class="text-blue-600 dark:text-blue-400 hover:underline">&sect;3.1</a>).</p> <p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>pySim command line</strong>, and <strong>Raw APDU</strong>. The <strong>Profiler</strong> and <strong>Phone simulator</strong> are separate top-level tabs.</p>
<ul class="list-disc list-inside text-sm space-y-1">
<li><strong>Command</strong> — SIM/USIM group (SELECT, STATUS, READ/UPDATE, PIN ops, CAT commands like TERMINAL PROFILE/ENVELOPE/FETCH/TERMINAL RESPONSE, MANAGE CHANNEL, &hellip;) or RAM/GP group (INSTALL, LOAD, DELETE, GET/STORE DATA, auth, SCP commands).</li>
<li><strong>SW decode</strong> — status words resolved against generic, UICC (TS 102 221), and GlobalPlatform maps, with context auto-detected.</li>
<li><strong>Privilege decode</strong> — GET DATA / INSTALL response payloads decode the privilege bytes into human-readable flags.</li>
<li><strong>Response data</strong> — raw hex rendered and interpreted per command (e.g. SELECT FCP templates).</li>
</ul>
<h3 id="file-manager" class="text-lg font-medium mb-2">4.1 File manager</h3>
<section class="mb-10"> <p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right. Entries are grouped with DFs above EFs and sorted by <strong>FID</strong> or symbolic <strong>Name</strong> (pills above the tree; the choice is remembered in <code class="font-mono text-sm">localStorage</code>). Selecting a file also shows its FID, file type, size / record layout and the decoded FCI above the content pane.</p>
<h2 id="card-reader" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Card reader (pySim) tab</h2>
<p class="mb-3">Connects to a local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> for live card operations: enter the server URL (default <code class="font-mono text-sm">http://127.0.0.1:8080</code>) and press <strong>Connect</strong>. The status area shows the reader/card state, and <strong>Equip card</strong> (re)initializes the card after insertion. Sub-tabs: <strong>File manager</strong>, <strong>Custom files</strong>, <strong>Profiler</strong>, <strong>pySim command line</strong>, <strong>Raw APDU</strong>, and <strong>Proactive UICC</strong>.</p>
<h3 id="file-manager" class="text-lg font-medium mb-2">5.1 File manager</h3>
<p class="text-sm mb-2">The file system tree is displayed on the left; selecting a file opens its detail pane on the right.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li> <li><strong>Read</strong> — reads the selected file (auto-detects transparent vs record files)</li>
<li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li> <li><strong>Edit</strong> — modify hex data, <strong>Save</strong> to write back (or <strong>Cancel</strong>)</li>
<li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li> <li><strong>Raw / Decoded</strong> — toggle between hex dump and pySim-decoded JSON</li>
<li><strong>Probe all files</strong> — walks the whole tree (including custom files) and marks every entry present (normal) or absent (red ✗, no expand arrow); empty-but-present DFs show <code class="font-mono text-sm">(empty)</code>. Shows progress <em>N / total</em>, can be stopped, and finishes with a present/absent summary. Files are only verified when expanded or probed — browsing stays lazy.</li>
</ul> </ul>
<h3 id="custom-files" class="text-lg font-medium mb-2">5.2 Custom files</h3> <h3 id="pysim-cmdline" class="text-lg font-medium mb-2">4.2 pySim command line</h3>
<p class="text-sm mb-3">Add files that pySim&rsquo;s model does not cover: enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>), then press <strong>Add</strong>; added files appear in the File manager tree. The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> and restored with <strong>Import from file</strong> / <strong>Paste &amp; import</strong> / <strong>Import JSON from clipboard</strong>.</p>
<h3 id="pysim-cmdline" class="text-lg font-medium mb-2">5.3 pySim command line</h3>
<p class="text-sm mb-3">Execute any pySim-shell command with usage hints (300&nbsp;ms) and autocomplete.</p> <p class="text-sm mb-3">Execute any pySim-shell command with usage hints (300&nbsp;ms) and autocomplete.</p>
<h3 id="raw-apdu" class="text-lg font-medium mb-2">5.4 Raw APDU</h3> <h3 id="raw-apdu" class="text-lg font-medium mb-2">4.3 Raw APDU</h3>
<p class="text-sm mb-3">Send an arbitrary APDU and view the raw response.</p> <p class="text-sm mb-3">Send an arbitrary APDU and view the raw response.</p>
<h3 id="proactive-uicc" class="text-lg font-medium mb-2">5.5 Proactive UICC</h3> <h3 id="usage-scenarios" class="text-lg font-medium mb-2">4.4 Usage scenarios</h3>
<p class="text-sm mb-3">Interacts with the Card Application Toolkit session: the STK menu, subscribed events, the proactive command log, the PROVIDE LOCAL INFORMATION data dictionary, and STATUS polling.</p>
<h4 id="stk-menu" class="font-medium mb-1">5.5.1 STK menu</h4> <h4 id="scenario-a" class="font-medium mb-1">Scenario A &mdash; Working with files not in pySim&rsquo;s model (Custom files)</h4>
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables).</p> <ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Obtain the FID of the target file (vendor documentation or ATR/file-system analysis; such files are often not in public specs).</li>
<li>Open the <strong>Card reader</strong> tab &rarr; <strong>Custom files</strong> sub-tab.</li>
<li>Enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>).</li>
<li>Click <strong>Add</strong> — the file appears in the tree in italics (unverified).</li>
<li>Click the file to verify existence; on success (<code class="font-mono text-sm">9000</code>) it behaves like a normal file.</li>
<li>Read, edit and save hex data; toggle Raw/Decoded views.</li>
<li>Export the custom-file list as JSON to share with other machines.</li>
</ol>
<h4 id="subscribed-events" class="font-medium mb-1">5.5.2 Subscribed events (SET UP EVENT LIST)</h4> <h4 id="scenario-b" class="font-medium mb-1">Scenario B &mdash; Simulating a real network environment for SIM testing</h4>
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p> <p class="text-sm mb-1"><strong>B.1 Answer PROVIDE LOCAL INFORMATION (PLI)</strong></p>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Open <strong>Phone simulator</strong> &rarr; <strong>PROVIDE LOCAL INFORMATION response data</strong>.</li>
<li>Use the decode/encode forms to set IMEI (<code class="font-mono text-sm">01</code>), Location Info (<code class="font-mono text-sm">00</code>), Access Technology (<code class="font-mono text-sm">06</code>), etc.</li>
<li>Click <strong>Save</strong> — values persist server-side.</li>
<li>Enable <strong>Polling</strong> (interval 30&nbsp;s) so the card issues PLI periodically.</li>
<li>The server injects the dictionary values into each TERMINAL RESPONSE.</li>
<li>Verify in the proactive log: the PLI entry shows the decoded response.</li>
</ol>
<p class="text-sm mb-1"><strong>B.2 Simulate network actions via ENVELOPE (event download)</strong></p>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Check the <strong>subscribed events</strong> list (from SET UP EVENT LIST).</li>
<li>Click <strong>Send</strong> on an event (e.g. Location Status) and fill the form; an <code class="font-mono text-sm">ENVELOPE(Event Download)</code> is sent.</li>
<li>For <strong>Network Rejection</strong>, select registration type &rarr; location fields &rarr; access technology &rarr; rejection cause.</li>
<li>The card may respond with a proactive command, which the chain handler logs and answers automatically.</li>
</ol>
<p class="text-sm mb-1"><strong>B.3 Verify the simulated environment</strong></p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>No-data events</strong> (User Activity, Idle Screen, Data Available, &hellip;) — one-click notification</li> <li>The proactive command log shows the full round-trip (command + TERMINAL RESPONSE bytes).</li>
<li><strong>Location Status</strong> — dropdown: Normal / Limited / No service (tag <code class="font-mono text-sm">9B</code>)</li> <li>The STATUS button / auto-polling keep the CAT session alive (drain loop).</li>
<li><strong>Access Technology Change</strong> — 13 RAT types (tag <code class="font-mono text-sm">BF</code>)</li>
<li><strong>Network Rejection</strong> — full adaptive form: registration type (LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access technology, and a 53-cause unified rejection cause dropdown covering EMM, GMM, 5GMM and LU causes</li>
</ul> </ul>
<p class="text-sm mb-3">Sending an event uses <code class="font-mono text-sm">ENVELOPE(Event Download)</code> per TS 102 223 / TS 131 111.</p>
<h4 id="proactive-log" class="font-medium mb-1">5.5.3 Proactive command log</h4>
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
<h4 id="pli-dict" class="font-medium mb-1">5.5.4 PROVIDE LOCAL INFORMATION data dictionary</h4>
<p class="text-sm mb-2">Editable hex values for all 22 PLI qualifiers (TS 102 223 &sect;8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
<li><strong>01</strong> IMEI &middot; <strong>03</strong> Date/Time/TZ &middot; <strong>04</strong> Language &middot; <strong>05</strong> Timing Advance</li>
<li><strong>06</strong> Access Technology &middot; <strong>08</strong> IMEISV &middot; <strong>09</strong> Search Mode</li>
<li><strong>0A</strong> Battery &middot; <strong>0E</strong> Multiple Access Technologies</li>
</ul>
<p class="text-sm mb-3">Values persist server-side until restart. When the card issues PLI, the server injects the dictionary values into the TERMINAL RESPONSE.</p>
<h4 id="status-polling" class="font-medium mb-1">5.5.5 STATUS polling</h4> <section class="mb-10">
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;s, default 30&nbsp;s, <code class="font-mono text-sm">0</code> disables polling) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.</p> <h2 id="profiler" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">5. Profiler</h2>
<h3 id="profiler" class="text-lg font-medium mb-2">5.6 Profiler</h3>
<p class="text-sm mb-2">Verifies that a card matches a named <strong>profile</strong> — an ordered set of rules describing the expected file system and (optionally) file contents. Profiles are stored in <code class="font-mono text-sm">localStorage</code>.</p> <p class="text-sm mb-2">Verifies that a card matches a named <strong>profile</strong> — an ordered set of rules describing the expected file system and (optionally) file contents. Profiles are stored in <code class="font-mono text-sm">localStorage</code>.</p>
<h4 class="font-medium mb-1">Profile list</h4> <h4 class="font-medium mb-1">Profile list</h4>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
@@ -386,57 +392,62 @@
<p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p> <p class="text-sm mb-2">The scan dialog asks for a profile name and offers a <strong>&ldquo;FCP/FCI check&rdquo;</strong> selector (the same three modes above, default <strong>Filetype + size</strong>) applied to every generated rule, plus an <strong>&ldquo;Ignore contents of files&rdquo;</strong> checklist (all checked by default except <code class="font-mono text-sm">EF.ARR</code>; the header checkbox checks or unchecks the whole list) of frequently-overwritten files whose contents are skipped: <code class="font-mono text-sm">EF.LOCI</code>, <code class="font-mono text-sm">EF.PSLOCI</code>, <code class="font-mono text-sm">EF.EPSLOCI</code>, <code class="font-mono text-sm">EF.5GS3GPPLOCI</code>, <code class="font-mono text-sm">EF.Keys</code>, <code class="font-mono text-sm">EF.KeysPS</code>, <code class="font-mono text-sm">EF.SMS</code>, <code class="font-mono text-sm">EF.Kc</code>, <code class="font-mono text-sm">EF.KcGPRS</code>, <code class="font-mono text-sm">EF.LOCIGPRS</code>, <code class="font-mono text-sm">EF.CBMID</code>, <code class="font-mono text-sm">EF.SMSS</code>, <code class="font-mono text-sm">EF.ACC</code>, <code class="font-mono text-sm">EF.EPSNSC</code>, <code class="font-mono text-sm">EF.START-HFN</code>, <code class="font-mono text-sm">EF.ARR</code>. Two further checked-by-default options <strong>&ldquo;Match first 4 bytes for&rdquo;</strong> <code class="font-mono text-sm">EF.IMSI</code> and <code class="font-mono text-sm">EF.ICCID</code> capture those files&rsquo; contents as a mask of only the first 4 bytes (uncheck for exact matching). A progress line shows <em>N / total files</em> with the current file path while scanning; during the scan the options are hidden and the buttons are locked. Rules are created only for files that actually exist on the card (a FCP template is returned); missing files are skipped. Custom files from the <strong>Custom files</strong> sub-tab are included under the same existence check.</p>
<h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4> <h4 id="card-snapshots" class="font-medium mb-1">Card snapshots</h4>
<p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name.</p> <p class="text-sm mb-2">The list view has two tabs &mdash; <strong>Profiles</strong> and <strong>Card snapshots</strong>. A card snapshot is an immutable capture of the card filesystem: for every existing file it stores the path, symbolic name, file type, size (or record length/count), the raw FCI from the SELECT response, and the contents whenever the file is readable (no ignore list, no masking). The ICCID is decoded from EF.ICCID and shown next to the snapshot name. The scan also measures each card command (SELECT, READ BINARY, READ RECORD) from command to response; the snapshot stores min/avg/max per command type and the total scan time, and the view shows these in the summary under the title plus the select/read times per file and the read time per record. Timings are informational only and are not used by checks or comparisons.</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li> <li><strong>New snapshot</strong> &mdash; asks for a name and scans the card, then returns to the list.</li>
<li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li> <li><strong>Import snapshot</strong> &mdash; loads a snapshot from a JSON file.</li>
<li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li> <li>Each snapshot row has <strong>Open</strong>, <strong>Export</strong>, and <strong>Delete</strong>. <strong>Open</strong> shows all captured data read-only (raw FCI with the decoded FCI, contents); only the snapshot name is editable.</li>
<li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check; files whose contents were not captured during the scan are reported as unverifiable errors.</li> <li><strong>Check card snapshot</strong> on a profile row runs the profile rules against a snapshot you pick from the list, without a card reader. The report is the same as a live check; files whose contents were not captured during the scan are reported as unverifiable errors.</li>
<li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li> <li><strong>Compare snapshots</strong> compares two snapshots offline, exactly like a profile check: pick the <em>master</em> snapshot and the <em>snapshot to check</em>, optionally masking the first 4 bytes of EF.IMSI/EF.ICCID (on by default), and get the same pass/fail report; in that report the mismatch fields and the FCI comparison columns are labeled with the master and checked snapshot names instead of expected/actual. Files present only in the checked snapshot are reported as extra files. Back to list returns to the Card snapshots tab.</li>
</ul> </ul>
<h3 id="usage-scenarios" class="text-lg font-medium mb-2">5.7 Usage scenarios</h3> </section>
<h4 id="scenario-a" class="font-medium mb-1">Scenario A &mdash; Working with files not in pySim&rsquo;s model (Custom files)</h4>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Obtain the FID of the target file (vendor documentation or ATR/file-system analysis; such files are often not in public specs).</li>
<li>Open the <strong>Card reader</strong> tab &rarr; <strong>Custom files</strong> sub-tab.</li>
<li>Enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>).</li>
<li>Click <strong>Add</strong> — the file appears in the tree in italics (unverified).</li>
<li>Click the file to verify existence; on success (<code class="font-mono text-sm">9000</code>) it behaves like a normal file.</li>
<li>Read, edit and save hex data; toggle Raw/Decoded views.</li>
<li>Export the custom-file list as JSON to share with other machines.</li>
</ol>
<h4 id="scenario-b" class="font-medium mb-1">Scenario B &mdash; Simulating a real network environment for SIM testing</h4>
<p class="text-sm mb-1"><strong>B.1 Answer PROVIDE LOCAL INFORMATION (PLI)</strong></p>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Open <strong>Proactive UICC</strong> &rarr; <strong>PROVIDE LOCAL INFORMATION response data</strong>.</li>
<li>Use the decode/encode forms to set IMEI (<code class="font-mono text-sm">01</code>), Location Info (<code class="font-mono text-sm">00</code>), Access Technology (<code class="font-mono text-sm">06</code>), etc.</li>
<li>Click <strong>Save</strong> — values persist server-side.</li>
<li>Enable <strong>Polling</strong> (interval 30&nbsp;s) so the card issues PLI periodically.</li>
<li>The server injects the dictionary values into each TERMINAL RESPONSE.</li>
<li>Verify in the proactive log: the PLI entry shows the decoded response.</li>
</ol>
<p class="text-sm mb-1"><strong>B.2 Simulate network actions via ENVELOPE (event download)</strong></p>
<ol class="list-decimal list-inside text-sm space-y-1 mb-3">
<li>Check the <strong>subscribed events</strong> list (from SET UP EVENT LIST).</li>
<li>Click <strong>Send</strong> on an event (e.g. Location Status) and fill the form; an <code class="font-mono text-sm">ENVELOPE(Event Download)</code> is sent.</li>
<li>For <strong>Network Rejection</strong>, select registration type &rarr; location fields &rarr; access technology &rarr; rejection cause.</li>
<li>The card may respond with a proactive command, which the chain handler logs and answers automatically.</li>
</ol>
<p class="text-sm mb-1"><strong>B.3 Verify the simulated environment</strong></p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li>The proactive command log shows the full round-trip (command + TERMINAL RESPONSE bytes).</li>
<li>The STATUS button / auto-polling keep the CAT session alive (drain loop).</li>
</ul>
<h4 id="custom-files" class="font-medium mb-1">Custom files</h4>
<p class="text-sm mb-3">Add files that pySim&rsquo;s model does not cover: enter the full path (e.g. <code class="font-mono text-sm">3F00/7F20/6F46</code>) and an alias (e.g. <code class="font-mono text-sm">EF.SPN</code>), then press <strong>Add</strong>; added files appear in the File manager tree. Each row has <strong>Edit</strong> (reloads the entry into the form — the button becomes <strong>Save</strong> and a <strong>Cancel</strong> button appears) and <strong>Delete</strong> (no confirmation) buttons. The list persists in <code class="font-mono text-sm">localStorage</code> and can be shared with <strong>Export as JSON</strong> / <strong>Export to file</strong> and restored with <strong>Import from file</strong> / <strong>Paste &amp; import</strong> / <strong>Import JSON from clipboard</strong>.</p>
<section class="mb-10"> <section class="mb-10">
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Server installation</h2> <h2 id="proactive-uicc" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">6. Phone simulator</h2>
<p class="mb-3">Live card operations (Card reader tab, Proactive UICC, OTA delivery) require the local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — a small HTTP server bundled with OTAMan that wraps pySim, talks to the reader over PC/SC or serial, and also serves the PWA itself (open <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p> <p class="text-sm mb-3">Interacts with the Card Application Toolkit session. The view has two pills: <strong>Phone</strong> (STK menu, STATUS and polling, subscribed events, proactive command log) and <strong>TR Config</strong> (response data injected into TERMINAL RESPONSEs for proactive commands).</p>
<h3 id="prerequisites" class="text-lg font-medium mb-2">6.1 Prerequisites</h3> <h3 id="stk-menu" class="text-lg font-medium mb-2">6.1 STK menu</h3>
<p class="text-sm mb-3">When the card has issued a SET UP MENU command, a &ldquo;STK menu&rdquo; block appears at the top of this view with an emerald <strong>STK: &lt;title&gt;</strong> button that opens the menu overlay (same as the card&rsquo;s STK menu browser). If the card has not set up a menu, the block shows &ldquo;No menu set by the card&rdquo; instead. The menu state is refreshed each time the view is opened. User-interactive proactive commands always get a TERMINAL RESPONSE: the overlay pauses for your choice, and if you neither answer nor press <strong>Timeout</strong>, the server answers with a timeout result after the <code class="font-mono text-sm">--menu-timeout</code> seconds (default 60, <code class="font-mono text-sm">0</code> disables). <strong>Back</strong> and <strong>Timeout</strong> keep the dialogue with the card going: when the card replies with a further proactive command (SELECT ITEM or DISPLAY TEXT) the panel shows it; the cached top menu appears only when the card has nothing more to execute.</p>
<h3 id="subscribed-events" class="text-lg font-medium mb-2">6.2 Subscribed events (SET UP EVENT LIST)</h3>
<p class="text-sm mb-2">The events the card monitors. Each event has a <strong>Send</strong> button that opens a form specific to the event type:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>No-data events</strong> (User Activity, Idle Screen, Data Available, &hellip;) — one-click notification</li>
<li><strong>Location Status</strong> — dropdown: Normal / Limited / No service (tag <code class="font-mono text-sm">9B</code>)</li>
<li><strong>Access Technology Change</strong> — 13 RAT types (tag <code class="font-mono text-sm">BF</code>)</li>
<li><strong>Network Rejection</strong> — full adaptive form: registration type (LU / GPRS / EPS / 5GS), location fields (MCC, MNC, LAC, RAC, TAC), access technology, and a 53-cause unified rejection cause dropdown covering EMM, GMM, 5GMM and LU causes</li>
</ul>
<p class="text-sm mb-3">Sending an event uses <code class="font-mono text-sm">ENVELOPE(Event Download)</code> per TS 102 223 / TS 131 111.</p>
<h3 id="proactive-log" class="text-lg font-medium mb-2">6.3 Proactive command log</h3>
<p class="text-sm mb-2">Chronological list of fetched proactive commands. Each row shows the elapsed time, type code, name, and a decoded qualifier (for commands that have one). Commands with response data show a <code class="font-mono text-sm">Response:</code> line with the TERMINAL RESPONSE bytes (boilerplate TLVs stripped); PROVIDE LOCAL INFORMATION responses are decoded using the PLI data dictionary decoders.</p>
<h3 id="status-polling" class="text-lg font-medium mb-2">6.4 STATUS polling</h3>
<p class="text-sm mb-3">A <strong>Send STATUS</strong> button issues a manual STATUS (F2). A <strong>Polling</strong> toggle enables background polling: after a configurable idle interval (server CLI <code class="font-mono text-sm">--poll-interval</code>, 1&ndash;255&nbsp;s, default 30&nbsp;s, <code class="font-mono text-sm">0</code> disables polling) the server sends STATUS and handles any pending proactive command. Polling stops and card state resets if the card is removed.</p>
<h3 id="pli-dict" class="text-lg font-medium mb-2">6.5 TR Config &mdash; PROVIDE LOCAL INFORMATION response data</h3>
<p class="text-sm mb-2">Editable hex values for all 22 PLI qualifiers (TS 102 223 &sect;8.6 + TS 131 111). Ten qualifiers have inline decode/encode forms:</p>
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>00</strong> Location Info (MCC, MNC, LAC/TAC, Cell ID)</li>
<li><strong>01</strong> IMEI &middot; <strong>03</strong> Date/Time/TZ &middot; <strong>04</strong> Language &middot; <strong>05</strong> Timing Advance</li>
<li><strong>06</strong> Access Technology &middot; <strong>08</strong> IMEISV &middot; <strong>09</strong> Search Mode</li>
<li><strong>0A</strong> Battery &middot; <strong>0E</strong> Multiple Access Technologies</li>
</ul>
<p class="text-sm mb-3">Values persist server-side until restart. When the card issues PLI, the server injects the dictionary values into the TERMINAL RESPONSE.</p>
</section>
<section class="mb-10">
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Server installation</h2>
<p class="mb-3">Live card operations (Card reader tab, Phone simulator, OTA delivery) require the local <a href="https://github.com/anttro/otaman" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-otaman-server</a> — a small HTTP server bundled with OTAMan that wraps pySim, talks to the reader over PC/SC or serial, and also serves the PWA itself (open <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
<h3 id="prerequisites" class="text-lg font-medium mb-2">7.1 Prerequisites</h3>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>Python 3.8+</strong> with <code class="font-mono text-sm">pip</code></li> <li><strong>Python 3.8+</strong> with <code class="font-mono text-sm">pip</code></li>
<li><strong>Git</strong></li> <li><strong>Git</strong></li>
@@ -444,20 +455,20 @@
<li><strong>Windows only</strong> — use <strong>Python 3.10&ndash;3.13</strong> (3.13 recommended): <code class="font-mono text-sm">pyscard</code> (the PC/SC driver wrapper) ships precompiled wheels for these versions. On Python 3.9 / 3.14 pip builds <code class="font-mono text-sm">pyscard</code> from source, which requires Microsoft C++ Build Tools (&ldquo;Desktop development with C++&rdquo;). The SMPP bridge (<code class="font-mono text-sm">smpp.twisted3</code>) is intentionally not installed on Windows, so no C++ Build Tools are needed for Python 3.10&ndash;3.13.</li> <li><strong>Windows only</strong> — use <strong>Python 3.10&ndash;3.13</strong> (3.13 recommended): <code class="font-mono text-sm">pyscard</code> (the PC/SC driver wrapper) ships precompiled wheels for these versions. On Python 3.9 / 3.14 pip builds <code class="font-mono text-sm">pyscard</code> from source, which requires Microsoft C++ Build Tools (&ldquo;Desktop development with C++&rdquo;). The SMPP bridge (<code class="font-mono text-sm">smpp.twisted3</code>) is intentionally not installed on Windows, so no C++ Build Tools are needed for Python 3.10&ndash;3.13.</li>
</ul> </ul>
<h3 id="quickstart-linux" class="text-lg font-medium mb-2">6.2 Quick start — Linux / macOS</h3> <h3 id="quickstart-linux" class="text-lg font-medium mb-2">7.2 Quick start — Linux / macOS</h3>
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git <pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
cd otaman cd otaman
chmod +x setup.sh start.sh chmod +x setup.sh start.sh
./setup.sh # creates .venv, installs pysim + server (run once) ./setup.sh # creates .venv, installs pysim + server (run once)
./start.sh # starts the server (serves PWA + API, auto-detects reader)</pre> ./start.sh # starts the server (serves PWA + API, auto-detects reader)</pre>
<h3 id="quickstart-windows" class="text-lg font-medium mb-2">6.3 Quick start — Windows</h3> <h3 id="quickstart-windows" class="text-lg font-medium mb-2">7.3 Quick start — Windows</h3>
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git <pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3">git clone https://github.com/anttro/otaman.git
cd otaman cd otaman
setup.bat # creates .venv, installs pysim + server (run once) setup.bat # creates .venv, installs pysim + server (run once)
start.bat # starts the server (serves PWA + API)</pre> start.bat # starts the server (serves PWA + API)</pre>
<h3 id="helper-scripts" class="text-lg font-medium mb-2">6.4 Helper scripts</h3> <h3 id="helper-scripts" class="text-lg font-medium mb-2">7.4 Helper scripts</h3>
<table class="w-full text-sm mb-3 border-collapse"> <table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Script</th><th class="text-left py-1 px-2">Purpose</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Script</th><th class="text-left py-1 px-2">Purpose</th></tr></thead>
<tbody> <tbody>
@@ -466,7 +477,7 @@ start.bat # starts the server (serves PWA + API)</pre>
</tbody> </tbody>
</table> </table>
<h3 id="reader-autodetect" class="text-lg font-medium mb-2">6.5 Reader auto-detection</h3> <h3 id="reader-autodetect" class="text-lg font-medium mb-2">7.5 Reader auto-detection</h3>
<ul class="list-disc list-inside text-sm space-y-1 mb-3"> <ul class="list-disc list-inside text-sm space-y-1 mb-3">
<li><strong>PC/SC (Linux)</strong><code class="font-mono text-sm">start.sh</code> passes <code class="font-mono text-sm">-p 0</code> when the <code class="font-mono text-sm">pcscd</code> daemon is running</li> <li><strong>PC/SC (Linux)</strong><code class="font-mono text-sm">start.sh</code> passes <code class="font-mono text-sm">-p 0</code> when the <code class="font-mono text-sm">pcscd</code> daemon is running</li>
<li><strong>PC/SC (Windows)</strong><code class="font-mono text-sm">start.bat</code> always uses <code class="font-mono text-sm">-p 0</code> (PC/SC is built into Windows)</li> <li><strong>PC/SC (Windows)</strong><code class="font-mono text-sm">start.bat</code> always uses <code class="font-mono text-sm">-p 0</code> (PC/SC is built into Windows)</li>
@@ -475,7 +486,7 @@ start.bat # starts the server (serves PWA + API)</pre>
</ul> </ul>
<p class="text-sm mb-3">If no card is present, the Card reader tab shows &ldquo;No card detected&rdquo;. Insert the card and click <strong>Equip card</strong> to initialize it.</p> <p class="text-sm mb-3">If no card is present, the Card reader tab shows &ldquo;No card detected&rdquo;. Insert the card and click <strong>Equip card</strong> to initialize it.</p>
<h3 id="manual-install" class="text-lg font-medium mb-2">6.6 Manual installation</h3> <h3 id="manual-install" class="text-lg font-medium mb-2">7.6 Manual installation</h3>
<pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Create and activate a venv <pre class="font-mono text-xs bg-gray-100 dark:bg-slate-800 rounded p-3 mb-3"># Create and activate a venv
python3 -m venv .venv python3 -m venv .venv
source .venv/bin/activate # Linux/macOS source .venv/bin/activate # Linux/macOS
@@ -494,7 +505,7 @@ pysim-otaman-server --http-port 8080</pre>
<section class="mb-10"> <section class="mb-10">
<h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">7. Version compatibility</h2> <h2 id="compatibility" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">8. Version compatibility</h2>
<table class="w-full text-sm mb-3 border-collapse"> <table class="w-full text-sm mb-3 border-collapse">
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">PWA (OTAMan)</th><th class="text-left py-1 px-2">Server</th><th class="text-left py-1 px-2">Status</th></tr></thead> <thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">PWA (OTAMan)</th><th class="text-left py-1 px-2">Server</th><th class="text-left py-1 px-2">Status</th></tr></thead>
<tbody> <tbody>
+929 -377
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -4,8 +4,8 @@
"description": "Standalone offline HTML/JS tool for building APDU commands for SIM, USIM, and GlobalPlatform RAM, plus encoding conversions.", "description": "Standalone offline HTML/JS tool for building APDU commands for SIM, USIM, and GlobalPlatform RAM, plus encoding conversions.",
"main": "index.js", "main": "index.js",
"scripts": { "scripts": {
"build": "npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js", "build": "npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js && cat src/contrast.css >> style.css",
"build:prod": "NODE_ENV=production npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js --minify", "build:prod": "NODE_ENV=production npx tailwindcss -i src/style.css -o style.css --config tailwind.config.js --minify && cat src/contrast.css >> style.css",
"test": "node --test" "test": "node --test"
}, },
"repository": { "repository": {
+112
View File
@@ -0,0 +1,112 @@
/* ===== Theme contrast adjustments ===== */
/* Light theme: darken softer (muted) text one step for higher contrast */
.text-gray-400 {
color: rgb(107 114 128 / var(--tw-text-opacity, 1));
}
.text-gray-500 {
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
}
.text-gray-600 {
color: rgb(55 65 81 / var(--tw-text-opacity, 1));
}
/* Dark theme: lighten softer (muted) text one step for higher contrast */
.dark\:text-slate-500:is(.dark *) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
.dark\:text-slate-400:is(.dark *) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
/* Dark theme: red text is too dark on dark backgrounds — lighten it */
.dark :where(.text-red-500) {
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
}
.dark :where(.text-red-600) {
color: rgb(248 113 113 / var(--tw-text-opacity, 1));
}
.dark :where(.text-red-700) {
color: rgb(239 68 68 / var(--tw-text-opacity, 1));
}
/* Light theme: darken light gray shades (backgrounds & borders) one step */
.bg-gray-50 {
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
}
.bg-gray-100 {
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
}
.bg-gray-200 {
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
}
.border-gray-100 {
border-color: rgb(229 231 235 / var(--tw-border-opacity, 1));
}
.border-gray-200 {
border-color: rgb(209 213 219 / var(--tw-border-opacity, 1));
}
.border-gray-300 {
border-color: rgb(156 163 175 / var(--tw-border-opacity, 1));
}
.text-gray-300 {
color: rgb(156 163 175 / var(--tw-text-opacity, 1));
}
.hover\:bg-gray-100:hover {
background-color: rgb(229 231 235 / var(--tw-bg-opacity, 1));
}
.hover\:bg-gray-200:hover {
background-color: rgb(209 213 219 / var(--tw-bg-opacity, 1));
}
.hover\:bg-gray-300:hover {
background-color: rgb(156 163 175 / var(--tw-bg-opacity, 1));
}
/* Dark theme: lighten gray fonts */
.dark\:text-gray-400:is(.dark *) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
.dark\:text-gray-600:is(.dark *) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
/* Dark theme: gray text without an explicit dark variant (ids, timestamps,
expand markers, ...) — match the lighten-on-dark level used above */
.dark .text-gray-300:not([class*="dark:text-"]) {
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
}
.dark .text-gray-400:not([class*="dark:text-"]) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
.dark .text-gray-500:not([class*="dark:text-"]) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
.dark .text-gray-600:not([class*="dark:text-"]) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
.dark .text-gray-700:not([class*="dark:text-"]) {
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
}
.dark .text-gray-800:not([class*="dark:text-"]) {
color: rgb(241 245 249 / var(--tw-text-opacity, 1));
}
/* Dark theme: brighten gray borders one step so they stay visible on dark
backgrounds */
.dark\:border-slate-600:is(.dark *) {
border-color: rgb(100 116 139 / var(--tw-border-opacity, 1));
}
.dark\:border-slate-700:is(.dark *) {
border-color: rgb(71 85 105 / var(--tw-border-opacity, 1));
}
.dark\:border-slate-700\/50:is(.dark *) {
border-color: rgb(71 85 105 / 0.5);
}
.dark\:border-slate-800:is(.dark *) {
border-color: rgb(51 65 85 / var(--tw-border-opacity, 1));
}
/* Dark theme: normal (non-muted) UI text — keep one step brighter than the
muted gray level so the two remain distinguishable */
.dark\:text-slate-300:is(.dark *) {
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
}
+44 -3
View File
@@ -1702,7 +1702,7 @@ video {
/* Dark theme: lighten softer (muted) text one step for higher contrast */ /* Dark theme: lighten softer (muted) text one step for higher contrast */
.dark\:text-slate-500:is(.dark *) { .dark\:text-slate-500:is(.dark *) {
color: rgb(148 163 184 / var(--tw-text-opacity, 1)); color: rgb(203 213 225 / var(--tw-text-opacity, 1));
} }
.dark\:text-slate-400:is(.dark *) { .dark\:text-slate-400:is(.dark *) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1)); color: rgb(203 213 225 / var(--tw-text-opacity, 1));
@@ -1753,8 +1753,49 @@ video {
/* Dark theme: lighten gray fonts */ /* Dark theme: lighten gray fonts */
.dark\:text-gray-400:is(.dark *) { .dark\:text-gray-400:is(.dark *) {
color: rgb(156 163 175 / var(--tw-text-opacity, 1)); color: rgb(203 213 225 / var(--tw-text-opacity, 1));
} }
.dark\:text-gray-600:is(.dark *) { .dark\:text-gray-600:is(.dark *) {
color: rgb(156 163 175 / var(--tw-text-opacity, 1)); color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
/* Dark theme: gray text without an explicit dark variant (ids, timestamps,
expand markers, ...) — match the lighten-on-dark level used above */
.dark .text-gray-300:not([class*="dark:text-"]) {
color: rgb(148 163 184 / var(--tw-text-opacity, 1));
}
.dark .text-gray-400:not([class*="dark:text-"]) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
.dark .text-gray-500:not([class*="dark:text-"]) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
.dark .text-gray-600:not([class*="dark:text-"]) {
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
}
.dark .text-gray-700:not([class*="dark:text-"]) {
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
}
.dark .text-gray-800:not([class*="dark:text-"]) {
color: rgb(241 245 249 / var(--tw-text-opacity, 1));
}
/* Dark theme: brighten gray borders one step so they stay visible on dark
backgrounds */
.dark\:border-slate-600:is(.dark *) {
border-color: rgb(100 116 139 / var(--tw-border-opacity, 1));
}
.dark\:border-slate-700:is(.dark *) {
border-color: rgb(71 85 105 / var(--tw-border-opacity, 1));
}
.dark\:border-slate-700\/50:is(.dark *) {
border-color: rgb(71 85 105 / 0.5);
}
.dark\:border-slate-800:is(.dark *) {
border-color: rgb(51 65 85 / var(--tw-border-opacity, 1));
}
/* Dark theme: normal (non-muted) UI text — keep one step brighter than the
muted gray level so the two remain distinguishable */
.dark\:text-slate-300:is(.dark *) {
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
} }
+18 -4
View File
@@ -1,4 +1,4 @@
const CACHE = 'otaman-v92'; const CACHE = 'otaman-v128';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
@@ -31,19 +31,33 @@ self.addEventListener('activate', e => {
); );
}); });
const OFFLINE_RESPONSE = new Response('Offline: page not cached', {
status: 503,
statusText: 'Offline',
headers: { 'Content-Type': 'text/plain' },
});
self.addEventListener('fetch', e => { self.addEventListener('fetch', e => {
if (!e.request.url.startsWith('http')) return; if (!e.request.url.startsWith('http')) return;
if (new URL(e.request.url).pathname.startsWith('/api/')) return; // live data, never cache const path = new URL(e.request.url).pathname;
if (path.startsWith('/api/')) return; // live data, never cache
if (e.request.method !== 'GET') return; if (e.request.method !== 'GET') return;
const isNavigate = e.request.mode === 'navigate' || e.request.url.endsWith('sw.js'); const isNavigate = e.request.mode === 'navigate';
const isSwScript = path.endsWith('/sw.js');
if (isNavigate) { if (isNavigate) {
e.respondWith( e.respondWith(
fetch(e.request).then(res => { fetch(e.request).then(res => {
const clone = res.clone(); const clone = res.clone();
caches.open(CACHE).then(c => c.put(e.request, clone)); caches.open(CACHE).then(c => c.put(e.request, clone));
return res; return res;
}).catch(() => caches.match(e.request)) }).catch(() =>
caches.match(e.request)
.then(r => r || caches.match('index.html'))
.then(r => r || OFFLINE_RESPONSE)
)
); );
} else if (isSwScript) {
e.respondWith(fetch(e.request).catch(() => OFFLINE_RESPONSE));
} else { } else {
e.respondWith( e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request).then(res => { caches.match(e.request).then(r => r || fetch(e.request).then(res => {
+73 -3
View File
@@ -21,20 +21,30 @@ function extractFunc(src, name) {
return src.slice(m.index, i + 1); return src.slice(m.index, i + 1);
} }
let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'; let code = 'var _pysimCardStateKey = null;\nvar _pysimCardSession = null;\n'
+ 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\n'
+ 'var _pysimProactiveSeq = null;\nvar _pysimStkSig = null;\n';
code += extractFunc(html, 'pysimCardStateUpdate') + '\n'; code += extractFunc(html, 'pysimCardStateUpdate') + '\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimControlDisabled') + '\n';
code += extractFunc(html, 'pysimProactiveSeqChanged') + '\n';
code += extractFunc(html, 'pysimStkStatusChanged') + '\n';
code += '\nglobalThis.esc = s => s;\n'; code += '\nglobalThis.esc = s => s;\n';
code += 'globalThis.t = s => s;\n'; code += 'globalThis.t = s => s;\n';
eval(code); eval(code);
function setup() { function setup() {
const el = { textContent: 'status line', innerHTML: '' }; const el = { textContent: 'status line', innerHTML: '' };
const calls = { connected: [], resets: [], refreshStatus: [] }; const calls = { connected: [], resets: [], refreshStatus: [], proactive: 0 };
_pysimCardStateKey = null; _pysimCardStateKey = null;
_pysimCardSession = null; _pysimCardSession = null;
globalThis.document = { getElementById: () => el }; _pysimProactiveSeq = null;
globalThis.document = { getElementById: () => el, querySelectorAll: () => [] };
globalThis.pysimSetConnected = v => calls.connected.push(v); globalThis.pysimSetConnected = v => calls.connected.push(v);
globalThis.pysimResetCardData = refresh => calls.resets.push(refresh); globalThis.pysimResetCardData = refresh => calls.resets.push(refresh);
globalThis.pysimApplyAvailability = () => {};
globalThis.isViewVisible = () => true;
globalThis.pysimProactiveLogRender = () => { calls.proactive++; };
return { el, calls }; return { el, calls };
} }
@@ -98,3 +108,63 @@ test('payload without connected flag is ignored', () => {
pysimCardStateUpdate(null); pysimCardStateUpdate(null);
assert.deepStrictEqual(calls.connected, []); assert.deepStrictEqual(calls.connected, []);
}); });
test('availability state and control gating follow server/card state', () => {
_pysimServerAvailable = null;
assert.strictEqual(pysimAvailabilityState(), 'server-down');
assert.strictEqual(pysimControlDisabled('server', 'server-down'), true);
assert.strictEqual(pysimControlDisabled('card', 'server-down'), true);
_pysimServerAvailable = true;
_pysimCardEquipped = false;
assert.strictEqual(pysimAvailabilityState(), 'no-card');
assert.strictEqual(pysimControlDisabled('server', 'no-card'), false);
assert.strictEqual(pysimControlDisabled('card', 'no-card'), true);
_pysimCardEquipped = true;
assert.strictEqual(pysimAvailabilityState(), 'card');
assert.strictEqual(pysimControlDisabled('card', 'card'), false);
assert.strictEqual(pysimControlDisabled('server', 'card'), false);
});
test('no card with auto-equip enabled still shows the no-card message', () => {
const { el } = setup();
pysimCardStateUpdate(status({ connected: false, card_present: false, auto_equip: true }));
assert.ok(el.innerHTML.includes('No card detected'), el.innerHTML);
assert.ok(!el.innerHTML.includes('initializing'), el.innerHTML);
});
test('proactive log refreshes when the status sequence changes', () => {
const { calls } = setup();
pysimCardStateUpdate(status({ proactive_seq: 7 }));
pysimCardStateUpdate(status({ proactive_seq: 7 }));
assert.strictEqual(calls.proactive, 1);
pysimCardStateUpdate(status({ proactive_seq: 8 }));
assert.strictEqual(calls.proactive, 2);
});
test('proactive log is not refreshed while the phone view is hidden', () => {
const { calls } = setup();
globalThis.isViewVisible = () => false;
pysimCardStateUpdate(status({ proactive_seq: 3 }));
assert.strictEqual(calls.proactive, 0);
});
test('pysimProactiveSeqChanged tracks the last sequence', () => {
_pysimProactiveSeq = null;
assert.ok(pysimProactiveSeqChanged(4));
assert.ok(!pysimProactiveSeqChanged(4));
assert.ok(pysimProactiveSeqChanged(5));
assert.ok(!pysimProactiveSeqChanged(undefined));
assert.ok(!pysimProactiveSeqChanged(null));
});
test('pysimStkStatusChanged detects menu state transitions', () => {
_pysimStkSig = null;
assert.ok(pysimStkStatusChanged({ active: false, pending: false }));
assert.ok(!pysimStkStatusChanged({ active: false, pending: false }));
assert.ok(pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
assert.ok(!pysimStkStatusChanged({ active: true, pending: true, pending_type: 'select_item' }));
assert.ok(pysimStkStatusChanged({ active: true, pending: false }));
assert.ok(!pysimStkStatusChanged(null));
});
+165
View File
@@ -0,0 +1,165 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimCustomFiles = [];\nvar pysimCustomEditIndex = null;\n';
for (const fn of ['pysimCustomSave', 'pysimCustomSubmit', 'pysimCustomEdit', 'pysimCustomEditCancel', 'pysimCustomRemove', 'pysimCustomRender']) {
code += extractFunc(html, fn) + '\n';
}
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
eval(code);
function fakeEl(id) {
const classes = new Set();
return {
id,
value: '',
innerHTML: '',
textContent: '',
attrs: {},
focused: 0,
classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
},
setAttribute(k, v) { this.attrs[k] = v; },
focus() { this.focused++; },
};
}
function setup(entries) {
const els = {
'pysim-cf-path': fakeEl('pysim-cf-path'),
'pysim-cf-name': fakeEl('pysim-cf-name'),
'pysim-cf-list': fakeEl('pysim-cf-list'),
'pysim-cf-add-btn': fakeEl('pysim-cf-add-btn'),
'pysim-cf-cancel-btn': fakeEl('pysim-cf-cancel-btn'),
};
els['pysim-cf-cancel-btn'].classList.add('hidden');
const store = {};
globalThis.localStorage = {
getItem: k => (k in store ? store[k] : null),
setItem: (k, v) => { store[k] = String(v); },
};
globalThis.document = { getElementById: id => els[id] || null };
globalThis.alertCalls = [];
globalThis.alert = m => { globalThis.alertCalls.push(m); };
pysimCustomFiles = (entries || []).map(e => Object.assign({}, e));
pysimCustomEditIndex = null;
return els;
}
const entry = (fid, name, parentFid) => ({ path: (parentFid || '3F00') + '/' + fid, name, fid, parentFid: parentFid || '3F00' });
test('rows render Edit and Delete buttons instead of the X glyph', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomRender();
const out = els['pysim-cf-list'].innerHTML;
assert.match(out, /pysimCustomEdit\(0\)/);
assert.match(out, /pysimCustomRemove\(0\)/);
assert.match(out, />Edit</);
assert.match(out, />Delete</);
assert.ok(!out.includes('✕'), 'old X glyph must be gone');
});
test('edit fills the top form and switches it to Save mode', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomEdit(0);
assert.strictEqual(pysimCustomEditIndex, 0);
assert.strictEqual(els['pysim-cf-path'].value, '3F00/6F46');
assert.strictEqual(els['pysim-cf-name'].value, 'EF.SPN');
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Save');
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Save');
assert.ok(!els['pysim-cf-cancel-btn'].classList.contains('hidden'));
assert.strictEqual(els['pysim-cf-path'].focused, 1);
});
test('submit in edit mode updates the entry in place', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomEdit(0);
els['pysim-cf-path'].value = '3F00/7F20/6F46';
els['pysim-cf-name'].value = 'EF.SPNX';
pysimCustomSubmit();
assert.strictEqual(pysimCustomFiles.length, 1);
assert.deepStrictEqual(pysimCustomFiles[0], { path: '3F00/7F20/6F46', name: 'EF.SPNX', fid: '6F46', parentFid: '7F20' });
assert.strictEqual(pysimCustomEditIndex, null);
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
});
test('saving an edited entry with its own path is not a duplicate', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomEdit(0);
pysimCustomSubmit();
assert.strictEqual(pysimCustomFiles.length, 1);
assert.deepStrictEqual(globalThis.alertCalls, []);
});
test('editing to another entry path is rejected as duplicate', () => {
const els = setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2')]);
pysimCustomEdit(0);
els['pysim-cf-path'].value = '3F00/6F44';
pysimCustomSubmit();
assert.deepStrictEqual(globalThis.alertCalls, ['Path already exists']);
assert.deepStrictEqual(pysimCustomFiles.map(c => c.path), ['3F00/6F46', '3F00/6F44']);
assert.strictEqual(pysimCustomEditIndex, 0);
});
test('cancel restores the Add mode and clears the inputs', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomEdit(0);
pysimCustomEditCancel();
assert.strictEqual(pysimCustomEditIndex, null);
assert.strictEqual(els['pysim-cf-path'].value, '');
assert.strictEqual(els['pysim-cf-name'].value, '');
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
assert.strictEqual(els['pysim-cf-add-btn'].attrs['data-l10n'], 'Add');
assert.ok(els['pysim-cf-cancel-btn'].classList.contains('hidden'));
});
test('deleting the entry being edited cancels the edit', () => {
const els = setup([entry('6F46', 'EF.SPN')]);
pysimCustomEdit(0);
pysimCustomRemove(0);
assert.deepStrictEqual(pysimCustomFiles, []);
assert.strictEqual(pysimCustomEditIndex, null);
assert.strictEqual(els['pysim-cf-add-btn'].textContent, 'Add');
});
test('deleting before the edited row shifts the edit index', () => {
setup([entry('6F46', 'EF.SPN'), entry('6F44', 'EF.SPN2'), entry('6F42', 'EF.SPN3')]);
pysimCustomEdit(2);
pysimCustomRemove(0);
assert.strictEqual(pysimCustomEditIndex, 1);
assert.strictEqual(pysimCustomFiles[1].name, 'EF.SPN3');
});
test('submit adds a new entry when not editing', () => {
const els = setup([]);
els['pysim-cf-path'].value = '3f00/6f46';
els['pysim-cf-name'].value = 'EF.SPN';
pysimCustomSubmit();
assert.deepStrictEqual(pysimCustomFiles, [{ path: '3F00/6F46', name: 'EF.SPN', fid: '6F46', parentFid: '3F00' }]);
assert.strictEqual(els['pysim-cf-path'].value, '');
assert.strictEqual(els['pysim-cf-name'].value, '');
});
+103
View File
@@ -0,0 +1,103 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = '';
code += extractFunc(html, 'getParentSel') + '\n';
code += extractFunc(html, 'getParentPath') + '\n';
code += extractFunc(html, 'pysimFsLoadChildren') + '\n';
code += 'globalThis.pysimCustomInject = () => {};\n';
eval(code);
let calls = [];
let responses = [];
let renders = 0;
function setup() {
calls = [];
responses = [];
renders = 0;
globalThis.pysimFetch = async (p, body) => {
calls.push({ path: p, body: JSON.parse(JSON.stringify(body)) });
const r = responses.shift();
if (r instanceof Error) throw r;
return JSON.parse(JSON.stringify(r));
};
globalThis.pysimFsRenderTree = () => { renders++; };
const node = { name: 'DF.USIM', fid: '7fff', isDir: true, children: null, exists: null, parent: { name: 'MF', fid: '3f00' } };
return node;
}
test('tree error payload marks the directory as absent', async () => {
const node = setup();
responses = [
{ success: false, error: 'SW ... 6a82', exists: false },
{ success: false, error: 'SW ... 6a82', exists: false },
];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, false);
assert.strictEqual(node.children, null);
assert.strictEqual(renders, 1);
assert.strictEqual(calls.length, 1);
assert.strictEqual(calls[0].body.parent_sel, 'MF');
assert.deepStrictEqual(calls[0].body.parent_path, ['MF']);
});
test('error payload without exists is not treated as an empty listing', async () => {
const node = setup();
responses = [
{ success: false, error: 'boom' },
{ success: false, error: 'boom' },
];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, false);
assert.strictEqual(node.children, null);
assert.strictEqual(renders, 1);
assert.strictEqual(calls.length, 1);
});
test('a 200 exists:false response marks the directory absent without retrying', async () => {
const node = setup();
responses = [{ exists: false }];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, false);
assert.strictEqual(node.children, null);
assert.strictEqual(calls.length, 1);
});
test('a node with loaded children is not fetched again', async () => {
const node = setup();
node.exists = true;
node.children = [{ name: 'EF.UPLMNWLAN', fid: '4f42', isDir: false, exists: true }];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, true);
assert.strictEqual(node.children.length, 1);
assert.strictEqual(calls.length, 0);
});
test('empty successful listing keeps the directory present', async () => {
const node = setup();
responses = [{ exists: true, children: [] }];
await pysimFsLoadChildren(node);
assert.strictEqual(node.exists, true);
assert.deepStrictEqual(node.children, []);
});
+156
View File
@@ -0,0 +1,156 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimFsTreeRoot = null;\nvar _pysimFsProbe = null;\nvar pysimFsSort = "fid";\n';
for (const fn of ['getParentSel', 'getParentPath', 'pysimFsSortChildren', 'pysimFsLoadChildren', 'pysimFsSelectBody', 'pysimFsProbeUi', 'pysimFsProbeAll']) {
code += extractFunc(html, fn) + '\n';
}
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\nglobalThis.pysimCustomInject = () => {};\n';
eval(code);
function fakeEl() {
const classes = new Set();
return {
textContent: '',
attrs: {},
classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
toggle: (c, on) => { if (on === undefined ? !classes.has(c) : on) classes.add(c); else classes.delete(c); },
},
setAttribute(k, v) { this.attrs[k] = v; },
};
}
let els = {};
let calls = [];
function setup(routes) {
els = { 'pysim-fs-probe-status': fakeEl(), 'pysim-fs-probe-btn': fakeEl() };
calls = [];
globalThis.document = { getElementById: id => els[id] || null };
globalThis.pysimFetch = async (p, body) => {
calls.push({ path: p, body: body || {} });
for (const r of routes) {
if (r.path !== p) continue;
if (r.name && (!body || body.name !== r.name)) continue;
return typeof r.reply === 'function' ? r.reply(body, calls) : JSON.parse(JSON.stringify(r.reply));
}
throw new Error('unexpected fetch ' + p + ' ' + JSON.stringify(body));
};
globalThis.pysimFsRenderTree = () => {};
}
function root(children) {
pysimFsTreeRoot = { name: 'MF', fid: '3f00', isDir: true, expanded: true, exists: true, children: null, parent: null };
pysimFsTreeRoot.children = children.map(c => Object.assign({ children: null, expanded: false, exists: true, parent: pysimFsTreeRoot }, c));
return pysimFsTreeRoot;
}
const df = (name, fid) => ({ name, fid, isDir: true });
const ef = (name, fid) => ({ name, fid, isDir: false });
const selectNames = () => calls.filter(c => c.path === '/api/select').map(c => c.body.name);
test('probes dirs and files, including custom entries, and reports counts', async () => {
root([df('DF.A', '5f01'), ef('EF.ROOT', '2f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
let sawStop = null;
setup([
{ path: '/api/tree', name: 'DF.A', reply: { exists: true, children: [{ name: 'EF.1', fid: '6f01', isDir: false }] } },
{ path: '/api/select', name: 'EF.1', reply: () => { sawStop = els['pysim-fs-probe-btn'].textContent; return { exists: true }; } },
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
{ path: '/api/select', name: 'EF.CUSTOM', reply: { exists: true } },
]);
await pysimFsProbeAll();
assert.strictEqual(sawStop, 'Stop');
assert.deepStrictEqual(selectNames(), ['EF.1', 'EF.ROOT', 'EF.CUSTOM']);
assert.strictEqual(pysimFsTreeRoot.children[0].children[0].exists, true);
assert.strictEqual(pysimFsTreeRoot.children[1].exists, false);
assert.strictEqual(pysimFsTreeRoot.children[2].exists, true);
const status = els['pysim-fs-probe-status'].textContent;
assert.match(status, /4\/4 files/);
assert.match(status, /3 present/);
assert.match(status, /1 absent/);
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
assert.strictEqual(els['pysim-fs-probe-btn'].attrs['data-l10n'], 'Probe all files');
});
test('an absent directory is marked and its subtree is never fetched', async () => {
root([df('DF.B', '5f02'), ef('EF.ROOT', '2f01')]);
setup([
{ path: '/api/tree', name: 'DF.B', reply: { success: false, error: 'SW 6a82', exists: false } },
{ path: '/api/select', name: 'EF.ROOT', reply: { error: 'SW 6a82', exists: false } },
]);
await pysimFsProbeAll();
assert.strictEqual(pysimFsTreeRoot.children[0].exists, false);
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
assert.strictEqual(calls.filter(c => c.path === '/api/tree' && c.body.name === 'DF.B').length, 1);
const status = els['pysim-fs-probe-status'].textContent;
assert.match(status, /2\/2 files/);
assert.match(status, /0 present/);
assert.match(status, /2 absent/);
});
test('stop halts the walk and still reports a summary', async () => {
root([ef('EF.X', '6f01'), ef('EF.Y', '6f02')]);
setup([
{ path: '/api/select', name: 'EF.X', reply: () => { _pysimFsProbe.stop = true; return { exists: true }; } },
]);
await pysimFsProbeAll();
assert.deepStrictEqual(selectNames(), ['EF.X']);
const status = els['pysim-fs-probe-status'].textContent;
assert.ok(status.startsWith('Stopped —'), status);
assert.strictEqual(els['pysim-fs-probe-btn'].textContent, 'Probe all files');
});
test('select bodies carry the parent path and set allow_probe only for custom files', async () => {
root([df('DF.A', '5f01'), Object.assign(ef('EF.CUSTOM', '6fcc'), { custom: true })]);
const body = pysimFsSelectBody(pysimFsTreeRoot.children[0]);
assert.deepStrictEqual(body.parent_path, ['MF']);
assert.strictEqual(body.parent_sel, 'MF');
assert.ok(!body.allow_probe);
const custom = pysimFsSelectBody(pysimFsTreeRoot.children[1]);
assert.deepStrictEqual(custom.parent_path, ['MF']);
assert.strictEqual(custom.allow_probe, true);
const nested = { name: 'EF.1', fid: '6f01', isDir: false, parent: pysimFsTreeRoot.children[0] };
assert.deepStrictEqual(pysimFsSelectBody(nested).parent_path, ['MF', '5f01']);
});
test('children of an absent directory are neither fetched nor selected', async () => {
const stale = Object.assign(df('DF.C', '5f03'), {
exists: false,
children: [{ name: 'EF.STALE', fid: '6f0e', isDir: false, exists: true, children: null }],
});
root([stale, ef('EF.ROOT', '2f01')]);
setup([
{ path: '/api/select', name: 'EF.ROOT', reply: { exists: true } },
]);
await pysimFsProbeAll();
assert.deepStrictEqual(selectNames(), ['EF.ROOT']);
assert.strictEqual(stale.exists, false);
assert.strictEqual(calls.filter(c => c.path === '/api/tree').length, 0);
const status = els['pysim-fs-probe-status'].textContent;
assert.match(status, /2\/2 files/);
assert.match(status, /1 present/);
assert.match(status, /1 absent/);
});
+67
View File
@@ -0,0 +1,67 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var pysimFsSort = "fid";\n';
code += extractFunc(html, 'pysimFsSortChildren') + '\n';
code += extractFunc(html, 'pysimFsRenderNode') + '\n';
code += 'globalThis.esc = s => s;\nglobalThis.t = s => s;\n';
eval(code);
const ef = (name, fid) => ({ name, fid, isDir: false, exists: true, children: null, expanded: false });
const df = (name, fid, extra) => Object.assign({ name, fid, isDir: true, exists: true, children: null, expanded: false }, extra || {});
test('absent directory renders a cross and no expand toggle', () => {
const node = df('DF.WLAN', '5f40', { exists: false, children: [] });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('✗'), out);
assert.ok(!out.includes('pysimFsToggleDir'), out);
assert.ok(!out.includes('▶'), out);
});
test('expanded empty directory shows the (empty) placeholder', () => {
const node = df('DF.EMPTY', '5f00', { children: [], expanded: true });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('(empty)'), out);
});
test('expanded directory with children renders no placeholder', () => {
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: true });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('EF.IMSI'), out);
assert.ok(!out.includes('(empty)'), out);
});
test('collapsed directory hides its children', () => {
const node = df('DF.GSM', '7f20', { children: [ef('EF.IMSI', '6f07')], expanded: false });
const out = pysimFsRenderNode(node, 0);
assert.ok(!out.includes('EF.IMSI'), out);
});
test('non-existing directory hides previously loaded children', () => {
const node = df('DF.WLAN', '5f40', { exists: false, expanded: true, children: [ef('EF.UPLMNWLAN', '4f42')] });
const out = pysimFsRenderNode(node, 0);
assert.ok(out.includes('✗'), out);
assert.ok(!out.includes('EF.UPLMNWLAN'), out);
assert.ok(!out.includes('(empty)'), out);
assert.ok(!out.includes('pysimFsToggleDir'), out);
});
+66
View File
@@ -0,0 +1,66 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
eval(extractFunc(html, 'pysimFsSortChildren'));
const f = (fid, name) => ({ fid, name, isDir: false });
const d = (fid, name) => ({ fid, name, isDir: true });
test('DFs sort before EFs in both modes', () => {
const children = [f('6F07', 'EF.IMSI'), d('7F20', 'DF.GSM'), f('2FE2', 'EF.ICCID'), d('7F10', 'DF.TELECOM')];
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['7F10', '7F20', '2FE2', '6F07']);
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['DF.GSM', 'DF.TELECOM', 'EF.ICCID', 'EF.IMSI']);
});
test('FID mode sorts EFs numerically by FID string', () => {
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'EF.ICCID'), f('6F07', 'EF.IMSI')];
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07', '6F3A']);
});
test('name mode sorts case-insensitively', () => {
const children = [f('6F3A', 'EF.ADN'), f('2FE2', 'ef.iccid'), f('6F07', 'EF.IMSI')];
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.name), ['EF.ADN', 'ef.iccid', 'EF.IMSI']);
});
test('missing name falls back to the FID as the sort key', () => {
const children = [f('2FE2', null), f('6F07', 'EF.IMSI'), f('6F3A', '')];
// keys: '2FE2', 'EF.IMSI', '6F3A' -> '2FE2' < '6F3A' < 'EF.IMSI'
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F3A', '6F07']);
});
test('custom entries use their isDir flag for the DF priority', () => {
const children = [f('6F3A', 'EF.ADN'), d('7F20', 'DF.GSM')];
assert.strictEqual(pysimFsSortChildren(children, 'fid')[0].name, 'DF.GSM');
});
test('equal keys keep a deterministic tie-break by the other field', () => {
const children = [f('6F07', 'EF.SAME'), f('2FE2', 'EF.SAME')];
assert.deepStrictEqual(pysimFsSortChildren(children, 'name').map(x => x.fid), ['2FE2', '6F07']);
assert.deepStrictEqual(pysimFsSortChildren(children, 'fid').map(x => x.fid), ['2FE2', '6F07']);
});
test('does not mutate the input array', () => {
const children = [f('6F3A', 'B'), f('2FE2', 'A')];
pysimFsSortChildren(children, 'fid');
assert.deepStrictEqual(children.map(x => x.fid), ['6F3A', '2FE2']);
});
+71
View File
@@ -10,3 +10,74 @@ const closes = (html.match(/<\/div>/g) || []).length;
test('HTML <div> tags are balanced', () => { test('HTML <div> tags are balanced', () => {
assert.strictEqual(opens, closes, `Unbalanced divs: ${opens} opens vs ${closes} closes`); assert.strictEqual(opens, closes, `Unbalanced divs: ${opens} opens vs ${closes} closes`);
}); });
test('top-level tabs match the rearranged views', () => {
const tabs = [...html.matchAll(/class="tab-btn[^"]*" data-tab="([^"]+)"/g)].map(m => m[1]);
assert.deepStrictEqual(tabs, ['c-apdu', 'scp80', 'profiler', 'pysim', 'phone']);
assert.match(html, /data-tab="c-apdu">Remote APDU</);
});
test('response parser is a Remote APDU pill', () => {
assert.match(html, /data-sub="response" onclick="cApduSwitchSubtab\('response'\)"/);
assert.ok(html.includes('id="c-apdu-sub-response"'));
});
test('profiler and phone simulator are top-level tab contents', () => {
assert.ok(html.includes('id="tab-profiler" class="tab-content hidden"'));
assert.ok(html.includes('id="tab-phone" class="tab-content hidden"'));
});
test('phone simulator has Phone / TR Config pills', () => {
assert.match(html, /data-phone-sub="phone" onclick="phoneSwitchSubtab\('phone'\)"/);
assert.match(html, /data-phone-sub="tr" onclick="phoneSwitchSubtab\('tr'\)"/);
assert.ok(html.includes('id="phone-sub-phone"'));
assert.ok(html.includes('id="phone-sub-tr"'));
});
test('scan name input starts scanning on Enter', () => {
assert.match(html, /id="profiler-scan-name"[^>]*onkeydown="profilerScanNameKeydown\(event\)"/);
});
test('snapshot view has a timing summary block', () => {
assert.ok(html.includes('id="snapshot-summary"'));
});
test('header state indicator and profiler custom-files tab', () => {
assert.ok(html.includes('id="state-indicator"'));
assert.ok(html.includes('id="profiler-list-custom"'));
assert.ok(html.includes('data-list-tab="custom"'));
assert.ok(!html.includes('data-pysim-sub="custom"'));
});
test('file manager has FID / Name sort pills', () => {
assert.match(html, /data-fs-sort="fid" onclick="pysimFsSetSort\('fid'\)"/);
assert.match(html, /data-fs-sort="name" onclick="pysimFsSetSort\('name'\)"/);
assert.ok(html.includes('pysim-fs-sort-pill'));
});
test('custom files form has add/save and cancel controls', () => {
assert.match(html, /id="pysim-cf-add-btn"[^>]*data-l10n="Add"/);
assert.match(html, /id="pysim-cf-cancel-btn"[^>]*class="hidden[^"]*"[^>]*data-l10n="Cancel"/);
assert.ok(html.includes("event.key==='Enter')pysimCustomSubmit()"));
assert.ok(!html.includes('pysimCustomAdd'));
});
test('file manager has a probe-all-files button and status line', () => {
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-needs="card"/);
assert.match(html, /id="pysim-fs-probe-btn"[^>]*data-l10n="Probe all files"/);
assert.ok(html.includes('onclick="pysimFsProbeAll()"'));
assert.ok(html.includes('id="pysim-fs-probe-status"'));
});
test('file manager shows FCI info and keeps the selection in state, not the DOM', () => {
const detail = html.indexOf('id="pysim-fs-detail"');
const info = html.indexOf('id="pysim-fs-info"');
const content = html.indexOf('id="pysim-fs-content"');
assert.ok(detail !== -1 && info > detail && info < content, 'pysim-fs-info must sit above the content');
assert.ok(html.includes('function pysimFsInfoHtml'));
assert.ok(html.includes("pysimFsInfoHtml(sel)"));
assert.ok(!html.includes('pysim-fs-filename'));
assert.ok(html.includes('let pysimFsSelected = null;'));
assert.ok(html.includes('pysimFsSelected = name;'));
assert.ok(!html.includes('pysimFsSelect()'));
});
+136
View File
@@ -0,0 +1,136 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
let code = 'var _pysimServerAvailable = null;\nvar _pysimCardEquipped = false;\nvar _pysimEquipping = false;\n';
code += extractFunc(html, 'pysimAvailabilityState') + '\n';
code += extractFunc(html, 'pysimUpdateStateIndicator') + '\n';
code += 'globalThis.t = s => s;\n';
eval(code);
function fakeEl() {
const classes = new Set();
return {
classes,
attrs: {},
classList: {
add: (...cs) => cs.forEach(c => classes.add(c)),
remove: (...cs) => cs.forEach(c => classes.delete(c)),
contains: c => classes.has(c),
},
setAttribute(k, v) { this.attrs[k] = v; },
removeAttribute(k) { delete this.attrs[k]; },
};
}
function setup() {
const els = {
'state-indicator': fakeEl(),
'state-indicator-dot': fakeEl(),
'state-indicator-img': fakeEl(),
};
els['state-indicator-img'].src = '';
globalThis.document = { getElementById: id => els[id] || null };
_pysimServerAvailable = null;
_pysimCardEquipped = false;
_pysimEquipping = false;
return els;
}
test('unprobed server shows a gray dot and a Connecting title', () => {
const els = setup();
pysimUpdateStateIndicator();
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
assert.ok(dot.classes.has('text-gray-400'));
assert.ok(!dot.classes.has('hidden'));
assert.ok(img.classes.has('hidden'));
assert.strictEqual(wrap.attrs.title, 'Connecting...');
assert.strictEqual(dot.attrs.title, 'Connecting...');
});
test('unreachable server shows a red dot', () => {
const els = setup();
_pysimServerAvailable = false;
pysimUpdateStateIndicator();
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
assert.ok(dot.classes.has('text-red-500'));
assert.ok(!dot.classes.has('text-gray-400'));
assert.ok(img.classes.has('hidden'));
assert.strictEqual(wrap.attrs.title, 'No server connection');
assert.strictEqual(dot.attrs.title, 'No server connection');
});
test('server up without a card shows nosim.svg', () => {
const els = setup();
_pysimServerAvailable = true;
pysimUpdateStateIndicator();
const { 'state-indicator': wrap, 'state-indicator-dot': dot, 'state-indicator-img': img } = els;
assert.ok(dot.classes.has('hidden'));
assert.ok(!img.classes.has('hidden'));
assert.strictEqual(img.src, 'nosim.svg');
assert.strictEqual(wrap.attrs.title, 'Server connected, no card equipped');
assert.strictEqual(dot.attrs.title, undefined);
});
test('equipped card shows sim.svg', () => {
const els = setup();
_pysimServerAvailable = true;
_pysimCardEquipped = true;
pysimUpdateStateIndicator();
const { 'state-indicator': wrap, 'state-indicator-img': img } = els;
assert.strictEqual(img.src, 'sim.svg');
assert.strictEqual(wrap.attrs.title, 'Card equipped');
});
test('equipping shows the animated sim_anim.svg', () => {
const els = setup();
_pysimServerAvailable = true;
_pysimEquipping = true;
pysimUpdateStateIndicator();
const { 'state-indicator': wrap, 'state-indicator-img': img } = els;
assert.strictEqual(img.src, 'sim_anim.svg');
assert.strictEqual(wrap.attrs.title, 'Card inserted — initializing...');
});
test('dot color transitions do not accumulate', () => {
const els = setup();
_pysimServerAvailable = false;
pysimUpdateStateIndicator();
_pysimServerAvailable = null;
pysimUpdateStateIndicator();
const dot = els['state-indicator-dot'];
assert.ok(dot.classes.has('text-gray-400'));
assert.ok(!dot.classes.has('text-red-500'));
});
test('indicator markup carries the dot and image elements', () => {
assert.match(html, /id="state-indicator-dot"/);
assert.match(html, /id="state-indicator-img"[^>]*src="nosim\.svg"/);
});
test('indicator image stays within the 32px header row budget', () => {
const m = /id="state-indicator-img"[^>]*style="width:(\d+)px;height:(\d+)px"/.exec(html);
assert.ok(m, 'inline image size not found');
assert.strictEqual(m[1], m[2]);
const size = Number(m[1]);
assert.ok(size >= 24 && size <= 32, 'size ' + size + 'px would change the header height');
});
+83
View File
@@ -0,0 +1,83 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return src.slice(m.index, i + 1);
}
const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
'globalThis.setHelpAnchor = a => { globalThis._anchor = a; };\n' +
'globalThis.stkCheckMenu = () => { globalThis._stk = (globalThis._stk || 0) + 1; };\n' +
'globalThis.pysimEventsRender = () => { globalThis._events = (globalThis._events || 0) + 1; };\n' +
'globalThis.pysimProactiveLogRender = () => { globalThis._log = (globalThis._log || 0) + 1; };\n' +
'globalThis.pysimPollStatusInit = () => { globalThis._poll = (globalThis._poll || 0) + 1; };\n' +
'globalThis.pysimPliRender = () => { globalThis._pli = (globalThis._pli || 0) + 1; };\n';
eval(code);
function makeClassList() {
const set = new Set();
return {
toggle: (c, on) => { on ? set.add(c) : set.delete(c); },
has: c => set.has(c),
};
}
function setup() {
const buttons = [
{ dataset: { phoneSub: 'phone' }, classList: makeClassList() },
{ dataset: { phoneSub: 'tr' }, classList: makeClassList() },
];
const panels = {
'phone-sub-phone': { classList: makeClassList() },
'phone-sub-tr': { classList: makeClassList() },
};
globalThis.document = {
querySelectorAll: sel => (sel === '.phone-subtab' ? buttons : []),
getElementById: id => panels[id] || null,
};
globalThis._anchor = null;
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = 0;
return { buttons, panels };
}
test('TR Config pill shows the TR panel and renders PLI data', () => {
const { buttons, panels } = setup();
phoneSwitchSubtab('tr');
assert.ok(!panels['phone-sub-tr'].classList.has('hidden'));
assert.ok(panels['phone-sub-phone'].classList.has('hidden'));
assert.ok(buttons[1].classList.has('bg-blue-600'));
assert.ok(!buttons[0].classList.has('bg-blue-600'));
assert.strictEqual(globalThis._anchor, 'pli-dict');
assert.strictEqual(globalThis._pli, 1);
assert.strictEqual(globalThis._stk, 0);
});
test('Phone pill shows the phone panel and renders CAT views', () => {
const { buttons, panels } = setup();
phoneSwitchSubtab('phone');
assert.ok(!panels['phone-sub-phone'].classList.has('hidden'));
assert.ok(panels['phone-sub-tr'].classList.has('hidden'));
assert.ok(buttons[0].classList.has('bg-blue-600'));
assert.strictEqual(globalThis._anchor, 'stk-menu');
assert.strictEqual(globalThis._stk, 1);
assert.strictEqual(globalThis._events, 1);
assert.strictEqual(globalThis._log, 1);
assert.strictEqual(globalThis._poll, 1);
assert.strictEqual(globalThis._pli, 0);
});
+195 -4
View File
@@ -21,7 +21,7 @@ function extractFunc(src, name, asyncFn) {
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1); return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
} }
const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults']; const FNS = ['profilerNormHex', 'profilerNormHexStrict', 'profilerMatch', 'profilerMatchMin', 'profilerMaskPrefix4', 'profilerFileFields', 'profilerContentKindForFileType', 'profilerEmptyRecordContent', 'profilerValidateProfile', 'profilerCustomNameForPath', 'profilerUpdateRulePath', 'profilerResultAspects', 'profilerAspectSummary', 'profilerNumRanges', 'esc', 'escHtml', 'profilerRawDataCheck', 'profilerRenderReport', 'parseBerLen', 'parseTlvList', 'fcpInt', 'fcpParseTlvs', 'fcpFileDescriptor', 'fcpLifeCycle', 'fcpSfi', 'fcpDo', 'fcpDecode', 'fcpDiffHtml', 'profilerFciPreviewItems', 'profilerUpdateFciPreview', 'profilerUpdateRule', 'profilerFciInput', 'profilerScanToggleAll', 'profilerScanIgnoreAllState', 'swapNibbles', 'decIccid', 'profilerSnapshotIccid', 'profilerValidateSnapshot', 'profilerListSwitch', 'profilerScanRefreshOptions', 'profilerLiveSource', 'profilerSnapshotSource', 'profilerVisibleResults', 'profilerMaskFidForFile', 'profilerRulesFromSnapshot', 'profilerExtraFileResults', 'profilerScanNameKeydown', 'profilerTimingStats', 'profilerTimingAccumulator', 'profilerFormatMs', 'profilerRenderSnapshotSummary', 'profilerSnapshotCountLabel', 'pysimFsInfoHtml'];
let code = ''; let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n'; for (const f of FNS) code += extractFunc(html, f) + '\n';
code += extractFunc(html, 'profilerBuildFileRule', true) + '\n'; code += extractFunc(html, 'profilerBuildFileRule', true) + '\n';
@@ -584,7 +584,7 @@ test('profilerRenderReport renders raw-data mismatches as aligned readonly field
assert.ok(html.includes('font-mono')); assert.ok(html.includes('font-mono'));
assert.ok(html.includes('value="621082024021"')); assert.ok(html.includes('value="621082024021"'));
assert.ok(html.includes('value="621082024022"')); assert.ok(html.includes('value="621082024022"'));
assert.ok(html.includes('w-24 text-right')); assert.ok(html.includes('text-right shrink-0 w-24'));
// non-raw check stays inline // non-raw check stays inline
assert.ok(html.includes('content.records: expected')); assert.ok(html.includes('content.records: expected'));
assert.ok(!fciBlock.includes(': expected')); assert.ok(!fciBlock.includes(': expected'));
@@ -698,6 +698,33 @@ test('fcpDiffHtml highlights differing FCI parameters', () => {
delete global.t; delete global.t;
}); });
test('pysimFsInfoHtml shows FID, type, size and the decoded FCI', () => {
global.t = s => s;
const hex = '621A82054221000F0583026F4F8A01058B036F06098002004B8801B0';
const out = pysimFsInfoHtml({ fid: '6f4f', file_type: 'linear_fixed', file_size: 75, record_len: 15, num_of_rec: 5, fci_hex: hex });
assert.ok(out.includes('FID: 6F4F'), out);
assert.ok(out.includes('File type: linear_fixed'), out);
assert.ok(out.includes('Size: 75'), out);
assert.ok(out.includes('Record length: 15'), out);
assert.ok(out.includes('Record count: 5'), out);
assert.ok(!out.includes('Decoded FCI'), out);
assert.ok(out.includes('File descriptor'), out);
assert.ok(out.includes('75 bytes'), out);
delete global.t;
});
test('pysimFsInfoHtml omits the FCI block without fci_hex and skips null fields', () => {
global.t = s => s;
const out = pysimFsInfoHtml({ fid: '6f07', file_type: 'transparent', file_size: null, record_len: null, num_of_rec: undefined, fci_hex: null });
assert.ok(out.includes('FID: 6F07'), out);
assert.ok(!out.includes('Size:'), out);
assert.ok(!out.includes('Record length:'), out);
assert.ok(!out.includes('Record count:'), out);
assert.ok(!out.includes('Decoded FCI'), out);
assert.strictEqual(pysimFsInfoHtml(null), '');
delete global.t;
});
test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => { test('profilerFciPreviewItems renders decoded items and degrades gracefully', () => {
global.t = s => s; global.t = s => s;
assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: ')); assert.ok(profilerFciPreviewItems(FCP_TRANSPARENT).includes('File size: '));
@@ -921,12 +948,15 @@ test('profilerListSwitch toggles the profiles/snapshots tabs', () => {
dataset: { listTab: tab }, dataset: { listTab: tab },
classList: { _c: new Set(), toggle(c, on) { if (on) this._c.add(c); else this._c.delete(c); } }, classList: { _c: new Set(), toggle(c, on) { if (on) this._c.add(c); else this._c.delete(c); } },
}); });
const btns = [mkBtn('profiles'), mkBtn('snapshots')]; const btns = [mkBtn('profiles'), mkBtn('snapshots'), mkBtn('custom')];
const profilesEl = { hidden: false, classList: { toggle(cls, on) { profilesEl.hidden = on; } } }; const profilesEl = { hidden: false, classList: { toggle(cls, on) { profilesEl.hidden = on; } } };
const snapsEl = { hidden: true, classList: { toggle(cls, on) { snapsEl.hidden = on; } } }; const snapsEl = { hidden: true, classList: { toggle(cls, on) { snapsEl.hidden = on; } } };
const customEl = { hidden: true, classList: { toggle(cls, on) { customEl.hidden = on; } } };
global.pysimCustomRender = () => {};
global.setHelpAnchor = () => {};
global.document = { global.document = {
querySelectorAll: sel => (sel === '.profiler-list-tab' ? btns : []), querySelectorAll: sel => (sel === '.profiler-list-tab' ? btns : []),
getElementById: id => (id === 'profiler-list-profiles' ? profilesEl : id === 'profiler-list-snapshots' ? snapsEl : null), getElementById: id => (id === 'profiler-list-profiles' ? profilesEl : id === 'profiler-list-snapshots' ? snapsEl : id === 'profiler-list-custom' ? customEl : null),
}; };
profilerListSwitch('snapshots'); profilerListSwitch('snapshots');
@@ -941,7 +971,14 @@ test('profilerListSwitch toggles the profiles/snapshots tabs', () => {
assert.ok(btns[0].classList._c.has('bg-blue-600')); assert.ok(btns[0].classList._c.has('bg-blue-600'));
assert.ok(btns[1].classList._c.has('bg-gray-200')); assert.ok(btns[1].classList._c.has('bg-gray-200'));
profilerListSwitch('custom');
assert.strictEqual(customEl.hidden, false);
assert.strictEqual(profilesEl.hidden, true);
assert.strictEqual(snapsEl.hidden, true);
delete global.document; delete global.document;
delete global.pysimCustomRender;
delete global.setHelpAnchor;
}); });
test('profilerScanRefreshOptions re-translates mask labels without touching checkbox state', () => { test('profilerScanRefreshOptions re-translates mask labels without touching checkbox state', () => {
@@ -1112,3 +1149,157 @@ test('profilerExtraFileResults reports files missing from the master', () => {
assert.strictEqual(extras[0].status, 'fail'); assert.strictEqual(extras[0].status, 'fail');
assert.deepStrictEqual(extras[0].checks, [{ label: 'extra file', expected: 'absent', actual: 'present', ok: false }]); assert.deepStrictEqual(extras[0].checks, [{ label: 'extra file', expected: 'absent', actual: 'present', ok: false }]);
}); });
test('profilerScanNameKeydown starts the scan on Enter only', () => {
const btn = { disabled: false };
global.document = { getElementById: () => btn };
let started = 0;
global.profilerScanStart = () => { started++; };
let prevented = 0;
profilerScanNameKeydown({ key: 'Enter', preventDefault: () => prevented++ });
profilerScanNameKeydown({ key: 'a', preventDefault: () => prevented++ });
assert.strictEqual(started, 1);
assert.strictEqual(prevented, 1);
btn.disabled = true;
profilerScanNameKeydown({ key: 'Enter', preventDefault: () => prevented++ });
assert.strictEqual(started, 1);
assert.strictEqual(prevented, 1);
delete global.document;
delete global.profilerScanStart;
});
// --- snapshot command timings ---
test('profilerTimingStats computes min, max and average', () => {
assert.deepStrictEqual(profilerTimingStats([10, 20, 30]), { min: 10, max: 30, avg: 20, count: 3 });
assert.deepStrictEqual(profilerTimingStats([7, 7.5, 8]), { min: 7, max: 8, avg: 7.5, count: 3 });
assert.strictEqual(profilerTimingStats([]), null);
assert.strictEqual(profilerTimingStats(null), null);
});
test('profilerFormatMs formats milliseconds and seconds', () => {
assert.strictEqual(profilerFormatMs(12), '12 ms');
assert.strictEqual(profilerFormatMs(999), '999 ms');
assert.strictEqual(profilerFormatMs(1500), '1.50 s');
assert.strictEqual(profilerFormatMs(null), 'n/a');
});
test('profilerTimingAccumulator aggregates per command type', () => {
const acc = profilerTimingAccumulator();
acc.add('select', 10);
acc.add('select', 20);
acc.add('read_record', 5);
acc.add('bogus', 1);
assert.deepStrictEqual(acc.stats(), {
select: { min: 10, max: 20, avg: 15, count: 2 },
read_record: { min: 5, max: 5, avg: 5, count: 1 },
});
});
test('profilerBuildSnapshotFile records per-command timings', async () => {
const acc = profilerTimingAccumulator();
mockFetch({
'/api/select': () => ({ exists: true, name: 'EF.ADN', file_type: 'linear_fixed', file_size: null, record_len: 2, num_of_rec: 2,
apdu_times: [{ type: 'select', ms: 5 }, { type: 'select', ms: 7 }] }),
'/api/read': () => ({ success: true, file_type: 'linear_fixed', records: [{ num: 1, data: 'AA' }, { num: 2, data: 'BB' }],
apdu_times: [{ type: 'select', ms: 4 }, { type: 'read_record', ms: 11 }, { type: 'read_record', ms: 13 }] }),
});
const file = await profilerBuildSnapshotFile('MF/6F3A', { name: 'EF.ADN' }, acc);
assert.deepStrictEqual(file.timing, { select_ms: 12, read_ms: 24 });
assert.strictEqual(file.content.records[0].ms, 11);
assert.strictEqual(file.content.records[1].ms, 13);
const stats = acc.stats();
assert.strictEqual(stats.select.count, 2);
assert.strictEqual(stats.read_record.count, 2);
});
test('profilerBuildSnapshotFile without apdu_times has empty timing', async () => {
mockFetch({
'/api/select': () => ({ exists: true, name: 'EF.ADN', file_type: 'transparent', file_size: 2, record_len: null, num_of_rec: null }),
'/api/read': () => ({ success: true, file_type: 'transparent', data: 'AABB' }),
});
const file = await profilerBuildSnapshotFile('MF/6F3A', { name: 'EF.ADN' }, null);
assert.strictEqual(file.timing, null);
assert.deepStrictEqual(file.content, { kind: 'transparent', data: 'AABB' });
});
test('profilerRenderSnapshotSummary shows counts, scan time and stats', () => {
global.t = s => s;
const snap = {
files: [
{ content: { kind: 'record', records: [{ num: 1, data: 'AA', ms: 5 }, { num: 2, data: 'BB', ms: 7 }] } },
{ content: { kind: 'transparent', data: 'AA' } },
],
timing: {
select: { min: 3, max: 9, avg: 6, count: 3 },
read_record: { min: 5, max: 7, avg: 6, count: 2 },
total_ms: 1500,
},
};
const html = profilerRenderSnapshotSummary(snap);
assert.ok(html.includes('Files: <b>2</b>'), html);
assert.ok(html.includes('Records: <b>2</b>'), html);
assert.ok(html.includes('Scan time: <b>1.50 s</b>'), html);
assert.ok(html.includes('Select: min 3 ms'), html);
assert.ok(html.includes('Read record: min 5 ms'), html);
assert.ok(!html.includes('Read binary'), html);
delete global.t;
});
test('profilerRenderSnapshotSummary notes missing timing data', () => {
global.t = s => s;
const html = profilerRenderSnapshotSummary({ files: [], timing: undefined });
assert.ok(html.includes('Files: <b>0</b>'), html);
assert.ok(html.includes('No timing data'), html);
delete global.t;
});
test('profilerSnapshotCountLabel appends the scan time when available', () => {
global.t = s => (s === 'scanned in' ? 'scanned in' : s);
const withTime = profilerSnapshotCountLabel({ files: new Array(95).fill({}), timing: { total_ms: 21320 } });
assert.strictEqual(withTime, '95 files, scanned in 21.32 sec');
const noTime = profilerSnapshotCountLabel({ files: new Array(3).fill({}) });
assert.strictEqual(noTime, '3 files');
const empty = profilerSnapshotCountLabel({ files: [], timing: {} });
assert.strictEqual(empty, '0 files');
delete global.t;
});
test('profilerRenderReport labels mismatches with custom names', () => {
global.t = s => s;
global.pysimCustomFiles = [];
const html = profilerRenderReport([{
path: 'MF/6F3A', name: 'EF.ADN', status: 'fail',
checks: [
{ label: 'content', expected: 'AABB', actual: 'CCDD', ok: false },
{ label: 'fileSize', expected: 4, actual: 9, ok: false },
],
}], { expected: 'Master snap', actual: 'Check snap' });
assert.ok(html.includes('Master snap'), html);
assert.ok(html.includes('Check snap'), html);
assert.ok(!html.includes('>expected<'), html);
assert.ok(!html.includes('>actual<'), html);
delete global.t;
delete global.pysimCustomFiles;
});
test('profilerRenderReport keeps expected/actual without labels', () => {
global.t = s => s;
global.pysimCustomFiles = [];
const html = profilerRenderReport([{
path: 'MF/6F3A', status: 'fail',
checks: [{ label: 'fileSize', expected: 4, actual: 9, ok: false }],
}]);
assert.ok(html.includes(' expected '), html);
assert.ok(html.includes(' actual '), html);
delete global.t;
delete global.pysimCustomFiles;
});
test('fcpDiffHtml headers use custom labels', () => {
global.t = s => s;
const diff = fcpDiffHtml(FCP_TRANSPARENT, '62128002000A8202412183026F078A0105880110', { expected: 'Master', actual: 'Candidate' });
assert.ok(diff.includes('>Master<'), diff);
assert.ok(diff.includes('>Candidate<'), diff);
delete global.t;
});
+161 -2
View File
@@ -6,7 +6,7 @@ const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8'); const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name) { function extractFunc(src, name) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{'); const re = new RegExp('(?:async\\s+)?function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src); const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found'); if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1; let i = m.index + m[0].length - 1;
@@ -22,13 +22,18 @@ function extractFunc(src, name) {
} }
// Extract chain builder functions and dependencies // Extract chain builder functions and dependencies
const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'chainInit', 'chainRamBuildRowHex']; const FNS = ['berLenStr', 'buildApdu', 'escHtml', 'esc', 'chainInit', 'chainRamBuildRowHex', 'ramFmtLifecycle', 'ramFmtPrivileges', 'ramRenderExploreHtml',
'ramCardIdxAfterRemove', 'ramClearResults', 'ramHideProgress', 'ramOpChanged', 'ramRender', 'ramApplyCard', 'ramExecute'];
let code = ''; let code = '';
for (const f of FNS) { for (const f of FNS) {
code += extractFunc(html, f) + '\n'; code += extractFunc(html, f) + '\n';
} }
const m = html.match(/const _chains = \{\};/); const m = html.match(/const _chains = \{\};/);
if (m) code += m[0].replace(/^const /, 'var ') + '\n'; if (m) code += m[0].replace(/^const /, 'var ') + '\n';
const lc = html.match(/const RAM_LIFECYCLE = \{[\s\S]*?\n\};/);
if (lc) code += lc[0].replace(/^const /, 'var ') + '\n';
eval(code);
code += 'var _ramCardIdx = null;\nvar _ramOpLast = null;\nvar _ramExplorerData = null;\n';
eval(code); eval(code);
const els = {}; const els = {};
@@ -161,3 +166,157 @@ test('STORE DATA ram-enc P1 values 00/40/80/C0/E0', () => {
assert.ok(apdu.startsWith('80E2' + p1 + '00'), enc + ' -> P1 ' + p1); assert.ok(apdu.startsWith('80E2' + p1 + '00'), enc + ' -> P1 ' + p1);
} }
}); });
test('ramRenderExploreHtml localizes every label and button', () => {
const seen = [];
global.t = s => { seen.push(s); return 'XX' + s; };
const out = ramRenderExploreHtml(
{ appCount: 5, freeNV: 100, freeV: 50 },
[{ aid: 'A000000151000000', lifecycle: '07', privileges: '', sdAid: 'A000000151000000' }],
[{ aid: 'A1130001180001', lifecycle: '07', privileges: '80', implicitSel: '00', elfAid: 'ELF1' }],
[{ aid: 'ELF1', lifecycle: '01', version: '1.0', moduleAids: ['M1'], sdAid: null }]
);
delete global.t;
assert.ok(out.includes('XXDelete'), out);
assert.ok(out.includes('XXDelete All'), out);
assert.ok(out.includes('XXApplications:'), out);
assert.ok(out.includes('XXFree NV:'), out);
assert.ok(out.includes('XXFree Volatile:'), out);
assert.ok(out.includes('XXAID:'), out);
assert.ok(out.includes('XXLifecycle:'), out);
assert.ok(out.includes('XXPrivileges:'), out);
assert.ok(out.includes('XXSD AID:'), out);
assert.ok(out.includes('XXImplicit sel:'), out);
assert.ok(out.includes('XXVersion:'), out);
assert.ok(seen.includes('Application / Instance AID:'));
assert.ok(seen.includes('Load File AID / Package AID:'));
assert.ok(seen.includes('Executable Module AIDs / Applet Class AIDs:'));
assert.ok(!out.includes('data-l10n'), out);
});
test('ramFmtPrivileges uses the translated (none) placeholder', () => {
global.t = s => 'XX' + s;
assert.strictEqual(ramFmtPrivileges(''), 'XX(none)');
assert.strictEqual(ramFmtPrivileges('00'), 'XX(none)');
delete global.t;
});
function fakeClassList() {
const set = new Set();
return {
add: (...cs) => cs.forEach(c => set.add(c)),
remove: (...cs) => cs.forEach(c => set.delete(c)),
contains: c => set.has(c),
toggle: (c, on) => { if (on === undefined ? !set.has(c) : on) set.add(c); else set.delete(c); },
};
}
function fakeEl(id) {
return {
id,
value: '',
innerHTML: '',
textContent: '',
classList: fakeClassList(),
options: [],
appendChild(opt) { this.options.push(opt); },
};
}
function fakeRamDocument(ids) {
const els = {};
for (const id of ids) els[id] = fakeEl(id);
const sel = els['ram-card-sel'];
if (sel) {
Object.defineProperty(sel, 'innerHTML', {
get() { return this._html || ''; },
set(v) { this._html = v; this.value = ''; },
});
}
globalThis.document = {
getElementById: id => els[id] || null,
createElement: () => fakeEl('option'),
};
return els;
}
test('ramOpChanged clears the executed status only on a real op change', () => {
const els = fakeRamDocument(['ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
_ramOpLast = null;
els['ram-op'].value = 'explore';
ramOpChanged();
assert.ok(!els['ram-result'].classList.contains('hidden'));
els['ram-result'].classList.remove('hidden');
els['ram-steps'].classList.remove('hidden');
ramOpChanged();
assert.ok(!els['ram-result'].classList.contains('hidden'), 'same op must keep the result');
els['ram-op'].value = 'install-cap';
ramOpChanged();
assert.ok(els['ram-result'].classList.contains('hidden'));
assert.ok(els['ram-steps'].classList.contains('hidden'));
assert.ok(els['ram-explorer'].classList.contains('hidden'));
assert.ok(els['ram-progress'].classList.contains('hidden'));
assert.ok(!els['ram-install-params'].classList.contains('hidden'));
});
test('ramRender keeps the selected card preset across rebuilds', () => {
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
globalThis.cards = [{ name: 'A' }, { name: 'B' }, { name: 'C' }];
_ramCardIdx = null;
_ramOpLast = 'explore';
els['ram-op'].value = 'explore';
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '');
els['ram-card-sel'].value = '1';
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '1');
els['ram-card-sel'].value = '';
_ramCardIdx = 2;
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '2');
globalThis.cards = [{ name: 'A' }];
_ramCardIdx = 2;
ramRender();
assert.strictEqual(els['ram-card-sel'].value, '');
delete globalThis.cards;
});
test('ramApplyCard remembers a valid picked preset', () => {
globalThis.cards = [{ name: 'A' }, { name: 'B' }];
let applied = null;
globalThis.cardsApply = i => { applied = i; };
_ramCardIdx = null;
ramApplyCard('1');
assert.strictEqual(_ramCardIdx, 1);
assert.strictEqual(applied, '1');
ramApplyCard('');
assert.strictEqual(_ramCardIdx, 1, 'invalid pick must not forget the preset');
delete globalThis.cards;
delete globalThis.cardsApply;
});
test('ramExecute commits the dropdown selection before running', async () => {
const els = fakeRamDocument(['ram-card-sel', 'ram-op', 'ram-install-params', 'ram-result', 'ram-explorer', 'ram-steps', 'ram-progress']);
globalThis.cards = [{ name: 'A' }];
globalThis.getRamSpParams = () => ({ kicKey: '11', kidKey: '22' });
let explored = false;
globalThis.ramExplore = async () => { explored = true; };
globalThis.alert = () => {};
_ramCardIdx = null;
els['ram-card-sel'].value = '0';
els['ram-op'].value = 'explore';
await ramExecute();
assert.strictEqual(_ramCardIdx, 0);
assert.ok(explored);
delete globalThis.cards;
delete globalThis.getRamSpParams;
delete globalThis.ramExplore;
delete globalThis.alert;
});
test('ramCardIdxAfterRemove keeps the remembered index aligned', () => {
assert.strictEqual(ramCardIdxAfterRemove(2, 0), 1);
assert.strictEqual(ramCardIdxAfterRemove(0, 0), null);
assert.strictEqual(ramCardIdxAfterRemove(0, 2), 0);
assert.strictEqual(ramCardIdxAfterRemove(null, 1), null);
});
+85
View File
@@ -0,0 +1,85 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const html = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
function extractFunc(src, name, asyncFn) {
const re = new RegExp('function\\s+' + name + '\\s*\\([^)]*\\)\\s*\\{');
const m = re.exec(src);
if (!m) throw new Error('function ' + name + ' not found');
let i = m.index + m[0].length - 1;
let depth = 0;
for (; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') {
depth--;
if (depth === 0) break;
}
}
return (asyncFn ? 'async ' : '') + src.slice(m.index, i + 1);
}
let code = extractFunc(html, 'stkMenuRespond', true) + '\n';
code += 'globalThis.esc = s => s;\n';
eval(code);
function setup(response) {
const calls = { handled: null, rendered: 0 };
globalThis.pysimFetch = async () => response;
globalThis.stkMenuHandleResponse = d => { calls.handled = d; };
globalThis.stkMenuRenderItems = () => { calls.rendered++; };
const btns = { classList: { add: () => {} } };
const back = { style: {} };
globalThis.document = {
getElementById: id => (id === 'stk-menu-buttons' ? btns : id === 'stk-back-btn' ? back : { innerHTML: '' }),
};
return calls;
}
test('back with a fetched SELECT ITEM continues the card dialogue', async () => {
const data = { type: 'select_item', items: [{ id: 1, text: 'Info' }] };
const calls = setup(data);
await stkMenuRespond('back');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('back with a fetched DISPLAY TEXT shows it', async () => {
const data = { type: 'display_text', text: 'hello' };
const calls = setup(data);
await stkMenuRespond('back');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('timeout with a fetched SELECT ITEM continues the card dialogue', async () => {
const data = { type: 'select_item', items: [] };
const calls = setup(data);
await stkMenuRespond('timeout');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
test('back answered with SW 9000 falls back to the cached top menu', async () => {
const calls = setup({ type: 'done', sw: '9000' });
await stkMenuRespond('back');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 1);
});
test('cancel falls back to the cached top menu', async () => {
const calls = setup({ sw: '9000' });
await stkMenuRespond('cancel');
assert.strictEqual(calls.handled, null);
assert.strictEqual(calls.rendered, 1);
});
test('ok navigates with the server response', async () => {
const data = { type: 'select_item', items: [{ id: 1, text: 'x' }] };
const calls = setup(data);
await stkMenuRespond('ok');
assert.strictEqual(calls.handled, data);
assert.strictEqual(calls.rendered, 0);
});
+121
View File
@@ -0,0 +1,121 @@
const { test } = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const swSource = fs.readFileSync(path.join(__dirname, '..', 'sw.js'), 'utf8');
class FakeResponse {
constructor(body, init) {
this.body = body;
this.status = init && init.status;
this.statusText = init && init.statusText;
}
clone() {
return new FakeResponse(this.body, { status: this.status, statusText: this.statusText });
}
}
function loadSW({ fetchImpl, cacheMatch }) {
const listeners = {};
const puts = [];
const sandbox = {
self: {
addEventListener: (type, fn) => { listeners[type] = fn; },
skipWaiting: () => {},
},
caches: {
open: async () => ({
addAll: async () => {},
put: async (req, res) => { puts.push([String(req && req.url || req), res]); },
}),
keys: async () => [],
delete: async () => true,
match: cacheMatch,
},
clients: { claim: () => {} },
fetch: fetchImpl,
Response: FakeResponse,
URL,
console,
};
vm.createContext(sandbox);
vm.runInContext(swSource, sandbox);
return { listeners, puts };
}
function navigateEvent(url) {
const event = {
request: { url, method: 'GET', mode: 'navigate' },
responded: null,
};
event.respondWith = p => { event.responded = p; };
event.passThrough = () => { event.responded = null; };
return event;
}
test('offline navigation falls back to the cached index.html', async () => {
const index = new FakeResponse('html');
const { listeners } = loadSW({
fetchImpl: async () => { throw new Error('offline'); },
cacheMatch: async req => (String(req && req.url || req) === 'index.html' ? index : undefined),
});
const event = navigateEvent('http://127.0.0.1:8080/');
listeners.fetch(event);
const res = await event.responded;
assert.strictEqual(res, index);
});
test('offline navigation with empty cache resolves to an offline Response', async () => {
const { listeners } = loadSW({
fetchImpl: async () => { throw new Error('offline'); },
cacheMatch: async () => undefined,
});
const event = navigateEvent('http://127.0.0.1:8080/');
listeners.fetch(event);
const res = await event.responded;
assert.ok(res instanceof FakeResponse);
assert.strictEqual(res.status, 503);
});
test('a successful navigation is cached and returned', async () => {
const page = new FakeResponse('html');
const { listeners, puts } = loadSW({
fetchImpl: async () => page,
cacheMatch: async () => undefined,
});
const event = navigateEvent('http://127.0.0.1:8080/help.html');
listeners.fetch(event);
const res = await event.responded;
assert.strictEqual(res, page);
await new Promise(r => setImmediate(r));
assert.strictEqual(puts.length, 1);
assert.strictEqual(puts[0][0], 'http://127.0.0.1:8080/help.html');
});
test('api requests bypass the service worker', () => {
const { listeners } = loadSW({
fetchImpl: async () => { throw new Error('unexpected'); },
cacheMatch: async () => undefined,
});
const event = navigateEvent('http://127.0.0.1:8080/api/status');
event.request.mode = 'cors';
listeners.fetch(event);
assert.strictEqual(event.responded, null);
});
test('uncached asset hits the network and gets cached', async () => {
const asset = new FakeResponse('js');
const { listeners, puts } = loadSW({
fetchImpl: async () => asset,
cacheMatch: async () => undefined,
});
const event = navigateEvent('http://127.0.0.1:8080/des-bundle.js');
event.request.mode = 'cors';
listeners.fetch(event);
const res = await event.responded;
assert.strictEqual(res, asset);
await new Promise(r => setImmediate(r));
assert.strictEqual(puts.length, 1);
});
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "pysim-otaman-server" name = "pysim-otaman-server"
version = "1.9.28" version = "2.1.1"
description = "HTTP REST server wrapping pysim for the OTAMan PWA" description = "HTTP REST server wrapping pysim for the OTAMan PWA"
requires-python = ">=3.8" requires-python = ">=3.8"
# pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh.
+247 -57
View File
@@ -19,7 +19,7 @@ from osmocom.construct import GsmOrUcs2Adapter
from osmocom.tlv import BER_TLV_IE from osmocom.tlv import BER_TLV_IE
VERSION = '1.9.28' VERSION = '2.1.1'
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
@@ -57,6 +57,38 @@ def _tlog(msg):
sys.stderr.write('TIMING [+%7.3fs] %s\n' % (time.time() - _T0, msg)) sys.stderr.write('TIMING [+%7.3fs] %s\n' % (time.time() - _T0, msg))
_APDU_TIMES = []
_APDU_TIME_COLLECT = False
def _classify_apdu(cmd):
"""Map a command APDU to a snapshot timing category by instruction byte."""
if not cmd or len(cmd) < 4:
return None
return {'A4': 'select', 'B0': 'read_binary', 'B2': 'read_record'}.get(cmd[2:4].upper())
def _collect_apdu_times():
"""Start collecting per-command times. (Re)attaches our tracer if pySim
nulled it (equip does). Callers hold _CARD_LOCK, so collection cannot be
interleaved by the background poll thread."""
global _APDU_TIME_COLLECT
scc = getattr(_server_ref, 'scc', None) if _server_ref else None
tp = getattr(scc, '_tp', None) if scc else None
if tp is not None and tp.apdu_tracer is None:
tp.apdu_tracer = _LoggingApduTracer()
_APDU_TIMES.clear()
_APDU_TIME_COLLECT = True
def _end_apdu_time_collection():
"""Stop collecting and return the collected [{type, ms}, ...] list."""
global _APDU_TIME_COLLECT
_APDU_TIME_COLLECT = False
times = list(_APDU_TIMES)
_APDU_TIMES.clear()
return times
class StderrApduTracer(ApduTracer): class StderrApduTracer(ApduTracer):
def __init__(self): def __init__(self):
super().__init__() super().__init__()
@@ -75,6 +107,10 @@ class StderrApduTracer(ApduTracer):
global _APDU_N global _APDU_N
_APDU_N += 1 _APDU_N += 1
elapsed = int((time.time() - self._cmd_start) * 1000) elapsed = int((time.time() - self._cmd_start) * 1000)
if _APDU_TIME_COLLECT:
category = _classify_apdu(cmd)
if category:
_APDU_TIMES.append({'type': category, 'ms': elapsed})
if _TIMING: if _TIMING:
msg = 'APDU-TRACE(+%7.3fs #%d, %dms): %s → SW: %s' % (time.time() - _T0, _APDU_N, elapsed, cmd, sw) msg = 'APDU-TRACE(+%7.3fs #%d, %dms): %s → SW: %s' % (time.time() - _T0, _APDU_N, elapsed, cmd, sw)
else: else:
@@ -178,39 +214,143 @@ def _get_file_type(lchan, cur_file):
return None return None
def _select_with_parent(lchan, name, parent_sel, app): def _fid4(sel):
if parent_sel: """True if sel is a 4-digit hex FID."""
lchan.select(parent_sel, app) return bool(re.fullmatch(r'[0-9a-fA-F]{4}', str(sel or '')))
fcp = lchan.select(name, app)
return fcp
def _file_by_sel(parent, sel):
"""Resolve sel (FID or symbolic name, case-insensitive) among parent's direct children."""
if parent is None or not sel:
return None
s = str(sel).strip().lower()
for f in (getattr(parent, 'children', None) or {}).values():
if f.fid and f.fid.lower() == s:
return f
if f.name and f.name.lower() == s:
return f
return None
def _app_by_sel(rs, sel):
"""Resolve an ADF by AID or application name (case-insensitive)."""
if rs is None or not sel:
return None
s = str(sel).strip().lower()
for aid, adf in (rs.mf.applications or {}).items():
if aid.lower() == s or (adf.name and adf.name.lower() == s):
return adf
return None
def _find_in_tree(root, sel):
"""All model files matching sel (fid or name) below root; unique-match helper."""
s = str(sel or '').strip().lower()
found = []
seen = set()
stack = [root]
while stack:
cur = stack.pop()
if id(cur) in seen:
continue
seen.add(id(cur))
candidates = list((getattr(cur, 'children', None) or {}).values())
candidates += list((getattr(cur, 'applications', None) or {}).values())
for f in candidates:
if (f.fid and f.fid.lower() == s) or (f.name and f.name.lower() == s):
found.append(f)
stack.append(f)
return found
def _select_with_parent(lchan, name, parent_sel, app, parent_path=None, allow_probe=False):
"""Select name strictly within the requested parent.
Every model-known FID/name is resolved through the parent's children and
selected with lchan.select_file(); pySim's global selectables and its
probe_file() fallback are never used for model files, so a same-FID file
under a different parent can no longer be picked and the model is not
mutated. Model-unknown 4-hex segments (custom files) are probed only when
allow_probe is set and are detached again via the returned cleanup.
Returns (selected_file, cleanup): cleanup is None unless a probe happened;
handlers must call it in a finally block after using the selection.
"""
rs = app.rs
prev = lchan.selected_file
probes = []
parent = rs.mf
lchan.select_file(parent, app)
segs = [s for s in (parent_path or ([parent_sel] if parent_sel else [])) if s]
for seg in segs:
if str(seg).upper() in ('MF', '3F00'):
continue
f = _app_by_sel(rs, seg) or _file_by_sel(parent, seg)
if f is None and not parent_path:
matches = _find_in_tree(rs.mf, seg)
if len(matches) > 1:
raise RuntimeError('Ambiguous parent selector: %s' % seg)
if matches:
f = matches[0]
if f is None:
if allow_probe and _fid4(seg):
fid = str(seg).lower()
probes.append((parent, fid))
lchan.probe_file(fid, app)
parent = lchan.selected_file
continue
raise RuntimeError('File not found: %s' % seg)
lchan.select_file(f, app)
parent = f
target = None
if str(name).upper() in ('MF', '3F00'):
target = rs.mf
if target is None:
target = _file_by_sel(parent, name) or _app_by_sel(rs, name)
if target is None and not parent_path and not parent_sel:
matches = _find_in_tree(rs.mf, name)
if len(matches) > 1:
raise RuntimeError('Ambiguous file selector: %s' % name)
if matches:
target = matches[0]
if target is not None:
lchan.select_file(target, app)
elif allow_probe and _fid4(name):
fid = str(name).lower()
probes.append((parent, fid))
lchan.probe_file(fid, app)
else:
raise RuntimeError('File not found: %s' % name)
cleanup = None
if probes:
def cleanup():
for p, fid in reversed(probes):
try:
(getattr(p, 'children', None) or {}).pop(fid, None)
except Exception:
pass
try:
lchan.select_file(prev, app)
except Exception:
try:
lchan.select_file(rs.mf, app)
except Exception:
pass
return lchan.selected_file, cleanup
def _select_path(lchan, path, app): def _select_path(lchan, path, app):
"""Select a file described by a full path. """Select a file described by a full path.
Path is '/' separated; the first element is either 'MF' (or the MF fid Path is '/' separated; the first element is 'MF' (or '3F00'), an ADF AID,
'3F00') or an ADF AID (hex). Remaining elements are FIDs or file names. or an ADF name; remaining elements are FIDs or file names. Resolution is
pySim's lchan.select() cannot select an ADF by its raw AID (selectables are strictly parent-scoped (see _select_with_parent); unknown 4-hex segments
keyed by name/fid only), so ADF roots are resolved through rs.mf.applications. are custom files and are probed without touching the model tree.
""" """
parts = [p for p in (path or '').split('/') if p] parts = [p for p in (path or '').split('/') if p]
if not parts: if not parts:
raise RuntimeError('Empty path') raise RuntimeError('Empty path')
rs = app.rs return _select_with_parent(lchan, parts[-1], None, app, parent_path=parts[:-1], allow_probe=True)
first = parts[0]
if first.upper() in ('MF', '3F00'):
lchan.select('MF', app)
else:
aid = first.lower()
adf = rs.mf.applications.get(aid)
if not adf:
adf = next((v for k, v in rs.mf.applications.items() if k.lower() == aid), None)
if not adf:
raise RuntimeError('ADF not found: %s' % first)
lchan.select_file(adf, app)
for seg in parts[1:]:
lchan.select(seg, app)
return lchan.selected_file
def _parse_tree_output(output): def _parse_tree_output(output):
@@ -1565,6 +1705,21 @@ def _menu_send_response(server, result, item_id=None):
return resp, 200 return resp, 200
def _finish_pending_menu(server, scc):
"""A new menu selection must never shadow a FETCHed command that awaits its
TERMINAL RESPONSE: answer it with a cancel TR (0x10) first, then drain any
follow-up proactive command so the card is ready for the new selection."""
pd = server.stk_pending
if not pd:
return
sys.stderr.write('MENU-SELECT: finishing pending cmd=%02x type=%02x with cancel TR\n'
% (pd['cmd_num'], pd['cmd_type']))
resp, _ = _menu_send_response(server, 'cancel', None)
sw = (resp or {}).get('sw', '')
if sw.startswith('91'):
_handle_proactive_chain(scc, sw)
class PysimHandler(BaseHTTPRequestHandler): class PysimHandler(BaseHTTPRequestHandler):
def _send_json(self, data, status=200): def _send_json(self, data, status=200):
self.send_response(status) self.send_response(status)
@@ -1666,6 +1821,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'connected': connected, 'connected': connected,
'card_present': bool(getattr(self.server, 'card_present', False)), 'card_present': bool(getattr(self.server, 'card_present', False)),
'card_session': int(getattr(self.server, 'card_session', 0)), 'card_session': int(getattr(self.server, 'card_session', 0)),
'proactive_seq': _PROACTIVE_ENTRY_ID,
'equipping': bool(getattr(self.server, 'equipping', False)), 'equipping': bool(getattr(self.server, 'equipping', False)),
'auto_equip': bool(_AUTO_EQUIP), 'auto_equip': bool(_AUTO_EQUIP),
'card': card.name if card else None, 'card': card.name if card else None,
@@ -1909,18 +2065,25 @@ class PysimHandler(BaseHTTPRequestHandler):
fid = body.get('fid') fid = body.get('fid')
name = fid if fid else body.get('name', '') name = fid if fid else body.get('name', '')
parent_sel = body.get('parent_sel') parent_sel = body.get('parent_sel')
parent_path = body.get('parent_path')
allow_probe = bool(body.get('allow_probe'))
rs = app.rs rs = app.rs
if not rs: if not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503) self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)}) self._log_resp({'error': _err('no_card_state', lang)})
return return
lchan = rs.lchan[0] lchan = rs.lchan[0]
cleanup = None
try: try:
if path: _collect_apdu_times()
_select_path(lchan, path, app) try:
else: if path:
_select_with_parent(lchan, name, parent_sel, app) cur, cleanup = _select_path(lchan, path, app)
cur = lchan.selected_file else:
cur, cleanup = _select_with_parent(lchan, name, parent_sel, app, parent_path, allow_probe)
finally:
apdu_times = _end_apdu_time_collection()
cur = cur or lchan.selected_file
data = { data = {
'name': cur.name if cur else None, 'name': cur.name if cur else None,
'fid': cur.fid.upper() if cur and cur.fid else None, 'fid': cur.fid.upper() if cur and cur.fid else None,
@@ -1929,6 +2092,7 @@ class PysimHandler(BaseHTTPRequestHandler):
'record_len': lchan.selected_file_record_len() if lchan else None, 'record_len': lchan.selected_file_record_len() if lchan else None,
'num_of_rec': lchan.selected_file_num_of_rec() if lchan else None, 'num_of_rec': lchan.selected_file_num_of_rec() if lchan else None,
'fci_hex': (lchan.selected_file_fcp_hex or '').upper() if lchan and lchan.selected_file_fcp_hex else None, 'fci_hex': (lchan.selected_file_fcp_hex or '').upper() if lchan and lchan.selected_file_fcp_hex else None,
'apdu_times': apdu_times,
'exists': True, 'exists': True,
} }
self._send_json(data) self._send_json(data)
@@ -1937,6 +2101,9 @@ class PysimHandler(BaseHTTPRequestHandler):
err = {'error': str(e), 'exists': False} err = {'error': str(e), 'exists': False}
self._send_json(err, 404) self._send_json(err, 404)
self._log_resp(err) self._log_resp(err)
finally:
if cleanup:
cleanup()
elif self.path == '/api/read': elif self.path == '/api/read':
app = self.server.app app = self.server.app
if not app: if not app:
@@ -1949,6 +2116,8 @@ class PysimHandler(BaseHTTPRequestHandler):
fid = body.get('fid') fid = body.get('fid')
name = fid if fid else body.get('name', '') name = fid if fid else body.get('name', '')
parent_sel = body.get('parent_sel') parent_sel = body.get('parent_sel')
parent_path = body.get('parent_path')
allow_probe = bool(body.get('allow_probe'))
mode = body.get('mode', 'raw') mode = body.get('mode', 'raw')
rs = app.rs rs = app.rs
if not rs: if not rs:
@@ -1956,29 +2125,34 @@ class PysimHandler(BaseHTTPRequestHandler):
self._log_resp({'error': _err('no_card_state', lang)}) self._log_resp({'error': _err('no_card_state', lang)})
return return
lchan = rs.lchan[0] lchan = rs.lchan[0]
cleanup = None
try: try:
sel = fid if fid else name _collect_apdu_times()
if path:
_select_path(lchan, path, app)
else:
_select_with_parent(lchan, sel, parent_sel, app)
ft = _get_file_type(lchan, lchan.selected_file)
is_record = ft in ('linear_fixed', 'cyclic')
if mode == 'decoded':
cmd = 'read_records_decoded' if is_record else 'read_binary_decoded'
else:
cmd = 'read_records' if is_record else 'read_binary'
out = StringIO()
old_stdout = app.stdout
old_stderr = sys.stderr
app.stdout = out
sys.stderr = out
try: try:
app.onecmd_plus_hooks(cmd) sel = fid if fid else name
output = _strip_ansi(out.getvalue()) if path:
_, cleanup = _select_path(lchan, path, app)
else:
_, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
ft = _get_file_type(lchan, lchan.selected_file)
is_record = ft in ('linear_fixed', 'cyclic')
if mode == 'decoded':
cmd = 'read_records_decoded' if is_record else 'read_binary_decoded'
else:
cmd = 'read_records' if is_record else 'read_binary'
out = StringIO()
old_stdout = app.stdout
old_stderr = sys.stderr
app.stdout = out
sys.stderr = out
try:
app.onecmd_plus_hooks(cmd)
output = _strip_ansi(out.getvalue())
finally:
app.stdout = old_stdout
sys.stderr = old_stderr
finally: finally:
app.stdout = old_stdout apdu_times = _end_apdu_time_collection()
sys.stderr = old_stderr
sw_match = re.search(r'SW:\s*(\w+)', output) sw_match = re.search(r'SW:\s*(\w+)', output)
err_match = re.search(r'got (\w+)', output) err_match = re.search(r'got (\w+)', output)
if err_match: if err_match:
@@ -1995,24 +2169,27 @@ class PysimHandler(BaseHTTPRequestHandler):
if mode == 'decoded': if mode == 'decoded':
try: try:
parsed = json.loads(clean) parsed = json.loads(clean)
resp = {'success': True, 'sw': sw, 'file_type': ft, 'decoded': parsed} resp = {'success': True, 'sw': sw, 'file_type': ft, 'decoded': parsed, 'apdu_times': apdu_times}
except json.JSONDecodeError: except json.JSONDecodeError:
resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean} resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean, 'apdu_times': apdu_times}
elif is_record: elif is_record:
records = [] records = []
for line in clean.split('\n'): for line in clean.split('\n'):
m = re.match(r'^(\d+)\s(.+)', line) m = re.match(r'^(\d+)\s(.+)', line)
if m: if m:
records.append({'num': int(m.group(1)), 'data': m.group(2)}) records.append({'num': int(m.group(1)), 'data': m.group(2)})
resp = {'success': True, 'sw': sw, 'file_type': ft, 'records': records} resp = {'success': True, 'sw': sw, 'file_type': ft, 'records': records, 'apdu_times': apdu_times}
else: else:
resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean} resp = {'success': True, 'sw': sw, 'file_type': ft, 'data': clean, 'apdu_times': apdu_times}
self._send_json(resp) self._send_json(resp)
self._log_resp(resp) self._log_resp(resp)
except Exception as e: except Exception as e:
err = {'success': False, 'error': str(e)} err = {'success': False, 'error': str(e)}
self._send_json(err, 500) self._send_json(err, 500)
self._log_resp(err) self._log_resp(err)
finally:
if cleanup:
cleanup()
elif self.path == '/api/write': elif self.path == '/api/write':
app = self.server.app app = self.server.app
if not app: if not app:
@@ -2026,15 +2203,18 @@ class PysimHandler(BaseHTTPRequestHandler):
fid = body.get('fid') fid = body.get('fid')
record_nr = body.get('record_nr') record_nr = body.get('record_nr')
parent_sel = body.get('parent_sel') parent_sel = body.get('parent_sel')
parent_path = body.get('parent_path')
allow_probe = bool(body.get('allow_probe'))
rs = app.rs rs = app.rs
if not rs: if not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503) self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)}) self._log_resp({'error': _err('no_card_state', lang)})
return return
lchan = rs.lchan[0] lchan = rs.lchan[0]
cleanup = None
try: try:
sel = fid if fid else name sel = fid if fid else name
_select_with_parent(lchan, sel, parent_sel, app) _, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
ft = _get_file_type(lchan, lchan.selected_file) ft = _get_file_type(lchan, lchan.selected_file)
is_record = ft in ('linear_fixed', 'cyclic') is_record = ft in ('linear_fixed', 'cyclic')
if record_nr: if record_nr:
@@ -2071,6 +2251,9 @@ class PysimHandler(BaseHTTPRequestHandler):
err = {'success': False, 'error': str(e)} err = {'success': False, 'error': str(e)}
self._send_json(err, 500) self._send_json(err, 500)
self._log_resp(err) self._log_resp(err)
finally:
if cleanup:
cleanup()
elif self.path == '/api/tree': elif self.path == '/api/tree':
app = self.server.app app = self.server.app
if not app: if not app:
@@ -2083,15 +2266,18 @@ class PysimHandler(BaseHTTPRequestHandler):
name = fid if fid else body.get('name', '') name = fid if fid else body.get('name', '')
fid = body.get('fid') fid = body.get('fid')
parent_sel = body.get('parent_sel') parent_sel = body.get('parent_sel')
parent_path = body.get('parent_path')
allow_probe = bool(body.get('allow_probe'))
rs = app.rs rs = app.rs
if not rs: if not rs:
self._send_json({'error': _err('no_card_state', lang)}, 503) self._send_json({'error': _err('no_card_state', lang)}, 503)
self._log_resp({'error': _err('no_card_state', lang)}) self._log_resp({'error': _err('no_card_state', lang)})
return return
lchan = rs.lchan[0] lchan = rs.lchan[0]
cleanup = None
try: try:
sel = fid if fid else name sel = fid if fid else name
_select_with_parent(lchan, sel, parent_sel, app) _, cleanup = _select_with_parent(lchan, sel, parent_sel, app, parent_path, allow_probe)
cur = lchan.selected_file cur = lchan.selected_file
out = StringIO() out = StringIO()
old_stdout = app.stdout old_stdout = app.stdout
@@ -2124,9 +2310,12 @@ class PysimHandler(BaseHTTPRequestHandler):
sys.stderr.write('Handler error: %s\n' % e) sys.stderr.write('Handler error: %s\n' % e)
if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e): if 'Card' in str(e) or 'Transaction' in str(e) or 'Transmit' in str(e):
_handle_card_disconnect() _handle_card_disconnect()
err = {'success': False, 'error': str(e)} err = {'success': False, 'error': str(e), 'exists': False}
self._send_json(err, 500) self._send_json(err, 500)
self._log_resp(err) self._log_resp(err)
finally:
if cleanup:
cleanup()
elif self.path == '/api/menu-select': elif self.path == '/api/menu-select':
scc = self.server.scc scc = self.server.scc
if not scc: if not scc:
@@ -2134,6 +2323,7 @@ class PysimHandler(BaseHTTPRequestHandler):
return return
body = self._read_body() body = self._read_body()
self._log_req(body) self._log_req(body)
_finish_pending_menu(self.server, scc)
item_id = body.get('item_id', 0) item_id = body.get('item_id', 0)
if not isinstance(item_id, int): if not isinstance(item_id, int):
item_id = int(item_id) item_id = int(item_id)
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env python3
"""Tests for per-command APDU timing collection (card snapshot measurements)."""
import sys
import time
import types
import unittest
from pathlib import Path
from unittest import mock
PROJECTS = Path(__file__).resolve().parents[2]
PY_SIM = PROJECTS / 'pysim'
if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM))
import pysim_otaman_server.server as S
class TestClassifyApdu(unittest.TestCase):
def test_select(self):
self.assertEqual(S._classify_apdu('00a40004023f0000'), 'select')
def test_read_binary(self):
self.assertEqual(S._classify_apdu('00b000000a'), 'read_binary')
def test_read_record(self):
self.assertEqual(S._classify_apdu('00b2010428'), 'read_record')
def test_other_not_classified(self):
self.assertIsNone(S._classify_apdu('80f2000c00'))
def test_short_input(self):
self.assertIsNone(S._classify_apdu(''))
self.assertIsNone(S._classify_apdu('00'))
class TestApduTimeCollection(unittest.TestCase):
def setUp(self):
self.saved = (S._APDU_TIME_COLLECT, list(S._APDU_TIMES), S._server_ref)
S._APDU_TIME_COLLECT = False
S._APDU_TIMES.clear()
S._server_ref = None
def tearDown(self):
S._APDU_TIME_COLLECT, times, S._server_ref = self.saved
S._APDU_TIMES[:] = times
def test_disabled_does_not_collect(self):
tracer = S.StderrApduTracer()
with mock.patch.object(S.os, 'write'):
tracer.trace_command('00a40004023f0000')
tracer.trace_response('00a40004023f0000', '9000', '')
self.assertEqual(S._APDU_TIMES, [])
def test_collects_only_classified_commands_with_ms(self):
S._collect_apdu_times()
tracer = S.StderrApduTracer()
with mock.patch.object(S.os, 'write'):
tracer._cmd_start = time.time() - 0.025
tracer.trace_response('00a40004023f0000', '9000', '')
tracer._cmd_start = time.time() - 0.010
tracer.trace_response('00b000000a', '9000', '')
tracer._cmd_start = time.time() - 0.005
tracer.trace_response('80f2000c00', '9000', '')
times = S._end_apdu_time_collection()
self.assertEqual([t['type'] for t in times], ['select', 'read_binary'])
self.assertGreaterEqual(times[0]['ms'], 20)
self.assertFalse(S._APDU_TIME_COLLECT)
self.assertEqual(S._APDU_TIMES, [])
def test_collect_reattaches_tracer_when_missing(self):
tp = types.SimpleNamespace(apdu_tracer=None)
scc = types.SimpleNamespace(_tp=tp)
S._server_ref = types.SimpleNamespace(scc=scc)
S._collect_apdu_times()
try:
self.assertIsInstance(tp.apdu_tracer, S._LoggingApduTracer)
finally:
S._end_apdu_time_collection()
def test_collect_keeps_existing_tracer(self):
tracer = S.StderrApduTracer()
tp = types.SimpleNamespace(apdu_tracer=tracer)
scc = types.SimpleNamespace(_tp=tp)
S._server_ref = types.SimpleNamespace(scc=scc)
S._collect_apdu_times()
try:
self.assertIs(tp.apdu_tracer, tracer)
finally:
S._end_apdu_time_collection()
if __name__ == '__main__':
unittest.main()
+39
View File
@@ -87,3 +87,42 @@ class TestMenuSendResponse(unittest.TestCase):
if __name__ == '__main__': if __name__ == '__main__':
unittest.main() unittest.main()
class TestFinishPendingMenu(unittest.TestCase):
def make_server(self):
return types.SimpleNamespace(
stk_pending={'type': 'select_item', 'cmd_num': 1, 'cmd_type': 0x24,
'dev_src': 0x81, 'dev_dst': 0x83, 'items': []},
menu_active=True, scc=None)
def make_scc(self, sent, sw='9000'):
return types.SimpleNamespace(
cat_cla='80',
_tp=types.SimpleNamespace(send_apdu=lambda h: (sent.append(h) or ('', sw))))
def test_no_pending_is_noop(self):
sent = []
S._finish_pending_menu(types.SimpleNamespace(stk_pending=None), self.make_scc(sent))
self.assertEqual(sent, [])
def test_pending_finished_with_cancel_tr(self):
server = self.make_server()
sent = []
scc = self.make_scc(sent)
server.scc = scc
S._finish_pending_menu(server, scc)
self.assertEqual(len(sent), 1)
tr = sent[0]
self.assertTrue(tr.startswith('801400000d'), tr)
self.assertIn('83021000', tr) # general result 0x10 = cancel
self.assertIsNone(server.stk_pending)
self.assertFalse(server.menu_active)
def test_91xx_answer_drains_chain(self):
server = self.make_server()
scc = self.make_scc([], sw='9120')
server.scc = scc
with mock.patch.object(S, '_handle_proactive_chain') as chain:
S._finish_pending_menu(server, scc)
chain.assert_called_once_with(scc, '9120')
+183
View File
@@ -0,0 +1,183 @@
#!/usr/bin/env python3
"""Unit tests for the parent-scoped select helpers in pysim_otaman_server.server.
The helpers must resolve every model-known file strictly within the requested
parent (no pySim global selectables, no probe_file model injection) and must
detach any model-unknown file that had to be probed for a custom file.
"""
import sys
import unittest
from pathlib import Path
from types import SimpleNamespace
PROJECTS = Path(__file__).resolve().parents[2]
PY_SIM = PROJECTS / 'pysim'
if str(PY_SIM) not in sys.path:
sys.path.insert(0, str(PY_SIM))
from pysim_otaman_server.server import (
_app_by_sel,
_fid4,
_file_by_sel,
_find_in_tree,
_select_path,
_select_with_parent,
)
class FakeFile:
def __init__(self, fid=None, name=None, parent=None, aid=None):
self.fid = fid
self.name = name
self.parent = parent
self.aid = aid
self.sfid = None
self.children = {}
def add_files(self, files):
for f in files:
f.parent = self
self.children[f.fid] = f
class FakeMF(FakeFile):
def __init__(self):
super().__init__(fid='3f00', name='MF')
self.applications = {}
class FakeLchan:
"""No .select() on purpose: any global-resolution call would fail loudly."""
def __init__(self, mf):
self.selected_file = mf
self.selects = []
self.probes = []
def select_file(self, f, app=None):
self.selected_file = f
self.selects.append(f)
def probe_file(self, fid, app=None):
self.probes.append(fid)
f = FakeFile(fid=fid, name='EF.' + fid.upper(), parent=self.selected_file)
self.selected_file.add_files([f])
self.selected_file = f
def build_model():
mf = FakeMF()
gsm = FakeFile('7f20', 'DF.GSM', mf)
mf.children['7f20'] = gsm
spn = FakeFile('6f46', 'EF.SPN', gsm)
gsm.children['6f46'] = spn
telecom = FakeFile('7f10', 'DF.TELECOM', mf)
mf.children['7f10'] = telecom
tel_ph = FakeFile('5f3a', 'DF.PHONEBOOK', telecom)
telecom.children['5f3a'] = tel_ph
usim = FakeFile(None, 'ADF.USIM', mf, aid='A0000000871002')
mf.applications['a0000000871002'] = usim
imsi = FakeFile('6f07', 'EF.IMSI', usim)
usim.children['6f07'] = imsi
usim_ph = FakeFile('5f3a', 'DF.PHONEBOOK', usim)
usim.children['5f3a'] = usim_ph
return mf, usim, usim_ph, telecom, tel_ph, gsm, spn
def setup():
mf, usim, usim_ph, telecom, tel_ph, gsm, spn = build_model()
app = SimpleNamespace(rs=SimpleNamespace(mf=mf))
lchan = FakeLchan(mf)
return app, lchan, mf, usim, usim_ph, gsm, spn
class FidHelpersTest(unittest.TestCase):
def test_fid4(self):
self.assertTrue(_fid4('6F07'))
self.assertFalse(_fid4('EF.IMSI'))
self.assertFalse(_fid4('6F0'))
def test_file_by_sel_matches_fid_and_name(self):
_, _, _, _, _, gsm, spn = setup()
self.assertIs(_file_by_sel(gsm, '6f46'), spn)
self.assertIs(_file_by_sel(gsm, 'EF.SPN'), spn)
self.assertIsNone(_file_by_sel(gsm, '6f07'))
def test_find_in_tree_reports_duplicates(self):
app, _, mf, _, _, _, _ = setup()
self.assertEqual(len(_find_in_tree(mf, '5f3a')), 2)
self.assertEqual(len(_find_in_tree(mf, 'EF.IMSI')), 1)
class ParentScopedSelectTest(unittest.TestCase):
def test_duplicate_fid_is_resolved_under_the_walked_parent(self):
app, lchan, mf, usim, usim_ph, _, _ = setup()
target, cleanup = _select_with_parent(lchan, '5f3a', None, app, parent_path=['MF', 'A0000000871002'])
self.assertIs(target, usim_ph)
self.assertIsNone(cleanup)
self.assertEqual(lchan.selects, [mf, usim, usim_ph])
self.assertEqual(lchan.probes, [])
def test_path_with_fids_selects_exactly(self):
app, lchan, mf, _, _, gsm, spn = setup()
target, cleanup = _select_path(lchan, 'MF/7F20/6F46', app)
self.assertIs(target, spn)
self.assertIsNone(cleanup)
self.assertEqual(lchan.selects, [mf, gsm, spn])
def test_path_with_aid_root_selects_application(self):
app, lchan, _, usim, _, _, _ = setup()
target, _ = _select_path(lchan, 'A0000000871002/6F07', app)
self.assertEqual(target.fid, '6f07')
self.assertEqual(lchan.selected_file.parent, usim)
def test_ambiguous_legacy_parent_selector_is_rejected(self):
app, lchan, _, _, _, _, _ = setup()
with self.assertRaisesRegex(RuntimeError, 'Ambiguous'):
_select_with_parent(lchan, '6f07', '5f3a', app)
def test_unknown_name_is_not_probed(self):
app, lchan, _, _, _, gsm, _ = setup()
with self.assertRaisesRegex(RuntimeError, 'File not found'):
_select_with_parent(lchan, 'NOSUCH', None, app, parent_path=['MF', '7F20'])
self.assertEqual(lchan.probes, [])
def test_unknown_fid_without_allow_probe_does_not_touch_the_card(self):
app, lchan, _, _, _, gsm, _ = setup()
with self.assertRaisesRegex(RuntimeError, 'File not found'):
_select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'])
self.assertEqual(lchan.probes, [])
def test_allow_probe_detaches_the_temporary_file_and_restores_selection(self):
app, lchan, mf, _, _, gsm, _ = setup()
before = set(gsm.children)
target, cleanup = _select_with_parent(lchan, '6f99', None, app, parent_path=['MF', '7F20'], allow_probe=True)
self.assertEqual(lchan.probes, ['6f99'])
self.assertEqual(target.fid, '6f99')
self.assertIsNotNone(cleanup)
self.assertIn('6f99', gsm.children)
cleanup()
self.assertEqual(set(gsm.children), before)
self.assertIs(lchan.selected_file, mf)
def test_custom_path_segments_are_probed_and_detached(self):
app, lchan, mf, _, _, gsm, _ = setup()
before = set(gsm.children)
target, cleanup = _select_path(lchan, 'MF/7F20/A0B1/6F01', app)
self.assertEqual(lchan.probes, ['a0b1', '6f01'])
self.assertEqual(target.fid, '6f01')
cleanup()
self.assertEqual(set(gsm.children), before)
self.assertIs(lchan.selected_file, mf)
def test_model_known_selection_never_mutates_the_tree(self):
app, lchan, _, _, _, gsm, spn = setup()
before = {id(k): k for k in gsm.children}
_select_with_parent(lchan, '6f46', None, app, parent_path=['MF', '7F20'])
self.assertEqual({id(k): k for k in gsm.children}, before)
self.assertEqual(lchan.probes, [])
if __name__ == '__main__':
unittest.main()