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