Tier 2: WiFi + Bluetooth ELM327 transports

- obdcore/transport.py: pluggable byte transports -- SerialTransport,
  TcpTransport (WiFi ELM327, stdlib socket), BleTransport (experimental, via
  optional 'bleak'; background asyncio loop buffering notifications). ble_scan().
- ElmLink refactored onto a transport with .serial()/.tcp()/.ble() factories
  (close/cmd now go through self.io); no behavior change for serial.
- Controller.connect(conn={kind:serial|wifi|ble,...}); GUI connection bar gains
  a transport selector (Serial/USB/BT-SPP | WiFi host:port | Bluetooth LE + Scan).
- Classic-Bluetooth needs no new code (pairs as a serial port); WiFi needs no
  extra deps; BLE is opt-in (bleak not bundled, so CI binaries keep building).
- tests/test_transport.py: drives ElmLink over a fake ELM TCP server end-to-end
  (connect, RPM, readiness, VIN). All suites pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016yT89n4zR4qbrySoSiEyZs
This commit is contained in:
2026-07-01 08:24:51 -04:00
parent 6548cf7fbe
commit 7bda758f88
6 changed files with 417 additions and 31 deletions
+24 -9
View File
@@ -74,25 +74,40 @@ def find_ports():
class ElmLink:
PROMPT = b">"
def __init__(self, port, baud=38400, verbose=False):
if serial is None:
raise RuntimeError("pyserial not installed (pip install pyserial)")
def __init__(self, transport, verbose=False):
"""transport: any object with write/read/reset_input_buffer/close.
Use the .serial() / .tcp() / .ble() factory helpers to build one."""
self.io = transport
self.verbose = verbose
self.ser = serial.Serial(port, baud, timeout=0.2)
self.protocol = "?"
time.sleep(0.3)
self.ser.reset_input_buffer()
self.io.reset_input_buffer()
@classmethod
def serial(cls, port, baud=38400, **kw):
from . import transport as tp
return cls(tp.SerialTransport(port, baud), **kw)
@classmethod
def tcp(cls, host, port=35000, **kw):
from . import transport as tp
return cls(tp.TcpTransport(host, port), **kw)
@classmethod
def ble(cls, address, **kw):
from . import transport as tp
return cls(tp.BleTransport(address), **kw)
# -- low-level --
def cmd(self, s, settle=0.0, timeout=4.0):
self.ser.reset_input_buffer()
self.ser.write((s + "\r").encode())
self.io.reset_input_buffer()
self.io.write((s + "\r").encode())
if settle:
time.sleep(settle)
buf = bytearray()
deadline = time.time() + timeout
while time.time() < deadline:
chunk = self.ser.read(256)
chunk = self.io.read(256)
if chunk:
buf += chunk
if self.PROMPT in buf:
@@ -209,6 +224,6 @@ class ElmLink:
def close(self):
try:
self.ser.close()
self.io.close()
except Exception:
pass
+184
View File
@@ -0,0 +1,184 @@
"""Byte transports for ElmLink: serial (USB / classic-Bluetooth SPP), TCP
(WiFi ELM327), and BLE (Bluetooth Low Energy ELM327).
Each transport presents the same tiny synchronous interface so ElmLink's
command loop doesn't care how the bytes move:
write(data: bytes) -> None
read(n: int) -> bytes (returns b"" on timeout)
reset_input_buffer() -> None
close() -> None
Classic Bluetooth ELM327 pair as a serial port (COMx / /dev/cu.* / rfcomm), so
they use SerialTransport. Only WiFi (TCP) and BLE need dedicated transports.
"""
import socket
import threading
import time
class SerialTransport:
def __init__(self, port, baud=38400):
import serial
self.ser = serial.Serial(port, baud, timeout=0.2)
def write(self, data):
self.ser.write(data)
def read(self, n):
return self.ser.read(n)
def reset_input_buffer(self):
self.ser.reset_input_buffer()
def close(self):
try:
self.ser.close()
except Exception:
pass
class TcpTransport:
"""WiFi ELM327 — a raw TCP socket (commonly 192.168.0.10:35000)."""
def __init__(self, host, port=35000, connect_timeout=5.0, read_timeout=0.2):
self._rt = read_timeout
self.sock = socket.create_connection((host, int(port)), timeout=connect_timeout)
self.sock.settimeout(read_timeout)
def write(self, data):
self.sock.sendall(data)
def read(self, n):
try:
return self.sock.recv(n)
except socket.timeout:
return b""
except OSError:
return b""
def reset_input_buffer(self):
self.sock.settimeout(0.05)
try:
while self.sock.recv(4096):
pass
except Exception:
pass
finally:
self.sock.settimeout(self._rt)
def close(self):
try:
self.sock.close()
except Exception:
pass
# Common BLE UART characteristic pairs used by cheap ELM327 dongles
# (write-char, notify-char). We try each until one has both properties.
_BLE_UART_PAIRS = [
("0000fff2-0000-1000-8000-00805f9b34fb", "0000fff1-0000-1000-8000-00805f9b34fb"),
("0000ffe1-0000-1000-8000-00805f9b34fb", "0000ffe1-0000-1000-8000-00805f9b34fb"),
("6e400002-b5a3-f393-e0a9-e50e24dcca9e", "6e400003-b5a3-f393-e0a9-e50e24dcca9e"),
]
class BleTransport:
"""Experimental BLE ELM327 transport (needs `bleak`). Runs an asyncio loop
on a background thread and buffers notifications so read()/write() stay
synchronous. Untested against every dongle -- BLE ELM327 GATT layouts vary."""
def __init__(self, address, connect_timeout=15.0):
try:
import bleak # noqa: F401
except ImportError as e:
raise RuntimeError("BLE support needs the 'bleak' package "
"(pip install bleak)") from e
self.address = address
self._buf = bytearray()
self._lock = threading.Lock()
self._loop = None
self._client = None
self._write_char = None
self._ready = threading.Event()
self._err = None
self._thread = threading.Thread(target=self._run, daemon=True)
self._thread.start()
if not self._ready.wait(connect_timeout) or self._err:
raise RuntimeError(f"BLE connect failed: {self._err or 'timeout'}")
def _run(self):
import asyncio
from bleak import BleakClient
self._loop = asyncio.new_event_loop()
asyncio.set_event_loop(self._loop)
async def setup():
self._client = BleakClient(self.address)
await self._client.connect()
notify_char = None
for svc in self._client.services:
for ch in svc.characteristics:
props = set(ch.properties)
if {"write", "write-without-response"} & props and self._write_char is None:
self._write_char = ch.uuid
if {"notify", "indicate"} & props and notify_char is None:
notify_char = ch.uuid
# prefer a known UART pair if present
for w, n in _BLE_UART_PAIRS:
if any(c.uuid == w for s in self._client.services for c in s.characteristics):
self._write_char = w
notify_char = n
break
if not self._write_char or not notify_char:
raise RuntimeError("no writable/notify characteristic found")
def on_notify(_h, data):
with self._lock:
self._buf += bytes(data)
await self._client.start_notify(notify_char, on_notify)
self._ready.set()
try:
self._loop.run_until_complete(setup())
self._loop.run_forever()
except Exception as e:
self._err = e
self._ready.set()
def write(self, data):
import asyncio
fut = asyncio.run_coroutine_threadsafe(
self._client.write_gatt_char(self._write_char, data, response=False), self._loop)
fut.result(timeout=3.0)
def read(self, n):
deadline = time.time() + 0.2
while time.time() < deadline:
with self._lock:
if self._buf:
out = bytes(self._buf[:n]); del self._buf[:n]
return out
time.sleep(0.005)
return b""
def reset_input_buffer(self):
with self._lock:
self._buf.clear()
def close(self):
try:
import asyncio
asyncio.run_coroutine_threadsafe(self._client.disconnect(), self._loop).result(timeout=3.0)
self._loop.call_soon_threadsafe(self._loop.stop)
except Exception:
pass
def ble_scan(timeout=6.0):
"""Return [(address, name), ...] of nearby BLE devices (needs bleak)."""
import asyncio
from bleak import BleakScanner
devs = asyncio.run(BleakScanner.discover(timeout=timeout))
return [(d.address, d.name or "?") for d in devs]