feat: generic BIP terminal control (Simulator BIP pill) (v3.6.0)
BIP was only reachable through the SCP81 listener; cards that use TCP for
other purposes (an applet's OPEN CHANNEL, a push trigger without HTTP OTA)
now get a generic control surface, independent of SCP81.
Server:
- /api/bip/control starts a plain BIP session in three modes: sink
(default; a local TcpDumpServer that accepts the card's channels and only
logs conn/sink-rx/conn-close, never answering; port 0 = ephemeral, the
bound port is reported), passthru (dial the OPEN CHANNEL destination, TCP
client only) and redirect (fixed host:port).
- /api/bip/status returns {owner, bip, listener}; /api/bip/log and
/api/bip/log-clear share the BIP event log with /api/scp81/log.
- The session state is shared with the SCP81 listener and only one session
runs at a time: _bip_session_stop() stops whichever control started it
and the control responses report it as `replaced` (both directions,
SCP81 <-> BIP). State renamed _SCP81_MODE/_TARGET/_LISTENER/_LINK_EVENTS
-> _BIP_* plus _BIP_OWNER.
- TcpDumpServer logs conn-close and counts accepted connections; stop()
joins the accept thread so the port is really free on restart.
- The new control endpoints are blocked during test-script runs.
PWA:
- New Simulator pill BIP (after TR Config): mode select with notes,
Host/Port rules, Start/Stop with a "replaced" notice, status line (mode,
bound address, owner, channels), a Channels box and the shared BIP log
(reuses the SCP81 log renderer, now showing `peer`).
- The SCP81 status line marks a session owned by the BIP pill.
Help EN/RU 8.10, docs/api.md, AGENTS; CAT_TP/UDP recorded as not
implemented.
Tests: tests/test_bip.py (9) and frontend/tests/bip.test.js (6).
600 frontend / 482 Python green; version 3.6.0; sw simple-v273.
This commit is contained in:
@@ -553,6 +553,15 @@
|
||||
<li><strong>Шаблоны</strong> — Empty, <em>Обзор меню STK</em>, <em>Апплет через SCP80</em>; редактор шагов — формный, отдельные скрипты экспортируются/импортируются как JSON.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="bip" class="text-lg font-medium mb-2">8.10 BIP-терминал (обычный TCP)</h3>
|
||||
<p class="text-sm mb-2">Пилюля <strong>BIP</strong> запускает терминальную сторону Bearer Independent Protocol для карт, использующих TCP без HTTP OTA (SCP81): апплет или пуш-триггер может открыть канал и обменяться сырыми данными. Одновременно работает только одна BIP-сессия — запуск здесь останавливает слушатель SCP81, а запуск слушателя SCP81 останавливает эту сессию (в обоих случаях сообщается, что было заменено).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Локальный приёмник (только журнал)</strong> (по умолчанию) — принимает каналы карты на локальном слушателе и только записывает принятое в журнал; ничего не отправляется обратно. Оставьте порт пустым для эфемерного порта (занятый адрес появится в статусе). Подходит для карт, которые открывают TCP-соединение только чтобы выгрузить данные.</li>
|
||||
<li><strong>Адрес карты (OPEN CHANNEL)</strong> — каждый канал подключается к адресу, который карта запросила в OPEN CHANNEL (Other address + transport port, только TCP-клиент); Host и Port не используются, используется сеть сервера (только для лаборатории).</li>
|
||||
<li><strong>Заданный адрес</strong> — каждый канал подключается к указанному Host:Port; запрошенный картой адрес только записывается в журнал.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-2">Строка статуса показывает активный режим, занятый адрес, владельца сессии и открытые каналы; блок <strong>Каналы</strong> перечисляет счётчики байт по каналам, а <strong>Журнал BIP</strong> записывает каждое событие open / send / receive / close с hex-превью (те же события, что показывает вкладка SCP81). CAT_TP через UDP не реализован.</p>
|
||||
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">9. Установка сервера</h2>
|
||||
<p class="mb-3">Для работы с картой (вкладка «Картридер», «Симулятор», доставка OTA) нужен локальный <a href="https://github.com/anttro/simple" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-simple-server</a> — встроенный в SIMple HTTP-сервер, оборачивающий pySim, работающий с ридером через PC/SC или serial и раздающий сам PWA (откройте <code class="font-mono text-sm">http://127.0.0.1:8080</code>).</p>
|
||||
|
||||
|
||||
@@ -552,6 +552,15 @@
|
||||
<li><strong>Templates</strong> — Empty, <em>STK menu browsing</em>, <em>Applet via SCP80</em>; the step editor is form-based and individual scripts export/import as JSON.</li>
|
||||
</ul>
|
||||
|
||||
<h3 id="bip" class="text-lg font-medium mb-2">8.10 BIP terminal (generic TCP)</h3>
|
||||
<p class="text-sm mb-2">The <strong>BIP</strong> pill runs the terminal side of the Bearer Independent Protocol for cards that use TCP without HTTP OTA (SCP81): an applet or a push trigger can open a channel and exchange raw data. Only one BIP session runs at a time — starting here stops a running SCP81 listener, and starting the SCP81 listener stops this session (both report what was replaced).</p>
|
||||
<ul class="list-disc list-inside text-sm space-y-1 mb-3">
|
||||
<li><strong>Local sink (log only)</strong> (default) — accepts the card's channels on a local listener and only logs what arrives; nothing is ever sent back. Leave the port empty for an ephemeral port (the bound address appears in the status). Use it for cards that open a TCP connection just to upload data.</li>
|
||||
<li><strong>Card's destination (OPEN CHANNEL)</strong> — each channel is connected to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port are not used and the server's network is used (lab only).</li>
|
||||
<li><strong>Fixed target</strong> — every channel is connected to the configured Host:Port; the address the card requests is only logged.</li>
|
||||
</ul>
|
||||
<p class="text-sm mb-2">The status line shows the active mode, the bound address, the session owner and the open channels; the <strong>Channels</strong> box lists the per-channel byte counts and the <strong>BIP log</strong> records every open / send / receive / close event with a hex preview (the same events the SCP81 tab shows). CAT_TP over UDP is not implemented.</p>
|
||||
|
||||
<h2 id="server" class="text-xl font-semibold mb-3 border-b border-gray-300 dark:border-slate-700 pb-1">9. Server installation</h2>
|
||||
<p class="mb-3">Live card operations (Card reader tab, Simulator, OTA delivery) require the local <a href="https://github.com/anttro/simple" class="text-blue-600 dark:text-blue-400 hover:underline">pysim-simple-server</a> — a small HTTP server bundled with SIMple 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>
|
||||
|
||||
|
||||
+251
-5
@@ -1032,6 +1032,7 @@
|
||||
<div class="flex flex-wrap justify-center gap-2 mb-3">
|
||||
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-blue-600 text-white" data-phone-sub="phone" onclick="phoneSwitchSubtab('phone')" data-l10n="Phone">Phone</button>
|
||||
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-phone-sub="tr" onclick="phoneSwitchSubtab('tr')" data-l10n="TR Config">TR Config</button>
|
||||
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-phone-sub="bip" onclick="phoneSwitchSubtab('bip')" data-l10n="BIP">BIP</button>
|
||||
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-phone-sub="esim" onclick="phoneSwitchSubtab('esim')" data-l10n="eSIM LPA">eSIM LPA</button>
|
||||
<button class="phone-subtab px-4 py-1.5 text-sm rounded-full bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600" data-phone-sub="test" onclick="phoneSwitchSubtab('test')" data-l10n="Test script">Test script</button>
|
||||
</div>
|
||||
@@ -1183,6 +1184,47 @@
|
||||
<span class="text-gray-400" data-l10n="Loading...">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="phone-sub-bip" class="hidden">
|
||||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400"><span data-l10n="BIP terminal">BIP terminal</span> <span id="bip-state" class="text-xs text-gray-400"></span></legend>
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<div>
|
||||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Mode">Mode</label>
|
||||
<select id="bip-mode" onchange="bipModeChanged()" class="border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||||
<option value="sink" data-l10n="Local sink (log only)" selected>Local sink (log only)</option>
|
||||
<option value="passthru" data-l10n="Card's destination (OPEN CHANNEL)">Card's destination (OPEN CHANNEL)</option>
|
||||
<option value="redirect" data-l10n="Fixed target">Fixed target</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Host">Host</label>
|
||||
<input id="bip-host" value="127.0.0.1" class="w-36 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block mb-1 text-xs font-medium text-gray-600 dark:text-slate-400" data-l10n="Port">Port</label>
|
||||
<input id="bip-port" placeholder="ephemeral" class="w-24 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">
|
||||
</div>
|
||||
<button id="bip-start-btn" data-needs="server" onclick="bipStart()" class="px-3 py-1.5 text-sm rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Start">Start</button>
|
||||
<button id="bip-stop-btn" data-needs="server" onclick="bipStop()" class="px-3 py-1.5 text-sm rounded bg-gray-600 text-white hover:bg-gray-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Stop">Stop</button>
|
||||
</div>
|
||||
<div id="bip-msg" class="text-xs mt-2 hidden"></div>
|
||||
<div id="bip-sink-note" class="mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Local sink: accepts the card's BIP channels on this Host and Port, logs everything received and never sends anything back. Leave the port empty for an ephemeral port (the bound one appears in the status). Use it for cards that open a TCP connection just to upload data.">Local sink: accepts the card's BIP channels on this Host and Port, logs everything received and never sends anything back. Leave the port empty for an ephemeral port (the bound one appears in the status). Use it for cards that open a TCP connection just to upload data.</div>
|
||||
<div id="bip-passthru-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Card's destination: each BIP channel is connected to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port are not used and the server's network is used (lab only).">Card's destination: each BIP channel is connected to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port are not used and the server's network is used (lab only).</div>
|
||||
<div id="bip-redirect-note" class="hidden mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Fixed target: every BIP channel is connected to this Host:Port; the address the card requests is only logged.">Fixed target: every BIP channel is connected to this Host:Port; the address the card requests is only logged.</div>
|
||||
<div class="mt-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="One BIP session at a time: starting here replaces a running SCP81 listener, and starting the SCP81 listener replaces this session.">One BIP session at a time: starting here replaces a running SCP81 listener, and starting the SCP81 listener replaces this session.</div>
|
||||
</fieldset>
|
||||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3 mb-3">
|
||||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="Channels">Channels</legend>
|
||||
<div id="bip-channels" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
|
||||
</fieldset>
|
||||
<fieldset class="border border-gray-200 dark:border-slate-700 rounded p-3">
|
||||
<legend class="px-1 text-xs font-medium text-gray-500 dark:text-slate-400" data-l10n="BIP log">BIP log</legend>
|
||||
<div class="flex justify-end mb-1">
|
||||
<button data-needs="server" onclick="bipLogClear()" class="px-2.5 py-1 text-xs rounded bg-gray-200 dark:bg-slate-700 text-gray-700 dark:text-slate-300 hover:bg-gray-300 dark:hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Clear">Clear</button>
|
||||
</div>
|
||||
<div id="bip-log" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[55vh] overflow-auto"></div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div id="phone-sub-esim" class="hidden">
|
||||
<div id="esim-not-euicc" class="text-sm text-gray-500 dark:text-slate-400 hidden" data-l10n="The equipped card is not an eUICC">The equipped card is not an eUICC</div>
|
||||
<div id="esim-body" class="space-y-4">
|
||||
@@ -1622,12 +1664,13 @@
|
||||
// ===== 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.5.19';
|
||||
const SIMPLE_VERSION = '3.6.0';
|
||||
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
|
||||
|
||||
// ===== Tab switching =====
|
||||
function switchTab(name) {
|
||||
if (_scp81Timer && name !== 'scp81') { clearInterval(_scp81Timer); _scp81Timer = null; }
|
||||
if (_bipTimer && name !== 'phone') { clearInterval(_bipTimer); _bipTimer = null; }
|
||||
document.querySelectorAll('.tab-content').forEach(el => el.classList.add('hidden'));
|
||||
document.getElementById('tab-' + name).classList.remove('hidden');
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
@@ -1719,11 +1762,13 @@ function phoneSwitchSubtab(name) {
|
||||
btn.classList.toggle('text-gray-700', !active);
|
||||
btn.classList.toggle('dark:text-slate-300', !active);
|
||||
});
|
||||
['phone', 'tr', 'esim', 'test'].forEach(s => {
|
||||
['phone', 'tr', 'bip', 'esim', 'test'].forEach(s => {
|
||||
document.getElementById('phone-sub-' + s).classList.toggle('hidden', s !== name);
|
||||
});
|
||||
if (name === 'tr') {
|
||||
pysimPliRender();
|
||||
} else if (name === 'bip') {
|
||||
bipEnter();
|
||||
} else if (name === 'esim') {
|
||||
esimFetchAll();
|
||||
} else if (name === 'test') {
|
||||
@@ -1736,7 +1781,7 @@ function phoneSwitchSubtab(name) {
|
||||
pysimPollStatusInit();
|
||||
tpRefresh();
|
||||
}
|
||||
setHelpAnchor({ phone: 'stk-menu', tr: 'pli-dict', esim: 'esim', test: 'test-script' }[name] || 'stk-menu');
|
||||
setHelpAnchor({ phone: 'stk-menu', tr: 'pli-dict', bip: 'bip', esim: 'esim', test: 'test-script' }[name] || 'stk-menu');
|
||||
}
|
||||
|
||||
// ===== eSIM / LPA (local ES10a/b/c operations) =====
|
||||
@@ -11454,6 +11499,7 @@ function scp81LogLine(e) {
|
||||
if (e.free_volatile !== undefined) parts.push('free vol=' + e.free_volatile);
|
||||
if (e.channel) parts.push('ch' + e.channel);
|
||||
if (e.requested) parts.push(e.requested);
|
||||
if (e.peer) parts.push(e.peer);
|
||||
if (e.target) parts.push('-> ' + e.target);
|
||||
if (e.bytes) parts.push(e.bytes + 'B');
|
||||
if (e.identity) parts.push('id=' + e.identity);
|
||||
@@ -11752,6 +11798,7 @@ async function scp81StatusRefresh() {
|
||||
return st;
|
||||
}
|
||||
let s = l.mode + (l.host && l.port ? ' ' + l.host + ':' + l.port : '');
|
||||
if (st.owner === 'bip') s = t('BIP session:') + ' ' + s;
|
||||
if (l.mode === 'tls') {
|
||||
const ids = l.psk_identities || [];
|
||||
s += ' | PSK: ' + ids.length;
|
||||
@@ -11841,8 +11888,11 @@ async function scp81Start() {
|
||||
}
|
||||
try {
|
||||
const resp = await pysimFetch('/api/scp81/bip', body);
|
||||
if (resp.ok) scp81Msg(t('Listening.'), 'text-emerald-600 dark:text-emerald-400');
|
||||
else scp81Msg(resp.error || t('Error'), 'text-red-500');
|
||||
if (resp.ok) {
|
||||
const notice = bipReplacedNotice(resp.replaced);
|
||||
scp81Msg(t('Listening.') + (notice ? ' ' + notice : ''),
|
||||
'text-emerald-600 dark:text-emerald-400');
|
||||
} else scp81Msg(resp.error || t('Error'), 'text-red-500');
|
||||
scp81StatusRefresh();
|
||||
scp81LogRefresh();
|
||||
scp81ResultsRefresh();
|
||||
@@ -12133,6 +12183,175 @@ function scp81Enter() {
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ===== Generic BIP terminal (Simulator -> BIP) =====
|
||||
let _bipTimer = null;
|
||||
|
||||
function bipModeChanged() {
|
||||
const mode = document.getElementById('bip-mode').value;
|
||||
const notes = { sink: 'bip-sink-note', passthru: 'bip-passthru-note',
|
||||
redirect: 'bip-redirect-note' };
|
||||
for (const key in notes) {
|
||||
const el = document.getElementById(notes[key]);
|
||||
if (el) el.classList.toggle('hidden', key !== mode);
|
||||
}
|
||||
// Host/Port apply to the fixed target and to the sink bind address;
|
||||
// passthru dials the destination from the card's OPEN CHANNEL.
|
||||
for (const id of ['bip-host', 'bip-port']) {
|
||||
const el = document.getElementById(id);
|
||||
if (!el) continue;
|
||||
el.disabled = (mode === 'passthru');
|
||||
el.classList.toggle('opacity-40', mode === 'passthru');
|
||||
}
|
||||
const port = document.getElementById('bip-port');
|
||||
if (port) port.placeholder = (mode === 'sink') ? t('ephemeral') : '8443';
|
||||
}
|
||||
|
||||
// Build the /api/bip/control start body; {error} for invalid input.
|
||||
function bipStartBody(mode, host, port) {
|
||||
const body = { action: 'start', mode: mode };
|
||||
if (mode === 'passthru') return { body: body };
|
||||
const h = String(host || '').trim();
|
||||
const p = String(port || '').trim();
|
||||
if (mode === 'redirect') {
|
||||
const n = parseInt(p, 10);
|
||||
if (!h) return { error: 'Fixed target requires the host' };
|
||||
if (!Number.isInteger(n) || n < 1 || n > 65535) return { error: 'Port must be 1-65535' };
|
||||
body.host = h;
|
||||
body.port = n;
|
||||
} else {
|
||||
if (h) body.host = h;
|
||||
if (p) {
|
||||
const n = parseInt(p, 10);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 65535) return { error: 'Port must be 0-65535' };
|
||||
body.port = n;
|
||||
}
|
||||
}
|
||||
return { body: body };
|
||||
}
|
||||
|
||||
function bipReplacedNotice(replaced) {
|
||||
if (!replaced || !replaced.owner) return '';
|
||||
return (replaced.owner === 'scp81' ? t('Stopped the SCP81 listener')
|
||||
: t('Stopped the BIP session')) + ' (' + replaced.mode + ')';
|
||||
}
|
||||
|
||||
function bipChannelLine(c) {
|
||||
const parts = ['ch' + c.id, (c.requested || '?') + ' -> ' + (c.target || '?')];
|
||||
parts.push('in:' + (c.bytes_in || 0) + ' out:' + (c.bytes_out || 0) +
|
||||
' buffer:' + (c.pending || 0));
|
||||
parts.push(c.peer_closed ? t('peer closed') : t('open'));
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
function bipStatusLine(st) {
|
||||
const bip = (st && st.bip) || {};
|
||||
const l = (st && st.listener) || null;
|
||||
const parts = [];
|
||||
if (l) {
|
||||
let line = l.mode + (l.host !== undefined && l.port !== undefined
|
||||
? ' ' + l.host + ':' + l.port : '');
|
||||
if (l.connections !== undefined) line += ' | ' + l.connections + ' ' + t('connections');
|
||||
parts.push(line);
|
||||
}
|
||||
if (st && st.owner) parts.push(t('owner') + ': ' + st.owner);
|
||||
const ch = (bip.channels || []).map(bipChannelLine).join(' | ');
|
||||
if (ch) parts.push(ch);
|
||||
return parts.join(' \u00b7 ');
|
||||
}
|
||||
|
||||
function bipMsg(text, cls) {
|
||||
const el = document.getElementById('bip-msg');
|
||||
el.textContent = text;
|
||||
el.classList.remove('hidden');
|
||||
el.className = 'text-xs mt-2 ' + (cls || 'text-gray-500 dark:text-slate-400');
|
||||
}
|
||||
|
||||
async function bipStatusRefresh() {
|
||||
const el = document.getElementById('bip-state');
|
||||
const chEl = document.getElementById('bip-channels');
|
||||
try {
|
||||
const st = await pysimFetch('/api/bip/status');
|
||||
el.textContent = bipStatusLine(st);
|
||||
const chans = (st.bip && st.bip.channels) || [];
|
||||
chEl.innerHTML = chans.length
|
||||
? chans.map(c => '<div class="py-0.5 border-b border-gray-100 dark:border-slate-700/50 break-all">' + esc(bipChannelLine(c)) + '</div>').join('')
|
||||
: '<span class="text-gray-400">' + t('No channels.') + '</span>';
|
||||
return st;
|
||||
} catch (err) {
|
||||
el.textContent = String(err.message || err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function bipLogRefresh() {
|
||||
const el = document.getElementById('bip-log');
|
||||
try {
|
||||
const log = await pysimFetch('/api/bip/log');
|
||||
const entries = (log.entries || []).slice(-200);
|
||||
if (!entries.length) {
|
||||
el.innerHTML = '<span class="text-gray-400">' + t('No entries yet.') + '</span>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = entries.map(e =>
|
||||
'<div class="py-0.5 border-b border-gray-100 dark:border-slate-700/50 break-all">' + scp81LogEntryHtml(e) + '</div>'
|
||||
).join('');
|
||||
} catch (err) {
|
||||
el.innerHTML = '<span class="text-red-500">Error: ' + esc(String(err.message || err)) + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function bipLogClear() {
|
||||
try {
|
||||
await pysimFetch('/api/bip/log-clear', {});
|
||||
} catch (err) { /* keep the log as it is */ }
|
||||
bipLogRefresh();
|
||||
}
|
||||
|
||||
async function bipStart() {
|
||||
const mode = document.getElementById('bip-mode').value;
|
||||
const built = bipStartBody(mode,
|
||||
document.getElementById('bip-host').value,
|
||||
document.getElementById('bip-port').value);
|
||||
if (built.error) { bipMsg(t(built.error), 'text-red-500'); return; }
|
||||
try {
|
||||
const resp = await pysimFetch('/api/bip/control', built.body);
|
||||
if (!resp.ok) { bipMsg(t('Start failed:') + ' ' + (resp.error || ''), 'text-red-500'); return; }
|
||||
const notice = bipReplacedNotice(resp.replaced);
|
||||
bipMsg(t('BIP session started') + (notice ? ' \u00b7 ' + notice : ''),
|
||||
'text-emerald-600 dark:text-emerald-400');
|
||||
bipStatusRefresh();
|
||||
bipLogRefresh();
|
||||
} catch (err) {
|
||||
bipMsg(String(err.message || err), 'text-red-500');
|
||||
}
|
||||
}
|
||||
|
||||
async function bipStop() {
|
||||
try {
|
||||
const resp = await pysimFetch('/api/bip/control', { action: 'stop' });
|
||||
const notice = bipReplacedNotice(resp.replaced);
|
||||
bipMsg(t('BIP session stopped') + (notice ? ' \u00b7 ' + notice : ''),
|
||||
'text-gray-500 dark:text-slate-400');
|
||||
bipStatusRefresh();
|
||||
bipLogRefresh();
|
||||
} catch (err) {
|
||||
bipMsg(String(err.message || err), 'text-red-500');
|
||||
}
|
||||
}
|
||||
|
||||
function bipEnter() {
|
||||
bipModeChanged();
|
||||
bipStatusRefresh();
|
||||
bipLogRefresh();
|
||||
setHelpAnchor('bip');
|
||||
if (_bipTimer) clearInterval(_bipTimer);
|
||||
_bipTimer = setInterval(() => {
|
||||
if (document.getElementById('phone-sub-bip').classList.contains('hidden')) return;
|
||||
bipStatusRefresh();
|
||||
bipLogRefresh();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// ===== PLI data dictionary =====
|
||||
function decPlmn(hex3) {
|
||||
const h = hex3.replace(/\s/g, '').slice(0, 6);
|
||||
@@ -16290,6 +16509,32 @@ const LANG_RU = {
|
||||
'The key is only sent to the local server and is never stored or logged.': 'Ключ передаётся только локальному серверу, не сохраняется и не записывается в журнал.',
|
||||
'Listening.': 'Слушает.',
|
||||
'Stopped.': 'Остановлено.',
|
||||
'BIP': 'BIP',
|
||||
'BIP terminal': 'BIP-терминал',
|
||||
'Local sink (log only)': 'Локальный приёмник (только журнал)',
|
||||
'Card\'s destination (OPEN CHANNEL)': 'Адрес карты (OPEN CHANNEL)',
|
||||
'Fixed target': 'Заданный адрес',
|
||||
'Channels': 'Каналы',
|
||||
'BIP log': 'Журнал BIP',
|
||||
'Local sink: accepts the card\'s BIP channels on this Host and Port, logs everything received and never sends anything back. Leave the port empty for an ephemeral port (the bound one appears in the status). Use it for cards that open a TCP connection just to upload data.': 'Локальный приёмник: принимает BIP-каналы карты на этом Host и Port, записывает всё принятое в журнал и ничего не отправляет обратно. Оставьте порт пустым для эфемерного порта (занятый порт появится в статусе). Подходит для карт, которые открывают TCP-соединение только чтобы выгрузить данные.',
|
||||
'Card\'s destination: each BIP channel is connected to the destination the card requests in OPEN CHANNEL (Other address + transport port, TCP client only); Host and Port are not used and the server\'s network is used (lab only).': 'Адрес карты: каждый BIP-канал подключается к адресу, который карта запросила в OPEN CHANNEL (Other address + transport port, только TCP-клиент); Host и Port не используются, используется сеть сервера (только для лаборатории).',
|
||||
'Fixed target: every BIP channel is connected to this Host:Port; the address the card requests is only logged.': 'Заданный адрес: каждый BIP-канал подключается к этому Host:Port; запрошенный картой адрес только записывается в журнал.',
|
||||
'One BIP session at a time: starting here replaces a running SCP81 listener, and starting the SCP81 listener replaces this session.': 'Одновременно работает только одна BIP-сессия: запуск здесь останавливает слушатель SCP81, а запуск слушателя SCP81 останавливает эту сессию.',
|
||||
'ephemeral': 'эфемерный',
|
||||
'Fixed target requires the host': 'Для заданного адреса нужен хост',
|
||||
'Port must be 1-65535': 'Порт должен быть в диапазоне 1-65535',
|
||||
'Port must be 0-65535': 'Порт должен быть в диапазоне 0-65535',
|
||||
'peer closed': 'пир закрыт',
|
||||
'open': 'открыт',
|
||||
'connections': 'соединений',
|
||||
'owner': 'владелец',
|
||||
'No channels.': 'Каналов нет.',
|
||||
'Start failed:': 'Запуск не удался:',
|
||||
'BIP session started': 'BIP-сессия запущена',
|
||||
'BIP session stopped': 'BIP-сессия остановлена',
|
||||
'Stopped the SCP81 listener': 'Остановлен слушатель SCP81',
|
||||
'Stopped the BIP session': 'Остановлена BIP-сессия',
|
||||
'BIP session:': 'BIP-сессия:',
|
||||
'not running': 'не запущен',
|
||||
'configured': 'настроен',
|
||||
'required': 'обязательно',
|
||||
@@ -16941,6 +17186,7 @@ function refreshDynamicI18n() {
|
||||
capRenderAnalysis('ram');
|
||||
capRenderAnalysis('scripts');
|
||||
if (isViewVisible('tab-phone')) {
|
||||
if (isViewVisible('phone-sub-bip')) { bipStatusRefresh(); bipLogRefresh(); }
|
||||
if (isViewVisible('phone-sub-phone')) {
|
||||
pysimEventsRender();
|
||||
pysimProactiveLogRender();
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = 'simple-v272';
|
||||
const CACHE = 'simple-v273';
|
||||
const URLS = [
|
||||
'index.html',
|
||||
'help.html',
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
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);
|
||||
}
|
||||
|
||||
globalThis.esc = s => s;
|
||||
globalThis.t = s => s;
|
||||
for (const fn of ['bipReplacedNotice', 'bipChannelLine', 'bipStatusLine', 'bipStartBody', 'bipModeChanged']) {
|
||||
eval(extractFunc(html, fn));
|
||||
}
|
||||
|
||||
function fakeDom(mode) {
|
||||
const els = {};
|
||||
for (const id of ['bip-mode', 'bip-host', 'bip-port', 'bip-sink-note',
|
||||
'bip-passthru-note', 'bip-redirect-note']) {
|
||||
const el = {
|
||||
disabled: false, placeholder: '', toggled: {},
|
||||
classList: { toggle(c, on) { el.toggled[c] = on; } },
|
||||
};
|
||||
els[id] = el;
|
||||
}
|
||||
els['bip-mode'].value = mode;
|
||||
globalThis.document = { getElementById: id => els[id] || null };
|
||||
return els;
|
||||
}
|
||||
|
||||
test('bipStartBody builds the control body per mode', () => {
|
||||
// passthru: no host/port at all - the card chooses the destination
|
||||
assert.deepStrictEqual(bipStartBody('passthru', '127.0.0.1', '9'),
|
||||
{ body: { action: 'start', mode: 'passthru' } });
|
||||
// sink: host optional, empty port = server-side ephemeral
|
||||
assert.deepStrictEqual(bipStartBody('sink', '127.0.0.1', ''),
|
||||
{ body: { action: 'start', mode: 'sink', host: '127.0.0.1' } });
|
||||
assert.deepStrictEqual(bipStartBody('sink', '', '9101'),
|
||||
{ body: { action: 'start', mode: 'sink', port: 9101 } });
|
||||
// fixed target: host and a real port required
|
||||
assert.deepStrictEqual(bipStartBody('redirect', '10.0.0.5', '8080'),
|
||||
{ body: { action: 'start', mode: 'redirect', host: '10.0.0.5', port: 8080 } });
|
||||
assert.strictEqual(bipStartBody('redirect', '', '8080').error, 'Fixed target requires the host');
|
||||
assert.strictEqual(bipStartBody('redirect', '10.0.0.5', '').error, 'Port must be 1-65535');
|
||||
assert.strictEqual(bipStartBody('redirect', '10.0.0.5', '0').error, 'Port must be 1-65535');
|
||||
assert.strictEqual(bipStartBody('redirect', '10.0.0.5', 'x').error, 'Port must be 1-65535');
|
||||
assert.strictEqual(bipStartBody('sink', '127.0.0.1', 'x').error, 'Port must be 0-65535');
|
||||
assert.strictEqual(bipStartBody('sink', '127.0.0.1', '70000').error, 'Port must be 0-65535');
|
||||
});
|
||||
|
||||
test('bipReplacedNotice names what the start replaced', () => {
|
||||
assert.strictEqual(bipReplacedNotice(null), '');
|
||||
assert.strictEqual(bipReplacedNotice({}), '');
|
||||
assert.strictEqual(bipReplacedNotice({ owner: 'scp81', mode: 'tls' }),
|
||||
'Stopped the SCP81 listener (tls)');
|
||||
assert.strictEqual(bipReplacedNotice({ owner: 'bip', mode: 'sink' }),
|
||||
'Stopped the BIP session (sink)');
|
||||
});
|
||||
|
||||
test('bipChannelLine summarizes one channel', () => {
|
||||
assert.strictEqual(bipChannelLine({ id: 1, requested: '10.0.0.5:80',
|
||||
target: '10.0.0.5:80', bytes_in: 412, bytes_out: 0, pending: 3,
|
||||
peer_closed: false }),
|
||||
'ch1 10.0.0.5:80 -> 10.0.0.5:80 in:412 out:0 buffer:3 open');
|
||||
assert.strictEqual(bipChannelLine({ id: 2, peer_closed: true }),
|
||||
'ch2 ? -> ? in:0 out:0 buffer:0 peer closed');
|
||||
});
|
||||
|
||||
test('bipStatusLine shows the mode, bound address, connections, owner and channels', () => {
|
||||
assert.strictEqual(bipStatusLine({ owner: 'bip', bip: { channels: [] },
|
||||
listener: { mode: 'sink', host: '127.0.0.1', port: 53121, connections: 1 } }),
|
||||
'sink 127.0.0.1:53121 | 1 connections \u00b7 owner: bip');
|
||||
assert.strictEqual(bipStatusLine({ owner: 'bip', bip: { channels: [] },
|
||||
listener: { mode: 'passthru' } }), 'passthru \u00b7 owner: bip');
|
||||
assert.strictEqual(bipStatusLine(null), '');
|
||||
const withChannel = bipStatusLine({
|
||||
owner: 'bip',
|
||||
bip: { channels: [
|
||||
{ id: 1, requested: 'a:1', target: 'b:2', bytes_in: 5, bytes_out: 7, pending: 0 }] },
|
||||
listener: { mode: 'sink', host: 'h', port: 1 },
|
||||
});
|
||||
assert.ok(withChannel.includes('ch1 a:1 -> b:2 in:5 out:7 buffer:0 open'), withChannel);
|
||||
});
|
||||
|
||||
test('bipModeChanged toggles the fields and the mode notes', () => {
|
||||
let els = fakeDom('sink');
|
||||
bipModeChanged();
|
||||
assert.strictEqual(els['bip-host'].disabled, false);
|
||||
assert.strictEqual(els['bip-port'].disabled, false);
|
||||
assert.strictEqual(els['bip-port'].placeholder, 'ephemeral');
|
||||
assert.strictEqual(els['bip-sink-note'].toggled.hidden, false);
|
||||
assert.strictEqual(els['bip-passthru-note'].toggled.hidden, true);
|
||||
assert.strictEqual(els['bip-redirect-note'].toggled.hidden, true);
|
||||
|
||||
els = fakeDom('passthru');
|
||||
bipModeChanged();
|
||||
assert.strictEqual(els['bip-host'].disabled, true);
|
||||
assert.strictEqual(els['bip-port'].disabled, true);
|
||||
assert.strictEqual(els['bip-host'].toggled['opacity-40'], true);
|
||||
assert.strictEqual(els['bip-passthru-note'].toggled.hidden, false);
|
||||
assert.strictEqual(els['bip-sink-note'].toggled.hidden, true);
|
||||
|
||||
els = fakeDom('redirect');
|
||||
bipModeChanged();
|
||||
assert.strictEqual(els['bip-host'].disabled, false);
|
||||
assert.strictEqual(els['bip-port'].placeholder, '8443');
|
||||
assert.strictEqual(els['bip-redirect-note'].toggled.hidden, false);
|
||||
});
|
||||
|
||||
test('the BIP pill, view and API endpoints are wired', () => {
|
||||
assert.match(html, /data-phone-sub="bip"[^>]*data-l10n="BIP"/);
|
||||
assert.match(html, /id="phone-sub-bip"/);
|
||||
assert.match(html, /\['phone', 'tr', 'bip', 'esim', 'test'\]/);
|
||||
assert.match(html, /else if \(name === 'bip'\) \{\s*bipEnter\(\);/);
|
||||
assert.match(html, /bip: 'bip'/);
|
||||
// the poll stops when the Simulator tab is left
|
||||
assert.match(html, /if \(_bipTimer && name !== 'phone'\)/);
|
||||
for (const id of ['bip-mode', 'bip-host', 'bip-port', 'bip-start-btn',
|
||||
'bip-stop-btn', 'bip-state', 'bip-channels', 'bip-log']) {
|
||||
assert.ok(html.includes('id="' + id + '"'), id);
|
||||
}
|
||||
assert.match(html, /\/api\/bip\/control/);
|
||||
assert.match(html, /\/api\/bip\/status/);
|
||||
assert.match(html, /\/api\/bip\/log-clear/);
|
||||
assert.match(html, /\/api\/bip\/log'/);
|
||||
// the shared one-session rule and the owner marker are both shown
|
||||
assert.match(html, /One BIP session at a time: starting here replaces a running SCP81 listener/);
|
||||
assert.match(html, /if \(st\.owner === 'bip'\) s = t\('BIP session:'\)/);
|
||||
assert.match(html, /if \(e\.peer\) parts\.push\(e\.peer\);/);
|
||||
assert.match(html, /if \(isViewVisible\('phone-sub-bip'\)\) \{ bipStatusRefresh\(\); bipLogRefresh\(\); \}/);
|
||||
});
|
||||
@@ -236,7 +236,7 @@ test('the chip view groups the sections into a grid', () => {
|
||||
test('the eSIM LPA pill is wired into the Simulator tab', () => {
|
||||
assert.ok(html.includes('data-phone-sub="esim"'));
|
||||
assert.ok(html.includes('id="phone-sub-esim"'));
|
||||
assert.ok(html.includes("'phone', 'tr', 'esim'"));
|
||||
assert.ok(html.includes("'phone', 'tr', 'bip', 'esim'"));
|
||||
assert.ok(html.includes('id="esim-refresh-btn"'));
|
||||
assert.ok(html.includes('id="esim-profiles"'));
|
||||
assert.ok(html.includes('id="esim-notifications"'));
|
||||
|
||||
@@ -31,6 +31,7 @@ const code = extractFunc(html, 'phoneSwitchSubtab') + '\n' +
|
||||
'globalThis.tpRefresh = () => { globalThis._tp = (globalThis._tp || 0) + 1; };\n' +
|
||||
'globalThis.esimFetchAll = () => { globalThis._esim = (globalThis._esim || 0) + 1; };\n' +
|
||||
'globalThis.testInit = () => { globalThis._test = (globalThis._test || 0) + 1; };\n' +
|
||||
'globalThis.bipEnter = () => { globalThis._bip = (globalThis._bip || 0) + 1; };\n' +
|
||||
'globalThis.netStateFetch = () => { globalThis._netstate = (globalThis._netstate || 0) + 1; };\n';
|
||||
eval(code);
|
||||
|
||||
@@ -46,12 +47,14 @@ function setup() {
|
||||
const buttons = [
|
||||
{ dataset: { phoneSub: 'phone' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'tr' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'bip' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'esim' }, classList: makeClassList() },
|
||||
{ dataset: { phoneSub: 'test' }, classList: makeClassList() },
|
||||
];
|
||||
const panels = {
|
||||
'phone-sub-phone': { classList: makeClassList() },
|
||||
'phone-sub-tr': { classList: makeClassList() },
|
||||
'phone-sub-bip': { classList: makeClassList() },
|
||||
'phone-sub-esim': { classList: makeClassList() },
|
||||
'phone-sub-test': { classList: makeClassList() },
|
||||
};
|
||||
@@ -63,6 +66,7 @@ function setup() {
|
||||
globalThis._stk = globalThis._events = globalThis._log = globalThis._poll = globalThis._pli = globalThis._tp = 0;
|
||||
globalThis._esim = globalThis._netstate = 0;
|
||||
globalThis._test = 0;
|
||||
globalThis._bip = 0;
|
||||
return { buttons, panels };
|
||||
}
|
||||
|
||||
@@ -78,6 +82,16 @@ test('TR Config pill shows the TR panel and renders PLI data', () => {
|
||||
assert.strictEqual(globalThis._stk, 0);
|
||||
});
|
||||
|
||||
test('BIP pill shows the BIP panel', () => {
|
||||
const { buttons, panels } = setup();
|
||||
phoneSwitchSubtab('bip');
|
||||
assert.ok(!panels['phone-sub-bip'].classList.has('hidden'));
|
||||
assert.ok(panels['phone-sub-phone'].classList.has('hidden'));
|
||||
assert.strictEqual(globalThis._anchor, 'bip');
|
||||
assert.strictEqual(globalThis._bip, 1);
|
||||
assert.strictEqual(globalThis._stk, 0);
|
||||
});
|
||||
|
||||
test('Phone pill shows the phone panel and renders CAT views', () => {
|
||||
const { buttons, panels } = setup();
|
||||
phoneSwitchSubtab('phone');
|
||||
|
||||
@@ -258,7 +258,7 @@ test('the item text check offers contains/exact only', () => {
|
||||
test('the Simulator hosts the Test script pill and its wiring', () => {
|
||||
assert.match(html, /data-phone-sub="test"[^>]*data-l10n="Test script"/);
|
||||
assert.match(html, /id="phone-sub-test"/);
|
||||
assert.match(html, /\['phone', 'tr', 'esim', 'test'\]/);
|
||||
assert.match(html, /\['phone', 'tr', 'bip', 'esim', 'test'\]/);
|
||||
assert.match(html, /else if \(name === 'test'\) \{\s*testInit\(\);/);
|
||||
assert.match(html, /test: 'test-script'/);
|
||||
assert.match(html, /testRunActive\(\) && el\.closest && !el\.closest\('#phone-sub-test'\)/);
|
||||
|
||||
Reference in New Issue
Block a user