Skip to content

Add safe modem discovery reports and capability profiles #11

Description

@Justinabox

Motivation

Callstack still assumes fixed Raspberry Pi modem ports (/dev/ttyUSB2 for AT commands and /dev/ttyUSB4 for audio) in ModemConfig. That is workable for one SIMCOM setup but fragile for unattended deployments and confusing for developers trying Quectel, Huawei, Sierra, or a different USB enumeration order.

This decomposes the roadmap modem auto-detection item from #9 into one safe PR: produce an explicit discovery report and capability profile without sending SMS, placing calls, unlocking SIMs, or mutating modem state.

User journey

A developer plugs in a USB LTE modem and runs a Callstack discovery helper or CLI/library function. It scans candidate serial ports, identifies which ports respond like AT command ports, records the modem identity, and reports a recommended ModemConfig plus known/unknown capabilities. If detection is uncertain, the report explains why instead of guessing silently.

API / UX sketch

Minimal library-first shape; a later CLI can wrap it:

from callstack.hardware import discover_modems

reports = await discover_modems(patterns=("/dev/ttyUSB*", "/dev/ttyACM*"), baudrate=115200)
for report in reports:
    print(report.at_port, report.identity.vendor, report.identity.model, report.capabilities)

Suggested dataclasses:

@dataclass(frozen=True)
class ModemIdentity:
    port: str
    manufacturer: str = ""
    model: str = ""
    revision: str = ""
    imei_present: bool = False  # never expose/log the actual IMEI by default

@dataclass(frozen=True)
class ModemCapabilities:
    sms_text_mode: str = "unknown"       # supported | unsupported | unknown
    sms_pdu_mode: str = "unknown"
    delivery_reports: str = "unknown"
    ussd: str = "unknown"
    voice_calls: str = "unknown"
    dtmf_send: str = "unknown"
    pcm_audio: str = "unknown"
    gnss: str = "unknown"

@dataclass(frozen=True)
class ModemDiscoveryReport:
    at_port: str
    audio_port: str | None
    identity: ModemIdentity
    capabilities: ModemCapabilities
    confidence: str                    # high | medium | low
    notes: tuple[str, ...] = ()

ModemConfig should remain explicit/overridable. Auto-detection must not break existing users who pass at_port= and audio_port= manually.

Technical approach

  1. Add a small callstack/hardware/ module with pure dataclasses plus an async discovery function.
  2. Discover candidate device paths via injectable glob/list function so tests do not touch real /dev.
  3. Probe each candidate with safe read-only/basic commands only, such as AT, ATE0, ATI, AT+CGMM, and AT+CGMR, with short timeouts and no SIM PIN, SMS, USSD, or call commands.
  4. Classify AT-capable ports separately from audio/unknown ports. If audio role cannot be proven safely, leave audio_port=None or confidence="low" with a note; do not hard-code /dev/ttyUSB4 as detected fact.
  5. Add simple profile matching from identity strings for SIMCOM/Quectel/Huawei/Sierra families, using unknown for unverified capabilities.
  6. Add a convenience ModemConfig.from_discovery(report) or documented helper only if it can remain explicit and reviewable.

Affected modules

  • Create: callstack/hardware/__init__.py
  • Create: callstack/hardware/discovery.py
  • Create: callstack/hardware/profiles.py if profile tables would keep discovery readable
  • Modify: callstack/config.py only if adding an explicit helper such as ModemConfig.from_discovery(...)
  • Modify: callstack/__init__.py only if exporting the discovery API is desired
  • Create: tests/test_hardware_discovery.py
  • README/ROADMAP docs can be a follow-up unless the PR adds public API that needs a short usage note

Hardware / modem caveats

  • ATI/identity commands are common across SIMCOM and Quectel-class AT command sets, but exact responses vary by family and firmware.
  • USB enumeration order can change after reconnect; detection should be safe to rerun and should not treat a previous /dev/ttyUSBx number as proof.
  • IMEI/IMSI/SIM numbers are sensitive. Discovery should never print or persist those values by default; if a command proves an IMEI exists, expose only imei_present=True or a redacted value in debug-only contexts.
  • Voice/audio capabilities vary widely. A profile may say unknown until real hardware evidence exists.

Research links:

Acceptance criteria

  • Discovery scans injectable candidate paths and opens/probes them with bounded timeouts.
  • A fake-transport test identifies one AT-capable port and ignores non-responsive/noisy ports.
  • Identity parsing handles at least SIMCOM-like and Quectel-like responses plus unknown devices.
  • Discovery produces a report with confidence and human-actionable notes when AT/audio role assignment is ambiguous.
  • No command used by discovery sends SMS, starts calls, sends USSD, unlocks SIMs, changes SMS storage, or enables network write surfaces.
  • Logs/reports do not include unredacted IMEI/IMSI/SIM numbers, phone numbers, API keys, or message bodies.
  • Existing explicit ModemConfig(at_port=..., audio_port=...) behavior remains unchanged.
  • Tests run without real modem hardware.

Exact 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_hardware_discovery.py tests/test_config.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

Non-goals for this PR

  • Building the CLI command itself (callstack detect/callstack status).
  • Real-hardware certification for every modem family.
  • Automatic SIM PIN entry, SMS send tests, USSD, or phone calls.
  • Multi-modem orchestration or failover.

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