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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user