From 547ea6170840109b964e2f38c8138cf2c2f8b507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BD=D1=82=D0=BE=D0=BD=20=D0=A2=D1=80=D0=BE=D1=88?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Sun, 11 Oct 2026 13:31:49 +0300 Subject: [PATCH] v3.25.2: the TR tag-form alignment with TS 102 223 6.8.0/6.8.1 The TR's response objects are all Min = N in the spec's own table, and 6.8.0 says such objects "should set the CR flag to comprehension not required" - so the builders now emit the base tag forms: - device identities 82 -> 02 (every builder) - PLI data 93/94/AD/AE/BF/E2/E5 -> 13/14/2D/2E/3F/62/65 (26/63 already base); the PWA inline codecs follow - POLL INTERVAL Duration 84 -> 04 - GET INKEY/INPUT Text string 8D -> 0D - SELECT ITEM Item identifier 90 -> 10 - Timer identifier/value A4/A5 -> 24/25 (_bip_tr already used the reference traces' 01/02/03.) The command details now echo the command's tag form (01/81) besides its qualifier - 6.8.1 requires the object to be identical to the command's "including the comprehension required flag": the parser returns cd_tag and _build_tr, _handle_timer_command, _handle_bip_command, the menu pendings and testscript.build_tr carry it. The Result keeps 03/83 (Min = Y). _tr_data_only strips both tag styles and the POLL Duration decode accepts 04. Tests: the base forms per builder + the echo + the PWA drift guards; 883 frontend + 866 Python; sw cache simple-v426. --- frontend/index.html | 34 +++--- frontend/sw.js | 2 +- frontend/tests/pli_dict.test.js | 39 ++++--- pyproject.toml | 2 +- pysim_simple_server/server.py | 179 ++++++++++++++++++------------ pysim_simple_server/testscript.py | 17 +-- tests/test_fetch_routes.py | 8 +- tests/test_httpota.py | 9 +- tests/test_menu_timeout.py | 2 +- tests/test_ota_helpers.py | 72 +++++++----- tests/test_tag_variants.py | 11 +- tests/test_testscript.py | 24 ++-- 12 files changed, 238 insertions(+), 161 deletions(-) diff --git a/frontend/index.html b/frontend/index.html index 169e5da..4e92be6 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1905,7 +1905,7 @@ // ===== Version ===== // Single source of truth for the PWA version: shown in the header and used // by the server version check in pysimConnect(). -const SIMPLE_VERSION = '3.25.1'; +const SIMPLE_VERSION = '3.25.2'; document.getElementById('app-version').textContent = 'v' + SIMPLE_VERSION; // ===== view history (the browser Back/Forward buttons) ===== @@ -18140,24 +18140,24 @@ function encImeisvPli(digits) { } const PLI_CODECS = { - '00': { tlv: '9305', + '00': { tlv: '1305', 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()); }, + encode(v) { return '1305' + 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', + '01': { tlv: '1408', 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 : ''; }, + encode(v) { const imei = encImeiPli(v.imei||''); return imei ? '1408'+imei : ''; }, fields: [ {id:'imei',label:'IMEI',type:'text',pattern:'\\d{15}',placeholder:'15 digits'}, ], @@ -18210,39 +18210,39 @@ const PLI_CODECS = { {id:'tz',label:'TZ offset',type:'text',pattern:'[-+]\\d{2}:?\\d{2}',placeholder:'+00:00'}, ], }, - '04': { tlv: 'AD02', + '04': { tlv: '2D02', 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'); + return '2D02' + 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: 'AE02', + '05': { tlv: '2E02', 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 'AE02' + (parseInt(v.status)||0).toString(16).padStart(2,'0') + (parseInt(v.ta)||0).toString(16).padStart(2,'0'); }, + encode(v) { return '2E02' + (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', + '06': { tlv: '3F01', 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'); }, + encode(v) { return '3F01' + (parseInt(v.tech)||0).toString(16).padStart(2,'0'); }, fields: [{id:'tech',label:'Access Technology',type:'select',opts:ACCESSTECH}], }, - '08': { tlv: 'E208', + '08': { tlv: '6208', 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() }; @@ -18253,17 +18253,17 @@ const PLI_CODECS = { 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 'E208' + h; + return '6208' + h; }, fields: [{id:'imeisv',label:'IMEISV',type:'text',pattern:'\\d{16}',placeholder:'16 digits'}], }, - '09': { tlv: 'E501', + '09': { tlv: '6501', 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 'E501' + (parseInt(v.mode)||0).toString(16).padStart(2,'0'); + return '6501' + (parseInt(v.mode)||0).toString(16).padStart(2,'0'); }, fields: [{id:'mode',label:'Search Mode',type:'select', opts:[{v:0,l:'Manual'},{v:1,l:'Automatic'}]}], @@ -18281,7 +18281,7 @@ const PLI_CODECS = { opts:[{v:0,l:'Very low'},{v:1,l:'Low'},{v:2,l:'Average'},{v:3,l:'Good'}, {v:4,l:'Full'},{v:0xFE,l:'Not battery powered'},{v:0xFF,l:'Unknown / charging'}]}], }, - '0E': { tlv: 'BF', + '0E': { tlv: '3F', 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)); @@ -18291,7 +18291,7 @@ const PLI_CODECS = { 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(''); + return '3F' + len + techs.map(t => t.toString(16).padStart(2,'0')).join(''); }, fields: [ {id:'techs',label:'Access Technologies (comma-separated)',type:'text', diff --git a/frontend/sw.js b/frontend/sw.js index 9bfd412..3dcd194 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -1,4 +1,4 @@ -const CACHE = 'simple-v425'; +const CACHE = 'simple-v426'; const URLS = [ 'index.html', 'help.html', diff --git a/frontend/tests/pli_dict.test.js b/frontend/tests/pli_dict.test.js index 21649a7..c9ca5d5 100644 --- a/frontend/tests/pli_dict.test.js +++ b/frontend/tests/pli_dict.test.js @@ -38,22 +38,31 @@ test('the PLI date/time codec uses the 8.39 TP-SCTS coding', () => { assert.strictEqual(c.decode('2607123'), null); }); -test('the PLI codec tags follow TS 101 220 Table 7.23', () => { - // 05 Timing Advance: 2E/AE (C6 is a 3GPP2 tag) - assert.strictEqual(PLI_CODECS['05'].tlv, 'AE02'); - assert.strictEqual(PLI_CODECS['05'].encode({ status: '0', ta: '0' }), 'AE020000'); - // 08 IMEISV: 62/E2 (94 is the IMEI tag) - assert.strictEqual(PLI_CODECS['08'].tlv, 'E208'); +test('the PLI codec tags follow TS 101 220 Table 7.23, base (CR=0) form', () => { + // the TR's response objects are Min = N, so TS 102 223 6.8.0 says they + // "should set the CR flag to comprehension not required" (v3.25.2): + // the base tag values (13/14/2D/2E/3F/62/65/63/26), not the CR-set forms + assert.strictEqual(PLI_CODECS['00'].tlv, '1305'); + assert.strictEqual(PLI_CODECS['01'].tlv, '1408'); + assert.strictEqual(PLI_CODECS['01'].encode({ imei: '490154203237518' }), '140894104502237315f8'); + assert.strictEqual(PLI_CODECS['04'].tlv, '2D02'); + assert.strictEqual(PLI_CODECS['06'].tlv, '3F01'); + // 05 Timing Advance: 2E (C6 is a 3GPP2 tag) + assert.strictEqual(PLI_CODECS['05'].tlv, '2E02'); + assert.strictEqual(PLI_CODECS['05'].encode({ status: '0', ta: '0' }), '2E020000'); + // 08 IMEISV: 62 (94 is the IMEI tag) + assert.strictEqual(PLI_CODECS['08'].tlv, '6208'); assert.strictEqual(PLI_CODECS['08'].encode({ imeisv: '4901542032375108' }), - 'E2089410450223731580'); - // 09 Network search mode: 65/E5, '00' Manual / '01' Automatic (8.75) - assert.strictEqual(PLI_CODECS['09'].tlv, 'E501'); + '62089410450223731580'); + // 09 Network search mode: 65, '00' Manual / '01' Automatic (8.75) + assert.strictEqual(PLI_CODECS['09'].tlv, '6501'); assert.strictEqual(PLI_CODECS['09'].fields[0].opts[0].l, 'Manual'); assert.strictEqual(PLI_CODECS['09'].fields[0].opts[1].l, 'Automatic'); // 0A Battery state: 63/E3 is an enum, not a percentage (8.76) assert.strictEqual(PLI_CODECS['0A'].tlv, '6301'); assert.strictEqual(PLI_CODECS['0A'].encode({ charge: '4' }), '630104'); assert.strictEqual(PLI_CODECS['0A'].encode({ charge: '0' }), '630100'); + assert.strictEqual(PLI_CODECS['0E'].tlv, '3F'); }); test('the IMEI encoder pads the odd 15th digit with F (TS 124 008)', () => { @@ -65,16 +74,16 @@ test('the IMEI encoder pads the odd 15th digit with F (TS 124 008)', () => { test('the server PLI defaults decode through their codecs', () => { // mirrors PLI_DEFAULTS in server.py (v3.23.1; a drift guard for the codecs) const defaults = { - '00': '930500F1100000', '01': '940894104502237315F8', - '04': 'AD02656E', '05': 'AE020000', '06': 'BF0108', - '08': 'E2089410450223731580', '09': 'E50101', - '0A': '630104', '0E': 'BF020308', + '00': '130500F1100000', '01': '140894104502237315F8', + '04': '2D02656E', '05': '2E020000', '06': '3F0108', + '08': '62089410450223731580', '09': '650101', + '0A': '630104', '0E': '3F020308', }; for (const [code, hex] of Object.entries(defaults)) { assert.ok(PLI_CODECS[code].decode(hex), code + ' must decode'); } - assert.strictEqual(PLI_CODECS['01'].decode('940894104502237315F8').imei, + assert.strictEqual(PLI_CODECS['01'].decode('140894104502237315F8').imei, '490154203237518'); - assert.deepStrictEqual(PLI_CODECS['09'].decode('E50101'), { mode: '1' }); + assert.deepStrictEqual(PLI_CODECS['09'].decode('650101'), { mode: '1' }); assert.deepStrictEqual(PLI_CODECS['0A'].decode('630104'), { charge: '4' }); }); diff --git a/pyproject.toml b/pyproject.toml index d8e8989..53d424d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "pysim-simple-server" -version = "3.25.1" +version = "3.25.2" description = "HTTP REST server wrapping pysim for the SIMple PWA" requires-python = ">=3.8" # pysim is a git-only dependency installed explicitly by setup.bat/setup.sh. diff --git a/pysim_simple_server/server.py b/pysim_simple_server/server.py index 3af3675..7e651c2 100644 --- a/pysim_simple_server/server.py +++ b/pysim_simple_server/server.py @@ -36,7 +36,7 @@ from osmocom.tlv import BER_TLV_IE from osmocom.utils import rpad -VERSION = '3.25.1' +VERSION = '3.25.2' MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE @@ -2542,7 +2542,7 @@ class _DefaultProactiveHandler(ProactiveHandler): try: raw = cmd_obj.to_tlv() if raw: - cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw) + cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, _ = _parse_proactive_header(raw) entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num) except Exception: pass @@ -2636,7 +2636,9 @@ PLI_QUALIFIER_NAMES = { # Sensible PLI answers for an unconfigured dictionary - the qualifiers whose # objects the pinned ETSI specs define (TS 102 223 8.x, tags per TS 101 220 -# V18.3.0 Table 7.23). They are the *base layer*: a value set through +# V18.3.0 Table 7.23, in the base (CR=0) form: the TR's response objects +# are Min = N and 6.8.0 says they "should set the CR flag to comprehension +# not required" - v3.25.2). They are the *base layer*: a value set through # /api/pli-dict overrides its default, and clearing the field restores it # (v3.23.1). Qualifier 03 stays empty - the date/time is answered from the # host clock (see _pli_data_hex). The 3GPP/3GPP2-specific qualifiers (02 @@ -2647,24 +2649,24 @@ PLI_QUALIFIER_NAMES = { PLI_DEFAULTS = { # Location Information (8.19): PLMN 001/01 (the test network, TS 23.003) # + LAC 0000 - 0x00: '9305' + '00F110' + '0000', + 0x00: '1305' + '00F110' + '0000', # IMEI (8.20): the classic test IMEI 490154203237518, nibble-swapped # BCD with the odd 15th digit padded by F (TS 124 008 Mobile Identity) - 0x01: '9408' + '94104502237315F8', + 0x01: '1408' + '94104502237315F8', # Language (8.45): 'en' in the SMS default alphabet (bit 8 clear) - 0x04: 'AD02' + '656E', - # Timing Advance (3GPP; tag 2E/AE): ME status 00 + TA 00 - 0x05: 'AE02' + '0000', + 0x04: '2D02' + '656E', + # Timing Advance (3GPP; base tag 2E): ME status 00 + TA 00 + 0x05: '2E02' + '0000', # Access technology (8.61): E-UTRAN - 0x06: 'BF01' + '08', - # IMEISV (8.74, tag 62/E2): the test IMEI + SVN 08 - 0x08: 'E208' + '9410450223731580', - # Network search mode (8.75, tag 65/E5): automatic - 0x09: 'E501' + '01', - # Battery state (8.76, tag 63/E3): full + 0x06: '3F01' + '08', + # IMEISV (8.74, base tag 62): the test IMEI + SVN 08 + 0x08: '6208' + '9410450223731580', + # Network search mode (8.75, base tag 65): automatic + 0x09: '6501' + '01', + # Battery state (8.76, base tag 63): full 0x0A: '6301' + '04', # Access technologies (8.61): UTRAN + E-UTRAN - 0x0E: 'BF02' + '0308', + 0x0E: '3F02' + '0308', } _PLI_DATA = {q: '' for q in PLI_QUALIFIER_NAMES} # the operator overrides @@ -2923,7 +2925,7 @@ _server_ref = None def _tr_data_only(tr_tlv): """Strip boilerplate CTLVs (Command Details, Device IDs, Result) from TR, returning only command-specific data TLVs or empty bytes.""" - skip_tags = {0x81, 0x82, 0x03, 0x83} + skip_tags = {0x01, 0x81, 0x02, 0x82, 0x03, 0x83} data = bytearray() off = 0 while off < len(tr_tlv) - 1: @@ -3301,7 +3303,7 @@ def _decode_tr(type_hex, qual_hex, tr_hex): cmd_type = int(type_hex, 16) if type_hex else None except ValueError: return [{'label': 'Data', 'value': h}] - if cmd_type == 0x03 and len(h) >= 8 and h[0:2] == '84': + if cmd_type == 0x03 and len(h) >= 8 and h[0:2] in ('84', '04'): return [{'label': 'Interval', 'value': '%d s' % int(h[6:8], 16)}] if cmd_type == 0x27: tlvs = _tlv_map(bytes.fromhex(h)) @@ -3378,13 +3380,17 @@ def _bip_channel_id(dev_dst): return (dev_dst & 0x07) if 0x21 <= dev_dst <= 0x27 else None -def _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, result=0x00, info=None, extra=b''): +def _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, result=0x00, info=None, extra=b'', + cd_tag=None): """TERMINAL RESPONSE payload for a BIP command. - Tags follow the captured real-terminal traces (comprehension TLVs 01/02/03) - and the result precedes the optional Channel status / data TLVs. + The tags follow the captured real-terminal traces: the command details + echo the command's tag form (TS 102 223 6.8.1) and the response objects + use the base (CR=0) forms per 6.8.0 (Min = N); the result precedes the + optional Channel status / data TLVs. """ - tr = bytes([0x01, 0x03, cmd_num & 0xFF, cmd_type & 0xFF, (cmd_qual if cmd_qual is not None else 0) & 0xFF, + tr = bytes([(cd_tag or 0x01), 0x03, cmd_num & 0xFF, cmd_type & 0xFF, + (cmd_qual if cmd_qual is not None else 0) & 0xFF, 0x02, 0x02, 0x82, 0x81]) if info is None: tr += bytes([0x03, 0x01, result & 0xFF]) @@ -3427,8 +3433,13 @@ def _decode_bip_cmd(cmd_type, raw): return out -def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst): +def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst, cd_tag=None): """Handle a BIP proactive command. Returns TR payload bytes, or None for the generic path.""" + if cd_tag is None: + cd_tag = _cd_tag_of(raw) + def _tr(result=0x00, info=None, extra=b''): + return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, + result, info, extra, cd_tag=cd_tag) tlvs = httpota.proactive_tlvs(raw) channel = _bip_channel_id(dev_dst) if cmd_type == 0x40: @@ -3439,7 +3450,7 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst) extra += bytes([httpota.TAG_BUFFER_SIZE, 0x02]) + buffer_size.to_bytes(2, 'big') if not _BIP.enabled: _BIP.log('open-unavailable', reason='BIP not enabled') - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00, extra) + return _tr(0x3A, 0x00, extra) addr = httpota.parse_other_address(_cmd_tlv(tlvs, httpota.TAG_OTHER_ADDRESS)) proto, port = httpota.parse_transport_level(_cmd_tlv(tlvs, httpota.TAG_TRANSPORT_LEVEL)) if not addr or port is None: @@ -3451,28 +3462,28 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst) note='destination/transport not fully specified') cid, err = _BIP.open(addr or '-', port or 0, buffer_size, proto=proto) if cid is None: - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00, extra) + return _tr(0x3A, 0x00, extra) if cmd_qual and (cmd_qual & 0x04): # Background mode: the terminal shall inform the UICC that the # link was established (TS 102 223 7.5.11). _BIP._queue_link_status(cid, status=0x80 | (cid & 0x07), info=0x00) status = bytes([httpota.TAG_CHANNEL_STATUS, 0x02, 0x80 | (cid & 0x07), 0x00]) - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00, None, status + extra) + return _tr(0x00, None, status + extra) if cmd_type == 0x43: data = _cmd_tlv(tlvs, httpota.TAG_CHANNEL_DATA) if channel is None or not _BIP.send(channel, data): - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00) + return _tr(0x3A, 0x00) length = _BIP.send_capacity(channel) - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00, None, + return _tr(0x00, None, bytes([httpota.TAG_CHANNEL_DATA_LENGTH, 0x01, length & 0xFF])) if cmd_type == 0x42: req = _cmd_tlv(tlvs, httpota.TAG_CHANNEL_DATA_LENGTH) or b'\x00' n = req[0] if req else 0 if channel is None: - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00) + return _tr(0x3A, 0x00) data = _BIP.receive(channel, n) if data is None: - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00) + return _tr(0x3A, 0x00) remaining = _BIP.available(channel) # The channel data TLV length is BER-encoded: a single byte only up # to 127, then the 0x81 long form (the reference terminal traces use @@ -3486,16 +3497,16 @@ def _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst) else: extra = bytes([httpota.TAG_CHANNEL_DATA, 0x81, len(data)]) + data extra += bytes([httpota.TAG_CHANNEL_DATA_LENGTH, 0x01, 0xFF if remaining > 0xFF else remaining]) - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00, None, extra) + return _tr(0x00, None, extra) if cmd_type == 0x41: if channel is None or not _BIP.close(channel): - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x3A, 0x00) - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00) + return _tr(0x3A, 0x00) + return _tr(0x00) if cmd_type == 0x44: extra = b'' for cid in sorted(_BIP.channels): extra += bytes([httpota.TAG_CHANNEL_STATUS, 0x02, 0x80 | (cid & 0x07), 0x00]) - return _bip_tr(cmd_num, cmd_type, cmd_qual, dev_src, dev_dst, 0x00, None, extra) + return _tr(0x00, None, extra) return None @@ -4279,20 +4290,23 @@ def _pli_data_hex(cmd_qual, dt=None): return '' -def _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, dt=None): +def _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, dt=None, cd_tag=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). `dt` is a test seam for the PLI date/time default (see _pli_data_hex). - The command details echo the command's qualifier: TS 102 223 6.8.1 - requires the object to be identical to the command's (v3.25.1).""" - base = bytes([0x81, 0x03, cmd_num, cmd_type, (cmd_qual or 0) & 0xFF, - 0x82, 0x02, dev_dst, dev_src, + The command details echo the command's tag form and qualifier (TS 102 223 + 6.8.1: the object shall be identical to the command's); the response + objects use the base (CR=0) forms per 6.8.0 ("for all COMPREHENSION-TLV + objects with Min = N the terminal should set the CR flag to comprehension + not required") - v3.25.2.""" + base = bytes([(cd_tag or 0x81), 0x03, cmd_num, cmd_type, (cmd_qual or 0) & 0xFF, + 0x02, 0x02, dev_dst, dev_src, 0x03, 0x01, 0x00]) if cmd_type == 0x03: - base += bytes([0x84, 0x02, 0x01, _POLL_INTERVAL]) + base += bytes([0x04, 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: @@ -5275,14 +5289,16 @@ def _timer_expired(timer_id, elapsed): break -def _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst): +def _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst, cd_tag=None): """Terminal side of TIMER MANAGEMENT. Returns the TERMINAL RESPONSE payload.""" tlvs = httpota.proactive_tlvs(raw) tid = _cmd_tlv(tlvs, 0x24) timer_id = tid[0] if tid else 1 action = (cmd_qual or 0) & 0x03 - base = bytes([0x81, 0x03, cmd_num, cmd_type, (cmd_qual or 0) & 0xFF, - 0x82, 0x02, dev_dst, dev_src]) + if cd_tag is None: + cd_tag = _cd_tag_of(raw) + base = bytes([(cd_tag or 0x81), 0x03, cmd_num, cmd_type, (cmd_qual or 0) & 0xFF, + 0x02, 0x02, dev_dst, dev_src]) if action == 0x00: value = _cmd_tlv(tlvs, 0x25) if len(value) >= 3 and 1 <= timer_id <= 8: @@ -5296,7 +5312,7 @@ def _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst): return base + bytes([0x03, 0x01, 0x24]) if action == 0x01: _timer_cancel(timer_id) - return (base + bytes([0xA4, 0x01, timer_id & 0xFF, 0xA5, 0x03]) + return (base + bytes([0x24, 0x01, timer_id & 0xFF, 0x25, 0x03]) + _hms_bcd(remaining) + bytes([0x03, 0x01, 0x00])) @@ -5326,15 +5342,31 @@ def _parse_proactive_header(raw): cmd_num, cmd_type = 1, 0 cmd_qual = None dev_src, dev_dst = 0x83, 0x81 + cd_tag = None for tag, val in _ber_tlv_list(_proactive_body(raw)): # Cards use both the plain (01/02) and comprehension-required # (81/82) tag variants - TS 101 220 7.1.1 leaves the CR flag to the - # application, and the reference cards switch between them. + # application, and the reference cards switch between them. The + # command-details tag is kept: the TR echoes it (TS 102 223 6.8.1, + # v3.25.2). if tag in (0x01, 0x81) and len(val) >= 3: + cd_tag = tag cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2] elif tag in (0x02, 0x82) and len(val) >= 2: dev_src, dev_dst = val[0], val[1] - return cmd_num, cmd_type, dev_src, dev_dst, cmd_qual + return cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, cd_tag + + +def _cd_tag_of(raw): + """The command-details tag of a fetched command (0x01 or 0x81), or None + when it cannot be parsed: the TERMINAL RESPONSE echoes it (TS 102 223 + 6.8.1, v3.25.2).""" + if not raw: + return None + try: + return _parse_proactive_header(raw)[5] + except Exception: + return None def _find_sms_tpdu(raw): @@ -5616,7 +5648,7 @@ def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True, max_rounds= fdata, sw = rv[0], rv[1] raw = bytes.fromhex(fdata) if fdata else None action = None - cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = 1, 0, 0x83, 0x81, None + cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, cd_tag = 1, 0, 0x83, 0x81, None, None entry = None if raw: # Every step is guarded: a decoder bug must never break the @@ -5625,7 +5657,7 @@ def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True, max_rounds= # card's command left unanswered). The exception and the # offending bytes go to the log; the TR is still sent. try: - cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw) + cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, cd_tag = _parse_proactive_header(raw) except Exception as e: sys.stderr.write('FETCH parse error: %s (raw=%s)\n' % (e, raw.hex())) try: @@ -5656,15 +5688,18 @@ def _run_proactive_chain(scc, sw91, on_fetch=None, status_poll=True, max_rounds= tr_tlv = None try: if raw and cmd_type in (0x40, 0x41, 0x42, 0x43, 0x44): - tr_tlv = _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst) + tr_tlv = _handle_bip_command(scc, cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst, + cd_tag=cd_tag) if cmd_type == 0x27: - tr_tlv = _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst) + tr_tlv = _handle_timer_command(cmd_num, cmd_type, cmd_qual, raw, dev_src, dev_dst, + cd_tag=cd_tag) except Exception as e: sys.stderr.write('FETCH handler error: %s (raw=%s)\n' % ( e, raw.hex() if raw else '(none)')) tr_tlv = None if tr_tlv is None: - tr_tlv = _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual) + tr_tlv = _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, + cd_tag=cd_tag) tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr_tlv), tr_tlv.hex())) sys.stderr.write('TR: cmd=%02x type=%02x -> %s %s\n' % (cmd_num, cmd_type, tr_rv[1], ('(%d bytes)' % len(tr_tlv)))) _record_tr(entry, tr_tlv, tr_rv[1]) @@ -5735,6 +5770,7 @@ def _send_terminal_profile(scc, tp_hex): fdata, sw = scc._tp.send_apdu('%s120000%02x' % (scc.cat_cla, fetch_len)) cmd_num, cmd_type = 1, 0 cmd_qual = None + cd_tag = None dev_src, dev_dst = 0x83, 0x81 if fdata: raw = bytes.fromhex(fdata) @@ -5745,6 +5781,7 @@ def _send_terminal_profile(scc, tp_hex): val = raw[off + 2: off + 2 + tlen] off += 2 + tlen if tag in (0x81, 0x01) and tlen >= 3: + cd_tag = tag cmd_num, cmd_type, cmd_qual = val[0], val[1], val[2] elif tag in (0x82, 0x02) and tlen >= 2: dev_src, dev_dst = val[0], val[1] @@ -5760,7 +5797,8 @@ def _send_terminal_profile(scc, tp_hex): entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num) else: entry = None - tr_tlv = _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual) + tr_tlv = _build_tr(scc, cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, + cd_tag=cd_tag) tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr_tlv), tr_tlv.hex())) sys.stderr.write('TR(tp): cmd=%02x type=%02x -> %s\n' % (cmd_num, cmd_type, tr_rv[1])) _record_tr(entry, tr_tlv, tr_rv[1]) @@ -5814,9 +5852,11 @@ def _make_menu_fetch_handler(server, resp): # 6.8.1); the on_fetch callback signature does not carry it, so parse # it here (v3.25.1) cmd_qual = None + cd_tag = None if raw: try: - cmd_qual = _parse_proactive_header(raw)[4] + hdr = _parse_proactive_header(raw) + cmd_qual, cd_tag = hdr[4], hdr[5] except Exception: cmd_qual = None if cmd_type == 0x21: @@ -5824,7 +5864,7 @@ def _make_menu_fetch_handler(server, resp): if text: server.stk_pending = {'type': 'display_text', 'cmd_num': cmd_num, 'cmd_type': cmd_type, - 'cmd_qual': cmd_qual, + 'cmd_qual': cmd_qual, 'cd_tag': cd_tag, 'dev_src': dev_src, 'dev_dst': dev_dst, 'text': text} resp.update(type='display_text', text=text) return 'pause' @@ -5832,7 +5872,7 @@ def _make_menu_fetch_handler(server, resp): items = _parse_select_item(raw) if raw else [] server.stk_pending = {'type': 'select_item', 'cmd_num': cmd_num, 'cmd_type': cmd_type, - 'cmd_qual': cmd_qual, + 'cmd_qual': cmd_qual, 'cd_tag': cd_tag, 'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items} resp.update(type='select_item', items=items) return 'pause' @@ -5840,7 +5880,7 @@ def _make_menu_fetch_handler(server, resp): items = _parse_setup_menu_items(raw) if raw else [] server.stk_pending = {'type': 'select_item', 'cmd_num': cmd_num, 'cmd_type': cmd_type, - 'cmd_qual': cmd_qual, + 'cmd_qual': cmd_qual, 'cd_tag': cd_tag, 'dev_src': dev_src, 'dev_dst': dev_dst, 'items': items} resp.update(type='select_item', items=items) return 'pause' @@ -5852,7 +5892,7 @@ def _make_menu_fetch_handler(server, resp): if info: kind = 'get_inkey' if cmd_type == 0x22 else 'get_input' pending = dict(info, type=kind, cmd_num=cmd_num, - cmd_type=cmd_type, cmd_qual=cmd_qual, + cmd_type=cmd_type, cmd_qual=cmd_qual, cd_tag=cd_tag, dev_src=dev_src, dev_dst=dev_dst) dur = _find_duration(raw) pending['duration_unit'] = dur[0] if dur else None @@ -5906,7 +5946,7 @@ def _input_text_tlv(pd, text): SMS default alphabet unpacked (DCS '04'), as UCS2 ('08') when the request asked for it, or packed ('00') for a packed GET INPUT.""" if text == '': - return bytes([0x8D, 0x00]) # null text string (empty input) + return bytes([0x0D, 0x00]) # null text string (empty input) if pd.get('yes_no'): body = bytes([0x04, 0x01 if text == '01' else 0x00]) elif pd.get('ucs2'): @@ -5915,7 +5955,7 @@ def _input_text_tlv(pd, text): body = bytes([0x00]) + _pack_gsm7(text.encode('gsm03.38')) else: body = bytes([0x04]) + text.encode('gsm03.38') - return bytes([0x8D, len(body)]) + body + return bytes([0x0D, len(body)]) + body def _menu_send_response(server, result, item_id=None, text=None): @@ -5949,16 +5989,16 @@ def _menu_send_response(server, result, item_id=None, text=None): 0x02: 10.0}.get(pd['duration_unit'], 1.0))) value = max(1, min(255, value)) duration_tlv = bytes([0x04, 0x02, pd['duration_unit'], value]) - cd = bytes([0x81, 0x03, pd['cmd_num'], pd['cmd_type'], + cd = bytes([(pd.get('cd_tag') or 0x81), 0x03, pd['cmd_num'], pd['cmd_type'], (pd.get('cmd_qual') or 0) & 0xFF]) - di = bytes([0x82, 0x02, pd['dev_dst'], pd['dev_src']]) + di = bytes([0x02, 0x02, pd['dev_dst'], pd['dev_src']]) # TS 102 223 6.8.0 object order: Result (C), Duration (D), Text string (E), # Item identifier (F). tr_data = cd + di tr_data += bytes([0x83, 0x02, gr, 0x00]) tr_data += duration_tlv + text_tlv if isinstance(item_id, int) and result == 'ok' and pd['type'] == 'select_item': - tr_data += bytes([0x90, 0x01, item_id]) + tr_data += bytes([0x10, 0x01, item_id]) tr_hex = '%s140000%02x%s' % (scc.cat_cla, len(tr_data), tr_data.hex()) tr_rv = scc._tp.send_apdu(tr_hex) sys.stderr.write('TR(menu): cmd=%02x type=%02x result=%02x -> %s\n' % (pd['cmd_num'], pd['cmd_type'], gr, tr_rv[1])) @@ -6552,7 +6592,7 @@ def _drain_require_label(req): return name + ((' q=%s' % q['value']) if q else '') -def _test_terminal_response(cmd_num, cmd_type, dev_dst, dev_src, cmd_qual, respond): +def _test_terminal_response(cmd_num, cmd_type, dev_dst, dev_src, cmd_qual, respond, cd_tag=None): """The runner's TERMINAL RESPONSE: the scripted answer plus the terminal's own command-specific object(s) - the same objects the terminal's answers carry (`_build_tr`), unless the script supplied `raw` TLVs, which win. @@ -6569,7 +6609,8 @@ def _test_terminal_response(cmd_num, cmd_type, dev_dst, dev_src, cmd_qual, respo elif cmd_type == 0x03: answer = '840201%02X' % _POLL_INTERVAL return testscript.build_tr(cmd_num, cmd_type, dev_dst, dev_src, respond, - answer_tlvs=answer or None, cmd_qual=cmd_qual) + answer_tlvs=answer or None, cmd_qual=cmd_qual, + cd_tag=cd_tag) def _test_run_drain(server, step): @@ -6598,7 +6639,7 @@ def _test_run_drain(server, step): break raw = bytes.fromhex(fdata) _test_log('FETCH=%s -> SW=%s %s' % (fetch_apdu, fetch_sw, raw.hex().upper())) - cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw) + cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, cd_tag = _parse_proactive_header(raw) name = PROACTIVE_TYPE_NAMES.get(cmd_type, 'UNKNOWN') _test_log('CMD 0x%02X %s qual=%s' % ( cmd_type, name, ('%02X' % cmd_qual) if cmd_qual is not None else '-')) @@ -6609,7 +6650,7 @@ def _test_run_drain(server, step): server.sim_menu = menu respond = p['first'] if (not consumed and p.get('first')) else p['respond'] tr = _test_terminal_response(cmd_num, cmd_type, dev_dst, dev_src, - cmd_qual, respond) + cmd_qual, respond, cd_tag=cd_tag) tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr), tr.hex())) _test_log('TR=%s -> SW=%s' % (tr.hex().upper(), tr_rv[1])) consumed.append({'type_hex': '%02X' % cmd_type, 'type_name': name, @@ -7035,7 +7076,7 @@ def _test_run_expect(server, step, pending, ctx=None): 'returned no data')]}, None raw = bytes.fromhex(fdata) _test_log('FETCH=%s -> SW=%s %s' % (fetch_apdu, fetch_sw, raw.hex().upper())) - cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw) + cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, cd_tag = _parse_proactive_header(raw) log_entry = _log_proactive(cmd_type, raw, cmd_qual, cmd_num) type_name = PROACTIVE_TYPE_NAMES.get(cmd_type, 'UNKNOWN') _test_log('CMD 0x%02X %s qual=%s' % ( @@ -7164,7 +7205,7 @@ def _test_run_expect(server, step, pending, ctx=None): 'Files', sorted(paths) == sorted(want), ', '.join(want), ', '.join(paths) or '(none)', c['on_fail'])) tr = _test_terminal_response(cmd_num, cmd_type, dev_dst, dev_src, cmd_qual, - step['respond']) + step['respond'], cd_tag=cd_tag) tr_rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr), tr.hex())) tr_sw = tr_rv[1] _test_log('TR=%s -> SW=%s' % (tr.hex().upper(), tr_sw)) @@ -7202,9 +7243,9 @@ def _test_drain_pending(scc, fetch_len, result=0x00, max_commands=3): if not fdata: break raw = bytes.fromhex(fdata) - cmd_num, cmd_type, dev_src, dev_dst, cmd_qual = _parse_proactive_header(raw) + cmd_num, cmd_type, dev_src, dev_dst, cmd_qual, cd_tag = _parse_proactive_header(raw) tr = _test_terminal_response(cmd_num, cmd_type, dev_dst, dev_src, - cmd_qual, {'result': result}) + cmd_qual, {'result': result}, cd_tag=cd_tag) rv = scc._tp.send_apdu('%s140000%02x%s' % (scc.cat_cla, len(tr), tr.hex())) name = PROACTIVE_TYPE_NAMES.get(cmd_type, '?') answered.append({'name': name, 'type': cmd_type, diff --git a/pysim_simple_server/testscript.py b/pysim_simple_server/testscript.py index f736b78..84f456b 100644 --- a/pysim_simple_server/testscript.py +++ b/pysim_simple_server/testscript.py @@ -701,7 +701,7 @@ def _encode_text(text, dcs): def build_tr(cmd_num, cmd_type, dev_dst, dev_src, respond, answer_tlvs=None, - cmd_qual=None): + cmd_qual=None, cd_tag=None): """Flat COMPREHENSION-TLV TERMINAL RESPONSE payload (TS 102 223 6.8) in the 6.8.0 object order: command details, device identities, Result, then the command-specific objects (the terminal's own `answer_tlvs` - the PLI data @@ -709,11 +709,12 @@ def build_tr(cmd_num, cmd_type, dev_dst, dev_src, respond, answer_tlvs=None, Text string for GET INKEY/GET INPUT, the Item identifier for SELECT ITEM) and any extra raw TLVs. Matches the interactive menu TR layout; the runner passes the terminal objects the script did not override. The - command details echo the command's qualifier (TS 102 223 6.8.1: the - object shall be identical to the command's).""" - out = bytearray([0x81, 0x03, cmd_num & 0xFF, cmd_type & 0xFF, + command details echo the command's tag form and qualifier (TS 102 223 + 6.8.1: the object shall be identical to the command's); the response + objects use the base (CR=0) forms per 6.8.0 (Min = N) - v3.25.2.""" + out = bytearray([(cd_tag or 0x81), 0x03, cmd_num & 0xFF, cmd_type & 0xFF, (cmd_qual or 0) & 0xFF]) - out += bytes([0x82, 0x02, dev_dst & 0xFF, dev_src & 0xFF]) + out += bytes([0x02, 0x02, dev_dst & 0xFF, dev_src & 0xFF]) result = int(respond.get('result', 0)) add_info = int(respond.get('additional_info', 0) or 0) out += bytes([0x83, 0x02, result & 0xFF, add_info & 0xFF]) @@ -723,13 +724,13 @@ def build_tr(cmd_num, cmd_type, dev_dst, dev_src, respond, answer_tlvs=None, text = str(respond['text']) if text == '': # TS 102 223 8.15: a null text string is Length 00, no value part - out += bytes([0x8D, 0x00]) + out += bytes([0x0D, 0x00]) else: dcs = int(respond.get('dcs', 0x00)) body = _encode_text(text, dcs) - out += bytes([0x8D, len(body) + 1, dcs]) + body + out += bytes([0x0D, len(body) + 1, dcs]) + body if respond.get('item_id') is not None and result == 0x00: - out += bytes([0x90, 0x01, respond['item_id'] & 0xFF]) + out += bytes([0x10, 0x01, respond['item_id'] & 0xFF]) if respond.get('raw'): out += bytes.fromhex(respond['raw']) return bytes(out) diff --git a/tests/test_fetch_routes.py b/tests/test_fetch_routes.py index 863f5b0..3625877 100644 --- a/tests/test_fetch_routes.py +++ b/tests/test_fetch_routes.py @@ -145,15 +145,15 @@ class PliDictHttpTests(unittest.TestCase): try: status, resp = self._req('GET', '/api/pli-dict') self.assertEqual(status, 200, resp) - self.assertEqual(resp['00'], '930500F1100000') + self.assertEqual(resp['00'], '130500F1100000') self.assertEqual(resp['0A'], '630104') self.assertEqual(resp['03'], '') # the host clock answers it self.assertEqual(resp['02'], '') # no default status, resp = self._req('POST', '/api/pli-dict', - {'01': '94080000000000000000'}) + {'01': '14080000000000000000'}) self.assertEqual(status, 200, resp) - self.assertEqual(resp['01'], '94080000000000000000') - self.assertEqual(srv._PLI_DATA.get(0x01), '94080000000000000000') + self.assertEqual(resp['01'], '14080000000000000000') + self.assertEqual(srv._PLI_DATA.get(0x01), '14080000000000000000') # clearing the field restores the default status, resp = self._req('POST', '/api/pli-dict', {'01': ''}) self.assertEqual(status, 200, resp) diff --git a/tests/test_httpota.py b/tests/test_httpota.py index eaa8ceb..2a88296 100644 --- a/tests/test_httpota.py +++ b/tests/test_httpota.py @@ -30,6 +30,9 @@ OPEN_LOCALHOST = bytes.fromhex( TR_OPEN_OK = '0103014001020282810301003802810035010339020200' TR_OPEN_FAIL = '01030140010202828103023a0035010339020200' +# the same vector as sent by the handler: the command details echo the +# command's tag form (the test raws use 81/82, TS 102 223 6.8.1) +TR_OPEN_OK_ECHO = '8103014001020282810301003802810035010339020200' class PeerServer(threading.Thread): @@ -314,13 +317,13 @@ class BipCommandFlowTest(unittest.TestCase): def test_open_send_receive_close_flow(self): tr = server._handle_bip_command(None, 1, 0x40, 0x01, open_cmd('127.0.0.1', self.peer.port), 0x81, 0x82) - self.assertEqual(tr.hex(), TR_OPEN_OK) + self.assertEqual(tr.hex(), TR_OPEN_OK_ECHO) tr = server._handle_bip_command(None, 1, 0x43, 0x01, channel_cmd(0x43, 0x01, bytes([0x36, 0x08]) + b'CLIENTHE'), 0x81, 0x21) tlvs = parse_tr(tr) - self.assertEqual(tlvs[0x01].hex(), '014301') + self.assertEqual(tlvs[0x81].hex(), '014301') self.assertEqual(tlvs[0x02].hex(), '8281') self.assertEqual(tlvs[0x03], b'\x00') self.assertEqual(tlvs[0x37], b'\xff') @@ -337,7 +340,7 @@ class BipCommandFlowTest(unittest.TestCase): self.assertEqual(tlvs[0x37], b'\x00') tr = server._handle_bip_command(None, 1, 0x41, 0x00, channel_cmd(0x41, 0x00), 0x81, 0x21) - self.assertEqual(tr.hex(), '010301410002028281030100') + self.assertEqual(tr.hex(), '810301410002028281030100') self.peer.done.wait(3) self.assertEqual(self.peer.received, b'CLIENTHE') diff --git a/tests/test_menu_timeout.py b/tests/test_menu_timeout.py index 4dce785..3b36a5d 100644 --- a/tests/test_menu_timeout.py +++ b/tests/test_menu_timeout.py @@ -76,7 +76,7 @@ class TestMenuSendResponse(unittest.TestCase): tr = sent[0] self.assertTrue(tr.startswith('801400000d'), tr) self.assertIn('8103012100', tr) - self.assertIn('82028381', tr) + self.assertIn('02028381', tr) self.assertIn('83021200', tr) def test_no_pending_returns_400(self): diff --git a/tests/test_ota_helpers.py b/tests/test_ota_helpers.py index 351698b..4b2a243 100644 --- a/tests/test_ota_helpers.py +++ b/tests/test_ota_helpers.py @@ -802,7 +802,7 @@ class TestProactiveDecode(unittest.TestCase): def test_parse_proactive_header_plain_tags(self): import pysim_simple_server.server as srv raw = bytes.fromhex('d00c010301270102028182240103') - self.assertEqual(srv._parse_proactive_header(raw), (1, 0x27, 0x81, 0x82, 0x01)) + self.assertEqual(srv._parse_proactive_header(raw), (1, 0x27, 0x81, 0x82, 0x01, 0x01)) def test_default_handler_logs_timer_management(self): # pySim's auto-handler path: the parsed command object (not the empty @@ -861,7 +861,23 @@ class TestProactiveDecode(unittest.TestCase): # 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') + self.assertEqual(tr.hex(), '81030126000202828103010093055210011000') + + def test_response_objects_use_the_base_cr0_forms(self): + # TS 102 223 6.8.0: the TR's response objects are Min = N ("should + # set the CR flag to comprehension not required"); 6.8.1: the + # command details echo the command's tag form (v3.25.2) + from pysim_simple_server import testscript as T + tr = _build_tr(None, 1, 0x03, 0x81, 0x82, None, cd_tag=0x01) + self.assertTrue(tr.hex().startswith('010301030002028281'), tr.hex()) + self.assertIn('040201', tr.hex()) # POLL Duration base tag + self.assertTrue(_build_tr(None, 1, 0x26, 0x81, 0x82, 0x00, cd_tag=0x01) + .hex().startswith('010301260002028281')) + self.assertTrue(T.build_tr(1, 0x26, 0x82, 0x81, {}, cd_tag=0x01) + .hex().startswith('010301260002028281')) + # no tag known: the previous CR-set default keeps the old bytes + self.assertTrue(_build_tr(None, 1, 0x26, 0x81, 0x82, 0x00) + .hex().startswith('810301260002028281')) def test_build_tr_echoes_the_command_qualifier(self): # TS 102 223 6.8.1: the TR's command details shall be identical to @@ -881,7 +897,7 @@ class TestProactiveDecode(unittest.TestCase): 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) + '810301030002028281030100040201%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. @@ -900,10 +916,10 @@ class TestProactiveDecode(unittest.TestCase): srv._PLI_DATA[0x03] = saved # the command details echo the qualifier (TS 102 223 6.8.1) self.assertEqual(tr_east.hex(), - '810301260382028281030100260762900312550021') + '810301260302028281030100260762900312550021') # negative zone: the sign bit of the time zone byte (0x08) self.assertEqual(tr_west.hex(), - '810301260382028281030100260762105080709009') + '810301260302028281030100260762105080709009') def test_build_tr_pli_dict_entry_overrides_host_clock(self): import pysim_simple_server.server as srv @@ -915,7 +931,7 @@ class TestProactiveDecode(unittest.TestCase): datetime(2026, 9, 30, 21, 55, tzinfo=timezone.utc)) finally: srv._PLI_DATA[0x03] = saved - self.assertEqual(tr.hex(), '810301260382028281030100130752f01000ff0001') + self.assertEqual(tr.hex(), '810301260302028281030100130752f01000ff0001') def test_build_tr_pli_empty_other_qualifier_sends_no_data(self): # a qualifier with neither an override nor a built-in default (02 @@ -928,7 +944,7 @@ class TestProactiveDecode(unittest.TestCase): data = srv._pli_data_hex(0x02) finally: srv._PLI_DATA[0x02] = saved - self.assertEqual(tr.hex(), '810301260282028281030100') + self.assertEqual(tr.hex(), '810301260202028281030100') self.assertEqual(data, '') def test_pli_defaults_cover_the_common_qualifiers(self): @@ -936,15 +952,15 @@ class TestProactiveDecode(unittest.TestCase): # define (TS 102 223 8.x, tags per TS 101 220 V18.3.0 Table 7.23) import pysim_simple_server.server as srv d = srv.PLI_DEFAULTS - self.assertEqual(d[0x00], '930500F1100000') # 001/01 + LAC 0000 - self.assertEqual(d[0x01], '940894104502237315F8') # IMEI, F pad - self.assertEqual(d[0x04], 'AD02656E') # 'en' - self.assertEqual(d[0x05], 'AE020000') # Timing Advance - self.assertEqual(d[0x06], 'BF0108') # E-UTRAN - self.assertEqual(d[0x08], 'E2089410450223731580') # IMEISV - self.assertEqual(d[0x09], 'E50101') # automatic + self.assertEqual(d[0x00], '130500F1100000') # 001/01 + LAC 0000 + self.assertEqual(d[0x01], '140894104502237315F8') # IMEI, F pad + self.assertEqual(d[0x04], '2D02656E') # 'en' + self.assertEqual(d[0x05], '2E020000') # Timing Advance + self.assertEqual(d[0x06], '3F0108') # E-UTRAN + self.assertEqual(d[0x08], '62089410450223731580') # IMEISV + self.assertEqual(d[0x09], '650101') # automatic self.assertEqual(d[0x0A], '630104') # battery full - self.assertEqual(d[0x0E], 'BF020308') # UTRAN + E-UTRAN + self.assertEqual(d[0x0E], '3F020308') # UTRAN + E-UTRAN # the date/time stays dynamic; the 3GPP/3GPP2 qualifiers stay empty self.assertNotIn(0x03, d) for q in (0x02, 0x07, 0x0B, 0x0C, 0x0D, 0x0F, 0x10, 0x11, 0x12, @@ -1208,7 +1224,7 @@ class TestTimerManagement(unittest.TestCase): def test_start_returns_result_only_and_arms_timer(self): import pysim_simple_server.server as srv tr = srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82) - self.assertEqual(tr.hex(), '810301270082028281030100') + self.assertEqual(tr.hex(), '810301270002028281030100') remaining = srv._timer_remaining(1) self.assertTrue(55 <= remaining <= 60, remaining) @@ -1216,20 +1232,20 @@ class TestTimerManagement(unittest.TestCase): import pysim_simple_server.server as srv srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82) tr = srv._handle_timer_command(1, 0x27, 0x02, self.START, 0x81, 0x82) - self.assertEqual(tr.hex(), '810301270282028281a40101a503001000030100') + self.assertEqual(tr.hex(), '8103012702020282812401012503001000030100') def test_deactivate_stops_and_reports_value(self): import pysim_simple_server.server as srv srv._handle_timer_command(1, 0x27, 0x00, self.START, 0x81, 0x82) tr = srv._handle_timer_command(1, 0x27, 0x01, self.START, 0x81, 0x82) self.assertTrue(tr.hex().startswith('8103012701'), tr.hex()) - self.assertIn('a40101a503001000', tr.hex()) + self.assertIn('2401012503001000', tr.hex()) self.assertIsNone(srv._timer_remaining(1)) def test_get_on_stopped_timer_is_contradiction(self): import pysim_simple_server.server as srv tr = srv._handle_timer_command(1, 0x27, 0x02, self.START, 0x81, 0x82) - self.assertEqual(tr.hex(), '810301270282028281030124') + self.assertEqual(tr.hex(), '810301270202028281030124') def test_timer_expiration_envelope(self): import pysim_simple_server.server as srv @@ -2130,7 +2146,7 @@ class ProactiveLongTlvTests(unittest.TestCase): TPDU + bytes(range(len(TPDU), 150))) def test_header_parses_with_a_long_form_tlv(self): - self.assertEqual(_parse_proactive_header(self.RAW), (1, 0x13, 0x81, 0x83, 0)) + self.assertEqual(_parse_proactive_header(self.RAW), (1, 0x13, 0x81, 0x83, 0, 0x81)) # the long TLV is found as one TLV, not mis-walked into its value self.assertEqual(sorted(_tlv_map(self.RAW[3:]).keys()), [0x81, 0x82, 0x86, 0x8B]) self.assertEqual(len(_find_sms_tpdu(self.RAW)), 150 * 2) @@ -2138,7 +2154,7 @@ class ProactiveLongTlvTests(unittest.TestCase): def test_a_truncated_tlv_does_not_raise(self): # a declared length running past the data ends the walk raw = bytes.fromhex('d004810381') - self.assertEqual(_parse_proactive_header(raw), (1, 0, 0x83, 0x81, None)) + self.assertEqual(_parse_proactive_header(raw), (1, 0, 0x83, 0x81, None, None)) self.assertEqual(_tlv_map(bytes.fromhex('810381')), {}) # display text with a long-form text TLV (144 bytes -> 8D 81 90) text = bytes([0x8D, 0x81, 0x90]) + b'\x00' * 0x90 @@ -2385,19 +2401,19 @@ class StkInputTests(unittest.TestCase): def test_input_text_tlv_codings(self): pd = {'type': 'get_input', 'digits_only': True, 'hidden': True} # digits: SMS default alphabet unpacked (DCS 04), one byte per char - self.assertEqual(_input_text_tlv(pd, '1234').hex().upper(), '8D050431323334') + self.assertEqual(_input_text_tlv(pd, '1234').hex().upper(), '0D050431323334') # empty input: the null text string (6.8.5) - self.assertEqual(_input_text_tlv(pd, '').hex().upper(), '8D00') + self.assertEqual(_input_text_tlv(pd, '').hex().upper(), '0D00') # UCS2 request: DCS 08 self.assertEqual(_input_text_tlv(dict(pd, ucs2=True, hidden=False), '12').hex().upper(), - '8D050800310032') + '0D050800310032') # packed request: DCS 00 + TS 23.038 septet packing self.assertEqual(_input_text_tlv(dict(pd, packed=True, hidden=False), '1234').hex().upper(), - '8D050031D98C06') + '0D050031D98C06') # GET INKEY Yes/No: value 01 positive / 00 negative (6.8.5) yesno = {'type': 'get_inkey', 'yes_no': True} - self.assertEqual(_input_text_tlv(yesno, '01').hex().upper(), '8D020401') - self.assertEqual(_input_text_tlv(yesno, '00').hex().upper(), '8D020400') + self.assertEqual(_input_text_tlv(yesno, '01').hex().upper(), '0D020401') + self.assertEqual(_input_text_tlv(yesno, '00').hex().upper(), '0D020400') def test_pack_gsm7_matches_the_sms_packing(self): self.assertEqual(_pack_gsm7(b'1234').hex().upper(), '31D98C06') @@ -2452,7 +2468,7 @@ class StkInputTests(unittest.TestCase): resp, code = _menu_send_response(server, 'ok', None, '1234') self.assertEqual(code, 200, resp) apdu = server.scc._tp.sent[0].upper() - self.assertIn('8D050431323334', apdu) # the entered digits + self.assertIn('0D050431323334', apdu) # the entered digits self.assertIn('83020000', apdu) # successful result self.assertIsNone(server.stk_pending) # a rejected value sends nothing and keeps the command pending diff --git a/tests/test_tag_variants.py b/tests/test_tag_variants.py index 2b0a7e6..14fba82 100644 --- a/tests/test_tag_variants.py +++ b/tests/test_tag_variants.py @@ -94,8 +94,15 @@ class TagVariantMatrixTests(unittest.TestCase): with self.subTest(label=label): plain = parser(raw) flipped = parser(flip_tags(raw)) - self.assertEqual(plain, flipped, - '%s differs between the tag styles' % label) + if isinstance(plain, tuple) and len(plain) == 6: + # the header parser also returns the command-details tag, + # which follows the style (the TR echoes it, v3.25.2) - + # the parsed values stay identical + self.assertEqual(plain[:5], flipped[:5], + '%s differs between the tag styles' % label) + else: + self.assertEqual(plain, flipped, + '%s differs between the tag styles' % label) self.assertNotEqual(raw, flip_tags(raw), 'fixture has no TLVs?') def test_response_scripting_accepts_both_rapdu_tags(self): diff --git a/tests/test_testscript.py b/tests/test_testscript.py index e9a2664..e877914 100644 --- a/tests/test_testscript.py +++ b/tests/test_testscript.py @@ -163,21 +163,21 @@ class TestValidation(unittest.TestCase): # then the command-specific objects (here Text), then the raw extras. tr = T.build_tr(4, 0x23, 0x81, 0x82, respond) self.assertEqual(tr.hex().upper(), - '8103042300' + '82028182' + '83020000' - + '8D0600' + 'E8329BFD06' + 'AA01BB') + '8103042300' + '02028182' + '83020000' + + '0D0600' + 'E8329BFD06' + 'AA01BB') # 8 bits: one octet per character tr = T.build_tr(4, 0x23, 0x81, 0x82, {'result': 0x00, 'text': 'hello', 'dcs': 0x04}) self.assertEqual(tr.hex().upper(), - '8103042300' + '82028182' + '83020000' + '8D0604' + '68656C6C6F') + '8103042300' + '02028182' + '83020000' + '0D0604' + '68656C6C6F') # UCS2 (UTF-16-BE): 'A' U+0041, 'Ж' U+0416 tr = T.build_tr(4, 0x23, 0x81, 0x82, {'result': 0x00, 'text': 'A\u0416', 'dcs': 0x08}) self.assertEqual(tr.hex().upper(), - '8103042300' + '82028182' + '83020000' + '8D0508' + '00410416') + '8103042300' + '02028182' + '83020000' + '0D0508' + '00410416') # an empty answer is the null text string (Length 00, no DCS) tr = T.build_tr(4, 0x23, 0x81, 0x82, {'result': 0x00, 'text': ''}) self.assertEqual(tr.hex().upper(), - '8103042300' + '82028182' + '83020000' + '8D00') + '8103042300' + '02028182' + '83020000' + '0D00') def test_a_text_the_dcs_cannot_code_is_refused_at_load(self): # v3.22.8: the load-time check uses the same coding the TERMINAL @@ -427,15 +427,15 @@ class TestBuildTr(unittest.TestCase): # TS 102 223 6.8.0: the item identifier (F) follows the Result (C) tr = T.build_tr(3, 0x24, 0x82, 0x81, {'result': 0x00, 'item_id': 7}) self.assertEqual(tr.hex().upper(), - '81030324008202828183020000900107') + '81030324000202828183020000100107') def test_cancel_response_has_no_item_identifier(self): tr = T.build_tr(3, 0x24, 0x82, 0x81, {'result': 0x10, 'item_id': 7}) - self.assertEqual(tr.hex().upper(), '81030324008202828183021000') + self.assertEqual(tr.hex().upper(), '81030324000202828183021000') def test_display_text_result_only(self): tr = T.build_tr(2, 0x21, 0x82, 0x81, {'result': 0x00}) - self.assertEqual(tr.hex().upper(), '81030221008202828183020000') + self.assertEqual(tr.hex().upper(), '81030221000202828183020000') def test_the_terminal_answer_objects_follow_the_result(self): # The runner passes the terminal's own objects (the PLI data, the @@ -444,18 +444,18 @@ class TestBuildTr(unittest.TestCase): tr = T.build_tr(1, 0x26, 0x82, 0x81, {'result': 0x00}, answer_tlvs='2607AABBCCDDEEFF') self.assertEqual(tr.hex().upper(), - '810301260082028281830200002607AABBCCDDEEFF') + '810301260002028281830200002607AABBCCDDEEFF') # with a text object the answer object keeps its place tr = T.build_tr(1, 0x23, 0x82, 0x81, {'result': 0x00, 'text': 'hi', 'dcs': 0x04}, answer_tlvs='8402011E') self.assertEqual(tr.hex().upper(), - '810301230082028281830200008402011E8D03046869') + '810301230002028281830200008402011E0D03046869') # and the script's raw TLVs stay last tr = T.build_tr(1, 0x26, 0x82, 0x81, {'result': 0x00, 'raw': '9B0100'}, answer_tlvs='2607AA') self.assertEqual(tr.hex().upper(), - '810301260082028281830200002607AA9B0100') + '810301260002028281830200002607AA9B0100') # ─── runner with a fake card ───────────────────────────────────────────── @@ -548,7 +548,7 @@ class TestRunnerDialogue(RunnerTestCase): self.assertEqual(run['status'], 'ok', run['steps']) self.assertEqual([s['status'] for s in run['steps']], ['ok', 'ok', 'ok']) # the SELECT ITEM TR carries the item identifier - self.assertIn('900101', run['steps'][1]['sent']) + self.assertIn('100101', run['steps'][1]['sent']) # the DISPLAY TEXT check decoded the text text_check = [c for c in run['steps'][2]['checks'] if c['label'] == 'Text'][0] self.assertEqual(text_check['actual'], 'hello')