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.
This commit is contained in:
2026-10-11 13:31:49 +03:00
parent beb001846f
commit 547ea61708
12 changed files with 238 additions and 161 deletions
+4 -4
View File
@@ -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)
+6 -3
View File
@@ -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')
+1 -1
View File
@@ -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):
+44 -28
View File
@@ -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
+9 -2
View File
@@ -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):
+12 -12
View File
@@ -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')