Skip to content

Commit df3d54c

Browse files
committed
more fixes
1 parent 8cd04ca commit df3d54c

6 files changed

Lines changed: 115 additions & 138 deletions

File tree

backend/app.py

Lines changed: 31 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,20 @@
55
import json
66
import sys
77
import re
8-
import os
9-
import shlex
108
import shutil
119
import subprocess
1210
from pathlib import Path
1311
from typing import Any, Dict, List, Optional
1412

15-
from fastapi import Body, FastAPI, WebSocket, WebSocketDisconnect, HTTPException
13+
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException
1614
from fastapi.middleware.cors import CORSMiddleware
1715
from fastapi.staticfiles import StaticFiles
18-
from fastapi import HTTPException
1916
from pydantic import BaseModel
2017
from paths import ensure_user_file
2118
from auto_setup import ensure_can_environment, log_env_summary
2219

2320
# Local modules
24-
from bus import BusManager, Frame
21+
from bus import BusManager
2522
from decoder import decode_frame, safe_hex
2623
from j1939_maps import PGN_NAME_MAP
2724
from models import ConnectRequest, SendRequest, LogStartRequest
@@ -41,23 +38,32 @@
4138

4239
# ---------------- Privileged runner and link helpers ----------------
4340

41+
# backend/app.py (replace _run_priv)
4442
def _run_priv(cmd: list[str], check: bool = True) -> subprocess.CompletedProcess:
4543
"""
46-
Run a privileged netlink command. We try directly first (works if the Linux
47-
binary has cap_net_admin/cap_net_raw). If that fails for permissions and
48-
pkexec is available, we transparently retry with pkexec (GUI password).
44+
Run a privileged netlink command.
45+
46+
Behavior:
47+
- Try normally first.
48+
- If it fails (non-zero exit), and it *looks* like a permission problem,
49+
retry via pkexec (GUI password) when available.
50+
- If check=True, raise CalledProcessError for a non-zero final result.
4951
"""
50-
try:
51-
return subprocess.run(cmd, text=True, capture_output=True, check=check)
52-
except subprocess.CalledProcessError as e:
53-
# If permission problem, try pkexec if present
54-
if ("Operation not permitted" in (e.stderr or "") or e.returncode in (1, 126)) and shutil.which("pkexec"):
55-
return subprocess.run(["pkexec", *cmd], text=True, capture_output=True, check=check)
56-
raise
57-
except PermissionError:
58-
if shutil.which("pkexec"):
59-
return subprocess.run(["pkexec", *cmd], text=True, capture_output=True, check=check)
60-
raise
52+
proc = subprocess.run(cmd, text=True, capture_output=True)
53+
if proc.returncode != 0:
54+
stderr = (proc.stderr or "")
55+
looks_perm = (
56+
"Operation not permitted" in stderr
57+
or "permission denied" in stderr.lower()
58+
or proc.returncode in (1, 126, 127)
59+
)
60+
if looks_perm and shutil.which("pkexec"):
61+
proc = subprocess.run(["pkexec", *cmd], text=True, capture_output=True)
62+
63+
if check and proc.returncode != 0:
64+
raise subprocess.CalledProcessError(proc.returncode, cmd, output=proc.stdout, stderr=proc.stderr)
65+
66+
return proc
6167

6268
def _ip_exists(iface: str) -> bool:
6369
r = subprocess.run(["ip", "-br", "link", "show", iface], text=True, capture_output=True)
@@ -107,26 +113,12 @@ def bus_health_snapshot_safe() -> Dict[str, Any]:
107113
# -----------------------------------------------------------------------------
108114
# Helpers for bring-up
109115
# -----------------------------------------------------------------------------
110-
def _which_any(*names: str) -> str:
111-
"""Return first existing absolute path for a binary name; raise if none."""
112-
for n in names:
113-
p = shutil.which(n)
114-
if p:
115-
return p
116-
raise FileNotFoundError(f"Missing required tool: {', '.join(names)}")
117-
118-
def _safe_bitrate(bps: int) -> int:
119-
allowed = {125000, 250000, 500000, 1000000}
120-
if bps in allowed:
121-
return bps
122-
raise HTTPException(status_code=400, detail=f"Unsupported bitrate {bps}")
123-
124116
def _safe_iface(name: str) -> str:
125-
import re
126117
if re.fullmatch(r"(v?can)\d{1,3}", name):
127118
return name
128119
raise HTTPException(status_code=400, detail=f"Bad interface name {name}")
129120

121+
130122
# -----------------------------------------------------------------------------
131123
# API routes
132124
# -----------------------------------------------------------------------------
@@ -223,7 +215,11 @@ def api_can_bringup(req: BringUpReq):
223215

224216
# Physical SocketCAN device: DOWN -> type can bitrate -> UP
225217
# Bring it down first (ignore error if it's already down)
226-
_run_priv(["ip", "link", "set", iface, "down"], check=False)
218+
try:
219+
_run_priv(["ip", "link", "set", iface, "down"], check=True)
220+
except subprocess.CalledProcessError:
221+
# Ignore errors like "Cannot find device" — the next steps will clarify state
222+
pass
227223
# Configure bitrate/type (this is what fails if you try it on vcan or while UP)
228224
_run_priv(["ip", "link", "set", iface, "type", "can", "bitrate", str(bitrate)], check=True)
229225
# Bring it up
@@ -294,7 +290,6 @@ async def api_platform():
294290
Report the server's platform: 'linux', 'win32', 'darwin', etc.
295291
Frontend uses this to hide Bring Up on Windows (not needed for Kvaser).
296292
"""
297-
import sys
298293
return {"platform": sys.platform}
299294

300295
# ----------------------------- Logging control -------------------------------

backend/bus.py

Lines changed: 16 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ def open(self):
7878
self.bus = can.interface.Bus(
7979
channel=self.channel,
8080
bustype="socketcan",
81-
receive_own_messages=True # <— this is the key
81+
receive_own_messages=True # <— key for loopback echo on vcan
8282
)
8383
self._start_rx()
8484

@@ -254,52 +254,31 @@ def health(self) -> Dict[str, Any]:
254254
"frames_total": self.frames_total,
255255
}
256256

257-
# --- Kvaser backend (Windows) -----------------------------------------------
258-
# This implements a minimal Kvaser backend via python-can. It expects that
259-
# Kvaser CANlib drivers are installed on the machine (so that python-can can
260-
# load canlib32.dll). Channel naming follows "kvaser<N>" where N is an integer.
257+
258+
# --- Kvaser backend (Windows) ------------------------------------------------
259+
# Present but not wired into BusManager I/O; kept for future Windows work.
261260
class _KvaserBus:
262261
def __init__(self):
263262
self._bus = None
264263
self._last_info = {}
265264

266265
async def discover_interfaces(self):
267-
"""
268-
Return a small set of candidate channel names the UI can offer.
269-
We can't reliably enumerate Kvaser channels portably without extra deps,
270-
so provide a few common indices. The user will usually pick kvaser0.
271-
"""
272266
return [f"kvaser{i}" for i in range(4)]
273267

274268
async def connect(self, channel: str, bitrate: int):
275-
"""
276-
Open a Kvaser channel: interface='kvaser', channel=<index>, bitrate=<bps>.
277-
'channel' is expected as 'kvaser0', 'kvaser1', etc.
278-
"""
279269
import can # python-can
280270
try:
281271
if not channel.lower().startswith("kvaser"):
282272
return False, f"invalid channel name '{channel}'. use 'kvaser0', 'kvaser1', etc."
283273
idx = int(channel.replace("kvaser", ""))
284-
285-
# Close previous if any
286274
if self._bus is not None:
287275
try:
288276
self._bus.shutdown()
289277
except Exception:
290278
pass
291279
self._bus = None
292-
293-
self._bus = can.interface.Bus(
294-
interface="kvaser",
295-
channel=idx,
296-
bitrate=bitrate,
297-
)
298-
self._last_info = {
299-
"backend": "kvaser",
300-
"channel": idx,
301-
"bitrate": bitrate,
302-
}
280+
self._bus = can.interface.Bus(interface="kvaser", channel=idx, bitrate=bitrate)
281+
self._last_info = {"backend": "kvaser", "channel": idx, "bitrate": bitrate}
303282
return True, f"connected to {channel} @ {bitrate} bps"
304283
except Exception as e:
305284
return False, f"Failed to open {channel}: {e}"
@@ -367,43 +346,17 @@ def _list_socketcan_names() -> List[str]:
367346

368347

369348
# ──────────────────────────────────────────────────────────────────────────────
370-
# Front-end facing manager (fixed deadlock + offloaded blocking calls)
349+
# Front-end facing manager (adds _lock/_bus/_info and avoids pre-instantiation)
371350
# ──────────────────────────────────────────────────────────────────────────────
372351

373352
class BusManager:
374353
def __init__(self):
375-
self._impl = None
376-
self._impl_name = None
354+
# Runtime state guarded by _lock
355+
self._bus = None # active low-level bus
356+
self._info: Dict[str, Any] = {} # metadata for /health
357+
self._lock = asyncio.Lock() # prevents concurrent connect/disconnect
377358

378-
# Prefer SocketCAN on Linux
379-
if sys.platform.startswith("linux"):
380-
try:
381-
self._impl = _SocketCANBus()
382-
self._impl_name = "socketcan"
383-
except Exception:
384-
pass
385-
386-
# If Intrepid (ics) is installed, allow that to override on any platform.
387-
# We probe for the module without importing it to keep Pylance happy.
388-
try:
389-
if importlib.util.find_spec("ics") is not None:
390-
self._impl = _IntrepidBus()
391-
self._impl_name = "intrepid"
392-
except Exception:
393-
pass
394-
395-
# On Windows, prefer Kvaser if python-can is present.
396-
if sys.platform.startswith("win"):
397-
try:
398-
if importlib.util.find_spec("can") is not None:
399-
self._impl = _KvaserBus()
400-
self._impl_name = "kvaser"
401-
except Exception:
402-
# Leave whatever impl we already selected (e.g., Intrepid)
403-
pass
404-
405-
406-
# ---- Discovery -----------------------------------------------------------
359+
# ---- Discovery -----------------------------------------------------------
407360

408361
async def discover_interfaces(self) -> List[str]:
409362
tasks = [
@@ -416,6 +369,7 @@ async def discover_interfaces(self) -> List[str]:
416369
results.append(await t) # type: ignore[arg-type]
417370
except Exception:
418371
results.append([])
372+
419373
out: List[str] = []
420374
seen: set[str] = set()
421375
for group in results:
@@ -427,11 +381,10 @@ async def discover_interfaces(self) -> List[str]:
427381

428382
# ---- Connect / Disconnect ----------------------------------------------
429383

430-
# INTERNAL: do not call without holding self._lock
431384
async def _disconnect_no_lock(self) -> None:
385+
"""Close current bus without acquiring _lock (caller must hold it)."""
432386
if self._bus is not None:
433387
try:
434-
# offload potential blocking close
435388
await asyncio.to_thread(self._bus.close) # type: ignore[attr-defined]
436389
except Exception:
437390
pass
@@ -444,13 +397,13 @@ async def connect(self, channel: str, bitrate: Optional[int] = None) -> Tuple[bo
444397
Offloads hardware open to a thread to avoid blocking the event loop.
445398
"""
446399
async with self._lock:
447-
# FIX: avoid deadlock by calling the no-lock variant
448400
await self._disconnect_no_lock()
449401
try:
450402
if channel.startswith("intrepid"):
403+
if not HAS_INTREPID:
404+
return False, "Intrepid library not available"
451405
idx = int(channel.replace("intrepid", "") or "0")
452406
b = _IntrepidBus(device_index=idx, bitrate=bitrate)
453-
# offload blocking open
454407
await asyncio.to_thread(b.open)
455408
self._bus = b
456409
name = ""
@@ -469,7 +422,6 @@ async def connect(self, channel: str, bitrate: Optional[int] = None) -> Tuple[bo
469422
if not HAS_PYCAN:
470423
return False, "python-can not available"
471424
b = _SocketCANBus(channel=channel, bitrate=bitrate)
472-
# offload blocking open
473425
await asyncio.to_thread(b.open)
474426
self._bus = b
475427
self._info = {

0 commit comments

Comments
 (0)