EF.SST: tri-state editor (deleted / allocated-off / on)

Give EF.SST its own per-file editor for the 2-bit SIM service table
(TS 51.011 10.3.7: allocated + activated per service):

- efEncSst encodes the pair layout, preserving unmodelled pairs and the
  file length; validates service number, fit and state.
- A new `bits2` field kind renders a three-state select per service and
  collects {n, name, allocated, activated}; registered as EF_SST_FIELDS
  with the encoder (df '7F20'), and SST leaves EF_READONLY_NAMES.
- i18n: Deleted/Off/On + the state-error and hint strings (EN/RU).

Docs/help updated: SST is no longer read-only.  Frontend 1093 + Python
1090 tests green.  (Version/sw bump still deferred for the unreleased
decoded-editing batch.)
This commit is contained in:
catarrh committed 2026-10-25 21:43:07 +03:00
1 parent 5ebcd0773d
commit fc27feba8e
6 files changed
+296 -30

No files matched your search

+115 -5
View File
@@ -23988,7 +23988,7 @@ const EF_DECODERS = [
{ name: 'EF.IMSI', fid: '6f07', fn: efDecImsi },
{ name: 'EF.IST', fid: '6f07', fn: b => efDecServiceTable(b, EF_IST_SERVICES) },
{ name: 'EF.UST', fid: '6f38', fn: b => efDecServiceTable(b, EF_UST_SERVICES) },
{ name: 'EF.SST', fid: '6f38', fn: efDecSst },
{ name: 'EF.SST', df: '7F20', fid: '6f38', fn: efDecSst },
{ name: 'EF.EST', fid: '6f56', fn: b => efDecServiceTable(b, EF_EST_SERVICES) },
{ name: 'EF.HPPLMN', fid: '6f31', fn: efDecHpplmn },
{ name: 'EF.LI', fid: '6f05', fn: efDecLi },
@@ -24467,6 +24467,51 @@ function efEncServiceTable(values, origHex, table) {
return efHex(out);
}
// EF.SST (TS 51.011 10.3.7, the SIM service table): two bits per service,
// four services per byte - the first bit of the pair (b1/b3/b5/b7) is
// allocated, the second (b2/b4/b6/b8) activated, so a pair is 00 deleted,
// 10 allocated but not activated or 11 allocated and activated. The original
// bytes are the base: both pair bits of every modelled service are cleared
// and the listed ones set again, so a pair of a service the 59-entry table
// does not name (the decoder reports it as RFU) keeps its original bits and
// the file length survives an edit. An entry is the decoder's own shape
// {n, name, allocated, activated} (the collector produces it), so
// enc(decode(orig), orig) reproduces the file of a spec-canonical card;
// activated without allocated is not a legal pair (10.3.7) and is refused.
function efEncSst(values, origHex) {
const orig = efBytes(origHex || '');
const out = new Uint8Array(orig.length);
out.set(orig, 0);
for (const key of Object.keys(EF_SST_SERVICES)) {
const n = Number(key);
if (n < 1 || (n - 1) >= out.length * 4) continue;
out[(n - 1) >> 2] &= ~(3 << (2 * ((n - 1) & 3)));
}
for (const item of (values && values.services) || []) {
const n = Number(item && typeof item === 'object' ? item.n : item);
// the table names services 1-59; a pair beyond it belongs to a longer
// file (the decoder reports those as RFU too), so only a number that
// cannot be a service at all is unknown
if (!Number.isInteger(n) || n < 1) {
throw efEncError('Unknown service', ': ' + (item && item.n));
}
if ((n - 1) >= out.length * 4) {
throw efEncError('The service table is too short for service', ' ' + n);
}
if (!item || typeof item !== 'object') {
throw efEncError('The service state must be deleted, off or on', ': ' + n);
}
const allocated = !!item.allocated;
const activated = !!item.activated;
if (activated && !allocated) {
throw efEncError('The service state must be deleted, off or on', ': ' + n);
}
const pair = activated ? 3 : (allocated ? 1 : 0);
out[(n - 1) >> 2] |= pair << (2 * ((n - 1) & 3));
}
return efHex(out);
}
// EF.ACC (TS 31.102 4.2.15): class c is bit c of the 16-bit value (class 15 is
// the MSB); ACC 10 is not used and is written as 0.
function efEncAcc(values, origHex) {
@@ -25118,6 +25163,13 @@ const EF_ACC_CLASSES = {
const EF_UST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_UST_SERVICES }];
const EF_IST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_IST_SERVICES }];
const EF_EST_FIELDS = [{ key: 'services', label: 'Services', kind: 'bits', items: EF_EST_SERVICES }];
// EF.SST is the SIM table with two bits per service: the tri-state grid
// (deleted 00 / off 10 / on 11) instead of the 1-bit checkbox grid.
const EF_SST_FIELDS = [{
key: 'services', label: 'Services', kind: 'bits2', items: EF_SST_SERVICES,
hint: 'Two bits per service (TS 51.011 10.3.7): deleted = not allocated (00), '
+ 'off = allocated but not activated (10), on = allocated and activated (11).',
}];
const EF_ACC_FIELDS = [{ key: 'access_classes', label: 'Access classes', kind: 'bits', items: EF_ACC_CLASSES }];
@@ -25186,6 +25238,7 @@ const EF_ENCODERS = [
confirm: 'Writing the IMSI changes the card identity and may break network registration. Continue?' },
{ name: 'EF.IST', fid: '6f07', enc: (v, o) => efEncServiceTable(v, o, EF_IST_SERVICES), fields: EF_IST_FIELDS },
{ name: 'EF.UST', fid: '6f38', enc: (v, o) => efEncServiceTable(v, o, EF_UST_SERVICES), fields: EF_UST_FIELDS },
{ name: 'EF.SST', df: '7F20', fid: '6f38', enc: efEncSst, fields: EF_SST_FIELDS },
{ name: 'EF.EST', fid: '6f56', enc: (v, o) => efEncServiceTable(v, o, EF_EST_SERVICES), fields: EF_EST_FIELDS },
{ name: 'EF.ACC', fid: '6f78', enc: efEncAcc, fields: EF_ACC_FIELDS },
{ name: 'EF.PHASE', fid: '6fae', enc: efEncPhase, model: efPhaseModel, fields: EF_PHASE_FIELDS },
@@ -25217,9 +25270,10 @@ const EF_ENCODERS = [
];
// Decoder-only names that share a FID with an editable file: the FID fallback
// must not pick the wrong layout (EF.SST is not EF.UST on 6F38 - its 2-bit
// SIM layout has no editor yet).
const EF_READONLY_NAMES = ['EF.SST'];
// must not pick the wrong layout. (The list is empty now - EF.SST got its
// own 2-bit editor on 6F38 - but the guard stays for a future decoder-only
// collision.)
const EF_READONLY_NAMES = [];
function efFindEncoder(name, fid, df) {
let n = (name || '').toUpperCase();
@@ -25500,6 +25554,31 @@ function efDecFieldHtml(f, value) {
'<span class="font-mono text-gray-500 dark:text-slate-400">' + n + '</span>' +
'<span class="break-all">' + esc(t(name)) + '</span></label>';
}).join('') + '</div></div>';
} else if (f.kind === 'bits2') {
// the tri-state grid (EF.SST): one select per service, the decoder's
// entries preselected (an absent service is a deleted pair)
const states = {};
for (const v of (value || [])) {
if (!v || typeof v !== 'object') continue;
states[String(v.n)] = v.activated && v.allocated ? 'on' : (v.allocated ? 'off' : 'deleted');
}
const nums = Object.keys(f.items || {}).map(Number).sort((a, b) => a - b);
const states3 = [['deleted', t('Deleted')], ['off', t('Off')], ['on', t('On')]];
input = '<div class="pysim-dec-bits border border-gray-200 dark:border-slate-700 rounded p-2">' +
'<input type="text" class="pysim-dec-bits-filter w-full mb-1 border border-gray-300 dark:border-slate-600 rounded px-1 bg-white dark:bg-slate-800 text-gray-800 dark:text-slate-200" placeholder="' +
esc(t('Filter services')) + '" oninput="pysimFsDecBits2Filter(this)">' +
'<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(17rem,1fr));max-height:12rem;overflow-y:auto">' +
nums.map(n => {
const name = String(f.items[n]);
const cur = states[String(n)] || 'deleted';
return '<label class="pysim-dec-bit2-row flex items-center gap-1" data-label="' + esc(n + ' ' + name) + '">' +
'<span class="font-mono text-gray-500 dark:text-slate-400">' + n + '</span>' +
'<span class="break-all flex-1">' + esc(t(name)) + '</span>' +
'<select data-bit2="' + n + '" data-bit2-name="' + esc(name) + '" onchange="pysimFsDecRowCheck(this)" class="border border-gray-300 dark:border-slate-600 rounded px-1 bg-white dark:bg-slate-800 text-gray-800 dark:text-slate-200">' +
states3.map(o => '<option value="' + o[0] + '"' + (o[0] === cur ? ' selected' : '') + '>' +
esc(o[1]) + '</option>').join('') +
'</select></label>';
}).join('') + '</div></div>';
} else if (f.kind === 'select') {
const cur = String(value === undefined || value === null ? '' : value);
const opts = (f.options || []).slice();
@@ -25518,7 +25597,7 @@ function efDecFieldHtml(f, value) {
' oninput="pysimFsDecRowCheck(this)" class="border border-gray-300 dark:border-slate-600 rounded px-1 bg-white dark:bg-slate-800 text-gray-800 dark:text-slate-200">';
}
const body = '<span class="text-gray-500 dark:text-slate-400">' + esc(t(f.label)) + '</span>' + input;
const wide = f.kind === 'techs' || f.kind === 'bits';
const wide = f.kind === 'techs' || f.kind === 'bits' || f.kind === 'bits2';
if (!f.hint) {
return wide
? '<div class="pysim-dec-item flex flex-wrap items-center gap-x-2 gap-y-1">' + body + '</div>'
@@ -25634,6 +25713,20 @@ function efDecCollectValues(root, fields) {
name: cb.getAttribute('data-bit-name') || '' });
});
out[f.key] = items;
} else if (f.kind === 'bits2') {
// the tri-state grid: every select is collected, a deleted pair as
// {allocated: false, activated: false} (the encoder clears it anyway)
const items = [];
root.querySelectorAll('[data-bit2]').forEach(sel => {
const state = String(sel.value || '');
items.push({
n: parseInt(sel.getAttribute('data-bit2'), 10),
name: sel.getAttribute('data-bit2-name') || '',
allocated: state === 'off' || state === 'on',
activated: state === 'on',
});
});
out[f.key] = items;
} else {
const el = root.querySelector('[data-key="' + f.key + '"]');
if (!el) { out[f.key] = f.kind === 'bool' ? false : ''; continue; }
@@ -25690,6 +25783,19 @@ function pysimFsDecBitsFilter(input) {
});
}
// The tri-state grid's text filter (EF.SST): hide the rows whose service name
// does not match. The query is matched against the row's label only - a
// select's textContent would carry every one of its three state options.
function pysimFsDecBits2Filter(input) {
const box = input && input.closest ? input.closest('.pysim-dec-bits') : null;
if (!box) return;
const q = String(input.value || '').toLowerCase();
box.querySelectorAll('.pysim-dec-bit2-row').forEach(row => {
const label = String(row.getAttribute('data-label') || '').toLowerCase();
row.style.display = label.indexOf(q) === -1 ? 'none' : '';
});
}
function pysimFsDecAddRow() {
if (!pysimFsSelected) return;
const node = pysimFsFindNodeByPath(pysimFsSelected);
@@ -26424,6 +26530,10 @@ const LANG_RU = {
'The number continues in EF.EXT5 - editing it is not supported': 'Номер продолжается в EF.EXT5 — редактирование не поддерживается',
'Services': 'Сервисы',
'Filter services': 'Фильтр сервисов',
'Off': 'Выключен',
'On': 'Включён',
'The service state must be deleted, off or on': 'Состояние сервиса должно быть: удалён, выключен или включён',
'Two bits per service (TS 51.011 10.3.7): deleted = not allocated (00), off = allocated but not activated (10), on = allocated and activated (11).': 'Два бита на сервис (TS 51.011 10.3.7): удалён — не выделен (00), выключен — выделен, но не активирован (10), включён — выделен и активирован (11).',
'Access classes': 'Классы доступа',
'Phase': 'Фаза',
'Interval': 'Интервал',