From 0276a3fb3c9723101b49d702d81cb203c420bb34 Mon Sep 17 00:00:00 2001 From: Marcel Peterkau Date: Fri, 5 Sep 2025 01:03:14 +0200 Subject: [PATCH] made everything modular --- app/app.py | 132 ++++++++ app/gui.py | 405 ------------------------- app/gui/__init__.py | 0 app/gui/can_panel.py | 108 +++++++ app/gui/dashboard.py | 65 ++++ app/gui/trace.py | 161 ++++++++++ app/simulation/modules/abs.py | 11 - app/simulation/modules/basic.py | 7 +- app/simulation/modules/engine.py | 19 +- app/simulation/modules/gearbox.py | 7 +- app/simulation/simulator.py | 213 +++++++++++++ app/simulation/simulator_main.py | 46 --- app/simulation/ui/__init__.py | 52 ++++ app/{tabs => simulation/ui}/basic.py | 11 +- app/{tabs => simulation/ui}/dtc.py | 8 +- app/{tabs => simulation/ui}/engine.py | 12 +- app/{tabs => simulation/ui}/gearbox.py | 10 +- app/simulation/vehicle.py | 122 -------- app/tabs/__init__.py | 12 - app/tabs/dashboard.py | 77 ----- main.py | 2 +- 21 files changed, 788 insertions(+), 692 deletions(-) create mode 100644 app/app.py delete mode 100644 app/gui.py create mode 100644 app/gui/__init__.py create mode 100644 app/gui/can_panel.py create mode 100644 app/gui/dashboard.py create mode 100644 app/gui/trace.py delete mode 100644 app/simulation/modules/abs.py create mode 100644 app/simulation/simulator.py delete mode 100644 app/simulation/simulator_main.py create mode 100644 app/simulation/ui/__init__.py rename app/{tabs => simulation/ui}/basic.py (97%) rename app/{tabs => simulation/ui}/dtc.py (89%) rename app/{tabs => simulation/ui}/engine.py (97%) rename app/{tabs => simulation/ui}/gearbox.py (95%) delete mode 100644 app/simulation/vehicle.py delete mode 100644 app/tabs/__init__.py delete mode 100644 app/tabs/dashboard.py diff --git a/app/app.py b/app/app.py new file mode 100644 index 0000000..2b4bb01 --- /dev/null +++ b/app/app.py @@ -0,0 +1,132 @@ +# app/gui/app.py +from __future__ import annotations +import json, threading, time, tkinter as tk +from tkinter import ttk, messagebox, filedialog + +from app.config import load_settings, setup_logging +from app.obd2 import ObdResponder, make_speed_response, make_rpm_response +from app.simulation.simulator import VehicleSimulator +from app.simulation.ui import discover_ui_tabs + +from app.gui.trace import TraceView +from app.gui.dashboard import DashboardView +from app.gui.can_panel import CanPanel + +def launch_gui(): + cfg = load_settings(); setup_logging(cfg) + + can_iface = (cfg.get("can", {}).get("interface")) or "can0" + resp_id_raw = (cfg.get("can", {}).get("resp_id")) or "0x7E8" + try: resp_id = int(resp_id_raw, 16) if isinstance(resp_id_raw, str) else int(resp_id_raw) + except Exception: resp_id = 0x7E8 + timeout_ms = cfg.get("can", {}).get("timeout_ms", 200) + bitrate = cfg.get("can", {}).get("baudrate", 500000) + + # Simulator + OBD2 + sim = VehicleSimulator() + responder = ObdResponder(interface=can_iface, resp_id=resp_id, timeout_ms=timeout_ms) + responder.register_pid(0x0D, lambda: make_speed_response(int(round(sim.snapshot().get("speed_kmh", 0))))) + responder.register_pid(0x0C, lambda: make_rpm_response(int(sim.snapshot().get("rpm", 0)))) + + # Physics thread + running = True + def physics_loop(): + last = time.monotonic() + while running: + now = time.monotonic() + dt = min(0.05, max(0.0, now - last)); last = now + sim.update(dt) + time.sleep(0.02) + threading.Thread(target=physics_loop, daemon=True).start() + + # --- Tk Window --------------------------------------------------------- + root = tk.Tk(); root.title("OBD-II ECU Simulator – SocketCAN") + root.geometry(f"{cfg.get('ui',{}).get('window',{}).get('width',1100)}x{cfg.get('ui',{}).get('window',{}).get('height',720)}") + + # ================== Panedwindow-Layout ================== + # Haupt-Split: Links/Rechts (ein senkrechter Trenner) + main_pw = tk.PanedWindow(root, orient="horizontal") + main_pw.pack(fill="both", expand=True) + + # linke Spalte + left_pw = tk.PanedWindow(main_pw, orient="vertical") + main_pw.add(left_pw) + + # rechte Spalte + right_pw = tk.PanedWindow(main_pw, orient="vertical") + main_pw.add(right_pw) + + # --- Callback-Bridge für Rebind: erst später existiert trace_view --- + trace_ref = {"obj": None} + def on_rebind_iface(new_iface: str): + tv = trace_ref["obj"] + if tv: + tv.rebind_interface(new_iface) + + # ----- Top-Left: CAN panel ----- + can_panel = CanPanel( + parent=left_pw, responder=responder, + initial_iface=can_iface, initial_resp_id=resp_id, + initial_timeout_ms=timeout_ms, initial_bitrate=bitrate, + on_rebind_iface=on_rebind_iface + ) + left_pw.add(can_panel.frame) + + # ----- Bottom-Left: Trace ----- + trace_view = TraceView(parent=left_pw, responder=responder, iface_initial=can_iface) + trace_ref["obj"] = trace_view + left_pw.add(trace_view.frame) + + # ----- Top-Right: dynamic tabs ----- + nb = ttk.Notebook(right_pw) + ui_tabs = discover_ui_tabs(nb, sim) + for t in ui_tabs: + title = getattr(t, "TITLE", getattr(t, "NAME", t.__class__.__name__)) + nb.add(t.frame, text=title) + right_pw.add(nb) + + # ----- Bottom-Right: Dashboard ----- + dash_view = DashboardView(parent=right_pw, sim=sim, refresh_ms=250) + right_pw.add(dash_view.frame) + + # ---------------- Menü (Load/Save) ---------------- + menubar = tk.Menu(root); filemenu = tk.Menu(menubar, tearoff=0) + def do_load(): + path = filedialog.askopenfilename(filetypes=[("JSON","*.json"),("All","*.*")]) + if not path: return + with open(path,"r",encoding="utf-8") as f: data = json.load(f) + for t in ui_tabs: + if hasattr(t, "load_from_config"): t.load_from_config(data) + sim.load_config(data) + messagebox.showinfo("Simulator", "Konfiguration geladen.") + def do_save(): + cfg_out = sim.export_config() + for t in ui_tabs: + if hasattr(t, "save_into_config"): t.save_into_config(cfg_out) + path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON","*.json")]) + if not path: return + with open(path,"w",encoding="utf-8") as f: json.dump(cfg_out, f, indent=2) + messagebox.showinfo("Simulator", "Konfiguration gespeichert.") + filemenu.add_command(label="Konfiguration laden…", command=do_load) + filemenu.add_command(label="Konfiguration speichern…", command=do_save) + filemenu.add_separator(); filemenu.add_command(label="Beenden", command=root.destroy) + menubar.add_cascade(label="Datei", menu=filemenu); root.config(menu=menubar) + + # Title updater + def tick_title(): + snap = sim.snapshot() + root.title(f"OBD-II ECU Simulator – RPM {int(snap.get('rpm',0))} | {int(round(snap.get('speed_kmh',0)))} km/h") + try: root.after(300, tick_title) + except tk.TclError: pass + tick_title() + + def on_close(): + nonlocal running + running = False + try: trace_view.stop() + except Exception: pass + try: responder.stop() + finally: + root.destroy() + root.protocol("WM_DELETE_WINDOW", on_close) + root.mainloop() diff --git a/app/gui.py b/app/gui.py deleted file mode 100644 index 0b56ce5..0000000 --- a/app/gui.py +++ /dev/null @@ -1,405 +0,0 @@ -# Project layout (drop-in) -# -# app/ -# ├─ gui.py ← new main GUI with Simulator tabs + Save/Load -# ├─ config.py (unchanged) -# ├─ can.py (unchanged) -# ├─ obd2.py (unchanged; GUI registers PIDs) -# ├─ tabs/ -# │ ├─ __init__.py -# │ ├─ basic.py ← base/vehicle tab (ignition, mass, type, ABS/TCS) -# │ ├─ engine.py ← engine tab -# │ ├─ gearbox.py ← gearbox tab -# │ └─ dtc.py ← DTC toggles tab -# └─ simulation/ -# ├─ __init__.py -# ├─ simulator_main.py ← VehicleSimulator wrapper used by GUI -# ├─ vehicle.py ← core state + module orchestration -# └─ modules/ -# ├─ __init__.py -# ├─ engine.py -# ├─ gearbox.py -# └─ abs_.py - - -# ============================= -# app/gui.py -# ============================= - -from __future__ import annotations -import json -import threading -import time -import tkinter as tk -from tkinter import ttk, messagebox, filedialog -from collections import deque -import subprocess - -import can # for trace - -from .config import load_settings, setup_logging -from .obd2 import ObdResponder, make_speed_response, make_rpm_response -from .can import ( - list_can_ifaces, link_up, link_down, link_state, link_kind, - have_cap_netadmin -) - -# Simulator pieces -from .simulation.simulator_main import VehicleSimulator -from .tabs.basic import BasicTab -from .tabs.engine import EngineTab -from .tabs.gearbox import GearboxTab -from .tabs.dtc import DtcTab -from .tabs.dashboard import DashboardTab - -# ---------- CAN Trace Collector ---------- -class TraceCollector: - def __init__(self, channel: str): - self.channel = channel - self.bus = None - self._run = threading.Event(); self._run.set() - self._thread = threading.Thread(target=self._rx_loop, name="CAN-TRACE", daemon=True) - self.stream_buffer = deque(maxlen=2000) - self.lock = threading.Lock() - - def _open(self): - self._close() - self.bus = can.interface.Bus(channel=self.channel, interface="socketcan") - - def _close(self): - try: - if self.bus: self.bus.shutdown() - except Exception: - pass - self.bus = None - - def start(self): - self._thread.start() - - def stop(self): - self._run.clear() - try: - self._thread.join(timeout=1.0) - except RuntimeError: - pass - self._close() - - def _rx_loop(self): - backoff = 0.5 - while self._run.is_set(): - if self.bus is None: - if link_state(self.channel) == "UP": - try: - self._open(); backoff = 0.5 - except Exception: - time.sleep(backoff); backoff = min(5.0, backoff*1.7) - continue - else: - time.sleep(0.5); continue - try: - msg = self.bus.recv(0.05) - if msg and not msg.is_error_frame and not msg.is_remote_frame: - ts = time.time() - with self.lock: - self.stream_buffer.append((ts, msg.arbitration_id, msg.dlc, bytes(msg.data))) - except (can.CanOperationError, OSError): - self._close(); time.sleep(0.5) - except Exception: - time.sleep(0.05) - - def snapshot_stream(self): - with self.lock: - return list(self.stream_buffer) - - -# ============================= -# GUI Launcher (reworked layout) -# ============================= - -def launch_gui(): - cfg = load_settings() - logger = setup_logging(cfg) - - # Config - can_iface = (cfg.get("can", {}).get("interface")) or "can0" - resp_id_raw = (cfg.get("can", {}).get("resp_id")) or "0x7E8" - try: - resp_id = int(resp_id_raw, 16) if isinstance(resp_id_raw, str) else int(resp_id_raw) - except Exception: - resp_id = 0x7E8 - timeout_ms = cfg.get("can", {}).get("timeout_ms", 200) - bitrate = cfg.get("can", {}).get("baudrate", 500000) - - # Simulator - sim = VehicleSimulator() - - # OBD2 responder - responder = ObdResponder(interface=can_iface, resp_id=resp_id, timeout_ms=timeout_ms, logger=logger) - responder.register_pid(0x0D, lambda: make_speed_response(int(round(sim.snapshot()["speed_kmh"])))) - responder.register_pid(0x0C, lambda: make_rpm_response(int(sim.snapshot()["rpm"]))) - - # Physics thread - running = True - def physics_loop(): - last = time.monotonic() - while running: - now = time.monotonic() - dt = min(0.05, max(0.0, now - last)) - last = now - sim.update(dt) - time.sleep(0.02) - threading.Thread(target=physics_loop, daemon=True).start() - - tracer = TraceCollector(can_iface); tracer.start() - - # Tk window - root = tk.Tk(); root.title("OBD-II ECU Simulator – SocketCAN") - root.geometry(f"{cfg.get('ui',{}).get('window',{}).get('width',1100)}x{cfg.get('ui',{}).get('window',{}).get('height',720)}") - - family = cfg.get("ui", {}).get("font_family", "TkDefaultFont") - size = int(cfg.get("ui", {}).get("font_size", 10)) - style = ttk.Style() - style.configure("TLabel", font=(family, size)) - style.configure("Header.TLabel", font=(family, size+2, "bold")) - style.configure("Small.TLabel", font=(family, max(8, size-1))) - - # Menu (Load/Save config) - menubar = tk.Menu(root) - filemenu = tk.Menu(menubar, tearoff=0) - - def action_load(): - path = filedialog.askopenfilename(filetypes=[("JSON", "*.json"), ("All", "*.*")]) - if not path: return - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - for tab in sim_tabs: tab.load_from_config(data) - sim.load_config(data) - messagebox.showinfo("Simulator", "Konfiguration geladen.") - except Exception as e: - messagebox.showerror("Laden fehlgeschlagen", str(e)) - - def action_save(): - cfg_dict = sim.export_config() - for tab in sim_tabs: tab.save_into_config(cfg_dict) - path = filedialog.asksaveasfilename(defaultextension=".json", filetypes=[("JSON", "*.json"), ("All", "*.*")]) - if not path: return - try: - with open(path, "w", encoding="utf-8") as f: - json.dump(cfg_dict, f, indent=2) - messagebox.showinfo("Simulator", "Konfiguration gespeichert.") - except Exception as e: - messagebox.showerror("Speichern fehlgeschlagen", str(e)) - - filemenu.add_command(label="Konfiguration laden…", command=action_load) - filemenu.add_command(label="Konfiguration speichern…", command=action_save) - filemenu.add_separator(); filemenu.add_command(label="Beenden", command=root.destroy) - menubar.add_cascade(label="Datei", menu=filemenu) - root.config(menu=menubar) - - # ===== New Layout ====================================================== - # Grid with two rows: - # Row 0: Left = CAN settings, Right = Simulator tabs - # Row 1: Trace spanning both columns - # ====================================================================== - - root.columnconfigure(0, weight=1) - root.columnconfigure(1, weight=2) - root.rowconfigure(1, weight=1) # trace grows - - # --- LEFT: CAN Settings ------------------------------------------------ - can_frame = ttk.LabelFrame(root, text="CAN & Settings", padding=8) - can_frame.grid(row=0, column=0, sticky="nsew", padx=(8,4), pady=(8,4)) - for i in range(2): can_frame.columnconfigure(i, weight=1) - - ttk.Label(can_frame, text="Interface").grid(row=0, column=0, sticky="w") - iface_var = tk.StringVar(value=can_iface) - iface_list = list_can_ifaces() or [can_iface] - iface_dd = ttk.Combobox(can_frame, textvariable=iface_var, values=iface_list, state="readonly", width=12) - iface_dd.grid(row=0, column=1, sticky="ew", padx=(6,0)) - - def refresh_ifaces(): - lst = list_can_ifaces() - if not lst: - messagebox.showwarning("Interfaces", "Keine can*/vcan* Interfaces gefunden.") - return - iface_dd.config(values=lst) - ttk.Button(can_frame, text="Refresh", command=refresh_ifaces).grid(row=0, column=2, padx=(6,0)) - - ttk.Label(can_frame, text="RESP-ID (hex)").grid(row=1, column=0, sticky="w") - resp_var = tk.StringVar(value=f"0x{resp_id:03X}") - ttk.Entry(can_frame, textvariable=resp_var, width=10).grid(row=1, column=1, sticky="w", padx=(6,0)) - - ttk.Label(can_frame, text="Timeout (ms)").grid(row=2, column=0, sticky="w") - to_var = tk.IntVar(value=int(timeout_ms)) - ttk.Spinbox(can_frame, from_=10, to=5000, increment=10, textvariable=to_var, width=8).grid(row=2, column=1, sticky="w", padx=(6,0)) - - ttk.Label(can_frame, text="Bitrate").grid(row=3, column=0, sticky="w") - br_var = tk.IntVar(value=int(bitrate)) - ttk.Spinbox(can_frame, from_=20000, to=1000000, increment=10000, textvariable=br_var, width=10).grid(row=3, column=1, sticky="w", padx=(6,0)) - - set_params = tk.BooleanVar(value=True) - ttk.Checkbutton(can_frame, text="Bitrate beim UP setzen", variable=set_params).grid(row=3, column=2, sticky="w") - - kind_label = ttk.Label(can_frame, text=f"Kind: {link_kind(can_iface)}", style="Small.TLabel") - kind_label.grid(row=4, column=0, columnspan=3, sticky="w", pady=(4,0)) - - # Buttons row - btns = ttk.Frame(can_frame) - btns.grid(row=5, column=0, columnspan=3, sticky="ew", pady=(8,0)) - btns.columnconfigure(0, weight=0) - btns.columnconfigure(1, weight=0) - btns.columnconfigure(2, weight=1) - - def do_link_up(): - try: - kind_label.config(text=f"Kind: {link_kind(iface_var.get())}") - if link_state(iface_var.get()) == "UP": - messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits UP"); return - link_up(iface_var.get(), bitrate=br_var.get(), fd=False, set_params=set_params.get()) - try: - out = subprocess.check_output(["ip", "-details", "-json", "link", "show", iface_var.get()], text=True) - info = json.loads(out)[0] - bt = (info.get("linkinfo", {}) or {}).get("info_data", {}).get("bittiming") or {} - br = bt.get("bitrate"); sp = bt.get("sample-point") - if br: - messagebox.showinfo("CAN", f"{iface_var.get()} ist UP @ {br} bit/s (sample-point {sp})") - except Exception: - pass - except Exception as e: - messagebox.showerror("CAN", f"Link UP fehlgeschlagen:{e}") - - def do_link_down(): - try: - if link_state(iface_var.get()) == "DOWN": - messagebox.showinfo("CAN", f"{iface_var.get()} ist bereits DOWN"); return - link_down(iface_var.get()); messagebox.showinfo("CAN", f"{iface_var.get()} ist DOWN") - except Exception as e: - messagebox.showerror("CAN", f"Link DOWN fehlgeschlagen:{e}") - - ttk.Button(btns, text="Link UP", command=do_link_up).grid(row=0, column=0, sticky="w") - ttk.Button(btns, text="Link DOWN", command=do_link_down).grid(row=0, column=1, sticky="w", padx=(6,0)) - - def do_rebind(): - nonlocal can_iface, resp_id, timeout_ms, bitrate, tracer - can_iface = iface_var.get() - try: - new_resp = int(resp_var.get(), 16) - except Exception: - messagebox.showerror("RESP-ID", "Bitte gültige Hex-Zahl, z.B. 0x7E8"); return - resp_id = new_resp; timeout_ms = to_var.get(); bitrate = br_var.get() - try: - responder.rebind(interface=can_iface, resp_id=resp_id) - tracer.stop(); tracer = TraceCollector(can_iface); tracer.start() - messagebox.showinfo("CAN", f"Responder neu gebunden: {can_iface}, RESP 0x{resp_id:03X}") - except Exception as e: - messagebox.showerror("CAN", f"Rebind fehlgeschlagen:{e}") - - ttk.Button(btns, text="Responder Rebind", command=do_rebind).grid(row=0, column=2, sticky="w", padx=(12,0)) - - ttk.Label(can_frame, text=f"CAP_NET_ADMIN: {'yes' if have_cap_netadmin() else 'no'}", style="Small.TLabel")\ - .grid(row=6, column=0, columnspan=3, sticky="w", pady=(6,0)) - - # --- RIGHT: Simulator Tabs -------------------------------------------- - right = ttk.Frame(root) - right.grid(row=0, column=1, sticky="nsew", padx=(4,8), pady=(8,4)) - right.columnconfigure(0, weight=1) - right.rowconfigure(0, weight=1) - - nb_sim = ttk.Notebook(right) - nb_sim.grid(row=0, column=0, sticky="nsew") - - basics_tab = BasicTab(nb_sim, sim) - engine_tab = EngineTab(nb_sim, sim) - gearbox_tab = GearboxTab(nb_sim, sim) - dtc_tab = DtcTab(nb_sim, sim) - dashboard_tab = DashboardTab(nb_sim, sim) - sim_tabs = [basics_tab, engine_tab, gearbox_tab, dtc_tab, dashboard_tab] - - nb_sim.add(basics_tab.frame, text="Basisdaten") - nb_sim.add(engine_tab.frame, text="Motor") - nb_sim.add(gearbox_tab.frame, text="Getriebe") - nb_sim.add(dtc_tab.frame, text="DTCs") - nb_sim.add(dashboard_tab.frame, text="Dashboard") - - # --- BOTTOM: Trace (spans both columns) ------------------------------- - trace_frame = ttk.LabelFrame(root, text="CAN Trace", padding=6) - trace_frame.grid(row=1, column=0, columnspan=2, sticky="nsew", padx=8, pady=(0,8)) - trace_frame.columnconfigure(0, weight=1) - trace_frame.rowconfigure(1, weight=1) - - ctrl = ttk.Frame(trace_frame) - ctrl.grid(row=0, column=0, sticky="ew", pady=(0,4)) - ctrl.columnconfigure(5, weight=1) - - mode_var = tk.StringVar(value="stream") - ttk.Label(ctrl, text="Modus:").grid(row=0, column=0, sticky="w") - ttk.Combobox(ctrl, textvariable=mode_var, state="readonly", width=10, values=["stream","aggregate"])\ - .grid(row=0, column=1, sticky="w", padx=(4,12)) - - paused = tk.BooleanVar(value=False) - ttk.Checkbutton(ctrl, text="Pause", variable=paused).grid(row=0, column=2, sticky="w") - - autoscroll = tk.BooleanVar(value=True) - ttk.Checkbutton(ctrl, text="Auto-Scroll", variable=autoscroll).grid(row=0, column=3, sticky="w") - - tree = ttk.Treeview(trace_frame, columns=("time","dir","id","dlc","data"), show="headings", height=10) - tree.grid(row=1, column=0, sticky="nsew") - sb_y = ttk.Scrollbar(trace_frame, orient="vertical", command=tree.yview) - tree.configure(yscrollcommand=sb_y.set); sb_y.grid(row=1, column=1, sticky="ns") - - def fmt_time(ts: float) -> str: - lt = time.localtime(ts) - return time.strftime("%H:%M:%S", lt) + f".{int((ts%1)*1000):03d}" - def fmt_id(i: int) -> str: return f"0x{i:03X}" - def fmt_data(b: bytes) -> str: return " ".join(f"{x:02X}" for x in b) - - last_index = 0 - def tick(): - nonlocal last_index - snap = sim.snapshot() - # Optional: könnte in eine Statusbar ausgelagert werden - root.title(f"OBD-II ECU Simulator – RPM {int(snap['rpm'])} | {int(round(snap['speed_kmh']))} km/h") - - if not paused.get(): - mode = mode_var.get() - buf = tracer.snapshot_stream() - if mode == "stream": - for ts, cid, dlc, data in buf[last_index:]: - d = "RX" if cid == 0x7DF else ("TX" if cid == responder.resp_id else "?") - tree.insert("", "end", values=(fmt_time(ts), d, fmt_id(cid), dlc, fmt_data(data))) - if autoscroll.get() and buf[last_index:]: - tree.see(tree.get_children()[-1]) - else: - tree.delete(*tree.get_children()) - agg = {} - for ts, cid, dlc, data in buf: - d = "RX" if cid == 0x7DF else ("TX" if cid == responder.resp_id else "?") - key = (cid, d) - e = agg.get(key) - if not e: - agg[key] = {"count":1, "last_ts":ts, "last_dlc":dlc, "last_data":data} - else: - e["count"] += 1 - if ts >= e["last_ts"]: - e["last_ts"], e["last_dlc"], e["last_data"] = ts, dlc, data - for (cid, d) in sorted(agg.keys(), key=lambda k:(k[0], 0 if k[1]=="RX" else 1)): - e = agg[(cid, d)] - tree.insert("", "end", values=(fmt_id(cid), d, e["count"], fmt_time(e["last_ts"]), e["last_dlc"], fmt_data(e["last_data"])) ) - last_index = len(buf) - - root.after(50, tick) - - tick() - - def on_close(): - nonlocal running - running = False - try: tracer.stop() - except Exception: pass - try: responder.stop() - finally: - root.destroy() - - root.protocol("WM_DELETE_WINDOW", on_close) - root.mainloop() \ No newline at end of file diff --git a/app/gui/__init__.py b/app/gui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/gui/can_panel.py b/app/gui/can_panel.py new file mode 100644 index 0000000..f6eded3 --- /dev/null +++ b/app/gui/can_panel.py @@ -0,0 +1,108 @@ +# app/gui/can_panel.py +from __future__ import annotations +import json, subprocess, tkinter as tk +from tkinter import ttk, messagebox +from app.can import ( + list_can_ifaces, link_up, link_down, link_state, link_kind, have_cap_netadmin +) + +class CanPanel: + """Fixes CAN- & Responder-Panel (oben links).""" + def __init__(self, parent, responder, initial_iface: str, initial_resp_id: int, + initial_timeout_ms: int, initial_bitrate: int, on_rebind_iface=None): + self.responder = responder + self.on_rebind_iface = on_rebind_iface or (lambda iface: None) + + self.frame = ttk.LabelFrame(parent, text="CAN & Settings", padding=8) + for i in range(3): self.frame.columnconfigure(i, weight=1) + + # Interface + ttk.Label(self.frame, text="Interface").grid(row=0, column=0, sticky="w") + self.iface_var = tk.StringVar(value=initial_iface) + self.iface_dd = ttk.Combobox(self.frame, textvariable=self.iface_var, + values=list_can_ifaces() or [initial_iface], + state="readonly", width=14) + self.iface_dd.grid(row=0, column=1, sticky="ew", padx=(6,0)) + ttk.Button(self.frame, text="Refresh", command=self._refresh_ifaces).grid(row=0, column=2, sticky="w") + + # RESP-ID + ttk.Label(self.frame, text="RESP-ID (hex)").grid(row=1, column=0, sticky="w") + self.resp_var = tk.StringVar(value=f"0x{initial_resp_id:03X}") + ttk.Entry(self.frame, textvariable=self.resp_var, width=10).grid(row=1, column=1, sticky="w", padx=(6,0)) + + # Timeout + ttk.Label(self.frame, text="Timeout (ms)").grid(row=2, column=0, sticky="w") + self.to_var = tk.IntVar(value=int(initial_timeout_ms)) + ttk.Spinbox(self.frame, from_=10, to=5000, increment=10, textvariable=self.to_var, width=10)\ + .grid(row=2, column=1, sticky="w", padx=(6,0)) + + # Bitrate + ttk.Label(self.frame, text="Bitrate").grid(row=3, column=0, sticky="w") + self.br_var = tk.IntVar(value=int(initial_bitrate)) + ttk.Spinbox(self.frame, from_=20000, to=1000000, increment=10000, textvariable=self.br_var, width=12)\ + .grid(row=3, column=1, sticky="w", padx=(6,0)) + + self.set_params = tk.BooleanVar(value=True) + ttk.Checkbutton(self.frame, text="Bitrate beim UP setzen", variable=self.set_params)\ + .grid(row=3, column=2, sticky="w") + + self.kind_label = ttk.Label(self.frame, text=f"Kind: {link_kind(initial_iface)}", style="Small.TLabel") + self.kind_label.grid(row=4, column=0, columnspan=3, sticky="w", pady=(4,0)) + + # Buttons + btns = ttk.Frame(self.frame); btns.grid(row=5, column=0, columnspan=3, sticky="ew", pady=(8,0)) + ttk.Button(btns, text="Link UP", command=self._do_link_up).grid(row=0, column=0, sticky="w") + ttk.Button(btns, text="Link DOWN", command=self._do_link_down).grid(row=0, column=1, sticky="w", padx=(6,0)) + ttk.Button(btns, text="Responder Rebind", command=self._do_rebind).grid(row=0, column=2, sticky="w", padx=(12,0)) + + ttk.Label(self.frame, text=f"CAP_NET_ADMIN: {'yes' if have_cap_netadmin() else 'no'}", + style="Small.TLabel").grid(row=6, column=0, columnspan=3, sticky="w", pady=(6,0)) + + # ---- actions ---- + def _refresh_ifaces(self): + lst = list_can_ifaces() + if not lst: + messagebox.showwarning("Interfaces", "Keine can*/vcan* Interfaces gefunden.") + return + self.iface_dd.config(values=lst) + + def _do_link_up(self): + iface = self.iface_var.get() + try: + self.kind_label.config(text=f"Kind: {link_kind(iface)}") + if link_state(iface) == "UP": + messagebox.showinfo("CAN", f"{iface} ist bereits UP"); return + link_up(iface, bitrate=self.br_var.get(), fd=False, set_params=self.set_params.get()) + try: + out = subprocess.check_output(["ip","-details","-json","link","show",iface], text=True) + info = json.loads(out)[0] + bt = (info.get("linkinfo", {}) or {}).get("info_data", {}).get("bittiming") or {} + br = bt.get("bitrate"); sp = bt.get("sample-point") + if br: + messagebox.showinfo("CAN", f"{iface} ist UP @ {br} bit/s (SP {sp})") + except Exception: + pass + except Exception as e: + messagebox.showerror("CAN", f"Link UP fehlgeschlagen:\n{e}") + + def _do_link_down(self): + iface = self.iface_var.get() + try: + if link_state(iface) == "DOWN": + messagebox.showinfo("CAN", f"{iface} ist bereits DOWN"); return + link_down(iface); messagebox.showinfo("CAN", f"{iface} ist DOWN") + except Exception as e: + messagebox.showerror("CAN", f"Link DOWN fehlgeschlagen:\n{e}") + + def _do_rebind(self): + iface = self.iface_var.get() + try: + new_resp = int(self.resp_var.get(), 16) + except Exception: + messagebox.showerror("RESP-ID", "Bitte gültige Hex-Zahl, z.B. 0x7E8"); return + try: + self.responder.rebind(interface=iface, resp_id=new_resp, timeout_ms=self.to_var.get()) + self.on_rebind_iface(iface) # TraceView umhängen + messagebox.showinfo("CAN", f"Responder neu gebunden: {iface}, RESP 0x{new_resp:03X}") + except Exception as e: + messagebox.showerror("CAN", f"Rebind fehlgeschlagen:\n{e}") diff --git a/app/gui/dashboard.py b/app/gui/dashboard.py new file mode 100644 index 0000000..5590785 --- /dev/null +++ b/app/gui/dashboard.py @@ -0,0 +1,65 @@ +# app/gui/dashboard.py +from __future__ import annotations +import tkinter as tk +from tkinter import ttk + +class DashboardView: + def __init__(self, parent, sim, refresh_ms: int = 250): + self.sim = sim + self.frame = ttk.LabelFrame(parent, text="Dashboard", padding=6) + self.refresh_ms = refresh_ms + + cols = ("label", "value", "unit", "key", "module") + self.tree = ttk.Treeview(self.frame, columns=cols, show="headings") + for c, w in zip(cols, (160, 80, 40, 160, 80)): + self.tree.heading(c, text=c.capitalize()) + self.tree.column(c, width=w, anchor="w") + self.tree.grid(row=0, column=0, sticky="nsew") + sb_y = ttk.Scrollbar(self.frame, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=sb_y.set); sb_y.grid(row=0, column=1, sticky="ns") + + self.frame.columnconfigure(0, weight=1) + self.frame.rowconfigure(0, weight=1) + + self._rows = {} + self._tick() + + def _tick(self): + snap = self.sim.v.dashboard_snapshot() + specs = snap.get("specs", {}) + values = snap.get("values", {}) + # sort by priority then label + ordered = sorted(specs.values(), key=lambda s: (s.get("priority", 100), s.get("label", s["key"]))) + + seen_keys = set() + for spec in ordered: + k = spec["key"]; seen_keys.add(k) + label = spec.get("label", k) + unit = spec.get("unit", "") or "" + fmt = spec.get("fmt") + src = spec.get("source", "") + val = values.get(k, "") + if fmt and isinstance(val, (int, float)): + try: + val = format(val, fmt) + except Exception: + pass + row_id = self._rows.get(k) + row_vals = (label, val, unit, k, src) + if row_id is None: + row_id = self.tree.insert("", "end", values=row_vals) + self._rows[k] = row_id + else: + self.tree.item(row_id, values=row_vals) + + # delete rows that disappeared + for k, rid in list(self._rows.items()): + if k not in seen_keys: + try: self.tree.delete(rid) + except Exception: pass + self._rows.pop(k, None) + + try: + self.frame.after(self.refresh_ms, self._tick) + except tk.TclError: + pass diff --git a/app/gui/trace.py b/app/gui/trace.py new file mode 100644 index 0000000..7fdaa6a --- /dev/null +++ b/app/gui/trace.py @@ -0,0 +1,161 @@ +# app/gui/trace.py +from __future__ import annotations +import time, json, threading +from collections import deque +import tkinter as tk +from tkinter import ttk, messagebox +import can + +from app.can import list_can_ifaces, link_up, link_down, link_state, link_kind, have_cap_netadmin + +class TraceCollector: + def __init__(self, channel: str): + self.channel = channel + self.bus = None + self._run = threading.Event(); self._run.set() + self._thread = threading.Thread(target=self._rx_loop, name="CAN-TRACE", daemon=True) + self.stream_buffer = deque(maxlen=2000) + self.lock = threading.Lock() + + def _open(self): + self._close() + self.bus = can.interface.Bus(channel=self.channel, interface="socketcan") + + def _close(self): + try: + if self.bus: self.bus.shutdown() + except Exception: + pass + self.bus = None + + def start(self): + self._thread.start() + + def stop(self): + self._run.clear() + try: self._thread.join(timeout=1.0) + except RuntimeError: pass + self._close() + + def _rx_loop(self): + backoff = 0.5 + while self._run.is_set(): + if self.bus is None: + if link_state(self.channel) == "UP": + try: + self._open(); backoff = 0.5 + except Exception: + time.sleep(backoff); backoff = min(5.0, backoff*1.7) + continue + else: + time.sleep(0.5); continue + try: + msg = self.bus.recv(0.05) + if msg and not msg.is_error_frame and not msg.is_remote_frame: + ts = time.time() + with self.lock: + self.stream_buffer.append((ts, msg.arbitration_id, msg.dlc, bytes(msg.data))) + except (can.CanOperationError, OSError): + self._close(); time.sleep(0.5) + except Exception: + time.sleep(0.05) + + def snapshot_stream(self): + with self.lock: + return list(self.stream_buffer) + +class TraceView: + def __init__(self, parent, responder, iface_initial: str): + self.responder = responder + self.collector = TraceCollector(iface_initial); self.collector.start() + + self.frame = ttk.LabelFrame(parent, text="CAN Trace", padding=6) + self.frame.columnconfigure(0, weight=1) + self.frame.rowconfigure(1, weight=1) + + # controls + ctrl = ttk.Frame(self.frame); ctrl.grid(row=0, column=0, sticky="ew", pady=(0,4)) + ctrl.columnconfigure(6, weight=1) + + self.mode_var = tk.StringVar(value="stream") + ttk.Label(ctrl, text="Modus:").grid(row=0, column=0, sticky="w") + ttk.Combobox(ctrl, textvariable=self.mode_var, state="readonly", width=10, values=["stream","aggregate"])\ + .grid(row=0, column=1, sticky="w", padx=(4,12)) + + self.paused = tk.BooleanVar(value=False) + ttk.Checkbutton(ctrl, text="Pause", variable=self.paused).grid(row=0, column=2, sticky="w") + + self.autoscroll = tk.BooleanVar(value=True) + ttk.Checkbutton(ctrl, text="Auto-Scroll", variable=self.autoscroll).grid(row=0, column=3, sticky="w") + + ttk.Button(ctrl, text="Clear", command=self._clear).grid(row=0, column=4, padx=(8,0)) + + # tree + self.tree = ttk.Treeview(self.frame, columns=("time","dir","id","dlc","data"), show="headings", height=10) + self.tree.grid(row=1, column=0, sticky="nsew") + sb_y = ttk.Scrollbar(self.frame, orient="vertical", command=self.tree.yview) + self.tree.configure(yscrollcommand=sb_y.set); sb_y.grid(row=1, column=1, sticky="ns") + + self._last_index = 0 + self._tick() + + def _clear(self): + self.tree.delete(*self.tree.get_children()) + self._last_index = 0 + + def _tick(self): + if not self.paused.get(): + buf = self.collector.snapshot_stream() + mode = self.mode_var.get() + if mode == "stream": + for ts, cid, dlc, data in buf[self._last_index:]: + d = "RX" if cid == 0x7DF else ("TX" if cid == self.responder.resp_id else "?") + self.tree.insert("", "end", values=(self._fmt_time(ts), d, self._fmt_id(cid), dlc, self._fmt_data(data))) + if self.autoscroll.get() and buf[self._last_index:]: + self.tree.see(self.tree.get_children()[-1]) + else: + self.tree.delete(*self.tree.get_children()) + agg = {} + for ts, cid, dlc, data in buf: + d = "RX" if cid == 0x7DF else ("TX" if cid == self.responder.resp_id else "?") + key = (cid, d) + e = agg.get(key) + if not e: + agg[key] = {"count":1, "last_ts":ts, "last_dlc":dlc, "last_data":data} + else: + e["count"] += 1 + if ts >= e["last_ts"]: + e["last_ts"], e["last_dlc"], e["last_data"] = ts, dlc, data + for (cid, d) in sorted(agg.keys(), key=lambda k:(k[0], 0 if k[1]=="RX" else 1)): + e = agg[(cid, d)] + self.tree.insert("", "end", + values=(self._fmt_id(cid), d, e["count"], + self._fmt_time(e["last_ts"]), e["last_dlc"], self._fmt_data(e["last_data"]))) + self._last_index = len(buf) + try: + self.frame.after(50, self._tick) + except tk.TclError: + pass + + @staticmethod + def _fmt_time(ts: float) -> str: + import time as _t + lt = _t.localtime(ts) + return _t.strftime("%H:%M:%S", lt) + f".{int((ts%1)*1000):03d}" + @staticmethod + def _fmt_id(i: int) -> str: return f"0x{i:03X}" + @staticmethod + def _fmt_data(b: bytes) -> str: return " ".join(f"{x:02X}" for x in b) + + def rebind_interface(self, iface: str): + # Collector auf neues Interface umhängen + try: + self.collector.stop() + except Exception: + pass + self.collector = TraceCollector(iface) + self.collector.start() + + def stop(self): + try: self.collector.stop() + except Exception: pass diff --git a/app/simulation/modules/abs.py b/app/simulation/modules/abs.py deleted file mode 100644 index 3d42092..0000000 --- a/app/simulation/modules/abs.py +++ /dev/null @@ -1,11 +0,0 @@ -# app/simulation/modules/abs.py -from __future__ import annotations -from ..vehicle import Vehicle, Module - -class AbsModule(Module): - """Stub: deceleration limiting if ABS enabled (future: needs braking input).""" - def apply(self, v: Vehicle, dt: float) -> None: - _abs = bool(v.config.get("vehicle", {}).get("abs", True)) - if not _abs: - return - # braking model folgt später diff --git a/app/simulation/modules/basic.py b/app/simulation/modules/basic.py index af9e412..150a5eb 100644 --- a/app/simulation/modules/basic.py +++ b/app/simulation/modules/basic.py @@ -1,6 +1,9 @@ +# ============================= # app/simulation/modules/basic.py +# ============================= + from __future__ import annotations -from ..vehicle import Vehicle, Module +from app.simulation.simulator import Module, Vehicle import bisect def _ocv_from_soc(soc: float, table: dict[float, float]) -> float: @@ -17,6 +20,8 @@ def _ocv_from_soc(soc: float, table: dict[float, float]) -> float: return y0 + t*(y1 - y0) class BasicModule(Module): + PRIO = 10 + NAME = "basic" """ - Zündungslogik inkl. START→ON nach crank_time_s - Ambient-Temperatur als globale Umweltgröße diff --git a/app/simulation/modules/engine.py b/app/simulation/modules/engine.py index 6b17821..3e2d3f4 100644 --- a/app/simulation/modules/engine.py +++ b/app/simulation/modules/engine.py @@ -3,7 +3,7 @@ # ============================= from __future__ import annotations -from ..vehicle import Vehicle, Module +from app.simulation.simulator import Module, Vehicle import random, math # Ein einziger Wahrheitsanker für alle Defaults: @@ -50,6 +50,8 @@ ENGINE_DEFAULTS = { } class EngineModule(Module): + PRIO = 20 + NAME = "engine" """ Erweiterte Motormodellierung mit realistischem Jitter & Drive-by-Wire: - OFF/ACC/ON/START Logik, Starten/Abwürgen @@ -310,16 +312,17 @@ class EngineModule(Module): self._rpm_noise *= 0.9 # --- Klammern & Setzen ----------------------------------------------------- - rpm = max(0.0, min(rpm, maxr)) - cool = max(-40.0, min(cool, 120.0)) - oil = max(-40.0, min(oil, 150.0)) + rpm = max(0.0, min(rpm, maxr)) + cool = max(-40.0, min(cool, 120.0)) + oil = max(-40.0, min(oil, 150.0)) oil_p = max(oil_floor_off if not self._running else oil_floor_off, min(8.0, oil_p)) v.set("rpm", int(rpm)) - v.set("coolant_temp", round(cool, 1)) - v.set("oil_temp", round(oil, 1)) - v.set("oil_pressure", round(oil_p, 2)) + # WICHTIG: NICHT runden – das macht das Dashboard per fmt + v.set("coolant_temp", float(cool)) + v.set("oil_temp", float(oil)) + v.set("oil_pressure", float(oil_p)) v.set("engine_available_torque_nm", float(avail_torque)) v.set("engine_net_torque_nm", float(net_torque)) v.set("throttle_pedal_pct", float(pedal)) - v.set("throttle_plate_pct", float(self._plate_pct)) + v.set("throttle_plate_pct", float(self._plate_pct)) \ No newline at end of file diff --git a/app/simulation/modules/gearbox.py b/app/simulation/modules/gearbox.py index 1e05141..aa3ec22 100644 --- a/app/simulation/modules/gearbox.py +++ b/app/simulation/modules/gearbox.py @@ -1,8 +1,13 @@ +# ============================= # app/simulation/modules/gearbox.py +# ============================= + from __future__ import annotations -from ..vehicle import Vehicle, Module +from app.simulation.simulator import Module, Vehicle class GearboxModule(Module): + PRIO = 30 + NAME = "gearbox" """Koppelt Engine-RPM ↔ Wheel-Speed; registriert speed_kmh/gear fürs Dashboard.""" def __init__(self): self.speed_tau = 0.3 diff --git a/app/simulation/simulator.py b/app/simulation/simulator.py new file mode 100644 index 0000000..c722ec1 --- /dev/null +++ b/app/simulation/simulator.py @@ -0,0 +1,213 @@ +# app/simulation/simulator.py +from __future__ import annotations +from dataclasses import dataclass, field +from typing import Dict, Any, List, Optional, Tuple, Type +import importlib, pkgutil, inspect, pathlib + +# ---------------------- Core: Vehicle + Accumulator-API ---------------------- + +@dataclass +class Vehicle: + """ + State-/Config-Container + Dashboard-Registry + generische Frame-Akkumulatoren. + + Grundprinzip: + - set(key, value): harter Setzer (eine Quelle „besitzt“ den Wert) + - get/ensure: lesen/initialisieren + - push(key, delta, source): additiv beitragen (Source/Sink über Vorzeichen) + - acc_total(key): Summe aller Beiträge in diesem Frame + - acc_breakdown(key): Beiträge je Quelle (Debug/Transparenz) + - acc_reset(): zu Framebeginn alle Akkus löschen + + Konvention (Empfehlung, aber nicht erzwungen): + * Positive Beiträge „belasten“ (z. B. Widerstandsmoment, Laststrom) + * Negative Beiträge „speisen“ (z. B. Generator-Moment, Einspeisestrom) + """ + state: Dict[str, Any] = field(default_factory=dict) + config: Dict[str, Any] = field(default_factory=dict) + dtc: Dict[str, bool] = field(default_factory=dict) + + dashboard_specs: Dict[str, Dict[str, Any]] = field(default_factory=dict) + + # Accumulatoren: key -> {source_name: float} + _acc: Dict[str, Dict[str, float]] = field(default_factory=dict) + + # ---- state helpers ---- + def get(self, key: str, default: Any = None) -> Any: + return self.state.get(key, default) + + def set(self, key: str, value: Any) -> None: + self.state[key] = value + + def ensure(self, key: str, default: Any) -> Any: + if key not in self.state: + self.state[key] = default + return self.state[key] + + # ---- dashboard helpers ---- + def register_metric( + self, key: str, *, + label: Optional[str] = None, + unit: Optional[str] = None, + fmt: Optional[str] = None, + source: Optional[str] = None, + priority: int = 100, + overwrite: bool = False, + ) -> None: + spec = self.dashboard_specs.get(key) + if spec and not overwrite: + if label and not spec.get("label"): spec["label"] = label + if unit and not spec.get("unit"): spec["unit"] = unit + if fmt and not spec.get("fmt"): spec["fmt"] = fmt + if source and not spec.get("source"): spec["source"] = source + if spec.get("priority") is None: spec["priority"] = priority + return + self.dashboard_specs[key] = { + "key": key, "label": label or key, "unit": unit, "fmt": fmt, + "source": source, "priority": priority, + } + + def dashboard_snapshot(self) -> Dict[str, Any]: + return {"specs": dict(self.dashboard_specs), "values": dict(self.state)} + + def snapshot(self) -> Dict[str, Any]: + return dict(self.state) + + # ---- generic accumulators (per-frame) ---- + def acc_reset(self) -> None: + self._acc.clear() + + def push(self, key: str, delta: float, source: Optional[str] = None) -> None: + """ + Additiver Beitrag zu einer Größe. + Vorzeichen: + belastet / - speist (Empfehlung). + """ + src = source or "anon" + bucket = self._acc.setdefault(key, {}) + bucket[src] = bucket.get(src, 0.0) + float(delta) + + def acc_total(self, key: str) -> float: + bucket = self._acc.get(key) + if not bucket: return 0.0 + return sum(bucket.values()) + + def acc_breakdown(self, key: str) -> Dict[str, float]: + return dict(self._acc.get(key, {})) + + # ---- Backwards-compat convenience for your current Basic code ---- + def elec_reset_frame(self) -> None: + # map legacy helpers auf generisches System + # loads + sources werden in einem Kanal gesammelt + # (loads positiv, sources negativ) + # Diese Methode ist mittlerweile redundant, acc_reset() macht alles. + pass + + def elec_add_load(self, name: str, amps: float) -> None: + self.push("elec.current", +max(0.0, float(amps)), source=name) + + def elec_add_source(self, name: str, amps: float) -> None: + self.push("elec.current", -max(0.0, float(amps)), source=name) + + def elec_totals(self) -> Tuple[float, float]: + """ + Gibt (loads_a_positiv, sources_a_positiv) zurück. + Intern liegt alles algebraisch in 'elec.current'. + """ + bd = self.acc_breakdown("elec.current") + loads = sum(v for v in bd.values() if v > 0) + sources = sum(-v for v in bd.values() if v < 0) + return (loads, sources) + +# ---------------------------- Module Base + Loader ---------------------------- + +class Module: + """ + Basisklasse für alle Module. Jedes Modul: + - deklariert PRIO (klein = früher) + - hat NAME (für Debug/Registry) + - implementiert apply(v, dt) + """ + PRIO: int = 100 + NAME: str = "module" + + def apply(self, v: Vehicle, dt: float) -> None: + raise NotImplementedError + +def _discover_modules(pkg_name: str = "app.simulation.modules") -> List[Module]: + """ + Sucht in app/simulation/modules nach Klassen, die Module erben, + instanziert sie und sortiert nach PRIO. + """ + mods: List[Module] = [] + try: + pkg = importlib.import_module(pkg_name) + except Exception as exc: + raise RuntimeError(f"Module package '{pkg_name}' konnte nicht geladen werden: {exc}") + + pkg_path = pathlib.Path(pkg.__file__).parent + for _, modname, ispkg in pkgutil.iter_modules([str(pkg_path)]): + if ispkg: # optional: auch Subpackages zulassen + continue + full_name = f"{pkg_name}.{modname}" + try: + m = importlib.import_module(full_name) + except Exception as exc: + print(f"[loader] Fehler beim Import {full_name}: {exc}") + continue + + for _, obj in inspect.getmembers(m, inspect.isclass): + if not issubclass(obj, Module): + continue + if obj is Module: + continue + try: + inst = obj() # Module ohne args + except Exception as exc: + print(f"[loader] Kann {obj.__name__} nicht instanziieren: {exc}") + continue + mods.append(inst) + + # sortieren nach PRIO; bei Gleichstand NAME als Tie-Break + mods.sort(key=lambda x: (getattr(x, "PRIO", 100), getattr(x, "NAME", x.__class__.__name__))) + return mods + +# ------------------------------- Simulator API -------------------------------- + +class VehicleSimulator: + """ + Öffentliche Fassade für GUI/Tests. + Lädt Module dynamisch, führt sie pro Tick in PRIO-Reihenfolge aus. + """ + def __init__(self, modules_package: str = "app.simulation.modules"): + self.v = Vehicle() + self.modules: List[Module] = _discover_modules(modules_package) + + def update(self, dt: float) -> None: + # pro Frame alle Akkumulatoren leeren + self.v.acc_reset() + for m in self.modules: + try: + m.apply(self.v, dt) + except Exception as exc: + print(f"[sim] Modul {getattr(m, 'NAME', m.__class__.__name__)} Fehler: {exc}") + + # Kompatible Hilfsfunktionen für GUI + def snapshot(self) -> Dict[str, Any]: + return self.v.snapshot() + + def load_config(self, cfg: Dict[str, Any]) -> None: + # Namespaced-Merge; Keys bleiben modul-spezifisch + for k, sub in cfg.items(): + self.v.config.setdefault(k, {}).update(sub if isinstance(sub, dict) else {}) + if "dtc" in cfg: + self.v.dtc.update(cfg["dtc"]) + + def export_config(self) -> Dict[str, Any]: + return {ns: dict(data) for ns, data in self.v.config.items()} | {"dtc": dict(self.v.dtc)} + + # für alte GUI-Knöpfe + def set_gear(self, g: int) -> None: + self.v.set("gear", max(0, min(10, int(g)))) + + def set_throttle(self, t: int) -> None: + self.v.set("throttle_pct", max(0, min(100, int(t)))) # falls noch genutzt diff --git a/app/simulation/simulator_main.py b/app/simulation/simulator_main.py deleted file mode 100644 index 4dffaf8..0000000 --- a/app/simulation/simulator_main.py +++ /dev/null @@ -1,46 +0,0 @@ -# app/simulation/simulator_main.py -from __future__ import annotations -from typing import Dict, Any -from .vehicle import Vehicle, Orchestrator -from .modules.engine import EngineModule -from .modules.gearbox import GearboxModule -from .modules.abs import AbsModule -from .modules.basic import BasicModule - -class VehicleSimulator: - def __init__(self): - self.v = Vehicle() - self.orch = Orchestrator(self.v) - # order matters: base → engine → gearbox → abs - self.orch.add(BasicModule()) - self.orch.add(EngineModule()) - self.orch.add(GearboxModule()) - self.orch.add(AbsModule()) - - # control from GUI - def set_gear(self, g: int) -> None: - self.v.set("gear", max(0, min(10, int(g)))) - def set_throttle(self, t: int) -> None: - self.v.set("throttle_pct", max(0, min(100, int(t)))) - - def update(self, dt: float) -> None: - self.orch.update(dt) - - def snapshot(self) -> Dict[str, Any]: - return self.v.snapshot() - - # config I/O (compat with old layout) - def load_config(self, cfg: Dict[str, Any]) -> None: - for k in ("engine","gearbox","vehicle"): - if k in cfg: - self.v.config.setdefault(k, {}).update(cfg[k]) - if "dtc" in cfg: - self.v.dtc.update(cfg["dtc"]) - - def export_config(self) -> Dict[str, Any]: - return { - "engine": dict(self.v.config.get("engine", {})), - "gearbox": dict(self.v.config.get("gearbox", {})), - "vehicle": dict(self.v.config.get("vehicle", {})), - "dtc": dict(self.v.dtc), - } diff --git a/app/simulation/ui/__init__.py b/app/simulation/ui/__init__.py new file mode 100644 index 0000000..a2ca1cc --- /dev/null +++ b/app/simulation/ui/__init__.py @@ -0,0 +1,52 @@ +# ============================= +# app/simulation/ui/__init__.py +# ============================= + +from __future__ import annotations +from typing import List, Optional, Type +import importlib, inspect, pkgutil, pathlib + +class UITab: + """ + Basis für alle Tabs. Erwarte: + - class-attr: NAME, TITLE, PRIO + - __init__(parent, sim) erzeugt self.frame (tk.Frame/ttk.Frame) + - optionale Methoden: apply(), save_into_config(out), load_from_config(cfg) + """ + NAME: str = "tab" + TITLE: str = "Tab" + PRIO: int = 100 + + # No-ops für Save/Load + def apply(self): pass + def save_into_config(self, out): pass + def load_from_config(self, cfg): pass + +def discover_ui_tabs(parent, sim, pkg_name: str = "app.simulation.ui") -> List[UITab]: + """Lädt alle Unter-Module von pkg_name, instanziiert Klassen, die UITab erben.""" + tabs: List[UITab] = [] + pkg = importlib.import_module(pkg_name) + pkg_path = pathlib.Path(pkg.__file__).parent + + for _, modname, ispkg in pkgutil.iter_modules([str(pkg_path)]): + if ispkg: # (optional: Subpackages zulassen – hier überspringen) + continue + full = f"{pkg_name}.{modname}" + try: + m = importlib.import_module(full) + except Exception as exc: + print(f"[ui-loader] Importfehler {full}: {exc}") + continue + + for _, obj in inspect.getmembers(m, inspect.isclass): + if obj is UITab or not issubclass(obj, UITab): + continue + try: + inst = obj(parent, sim) + except Exception as exc: + print(f"[ui-loader] Instanzierung fehlgeschlagen {obj.__name__}: {exc}") + continue + tabs.append(inst) + + tabs.sort(key=lambda t: (getattr(t, "PRIO", 100), getattr(t, "NAME", t.__class__.__name__))) + return tabs diff --git a/app/tabs/basic.py b/app/simulation/ui/basic.py similarity index 97% rename from app/tabs/basic.py rename to app/simulation/ui/basic.py index 6e01d4d..0ad74ed 100644 --- a/app/tabs/basic.py +++ b/app/simulation/ui/basic.py @@ -1,10 +1,17 @@ -# app/tabs/basic.py +# ============================= +# app/simulation/ui/basic.py +# ============================= + from __future__ import annotations import tkinter as tk from tkinter import ttk from typing import Dict, Any +from app.simulation.ui import UITab -class BasicTab: +class BasicTab(UITab): + NAME = "basic" + TITLE = "Basisdaten" + PRIO = 10 """Basis-Fahrzeug-Tab (Zündung & Elektrik).""" def __init__(self, parent, sim): diff --git a/app/tabs/dtc.py b/app/simulation/ui/dtc.py similarity index 89% rename from app/tabs/dtc.py rename to app/simulation/ui/dtc.py index 3a3c16c..9d6b916 100644 --- a/app/tabs/dtc.py +++ b/app/simulation/ui/dtc.py @@ -1,11 +1,12 @@ # ============================= -# app/tabs/dtc.py +# app/simulation/ui/dtc.py # ============================= from __future__ import annotations import tkinter as tk from tkinter import ttk from typing import Dict, Any +from app.simulation.ui import UITab DTC_LIST = [ ("P0300", "Random/Multiple Cylinder Misfire"), @@ -14,7 +15,10 @@ DTC_LIST = [ ("U0121", "Lost Communication With ABS") ] -class DtcTab: +class DtcTab(UITab): + NAME = "dtc" + TITLE = "Fehlercodes" + PRIO = 10 def __init__(self, parent, sim): self.sim = sim self.frame = ttk.Frame(parent, padding=8) diff --git a/app/tabs/engine.py b/app/simulation/ui/engine.py similarity index 97% rename from app/tabs/engine.py rename to app/simulation/ui/engine.py index 6c9cdf8..64daac9 100644 --- a/app/tabs/engine.py +++ b/app/simulation/ui/engine.py @@ -1,12 +1,20 @@ -# app/tabs/engine.py +# ============================= +# app/simulation/ui/engine.py +# ============================= + from __future__ import annotations import tkinter as tk from tkinter import ttk from typing import Dict, Any # Wichtig: Defaults aus dem Modul importieren from app.simulation.modules.engine import ENGINE_DEFAULTS +from app.simulation.ui import UITab -class EngineTab: + +class EngineTab(UITab): + NAME = "engine" + TITLE = "Motor" + PRIO = 10 def __init__(self, parent, sim): self.sim = sim self.frame = ttk.Frame(parent, padding=8) diff --git a/app/tabs/gearbox.py b/app/simulation/ui/gearbox.py similarity index 95% rename from app/tabs/gearbox.py rename to app/simulation/ui/gearbox.py index d7f62ce..1657169 100644 --- a/app/tabs/gearbox.py +++ b/app/simulation/ui/gearbox.py @@ -1,13 +1,19 @@ # ============================= -# app/tabs/gearbox.py +# app/simulation/ui/gearbox.py # ============================= from __future__ import annotations import tkinter as tk from tkinter import ttk from typing import Dict, Any, List +from app.simulation.ui import UITab + + +class GearboxTab(UITab): + NAME = "gearbox" + TITLE = "Getriebe" + PRIO = 10 -class GearboxTab: def __init__(self, parent, sim): self.sim = sim self.frame = ttk.Frame(parent, padding=8) diff --git a/app/simulation/vehicle.py b/app/simulation/vehicle.py deleted file mode 100644 index 1d0529a..0000000 --- a/app/simulation/vehicle.py +++ /dev/null @@ -1,122 +0,0 @@ -# app/simulation/vehicle.py -from __future__ import annotations -from dataclasses import dataclass, field -from typing import Dict, Any, List - -@dataclass -class Vehicle: - """Dynamic property-bag vehicle.""" - state: Dict[str, Any] = field(default_factory=lambda: { - "rpm": 1400, - "speed_kmh": 0.0, - "gear": 0, - "throttle_pct": 0, - "ignition": "OFF", - # elektrische Live-Werte - "battery_voltage": 12.6, # Batterie-Klemmenspannung - "elx_voltage": 0.0, # Bordnetz/Bus-Spannung - "system_voltage": 12.4, # alias - "battery_soc": 0.80, # 0..1 - "battery_current_a": 0.0, # + entlädt, – lädt - "alternator_current_a": 0.0, # von Lima geliefert - "elec_load_total_a": 0.0, # Summe aller Verbraucher - "ambient_c": 20.0, - }) - - config: Dict[str, Any] = field(default_factory=lambda: { - "vehicle": { - "type": "motorcycle", - "mass_kg": 210.0, - "abs": True, - "tcs": False, - }, - # Elektrik-Parameter (global) - "electrical": { - "battery_capacity_ah": 8.0, - "battery_r_int_ohm": 0.020, # ~20 mΩ - # sehr einfache OCV(SOC)-Kennlinie - "battery_ocv_v": { # bei ~20°C - 0.0: 11.8, 0.1: 12.0, 0.2: 12.1, 0.3: 12.2, 0.4: 12.3, - 0.5: 12.45, 0.6: 12.55, 0.7: 12.65, 0.8: 12.75, 0.9: 12.85, - 1.0: 12.95 - }, - "alternator_reg_v": 14.2, - "alternator_rated_a": 20.0, # Nennstrom - "alt_cut_in_rpm": 1500, # ab hier fängt sie an zu liefern - "alt_full_rpm": 4000, # ab hier volle Kapazität - }, - }) - - dtc: Dict[str, bool] = field(default_factory=dict) - dashboard_specs: Dict[str, Dict[str, Any]] = field(default_factory=dict) - - # accumulator für dieses Sim-Frame - _elec_loads_a: Dict[str, float] = field(default_factory=dict) - _elec_sources_a: Dict[str, float] = field(default_factory=dict) - - # ---- helpers for modules ---- - def get(self, key: str, default: Any = None) -> Any: - return self.state.get(key, default) - - def set(self, key: str, value: Any) -> None: - self.state[key] = value - - def ensure(self, key: str, default: Any) -> Any: - return self.state.setdefault(key, default) - - # Dashboard registry (wie gehabt) - def register_metric(self, key: str, *, label: str | None = None, unit: str | None = None, - fmt: str | None = None, source: str | None = None, - priority: int = 100, overwrite: bool = False) -> None: - spec = self.dashboard_specs.get(key) - if spec and not overwrite: - if label and not spec.get("label"): spec["label"] = label - if unit and not spec.get("unit"): spec["unit"] = unit - if fmt and not spec.get("fmt"): spec["fmt"] = fmt - if source and not spec.get("source"): spec["source"] = source - if spec.get("priority") is None: spec["priority"] = priority - return - self.dashboard_specs[key] = { - "key": key, "label": label or key, "unit": unit, "fmt": fmt, - "source": source, "priority": priority, - } - - def dashboard_snapshot(self) -> Dict[str, Any]: - return {"specs": dict(self.dashboard_specs), "values": dict(self.state)} - - def snapshot(self) -> Dict[str, Any]: - return dict(self.state) - - # ---- Electrical frame helpers ---- - def elec_reset_frame(self) -> None: - self._elec_loads_a.clear() - self._elec_sources_a.clear() - - def elec_add_load(self, name: str, amps: float) -> None: - # positive Werte = Stromaufnahme - self._elec_loads_a[name] = max(0.0, float(amps)) - - def elec_add_source(self, name: str, amps: float) -> None: - # positive Werte = Einspeisung - self._elec_sources_a[name] = max(0.0, float(amps)) - - def elec_totals(self) -> tuple[float, float]: - return sum(self._elec_loads_a.values()), sum(self._elec_sources_a.values()) - -class Module: - def apply(self, v: Vehicle, dt: float) -> None: - pass - -class Orchestrator: - def __init__(self, vehicle: Vehicle): - self.vehicle = vehicle - self.modules: List[Module] = [] - - def add(self, m: Module): - self.modules.append(m) - - def update(self, dt: float): - # Pro Frame die Electrical-Recorder nullen - self.vehicle.elec_reset_frame() - for m in self.modules: - m.apply(self.vehicle, dt) diff --git a/app/tabs/__init__.py b/app/tabs/__init__.py deleted file mode 100644 index 38c4640..0000000 --- a/app/tabs/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -# ============================= -# app/tabs/__init__.py -# ============================= - -from __future__ import annotations -from dataclasses import dataclass -from typing import Protocol, Dict, Any - -class SimTab(Protocol): - frame: any - def save_into_config(self, out: Dict[str, Any]) -> None: ... - def load_from_config(self, cfg: Dict[str, Any]) -> None: ... diff --git a/app/tabs/dashboard.py b/app/tabs/dashboard.py deleted file mode 100644 index 210eaf7..0000000 --- a/app/tabs/dashboard.py +++ /dev/null @@ -1,77 +0,0 @@ -# app/tabs/dashboard.py -from __future__ import annotations -import tkinter as tk -from tkinter import ttk - -class DashboardTab: - """Zeigt dynamisch alle im Vehicle registrierten Dashboard-Metriken.""" - def __init__(self, parent, sim): - self.sim = sim - self.frame = ttk.Frame(parent, padding=8) - self.tree = ttk.Treeview(self.frame, columns=("label","value","unit","key","source"), show="headings", height=12) - self.tree.heading("label", text="Parameter") - self.tree.heading("value", text="Wert") - self.tree.heading("unit", text="Einheit") - self.tree.heading("key", text="Key") - self.tree.heading("source",text="Modul") - self.tree.column("label", width=180, anchor="w") - self.tree.column("value", width=120, anchor="e") - self.tree.column("unit", width=80, anchor="w") - self.tree.column("key", width=180, anchor="w") - self.tree.column("source",width=100, anchor="w") - self.tree.grid(row=0, column=0, sticky="nsew") - sb = ttk.Scrollbar(self.frame, orient="vertical", command=self.tree.yview) - self.tree.configure(yscrollcommand=sb.set) - sb.grid(row=0, column=1, sticky="ns") - self.frame.columnconfigure(0, weight=1) - self.frame.rowconfigure(0, weight=1) - - self._last_keys = None - self._tick() - - def _format_value(self, val, fmt): - if fmt: - try: - return f"{val:{fmt}}" - except Exception: - return str(val) - return str(val) - - def _tick(self): - snap = self.sim.v.dashboard_snapshot() - specs = snap["specs"] - values = snap["values"] - - keys = sorted(specs.keys(), key=lambda k: (specs[k].get("priority", 999), specs[k].get("label", k))) - if keys != self._last_keys: - # rebuild table - for item in self.tree.get_children(): - self.tree.delete(item) - for k in keys: - spec = specs[k] - lbl = spec.get("label", k) - unit = spec.get("unit", "") - src = spec.get("source", "") - val = self._format_value(values.get(k, ""), spec.get("fmt")) - self.tree.insert("", "end", iid=k, values=(lbl, val, unit, k, src)) - self._last_keys = keys - else: - # update values only - for k in keys: - spec = specs[k] - val = self._format_value(values.get(k, ""), spec.get("fmt")) - try: - self.tree.set(k, "value", val) - except tk.TclError: - pass - - try: - self.frame.after(200, self._tick) - except tk.TclError: - pass - - # Config-API no-ops (für Konsistenz mit anderen Tabs) - def save_into_config(self, out): # pragma: no cover - pass - def load_from_config(self, cfg): # pragma: no cover - pass diff --git a/main.py b/main.py index 39a5adb..a45ae4f 100644 --- a/main.py +++ b/main.py @@ -1,5 +1,5 @@ # main.py -from app.gui import launch_gui +from app.app import launch_gui if __name__ == "__main__": launch_gui()