PLI date/time responses default to the host clock

Live finding (Network rejection events): the card asks the terminal for the
current date and time (PROVIDE LOCAL INFORMATION, qualifier 03) and our TR
answered "performed successfully" with no data.  TS 102 223 6.4.15 says the
terminal shall return the current date and time, and the 6.8.0 TR structure
requires the Date-Time and Time zone object (8.39) for that qualifier.

- _pli_data_hex(qual): the TR Config dictionary entry wins; when it is empty
  the date/time qualifier is answered from the host clock via _encode_scts
  (the SMS timestamp builder, TP-SCTS coding).
- _build_tr and _DefaultProactiveHandler put the command-specific data objects
  after the Result TLV per the 6.8.0 order (the POLL INTERVAL Duration echo
  too; real terminal responses pin the order: 8103010300 02028281 030100
  0402011E).
- _decode_tr decodes qualifier 03 with the same 8.39 coding (swapped
  semi-octet BCD digits, time-zone sign bit 0x08, 'FF' = unknown); it read
  plain decimal bytes and a 0x80 sign before.
- The PWA PLI dictionary codec for the date/time entry writes the same coding
  (it wrote plain hex bytes and a 0x80 sign), and the TR Config panel notes
  the empty-entry default.  sw cache simple-v351.

Tests: builder vectors (east/west/unknown zone) + dictionary override + the
empty non-date/time qualifier + the object order (PLI and POLL INTERVAL) + the
handler path + the TR decode + a frontend codec round-trip test.
This commit is contained in:
2026-10-01 23:37:26 +03:00
parent 69abbe2fa5
commit ad2a253f2a
5 changed files with 217 additions and 43 deletions
+33 -18
View File
@@ -1291,6 +1291,7 @@
<span class="text-sm text-gray-500 dark:text-slate-400" data-l10n="PROVIDE LOCAL INFORMATION response data">PROVIDE LOCAL INFORMATION response data</span> <span class="text-sm 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" data-needs="server" onclick="pysimPliSave()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Save">Save</button> <button id="pli-save-btn" data-needs="server" onclick="pysimPliSave()" class="px-2.5 py-1 text-sm rounded bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-40 disabled:cursor-not-allowed" data-l10n="Save">Save</button>
</div> </div>
<div class="text-xs text-gray-400 dark:text-slate-500 mb-1" data-l10n="The date/time qualifier is answered from the host clock while its entry is empty.">The date/time qualifier is answered from the host clock while its entry is empty.</div>
<div id="pysim-pli-dict" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[60vh] overflow-auto"> <div id="pysim-pli-dict" class="text-sm font-mono text-gray-600 dark:text-slate-400 max-h-[60vh] overflow-auto">
<span class="text-gray-400" data-l10n="Loading...">Loading...</span> <span class="text-gray-400" data-l10n="Loading...">Loading...</span>
</div> </div>
@@ -15572,32 +15573,45 @@ const PLI_CODECS = {
'03': { tlv: '2607', '03': { tlv: '2607',
decode(hex) { decode(hex) {
const v = hex.replace(/\s/g, '').slice(4); if (v.length < 14) return null; 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]); // TP-SCTS coding (TS 123 040 9.2.3.11): swapped semi-octet BCD
const mo = parseInt(v[2]+v[3]), dy = parseInt(v[4]+v[5]); // digits, time zone in quarters of an hour with the sign in 0x08,
const hh = parseInt(v[6]+v[7]), mm = parseInt(v[8]+v[9]), ss = parseInt(v[10]+v[11]); // 'FF' unknown - the coding of eventDateTimeTlv / _encode_scts.
const tz = parseInt(v[12]+v[13], 16); const b = [];
const tzSign = (tz & 0x80) ? '-' : '+'; for (let i = 0; i < 14; i += 2) b.push(parseInt(v[i] + v[i + 1], 16));
const tzH = Math.floor(((tz & 0x3F) || 0) / 4); const semi = x => (x & 0x0F) * 10 + (x >> 4);
const tzM = (((tz & 0x3F) || 0) % 4) * 15; const two = x => String(x).padStart(2, '0');
const yr = semi(b[0]) < 70 ? 2000 + semi(b[0]) : 1900 + semi(b[0]);
let tz = 'unknown';
if (b[6] !== 0xFF) {
const q = (b[6] & 0x07) * 10 + (b[6] >> 4);
tz = (b[6] & 0x08 ? '-' : '+') + two(q >> 2) + ':' + two((q % 4) * 15);
}
return { return {
dt: yr+'-'+(''+mo).padStart(2,'0')+'-'+(''+dy).padStart(2,'0'), dt: yr + '-' + two(semi(b[1])) + '-' + two(semi(b[2])),
tm: (''+hh).padStart(2,'0')+':'+(''+mm).padStart(2,'0')+':'+(''+ss).padStart(2,'0'), tm: two(semi(b[3])) + ':' + two(semi(b[4])) + ':' + two(semi(b[5])),
tz: tzSign+(''+tzH).padStart(2,'0')+':'+(''+tzM).padStart(2,'0'), tz: tz,
}; };
}, },
encode(v) { encode(v) {
const dt = (v.dt||'2000-01-01').split('-'), tm = (v.tm||'00:00:00').split(':'); const dt = (v.dt||'2000-01-01').split('-'), tm = (v.tm||'00:00:00').split(':');
const semi = x => (((x % 10) << 4) | Math.floor(x / 10)) & 0xFF;
const hx = x => x.toString(16).padStart(2, '0');
let tzQ = 0; let tzQ = 0;
const tzM = v.tz ? v.tz.match(/^([-+])(\d{2}):?(\d{2})$/) : null; 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; } if (tzM) {
// quarters of an hour, swapped digits + the 0x08 sign bit
const q = Math.min(62, (parseInt(tzM[2]) || 0) * 4 +
Math.floor((parseInt(tzM[3]) || 0) / 15));
tzQ = semi(q) | (tzM[1] === '-' ? 0x08 : 0);
}
return '2607' return '2607'
+ (parseInt(dt[0])%100).toString(16).padStart(2,'0') + hx(semi((parseInt(dt[0], 10) || 0) % 100))
+ (parseInt(dt[1])||1).toString(16).padStart(2,'0') + hx(semi(parseInt(dt[1], 10) || 1))
+ (parseInt(dt[2])||1).toString(16).padStart(2,'0') + hx(semi(parseInt(dt[2], 10) || 1))
+ (parseInt(tm[0])||0).toString(16).padStart(2,'0') + hx(semi(parseInt(tm[0], 10) || 0))
+ (parseInt(tm[1])||0).toString(16).padStart(2,'0') + hx(semi(parseInt(tm[1], 10) || 0))
+ (parseInt(tm[2])||0).toString(16).padStart(2,'0') + hx(semi(parseInt(tm[2], 10) || 0))
+ tzQ.toString(16).padStart(2,'0'); + hx(tzQ);
}, },
fields: [ fields: [
{id:'dt',label:'Date',type:'date'},{id:'tm',label:'Time',type:'time'}, {id:'dt',label:'Date',type:'date'},{id:'tm',label:'Time',type:'time'},
@@ -19941,6 +19955,7 @@ const LANG_RU = {
'No further info can be given': 'Дополнительная информация отсутствует', 'No further info can be given': 'Дополнительная информация отсутствует',
'Link dropped (network failure or user cancellation)': 'Линия разорвана (сбой сети или отмена пользователем)', 'Link dropped (network failure or user cancellation)': 'Линия разорвана (сбой сети или отмена пользователем)',
'PROVIDE LOCAL INFORMATION response data': 'Данные для PROVIDE LOCAL INFORMATION', 'PROVIDE LOCAL INFORMATION response data': 'Данные для PROVIDE LOCAL INFORMATION',
'The date/time qualifier is answered from the host clock while its entry is empty.': 'Квалификатор «дата и время» при пустой записи отвечается с часов хоста.',
'Send STATUS': 'Отправить STATUS', 'Send STATUS': 'Отправить STATUS',
'help': 'справка', 'help': 'справка',
'STATUS and Polling': 'STATUS и опрос', 'STATUS and Polling': 'STATUS и опрос',
+1 -1
View File
@@ -1,4 +1,4 @@
const CACHE = 'simple-v350'; const CACHE = 'simple-v351';
const URLS = [ const URLS = [
'index.html', 'index.html',
'help.html', 'help.html',
+39
View File
@@ -0,0 +1,39 @@
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 extractBlock(startMarker, endMarker) {
const start = html.indexOf(startMarker);
const end = html.indexOf(endMarker, start);
if (start < 0 || end < 0) throw new Error('block not found: ' + startMarker);
return html.slice(start, end);
}
// The codec map's select fields reference ACCESSTECH; only the date/time
// codec is exercised here, but the map literal still evaluates that list.
eval(extractBlock('const ACCESSTECH = [', 'const EVENT_FORMS = {').replace(/^const /gm, 'var '));
eval(extractBlock('// ===== PLI data dictionary =====', 'async function pysimPliRender')
.replace(/^const /gm, 'var '));
test('the PLI date/time codec uses the 8.39 TP-SCTS coding', () => {
const c = PLI_CODECS['03'];
assert.strictEqual(c.tlv, '2607');
// +03:00: swapped semi-octet BCD digits, the time zone in quarters of an
// hour (0x21 = 12 quarters) - the eventDateTimeTlv / _encode_scts vectors
assert.strictEqual(c.encode({ dt: '2026-09-30', tm: '21:55:00', tz: '+03:00' }),
'260762900312550021');
assert.deepStrictEqual(c.decode('260762900312550021'),
{ dt: '2026-09-30', tm: '21:55:00', tz: '+03:00' });
// negative zones carry the sign bit 0x08: -2:30 = 10 quarters -> 0x09
assert.strictEqual(c.encode({ dt: '2026-01-05', tm: '08:07:09', tz: '-02:30' }),
'260762105080709009');
assert.deepStrictEqual(c.decode('260762105080709009'),
{ dt: '2026-01-05', tm: '08:07:09', tz: '-02:30' });
// 'FF' time zone = unknown (TS 123 040 9.2.3.11), never a bogus offset
assert.strictEqual(c.decode('2607629003125500FF').tz, 'unknown');
// a truncated value still returns null instead of throwing
assert.strictEqual(c.decode('2607123'), null);
});
+51 -23
View File
@@ -1919,10 +1919,11 @@ class _DefaultProactiveHandler(ProactiveHandler):
pass pass
ti_list = self.prepare_response(cmd_obj, 'performed_successfully') ti_list = self.prepare_response(cmd_obj, 'performed_successfully')
if cmd_type == 0x26 and cmd_qual is not None: if cmd_type == 0x26 and cmd_qual is not None:
pli_hex = _PLI_DATA.get(cmd_qual, '') pli_hex = _pli_data_hex(cmd_qual)
if pli_hex: if pli_hex:
try: try:
ti_list.insert(2, _RawBerTlv(pli_hex)) # After the Result, per the TS 102 223 6.8.0 object order.
ti_list.append(_RawBerTlv(pli_hex))
except Exception: except Exception:
pass pass
if entry: if entry:
@@ -2619,15 +2620,24 @@ def _decode_tr(type_hex, qual_hex, tr_hex):
if qual == 0x01 and len(v) >= 16: if qual == 0x01 and len(v) >= 16:
return [{'label': 'IMEI', 'value': _dec_imei(v[:16])}] return [{'label': 'IMEI', 'value': _dec_imei(v[:16])}]
if qual == 0x03 and len(v) >= 14: if qual == 0x03 and len(v) >= 14:
yr = 2000 + int(v[0:2]) if int(v[0:2]) < 70 else 1900 + int(v[0:2]) # Date-Time and Time zone (TS 102 223 8.39): TP-SCTS coding
mo, dy = int(v[2:4]), int(v[4:6]) # (TS 123 040 9.2.3.11) - swapped semi-octet BCD digits, the
hh, mm, ss = int(v[6:8]), int(v[8:10]), int(v[10:12]) # time zone sign in bit 0x08 of the last byte, 'FF' unknown.
tz = int(v[12:14], 16) b = bytes.fromhex(v[:14])
tz_sign = '-' if tz & 0x80 else '+' y = _bcd_swap(b[0])
tz_q = (tz & 0x3F) or 0 yr = 2000 + y if y < 70 else 1900 + y
return [{'label': 'Date', 'value': '%04d-%02d-%02d' % (yr, mo, dy)}, out = [{'label': 'Date',
{'label': 'Time', 'value': '%02d:%02d:%02d' % (hh, mm, ss)}, 'value': '%04d-%02d-%02d' % (yr, _bcd_swap(b[1]), _bcd_swap(b[2]))},
{'label': 'TZ offset', 'value': '%s%02d:%02d' % (tz_sign, tz_q // 4, (tz_q % 4) * 15)}] {'label': 'Time',
'value': '%02d:%02d:%02d' % (_bcd_swap(b[3]), _bcd_swap(b[4]), _bcd_swap(b[5]))}]
if b[6] == 0xFF:
out.append({'label': 'TZ offset', 'value': 'unknown'})
else:
q = (b[6] & 0x07) * 10 + (b[6] >> 4)
out.append({'label': 'TZ offset',
'value': '%s%02d:%02d' % ('-' if b[6] & 0x08 else '+',
q // 4, (q % 4) * 15)})
return out
if qual == 0x04 and len(v) >= 4: if qual == 0x04 and len(v) >= 4:
try: try:
lang = bytes.fromhex(v[:4]).decode('ascii') lang = bytes.fromhex(v[:4]).decode('ascii')
@@ -3533,21 +3543,39 @@ def _scp81_bip_control(body):
'listener': _bip_listener_status()} 'listener': _bip_listener_status()}
def _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual): def _pli_data_hex(cmd_qual, dt=None):
"""Build the TERMINAL RESPONSE TLV payload for a fetched command """Data object(s) for a PROVIDE LOCAL INFORMATION TERMINAL RESPONSE, as
(includes PLI dictionary data for PROVIDE LOCAL INFORMATION).""" hex - or '' when there is nothing to send.
if cmd_type == 0x03:
return bytes([0x81, 0x03, cmd_num, cmd_type, 0x00, The TR Config dictionary entry (if any) wins. Otherwise the date/time
0x82, 0x02, dev_dst, dev_src, qualifier is answered with the current date and time from the host clock
0x84, 0x02, 0x01, _POLL_INTERVAL, (TS 102 223 6.4.15: "The terminal shall return the current date and time
0x03, 0x01, 0x00]) as set by the user"), coded as the Date-Time and Time zone object (8.39).
`dt` is a test seam, the clock is read live otherwise."""
val = str(_PLI_DATA.get(cmd_qual, '') or '').strip()
if val:
return val
if cmd_qual == 0x03:
return '2607' + _encode_scts(dt).hex().upper()
return ''
def _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, dt=None):
"""Build the TERMINAL RESPONSE TLV payload for a fetched command.
The object order follows TS 102 223 6.8.0: command details, device
identities, Result, then the command-specific data objects (the echoed
Duration for POLL INTERVAL, the PLI data for PROVIDE LOCAL INFORMATION)."""
base = bytes([0x81, 0x03, cmd_num, cmd_type, 0x00, base = bytes([0x81, 0x03, cmd_num, cmd_type, 0x00,
0x82, 0x02, dev_dst, dev_src]) 0x82, 0x02, dev_dst, dev_src,
if cmd_type == 0x26 and cmd_qual is not None: 0x03, 0x01, 0x00])
pli_hex = _PLI_DATA.get(cmd_qual, '') if cmd_type == 0x03:
base += bytes([0x84, 0x02, 0x01, _POLL_INTERVAL])
elif cmd_type == 0x26 and cmd_qual is not None:
pli_hex = _pli_data_hex(cmd_qual, dt)
if pli_hex: if pli_hex:
base += bytes.fromhex(pli_hex) base += bytes.fromhex(pli_hex)
return base + bytes([0x03, 0x01, 0x00]) return base
_RESULT_NAMES_BASIC = { _RESULT_NAMES_BASIC = {
+93 -1
View File
@@ -691,9 +691,87 @@ class TestProactiveDecode(unittest.TestCase):
parsed = pcmd.from_tlv(h2b('d00d810301260082028182')) parsed = pcmd.from_tlv(h2b('d00d810301260082028182'))
ti = handler.receive_fetch_raw(pcmd, parsed) ti = handler.receive_fetch_raw(pcmd, parsed)
tr = b''.join(x.to_tlv() for x in ti).hex() tr = b''.join(x.to_tlv() for x in ti).hex()
self.assertIn('93055210011000', tr) # the data object follows the Result (TS 102 223 6.8.0 order)
self.assertTrue(tr.startswith('81030126008202828183010093055210011000'), tr)
self.assertEqual(srv._PROACTIVE_LOG[-1]['tr_hex'], '93055210011000') self.assertEqual(srv._PROACTIVE_LOG[-1]['tr_hex'], '93055210011000')
def test_default_handler_pli_datetime_when_dict_empty(self):
# No dictionary entry: the date/time qualifier is answered from the
# host clock (TS 102 223 6.4.15), after the Result per 6.8.0.
import pysim_simple_server.server as srv
from pySim.cat import ProactiveCommand
from pySim.utils import h2b
srv._PROACTIVE_LOG.clear()
saved = srv._PLI_DATA.get(0x03)
srv._PLI_DATA[0x03] = ''
try:
handler = srv._DefaultProactiveHandler()
pcmd = ProactiveCommand()
parsed = pcmd.from_tlv(h2b('d009810301260382028182'))
ti = handler.receive_fetch_raw(pcmd, parsed)
finally:
srv._PLI_DATA[0x03] = saved
tr = b''.join(x.to_tlv() for x in ti).hex()
self.assertRegex(tr, r'^8103012603820282818301002607[0-9A-F]{14}$')
self.assertRegex(srv._PROACTIVE_LOG[-1]['tr_hex'], r'^2607[0-9A-F]{14}$')
def test_build_tr_puts_result_before_pli_data(self):
# TS 102 223 6.8.0: command details, device identities, Result, then
# the command-specific data objects (real terminals send them last).
tr = _build_tr(None, 1, 0x26, 0x81, 0x82, 0x00)
self.assertEqual(tr.hex(), '81030126008202828103010093055210011000')
def test_build_tr_poll_interval_duration_after_result(self):
import pysim_simple_server.server as srv
tr = _build_tr(None, 1, 0x03, 0x81, 0x82, None)
self.assertEqual(tr.hex(),
'810301030082028281030100840201%02x' % srv._POLL_INTERVAL)
def test_build_tr_pli_datetime_from_host_clock_when_dict_empty(self):
# 8.39 coding: the same vectors as the PWA's eventDateTimeTlv test.
import pysim_simple_server.server as srv
from datetime import datetime, timedelta, timezone
saved = srv._PLI_DATA.get(0x03)
srv._PLI_DATA[0x03] = ''
try:
east = datetime(2026, 9, 30, 21, 55, 0,
tzinfo=timezone(timedelta(hours=3)))
west = datetime(2026, 1, 5, 8, 7, 9,
tzinfo=timezone(-timedelta(hours=2, minutes=30)))
tr_east = _build_tr(None, 1, 0x26, 0x81, 0x82, 0x03, east)
tr_west = _build_tr(None, 1, 0x26, 0x81, 0x82, 0x03, west)
finally:
srv._PLI_DATA[0x03] = saved
self.assertEqual(tr_east.hex(),
'810301260082028281030100260762900312550021')
# negative zone: the sign bit of the time zone byte (0x08)
self.assertEqual(tr_west.hex(),
'810301260082028281030100260762105080709009')
def test_build_tr_pli_dict_entry_overrides_host_clock(self):
import pysim_simple_server.server as srv
from datetime import datetime, timezone
saved = srv._PLI_DATA.get(0x03)
srv._PLI_DATA[0x03] = '130752f01000ff0001'
try:
tr = _build_tr(None, 1, 0x26, 0x81, 0x82, 0x03,
datetime(2026, 9, 30, 21, 55, tzinfo=timezone.utc))
finally:
srv._PLI_DATA[0x03] = saved
self.assertEqual(tr.hex(), '810301260082028281030100130752f01000ff0001')
def test_build_tr_pli_empty_other_qualifier_sends_no_data(self):
import pysim_simple_server.server as srv
saved = srv._PLI_DATA.get(0x0a)
srv._PLI_DATA[0x0a] = ' '
try:
tr = _build_tr(None, 1, 0x26, 0x81, 0x82, 0x0a)
data = srv._pli_data_hex(0x0a)
finally:
srv._PLI_DATA[0x0a] = saved
self.assertEqual(tr.hex(), '810301260082028281030100')
self.assertEqual(data, '')
def test_decode_cmd_empty_raw(self): def test_decode_cmd_empty_raw(self):
self.assertEqual(_decode_cmd(0x26, b'', None), []) self.assertEqual(_decode_cmd(0x26, b'', None), [])
self.assertEqual(_decode_cmd(0x03, None, None), []) self.assertEqual(_decode_cmd(0x03, None, None), [])
@@ -710,6 +788,20 @@ class TestProactiveDecode(unittest.TestCase):
r = _decode_tr('26', '01', '94082143658709214305') r = _decode_tr('26', '01', '94082143658709214305')
self.assertEqual(r[0], {'label': 'IMEI', 'value': '123456789012345'}) self.assertEqual(r[0], {'label': 'IMEI', 'value': '123456789012345'})
def test_decode_tr_pli_datetime(self):
# 8.39 object in the TP-SCTS coding (TS 123 040 9.2.3.11): swapped
# semi-octet BCD digits, the time zone sign bit 0x08, 'FF' unknown.
# The vectors are the ones the PWA encoder pins too.
self.assertEqual(_decode_tr('26', '03', '260762900312550021'), [
{'label': 'Date', 'value': '2026-09-30'},
{'label': 'Time', 'value': '21:55:00'},
{'label': 'TZ offset', 'value': '+03:00'},
])
self.assertEqual(_decode_tr('26', '03', '260762105080709009')[2],
{'label': 'TZ offset', 'value': '-02:30'})
self.assertEqual(_decode_tr('26', '03', '2607629003125500FF')[2],
{'label': 'TZ offset', 'value': 'unknown'})
def test_decode_tr_pli_access_technology(self): def test_decode_tr_pli_access_technology(self):
r = _decode_tr('26', '06', 'bf0103') r = _decode_tr('26', '06', 'bf0103')
self.assertEqual(r, [{'label': 'Access Technology', 'value': 'UTRAN (3)'}]) self.assertEqual(r, [{'label': 'Access Technology', 'value': 'UTRAN (3)'}])