Skip to content

Make PUK-locked SIM recovery reachable through public API #15

Description

@Justinabox

Summary

Modem.unlock_puk(puk, new_pin) is advertised by the SIM PUK error path, but the normal async with Modem(...) startup aborts before user code receives a connected modem object. That makes PUK recovery difficult to perform through the public API precisely when it is needed.

Evidence

Affected code:

  • callstack/modem.py:89-100 enters the context manager by calling _connect() before returning self.
  • callstack/modem.py:148-154 _initialize_modem() checks SIM state during _connect().
  • callstack/modem.py:197-200 raises SIMPUKRequired("SIM is PUK-locked — use modem.unlock_puk(puk, new_pin) to recover") when AT+CPIN? reports SIM PUK.
  • callstack/modem.py:322-329 implements unlock_puk(), but it sends through self._executor; there is no documented/public recovery entry point that opens the AT transport while bypassing the failing startup check.
  • tests/test_sim_pin.py covers command formatting and CPIN parsing but has no test proving a PUK-locked modem can be recovered through public API.

Repository health checks from this scout run:

git diff --check
# exit 0

PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q
# 291 passed in 3.94s

Duplicate checks:

gh issue list --repo Justinabox/Callstack --state all --search 'SIM PUK unlock_puk in:title,body'
# no matches

gh issue list --repo Justinabox/Callstack --state all --search 'PUK locked connect unlock in:title,body'
# no matches

Expected behavior

A user with a PUK-locked SIM should have a documented, test-covered way to recover without using private internals or raw serial tools. The recovery path should send the PUK/new PIN safely, then allow normal modem initialization to continue or clearly instruct the user to reconnect.

Actual behavior

The startup path raises SIMPUKRequired before the context manager yields. The exception tells users to call modem.unlock_puk(...), but the obvious public pattern never reaches a usable modem inside the async with block.

Suggested fix direction

Pick a small, explicit recovery API and document it. Possible shapes:

  • Add ModemConfig(sim_puk=..., new_sim_pin=...) and handle SIM PUK during _check_sim_pin() similarly to sim_pin, with careful validation and redacted logging.
  • Or add a class/helper such as await Modem.recover_puk(config, puk, new_pin) that opens only the AT transport, sends AT+CPIN="puk","new_pin", verifies +CPIN: READY, and closes cleanly.
  • Update the SIMPUKRequired message to point at the actual supported path.

Avoid logging PUK/PIN values or SIM identifiers.

Acceptance criteria

  • A PUK-locked SIM can be recovered through documented public API without touching private attributes.
  • SIMPUKRequired points to that API and does not suggest an unreachable call pattern.
  • Tests simulate +CPIN: SIM PUK, successful PUK entry, and subsequent READY state with mock transport.
  • Invalid PUK/new PIN inputs are rejected before being sent to the modem.
  • Logs and exceptions do not include PUK/PIN values, IMSI/ICCID, SIM numbers, or other credentials.

Verification gates

git diff --check
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/test_sim_pin.py tests/test_modem.py -q
PYTHONPATH=. uv run --no-project --with pytest --with pytest-asyncio --with pytest-aiohttp --with pyserial-asyncio --with aiosqlite pytest tests/ -q

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions