feat: `op esim` and esim.py 2 (#35314)
* init: lpa interface * handle multiple messages * handle timeouts * delete old LPA, add enable/disable/validation * check if valid * keep old file the same for easier diff * keep * nickname, bug fixes * space * simple * need to test this on slow conn * initial HITL test for eSIM provisioning * cleanup * lint * test flakes if lpac called concurrently * no * cleanup * org * comment * vibe coded uts * Revert "vibe coded uts" This reverts commitpull/35318/head8b4d8f8ade
. * much simpler test * no value * remove no value add comments * only one test flow now * simpler * reorganize * replace impl * brevity * moar * why didnt u rename * moar * check lpac installed * Profile dataclass * shorten * print out profiles * better * plurals * argparse * download/nickname * move to end to show change * just end early if already enabled * --reboot * reconfigure conn * mutations require reboot today * not needed * lint * guard delete * better * print help * spaceg * rename * support at device * choose backend * desc * more * brackets * op esim * Revert "brackets" This reverts commit124dbc0cbc
. * Update Jenkinsfile
parent
4423b47b6c
commit
786b46c0b4
4 changed files with 219 additions and 104 deletions
@ -1,115 +1,170 @@ |
||||
#!/usr/bin/env python3 |
||||
|
||||
import argparse |
||||
import json |
||||
import os |
||||
import math |
||||
import time |
||||
import binascii |
||||
import requests |
||||
import serial |
||||
import shutil |
||||
import subprocess |
||||
from dataclasses import dataclass |
||||
from typing import Literal |
||||
|
||||
@dataclass |
||||
class Profile: |
||||
iccid: str |
||||
nickname: str |
||||
enabled: bool |
||||
provider: str |
||||
|
||||
class LPAError(RuntimeError): |
||||
pass |
||||
|
||||
def post(url, payload): |
||||
print() |
||||
print("POST to", url) |
||||
r = requests.post( |
||||
url, |
||||
data=payload, |
||||
verify=False, |
||||
headers={ |
||||
"Content-Type": "application/json", |
||||
"X-Admin-Protocol": "gsma/rsp/v2.2.0", |
||||
"charset": "utf-8", |
||||
"User-Agent": "gsma-rsp-lpad", |
||||
}, |
||||
) |
||||
print("resp", r) |
||||
print("resp text", repr(r.text)) |
||||
print() |
||||
r.raise_for_status() |
||||
|
||||
ret = f"HTTP/1.1 {r.status_code}" |
||||
ret += ''.join(f"{k}: {v}" for k, v in r.headers.items() if k != 'Connection') |
||||
return ret.encode() + r.content |
||||
class LPAProfileNotFoundError(LPAError): |
||||
pass |
||||
|
||||
|
||||
class LPA: |
||||
def __init__(self): |
||||
self.dev = serial.Serial('/dev/ttyUSB2', baudrate=57600, timeout=1, bytesize=8) |
||||
self.dev.reset_input_buffer() |
||||
self.dev.reset_output_buffer() |
||||
assert "OK" in self.at("AT") |
||||
|
||||
def at(self, cmd): |
||||
print(f"==> {cmd}") |
||||
self.dev.write(cmd.encode() + b'\r\n') |
||||
|
||||
r = b"" |
||||
cnt = 0 |
||||
while b"OK" not in r and b"ERROR" not in r and cnt < 20: |
||||
r += self.dev.read(8192).strip() |
||||
cnt += 1 |
||||
r = r.decode() |
||||
print(f"<== {repr(r)}") |
||||
return r |
||||
|
||||
def download_ota(self, qr): |
||||
return self.at(f'AT+QESIM="ota","{qr}"') |
||||
|
||||
def download(self, qr): |
||||
smdp = qr.split('$')[1] |
||||
out = self.at(f'AT+QESIM="download","{qr}"') |
||||
for _ in range(5): |
||||
line = out.split("+QESIM: ")[1].split("\r\n\r\nOK")[0] |
||||
|
||||
parts = [x.strip().strip('"') for x in line.split(',', maxsplit=4)] |
||||
print(repr(parts)) |
||||
trans, ret, url, payloadlen, payload = parts |
||||
assert trans == "trans" and ret == "0" |
||||
assert len(payload) == int(payloadlen) |
||||
|
||||
r = post(f"https://{smdp}/{url}", payload) |
||||
to_send = binascii.hexlify(r).decode() |
||||
|
||||
chunk_len = 1400 |
||||
for i in range(math.ceil(len(to_send) / chunk_len)): |
||||
state = 1 if (i+1)*chunk_len < len(to_send) else 0 |
||||
data = to_send[i * chunk_len : (i+1)*chunk_len] |
||||
out = self.at(f'AT+QESIM="trans",{len(to_send)},{state},{i},{len(data)},"{data}"') |
||||
assert "OK" in out |
||||
|
||||
if '+QESIM:"download",1' in out: |
||||
raise Exception("profile install failed") |
||||
elif '+QESIM:"download",0' in out: |
||||
print("done, successfully loaded") |
||||
break |
||||
|
||||
def enable(self, iccid): |
||||
self.at(f'AT+QESIM="enable","{iccid}"') |
||||
|
||||
def disable(self, iccid): |
||||
self.at(f'AT+QESIM="disable","{iccid}"') |
||||
|
||||
def delete(self, iccid): |
||||
self.at(f'AT+QESIM="delete","{iccid}"') |
||||
|
||||
def list_profiles(self): |
||||
out = self.at('AT+QESIM="list"') |
||||
return out.strip().splitlines()[1:] |
||||
def __init__(self, interface: Literal['qmi', 'at'] = 'qmi'): |
||||
self.env = os.environ.copy() |
||||
self.env['LPAC_APDU'] = interface |
||||
self.env['QMI_DEVICE'] = '/dev/cdc-wdm0' |
||||
self.env['AT_DEVICE'] = '/dev/ttyUSB2' |
||||
|
||||
self.timeout_sec = 45 |
||||
|
||||
if shutil.which('lpac') is None: |
||||
raise LPAError('lpac not found, must be installed!') |
||||
|
||||
def list_profiles(self) -> list[Profile]: |
||||
msgs = self._invoke('profile', 'list') |
||||
self._validate_successful(msgs) |
||||
return [Profile( |
||||
iccid=p['iccid'], |
||||
nickname=p['profileNickname'], |
||||
enabled=p['profileState'] == 'enabled', |
||||
provider=p['serviceProviderName'] |
||||
) for p in msgs[-1]['payload']['data']] |
||||
|
||||
def get_active_profile(self) -> Profile | None: |
||||
return next((p for p in self.list_profiles() if p.enabled), None) |
||||
|
||||
def enable_profile(self, iccid: str) -> None: |
||||
self._validate_profile_exists(iccid) |
||||
latest = self.get_active_profile() |
||||
if latest: |
||||
if latest.iccid == iccid: |
||||
return |
||||
self.disable_profile(latest.iccid) |
||||
self._validate_successful(self._invoke('profile', 'enable', iccid)) |
||||
self.process_notifications() |
||||
|
||||
def disable_profile(self, iccid: str) -> None: |
||||
self._validate_profile_exists(iccid) |
||||
latest = self.get_active_profile() |
||||
if latest is not None and latest.iccid != iccid: |
||||
return |
||||
self._validate_successful(self._invoke('profile', 'disable', iccid)) |
||||
self.process_notifications() |
||||
|
||||
def delete_profile(self, iccid: str) -> None: |
||||
self._validate_profile_exists(iccid) |
||||
latest = self.get_active_profile() |
||||
if latest is not None and latest.iccid == iccid: |
||||
self.disable_profile(iccid) |
||||
self._validate_successful(self._invoke('profile', 'delete', iccid)) |
||||
self.process_notifications() |
||||
|
||||
def download_profile(self, qr: str, nickname: str | None = None) -> None: |
||||
msgs = self._invoke('profile', 'download', '-a', qr) |
||||
self._validate_successful(msgs) |
||||
new_profile = next((m for m in msgs if m['payload']['message'] == 'es8p_meatadata_parse'), None) |
||||
if new_profile is None: |
||||
raise LPAError('no new profile found') |
||||
if nickname: |
||||
self.nickname_profile(new_profile['payload']['data']['iccid'], nickname) |
||||
self.process_notifications() |
||||
|
||||
def nickname_profile(self, iccid: str, nickname: str) -> None: |
||||
self._validate_profile_exists(iccid) |
||||
self._validate_successful(self._invoke('profile', 'nickname', iccid, nickname)) |
||||
|
||||
def process_notifications(self) -> None: |
||||
""" |
||||
Process notifications stored on the eUICC, typically to activate/deactivate the profile with the carrier. |
||||
""" |
||||
self._validate_successful(self._invoke('notification', 'process', '-a', '-r')) |
||||
|
||||
def _invoke(self, *cmd: str): |
||||
proc = subprocess.Popen(['sudo', '-E', 'lpac'] + list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=self.env) |
||||
try: |
||||
out, err = proc.communicate(timeout=self.timeout_sec) |
||||
except subprocess.TimeoutExpired as e: |
||||
proc.kill() |
||||
raise LPAError(f"lpac {cmd} timed out after {self.timeout_sec} seconds") from e |
||||
|
||||
messages = [] |
||||
for line in out.decode().strip().splitlines(): |
||||
if line.startswith('{'): |
||||
message = json.loads(line) |
||||
|
||||
# lpac response format validations |
||||
assert 'type' in message, 'expected type in message' |
||||
assert message['type'] == 'lpa' or message['type'] == 'progress', 'expected lpa or progress message type' |
||||
assert 'payload' in message, 'expected payload in message' |
||||
assert 'code' in message['payload'], 'expected code in message payload' |
||||
assert 'data' in message['payload'], 'expected data in message payload' |
||||
|
||||
msg_ret_code = message['payload']['code'] |
||||
if msg_ret_code != 0: |
||||
raise LPAError(f"lpac {' '.join(cmd)} failed with code {msg_ret_code}: <{message['payload']['message']}> {message['payload']['data']}") |
||||
|
||||
messages.append(message) |
||||
|
||||
if len(messages) == 0: |
||||
raise LPAError(f"lpac {cmd} returned no messages") |
||||
|
||||
return messages |
||||
|
||||
def _validate_profile_exists(self, iccid: str) -> None: |
||||
if not any(p.iccid == iccid for p in self.list_profiles()): |
||||
raise LPAProfileNotFoundError(f'profile {iccid} does not exist') |
||||
|
||||
def _validate_successful(self, msgs: list[dict]) -> None: |
||||
assert msgs[-1]['payload']['message'] == 'success', 'expected success notification' |
||||
|
||||
|
||||
if __name__ == "__main__": |
||||
import sys |
||||
|
||||
if "RESTART" in os.environ: |
||||
subprocess.check_call("sudo systemctl stop ModemManager", shell=True) |
||||
subprocess.check_call("/usr/comma/lte/lte.sh stop_blocking", shell=True) |
||||
subprocess.check_call("/usr/comma/lte/lte.sh start", shell=True) |
||||
while not os.path.exists('/dev/ttyUSB2'): |
||||
time.sleep(1) |
||||
time.sleep(3) |
||||
|
||||
lpa = LPA() |
||||
print(lpa.list_profiles()) |
||||
if len(sys.argv) > 1: |
||||
lpa.download(sys.argv[1]) |
||||
print(lpa.list_profiles()) |
||||
parser = argparse.ArgumentParser(prog='esim.py', description='manage eSIM profiles on your comma device', epilog='comma.ai') |
||||
parser.add_argument('--backend', choices=['qmi', 'at'], default='qmi', help='use the specified backend, defaults to qmi') |
||||
parser.add_argument('--enable', metavar='iccid', help='enable profile; will disable current profile') |
||||
parser.add_argument('--disable', metavar='iccid', help='disable profile') |
||||
parser.add_argument('--delete', metavar='iccid', help='delete profile (warning: this cannot be undone)') |
||||
parser.add_argument('--download', nargs=2, metavar=('qr', 'name'), help='download a profile using QR code (format: LPA:1$rsp.truphone.com$QRF-SPEEDTEST)') |
||||
parser.add_argument('--nickname', nargs=2, metavar=('iccid', 'name'), help='update the nickname for a profile') |
||||
args = parser.parse_args() |
||||
|
||||
lpa = LPA(interface=args.backend) |
||||
if args.enable: |
||||
lpa.enable_profile(args.enable) |
||||
print('enabled profile, please restart device to apply changes') |
||||
elif args.disable: |
||||
lpa.disable_profile(args.disable) |
||||
print('disabled profile, please restart device to apply changes') |
||||
elif args.delete: |
||||
confirm = input('are you sure you want to delete this profile? (y/N) ') |
||||
if confirm == 'y': |
||||
lpa.delete_profile(args.delete) |
||||
print('deleted profile, please restart device to apply changes') |
||||
else: |
||||
print('cancelled') |
||||
exit(0) |
||||
elif args.download: |
||||
lpa.download_profile(args.download[0], args.download[1]) |
||||
elif args.nickname: |
||||
lpa.nickname_profile(args.nickname[0], args.nickname[1]) |
||||
else: |
||||
parser.print_help() |
||||
|
||||
profiles = lpa.list_profiles() |
||||
print(f'\n{len(profiles)} profile{"s" if len(profiles) > 1 else ""}:') |
||||
for p in profiles: |
||||
print(f'- {p.iccid} (nickname: {p.nickname or "<none provided>"}) (provider: {p.provider}) - {"enabled" if p.enabled else "disabled"}') |
||||
|
@ -0,0 +1,51 @@ |
||||
import pytest |
||||
|
||||
from openpilot.system.hardware import TICI |
||||
from openpilot.system.hardware.tici.esim import LPA, LPAProfileNotFoundError |
||||
|
||||
# https://euicc-manual.osmocom.org/docs/rsp/known-test-profile |
||||
# iccid is always the same for the given activation code |
||||
TEST_ACTIVATION_CODE = 'LPA:1$rsp.truphone.com$QRF-BETTERROAMING-PMRDGIR2EARDEIT5' |
||||
TEST_ICCID = '8944476500001944011' |
||||
|
||||
TEST_NICKNAME = 'test_profile' |
||||
|
||||
def cleanup(): |
||||
lpa = LPA() |
||||
try: |
||||
lpa.delete_profile(TEST_ICCID) |
||||
except LPAProfileNotFoundError: |
||||
pass |
||||
lpa.process_notifications() |
||||
|
||||
class TestEsim: |
||||
|
||||
@classmethod |
||||
def setup_class(cls): |
||||
if not TICI: |
||||
pytest.skip() |
||||
cleanup() |
||||
|
||||
@classmethod |
||||
def teardown_class(cls): |
||||
cleanup() |
||||
|
||||
def test_provision_enable_disable(self): |
||||
lpa = LPA() |
||||
current_active = lpa.get_active_profile() |
||||
|
||||
lpa.download_profile(TEST_ACTIVATION_CODE, TEST_NICKNAME) |
||||
assert any(p.iccid == TEST_ICCID and p.nickname == TEST_NICKNAME for p in lpa.list_profiles()) |
||||
|
||||
lpa.enable_profile(TEST_ICCID) |
||||
new_active = lpa.get_active_profile() |
||||
assert new_active is not None |
||||
assert new_active.iccid == TEST_ICCID |
||||
assert new_active.nickname == TEST_NICKNAME |
||||
|
||||
lpa.disable_profile(TEST_ICCID) |
||||
new_active = lpa.get_active_profile() |
||||
assert new_active is None |
||||
|
||||
if current_active: |
||||
lpa.enable_profile(current_active.iccid) |
Loading…
Reference in new issue