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)'}])