VintageNetECM

Hex version API docs

A VintageNet technology for cellular modems whose data plane is a USB CDC-ECM (or RNDIS) network interface — for example the Quectel EG800Q in usbnet ECM mode.

Unlike VintageNetMobile, which dials a PPP link over a serial AT port, an ECM-class modem exposes an ordinary Ethernet-like netdev (usb0/usb1/wwan0) whose IP/DNS/route are obtained over DHCP once the modem's internal data call is up. VintageNetECM therefore composes VintageNetEthernet for the IP plane and adds an AT-control sidecar that brings the modem's data call up and reports registration and signal.

The vendor-specific AT details live behind the VintageNetECM.Modem behaviour. The default implementation, VintageNetECM.Modem.Quectel, targets Quectel usbnet ECM modems.

Before choosing this library, review the other cellular modem libraries:

Library Data interface Protocol Notes
VintageNetECM USB AT/CDC-ECM AT commands for the control path and USB CDC-ECM for data. Newer modems are starting to make this the default.
VintageNetMobile UART (direct or over USB) AT/PPP Pretty much ever modem supports this, but it can be hard to use due to the control and data links being shared and vendor-specific commands
VintageNetQMI USB QMI Generic control protocol for modems with a separate data path. Modems generally just work if they support this protocol.

Installation

Add vintage_net_ecm to your list of dependencies in mix.exs:

def deps do
  [
    {:vintage_net_ecm, "~> 0.1.0"}
  ]
end

Documentation can be generated with ExDoc and published on HexDocs. Once published, the docs can be found at https://hexdocs.pm/vintage_net_ecm.

Configuration

VintageNet.configure("usb1", %{
  type: VintageNetECM,
  vintage_net_ecm: %{
    service_providers: [%{apn: "super"}],
    at_tty: "ttyUSB2",
    context_id: 1
  }
})

:ipv4 defaults to %{method: :dhcp} — the only sensible mode for ECM. Other VintageNetEthernet options (:dhcpd, static :ipv4, :mac_address) pass through to the composed ethernet config.

Modem, SIM and network information

The AT sidecar publishes what it learns about the modem under ["interface", ifname, "mobile", ...], so it comes back with the rest of VintageNet's properties:

iex> VintageNet.get_by_prefix(["interface", "usb1", "mobile"])
[
  {["interface", "usb1", "mobile", "access_technology"], "LTE"},
  {["interface", "usb1", "mobile", "band"], "LTE BAND 2"},
  {["interface", "usb1", "mobile", "cell_id"], "B57DE33"},
  {["interface", "usb1", "mobile", "channel"], 900},
  {["interface", "usb1", "mobile", "dst_offset"], 3600},
  {["interface", "usb1", "mobile", "firmware_version"], "EG800QEULCR01A03M04"},
  {["interface", "usb1", "mobile", "iccid"], "89014103211118510720"},
  {["interface", "usb1", "mobile", "imei"], "867698041234567"},
  {["interface", "usb1", "mobile", "imsi"], "310410123456789"},
  {["interface", "usb1", "mobile", "manufacturer"], "Quectel"},
  {["interface", "usb1", "mobile", "mcc"], "310"},
  {["interface", "usb1", "mobile", "mnc"], "410"},
  {["interface", "usb1", "mobile", "model"], "EG800Q"},
  {["interface", "usb1", "mobile", "operator"], "AT&T"},
  {["interface", "usb1", "mobile", "registration"], :registered_home},
  {["interface", "usb1", "mobile", "rsrp_dbm"], -85},
  {["interface", "usb1", "mobile", "rsrq_db"], -10},
  {["interface", "usb1", "mobile", "serial_number"], "BA1234567890"},
  {["interface", "usb1", "mobile", "signal_dbm"], -79},
  {["interface", "usb1", "mobile", "sinr_db"], 17},
  {["interface", "usb1", "mobile", "tac"], "B504"},
  {["interface", "usb1", "mobile", "timezone"], "-07:00"},
  {["interface", "usb1", "mobile", "utc_offset"], -25200}
]

Identity properties (imei, iccid, ...) are read once and then retried until the modem answers; the rest are refreshed every 30 seconds. A property is nil when the modem couldn't answer for it. See VintageNetECM.ATController for which AT command each one comes from.

Note that the ESN and MEID aren't reported: those are 3GPP2 (CDMA) identifiers, and an LTE-only module like the EG800Q has neither.

Current time

The time is a live query rather than a property — it would be stale the moment it was published:

iex> VintageNetECM.utc_now("usb1")
{:ok, ~U[2026-08-08 17:04:31Z]}

iex> VintageNetECM.network_time("usb1")
{:ok, %{utc: ~U[2026-08-08 17:04:31Z], utc_offset: -25200, dst_offset: 3600}}

This is the time the network gave the modem over NITZ, so it needs no NTP server and no route to the internet. It's only available once the operator has sent it — expect {:error, :not_synchronized} for the first moments after registering, and on networks that don't send NITZ at all.

Sending AT commands

VintageNetECM.command/3 sends an arbitrary AT command over the tty the controller already owns and returns the response lines:

iex> VintageNetECM.command("usb1", "AT+CGMI")
{:ok, ["Quectel"]}

iex> VintageNetECM.command("usb1", "AT+COPS=?", timeout: 180_000)
{:ok, ["+COPS: (1,\"T-Mobile\",\"T-Mobile\",\"310260\",7),..."]}

Avoid commands that change state the controller manages (AT+CFUN, AT+CGDCONT, data-call control), since it may undo them.

Supporting another modem

Implement the VintageNetECM.Modem behaviour and pass it as :modem. The vendor-specific parts are data-call control, access technology, ICCID, serving-cell details and network time; the rest of the lifecycle uses standard 3GPP commands. Callbacks may return {:error, :unsupported} for anything the modem has no command for.