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
+11 -2
View File
@@ -55,12 +55,21 @@ class Controller:
self.reg = PidRegistry(self.profile)
self.dtcdb = DtcDatabase(self.profile)
def connect(self, port=None, baud=38400, mock=False):
def connect(self, port=None, baud=38400, mock=False, conn=None):
"""conn: optional {'kind': 'serial'|'wifi'|'ble', ...}. Falls back to
serial(port, baud) for backward compatibility."""
if mock:
self.link = MockLink(clock=time.time)
else:
from obdcore.link import ElmLink # imported lazily (needs pyserial)
self.link = ElmLink(port, baud)
c = conn or {"kind": "serial", "port": port, "baud": baud}
kind = c.get("kind", "serial")
if kind == "wifi":
self.link = ElmLink.tcp(c["host"], c.get("port", 35000))
elif kind == "ble":
self.link = ElmLink.ble(c["address"])
else:
self.link = ElmLink.serial(c.get("port", port), c.get("baud", baud))
self.link.init()
ok = self.link.connect()
try:
+82 -13
View File
@@ -156,16 +156,44 @@ class MainWindow(QtWidgets.QMainWindow):
tb = QtWidgets.QToolBar("Connection")
tb.setMovable(False)
self.addToolBar(tb)
tb.addWidget(QtWidgets.QLabel(" Port "))
self.port_combo = QtWidgets.QComboBox()
self.port_combo.setMinimumWidth(180)
self.conn_kind = QtWidgets.QComboBox()
self.conn_kind.addItems(["Serial / USB / BT-SPP", "WiFi", "Bluetooth LE"])
self.conn_kind.currentIndexChanged.connect(self._conn_kind_changed)
tb.addWidget(self.conn_kind)
# serial inputs
self._serial_w = []
self._serial_w.append(tb.addWidget(QtWidgets.QLabel(" Port ")))
self.port_combo = QtWidgets.QComboBox(); self.port_combo.setMinimumWidth(180)
self._refresh_ports()
tb.addWidget(self.port_combo)
self._serial_w.append(tb.addWidget(self.port_combo))
b = QtWidgets.QToolButton(); b.setText(""); b.clicked.connect(self._refresh_ports)
tb.addWidget(b)
tb.addWidget(QtWidgets.QLabel(" Baud "))
self.baud_edit = QtWidgets.QLineEdit("38400"); self.baud_edit.setFixedWidth(70)
tb.addWidget(self.baud_edit)
self._serial_w.append(tb.addWidget(b))
self._serial_w.append(tb.addWidget(QtWidgets.QLabel(" Baud ")))
self.baud_edit = QtWidgets.QLineEdit("38400"); self.baud_edit.setFixedWidth(64)
self._serial_w.append(tb.addWidget(self.baud_edit))
# wifi inputs
self._wifi_w = []
self._wifi_w.append(tb.addWidget(QtWidgets.QLabel(" Host ")))
self.host_edit = QtWidgets.QLineEdit("192.168.0.10"); self.host_edit.setFixedWidth(120)
self._wifi_w.append(tb.addWidget(self.host_edit))
self._wifi_w.append(tb.addWidget(QtWidgets.QLabel(" : ")))
self.tcpport_edit = QtWidgets.QLineEdit("35000"); self.tcpport_edit.setFixedWidth(56)
self._wifi_w.append(tb.addWidget(self.tcpport_edit))
# ble inputs
self._ble_w = []
self._ble_w.append(tb.addWidget(QtWidgets.QLabel(" Device ")))
self.ble_combo = QtWidgets.QComboBox(); self.ble_combo.setMinimumWidth(200)
self.ble_combo.setEditable(True)
self._ble_w.append(tb.addWidget(self.ble_combo))
self.ble_scan_btn = QtWidgets.QToolButton(); self.ble_scan_btn.setText("Scan")
self.ble_scan_btn.clicked.connect(self._ble_scan)
self._ble_w.append(tb.addWidget(self.ble_scan_btn))
tb.addSeparator()
self.mock_chk = QtWidgets.QCheckBox("Mock"); tb.addWidget(self.mock_chk)
self.connect_btn = QtWidgets.QPushButton("Connect")
self.connect_btn.clicked.connect(self._toggle_connect)
@@ -174,6 +202,35 @@ class MainWindow(QtWidgets.QMainWindow):
self._preset_tb = tb
self._preset_sep = tb.addSeparator()
self._preset_buttons = []
self._conn_kind_changed()
def _conn_kind_changed(self):
k = self.conn_kind.currentIndex()
for a in self._serial_w:
a.setVisible(k == 0)
for a in self._wifi_w:
a.setVisible(k == 1)
for a in self._ble_w:
a.setVisible(k == 2)
def _ble_scan(self):
try:
from obdcore.transport import ble_scan
except Exception:
QtWidgets.QMessageBox.information(self, "Bluetooth LE",
"BLE needs the 'bleak' package (pip install bleak). Classic-Bluetooth "
"ELM327 pair as a serial port — use Serial instead.")
return
self.status.showMessage("Scanning for BLE devices…")
QtWidgets.QApplication.processEvents()
try:
devs = ble_scan(timeout=6.0)
except Exception as e:
QtWidgets.QMessageBox.critical(self, "BLE scan failed", str(e)); return
self.ble_combo.clear()
for addr, name in devs:
self.ble_combo.addItem(f"{name} [{addr}]", addr)
self.status.showMessage(f"Found {len(devs)} BLE device(s).")
def _rebuild_preset_buttons(self):
for b in self._preset_buttons:
@@ -593,16 +650,28 @@ class MainWindow(QtWidgets.QMainWindow):
if not ports:
self.port_combo.addItem("(no ports found)", None)
def _toggle_connect(self):
if self.ctl.connected:
self._disconnect(); return
port = self.port_combo.currentData()
def _conn_spec(self):
k = self.conn_kind.currentIndex()
if k == 1:
try:
p = int(self.tcpport_edit.text())
except ValueError:
p = 35000
return {"kind": "wifi", "host": self.host_edit.text().strip(), "port": p}
if k == 2:
addr = self.ble_combo.currentData() or self.ble_combo.currentText().strip()
return {"kind": "ble", "address": addr}
try:
baud = int(self.baud_edit.text())
except ValueError:
baud = 38400
return {"kind": "serial", "port": self.port_combo.currentData(), "baud": baud}
def _toggle_connect(self):
if self.ctl.connected:
self._disconnect(); return
try:
ok = self.ctl.connect(port=port, baud=baud, mock=self.mock_chk.isChecked())
ok = self.ctl.connect(mock=self.mock_chk.isChecked(), conn=self._conn_spec())
except Exception as e:
QtWidgets.QMessageBox.critical(self, "Connect failed", str(e)); return
self.ctl.start(); self.timer.start()