Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 851ff65af6 | |||
| 68a22e854b | |||
| 39d384636e | |||
| 14d15b1fe8 | |||
| 110dc1f1ad | |||
| fb65644328 |
@@ -9,6 +9,36 @@ every change carries a descriptive commit message.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [3.36.0] — 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- The batch **"waiting for the next card"** state is now a deliberate cue: the status
|
||||
modal shows a card-into-reader animation next to the prompt, a short chime sounds
|
||||
**once per card** when the run enters the waiting phase (Web Audio — no audio asset;
|
||||
edge-triggered in the 2 s poll, so it does not repeat), and while the tab is hidden the
|
||||
title hints "Insert next card". The speaker button next to the status mutes the chime
|
||||
and the choice is remembered (`simple_ram_batch_sound`); the chime is unlocked by the
|
||||
Start click (browser autoplay policy); reduced-motion users get the static card-above-
|
||||
the-slot picture. The card follows the app's SIM silhouette (`sim.svg`): a cut corner
|
||||
at the top and the contact pad near the bottom edge, sliding behind the reader bar.
|
||||
|
||||
### Changed
|
||||
|
||||
- A **batch delete that finds the object already gone** is still a success — the goal is met
|
||||
and the run continues — but it is no longer reported as a plain "OK": the attempt's verdict
|
||||
reads **Already absent**, the delete run's counts separate real deletions from no-ops
|
||||
(`Deleted: N · Already absent: M`), and the notice says the card was already absent. The
|
||||
counts gain an `already_absent` bucket (the attempt result is `already_absent`); a
|
||||
reinstall's tolerated pre-delete is *not* affected — its outcome is still the install.
|
||||
- The READMEs link the project's news channel: [Telegram — @youeyeseesee](https://t.me/youeyeseesee).
|
||||
|
||||
### Fixed
|
||||
|
||||
- The prebuilt `style.css` had gone stale: a rebuild restored utility classes the UI
|
||||
already used but the output lacked (e.g. `bg-blue-50` on the batch notice, `inline-flex`,
|
||||
`mx-2`, `max-h-48`, `w-44`, `min-w-24`).
|
||||
|
||||
## [3.35.0] — 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> Formerly known as OTAMan — renamed to SIMple on 2026-09-19.
|
||||
|
||||
📢 Project news and updates: [Telegram channel](https://t.me/youeyeseesee)
|
||||
|
||||
SIMple is a workbench for UICC/SIM card specialists: an offline HTML/JS PWA plus a local card server ([`pysim-simple-server`](pysim_simple_server/), a pySim/PC-SC bridge that also serves the PWA). It covers the full lab cycle:
|
||||
|
||||
- **APDU workbench** — SIM/USIM RFM and Expanded Script builders (TS 102 226), GlobalPlatform RAM commands, a C-APDU/R-APDU parser and a response decoder (ISO 7816 / TS 102 221 / GP);
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
> Прежнее название — OTAMan; переименован в SIMple 2026-09-19.
|
||||
|
||||
📢 Новости и обновления проекта: [Telegram-канал](https://t.me/youeyeseesee)
|
||||
|
||||
SIMple — инструментарий для специалистов по UICC/SIM-картам: автономный HTML/JS PWA и локальный сервер карт ([`pysim-simple-server`](pysim_simple_server/) — мост к pySim/PC-SC, он же раздаёт PWA). Закрывает весь цикл лабораторной работы:
|
||||
|
||||
- **APDU-верстак** — конструкторы SIM/USIM RFM и Expanded Script (TS 102 226), команды GlobalPlatform RAM, парсер C-APDU/R-APDU и декодер ответов (ISO 7816 / TS 102 221 / GP);
|
||||
|
||||
File diff suppressed because one or more lines are too long
+2
-2
@@ -324,13 +324,13 @@
|
||||
<tr><td class="py-1 px-2">Install Package (.cap file)</td><td class="py-1 px-2">Sends a <code class="font-mono text-sm">.cap</code> file to the card via the server: INSTALL[for load] → LOAD ×N → INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1–240 bytes of payload, 240 by default); each secured packet is delivered as a single SMS or, when larger, as a concatenated SMS-PP download of up to 5 segments, so a large <code class="font-mono text-sm">.cap</code> simply takes several SMS. As soon as the file is selected it is validated and its <strong>memory requirements are estimated</strong> in the form: the <strong>NVRAM requirement</strong> (the code image + persistent data, the GlobalPlatform Card Spec C6+C8 style total) and the RAM estimate. The same box reports the <strong>libraries the CAP is linked against</strong> (its Import component: library name, AID and the minimum version <code class="font-mono text-sm">≥ x.y</code>, with standard names and families resolved and a Java Card SDK hint where known), the package/applet identity and the component breakdown (sizes and shares in load-file order) — grouped under the <strong>Package</strong>, <strong>Requires</strong>, <strong>Memory</strong> and <strong>Components</strong> headings. The <strong>Platform</strong> line names the minimum Java Card level the card must provide (derived from the <code class="font-mono text-sm">javacard.framework</code> import; the header <code class="font-mono text-sm">int</code> flag additionally requires int support, Java Card 2.2+) and the <strong>ETSI release</strong> the UICC API imports come from (<code class="font-mono text-sm">uicc.toolkit</code> version → REL-N, per the workspace <code class="font-mono text-sm">sim-tools</code> corpus; shown with its basis in brackets): the JCRE checks every import against its own packages when it verifies the load file (JC VM spec 4.5.2), so a card whose platform is older rejects the LOAD as soon as the <strong>Import</strong> component is complete (the step line carries the card's remote SW and names the component the block completed, e.g. <em>completes Import</em>): the failure hint and the popup then point at the <strong>CAP compatibility test</strong> (one click — the same <code class="font-mono text-sm">.cap</code> is still selected) to find the missing package and version; a smaller LOAD block only helps when the block exceeds the card's buffer. A corrupt or wrong-format file fails the analysis, and <strong>Execute</strong> stays disabled until a successful analysis. The estimate is informational only and is never used for installation: NVRAM is the code image plus the persistent data (the GlobalPlatform C6+C8 style total, Table 11-48 — card memory management, rounding and the registry entry are not included, and the static field image is counted in both parts); the suggested <strong>C6</strong> follows the load file, and the suggested quotas are decimal byte counts (the hex in parentheses is how the C7/C8 install parameters encode them: C6 = non-volatile code, C7 = volatile RAM, C8 = non-volatile data). The <strong>Delete the package before installing (with its applications)</strong> checkbox prepends a cascade GP <code class="font-mono text-sm">DELETE</code> on the package AID (the load file with its modules and applications, P2=<code class="font-mono text-sm">0x80</code>) — a leftover package blocks INSTALL[for load]; 'not found' (a fresh card) proceeds, any other delete failure stops the chain with its reason.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch install (.cap file, per card)</td><td class="py-1 px-2">The bulk workflow: one <code class="font-mono text-sm">.cap</code> and one set of install parameters, applied to card after card. <strong>Start batch</strong> needs no card — the server owns the loop: it waits for a card, installs the moment the equip finishes, then waits for the next one. Every card's <strong>preset is matched by ICCID</strong> and supplies the keyset (the ISD role TAR's default, v3.26.0), the keys and the counter; a card with no matching preset or no usable ISD keyset is reported and skipped. Each attempt lands in the list with its time, ICCID, preset, verdict and duration; a failure shows the failing step and the remote status word — the batch never retries and never stops on a failure (failures are yours to manage afterwards). A card is attempted <strong>once per run</strong>: re-equipping it reports “already processed” — restart the batch to retry it. <strong>Stop</strong> finishes the current card first (an interrupted LOAD chain can leave a half-loaded package) and then ends the run; <strong>Clear</strong> drops the finished report. The loop runs in the server, so closing the tab, switching views or reloading the page does not interrupt it; a server restart ends it. A batch and a test run exclude each other. The batch shares the install form, so the <strong>Delete the package before installing</strong> option applies to every card.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch reinstall app (per card)</td><td class="py-1 px-2">The repair workflow: for every equipped card, delete the applet instance (tolerating 'not found') and run INSTALL [for install] again from the already loaded package — no <code class="font-mono text-sm">.cap</code> and no LOAD. The fields are the single INSTALL [for install] parameters (Load File / Module / Instance AIDs, privileges, the toolkit/STK parameters, <em>make selectable</em>); the preset, keyset and counter come from each card's own preset like the other batch operations, and the attempts list shows both verdicts (delete and install).</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as such and counted OK; any other failure is reported with the card's status word.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Batch delete (per card)</td><td class="py-1 px-2">Deletes one object on every equipped card: an application (instance only, P2=<code class="font-mono text-sm">0x00</code>) or a package with its applications (cascade, P2=<code class="font-mono text-sm">0x80</code>). Fields: the AID and the target. A 'not found' result (the object is already gone) is reported as <strong>Already absent</strong> and counted separately from real deletions — it is still a success (the goal is met) and the run continues; any other failure is reported with the card's status word.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">CAP compatibility test</td><td class="py-1 px-2">Answers one question without installing anything: <strong>can this card satisfy the CAP's import list?</strong> The server runs the format check, INSTALL[for load] and the LOAD blocks only up to the block that completes the <strong>Import</strong> component — the point where the JCRE verifies the import list — then stops: no last-block flag and no INSTALL[for install], so nothing is committed and nothing has to be deleted afterwards. The <strong>Import versions to test</strong> list is pre-filled from the CAP analysis: <code class="font-mono text-sm">AID=version</code> rewrites an import's requested version, <code class="font-mono text-sm">+AID=version</code> appends a synthetic import (a card-capability query: the LOAD passes only if the card exports that package at ≥ that version), and <strong>Set all 0.0</strong> drops every requirement. Two-step use: press Set all 0.0 — if the test passes, the import list was the blocker; then restore one line at a time to find the package and version the card lacks. The verdict states it covers the <strong>LOAD/import gate only</strong>: the link gate (whether the applet's actual API calls exist) is what a real install checks at INSTALL[for install]. The run and its result appear in a popup (like the install): the step lines, the verdict and a per-AID detail block — on a rejection it names the CAP section the failing block covers (load-file byte range and components, and whether that block completes <strong>Import</strong>), the remote status word with its meaning, the import list exactly as tested (CAP versions, probe rewrites, appended synthetic entries, unmatched lines) and what the probe state implies; on success it lists the import list that passed.</td></tr>
|
||||
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">INSTALL [for install] (loaded package)</td><td class="py-1 px-2">For a package that is already loaded on the card (its Load File AID and Module AID, privileges, install/STK parameters and an optional <em>make selectable</em>). The applet instance is deleted first (tolerating 'not found' — a fresh card just installs; any other delete failure stops the chain with its reason), so the install parameters can be iterated without re-loading the <code class="font-mono text-sm">.cap</code>.</td></tr>
|
||||
<tr><td class="py-1 px-2">INSTALL [for make selectable]</td><td class="py-1 px-2">One INSTALL [for make selectable] APDU (the instance AID) for an INSTALLED, non-selectable applet instance.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p class="text-sm mb-3">A batch operation shows its status in a modal window: the live progress (or “Insert next card. Waiting for a card...”) on top and the processed cards below, with <strong>Stop</strong> and <strong>Clear</strong> inside. It opens on Start and on a page load while a run is active; <strong>Close</strong> stays disabled until the run ends — the run itself is server-side and survives a reload. After a finished run the panel’s <strong>Show status</strong> button reopens the report.</p>
|
||||
<p class="text-sm mb-3">A batch operation shows its status in a modal window: the live progress (or “Insert next card. Waiting for a card...”) on top and the processed cards below, with <strong>Stop</strong> and <strong>Clear</strong> inside. It opens on Start and on a page load while a run is active; <strong>Close</strong> stays disabled until the run ends — the run itself is server-side and survives a reload. After a finished run the panel’s <strong>Show status</strong> button reopens the report. While the run waits for a card the modal shows a card-into-reader animation and plays a short chime once per card (the speaker button next to the status mutes it and the choice is remembered); when the window is in the background the tab title asks for the next card.</p>
|
||||
|
||||
<h4 id="ram-explorer" class="font-medium mb-1">Explorer View</h4>
|
||||
<p class="text-sm mb-2">After “Explore Card” runs, the explorer displays:</p>
|
||||
|
||||
+133
-4
@@ -1918,7 +1918,22 @@
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-gray-600 dark:text-slate-400">
|
||||
<div id="ram-op-spin" class="animate-spin w-4 h-4 border-2 border-blue-500 border-t-transparent rounded-full"></div>
|
||||
<span id="ram-op-insert" class="hidden items-center" aria-hidden="true">
|
||||
<svg viewBox="0 0 72 48" width="64" height="43" class="shrink-0">
|
||||
<!-- the card follows the app's SIM silhouette: a cut corner and the contact pad -->
|
||||
<g class="ram-insert-card text-blue-600 dark:text-blue-400">
|
||||
<path d="M29.5 0.5 H39.5 L45 6 V24 A2.5 2.5 0 0 1 42.5 26.5 H29.5 A2.5 2.5 0 0 1 27 24 V3 A2.5 2.5 0 0 1 29.5 0.5 Z" fill="currentColor"></path>
|
||||
<rect x="31.5" y="16.5" width="9" height="8" rx="1.2" fill="#fff" opacity="0.92"></rect>
|
||||
<path d="M31.5 20.5h9M34.5 16.5v8M37.5 16.5v8" stroke="currentColor" stroke-width="0.55" opacity="0.6"></path>
|
||||
</g>
|
||||
<!-- the reader bar and its slot, drawn after the card so it slides behind -->
|
||||
<rect x="3" y="27" width="66" height="20" rx="4" fill="currentColor" class="text-gray-500 dark:text-slate-400"></rect>
|
||||
<rect x="8" y="33" width="56" height="5" rx="2.5" fill="currentColor" class="text-gray-900" opacity="0.55"></rect>
|
||||
<rect class="ram-insert-glow text-amber-500 dark:text-amber-400" x="8" y="33" width="56" height="5" rx="2.5" fill="none" stroke="currentColor" stroke-width="1.2"></rect>
|
||||
</svg>
|
||||
</span>
|
||||
<span id="ram-op-status" data-l10n="Working...">Working...</span>
|
||||
<button type="button" id="ram-op-sound" onclick="ramBatchToggleSound()" class="ml-auto hidden px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-slate-600 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-700" aria-pressed="true" title="Sound on" aria-label="Sound on">🔊</button>
|
||||
</div>
|
||||
<div class="mt-2 w-full bg-gray-200 dark:bg-slate-700 rounded h-1.5">
|
||||
<div id="ram-op-bar" class="bg-blue-600 dark:bg-blue-500 h-1.5 rounded" style="width:0%"></div>
|
||||
@@ -1944,7 +1959,7 @@
|
||||
// ===== Version =====
|
||||
// Single source of truth for the PWA version: shown in the header and used
|
||||
// by the server version check in pysimConnect().
|
||||
const SIMPLE_VERSION = '3.35.0';
|
||||
const SIMPLE_VERSION = '3.36.0';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== view history (the browser Back/Forward buttons) =====
|
||||
@@ -10273,6 +10288,8 @@ function ramBatchModalOpen() {
|
||||
document.getElementById('ram-op-spin').classList.add('hidden');
|
||||
document.getElementById('ram-op-warn').classList.add('hidden');
|
||||
document.getElementById('ram-op-batch-info').classList.remove('hidden');
|
||||
const sound = document.getElementById('ram-op-sound');
|
||||
if (sound) { sound.classList.remove('hidden'); ramBatchUpdateSoundButton(); }
|
||||
modal.classList.remove('hidden');
|
||||
ramBatchRender(); // paint from the current state now
|
||||
}
|
||||
@@ -10306,7 +10323,9 @@ function ramBatchModalRender(st, okLabel, actLabel) {
|
||||
document.getElementById('ram-op-bar').style.width = pct + '%';
|
||||
// the counts and the last finished card's verdict
|
||||
const c = st.counts || {};
|
||||
let info = okLabel + ': ' + (c.installed || 0) + ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
|
||||
let info = okLabel + ': ' + (c.installed || 0);
|
||||
if (c.already_absent) info += ' \u00b7 ' + t('Already absent') + ': ' + c.already_absent;
|
||||
info += ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
|
||||
+ ' \u00b7 ' + t('Skipped') + ': ' + (c.skipped || 0);
|
||||
if (st.attempts_total) info += ' \u00b7 ' + st.attempts_total + ' ' + t('attempts');
|
||||
if (st.notice) info += '\n' + st.notice;
|
||||
@@ -10327,6 +10346,98 @@ function ramBatchModalRender(st, okLabel, actLabel) {
|
||||
document.getElementById('ram-op-close').disabled = active;
|
||||
}
|
||||
|
||||
// ===== batch "insert the next card" cue (v3.36.0) =====
|
||||
// The waiting phase is the only moment the operator must act, so it gets a
|
||||
// card-into-reader animation in the modal, an optional chime (edge-triggered:
|
||||
// once per card, not on every 2 s poll) and a tab-title hint while the tab is
|
||||
// hidden. The chime needs an AudioContext unlocked by a gesture: the Start
|
||||
// click (and the sound toggle) do that; a reloaded page stays silent until the
|
||||
// next click.
|
||||
let _ramBatchWaiting = false;
|
||||
let _ramBatchAudio = null;
|
||||
let _ramBatchTitleBase = '';
|
||||
|
||||
function ramBatchSoundEnabled() {
|
||||
try { return localStorage.getItem('simple_ram_batch_sound') !== '0'; }
|
||||
catch (e) { return true; }
|
||||
}
|
||||
|
||||
function ramBatchUpdateSoundButton() {
|
||||
const btn = document.getElementById('ram-op-sound');
|
||||
if (!btn) return;
|
||||
const on = ramBatchSoundEnabled();
|
||||
btn.textContent = on ? '\u{1F50A}' : '\u{1F507}';
|
||||
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
||||
btn.title = t(on ? 'Sound on' : 'Sound off');
|
||||
btn.setAttribute('aria-label', t(on ? 'Sound on' : 'Sound off'));
|
||||
}
|
||||
|
||||
function ramBatchToggleSound() {
|
||||
try { localStorage.setItem('simple_ram_batch_sound', ramBatchSoundEnabled() ? '0' : '1'); }
|
||||
catch (e) { /* private mode */ }
|
||||
ramBatchUpdateSoundButton();
|
||||
if (ramBatchSoundEnabled()) ramBatchChime(); // a preview when turning it on
|
||||
}
|
||||
|
||||
function ramBatchAudioUnlock() {
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
const Ctor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
if (!_ramBatchAudio) _ramBatchAudio = new Ctor();
|
||||
if (_ramBatchAudio.state === 'suspended') _ramBatchAudio.resume();
|
||||
} catch (e) { _ramBatchAudio = null; }
|
||||
}
|
||||
|
||||
function ramBatchChime() {
|
||||
if (!ramBatchSoundEnabled()) return;
|
||||
try {
|
||||
if (typeof window === 'undefined') return;
|
||||
const Ctor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!Ctor) return;
|
||||
if (!_ramBatchAudio) _ramBatchAudio = new Ctor();
|
||||
const ctx = _ramBatchAudio;
|
||||
if (ctx.state === 'suspended') { ctx.resume().catch(() => {}); }
|
||||
const start = ctx.currentTime + 0.02;
|
||||
[880, 660].forEach((freq, i) => {
|
||||
const osc = ctx.createOscillator();
|
||||
const gain = ctx.createGain();
|
||||
osc.type = 'sine';
|
||||
osc.frequency.value = freq;
|
||||
const at = start + i * 0.18;
|
||||
gain.gain.setValueAtTime(0.0001, at);
|
||||
gain.gain.exponentialRampToValueAtTime(0.18, at + 0.02);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, at + 0.15);
|
||||
osc.connect(gain);
|
||||
gain.connect(ctx.destination);
|
||||
osc.start(at);
|
||||
osc.stop(at + 0.17);
|
||||
});
|
||||
} catch (e) { /* no audio available: the visual cue remains */ }
|
||||
}
|
||||
|
||||
function ramBatchTitleHint(waiting) {
|
||||
if (!_ramBatchTitleBase) _ramBatchTitleBase = document.title || 'SIMple';
|
||||
const hint = !!waiting && !!document.hidden;
|
||||
document.title = hint ? t('Insert next card') + ' \u2014 SIMple' : _ramBatchTitleBase;
|
||||
}
|
||||
|
||||
// Called from ramBatchRender (the 2 s poll): returns true only on the
|
||||
// transition into the waiting phase, and updates the animation/title.
|
||||
function ramBatchWaitingCue(waiting) {
|
||||
const entered = !!waiting && !_ramBatchWaiting;
|
||||
_ramBatchWaiting = !!waiting;
|
||||
const ins = document.getElementById('ram-op-insert');
|
||||
if (ins) {
|
||||
ins.classList.toggle('hidden', !waiting);
|
||||
ins.classList.toggle('flex', !!waiting);
|
||||
}
|
||||
ramBatchTitleHint(waiting);
|
||||
return entered;
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => ramBatchTitleHint(_ramBatchWaiting));
|
||||
|
||||
async function ramBatchFetch() {
|
||||
if (_ramBatchFetching) return;
|
||||
_ramBatchFetching = true;
|
||||
@@ -10378,6 +10489,10 @@ function ramBatchRender() {
|
||||
const st = ramBatchMergedState();
|
||||
const active = !!st.active;
|
||||
const phase = st.phase || (active ? 'waiting_card' : 'idle');
|
||||
// The waiting cue: the animation in the modal, the chime once per card and
|
||||
// the tab-title hint (v3.36.0).
|
||||
const waiting = active && phase !== 'installing' && phase !== 'stopping';
|
||||
if (ramBatchWaitingCue(waiting)) ramBatchChime();
|
||||
// The success labels follow the operation (v3.29.0): the counts bucket is
|
||||
// 'installed' for every kind - "the operation succeeded". The last seen
|
||||
// kind is remembered for the modal's title and these labels, so a cleared
|
||||
@@ -10433,8 +10548,9 @@ function ramBatchRender() {
|
||||
const counts = document.getElementById('ram-batch-counts');
|
||||
if (counts) {
|
||||
const c = st.counts || {};
|
||||
let text = okLabel + ': ' + (c.installed || 0)
|
||||
+ ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
|
||||
let text = okLabel + ': ' + (c.installed || 0);
|
||||
if (c.already_absent) text += ' \u00b7 ' + t('Already absent') + ': ' + c.already_absent;
|
||||
text += ' \u00b7 ' + t('Failed') + ': ' + (c.failed || 0)
|
||||
+ ' \u00b7 ' + t('Skipped') + ': ' + (c.skipped || 0);
|
||||
if (st.attempts_total) text += ' \u00b7 ' + st.attempts_total + ' ' + t('attempts');
|
||||
if (active && _ramBatchProgress && _ramBatchProgress.active && _ramBatchProgress.name) {
|
||||
@@ -10470,7 +10586,10 @@ function ramBatchAttemptsHtml(attempts) {
|
||||
for (let i = attempts.length - 1; i >= 0; i--) {
|
||||
const a = attempts[i];
|
||||
const when = a.ts ? new Date(a.ts * 1000).toLocaleTimeString() : '';
|
||||
// A delete that found the object already gone is a success, but not a
|
||||
// deletion: say so instead of a plain OK (v3.36.0).
|
||||
const verdict = a.result === 'installed' ? t('OK')
|
||||
: a.result === 'already_absent' ? t('Already absent')
|
||||
: (a.result === 'failed' ? t('Failed') : t('Skipped'));
|
||||
const tone = a.result === 'installed' ? 'text-emerald-600 dark:text-emerald-400'
|
||||
: (a.result === 'failed' ? 'text-red-600 dark:text-red-400' : 'text-amber-600 dark:text-amber-400');
|
||||
@@ -10526,6 +10645,8 @@ function ramInstallCapFields() {
|
||||
}
|
||||
|
||||
async function ramBatchStart() {
|
||||
// The click is the gesture the waiting chime needs later.
|
||||
ramBatchAudioUnlock();
|
||||
// The operation selects the batch kind (v3.29.0): the .cap install, the
|
||||
// applet reinstall (no .cap - the package is loaded) or a GP delete. The
|
||||
// server builds and validates the plan once at Start.
|
||||
@@ -10634,6 +10755,10 @@ function ramOpModalBegin(title) {
|
||||
'mt-2 text-xs font-mono whitespace-pre-wrap overflow-auto text-gray-700 dark:text-slate-300';
|
||||
document.getElementById('ram-op-close').disabled = true;
|
||||
document.getElementById('ram-op-spin').classList.remove('hidden');
|
||||
const ins = document.getElementById('ram-op-insert');
|
||||
if (ins) { ins.classList.add('hidden'); ins.classList.remove('flex'); }
|
||||
const snd = document.getElementById('ram-op-sound');
|
||||
if (snd) snd.classList.add('hidden');
|
||||
document.getElementById('ram-op-warn').classList.remove('hidden');
|
||||
document.getElementById('ram-op-batch-info').classList.add('hidden');
|
||||
document.getElementById('ram-op-batch-stop').classList.add('hidden');
|
||||
@@ -26786,6 +26911,10 @@ const LANG_RU = {
|
||||
'Reinstalling...': 'Переустановка…',
|
||||
'Stopping after the current card...': 'Остановка после текущей карты…',
|
||||
'Insert next card. Waiting for a card...': 'Вставьте следующую карту. Ожидание карты…',
|
||||
'Insert next card': 'Вставьте следующую карту',
|
||||
'Already absent': 'Уже отсутствует',
|
||||
'Sound on': 'Звук включён',
|
||||
'Sound off': 'Звук выключен',
|
||||
'Installed': 'Установлено',
|
||||
'Deleted': 'Удалено',
|
||||
'Reinstalled': 'Переустановлено',
|
||||
|
||||
+25
-1
@@ -4,4 +4,28 @@
|
||||
|
||||
.dark .text-heading {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
}
|
||||
/* RAM batch: the "insert the next card" cue (v3.36.0).
|
||||
The card slides into the reader slot and back; the slot glows while the
|
||||
run waits. The static transform is the reduced-motion fallback: the card
|
||||
rests above the slot. */
|
||||
.ram-insert-card {
|
||||
/* the resting position is the card just above the slot (0); the insert
|
||||
slide takes it 20px down, behind the reader front */
|
||||
animation: ram-insert 2.8s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ram-insert {
|
||||
0%, 12% { transform: translateY(0); }
|
||||
45%, 55% { transform: translateY(20px); }
|
||||
88%, 100% { transform: translateY(0); }
|
||||
}
|
||||
.ram-insert-glow {
|
||||
animation: ram-insert-glow 1.4s ease-in-out infinite;
|
||||
}
|
||||
@keyframes ram-insert-glow {
|
||||
0%, 100% { opacity: 0.35; }
|
||||
50% { opacity: 0.95; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ram-insert-card, .ram-insert-glow { animation: none; }
|
||||
}
|
||||
|
||||
+97
-10
@@ -616,6 +616,10 @@ video {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.\!visible {
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
@@ -684,6 +688,11 @@ video {
|
||||
grid-column: span 4 / span 4;
|
||||
}
|
||||
|
||||
.mx-2 {
|
||||
margin-left: 0.5rem;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.mx-3 {
|
||||
margin-left: 0.75rem;
|
||||
margin-right: 0.75rem;
|
||||
@@ -807,6 +816,10 @@ video {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
@@ -843,6 +856,10 @@ video {
|
||||
max-height: 8rem;
|
||||
}
|
||||
|
||||
.max-h-48 {
|
||||
max-height: 12rem;
|
||||
}
|
||||
|
||||
.max-h-56 {
|
||||
max-height: 14rem;
|
||||
}
|
||||
@@ -935,6 +952,10 @@ video {
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.w-44 {
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
.w-48 {
|
||||
width: 12rem;
|
||||
}
|
||||
@@ -980,6 +1001,10 @@ video {
|
||||
min-width: 0px;
|
||||
}
|
||||
|
||||
.min-w-24 {
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
.min-w-\[12rem\] {
|
||||
min-width: 12rem;
|
||||
}
|
||||
@@ -1395,6 +1420,11 @@ video {
|
||||
background-color: rgb(219 234 254 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-blue-50 {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(239 246 255 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.bg-blue-50\/50 {
|
||||
background-color: rgb(239 246 255 / 0.5);
|
||||
}
|
||||
@@ -1796,6 +1826,11 @@ video {
|
||||
color: rgb(31 41 55 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-gray-900 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(17 24 39 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.text-green-600 {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(22 163 74 / var(--tw-text-opacity, 1));
|
||||
@@ -1893,10 +1928,61 @@ video {
|
||||
filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
|
||||
}
|
||||
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition-duration: 150ms;
|
||||
}
|
||||
|
||||
.dark .text-heading {
|
||||
color: #f1f5f9;
|
||||
}
|
||||
|
||||
/* RAM batch: the "insert the next card" cue (v3.36.0).
|
||||
The card slides into the reader slot and back; the slot glows while the
|
||||
run waits. The static transform is the reduced-motion fallback: the card
|
||||
rests above the slot. */
|
||||
|
||||
.ram-insert-card {
|
||||
/* the resting position is the card just above the slot (0); the insert
|
||||
slide takes it 20px down, behind the reader front */
|
||||
animation: ram-insert 2.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ram-insert {
|
||||
0%, 12% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
45%, 55% {
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
88%, 100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.ram-insert-glow {
|
||||
animation: ram-insert-glow 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes ram-insert-glow {
|
||||
0%, 100% {
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
50% {
|
||||
opacity: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ram-insert-card, .ram-insert-glow {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.hover\:bg-amber-100:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 243 199 / var(--tw-bg-opacity, 1));
|
||||
@@ -1956,11 +2042,6 @@ video {
|
||||
background-color: rgb(55 65 81 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-red-100:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
|
||||
}
|
||||
|
||||
.hover\:bg-red-200:hover {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(254 202 202 / var(--tw-bg-opacity, 1));
|
||||
@@ -2093,6 +2174,10 @@ video {
|
||||
background-color: rgb(30 58 138 / 0.1);
|
||||
}
|
||||
|
||||
.dark\:bg-blue-900\/20:is(.dark *) {
|
||||
background-color: rgb(30 58 138 / 0.2);
|
||||
}
|
||||
|
||||
.dark\:bg-blue-900\/40:is(.dark *) {
|
||||
background-color: rgb(30 58 138 / 0.4);
|
||||
}
|
||||
@@ -2258,10 +2343,6 @@ video {
|
||||
background-color: rgb(6 78 59 / 0.4);
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-red-900\/40:hover:is(.dark *) {
|
||||
background-color: rgb(127 29 29 / 0.4);
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-red-900\/50:hover:is(.dark *) {
|
||||
background-color: rgb(127 29 29 / 0.5);
|
||||
}
|
||||
@@ -2289,6 +2370,11 @@ video {
|
||||
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-slate-200:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(226 232 240 / var(--tw-text-opacity, 1));
|
||||
}
|
||||
|
||||
.dark\:hover\:text-slate-300:hover:is(.dark *) {
|
||||
--tw-text-opacity: 1;
|
||||
color: rgb(203 213 225 / var(--tw-text-opacity, 1));
|
||||
@@ -2328,7 +2414,8 @@ video {
|
||||
.lg\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
}/* ===== Theme contrast adjustments ===== */
|
||||
}
|
||||
/* ===== Theme contrast adjustments ===== */
|
||||
|
||||
/* Light theme: darken softer (muted) text one step for higher contrast */
|
||||
.text-gray-400 {
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v469';
|
||||
const CACHE = 'simple-v473';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -28,7 +28,10 @@ for (const fn of ['ramIsBatchOp', 'ramBatchPanelVisible', 'ramBatchMergedState',
|
||||
'ramBatchStepsText',
|
||||
'ramBatchAttemptsHtml', 'ramBatchRender', 'ramBatchApplyStatus', 'ramBatchFetch',
|
||||
'ramBatchStart', 'ramBatchStop', 'ramBatchClear', 'ramOpProgressText',
|
||||
'ramOpModalClose', 'esc']) {
|
||||
'ramOpModalClose', 'esc',
|
||||
'ramBatchSoundEnabled', 'ramBatchUpdateSoundButton', 'ramBatchToggleSound',
|
||||
'ramBatchAudioUnlock', 'ramBatchChime', 'ramBatchTitleHint',
|
||||
'ramBatchWaitingCue']) {
|
||||
code += extractFunc(html, fn, ASYNC_FNS.indexOf(fn) >= 0) + '\n';
|
||||
}
|
||||
|
||||
@@ -39,7 +42,9 @@ const els = {};
|
||||
function setEl(id, value) {
|
||||
els[id] = {
|
||||
id: id, value: value === undefined ? '' : value, textContent: '', innerHTML: '',
|
||||
disabled: false, style: {},
|
||||
disabled: false, style: {}, title: '', attrs: {},
|
||||
setAttribute(k, v) { els[id].attrs[k] = String(v); },
|
||||
getAttribute(k) { return els[id].attrs[k]; },
|
||||
classes: {},
|
||||
classList: {
|
||||
add(c) { els[id].classes[c] = true; },
|
||||
@@ -64,7 +69,8 @@ function clearEls() {
|
||||
function setModalEls() {
|
||||
for (const id of ['ram-op-modal', 'ram-op-title', 'ram-op-status', 'ram-op-bar',
|
||||
'ram-op-steps', 'ram-op-spin', 'ram-op-warn', 'ram-op-close',
|
||||
'ram-op-batch-info', 'ram-op-batch-stop', 'ram-op-batch-clear']) setEl(id, '');
|
||||
'ram-op-batch-info', 'ram-op-batch-stop', 'ram-op-batch-clear',
|
||||
'ram-op-insert', 'ram-op-sound']) setEl(id, '');
|
||||
}
|
||||
function setPanelEls() {
|
||||
for (const id of ['ram-op', 'ram-batch-panel', 'ram-batch-start', 'ram-batch-stop',
|
||||
@@ -75,7 +81,17 @@ function setPanelEls() {
|
||||
|
||||
const app = new Function('els', `
|
||||
var sandbox = { fetched: [], replies: {}, fetchError: null, alerts: [], visibility: true };
|
||||
var document = { getElementById: id => els[id] || null };
|
||||
var document = {
|
||||
getElementById: id => els[id] || null,
|
||||
hidden: false, title: 'SIMple',
|
||||
addEventListener: () => {},
|
||||
};
|
||||
var _ls = {};
|
||||
var localStorage = {
|
||||
getItem: k => (k in _ls ? _ls[k] : null),
|
||||
setItem: (k, v) => { _ls[k] = String(v); },
|
||||
removeItem: k => { delete _ls[k]; },
|
||||
};
|
||||
var t = k => k;
|
||||
var isViewVisible = () => sandbox.visibility;
|
||||
var alert = m => sandbox.alerts.push(String(m));
|
||||
@@ -109,6 +125,9 @@ const app = new Function('els', `
|
||||
var _ramBatchFetchAt = 0;
|
||||
var _ramBatchSummary = null;
|
||||
var _ramBatchProgress = null;
|
||||
var _ramBatchWaiting = false;
|
||||
var _ramBatchAudio = null;
|
||||
var _ramBatchTitleBase = '';
|
||||
var RAM_BATCH_FETCH_STALE_MS = ${RAM_BATCH_FETCH_STALE_MS};
|
||||
${code}
|
||||
sandbox.getLast = () => _ramBatchLast;
|
||||
@@ -134,6 +153,16 @@ const app = new Function('els', `
|
||||
sandbox.ramBatchStart = ramBatchStart;
|
||||
sandbox.ramBatchStop = ramBatchStop;
|
||||
sandbox.ramBatchClear = ramBatchClear;
|
||||
sandbox.ramBatchWaitingCue = ramBatchWaitingCue;
|
||||
sandbox.ramBatchTitleHint = ramBatchTitleHint;
|
||||
sandbox.ramBatchSoundEnabled = ramBatchSoundEnabled;
|
||||
sandbox.ramBatchUpdateSoundButton = ramBatchUpdateSoundButton;
|
||||
sandbox.ramBatchToggleSound = ramBatchToggleSound;
|
||||
sandbox.setHidden = v => { document.hidden = !!v; };
|
||||
sandbox.getTitle = () => document.title;
|
||||
sandbox.resetWaiting = () => { _ramBatchWaiting = false; };
|
||||
sandbox.storage = _ls;
|
||||
sandbox.resetStorage = () => { for (const k of Object.keys(_ls)) delete _ls[k]; };
|
||||
return sandbox;
|
||||
`)(els);
|
||||
|
||||
@@ -639,3 +668,96 @@ test('the stop and clear calls hit their endpoints', async () => {
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.deepStrictEqual(app.fetched, ['/api/ram-batch/stop', '/api/ram-batch/clear', '/api/ram-batch/status']);
|
||||
});
|
||||
|
||||
|
||||
test('the waiting cue fires once per card and drives the insert animation (v3.36.0)', () => {
|
||||
clearEls();
|
||||
setPanelEls();
|
||||
setModalEls();
|
||||
app.resetWaiting();
|
||||
assert.strictEqual(app.ramBatchWaitingCue(true), true, 'entering waiting cues');
|
||||
assert.strictEqual(app.ramBatchWaitingCue(true), false, 'still waiting: no repeat');
|
||||
assert.strictEqual(app.ramBatchWaitingCue(false), false, 'installing resets');
|
||||
assert.strictEqual(app.ramBatchWaitingCue(true), true, 'the next card cues again');
|
||||
assert.ok(els['ram-op-insert'].classes['flex'] === true);
|
||||
assert.ok(!els['ram-op-insert'].classes['hidden']);
|
||||
app.ramBatchWaitingCue(false);
|
||||
assert.ok(els['ram-op-insert'].classes['hidden']);
|
||||
// the render drives it from the phase
|
||||
app.setLast({ active: true, phase: 'waiting_card', attempts: [], counts: {}, attempts_total: 0, notice: '' });
|
||||
app.ramBatchRender();
|
||||
assert.ok(!els['ram-op-insert'].classes['hidden']);
|
||||
app.setLast({ active: true, phase: 'installing', current: { iccid: '8970119000004600098' },
|
||||
attempts: [], counts: {}, attempts_total: 1 });
|
||||
app.ramBatchRender();
|
||||
assert.ok(els['ram-op-insert'].classes['hidden']);
|
||||
});
|
||||
|
||||
test('the sound toggle persists and updates the button (v3.36.0)', () => {
|
||||
clearEls();
|
||||
setModalEls();
|
||||
app.resetStorage();
|
||||
assert.strictEqual(app.ramBatchSoundEnabled(), true, 'sound is on by default');
|
||||
app.ramBatchUpdateSoundButton();
|
||||
assert.strictEqual(els['ram-op-sound'].textContent, '\u{1F50A}');
|
||||
assert.strictEqual(els['ram-op-sound'].attrs['aria-pressed'], 'true');
|
||||
app.ramBatchToggleSound();
|
||||
assert.strictEqual(app.ramBatchSoundEnabled(), false);
|
||||
assert.strictEqual(app.storage['simple_ram_batch_sound'], '0');
|
||||
assert.strictEqual(els['ram-op-sound'].textContent, '\u{1F507}');
|
||||
assert.strictEqual(els['ram-op-sound'].attrs['aria-pressed'], 'false');
|
||||
app.ramBatchToggleSound();
|
||||
assert.strictEqual(app.ramBatchSoundEnabled(), true);
|
||||
});
|
||||
|
||||
test('the tab title hints while the run waits and the tab is hidden (v3.36.0)', () => {
|
||||
app.setHidden(false);
|
||||
app.ramBatchTitleHint(true);
|
||||
assert.strictEqual(app.getTitle(), 'SIMple');
|
||||
app.setHidden(true);
|
||||
app.ramBatchTitleHint(true);
|
||||
assert.strictEqual(app.getTitle(), 'Insert next card \u2014 SIMple');
|
||||
app.setHidden(false);
|
||||
app.ramBatchTitleHint(true);
|
||||
assert.strictEqual(app.getTitle(), 'SIMple');
|
||||
});
|
||||
|
||||
test('the waiting cue is wired into the poll render, the Start gesture and the CSS (v3.36.0)', () => {
|
||||
const render = extractFunc(html, 'ramBatchRender');
|
||||
assert.match(render, /const waiting = active && phase !== 'installing' && phase !== 'stopping';/);
|
||||
assert.match(render, /if \(ramBatchWaitingCue\(waiting\)\) ramBatchChime\(\);/);
|
||||
assert.match(extractFunc(html, 'ramBatchStart'), /ramBatchAudioUnlock\(\)/);
|
||||
assert.match(html, /id="ram-op-insert"/);
|
||||
assert.match(html, /id="ram-op-sound"/);
|
||||
const css = fs.readFileSync(path.join(__dirname, '..', 'style.css'), 'utf8');
|
||||
assert.match(css, /\.ram-insert-card/);
|
||||
assert.match(css, /@keyframes ram-insert/);
|
||||
assert.match(css, /\.ram-insert-glow/);
|
||||
assert.match(css, /prefers-reduced-motion: reduce/);
|
||||
});
|
||||
|
||||
test('an already-absent delete is reported as such, not as a deletion (v3.36.0)', () => {
|
||||
clearEls();
|
||||
setPanelEls();
|
||||
setModalEls();
|
||||
app.resetBatchModal();
|
||||
app.setLast({ active: false, phase: 'finished', kind: 'delete',
|
||||
attempts: [{
|
||||
iccid: '8970119000000115026', preset: 'Alfa', result: 'already_absent',
|
||||
steps: [{ name: 'DELETE (application)', sw: '6A88', ok: true, not_found: true }],
|
||||
steps_total: 1, duration_ms: 1200,
|
||||
}],
|
||||
counts: { installed: 2, already_absent: 1, failed: 0, skipped: 0 },
|
||||
attempts_total: 3, notice: 'Card 8970119000000115026 was already absent.' });
|
||||
app.ramBatchRender();
|
||||
// the counts separate a real deletion from a no-op
|
||||
assert.match(els['ram-batch-counts'].textContent, /Deleted: 2/);
|
||||
assert.match(els['ram-batch-counts'].textContent, /Already absent: 1/);
|
||||
// the attempt's verdict says it - not a plain OK - and keeps the step line
|
||||
assert.match(els['ram-batch-attempts'].innerHTML, /Already absent/);
|
||||
assert.match(els['ram-batch-attempts'].innerHTML, /6A88 \(not found\)/);
|
||||
// the modal carries the same counts
|
||||
app.ramBatchModalOpen();
|
||||
assert.match(els['ram-op-batch-info'].textContent, /Deleted: 2/);
|
||||
assert.match(els['ram-op-batch-info'].textContent, /Already absent: 1/);
|
||||
});
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "pysim-simple-server"
|
||||
version = "3.35.0"
|
||||
version = "3.36.0"
|
||||
description = "HTTP REST server wrapping pysim for the SIMple PWA"
|
||||
license = { text = "GPL-2.0-or-later" }
|
||||
requires-python = ">=3.9"
|
||||
|
||||
@@ -37,7 +37,7 @@ from osmocom.tlv import BER_TLV_IE
|
||||
from osmocom.utils import rpad
|
||||
|
||||
|
||||
VERSION = '3.35.0'
|
||||
VERSION = '3.36.0'
|
||||
|
||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||
|
||||
@@ -2914,7 +2914,7 @@ def _ram_batch_new_state(plan):
|
||||
return {'active': True, 'phase': 'waiting_card', 'stop': False,
|
||||
'started': time.time(), 'stopped': None, 'plan': plan,
|
||||
'session': None, 'seen': {}, 'attempts': [], 'attempts_total': 0,
|
||||
'counts': {'installed': 0, 'failed': 0, 'skipped': 0},
|
||||
'counts': {'installed': 0, 'already_absent': 0, 'failed': 0, 'skipped': 0},
|
||||
'current': None, 'notice': ''}
|
||||
|
||||
|
||||
@@ -2939,6 +2939,8 @@ def _ram_batch_record(state, rec):
|
||||
verb = {'delete': 'deleted',
|
||||
'reinstall-app': 'reinstalled'}.get(kind, 'installed')
|
||||
state['notice'] = 'Card %s %s.' % (who, verb)
|
||||
elif result == 'already_absent':
|
||||
state['notice'] = 'Card %s was already absent.' % who
|
||||
elif result == 'failed':
|
||||
step = rec.get('failed_step') or ''
|
||||
sw = rec.get('por_sw') or rec.get('sw') or ''
|
||||
@@ -2947,8 +2949,9 @@ def _ram_batch_record(state, rec):
|
||||
else:
|
||||
state['notice'] = 'Card %s skipped: %s.' % (who, rec.get('reason') or '')
|
||||
detail = rec.get('reason') or rec.get('failed_step') or ''
|
||||
label = {'already_absent': 'already absent'}.get(result, result)
|
||||
sys.stderr.write('RAM-BATCH: %s%s%s\n' % (
|
||||
result, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else ''))
|
||||
label, (' ' + who) if rec.get('iccid') else '', (' - ' + detail) if detail else ''))
|
||||
|
||||
|
||||
def _ram_batch_failure_fields(result):
|
||||
@@ -3044,6 +3047,15 @@ def _ram_batch_handle_card(state, server):
|
||||
'module_aid': result.get('module_aid'),
|
||||
'nv_delta': result.get('nv_delta')}
|
||||
rec.update(_ram_batch_steps_fields(result))
|
||||
# A delete run whose DELETE answered 'not found' is a success (the
|
||||
# object is gone, the goal is met) but not a deletion: report it as
|
||||
# "already absent" and count it separately (v3.36.0). Only the delete
|
||||
# kind: a reinstall's tolerated pre-delete is not the outcome.
|
||||
if (state.get('plan') or {}).get('kind') == 'delete' and any(
|
||||
s.get('delete_not_found') for s in (result.get('steps') or [])):
|
||||
outcome = 'already_absent'
|
||||
rec['result'] = outcome
|
||||
rec['not_found'] = True
|
||||
elif result is not None:
|
||||
outcome = 'failed'
|
||||
rec = {'iccid': iccid, 'preset': name, 'result': outcome,
|
||||
@@ -3178,7 +3190,7 @@ def _ram_batch_status():
|
||||
if _RAM_BATCH is None:
|
||||
return {'active': False, 'phase': 'idle', 'attempts': [],
|
||||
'attempts_total': 0, 'current': None, 'notice': '',
|
||||
'counts': {'installed': 0, 'failed': 0, 'skipped': 0},
|
||||
'counts': {'installed': 0, 'already_absent': 0, 'failed': 0, 'skipped': 0},
|
||||
'started': None, 'stopped': None, 'progress': None,
|
||||
'kind': None}
|
||||
return {'active': bool(_RAM_BATCH.get('active')),
|
||||
|
||||
+45
-1
@@ -88,7 +88,8 @@ class BatchStateTests(unittest.TestCase):
|
||||
'failed_step': 'LOAD (3/7)', 'por_sw': '6A80'})
|
||||
srv._ram_batch_record(st, {'iccid': '8970119000004600114', 'result': 'skipped',
|
||||
'reason': 'no preset matches this ICCID'})
|
||||
self.assertEqual(st['counts'], {'installed': 1, 'failed': 1, 'skipped': 1})
|
||||
self.assertEqual(st['counts'], {'installed': 1, 'already_absent': 0,
|
||||
'failed': 1, 'skipped': 1})
|
||||
self.assertEqual(st['attempts_total'], 3)
|
||||
# the notice names the last attempt's outcome
|
||||
self.assertIn('skipped: no preset matches this ICCID', st['notice'])
|
||||
@@ -230,6 +231,49 @@ class BatchCardTests(unittest.TestCase):
|
||||
self.assertEqual(seen['current']['iccid'], '8970119000004600098')
|
||||
self.assertIn('installed', st['notice']) # ...and set for the new outcome
|
||||
|
||||
def test_a_delete_that_finds_nothing_is_reported_already_absent(self):
|
||||
"""A delete run whose DELETE answered 6A88 is a success (the object is
|
||||
gone, the goal is met) but not a deletion: it is counted in its own
|
||||
bucket and the notice says so (v3.36.0)."""
|
||||
st = srv._ram_batch_new_state(_plan('delete'))
|
||||
preset = _preset('8970119000004600098', 'Alfa')
|
||||
|
||||
def fake_run(server, body, plan, scc):
|
||||
return {'success': True, 'final_cntr': '0000000002',
|
||||
'steps': [{'name': 'DELETE (application)', 'sw': '9000',
|
||||
'por_status': 'por_ok', 'por_sw': '6A88',
|
||||
'delete_not_found': True}]}
|
||||
|
||||
with mock.patch.object(srv, '_ram_plan_run', fake_run):
|
||||
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
|
||||
_FakeStore([preset])))
|
||||
rec = st['attempts'][0]
|
||||
self.assertEqual(rec['result'], 'already_absent')
|
||||
self.assertTrue(rec['not_found'])
|
||||
self.assertEqual(st['counts']['already_absent'], 1)
|
||||
self.assertEqual(st['counts']['installed'], 0)
|
||||
self.assertIn('already absent', st['notice'])
|
||||
self.assertEqual(st['seen']['8970119000004600098'], 'already_absent')
|
||||
|
||||
def test_a_reinstall_with_a_tolerated_pre_delete_is_still_an_install(self):
|
||||
"""The reinstall's pre-delete may tolerate 'not found', but the
|
||||
outcome is the INSTALL, not 'already absent'."""
|
||||
st = srv._ram_batch_new_state(_plan('reinstall-app'))
|
||||
preset = _preset('8970119000004600098', 'Alfa')
|
||||
|
||||
def fake_run(server, body, plan, scc):
|
||||
return {'success': True,
|
||||
'steps': [{'name': 'DELETE (application)', 'por_sw': '6A88',
|
||||
'delete_not_found': True},
|
||||
{'name': 'INSTALL [for install]', 'por_sw': '9000'}]}
|
||||
|
||||
with mock.patch.object(srv, '_ram_plan_run', fake_run):
|
||||
srv._ram_batch_handle_card(st, _fake_server('8970119000004600098',
|
||||
_FakeStore([preset])))
|
||||
self.assertEqual(st['attempts'][0]['result'], 'installed')
|
||||
self.assertEqual(st['counts']['installed'], 1)
|
||||
self.assertEqual(st['counts']['already_absent'], 0)
|
||||
|
||||
def test_a_card_already_processed_is_reported_not_reinstalled(self):
|
||||
st = srv._ram_batch_new_state(_plan())
|
||||
preset = _preset('8970119000004600098', 'Alfa')
|
||||
|
||||
Reference in New Issue
Block a user