v1.5.0: PLI data dictionary, event send, rejection cause dropdown, clipboard import fix

This commit is contained in:
2026-08-10 19:44:25 +03:00
parent f6d36aaf99
commit ef754befab
2 changed files with 799 additions and 16 deletions
+669 -16
View File
@@ -16,7 +16,7 @@
<div class="max-w-7xl mx-auto px-6 py-4">
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v1.4.3</span></h1>
<h1 class="text-2xl font-bold text-heading">OTAMan <span id="slogan" class="text-sm font-normal text-gray-500 dark:text-slate-400 ml-2" data-l10n="SIM OTA with a Human Face">SIM OTA with a Human Face</span> <span class="text-xs text-gray-400 dark:text-slate-500 ml-1">v1.5.0</span></h1>
<div class="flex items-center gap-4">
<button id="install-btn" class="px-2 py-1 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-200 dark:hover:bg-slate-700" style="display:none">INSTALL PWA [for offline use]</button>
<a href="https://github.com/anttro/otaman" target="_blank" class="text-xs text-gray-400 hover:text-gray-600 dark:text-slate-500 dark:hover:text-slate-300">github</a>
@@ -926,11 +926,37 @@
<textarea id="pysim-apdu-output" rows="5" readonly class="w-full font-mono border border-gray-300 dark:border-slate-600 text-sm rounded px-3 py-2.5 bg-gray-100 dark:bg-slate-800"></textarea>
</div>
<div id="pysim-sub-proactive" class="hidden">
<div class="mb-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Events that the card monitors (SET UP EVENT LIST):">Events that the card monitors (SET UP EVENT LIST):</div>
<div id="pysim-event-list" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
<div class="mt-3 mb-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Fetched proactive commands:">Fetched proactive commands:</div>
<div id="pysim-proactive-log" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
<div class="flex gap-4">
<div>
<div class="mb-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Events that the card monitors (SET UP EVENT LIST):">Events that the card monitors (SET UP EVENT LIST):</div>
<div id="pysim-event-list" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
<div class="mt-3 mb-2 text-xs text-gray-500 dark:text-slate-400" data-l10n="Fetched proactive commands:">Fetched proactive commands:</div>
<div id="pysim-proactive-log" class="text-xs font-mono text-gray-600 dark:text-slate-400"></div>
</div>
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-xs text-gray-500 dark:text-slate-400" data-l10n="PROVIDE LOCAL INFORMATION response data">PROVIDE LOCAL INFORMATION response data</span>
<button id="pli-save-btn" onclick="pysimPliSave()" class="px-2 py-0.5 text-xs rounded bg-emerald-600 text-white hover:bg-emerald-700" data-l10n="Save">Save</button>
</div>
<div id="pysim-pli-dict" class="text-xs font-mono text-gray-600 dark:text-slate-400 max-h-[60vh] overflow-auto">
<span class="text-gray-400" data-l10n="Loading...">Loading...</span>
</div>
</div>
</div>
</div>
<div id="event-send-modal" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
<div class="bg-white dark:bg-slate-800 rounded-lg p-6 max-w-sm w-full mx-4 max-h-[85vh] overflow-auto">
<h3 id="event-send-title" class="text-lg font-semibold mb-4 text-gray-800 dark:text-slate-200"></h3>
<div id="event-send-body" class="space-y-3"></div>
<div id="event-send-error" class="text-xs text-red-500 hidden mt-2"></div>
<div id="event-send-result" class="text-xs text-emerald-600 dark:text-emerald-400 hidden mt-2"></div>
<div class="flex gap-2 mt-4 justify-end">
<button onclick="document.getElementById('event-send-modal').classList.add('hidden')" class="px-3 py-1.5 text-xs rounded border border-gray-300 dark:border-slate-600 hover:bg-gray-100 dark:hover:bg-slate-700 text-gray-700 dark:text-slate-300" data-l10n="Cancel">Cancel</button>
<button id="event-send-btn" onclick="pysimEventSend()" class="px-4 py-1.5 text-xs rounded bg-blue-600 text-white hover:bg-blue-700" data-l10n="Send">Send</button>
</div>
</div>
</div>
</div>
<div id="stk-menu-panel" class="fixed inset-0 bg-black/50 z-50 flex items-center justify-center hidden">
@@ -3017,10 +3043,10 @@ function cardsExport() {
el.select();
}
function cardsImport() {
const el = document.getElementById('cards-io');
async function cardsImport() {
try {
const imported = JSON.parse(el.value);
const text = await navigator.clipboard.readText();
const imported = JSON.parse(text);
if (!Array.isArray(imported)) throw new Error('Expected an array');
let added = 0;
for (const entry of imported) {
@@ -3042,6 +3068,7 @@ function cardsImport() {
}
cardsSave();
cardsRender();
const el = document.getElementById('cards-io');
el.value = 'Imported ' + added + ' cards.';
setTimeout(() => { el.classList.add('hidden'); }, 2000);
} catch (e) {
@@ -3067,7 +3094,7 @@ function pysimSwitchSubtab(name) {
document.getElementById('pysim-sub-' + s).classList.toggle('hidden', s !== name);
});
if (name === 'custom') pysimCustomRender();
if (name === 'proactive') { pysimEventsRender(); pysimProactiveLogRender(); }
if (name === 'proactive') { pysimEventsRender(); pysimProactiveLogRender(); pysimPliRender(); }
}
function pysimCmdKeydown(e) {
@@ -3555,6 +3582,165 @@ const EVENT_NAMES = {
0x20: 'EARFCN Update', 0x21: 'SCEF Channel Status',
};
const REJECTION_CAUSES = [
{v:2,l:'02 \u2014 IMSI unknown in HLR/HSS'},{v:3,l:'03 \u2014 Illegal UE / MS'},
{v:5,l:'05 \u2014 IMEI / PEI not accepted'},{v:6,l:'06 \u2014 Illegal ME'},
{v:7,l:'07 \u2014 Services not allowed'},{v:8,l:'08 \u2014 Services + non-services not allowed'},
{v:9,l:'09 \u2014 UE identity cannot be derived'},{v:10,l:'0A \u2014 Implicitly detached / de-registered'},
{v:11,l:'0B \u2014 PLMN not allowed'},{v:12,l:'0C \u2014 Location / Tracking Area not allowed'},
{v:13,l:'0D \u2014 Roaming not allowed in this area'},{v:14,l:'0E \u2014 Services not allowed in this PLMN'},
{v:15,l:'0F \u2014 No Suitable Cells'},{v:16,l:'10 \u2014 MSC temporarily not reachable'},
{v:17,l:'11 \u2014 Network failure'},
{v:18,l:'12 \u2014 CS domain not avail. (EMM) / MAC fail (GMM)'},
{v:19,l:'13 \u2014 ESM failure (EMM) / Synch failure (GMM)'},
{v:20,l:'14 \u2014 MAC failure (EMM) / Congestion (GMM)'},
{v:21,l:'15 \u2014 Synch failure (EMM)'},{v:22,l:'16 \u2014 Congestion / PLMN rejected perm. (LU)'},
{v:23,l:'17 \u2014 UE security capabilities mismatch'},{v:24,l:'18 \u2014 Security mode rejected, unspecified'},
{v:25,l:'19 \u2014 Not authorized for this CSG'},{v:26,l:'1A \u2014 Non-3GPP/5G auth unacceptable'},
{v:27,l:'1B \u2014 N1 mode not allowed (5GMM)'},{v:28,l:'1C \u2014 Restricted service area (5GMM)'},
{v:31,l:'1F \u2014 Redirection to EPC required (5GMM)'},
{v:32,l:'20 \u2014 Service option not supported (LU)'},
{v:33,l:'21 \u2014 Requested service option not subscribed (LU)'},
{v:34,l:'22 \u2014 Service option temporarily out of order (LU)'},
{v:35,l:'23 \u2014 Requested service option not authorized (EMM)'},
{v:38,l:'26 \u2014 Call cannot be identified (LU)'},
{v:39,l:'27 \u2014 CS service temporarily not available (EMM)'},
{v:40,l:'28 \u2014 No EPS bearer context activated (EMM)'},
{v:42,l:'2A \u2014 Severe network failure (EMM)'},{v:43,l:'2B \u2014 LADN not available (5GMM)'},
{v:48,l:'30 \u2014 Retry upon entry into new cell (LU)'},
{v:62,l:'3E \u2014 No network slices available (5GMM)'},
{v:65,l:'41 \u2014 Maximum PDU sessions reached (5GMM)'},
{v:67,l:'43 \u2014 Insufficient resources for slice/DNN (5GMM)'},
{v:69,l:'45 \u2014 Insufficient resources for slice (5GMM)'},
{v:71,l:'47 \u2014 ngKSI already in use (5GMM)'},
{v:72,l:'48 \u2014 Non-3GPP access to 5GCN not allowed (5GMM)'},
{v:73,l:'49 \u2014 Serving network not authorized (5GMM)'},
{v:74,l:'4A \u2014 Temporarily not authorized for SNPN (5GMM)'},
{v:75,l:'4B \u2014 Permanently not authorized for SNPN (5GMM)'},
{v:76,l:'4C \u2014 Not authorized for this CAG (5GMM)'},
{v:90,l:'5A \u2014 Payload was not forwarded (5GMM)'},
{v:91,l:'5B \u2014 DNN not supported/subscribed in slice (5GMM)'},
{v:92,l:'5C \u2014 Insufficient UP resources for PDU session (5GMM)'},
{v:95,l:'5F \u2014 Semantically incorrect message'},{v:96,l:'60 \u2014 Invalid mandatory information'},
{v:97,l:'61 \u2014 Message type non-existent/not implemented'},
{v:98,l:'62 \u2014 Message type not compatible with protocol state'},
{v:99,l:'63 \u2014 IE non-existent/not implemented'},
{v:100,l:'64 \u2014 Conditional IE error'},{v:101,l:'65 \u2014 Message not compatible with protocol state'},
{v:111,l:'6F \u2014 Protocol error, unspecified'},
];
const EVENT_FORMS = {
0x00: { title: 'MT call', note: 'not_yet' },
0x01: { title: 'Call connected', note: 'not_yet' },
0x02: { title: 'Call disconnected', note: 'not_yet' },
0x03: { title: 'Location status',
fields: [
{ id: 'status', label: 'Service Status', type: 'select',
opts: [{v:0,l:'Normal service'},{v:1,l:'Limited service'},{v:2,l:'No service'}] }
],
build(v) { return '9B01' + (v.status|0).toString(16).padStart(2,'0'); }
},
0x04: null,
0x05: null,
0x06: { title: 'Card reader status',
fields: [
{ id: 'status', label: 'Status', type: 'select',
opts: [{v:0,l:'Card reader removed'},{v:1,l:'Card reader inserted'}] }
],
build(v) { return '' + (v.status|0).toString(16).padStart(2,'0'); }
},
0x07: { title: 'Language selection',
fields: [
{ id: 'lang', label: 'Language code', type: 'text', placeholder: 'ISO 639, e.g. en, ru', pattern: '[a-zA-Z]{2,3}' }
],
build(v) { const b = new TextEncoder().encode(v.lang||'en'); return b.length.toString(16).padStart(2,'0') + bytesToHex(Array.from(b)); }
},
0x08: { title: 'Browser termination', note: 'not_yet' },
0x09: null,
0x0A: { title: 'Channel status', note: 'not_yet' },
0x0B: { title: 'Access Technology Change',
fields: [
{ id: 'tech', label: 'Access Technology', type: 'select',
opts: [{v:0,l:'GSM'},{v:1,l:'GSM Compact'},{v:3,l:'UTRAN'},{v:4,l:'GSM w/EGPRS'},{v:5,l:'UTRAN w/HSDPA'},{v:6,l:'UTRAN w/HSUPA'},{v:7,l:'UTRAN w/HSPA+'},{v:8,l:'E-UTRAN'},{v:9,l:'E-UTRAN (4G)'},{v:10,l:'5G NR'},{v:11,l:'5G NR (NSA)'},{v:12,l:'Satellite'}] }
],
build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); }
},
0x0C: null,
0x0D: null,
0x0E: null,
0x0F: null,
0x10: null,
0x11: { title: 'I-WLAN Access Status', note: 'not_yet' },
0x12: { title: 'Network Rejection',
fields: [
{ id: 'reg_type', label: 'Registration type', type: 'select', onchange: 'updateNetRejectionFields()',
opts: [
{v:0x00,l:'Normal Location Updating',g:'lu'},{v:0x01,l:'Periodic Location Updating',g:'lu'},
{v:0x02,l:'IMSI Attach',g:'lu'},{v:0x03,l:'GPRS Attach',g:'gprs'},
{v:0x04,l:'Combined GPRS/IMSI Attach',g:'gprs'},{v:0x05,l:'RA Updating',g:'gprs'},
{v:0x06,l:'Combined RA/LA Updating',g:'gprs'},{v:0x07,l:'Combined RA/LA Updating w/ IMSI Attach',g:'gprs'},
{v:0x08,l:'Periodic RA Updating',g:'gprs'},{v:0x09,l:'EPS Attach (EMM)',g:'eps'},
{v:0x0A,l:'Combined EPS/IMSI Attach (EMM)',g:'eps'},{v:0x0B,l:'TA Updating (TAU)',g:'eps'},
{v:0x0C,l:'Combined TA/LA Updating (TAU)',g:'eps'},{v:0x0D,l:'Combined TA/LA Updating w/ IMSI Attach (TAU)',g:'eps'},
{v:0x0E,l:'Periodic TA Updating (TAU)',g:'eps'},{v:0x0F,l:'Initial Registration (5GS)',g:'eps'},
{v:0x10,l:'Mobility Registration Updating (5GS)',g:'eps'},{v:0x11,l:'Periodic Registration Updating (5GS)',g:'eps'},
]},
{ id: 'mcc', label: 'MCC', type: 'text', pattern: '\\d{3}', placeholder: 'e.g. 250', g: 'lu,gprs,eps' },
{ id: 'mnc', label: 'MNC', type: 'text', pattern: '\\d{2,3}', placeholder: 'e.g. 01', g: 'lu,gprs,eps' },
{ id: 'lac', label: 'LAC', type: 'text', pattern: '[0-9A-Fa-f]{4}', placeholder: 'hex, e.g. 00FF', g: 'lu,gprs' },
{ id: 'rac', label: 'RAC', type: 'text', pattern: '[0-9A-Fa-f]{2}', placeholder: 'hex, e.g. 01', g: 'gprs' },
{ id: 'tac', label: 'TAC', type: 'text', pattern: '[0-9A-Fa-f]{4,6}', placeholder: '4 (E-UTRAN) or 6 (NR) hex chars', g: 'eps' },
{ id: 'access_tech', label: 'Access Technology', type: 'select',
opts: [{v:0x00,l:'GSM'},{v:0x03,l:'UTRAN'},{v:0x08,l:'E-UTRAN'},{v:0x0A,l:'NG-RAN'},{v:0x0B,l:'Satellite NG-RAN'},{v:0x0C,l:'Satellite E-UTRAN'}], g: 'lu,gprs,eps' },
{ id: 'cause', label: 'Rejection cause code', type: 'select', opts: REJECTION_CAUSES, g: 'lu,gprs,eps' },
{ id: 'ext_cause', label: 'Extended EMM cause (optional)', type: 'number', min: '0', max: '255', placeholder: '0255', g: 'eps' },
],
build(v) {
const rt = parseInt(v.reg_type) || 0;
const g = rt <= 0x02 ? 'lu' : rt <= 0x08 ? 'gprs' : 'eps';
let d = 'F401' + rt.toString(16).padStart(2,'0');
d += 'BF01' + (parseInt(v.access_tech)|0).toString(16).padStart(2,'0');
d += 'F501' + (parseInt(v.cause) || 2).toString(16).padStart(2,'0');
const pm = encPlmn(v.mcc||'250',v.mnc||'01');
if (g === 'lu') {
d += '9305' + pm + ((v.lac||'0000').toUpperCase());
} else if (g === 'gprs') {
d += 'F306' + pm + ((v.lac||'0000').toUpperCase()) + ((v.rac||'01').toUpperCase());
} else {
const tc = (v.tac||'0001').toUpperCase();
d += '7D0' + (3 + tc.length/2).toString(16) + pm + tc;
if (v.ext_cause !== '' && v.ext_cause !== undefined) {
d += '5701' + (parseInt(v.ext_cause)|0).toString(16).padStart(2,'0');
}
}
return d.toUpperCase();
}
},
0x13: null,
0x14: { title: 'Change of UICC Access',
fields: [{ id: 'tech', label: 'Access Technology', type: 'select',
opts: [{v:0,l:'GSM'},{v:3,l:'UTRAN'},{v:8,l:'E-UTRAN'},{v:10,l:'NG-RAN'}] }],
build(v) { return 'BF01' + (v.tech|0).toString(16).padStart(2,'0'); }
},
0x15: { title: 'CSG Cell Change', note: 'not_yet' },
0x16: null,
0x17: null,
};
const PLI_QUALIFIERS = [
{code:'00',name:'Location Info (MCC, MNC, LAC/TAC, Cell ID)'},{code:'01',name:'IMEI'},
{code:'02',name:'Network Measurement results'},{code:'03',name:'Date, time and time zone'},
{code:'04',name:'Language setting'},{code:'05',name:'Timing Advance'},
{code:'06',name:'Access Technology (single)'},{code:'08',name:'IMEISV'},
{code:'09',name:'Search Mode'},{code:'0A',name:'Battery charge state'},
{code:'0C',name:'Current WSID'},{code:'0D',name:'Broadcast Network info'},
{code:'0E',name:'Multiple Access Technologies'},{code:'0F',name:'Location Info (multi-RAT)'},
{code:'10',name:'NMR (multi-RAT)'},{code:'11',name:'CSG ID list + HNB name'},
{code:'12',name:'H(e)NB IP address'},{code:'13',name:'H(e)NB surrounding macrocells'},
{code:'14',name:'Current WLAN identifier'},{code:'15',name:'Slices information'},
{code:'16',name:'CAG information list'},{code:'17',name:'Rejected slices information'},
];
async function pysimEventsRender() {
const el = document.getElementById('pysim-event-list');
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
@@ -3567,7 +3753,10 @@ async function pysimEventsRender() {
let html = '';
events.forEach(e => {
const name = EVENT_NAMES[e] || ('Event 0x' + e.toString(16).padStart(2, '0').toUpperCase());
html += '<div class="flex items-center gap-2 py-0.5"><span class="w-6 text-right text-gray-400">' + e.toString(16).padStart(2, '0').toUpperCase() + '</span> ' + esc(name) + '</div>';
html += '<div class="flex items-center gap-2 py-0.5"><span class="w-6 text-right text-gray-400">'
+ e.toString(16).padStart(2, '0').toUpperCase()
+ '</span> ' + esc(name)
+ '<button onclick="pysimEventSendForm(' + e + ')" class="ml-auto px-1.5 py-0.5 text-[10px] rounded bg-blue-600 text-white hover:bg-blue-700">' + t('Send') + '</button></div>';
});
el.innerHTML = html;
} catch (e) {
@@ -3575,6 +3764,129 @@ async function pysimEventsRender() {
}
}
let _eventFormType = null;
function pysimEventSendForm(eventType) {
const modal = document.getElementById('event-send-modal');
const title = document.getElementById('event-send-title');
const body = document.getElementById('event-send-body');
const err = document.getElementById('event-send-error');
const result = document.getElementById('event-send-result');
const btn = document.getElementById('event-send-btn');
err.classList.add('hidden');
result.classList.add('hidden');
err.textContent = '';
result.textContent = '';
const name = EVENT_NAMES[eventType] || ('0x' + eventType.toString(16).padStart(2,'0'));
const cfg = EVENT_FORMS[eventType];
_eventFormType = eventType;
if (!cfg) {
title.textContent = name + ' (' + t('Send') + ')';
body.innerHTML = '<p class="text-sm text-gray-600 dark:text-slate-400">' + t('Send') + ' <span class="font-mono">' + esc(name) + '</span> ' + t('event notification to the card?') + '</p>';
btn.textContent = t('Send');
modal.classList.remove('hidden');
return;
}
if (cfg.note === 'not_yet') {
title.textContent = name;
body.innerHTML = '<p class="text-sm text-gray-500 dark:text-slate-400">' + t('This event type is not yet supported.') + '</p>';
btn.classList.add('hidden');
} else {
title.textContent = name + ' (' + t('Send') + ')';
let html = '';
cfg.fields.forEach(f => {
const rg = f.g ? f.g.split(',') : null;
html += '<div class="event-field" data-groups="' + (f.g||'') + '" data-id="' + f.id + '">';
html += '<label class="block mb-1 text-xs font-medium text-gray-700 dark:text-slate-300">' + t(f.label) + '</label>';
if (f.type === 'select') {
html += '<select id="ev-f-' + f.id + '"' + (f.onchange ? ' onchange="' + f.onchange + '"' : '') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800">';
f.opts.forEach(o => html += '<option value="' + o.v + '"' + (o.g ? ' data-gr="' + o.g + '"' : '') + '>' + esc(o.l) + '</option>');
html += '</select>';
} else {
const attrs = [];
if (f.pattern) attrs.push('pattern="' + f.pattern + '"');
if (f.placeholder) attrs.push('placeholder="' + f.placeholder + '"');
if (f.min !== undefined) attrs.push('min="' + f.min + '" max="' + f.max + '"');
html += '<input id="ev-f-' + f.id + '" type="' + f.type + '" ' + attrs.join(' ') + ' class="w-full border border-gray-300 dark:border-slate-600 text-sm rounded px-2 py-1.5 dark:bg-slate-800 font-mono">';
}
html += '</div>';
});
body.innerHTML = html;
btn.classList.remove('hidden');
if (eventType === 0x12) updateNetRejectionFields();
}
modal.classList.remove('hidden');
}
function updateNetRejectionFields() {
const sel = document.getElementById('ev-f-reg_type');
if (!sel) return;
const opt = sel.options[sel.selectedIndex];
const g = opt ? opt.getAttribute('data-gr') || 'lu' : 'lu';
document.querySelectorAll('#event-send-body .event-field').forEach(div => {
const groups = div.getAttribute('data-groups') || '';
const show = groups === '' || groups.indexOf(g) >= 0;
div.style.display = show ? '' : 'none';
});
}
async function pysimEventSend() {
const err = document.getElementById('event-send-error');
const result = document.getElementById('event-send-result');
const btn = document.getElementById('event-send-btn');
err.classList.add('hidden');
result.classList.add('hidden');
const cfg = EVENT_FORMS[_eventFormType];
let eventData = null;
if (cfg && cfg.build) {
const values = {};
let valid = true;
cfg.fields.forEach(f => {
const el = document.getElementById('ev-f-' + f.id);
if (!el || el.closest('.event-field').style.display === 'none') return;
const v = el.value.trim();
values[f.id] = v;
if (f.pattern && v && !new RegExp('^' + f.pattern + '$').test(v)) {
err.textContent = t('Invalid') + ': ' + t(f.label);
err.classList.remove('hidden');
valid = false;
}
});
if (!valid) return;
try {
eventData = cfg.build(values);
} catch (e) {
err.textContent = 'Error: ' + e.message;
err.classList.remove('hidden');
return;
}
}
btn.disabled = true;
btn.textContent = t('Sending...');
try {
const resp = await pysimFetch('/api/event-send', { event_type: _eventFormType, event_data: eventData });
if (resp.sw === '9000') {
result.textContent = (eventData ? t('Event data sent.') : t('Event sent.')) + ' SW: ' + resp.sw;
result.classList.remove('hidden');
} else {
result.textContent = 'SW: ' + resp.sw;
result.classList.remove('hidden');
result.classList.add('text-amber-600');
}
} catch (e2) {
err.textContent = 'Error: ' + e2.message;
err.classList.remove('hidden');
} finally {
btn.disabled = false;
btn.textContent = t('Send');
}
}
// ===== proactive command log =====
const CMD_NAMES = {
'03': 'POLL INTERVAL', '05': 'SET UP EVENT LIST',
@@ -3598,9 +3910,10 @@ async function pysimProactiveLogRender() {
log.forEach(e => {
const name = CMD_NAMES[e.type_hex] || ('Cmd 0x' + e.type_hex.toUpperCase());
html += '<div class="flex items-center gap-2 py-0.5">'
+ '<span class="w-10 text-right text-gray-400">0x' + e.type_hex.toUpperCase() + '</span>'
+ '<span class="w-40 truncate">' + esc(name) + '</span>'
+ '<span class="text-gray-400">— ' + e.elapsed + 's (' + e.bytes + ' bytes)</span>'
+ '<span class="w-12 text-right text-gray-400">' + e.elapsed + 's</span>'
+ '<span class="font-mono text-gray-400">0x' + e.type_hex.toUpperCase() + '</span>'
+ '<span class="truncate">' + esc(name) + '</span>'
+ '<span class="ml-auto text-gray-400">' + e.bytes + ' bytes</span>'
+ '</div>';
});
el.innerHTML = html;
@@ -3609,6 +3922,323 @@ async function pysimProactiveLogRender() {
}
}
// ===== PLI data dictionary =====
function decPlmn(hex3) {
const h = hex3.replace(/\s/g, '').slice(0, 6);
if (h.length < 6) return ['250', '01'];
const b1 = parseInt(h[0]+h[1], 16), b2 = parseInt(h[2]+h[3], 16), b3 = parseInt(h[4]+h[5], 16);
const mcc = '' + (b1&0xF) + ((b1>>4)&0xF) + (b2&0xF);
let mnc = '' + (b3&0xF);
if ((b2>>4) !== 0xF) mnc += ((b2>>4)&0xF);
return [mcc, mnc];
}
function decImei(hex8) {
const h = hex8.replace(/\s/g, '').slice(0, 16);
if (h.length < 16) return '';
let s = '';
for (let i = 0; i < 15; i += 2) {
const b = parseInt(h[i]+h[i+1], 16);
s += '' + (b&0xF) + ((b>>4)&0xF);
}
return s.slice(0, 15);
}
function encImeiPli(digits) {
const d = digits.replace(/\D/g, '').slice(0, 15);
if (d.length < 15) return '';
let h = '';
for (let i = 0; i < 15; i += 2)
h += (parseInt(d[i+1])<<4 | parseInt(d[i])).toString(16).padStart(2, '0');
return h;
}
function encImeisvPli(digits) {
const d = digits.replace(/\D/g, '').slice(0, 16);
if (d.length < 16) return '';
let h = '';
for (let i = 0; i < 16; i += 2)
h += (parseInt(d[i+1])<<4 | parseInt(d[i])).toString(16).padStart(2, '0');
return h;
}
const ACCESSTECH = [
{v:0,l:'GSM'},{v:1,l:'GSM Compact'},{v:2,l:'TIA/EIA-533'},{v:3,l:'UTRAN'},
{v:4,l:'TETRA'},{v:5,l:'TIA/EIA-95-B'},{v:6,l:'CDMA2000 1x'},{v:7,l:'CDMA2000 HRPD'},
{v:8,l:'E-UTRAN'},{v:9,l:'eHRPD'},{v:10,l:'NG-RAN'},{v:11,l:'Satellite NG-RAN'},{v:12,l:'Satellite E-UTRAN'},
];
const PLI_CODECS = {
'00': { tlv: '9305',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 10) return null;
const pl = decPlmn(v); return { mcc: pl[0], mnc: pl[1], lac: v.slice(6,10).toUpperCase() };
},
encode(v) { return '9305' + encPlmn(v.mcc||'250',v.mnc||'01') + ((v.lac||'0000').toUpperCase()); },
fields: [
{id:'mcc',label:'MCC',type:'text',pattern:'\\d{3}',placeholder:'e.g. 250'},
{id:'mnc',label:'MNC',type:'text',pattern:'\\d{2,3}',placeholder:'e.g. 01'},
{id:'lac',label:'LAC/TAC',type:'text',pattern:'[0-9A-Fa-f]{4}',placeholder:'hex'},
],
},
'01': { tlv: '9408',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 16) return null;
return { imei: decImei(v) };
},
encode(v) { const imei = encImeiPli(v.imei||''); return imei ? '9408'+imei : ''; },
fields: [
{id:'imei',label:'IMEI',type:'text',pattern:'\\d{15}',placeholder:'15 digits'},
],
},
'03': { tlv: '2607',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 14) return null;
const yr = parseInt(v[0]+v[1])<70 ? 2000+parseInt(v[0]+v[1]) : 1900+parseInt(v[0]+v[1]);
const mo = parseInt(v[2]+v[3]), dy = parseInt(v[4]+v[5]);
const hh = parseInt(v[6]+v[7]), mm = parseInt(v[8]+v[9]), ss = parseInt(v[10]+v[11]);
const tz = parseInt(v[12]+v[13], 16);
const tzSign = (tz & 0x80) ? '-' : '+';
const tzH = Math.floor(((tz & 0x3F) || 0) / 4);
const tzM = (((tz & 0x3F) || 0) % 4) * 15;
return {
dt: yr+'-'+(''+mo).padStart(2,'0')+'-'+(''+dy).padStart(2,'0'),
tm: (''+hh).padStart(2,'0')+':'+(''+mm).padStart(2,'0')+':'+(''+ss).padStart(2,'0'),
tz: tzSign+(''+tzH).padStart(2,'0')+':'+(''+tzM).padStart(2,'0'),
};
},
encode(v) {
const dt = (v.dt||'2000-01-01').split('-'), tm = (v.tm||'00:00:00').split(':');
let tzQ = 0;
const tzM = v.tz ? v.tz.match(/^([-+])(\d{2}):?(\d{2})$/) : null;
if (tzM) { const h = parseInt(tzM[2])||0, m = parseInt(tzM[3])||0; tzQ = h*4 + Math.floor(m/15); if (tzM[1]==='-') tzQ |= 0x80; }
return '2607'
+ (parseInt(dt[0])%100).toString(16).padStart(2,'0')
+ (parseInt(dt[1])||1).toString(16).padStart(2,'0')
+ (parseInt(dt[2])||1).toString(16).padStart(2,'0')
+ (parseInt(tm[0])||0).toString(16).padStart(2,'0')
+ (parseInt(tm[1])||0).toString(16).padStart(2,'0')
+ (parseInt(tm[2])||0).toString(16).padStart(2,'0')
+ tzQ.toString(16).padStart(2,'0');
},
fields: [
{id:'dt',label:'Date',type:'date'},{id:'tm',label:'Time',type:'time'},
{id:'tz',label:'TZ offset',type:'text',pattern:'[-+]\\d{2}:?\\d{2}',placeholder:'+00:00'},
],
},
'04': { tlv: 'AD02',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 4) return null;
return { lang: String.fromCharCode(parseInt(v[0]+v[1],16), parseInt(v[2]+v[3],16)) };
},
encode(v) {
const lang = (v.lang||'en').slice(0,2);
return 'AD02' + lang.charCodeAt(0).toString(16).padStart(2,'0') + lang.charCodeAt(1).toString(16).padStart(2,'0');
},
fields: [
{id:'lang',label:'Language',type:'text',pattern:'[a-zA-Z]{2,3}',placeholder:'en'},
],
},
'05': { tlv: 'C602',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 4) return null;
return { status: parseInt(v[0]+v[1],16).toString(), ta: parseInt(v[2]+v[3],16).toString() };
},
encode(v) { return 'C602' + (parseInt(v.status)||0).toString(16).padStart(2,'0') + (parseInt(v.ta)||0).toString(16).padStart(2,'0'); },
fields: [
{id:'status',label:'ME Status',type:'number',min:'0',max:'255',placeholder:'0'},
{id:'ta',label:'Timing Advance',type:'number',min:'0',max:'255',placeholder:'0'},
],
},
'06': { tlv: 'BF01',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
return { tech: parseInt(v[0]+v[1],16).toString() };
},
encode(v) { return 'BF01' + (parseInt(v.tech)||0).toString(16).padStart(2,'0'); },
fields: [{id:'tech',label:'Access Technology',type:'select',opts:ACCESSTECH}],
},
'08': { tlv: '9408',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 16) return null;
return { imeisv: decImei(v) + v.slice(14,16).toUpperCase() };
},
encode(v) {
const raw = (v.imeisv||'').replace(/\D/g, '').slice(0, 16);
if (raw.length < 16) return '';
let h = '';
for (let i = 0; i < 16; i += 2)
h += (parseInt(raw[i+1])<<4 | parseInt(raw[i])).toString(16).padStart(2,'0');
return '9408' + h;
},
fields: [{id:'imeisv',label:'IMEISV',type:'text',pattern:'\\d{16}',placeholder:'16 digits'}],
},
'09': { tlv: 'AD01',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
return { mode: parseInt(v[0]+v[1],16).toString() };
},
encode(v) {
return 'AD01' + (parseInt(v.mode)||0).toString(16).padStart(2,'0');
},
fields: [{id:'mode',label:'Search Mode',type:'select',
opts:[{v:0,l:'Automatic'},{v:1,l:'Manual'}]}],
},
'0A': { tlv: '6301',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
return { charge: parseInt(v[0]+v[1],16).toString() };
},
encode(v) { return '6301' + (parseInt(v.charge)||50).toString(16).padStart(2,'0'); },
fields: [{id:'charge',label:'Charge state (%)',type:'number',min:'0',max:'100',placeholder:'50'}],
},
'0E': { tlv: 'BF',
decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 2) return null;
const techs = []; for (let i = 0; i < v.length; i += 2) techs.push(parseInt(v[i]+v[i+1],16));
return { techs: techs.join(',') };
},
encode(v) {
const techs = (v.techs||'').split(',').filter(t => t !== '').map(t => parseInt(t)&0xFF);
if (!techs.length) return '';
const len = techs.length.toString(16).padStart(2,'0');
return 'BF' + len + techs.map(t => t.toString(16).padStart(2,'0')).join('');
},
fields: [
{id:'techs',label:'Access Technologies (comma-separated)',type:'text',
placeholder:'e.g. 3,8,10 (= UTRAN + E-UTRAN + NG-RAN)'},
],
},
};
async function pysimPliRender() {
const el = document.getElementById('pysim-pli-dict');
el.innerHTML = '<span class="text-gray-400">' + t('Loading...') + '</span>';
try {
const data = await pysimFetch('/api/pli-dict');
let html = '<div class="space-y-1">';
PLI_QUALIFIERS.forEach(q => {
const val = data[q.code] || '';
const hasCodec = !!PLI_CODECS[q.code];
html += '<div data-pli-row="' + q.code + '">'
+ '<div class="flex items-center gap-1">'
+ '<span class="w-6 text-right text-gray-400 text-[10px]">' + q.code + '</span>'
+ '<span class="w-32 truncate text-[10px]" title="' + esc(q.name) + '">' + esc(q.name) + '</span>'
+ '<input type="text" id="pli-v-' + q.code + '" value="' + esc(val) + '" placeholder="hex"'
+ ' class="w-28 border border-gray-300 dark:border-slate-600 text-[10px] rounded px-1 py-0.5 dark:bg-slate-800 font-mono">';
if (hasCodec) {
html += '<span onclick="pysimPliDecodeRow(\'' + q.code + '\')" id="pli-tog-' + q.code + '"'
+ ' class="cursor-pointer select-none text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">▶</span>';
} else {
html += '<span class="w-4"></span>';
}
html += '</div><div class="pli-decode-panel hidden mt-1 ml-8 pl-2 border-l border-gray-300 dark:border-slate-600" data-pli-panel="' + q.code + '"></div></div>';
});
html += '</div>';
el.innerHTML = html;
} catch (e) {
el.innerHTML = '<span class="text-red-500">Error: ' + esc(e.message) + '</span>';
}
}
function pysimPliDecodeRow(code) {
const panel = document.querySelector('[data-pli-panel="' + code + '"]');
const codec = PLI_CODECS[code];
if (!codec) return;
if (!panel.classList.contains('hidden')) {
panel.innerHTML = '';
panel.classList.add('hidden');
const tog = document.getElementById('pli-tog-' + code);
if (tog) tog.textContent = '▶';
return;
}
const input = document.getElementById('pli-v-' + code);
const hex = input.value.trim().replace(/\s/g, '');
let values = {};
if (hex) {
values = codec.decode(hex);
if (!values) {
panel.innerHTML = '<span class="text-[10px] text-red-500">Invalid hex</span>';
panel.classList.remove('hidden');
const tog = document.getElementById('pli-tog-' + code);
if (tog) tog.textContent = '▼';
return;
}
}
let html = '<div class="space-y-1 mb-1">';
codec.fields.forEach(f => {
html += '<div class="flex items-center gap-1">'
+ '<span class="text-[10px] w-12 text-gray-500 dark:text-slate-400 truncate" title="' + esc(f.label) + '">' + esc(f.label) + '</span>';
if (f.type === 'select') {
html += '<select id="pli-d-' + code + '-' + f.id + '" class="text-[10px] border border-gray-300 dark:border-slate-600 rounded px-1 py-0.5 dark:bg-slate-800 w-36">';
f.opts.forEach(o => html += '<option value="' + o.v + '"' + (String(values[f.id]) === String(o.v) ? ' selected' : '') + '>' + esc(o.l) + '</option>');
html += '</select>';
} else {
html += '<input id="pli-d-' + code + '-' + f.id + '" type="' + f.type + '"'
+ ' value="' + esc(String(values[f.id] || '')) + '"'
+ (f.pattern ? ' pattern="' + f.pattern + '"' : '')
+ (f.placeholder ? ' placeholder="' + f.placeholder + '"' : '')
+ (f.min !== undefined ? ' min="' + f.min + '" max="' + f.max + '"' : '')
+ ' class="text-[10px] border border-gray-300 dark:border-slate-600 rounded px-1 py-0.5 dark:bg-slate-800 w-32 font-mono">';
}
html += '</div>';
});
html += '</div>';
html += '<div class="flex gap-1">'
+ '<button onclick="pysimPliApplyRow(\'' + code + '\')" class="text-[10px] px-1.5 py-0.5 rounded bg-emerald-600 text-white hover:bg-emerald-700">Apply</button>'
+ '<button onclick="var p=document.querySelector(\'[data-pli-panel=\\\'' + code + '\\\']\');p.classList.add(\'hidden\');p.innerHTML=\'\';var t=document.getElementById(\'pli-tog-' + code + '\');if(t)t.textContent=\'▶\'" class="text-[10px] px-1.5 py-0.5 rounded border border-gray-300 dark:border-slate-600">Cancel</button>'
+ '</div>';
panel.innerHTML = html;
panel.classList.remove('hidden');
const tog = document.getElementById('pli-tog-' + code);
if (tog) tog.textContent = '▼';
}
function pysimPliApplyRow(code) {
const codec = PLI_CODECS[code];
if (!codec) return;
const values = {};
codec.fields.forEach(f => {
const el = document.getElementById('pli-d-' + code + '-' + f.id);
if (el) values[f.id] = el.value;
});
const hex = codec.encode(values);
if (!hex) return;
const input = document.getElementById('pli-v-' + code);
if (input) input.value = hex;
const panel = document.querySelector('[data-pli-panel="' + code + '"]');
if (panel) { panel.innerHTML = ''; panel.classList.add('hidden'); }
const tog = document.getElementById('pli-tog-' + code);
if (tog) tog.textContent = '▶';
}
async function pysimPliSave() {
const btn = document.getElementById('pli-save-btn');
btn.disabled = true;
btn.textContent = t('Saving...');
const data = {};
PLI_QUALIFIERS.forEach(q => {
const el = document.getElementById('pli-v-' + q.code);
if (el) {
const v = el.value.trim().replace(/\s/g, '');
if (v && !/^[0-9A-Fa-f]*$/.test(v)) return;
data[q.code] = v;
}
});
try {
await pysimFetch('/api/pli-dict', data);
btn.textContent = t('Saved');
btn.classList.add('bg-emerald-700');
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-emerald-700'); btn.disabled = false; }, 1500);
} catch (e) {
btn.textContent = 'Error';
btn.classList.add('bg-red-600');
setTimeout(() => { btn.textContent = t('Save'); btn.classList.remove('bg-red-600'); btn.disabled = false; }, 2000);
}
}
// ===== custom files =====
let pysimCustomFiles = [];
@@ -3672,10 +4302,10 @@ function pysimCustomExport() {
el.select();
}
function pysimCustomImport() {
const el = document.getElementById('pysim-cf-io');
async function pysimCustomImport() {
try {
const imported = JSON.parse(el.value);
const text = await navigator.clipboard.readText();
const imported = JSON.parse(text);
if (!Array.isArray(imported)) throw new Error('Expected an array');
let added = 0;
for (const entry of imported) {
@@ -3694,6 +4324,7 @@ function pysimCustomImport() {
}
pysimCustomSave();
pysimCustomRender();
const el = document.getElementById('pysim-cf-io');
el.value = 'Imported ' + added + ' files.';
setTimeout(() => { el.classList.add('hidden'); }, 2000);
} catch (e) {
@@ -3836,6 +4467,28 @@ const LANG_RU = {
'No events configured.': 'Нет настроенных событий.',
'Fetched proactive commands:': 'Извлечённые проактивные команды:',
'No commands logged yet.': 'Команды пока не регистрировались.',
'Send event notification to the card?': 'Отправить уведомление о событии на карту?',
'This event type is not yet supported.': 'Этот тип события пока не поддерживается.',
'Event sent.': 'Событие отправлено.',
'Event data sent.': 'Данные события отправлены.',
'Sending...': 'Отправка...',
'Invalid': 'Некорректное значение',
'Service Status': 'Статус сервиса',
'Normal service': 'Обычный сервис',
'Limited service': 'Ограниченный сервис',
'No service': 'Нет сервиса',
'Language code': 'Код языка',
'Registration type': 'Тип регистрации',
'Access Technology': 'Технология доступа',
'Rejection cause code': 'Код причины отклонения',
'Extended EMM cause (optional)': 'Расширенная EMM причина (опционально)',
'Status': 'Статус',
'Card reader removed': 'Картридер извлечён',
'Card reader inserted': 'Картридер вставлен',
'PROVIDE LOCAL INFORMATION response data': 'Данные для PROVIDE LOCAL INFORMATION',
'Saving...': 'Сохранение...',
'Saved': 'Сохранено',
'Save': 'Сохранить',
};
let currentLang = 'en';
+130
View File
@@ -590,6 +590,11 @@ video {
z-index: 50;
}
.mx-4 {
margin-left: 1rem;
margin-right: 1rem;
}
.mx-auto {
margin-left: auto;
margin-right: auto;
@@ -655,6 +660,10 @@ video {
margin-top: 0.75rem;
}
.mt-4 {
margin-top: 1rem;
}
.block {
display: block;
}
@@ -683,10 +692,18 @@ video {
max-height: 8rem;
}
.max-h-\[60vh\] {
max-height: 60vh;
}
.max-h-\[80vh\] {
max-height: 80vh;
}
.max-h-\[85vh\] {
max-height: 85vh;
}
.min-h-20 {
min-height: 5rem;
}
@@ -711,6 +728,10 @@ video {
width: 4rem;
}
.w-2\/3 {
width: 66.666667%;
}
.w-20 {
width: 5rem;
}
@@ -719,6 +740,26 @@ video {
width: 6rem;
}
.w-28 {
width: 7rem;
}
.w-32 {
width: 8rem;
}
.w-36 {
width: 9rem;
}
.w-4 {
width: 1rem;
}
.w-40 {
width: 10rem;
}
.w-48 {
width: 12rem;
}
@@ -751,6 +792,10 @@ video {
max-width: 28rem;
}
.max-w-sm {
max-width: 24rem;
}
.max-w-xs {
max-width: 20rem;
}
@@ -797,6 +842,10 @@ video {
align-items: center;
}
.justify-end {
justify-content: flex-end;
}
.justify-center {
justify-content: center;
}
@@ -851,6 +900,12 @@ video {
margin-bottom: calc(0.5rem * var(--tw-space-y-reverse));
}
.space-y-3 > :not([hidden]) ~ :not([hidden]) {
--tw-space-y-reverse: 0;
margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse)));
margin-bottom: calc(0.75rem * var(--tw-space-y-reverse));
}
.overflow-auto {
overflow: auto;
}
@@ -863,6 +918,12 @@ video {
overflow-y: auto;
}
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.whitespace-nowrap {
white-space: nowrap;
}
@@ -900,6 +961,10 @@ video {
border-bottom-width: 1px;
}
.border-l {
border-left-width: 1px;
}
.border-t {
border-top-width: 1px;
}
@@ -933,6 +998,11 @@ video {
background-color: rgb(5 150 105 / var(--tw-bg-opacity, 1));
}
.bg-emerald-700 {
--tw-bg-opacity: 1;
background-color: rgb(4 120 87 / var(--tw-bg-opacity, 1));
}
.bg-gray-100 {
--tw-bg-opacity: 1;
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
@@ -963,6 +1033,11 @@ video {
background-color: rgb(254 226 226 / var(--tw-bg-opacity, 1));
}
.bg-red-600 {
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity, 1));
}
.bg-slate-600 {
--tw-bg-opacity: 1;
background-color: rgb(71 85 105 / var(--tw-bg-opacity, 1));
@@ -989,11 +1064,25 @@ video {
padding: 1rem;
}
.p-6 {
padding: 1.5rem;
}
.px-0\.5 {
padding-left: 0.125rem;
padding-right: 0.125rem;
}
.px-1 {
padding-left: 0.25rem;
padding-right: 0.25rem;
}
.px-1\.5 {
padding-left: 0.375rem;
padding-right: 0.375rem;
}
.px-2 {
padding-left: 0.5rem;
padding-right: 0.5rem;
@@ -1054,6 +1143,10 @@ video {
padding-bottom: 1rem;
}
.pl-2 {
padding-left: 0.5rem;
}
.pt-1 {
padding-top: 0.25rem;
}
@@ -1087,6 +1180,15 @@ video {
line-height: 2rem;
}
.text-\[10px\] {
font-size: 10px;
}
.text-lg {
font-size: 1.125rem;
line-height: 1.75rem;
}
.text-sm {
font-size: 0.875rem;
line-height: 1.25rem;
@@ -1109,19 +1211,37 @@ video {
font-weight: 400;
}
.font-semibold {
font-weight: 600;
}
.italic {
font-style: italic;
}
.leading-none {
line-height: 1;
}
.leading-relaxed {
line-height: 1.625;
}
.text-amber-600 {
--tw-text-opacity: 1;
color: rgb(217 119 6 / var(--tw-text-opacity, 1));
}
.text-blue-600 {
--tw-text-opacity: 1;
color: rgb(37 99 235 / var(--tw-text-opacity, 1));
}
.text-emerald-600 {
--tw-text-opacity: 1;
color: rgb(5 150 105 / var(--tw-text-opacity, 1));
}
.text-gray-300 {
--tw-text-opacity: 1;
color: rgb(209 213 219 / var(--tw-text-opacity, 1));
@@ -1334,6 +1454,11 @@ video {
color: rgb(96 165 250 / var(--tw-text-opacity, 1));
}
.dark\:text-emerald-400:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(52 211 153 / var(--tw-text-opacity, 1));
}
.dark\:text-gray-600:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(75 85 99 / var(--tw-text-opacity, 1));
@@ -1403,6 +1528,11 @@ video {
background-color: rgb(51 65 85 / var(--tw-bg-opacity, 1));
}
.dark\:hover\:text-gray-300:hover:is(.dark *) {
--tw-text-opacity: 1;
color: rgb(209 213 219 / 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));