diff --git a/frontend/index.html b/frontend/index.html
index aeda54b..494e54f 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -1291,6 +1291,7 @@
PROVIDE LOCAL INFORMATION response data
+
The date/time qualifier is answered from the host clock while its entry is empty.
Loading...
@@ -15572,32 +15573,45 @@ const PLI_CODECS = {
'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;
+ // TP-SCTS coding (TS 123 040 9.2.3.11): swapped semi-octet BCD
+ // digits, time zone in quarters of an hour with the sign in 0x08,
+ // 'FF' unknown - the coding of eventDateTimeTlv / _encode_scts.
+ const b = [];
+ for (let i = 0; i < 14; i += 2) b.push(parseInt(v[i] + v[i + 1], 16));
+ const semi = x => (x & 0x0F) * 10 + (x >> 4);
+ 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 {
- 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'),
+ dt: yr + '-' + two(semi(b[1])) + '-' + two(semi(b[2])),
+ tm: two(semi(b[3])) + ':' + two(semi(b[4])) + ':' + two(semi(b[5])),
+ tz: tz,
};
},
encode(v) {
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;
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'
- + (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');
+ + hx(semi((parseInt(dt[0], 10) || 0) % 100))
+ + hx(semi(parseInt(dt[1], 10) || 1))
+ + hx(semi(parseInt(dt[2], 10) || 1))
+ + hx(semi(parseInt(tm[0], 10) || 0))
+ + hx(semi(parseInt(tm[1], 10) || 0))
+ + hx(semi(parseInt(tm[2], 10) || 0))
+ + hx(tzQ);
},
fields: [
{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': 'Дополнительная информация отсутствует',
'Link dropped (network failure or user cancellation)': 'Линия разорвана (сбой сети или отмена пользователем)',
'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',
'help': 'справка',
'STATUS and Polling': 'STATUS и опрос',
diff --git a/frontend/sw.js b/frontend/sw.js
index ec862c0..340599d 100644
--- a/frontend/sw.js
+++ b/frontend/sw.js
@@ -1,4 +1,4 @@
-const CACHE = 'simple-v350';
+const CACHE = 'simple-v351';
const URLS = [
'index.html',
'help.html',
diff --git a/frontend/tests/pli_dict.test.js b/frontend/tests/pli_dict.test.js
new file mode 100644
index 0000000..ef27392
--- /dev/null
+++ b/frontend/tests/pli_dict.test.js
@@ -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);
+});
diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py
index b221a87..1a406cf 100644
--- a/pysim_simple_server/server.py
+++ b/pysim_simple_server/server.py
@@ -1919,10 +1919,11 @@ class _DefaultProactiveHandler(ProactiveHandler):
pass
ti_list = self.prepare_response(cmd_obj, 'performed_successfully')
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:
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:
pass
if entry:
@@ -2619,15 +2620,24 @@ def _decode_tr(type_hex, qual_hex, tr_hex):
if qual == 0x01 and len(v) >= 16:
return [{'label': 'IMEI', 'value': _dec_imei(v[:16])}]
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])
- mo, dy = int(v[2:4]), int(v[4:6])
- hh, mm, ss = int(v[6:8]), int(v[8:10]), int(v[10:12])
- tz = int(v[12:14], 16)
- tz_sign = '-' if tz & 0x80 else '+'
- tz_q = (tz & 0x3F) or 0
- return [{'label': 'Date', 'value': '%04d-%02d-%02d' % (yr, mo, dy)},
- {'label': 'Time', 'value': '%02d:%02d:%02d' % (hh, mm, ss)},
- {'label': 'TZ offset', 'value': '%s%02d:%02d' % (tz_sign, tz_q // 4, (tz_q % 4) * 15)}]
+ # Date-Time and Time zone (TS 102 223 8.39): TP-SCTS coding
+ # (TS 123 040 9.2.3.11) - swapped semi-octet BCD digits, the
+ # time zone sign in bit 0x08 of the last byte, 'FF' unknown.
+ b = bytes.fromhex(v[:14])
+ y = _bcd_swap(b[0])
+ yr = 2000 + y if y < 70 else 1900 + y
+ out = [{'label': 'Date',
+ 'value': '%04d-%02d-%02d' % (yr, _bcd_swap(b[1]), _bcd_swap(b[2]))},
+ {'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:
try:
lang = bytes.fromhex(v[:4]).decode('ascii')
@@ -3533,21 +3543,39 @@ def _scp81_bip_control(body):
'listener': _bip_listener_status()}
-def _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual):
- """Build the TERMINAL RESPONSE TLV payload for a fetched command
- (includes PLI dictionary data for PROVIDE LOCAL INFORMATION)."""
- if cmd_type == 0x03:
- return bytes([0x81, 0x03, cmd_num, cmd_type, 0x00,
- 0x82, 0x02, dev_dst, dev_src,
- 0x84, 0x02, 0x01, _POLL_INTERVAL,
- 0x03, 0x01, 0x00])
+def _pli_data_hex(cmd_qual, dt=None):
+ """Data object(s) for a PROVIDE LOCAL INFORMATION TERMINAL RESPONSE, as
+ hex - or '' when there is nothing to send.
+
+ The TR Config dictionary entry (if any) wins. Otherwise the date/time
+ qualifier is answered with the current date and time from the host clock
+ (TS 102 223 6.4.15: "The terminal shall return the current date and time
+ 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,
- 0x82, 0x02, dev_dst, dev_src])
- if cmd_type == 0x26 and cmd_qual is not None:
- pli_hex = _PLI_DATA.get(cmd_qual, '')
+ 0x82, 0x02, dev_dst, dev_src,
+ 0x03, 0x01, 0x00])
+ 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:
base += bytes.fromhex(pli_hex)
- return base + bytes([0x03, 0x01, 0x00])
+ return base
_RESULT_NAMES_BASIC = {
diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py
index 5d5b1c6..0d9e7ae 100644
--- a/tests/test_ota_helpers.py
+++ b/tests/test_ota_helpers.py
@@ -691,9 +691,87 @@ class TestProactiveDecode(unittest.TestCase):
parsed = pcmd.from_tlv(h2b('d00d810301260082028182'))
ti = handler.receive_fetch_raw(pcmd, parsed)
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')
+ 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):
self.assertEqual(_decode_cmd(0x26, b'', None), [])
self.assertEqual(_decode_cmd(0x03, None, None), [])
@@ -710,6 +788,20 @@ class TestProactiveDecode(unittest.TestCase):
r = _decode_tr('26', '01', '94082143658709214305')
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):
r = _decode_tr('26', '06', 'bf0103')
self.assertEqual(r, [{'label': 'Access Technology', 'value': 'UTRAN (3)'}])