made everything modular
This commit is contained in:
		
							
								
								
									
										132
									
								
								app/app.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								app/app.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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() | ||||
							
								
								
									
										405
									
								
								app/gui.py
									
									
									
									
									
								
							
							
						
						
									
										405
									
								
								app/gui.py
									
									
									
									
									
								
							| @@ -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() | ||||
							
								
								
									
										0
									
								
								app/gui/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								app/gui/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										108
									
								
								app/gui/can_panel.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								app/gui/can_panel.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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}") | ||||
							
								
								
									
										65
									
								
								app/gui/dashboard.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										65
									
								
								app/gui/dashboard.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
							
								
								
									
										161
									
								
								app/gui/trace.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										161
									
								
								app/gui/trace.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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 | ||||
| @@ -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 | ||||
|   | ||||
| @@ -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)) | ||||
| @@ -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 | ||||
|   | ||||
							
								
								
									
										213
									
								
								app/simulation/simulator.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										213
									
								
								app/simulation/simulator.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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), | ||||
|         } | ||||
							
								
								
									
										52
									
								
								app/simulation/ui/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								app/simulation/ui/__init__.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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 | ||||
| @@ -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): | ||||
| @@ -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) | ||||
| @@ -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) | ||||
| @@ -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) | ||||
| @@ -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) | ||||
| @@ -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: ... | ||||
| @@ -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 | ||||
		Reference in New Issue
	
	Block a user