v3.18.0: the semantic event action for test scripts
The other-applet batch: a test script can send the common event downloads semantically instead of raw hex. - events.py: server-side mirrors of the PWA's event forms for Location status (0x03), Access technology change (0x0B), Network rejection (0x12 - the LA/RA/TA object selected by the registration group, the extended EMM cause and the extended information object) and Data connection status change (0x1D - status/type/TI plus the optional cause, host-clock 8.39 date-time, location information, access technology, APN and PDP/PDN/PDU type). Fields are validated before the run (a bad value is a script error, not a step failure); `src` overrides the device-identities source (default terminal, matching the Phone forms). - The engine gains the `event` action kind; the runner builds and sends it. The PWA's action-kind list stays in sync (drift-guarded - the guard itself caught its own regex bug on the first run). - Byte parity: the vectors are generated from the PWA's own builders and pinned in tests/test_events.py; the frontend suites assert the same values. PLMN and SCTS helpers are mirrored too. - The Phone tab's event form shows the built "Event data (hex)" (updated as the fields change), so every event without a builder can be used through the raw `envelope` action. - The test-step editor offers the event select + a JSON fields textarea; malformed JSON refuses the save with a clear message. - docs/test-scripts.md (the semantic-event table, an event example, the format stamp), docs/api.md and help EN/RU updated. Tests: 812 frontend + 717 Python; sw cache simple-v381.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
"""Event-data builders for the test scripts' semantic ``event`` action.
|
||||
|
||||
The PWA's Phone tab builds the same objects client-side (``EVENT_FORMS``);
|
||||
this module is the server-side mirror for the events a test script can send
|
||||
semantically. Every other event goes through the raw ``envelope`` action
|
||||
(the Phone form shows its built hex, which can be pasted into ``data``).
|
||||
|
||||
Field sets and defaults follow the PWA's forms and the pinned specs
|
||||
(TS 31.111 7.5.x, TS 102 223 8.x); the byte vectors are pinned in both the
|
||||
Node and the Python suites so the two implementations cannot drift apart.
|
||||
|
||||
Supported events:
|
||||
|
||||
* ``0x03`` Location status - status + optional Location information (8.19,
|
||||
normal service only).
|
||||
* ``0x0B`` Access technology change - the 8.61 single-access-technology byte.
|
||||
* ``0x12`` Network rejection - registration type, access technology, cause,
|
||||
the location area (LAI/RAI/TAI by registration group), the optional
|
||||
extended EMM cause and the extended information object.
|
||||
* ``0x1D`` Data connection status change - status/type/transaction id plus
|
||||
the optional cause, host-clock date-time (8.39), location information,
|
||||
access technology, location status, APN and PDP/PDN/PDU type.
|
||||
"""
|
||||
|
||||
import re
|
||||
import time
|
||||
|
||||
EVENT_LOCATION_STATUS = 0x03
|
||||
EVENT_ACCESS_TECH = 0x0B
|
||||
EVENT_NETWORK_REJECTION = 0x12
|
||||
EVENT_DATA_CONNECTION = 0x1D
|
||||
|
||||
# The event code from a script's `event` value (a hex byte, or one of these
|
||||
# names - the PWA's display names and short keywords).
|
||||
EVENT_NAMES = {
|
||||
'location status': EVENT_LOCATION_STATUS,
|
||||
'location_status': EVENT_LOCATION_STATUS,
|
||||
'location-status': EVENT_LOCATION_STATUS,
|
||||
'access technology change': EVENT_ACCESS_TECH,
|
||||
'access_tech': EVENT_ACCESS_TECH,
|
||||
'access-technology-change': EVENT_ACCESS_TECH,
|
||||
'network rejection': EVENT_NETWORK_REJECTION,
|
||||
'network_rejection': EVENT_NETWORK_REJECTION,
|
||||
'network-rejection': EVENT_NETWORK_REJECTION,
|
||||
'data connection status change': EVENT_DATA_CONNECTION,
|
||||
'data_connection': EVENT_DATA_CONNECTION,
|
||||
'data-connection-status-change': EVENT_DATA_CONNECTION,
|
||||
}
|
||||
|
||||
|
||||
class EventError(ValueError):
|
||||
"""Invalid event parameters - a user-facing message."""
|
||||
|
||||
|
||||
def resolve_event(value):
|
||||
"""The event code of a script's `event` value (a hex byte or a name), or
|
||||
None when it is neither."""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value if 0 <= value <= 0xFF else None
|
||||
s = str(value or '').strip()
|
||||
if not s:
|
||||
return None
|
||||
if re.fullmatch(r'(0x)?[0-9A-Fa-f]{1,2}', s):
|
||||
return int(s, 16)
|
||||
return EVENT_NAMES.get(s.lower())
|
||||
|
||||
|
||||
def event_name(code):
|
||||
"""A display name for an event code (the short keyword)."""
|
||||
for name, c in (('location_status', EVENT_LOCATION_STATUS),
|
||||
('access_tech', EVENT_ACCESS_TECH),
|
||||
('network_rejection', EVENT_NETWORK_REJECTION),
|
||||
('data_connection', EVENT_DATA_CONNECTION)):
|
||||
if c == code:
|
||||
return name
|
||||
return '0x%02X' % code
|
||||
|
||||
|
||||
# ─── field helpers ──────────────────────────────────────────────────────
|
||||
|
||||
_DIGITS_RE = re.compile(r'^\d+$')
|
||||
|
||||
|
||||
def _int(fields, key, default, lo, hi):
|
||||
v = fields.get(key)
|
||||
if v in (None, ''):
|
||||
return default
|
||||
text = str(v).strip()
|
||||
try:
|
||||
n = int(text, 16) if text.lower().startswith('0x') else int(text, 10)
|
||||
except ValueError:
|
||||
raise EventError('%s must be an integer' % key)
|
||||
if not lo <= n <= hi:
|
||||
raise EventError('%s must be %d..%d' % (key, lo, hi))
|
||||
return n
|
||||
|
||||
|
||||
def _hex(fields, key, default, lo_len, hi_len):
|
||||
v = re.sub(r'\s', '', str(fields.get(key) or default or '')).upper()
|
||||
if len(v) % 2 or not (lo_len <= len(v) <= hi_len) or \
|
||||
not re.fullmatch(r'[0-9A-F]+', v):
|
||||
raise EventError('%s must be %d-%d hex digits'
|
||||
% (key, lo_len, hi_len))
|
||||
return v
|
||||
|
||||
|
||||
def _digits(fields, key, default, lo, hi):
|
||||
v = str(fields.get(key) or default or '').strip()
|
||||
if not _DIGITS_RE.match(v) or not lo <= len(v) <= hi:
|
||||
raise EventError('%s must be %d-%d digits' % (key, lo, hi))
|
||||
return v
|
||||
|
||||
|
||||
def plmn_hex(mcc, mnc):
|
||||
"""MCC/MNC -> the 3-byte TS 24.008 BCD PLMN (the PWA's encPlmn: MCC
|
||||
digits swapped, MNC padded to 3 digits with 'F')."""
|
||||
d = mcc.ljust(3, '0')[:3] + mnc.ljust(3, 'F')[:3]
|
||||
return (d[1] + d[0] + d[5] + d[2] + d[4] + d[3]).upper()
|
||||
|
||||
|
||||
def _local_now():
|
||||
return time.localtime()
|
||||
|
||||
|
||||
def _tz_offset_minutes():
|
||||
"""Minutes east of UTC for the host clock."""
|
||||
try:
|
||||
return int(time.localtime().tm_gmtoff // 60)
|
||||
except AttributeError: # Windows
|
||||
return -int(time.timezone // 60)
|
||||
|
||||
|
||||
def scts_bytes(st, tz_min):
|
||||
"""The 7-byte TS 123 040 TP-SCTS (8.39 Date-Time and Time zone): swapped
|
||||
semi-octet BCD digits, the time zone in quarters of an hour with the sign
|
||||
in bit 3."""
|
||||
semi = lambda v: ((v % 10) << 4) | (v // 10)
|
||||
off = int(tz_min)
|
||||
tz = semi(int(round(abs(off) / 15.0)) & 0xFF) | (0x08 if off < 0 else 0)
|
||||
return bytes([semi(st.tm_year % 100), semi(st.tm_mon), semi(st.tm_mday),
|
||||
semi(st.tm_hour), semi(st.tm_min), semi(st.tm_sec), tz])
|
||||
|
||||
|
||||
# ─── the builders ───────────────────────────────────────────────────────
|
||||
|
||||
def build_location_status(fields):
|
||||
"""TS 31.111 7.5.2: status + Location information (8.19) for normal
|
||||
service only."""
|
||||
status = _int(fields, 'status', 0, 0, 2)
|
||||
out = '9B01%02X' % status
|
||||
if status == 0 and fields.get('mcc') not in (None, ''):
|
||||
mcc = _digits(fields, 'mcc', None, 3, 3)
|
||||
mnc = _digits(fields, 'mnc', '01', 2, 3)
|
||||
lac = _hex(fields, 'lac', '0000', 4, 4)
|
||||
cell = _hex(fields, 'cell', '0001', 4, 4)
|
||||
out += '9307' + plmn_hex(mcc, mnc) + lac + cell
|
||||
return out
|
||||
|
||||
|
||||
def build_access_tech(fields):
|
||||
"""TS 102 223 8.61: the single access technology byte."""
|
||||
tech = _int(fields, 'tech', 0, 0, 255)
|
||||
return 'BF01%02X' % tech
|
||||
|
||||
|
||||
def build_network_rejection(fields):
|
||||
"""TS 31.111 7.5.5 (the PWA form's build): registration type selects the
|
||||
location object - LAI for the location-updating group, RAI for GPRS, TAI
|
||||
for EPS/5GS."""
|
||||
rt = _int(fields, 'reg_type', 0x00, 0x00, 0x11)
|
||||
access_tech = _int(fields, 'access_tech', 0x00, 0, 255)
|
||||
cause = _int(fields, 'cause', 0x02, 0, 255)
|
||||
group = 'lu' if rt <= 0x02 else ('gprs' if rt <= 0x08 else 'eps')
|
||||
mcc = _digits(fields, 'mcc', '250', 3, 3)
|
||||
mnc = _digits(fields, 'mnc', '01', 2, 3)
|
||||
plmn = plmn_hex(mcc, mnc)
|
||||
out = 'F401%02X' % rt
|
||||
out += 'BF01%02X' % access_tech
|
||||
out += 'F501%02X' % cause
|
||||
if group == 'lu':
|
||||
out += '9305' + plmn + _hex(fields, 'lac', '0000', 4, 4)
|
||||
elif group == 'gprs':
|
||||
out += 'F306' + plmn + _hex(fields, 'lac', '0000', 4, 4) \
|
||||
+ _hex(fields, 'rac', '01', 2, 2)
|
||||
else:
|
||||
tac = _hex(fields, 'tac', '0001', 4, 6)
|
||||
out += '7D0%X' % (3 + len(tac) // 2) + plmn + tac
|
||||
if fields.get('ext_cause') not in (None, ''):
|
||||
out += '5701%02X' % _int(fields, 'ext_cause', 0, 0, 255)
|
||||
ext_type = fields.get('ext_info_type')
|
||||
if ext_type not in (None, ''):
|
||||
ext = _hex(fields, 'ext_info', '', 2, 20)
|
||||
out += 'F2%02X%02X%s' % (1 + len(ext) // 2, _int(fields, 'ext_info_type', 0, 0, 255), ext)
|
||||
return out
|
||||
|
||||
|
||||
def build_data_connection(fields, now=None):
|
||||
"""TS 31.111 7.5.25.2 object order; the conditional objects are omitted
|
||||
when left empty. ``now`` (a struct_time) makes the date-time testable."""
|
||||
status = _int(fields, 'status', 0, 0, 2)
|
||||
dtype = _int(fields, 'type', 0, 0, 2)
|
||||
out = '9D01%02X' % status
|
||||
out += 'AA01%02X' % dtype
|
||||
if fields.get('cause') not in (None, ''):
|
||||
out += 'AE01%02X' % _int(fields, 'cause', 0, 0, 255)
|
||||
ti = str(fields.get('ti') or '00').strip().upper()
|
||||
if not re.fullmatch(r'[0-9A-F]{2}', ti):
|
||||
raise EventError('ti must be one hex byte')
|
||||
out += '1C01' + ti
|
||||
if fields.get('datetime') == 'now':
|
||||
out += '2607' + scts_bytes(now or _local_now(), _tz_offset_minutes()).hex().upper()
|
||||
if fields.get('mcc') not in (None, ''):
|
||||
mcc = _digits(fields, 'mcc', None, 3, 3)
|
||||
mnc = _digits(fields, 'mnc', '01', 2, 3)
|
||||
out += '1307' + plmn_hex(mcc, mnc) \
|
||||
+ _hex(fields, 'lac', '0000', 4, 4) + _hex(fields, 'cell', '0001', 4, 4)
|
||||
if fields.get('tech') not in (None, ''):
|
||||
out += 'BF01%02X' % _int(fields, 'tech', 0, 0, 255)
|
||||
out += '9B01%02X' % _int(fields, 'loc_status', 0, 0, 2)
|
||||
if str(fields.get('apn') or '').strip():
|
||||
body = str(fields['apn']).encode('utf-8')
|
||||
if len(body) > 100:
|
||||
raise EventError('apn must be at most 100 bytes')
|
||||
out += 'C7%02X%s' % (len(body), body.hex().upper())
|
||||
if fields.get('pdp_type') not in (None, ''):
|
||||
out += '0B01%02X' % _int(fields, 'pdp_type', 0, 0, 255)
|
||||
return out
|
||||
|
||||
|
||||
BUILDERS = {
|
||||
EVENT_LOCATION_STATUS: build_location_status,
|
||||
EVENT_ACCESS_TECH: build_access_tech,
|
||||
EVENT_NETWORK_REJECTION: build_network_rejection,
|
||||
EVENT_DATA_CONNECTION: build_data_connection,
|
||||
}
|
||||
|
||||
|
||||
def normalise(event, fields):
|
||||
"""Validate an event's fields (returning the cleaned dict) - the same
|
||||
function the builder runs, so a script fails before the run starts."""
|
||||
fields = dict(fields or {})
|
||||
if event not in BUILDERS:
|
||||
raise EventError('no semantic builder for event 0x%02X - use the '
|
||||
'envelope action with the data hex' % event)
|
||||
BUILDERS[event](fields)
|
||||
return fields
|
||||
|
||||
|
||||
def build(event, fields, now=None):
|
||||
"""The event-data hex for a semantic event action."""
|
||||
fields = normalise(event, fields)
|
||||
if event == EVENT_DATA_CONNECTION:
|
||||
return BUILDERS[event](fields, now=now)
|
||||
return BUILDERS[event](fields)
|
||||
@@ -23,6 +23,7 @@ from pysim_simple_server import capmem
|
||||
from pysim_simple_server import testscript
|
||||
from pysim_simple_server import presets
|
||||
from pysim_simple_server import test_scripts
|
||||
from pysim_simple_server import events
|
||||
from smartcard.CardMonitoring import CardMonitor, CardObserver
|
||||
from cmd2.exceptions import CommandSetRegistrationError
|
||||
|
||||
@@ -33,7 +34,7 @@ from osmocom.tlv import BER_TLV_IE
|
||||
from osmocom.utils import rpad
|
||||
|
||||
|
||||
VERSION = '3.17.0'
|
||||
VERSION = '3.18.0'
|
||||
|
||||
MAX_ENVELOPE_SEGMENTS = 5 # max SMS segments for outgoing C-APDU in ENVELOPE
|
||||
|
||||
@@ -5582,7 +5583,8 @@ _TEST_RUN = {
|
||||
_TEST_LOCK = threading.Lock()
|
||||
_TEST_THREAD = None
|
||||
_TEST_KIND_LABELS = {
|
||||
'envelope': 'ENVELOPE(Event Download)', 'menu-select': 'ENVELOPE(Menu Selection)',
|
||||
'envelope': 'ENVELOPE(Event Download)', 'event': 'ENVELOPE(Event Download)',
|
||||
'menu-select': 'ENVELOPE(Menu Selection)',
|
||||
'file-write': 'UPDATE FILE', 'file-read': 'READ FILE', 'apdu': 'APDU',
|
||||
'scp80': 'SCP80', 'status': 'STATUS', 'cleanup': 'CLEANUP',
|
||||
}
|
||||
@@ -5953,6 +5955,13 @@ def _test_run_action(server, step, ctx):
|
||||
bytes.fromhex(p['data']) if p['data'] else None,
|
||||
drain=False)
|
||||
sent = 'ENVELOPE(Event Download) type=0x%02X' % p['event']
|
||||
elif kind == 'event':
|
||||
# The semantic event download: the builder owns the byte layout.
|
||||
data_hex = events.build(p['event'], p.get('fields') or {})
|
||||
data, sw = _send_event_download(scc, p['event'], bytes.fromhex(data_hex),
|
||||
drain=False, src=p.get('src'))
|
||||
sent = 'ENVELOPE(Event Download) type=0x%02X (%s) data=%s' % (
|
||||
p['event'], events.event_name(p['event']), data_hex)
|
||||
elif kind == 'menu-select':
|
||||
item_id = p.get('item_id')
|
||||
if item_id is None:
|
||||
|
||||
@@ -23,6 +23,8 @@ import re
|
||||
|
||||
import gsm0338 # registers the 'gsm03.38' codec (the default alphabet coding)
|
||||
|
||||
from pysim_simple_server import events
|
||||
|
||||
__all__ = [
|
||||
'ScriptError', 'ACTION_KINDS', 'FAIL_LEVELS', 'POR_CHECKS', 'RESULT_NAMES',
|
||||
'normalise_script', 'normalise_step', 'normalise_respond',
|
||||
@@ -30,8 +32,8 @@ __all__ = [
|
||||
'pack_gsm7',
|
||||
]
|
||||
|
||||
ACTION_KINDS = ('envelope', 'menu-select', 'file-write', 'file-read', 'apdu',
|
||||
'scp80', 'status')
|
||||
ACTION_KINDS = ('envelope', 'event', 'menu-select', 'file-write', 'file-read',
|
||||
'apdu', 'scp80', 'status')
|
||||
FAIL_LEVELS = ('error', 'warning')
|
||||
POR_CHECKS = ('none', 'ok', 'any')
|
||||
|
||||
@@ -218,6 +220,31 @@ def _normalise_params(kind, p):
|
||||
raise ScriptError('envelope: event is required')
|
||||
return {'event': _int(p['event'], 'envelope event', 0, 255),
|
||||
'data': _data_hex(p.get('data'), 'envelope data', allow_empty=True)}
|
||||
if kind == 'event':
|
||||
# The semantic event download: the builder owns the field set and the
|
||||
# byte layout (events.py mirrors the PWA's forms for the supported
|
||||
# events; every other event uses the raw `envelope` action).
|
||||
code = events.resolve_event(p.get('event'))
|
||||
if code is None:
|
||||
raise ScriptError('event: a supported event name or a hex byte is '
|
||||
'required (supported: %s)'
|
||||
% ', '.join('%s/0x%02X' % (events.event_name(c), c)
|
||||
for c in sorted(events.BUILDERS)))
|
||||
raw = p.get('fields')
|
||||
if raw is not None and not isinstance(raw, dict):
|
||||
raise ScriptError('event: fields must be an object')
|
||||
try:
|
||||
fields = events.normalise(code, raw or {})
|
||||
except events.EventError as e:
|
||||
raise ScriptError('event 0x%02X: %s' % (code, e))
|
||||
out = {'event': code, 'fields': fields}
|
||||
src = str(p.get('src') or '').strip().upper()
|
||||
if src:
|
||||
if not re.fullmatch(r'[0-9A-F]{2}', src):
|
||||
raise ScriptError('event: src must be one hex byte '
|
||||
'(82 terminal / 83 network)')
|
||||
out['src'] = src
|
||||
return out
|
||||
if kind == 'menu-select':
|
||||
# The item is named by its id, or by its text - the id varies with the
|
||||
# applet's install parameters, so a script can match the text the card
|
||||
|
||||
Reference in New Issue
Block a user