esim: decode the notification operations from the card's TLV shape

ListNotification showed 'pmo' as the operation for every notification:
pySim's ProfileMgmtOperation is a Struct whose first (ignored) byte is
the padding-bits octet, so a TLV parsed from the card nests the flags
under 'pmo' while an object built from decoded flags (the unit-test path)
does not.  The mapping iterated the outer dict and reported the nested
dict as a truthy key.

- esim._profile_operations() accepts both shapes and returns the set
  flags in spec/bit order (install, enable, disable, delete).
- tests: a notification parsed from the card-shaped raw TLV (padding
  octet included) plus the helper's both-shapes/multi-flag cases.
- docs: /api/esim/notifications and the repo AGENTS note.
This commit is contained in:
2026-09-22 00:30:32 +03:00
parent a7ec67b19c
commit f87f3e9841
3 changed files with 47 additions and 4 deletions
+3 -1
View File
@@ -232,7 +232,9 @@ lock; `GET /api/status` reports `euicc` and `eid` for the PWA.
card sent no icon. card sent no icon.
- `GET /api/esim/notifications` — `{"notifications": [{"seq_number": 3, - `GET /api/esim/notifications` — `{"notifications": [{"seq_number": 3,
"operations": ["enable"], "address": "smdp.example.org", "operations": ["enable"], "address": "smdp.example.org",
"iccid": "8970…"}], "error": null}`. "iccid": "8970…"}], "error": null}`. `operations` are the decoded
`ProfileMgmtOperation` flags (`install`, `enable`, `disable`, `delete`, in
that order) — one or more per notification.
- `POST /api/esim/profile` — `{"action": "enable"|"disable", "iccid"?: …, - `POST /api/esim/profile` — `{"action": "enable"|"disable", "iccid"?: …,
"isdp_aid"?: …, "refresh"?: true}` (one identifier required). With the "isdp_aid"?: …, "refresh"?: true}` (one identifier required). With the
refresh flag set the ISD-R returns OK *before* the REFRESH (SGP.22 v2.6 refresh flag set the ISD-R returns OK *before* the REFRESH (SGP.22 v2.6
+16 -3
View File
@@ -436,6 +436,21 @@ def profiles(app):
return {'profiles': out, 'error': None} return {'profiles': out, 'error': None}
# ProfileMgmtOperation flags in bit order (SGP.22 §5.7.9). The TLV carries a
# padding-bits octet followed by the flags octet, so a TLV parsed from the
# card nests the flags under 'pmo' while an object built from decoded flags
# carries them directly.
PROFILE_MGMT_OPERATIONS = ('install', 'enable', 'disable', 'delete')
def _profile_operations(op):
"""ProfileMgmtOperation flags -> operation names ([] when unknown)."""
if not isinstance(op, dict):
return []
flags = op.get('pmo') if isinstance(op.get('pmo'), dict) else op
return [name for name in PROFILE_MGMT_OPERATIONS if flags.get(name)]
def notifications(app): def notifications(app):
"""ES10b ListNotification: read-only list of pending notifications.""" """ES10b ListNotification: read-only list of pending notifications."""
resp = _transceive(app, ListNotificationReq(), ListNotificationResp) resp = _transceive(app, ListNotificationReq(), ListNotificationResp)
@@ -446,11 +461,9 @@ def notifications(app):
lst = flat.get('notification_metadata_list') lst = flat.get('notification_metadata_list')
out = [] out = []
for n in _repeated(lst, 'notification_metadata'): for n in _repeated(lst, 'notification_metadata'):
op = n.get('profile_mgmt_operation')
operations = sorted(k for k, v in op.items() if v) if isinstance(op, dict) else []
out.append({ out.append({
'seq_number': n.get('seq_number'), 'seq_number': n.get('seq_number'),
'operations': operations, 'operations': _profile_operations(n.get('profile_mgmt_operation')),
'address': n.get('notification_address'), 'address': n.get('notification_address'),
'iccid': n.get('iccid'), 'iccid': n.get('iccid'),
}) })
+28
View File
@@ -161,6 +161,34 @@ class EsimTests(unittest.TestCase):
'address': 'smdp.example.org', 'iccid': '8970119000004002667', 'address': 'smdp.example.org', 'iccid': '8970119000004002667',
}]) }])
def test_notifications_parses_operations_from_the_card_tlv(self):
# The card's ProfileMgmtOperation TLV (padding octet + flags octet,
# SGP.22 5.7.9) nests the flags under 'pmo' when pySim parses it.
app, _ = make_app()
address = '6D6E6F2D30302E6573696D73657276696365732E636F6D' # mno-00.esimservices.com
meta = _tlv(0xBF2F, _tlv(0x80, '00') + _tlv(0x81, '0140')
+ _tlv(0x0C, address) + _tlv(0x5A, '980711090000042066F7'))
resp = ListNotificationResp()
resp.from_tlv(bytes.fromhex(_tlv(0xBF28, _tlv(0xA0, meta))))
self.patch([resp])
out = esim.notifications(app)
self.assertIsNone(out['error'])
self.assertEqual(out['notifications'], [{
'seq_number': 0, 'operations': ['enable'],
'address': 'mno-00.esimservices.com',
'iccid': '8970119000004002667',
}])
def test_profile_operations_handles_both_shapes(self):
self.assertEqual(esim._profile_operations(
{'pmo': {'install': False, 'enable': True, 'disable': False,
'delete': True}}), ['enable', 'delete'])
self.assertEqual(esim._profile_operations(
{'install': True, 'enable': False, 'disable': False, 'delete': False}),
['install'])
self.assertEqual(esim._profile_operations(None), [])
self.assertEqual(esim._profile_operations('pmo'), [])
def test_chip_info_collects_parts_and_errors(self): def test_chip_info_collects_parts_and_errors(self):
app, _ = make_app() app, _ = make_app()
self.patch_eid('89049032000000000000000000000001') self.patch_eid('89049032000000000000000000000001')