feat: card-driven STATUS polling and the Poll Interval Negotiation event (v3.5.9)

- EVENT_NAMES (server + PWA) corrected against TS 102 223 v18.3.0 8.25:
  0x14 is "Access technology change (multiple)" (not Change of UICC
  Access), 0x19 Profile container, 0x1A Void, 0x1B Secured profile
  container, 0x1C Poll interval negotiation, 0x20-0x22 reserved.  Values
  the CAT spec leaves "Reserved for 3GPP" now carry the concrete TS 31.111
  event name + clause (0x11 (I-)WLAN access status, 0x12 Network
  rejection, 0x15 CSG cell selection, 0x17 IMS registration, 0x18 Incoming
  IMS data, 0x1D Data connection status change, 0x1E CAG cell selection,
  0x1F Slices status change).
- Poll Interval Negotiation (0x1C) in the Phone tab: the form proposes a
  Duration (unit + interval) and the UICC's answer (TS 102 223 8.97
  accepted / rejected / modified + optional Duration) is decoded and
  shown; a "modified" duration becomes the background poll interval.  The
  form appears when the card subscribed to the event, like every other.
- Polling emulation is card-driven: a POLL INTERVAL adopts its Duration
  (minutes/seconds/tenths -> 1..255 s, logged) for the background poll,
  is echoed in the TERMINAL RESPONSE (6.8.4) and clears a POLLING OFF
  suspension; POLLING OFF (6.4.14) suspends proactive polling until a new
  POLL INTERVAL - the manual Send STATUS button and presence detection are
  unaffected.  /api/poll-status and /api/poll-toggle report card_disabled
  (+ a warning when enabling while suspended); the PWA shows it in amber.
- Tests: tests/test_poll.py +7, test_proactive_names.py +3, frontend
  event_forms +2 and poll_ui (4).  Help EN/RU, docs/api.md, AGENTS.

573 frontend / 439 Python green; version 3.5.9; sw cache simple-v262.
This commit is contained in:
2026-09-26 12:56:18 +03:00
parent d9e6c6c9dd
commit 25f001a778
11 changed files with 455 additions and 47 deletions
+85
View File
@@ -51,5 +51,90 @@ class TestPollInterval(unittest.TestCase):
self.assertIsNone(S._POLL_TIMER)
POLL_CMD = 'D00D810301030082028182' # Command Details + Device Identities
MIN_2_TLV = POLL_CMD + '04020002' # Duration: 2 minutes (plain tag)
SEC_30_TLV = POLL_CMD + '8402011E' # Duration: 30 seconds (CR tag)
class TestCardDrivenPolling(unittest.TestCase):
"""TS 102 223 6.4.6/6.4.14: adopt the card's POLL INTERVAL duration and
honour POLLING OFF; TS 102 223 7.5.22/8.97: act on a negotiation answer."""
def setUp(self):
self.saved = (S._POLL_ENABLED, S._POLL_INTERVAL, S._POLL_TIMER,
S._POLL_DISABLED_BY_CARD)
def tearDown(self):
S._poll_disable()
(S._POLL_ENABLED, S._POLL_INTERVAL, S._POLL_TIMER,
S._POLL_DISABLED_BY_CARD) = self.saved
def test_duration_units_convert_to_seconds(self):
self.assertEqual(S._duration_seconds(0x01, 30), 30)
self.assertEqual(S._duration_seconds(0x00, 2), 120)
self.assertEqual(S._duration_seconds(0x02, 5), 1) # tenths, min 1 s
self.assertEqual(S._duration_seconds(0x00, 200), 255) # clamped
self.assertEqual(S._duration_text(0x00, 2), '2 min')
self.assertEqual(S._duration_text(0x01, 30), '30 s')
def test_find_duration_accepts_both_tag_styles(self):
self.assertEqual(S._find_duration(bytes.fromhex(SEC_30_TLV)), (1, 30))
self.assertEqual(S._find_duration(bytes.fromhex(MIN_2_TLV)), (0, 2))
self.assertIsNone(S._find_duration(bytes.fromhex(POLL_CMD)))
def test_poll_interval_command_is_adopted(self):
S._set_poll_interval(30)
S._handle_card_poll_command(0x03, bytes.fromhex(MIN_2_TLV))
self.assertEqual(S._POLL_INTERVAL, 120)
self.assertFalse(S._POLL_DISABLED_BY_CARD)
def test_polling_off_suspends_until_a_new_interval(self):
S._POLL_ENABLED = True
S._set_poll_interval(30)
S._handle_card_poll_command(0x04, b'')
self.assertTrue(S._POLL_DISABLED_BY_CARD)
with mock.patch.object(S.threading, 'Timer') as timer:
S._reset_poll_timer()
timer.assert_not_called()
with mock.patch.object(S.threading, 'Timer') as timer:
S._handle_card_poll_command(0x03, bytes.fromhex(SEC_30_TLV))
timer.assert_called_once_with(30, S._do_status_poll)
self.assertFalse(S._POLL_DISABLED_BY_CARD)
def test_status_poll_does_not_run_when_card_disabled(self):
S._POLL_ENABLED = True
S._POLL_DISABLED_BY_CARD = True
with mock.patch.object(S, '_send_status') as send, \
mock.patch.object(S.threading, 'Timer') as timer:
S._do_status_poll()
send.assert_not_called()
timer.assert_not_called()
def test_negotiation_decode_and_modified_apply(self):
neg = S._decode_poll_negotiation('1101020402011E') # modified, 30 s
self.assertEqual(neg['result_name'], 'modified')
self.assertEqual(neg['seconds'], 30)
self.assertEqual(S._decode_poll_negotiation('')['result_name'], 'accepted')
self.assertEqual(S._decode_poll_negotiation('110100')['result_name'], 'accepted')
self.assertEqual(S._decode_poll_negotiation('11010104020102')['result_name'], 'rejected')
self.assertIsNone(S._decode_poll_negotiation('1102')) # truncated result
# a 'modified' answer becomes the new poll interval
S._set_poll_interval(50)
S._POLL_ENABLED = True
with mock.patch.object(S.threading, 'Timer') as timer:
S._apply_poll_negotiation(neg)
timer.assert_called_once_with(30, S._do_status_poll)
self.assertEqual(S._POLL_INTERVAL, 30)
# a 'rejected' answer leaves the current interval alone
S._apply_poll_negotiation(S._decode_poll_negotiation('11010104020102'))
self.assertEqual(S._POLL_INTERVAL, 30)
def test_poll_interval_decoded_in_the_proactive_log(self):
fields = S._decode_cmd(0x03, bytes.fromhex(MIN_2_TLV), 0)
self.assertEqual(fields[0]['value'], '2 min (120 s)')
fields = S._decode_cmd(0x03, bytes.fromhex(SEC_30_TLV), 0)
self.assertEqual(fields[0]['value'], '30 s')
if __name__ == '__main__':
unittest.main()
+39 -1
View File
@@ -7,7 +7,7 @@ missing entry makes the proactive log show the generic 'Cmd 0x01' fallback.
import unittest
from pysim_simple_server.server import PROACTIVE_TYPE_NAMES
from pysim_simple_server.server import EVENT_NAMES, PROACTIVE_TYPE_NAMES
class ProactiveTypeNamesTests(unittest.TestCase):
@@ -34,5 +34,43 @@ class ProactiveTypeNamesTests(unittest.TestCase):
self.assertTrue(isinstance(name, str) and name, hex(value))
class EventNamesTests(unittest.TestCase):
"""Event list names follow TS 102 223 v18.3.0 8.25; the values the CAT
spec leaves "Reserved for 3GPP" carry the TS 31.111 event name."""
def test_reassigned_values_match_the_pinned_spec(self):
for value, name in [
(0x0B, 'Access technology change (single)'),
(0x14, 'Access technology change (multiple)'),
(0x19, 'Profile container'),
(0x1A, 'Void'),
(0x1B, 'Secured profile container'),
(0x1C, 'Poll interval negotiation'),
(0x20, 'Reserved for 3GPP (future usage)'),
]:
self.assertEqual(EVENT_NAMES[value], name, hex(value))
def test_3gpp_reserved_events_carry_their_3gpp_name(self):
for value, fragment, clause in [
(0x11, '(I-)WLAN access status', 'TS 31.111 7.5.1'),
(0x12, 'Network rejection', 'TS 31.111 7.5.2'),
(0x15, 'CSG cell selection', 'TS 31.111 7.5.3'),
(0x17, 'IMS registration', 'TS 31.111 7.5.21'),
(0x18, 'Incoming IMS data', 'TS 31.111 7.5.20'),
(0x1D, 'Data connection status change', 'TS 31.111 7.5.25'),
(0x1E, 'CAG cell selection', 'TS 31.111 7.5.26'),
(0x1F, 'Slices status change', 'TS 31.111 7.5.27'),
]:
name = EVENT_NAMES[value]
self.assertIn('Reserved for 3GPP', name, hex(value))
self.assertIn(fragment, name, hex(value))
self.assertIn(clause, name, hex(value))
def test_names_are_nonempty_strings(self):
for value, name in EVENT_NAMES.items():
self.assertIsInstance(value, int)
self.assertTrue(isinstance(name, str) and name, hex(value))
if __name__ == '__main__':
unittest.main()