feat: SCP80 SMS concatenation + packet size / SMS count display (v3.2.0)

Packets longer than one SMS now go out as concatenated SMS-PP downloads
per TS 31.115 4.3 and the UI shows how many SMS a packet needs.

Server:
- `_split_secured_packet` cuts the command packet at the exact SMS
  user-data capacities (first SM 132 octets: concat IE 5 + CPI IE 2;
  following ones 134; a single-SM packet may be 137 with the CPI IE) and
  `_build_sms_tpdu` tags every segment with the fixed concatenation
  reference 01; `_build_sms_tpdu` also enforces the 140-octet budget.
- `_send_secured_packet` (shared by /api/send-ota and /api/ram-install)
  sends one ENVELOPE per segment in order, refuses more than
  MAX_ENVELOPE_SEGMENTS (5, the card's concatenation buffer) and reports
  `bytes`/`segments` in the response (RAM install per step as well).
- `_build_secured_packet`/`_encode_cmd_unlimited`: our own TS 102 225
  5.1.1 encoder on pySim's keyset/header constructors, byte-identical to
  pySim for packets <= 140 octets (tests) and not limited to one SMS
  (pySim refuses the longer ones, which is why they never went out).
- RAM LOAD blocks are no longer clamped to one SMS: 1-240 bytes of
  payload with the default 240 (the GP maximum); `load_block_size_auto`
  replaces `load_block_size_clamped`.

PWA:
- `scp80SegmentInfo` / `spSizeInfoText` show "N bytes . M SMS
  (concatenated)" under the packet field, turn red past 5 SMS and report
  size/SMS in the send result and the RAM step log; LOAD block hints and
  placeholders updated; EN/RU.

Tests/docs: Python +2 cases incl. segment order/capacities and byte
identity with pySim; Node drift guard against the server constants and
UI text tests; README/README_RUS, help EN/RU, docs/api.md, AGENTS.
This commit is contained in:
2026-09-22 23:02:13 +03:00
parent f72054f61f
commit 43f50d3b72
11 changed files with 527 additions and 175 deletions
+2 -2
View File
@@ -273,7 +273,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>Байт паддинга настраивается (<code class="font-mono text-sm">00</code> по умолчанию или <code class="font-mono text-sm">FF</code>)</li>
</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> (Remote APDU), а успешный 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 (при подключении к серверу). Под полем пакета показывается его размер и число SMS; пакет, не помещающийся в одно SMS, отправляется <strong>конкатенированной</strong> загрузкой (TS 31.115 &sect;4.3) &mdash; пакет делится на части SMS-пользовательских данных (первое SMS 132 октета, последующие по 134; первое дополнительно несёт информационные элементы конкатенации и CPI), и сегменты отправляются по порядку. Более 5 сегментов отклоняется: это практический предел буфера конкатенации карты. Полученный Proof of Receipt декодируется и показывается строкой статуса PoR (статус, TAR, счётчик, сырой PoR); статусное слово и данные ответа последней команды подставляются в подвкладку <strong>&laquo;Парсер ответов&raquo;</strong> (Remote APDU), а успешный PoR увеличивает счётчик повторов и очищает пакет.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3>
<p class="mb-2">Выполняет операции удалённого управления приложениями (Remote Application Management) как защищённые пакеты SCP80 через SMS-PP-DOWNLOAD ENVELOPE. Карта должна поддерживать SCP03 (AES или 3DES). Предустановка карты со вкладки <strong>Карты</strong> обеспечивает SPI, ключи, TAR и счётчик (RAM использует <strong>ISD TAR</strong> предустановки, по умолчанию <code class="font-mono text-sm">000000</code>).</p>
@@ -283,7 +283,7 @@
<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>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Обзор карты (все данные GP)</td><td class="py-1 px-2">Запрос GET STATUS для ISD, приложений, ELF и модулей ELF, а также GET DATA FF21 для информации о памяти. Результаты отображаются в обзоре с кнопками <strong>Удалить</strong> для каждого элемента.</td></tr>
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU, каждый из которых помещается в один SMS SCP80; поле <strong>размер блока LOAD</strong> переопределяет авто-подобранный размер (пусто = максимальный размер, чей secured-пакет укладывается в 140 октетов), так что большой <code>.cap</code> просто занимает несколько SMS.</td></tr>
<tr><td class="py-1 px-2">Установка пакета (.cap файл)</td><td class="py-1 px-2">Отправка <code class="font-mono text-sm">.cap</code> файла на карту через сервер: INSTALL[for load] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. Load-файл делится на LOAD APDU размера <strong>размер блока LOAD</strong> (1&ndash;240 байт полезной нагрузки, по умолчанию 240); каждый защищённый пакет доставляется одним SMS или, если он больше, конкатенированной SMS-PP загрузкой до 5 сегментов, так что большой <code>.cap</code> просто занимает несколько SMS.</td></tr>
</tbody>
</table>
+2 -2
View File
@@ -273,7 +273,7 @@
<li>AES requires a replay-protected counter: SPI1 bits b5&nbsp;b4 must be <code class="font-mono text-sm">10</code> (counter higher) or <code class="font-mono text-sm">11</code> (counter +1) per TS 102 225 &sect;5.1.2/&sect;5.1.3.1</li>
<li>Padding byte configurable (<code class="font-mono text-sm">00</code> default, or <code class="font-mono text-sm">FF</code>)</li>
</ul>
<p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command&rsquo;s status word and response data are filled into the <strong>Response parser</strong> tab, and a successful PoR advances the replay counter and clears the packet.</p>
<p class="text-sm mb-3">A &ldquo;Verify vs pySim&rdquo; button cross-checks the assembled packet against pySim&rsquo;s reference <code class="font-mono text-sm">OtaDialectSms.encode_cmd</code>. A &ldquo;Send to Card&rdquo; button delivers it via SMS-PP-DOWNLOAD ENVELOPE (when connected to the server). Under the packet field the app reports its size and how many SMS it takes; a packet that does not fit one SMS is sent as a <strong>concatenated</strong> download (TS 31.115 &sect;4.3) &mdash; the packet is split into SMS user-data parts (first SM 132 octets, following ones 134; the first one additionally carries the concatenation and CPI information elements) and the segments are sent in order. More than 5 segments is refused, that being the practical limit of the card&rsquo;s concatenation buffer. The returned Proof of Receipt is decoded and shown as a PoR status line (status, TAR, counter, raw PoR); the last command&rsquo;s status word and response data are filled into the <strong>Response parser</strong> tab, and a successful PoR advances the replay counter and clears the packet.</p>
<h3 id="ram" class="text-lg font-medium mb-2">3.2 RAM</h3>
<p class="mb-2">Delivers Remote Application Management operations as SCP80 secured packets via SMS-PP-DOWNLOAD ENVELOPE. The card must support SCP03 (AES or 3DES). A saved card preset from the <strong>Cards</strong> tab provides the SPI, keys, TAR, and counter (RAM uses the preset's <strong>ISD TAR</strong>, <code class="font-mono text-sm">000000</code> by default).</p>
@@ -283,7 +283,7 @@
<thead><tr class="border-b border-gray-300 dark:border-slate-700"><th class="text-left py-1 px-2">Operation</th><th class="text-left py-1 px-2">Description</th></tr></thead>
<tbody>
<tr class="border-b border-gray-200 dark:border-slate-700"><td class="py-1 px-2">Explore Card (all GP data)</td><td class="py-1 px-2">Queries GET STATUS for ISD, Applications, ELFs, and ELF Modules, plus GET DATA FF21 for memory info. Results appear in an explorer view with per-item <strong>Delete</strong> buttons.</td></tr>
<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] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs that each fit one SCP80 SMS; the <strong>LOAD block size</strong> field overrides the auto-fitted size (empty = largest size whose secured packet still encodes into 140 octets), so a large <code>.cap</code> simply takes several SMS.</td></tr>
<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] &rarr; LOAD &times;N &rarr; INSTALL[for install (+make selectable)]. The load file is split into LOAD APDUs of the <strong>LOAD block size</strong> (1&ndash;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.</td></tr>
</tbody>
</table>
+69 -14
View File
@@ -550,6 +550,7 @@
<button data-needs="card" onclick="pysimSendOta()" id="sp-send-btn" class="hidden mb-3 px-5 py-2.5 bg-emerald-600 text-white text-sm font-medium rounded hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Send to Card">Send to Card</button>
<div id="sp-por-status" class="mt-2 text-base font-semibold font-mono hidden"></div>
<textarea id="sp-result" rows="3" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 bg-gray-100 dark:bg-slate-800"></textarea>
<div id="sp-size-info" class="text-xs mt-1 text-gray-500 dark:text-slate-400"></div>
<div id="sp-send-result" class="mt-2 text-xs font-mono hidden"></div>
<div id="sp-verify-result" class="mt-2 text-xs font-mono hidden"></div>
@@ -693,9 +694,9 @@
<label class="flex items-center gap-2 mt-2">
<input type="checkbox" id="ram-make-sel" checked> <span class="text-sm" data-l10n="Make selectable">Make selectable</span>
</label>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400 mt-2" data-l10n="LOAD block size (bytes, empty = auto-fit)">LOAD block size (bytes, empty = auto-fit)</label>
<input id="ram-load-size" type="number" min="1" max="240" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="auto">
<div class="text-xs text-gray-500 dark:text-slate-400 mt-1" data-l10n="Splits the load file into LOAD APDUs that fit one SCP80 SMS; auto-fit picks the largest size that encodes into 140 octets.">Splits the load file into LOAD APDUs that fit one SCP80 SMS; auto-fit picks the largest size that encodes into 140 octets.</div>
<label class="block mb-1 text-xs font-medium text-gray-500 dark:text-slate-400 mt-2" data-l10n="LOAD block size (bytes, empty = 240)">LOAD block size (bytes, empty = 240)</label>
<input id="ram-load-size" type="number" min="1" max="240" class="w-32 font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-1.5 dark:bg-slate-800" placeholder="240">
<div class="text-xs text-gray-500 dark:text-slate-400 mt-1" data-l10n="Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).">Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).</div>
</div>
<div id="ram-progress" class="hidden mb-3">
@@ -1436,7 +1437,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.1.4';
const SIMPLE_VERSION = '3.2.0';
document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION;
// ===== Tab switching =====
@@ -3268,6 +3269,7 @@ function spCntrAdjust(delta) {
}
function spInvalidate() {
document.getElementById('sp-result').value = '';
spShowSizeInfo();
}
function updateSp() {
@@ -3468,6 +3470,56 @@ function aesCmac(data, keyBytes) {
// CPL = octets from CHL to end incl. padding; CHL = 13 + len_sig.
// CPI (0x70) and CHI (null) are NOT part of the SMS packet data.
function genSp() {
_genSpBuild();
spShowSizeInfo();
}
// ===== SCP80 SMS segmentation (TS 31.115 4.2/4.3) =====
// Capacities mirror the server's SCP80_*_BYTES constants (pysim_simple_server/
// server.py): 140 octets of SMS user data minus the UDH, which carries the
// concatenation IE (5 octets) in every SM and the CPI IE (2 octets) in the
// first one of a concatenated command.
const SCP80_MAX_SMS = 5;
const SCP80_SINGLE_BYTES = 137;
const SCP80_FIRST_BYTES = 132;
const SCP80_NEXT_BYTES = 134;
// Secured packet hex -> {bytes, segments} of the SMS-PP download.
function scp80SegmentInfo(hex) {
const bytes = Math.floor((hex || '').replace(/[^0-9a-fA-F]/g, '').length / 2);
if (!bytes) return { bytes: 0, segments: 0 };
if (bytes <= SCP80_SINGLE_BYTES) return { bytes: bytes, segments: 1 };
let segments = 1;
let rest = bytes - SCP80_FIRST_BYTES;
while (rest > 0) { segments += 1; rest -= SCP80_NEXT_BYTES; }
return { bytes: bytes, segments: segments };
}
function spSizeInfoText(hex) {
const info = scp80SegmentInfo(hex);
if (!info.segments) return '';
let s = info.bytes + ' ' + t('bytes') + ' · ' + info.segments + ' SMS';
if (info.segments > 1) s += ' (' + t('concatenated') + ')';
if (info.segments > SCP80_MAX_SMS) {
s += ' — ' + t('too large for the card concatenation buffer') + ' (' + SCP80_MAX_SMS + ' SMS)';
}
return s;
}
// Refresh the packet size / SMS count line under the packet field.
function spShowSizeInfo() {
const el = document.getElementById('sp-size-info');
if (!el) return;
const value = (document.getElementById('sp-result') || {}).value || '';
const isPacket = /^\s*[0-9a-fA-F\s]+$/.test(value);
const info = isPacket ? scp80SegmentInfo(value) : { bytes: 0, segments: 0 };
el.textContent = info.segments ? spSizeInfoText(value) : '';
el.className = 'text-xs mt-1 ' + (info.segments > SCP80_MAX_SMS
? 'text-red-600 dark:text-red-400'
: 'text-gray-500 dark:text-slate-400');
}
function _genSpBuild() {
const apduHex = (document.getElementById('sp-apdu').value || '').replace(/[^0-9a-fA-F]/g, '');
if (!apduHex) { document.getElementById('sp-result').value = 'Error: specify APDU'; return; }
@@ -5804,6 +5856,9 @@ async function pysimSendOta() {
cApduSwitchSubtab('response');
}
let msg = 'OTA sent. SW: ' + data.sw;
if (data.bytes && data.segments) {
msg += ' | ' + data.bytes + ' ' + t('bytes') + ' · ' + data.segments + ' SMS';
}
const por = data.por;
if (por && por.response_status) {
msg += ' | PoR status: ' + por.response_status + ' (TAR ' + por.tar + ')';
@@ -5821,6 +5876,7 @@ async function pysimSendOta() {
cntrEl.value = spNextCntr(cntrEl.value);
msg += ' | CNTR -> ' + cntrEl.value;
document.getElementById('sp-result').value = '';
spShowSizeInfo();
// keep the selected card preset in sync with the new counter (v1.9.8)
spCntrSyncPreset();
sendResultEl.textContent = msg;
@@ -6429,7 +6485,9 @@ async function ramInstallCap(sp) {
let txt = '';
(data.steps || []).forEach((s, idx) => {
const mark = s.por_status === 'por_ok' ? '✅' : '❌';
txt += mark + ' ' + t('Step') + ' ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')\n';
let line = mark + ' ' + t('Step') + ' ' + (idx + 1) + ': ' + s.name + ' — ' + s.por_status + ' (SW ' + s.sw + ')';
if (s.bytes && s.segments) line += ' · ' + s.bytes + ' ' + t('bytes') + ' / ' + s.segments + ' SMS';
txt += line + '\n';
});
stepsEl.textContent = txt;
@@ -6438,11 +6496,7 @@ async function ramInstallCap(sp) {
let sizeInfo = '';
if (data.load_block_size) {
sizeInfo = ' — ' + t('LOAD blocks') + ': ' + data.load_block_size + ' B';
if (data.load_block_size_clamped) {
sizeInfo += ' (' + t('clamped from') + ' ' + data.load_block_size_requested + ')';
} else if (!data.load_block_size_requested) {
sizeInfo += ' (' + t('auto-fit') + ')';
}
if (data.load_block_size_auto) sizeInfo += ' (' + t('default') + ')';
}
resultEl.textContent = t('Install OK') + ' — load_file_aid=' + data.load_file_aid + ' module_aid=' + data.module_aid + sizeInfo;
resultEl.classList.remove('hidden', 'text-red-600'); resultEl.classList.add('text-green-600');
@@ -13993,12 +14047,13 @@ const LANG_RU = {
'Object and related objects': 'Объект и связанные объекты',
'SD AID (empty = ISD)': 'AID SD (пусто = ISD)',
'CAP file': 'CAP-файл',
'LOAD block size (bytes, empty = auto-fit)': 'Размер блока LOAD (байт, пусто = авто)',
'Splits the load file into LOAD APDUs that fit one SCP80 SMS; auto-fit picks the largest size that encodes into 140 octets.': 'Разбивает load-файл на LOAD APDU, помещающиеся в один SMS SCP80; авто-подбор выбирает максимальный размер, укладывающийся в 140 октетов.',
'LOAD block size (bytes, empty = 240)': 'Размер блока LOAD (байт, пусто = 240)',
'default': 'по умолчанию',
'concatenated': 'конкатенация',
'too large for the card concatenation buffer': 'слишком велик для буфера конкатенации карты',
'Splits the load file into LOAD APDUs (1-240 bytes of payload). A secured packet larger than one SMS is sent as a concatenated SMS-PP download (up to 5 SMs).': 'Разбивает load-файл на LOAD APDU (полезная нагрузка 1–240 байт). Защищённый пакет больше одного SMS отправляется как конкатенированная SMS-PP загрузка (до 5 SMS).',
'LOAD block size must be 1..240': 'Размер блока LOAD должен быть в диапазоне 1..240',
'LOAD blocks': 'Блоки LOAD',
'clamped from': 'ограничено с',
'auto-fit': 'авто',
'Redirect to external server': 'Перенаправление на внешний сервер',
'Pass-through (card destination)': 'Проброс (адрес карты)',
'Redirect: every BIP channel the card opens is connected to this Host:Port (the external HTTP OTA platform); TLS is terminated there, and the address the card requests is only logged.': 'Перенаправление: каждый открываемый картой BIP-канал подключается к этому Host:Port (внешняя платформа HTTP OTA); TLS завершается там, а запрошенный картой адрес только журналируется.',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v239';
const CACHE = 'simple-v240';
const URLS = [
'index.html',
'help.html',
+42 -1
View File
@@ -28,9 +28,14 @@ function extractFunc(src, name) {
const FNS = ['hexToBytes', 'bytesToHex', 'des3Keys', 'des3EncryptBlock', 'des3CbcEncrypt',
'xorBytes', 'zeroPad', 'cbcMac', 'aesCbcEncrypt', 'aesShiftLeft1', 'aesCmacSubkeys',
'aesCmac', 'genSp', 'spNextCntr'];
'aesCmac', '_genSpBuild', 'genSp', 'spNextCntr', 'scp80SegmentInfo', 'spSizeInfoText',
'spShowSizeInfo'];
let code = '';
for (const f of FNS) code += extractFunc(html, f) + '\n';
for (const c of ['SCP80_MAX_SMS', 'SCP80_SINGLE_BYTES', 'SCP80_FIRST_BYTES', 'SCP80_NEXT_BYTES']) {
code += html.match(new RegExp('const ' + c + ' = \\d+;'))[0].replace('const ', 'var ') + '\n';
}
code += 'function t(s){return s;}\n';
// All test vectors are computed with the synthetic dummy key material below
// (no live/sample card keys, no ICCIDs). They are cross-checked byte-for-byte
@@ -229,3 +234,39 @@ test('spNextCntr tolerates lower case and separators', () => {
assert.strictEqual(spNextCntr('00000000 0a'), '000000000B');
assert.strictEqual(spNextCntr(''), '0000000001');
});
test('scp80SegmentInfo mirrors the server segmentation rules', () => {
assert.deepStrictEqual(scp80SegmentInfo(''), { bytes: 0, segments: 0 });
assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(137)), { bytes: 137, segments: 1 });
assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(138)), { bytes: 138, segments: 2 });
assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(132 + 134)), { bytes: 266, segments: 2 });
assert.deepStrictEqual(scp80SegmentInfo('AA'.repeat(132 + 134 * 2)), { bytes: 400, segments: 3 });
});
test('spSizeInfoText reports size, SMS count and the card buffer limit', () => {
assert.strictEqual(spSizeInfoText('AA'.repeat(18)), '18 bytes · 1 SMS');
assert.strictEqual(spSizeInfoText('AA'.repeat(266)), '266 bytes · 2 SMS (concatenated)');
assert.match(spSizeInfoText('AA'.repeat(132 + 134 * 5)),
/too large for the card concatenation buffer \(5 SMS\)/);
assert.strictEqual(spSizeInfoText(''), '');
});
test('the SCP80 UI constants match the server segmentation constants', () => {
const py = fs.readFileSync(
path.join(__dirname, '..', '..', 'pysim_simple_server', 'server.py'), 'utf8');
const read = (name) => {
const m = new RegExp('^' + name + '\\s*=\\s*(\\d+)', 'm').exec(py);
assert.ok(m, name + ' not found in server.py');
return parseInt(m[1], 10);
};
assert.strictEqual(SCP80_SINGLE_BYTES, read('SCP80_SINGLE_BYTES'));
assert.strictEqual(SCP80_FIRST_BYTES, read('SCP80_FIRST_BYTES'));
assert.strictEqual(SCP80_NEXT_BYTES, read('SCP80_NEXT_BYTES'));
assert.strictEqual(SCP80_MAX_SMS, read('MAX_ENVELOPE_SEGMENTS'));
});
test('a generated single-SMS packet shows its size and SMS count', () => {
const pkt = makeRun({});
const info = elements['sp-size-info'] || {};
assert.strictEqual(info.textContent, (pkt.length / 2) + ' bytes · 1 SMS');
});