Reusable runbook: when the phone OBD app can’t connect to the ELM327-over-TCP bridge even though the TCP port is listening, the cause is almost always a stale macOS Bluetooth SDP cache after the dongle power-cycled. Fix = reconnect just that Bluetooth device + bounce the bridge. Expect this every time the car ignition goes off (the ELM327 is bus-powered).

For Agents

  • Bridge host: the Mac (~/obd-bridge/obdbridge, a Swift binary; launchd com.levander.obdbridge, KeepAlive). Logs: ~/obd-bridge/bridge.err + ~/obd-bridge/bridge.log.
  • Adapter: ELM327 BT dongle “OBDII”, address 00:1D:A5:68:98:8B (dashed form 00-1d-a5-68-98-8b), PIN 1234, paired to the Mac.
  • Data path: dongle ⇄ Bluetooth RFCOMM ⇄ bridge ⇄ ELM327-over-TCP on 100.83.222.120:35000 (tailnet). Configure the phone OBD app as a WiFi adapter pointed at that host:port.
  • The break is downstream of the bridge software — restarting the bridge alone does NOT fix it. blueutil is at /opt/homebrew/bin/blueutil (v2.13.0).

Symptom

  • Phone OBD app fails to connect. TCP 35000 is listening (bridge is up).
  • ~/obd-bridge/bridge.err loops:
    opening RFCOMM channel 1 to 00-1D-A5-68-98-8B
    openRFCOMMChannelSync failed: ret=0x-1ffffd44
    RFCOMM open attempt N failed; retrying in 3s
    
    0x-1ffffd44 decodes to IOReturn 0x2bc = kIOReturnError (generic error).

One-glance diagnosis

A listening TCP port does NOT mean the serial link is up

lsof -iTCP:35000 shows the bridge accepting connections regardless of whether the RFCOMM channel to the dongle actually opened. Always check bridge.err for RFCOMM errors before blaming the phone or the app.

Confirm the two facts, in order:

  1. Dongle powered + Bluetooth-connected?
    blueutil --is-connected 00-1d-a5-68-98-8b      # 1 = connected, 0 = not
    system_profiler SPBluetoothDataType            # OBDII should appear under "Connected:"
  2. Is a Serial Port (SPP) service being advertised? This is the root-cause check:
    system_profiler SPBluetoothDataType | grep -A3 OBDII
    • Broken: Services: 0x802000 < Braille ACL > — no SPP. The bridge queries SDP for the RFCOMM channel ID, finds none, falls back to hardcoded channel 1, and channel 1 fails to open.
    • Healthy: a Serial Port / SPP service is listed → SDP yields a real channel ID → RFCOMM opens.

It is NOT a serial-port-held-by-another-app problem

No /dev/tty.* or /dev/cu.* claimant is involved here. Don’t go hunting for a process holding a serial device — the break is in the Bluetooth SDP service-record cache, not a POSIX tty.

Root cause

The ELM327 is bus-powered from the car’s OBD port, so it loses power whenever the car/ignition is off. On the next power-up, macOS’s cached SDP service record for the dongle can go stale — the adapter comes back advertising a bogus service set (< Braille ACL >) instead of Serial Port Profile. The bridge (obdbridge.swift) does an SDP lookup for the RFCOMM channel, gets nothing usable, falls back to channel 1, and the open fails with kIOReturnError.

The fix (that worked)

Disconnect and reconnect just that one device (so the BT keyboard/mouse survive — do NOT toggle the whole Bluetooth stack), then bounce the bridge to force a fresh SDP query:

blueutil --disconnect 00-1d-a5-68-98-8b
sleep 3
blueutil --connect 00-1d-a5-68-98-8b
launchctl kickstart -k gui/$(id -u)/com.levander.obdbridge

Then watch until it links up:

tail -f ~/obd-bridge/bridge.err
# wait for:  RFCOMM connected   →   client connected

Once SPP is advertised again, the bridge’s KeepAlive retry loop grabs the channel on its own.

blueutil --connect can BLOCK for a long time

In one attempt it hung until a 2-minute timeout while the dongle was slow to re-establish — but the reconnect + fresh SDP still succeeded (the retry loop picked it up). Don’t re-run --connect on a hang; poll state with the non-blocking blueutil --is-connected 00-1d-a5-68-98-8b instead.

Restarting the bridge alone will NOT fix it

launchctl kickstart by itself re-runs the same failing SDP lookup. You must reconnect the Bluetooth device first so macOS refreshes the SDP record; the bridge bounce is only there to force an immediate fresh query instead of waiting out the retry backoff.

Expect recurrence

This recurs every time the dongle loses power (car off → ignition off → OBD port unpowered). It is not a one-time fix; keep this runbook handy and re-run the four fix commands whenever the phone can’t connect and bridge.err shows the RFCOMM loop.

  • homelab — device inventory and tailnet peers (the bridge is a Mac-hosted tailnet service on 100.83.222.120:35000)
  • SESSION-HANDOVER — the OBD2 bridge one-liner in current-state
  • LOG
  • TOPICS