made everything modular

This commit is contained in:
2025-09-05 01:03:14 +02:00
parent 268dc201bf
commit 0276a3fb3c
21 changed files with 788 additions and 692 deletions

132
app/app.py Normal file
View 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()

View File

@@ -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
View File

108
app/gui/can_panel.py Normal file
View 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
View 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
View 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

View File

@@ -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

View File

@@ -1,6 +1,9 @@
# =============================
# app/simulation/modules/basic.py # app/simulation/modules/basic.py
# =============================
from __future__ import annotations from __future__ import annotations
from ..vehicle import Vehicle, Module from app.simulation.simulator import Module, Vehicle
import bisect import bisect
def _ocv_from_soc(soc: float, table: dict[float, float]) -> float: 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) return y0 + t*(y1 - y0)
class BasicModule(Module): class BasicModule(Module):
PRIO = 10
NAME = "basic"
""" """
- Zündungslogik inkl. START→ON nach crank_time_s - Zündungslogik inkl. START→ON nach crank_time_s
- Ambient-Temperatur als globale Umweltgröße - Ambient-Temperatur als globale Umweltgröße

View File

@@ -3,7 +3,7 @@
# ============================= # =============================
from __future__ import annotations from __future__ import annotations
from ..vehicle import Vehicle, Module from app.simulation.simulator import Module, Vehicle
import random, math import random, math
# Ein einziger Wahrheitsanker für alle Defaults: # Ein einziger Wahrheitsanker für alle Defaults:
@@ -50,6 +50,8 @@ ENGINE_DEFAULTS = {
} }
class EngineModule(Module): class EngineModule(Module):
PRIO = 20
NAME = "engine"
""" """
Erweiterte Motormodellierung mit realistischem Jitter & Drive-by-Wire: Erweiterte Motormodellierung mit realistischem Jitter & Drive-by-Wire:
- OFF/ACC/ON/START Logik, Starten/Abwürgen - OFF/ACC/ON/START Logik, Starten/Abwürgen
@@ -310,15 +312,16 @@ class EngineModule(Module):
self._rpm_noise *= 0.9 self._rpm_noise *= 0.9
# --- Klammern & Setzen ----------------------------------------------------- # --- Klammern & Setzen -----------------------------------------------------
rpm = max(0.0, min(rpm, maxr)) rpm = max(0.0, min(rpm, maxr))
cool = max(-40.0, min(cool, 120.0)) cool = max(-40.0, min(cool, 120.0))
oil = max(-40.0, min(oil, 150.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)) 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("rpm", int(rpm))
v.set("coolant_temp", round(cool, 1)) # WICHTIG: NICHT runden das macht das Dashboard per fmt
v.set("oil_temp", round(oil, 1)) v.set("coolant_temp", float(cool))
v.set("oil_pressure", round(oil_p, 2)) 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_available_torque_nm", float(avail_torque))
v.set("engine_net_torque_nm", float(net_torque)) v.set("engine_net_torque_nm", float(net_torque))
v.set("throttle_pedal_pct", float(pedal)) v.set("throttle_pedal_pct", float(pedal))

View File

@@ -1,8 +1,13 @@
# =============================
# app/simulation/modules/gearbox.py # app/simulation/modules/gearbox.py
# =============================
from __future__ import annotations from __future__ import annotations
from ..vehicle import Vehicle, Module from app.simulation.simulator import Module, Vehicle
class GearboxModule(Module): class GearboxModule(Module):
PRIO = 30
NAME = "gearbox"
"""Koppelt Engine-RPM ↔ Wheel-Speed; registriert speed_kmh/gear fürs Dashboard.""" """Koppelt Engine-RPM ↔ Wheel-Speed; registriert speed_kmh/gear fürs Dashboard."""
def __init__(self): def __init__(self):
self.speed_tau = 0.3 self.speed_tau = 0.3

213
app/simulation/simulator.py Normal file
View 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

View File

@@ -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),
}

View 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

View File

@@ -1,10 +1,17 @@
# app/tabs/basic.py # =============================
# app/simulation/ui/basic.py
# =============================
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
from typing import Dict, Any 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).""" """Basis-Fahrzeug-Tab (Zündung & Elektrik)."""
def __init__(self, parent, sim): def __init__(self, parent, sim):

View File

@@ -1,11 +1,12 @@
# ============================= # =============================
# app/tabs/dtc.py # app/simulation/ui/dtc.py
# ============================= # =============================
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
from typing import Dict, Any from typing import Dict, Any
from app.simulation.ui import UITab
DTC_LIST = [ DTC_LIST = [
("P0300", "Random/Multiple Cylinder Misfire"), ("P0300", "Random/Multiple Cylinder Misfire"),
@@ -14,7 +15,10 @@ DTC_LIST = [
("U0121", "Lost Communication With ABS") ("U0121", "Lost Communication With ABS")
] ]
class DtcTab: class DtcTab(UITab):
NAME = "dtc"
TITLE = "Fehlercodes"
PRIO = 10
def __init__(self, parent, sim): def __init__(self, parent, sim):
self.sim = sim self.sim = sim
self.frame = ttk.Frame(parent, padding=8) self.frame = ttk.Frame(parent, padding=8)

View File

@@ -1,12 +1,20 @@
# app/tabs/engine.py # =============================
# app/simulation/ui/engine.py
# =============================
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
from typing import Dict, Any from typing import Dict, Any
# Wichtig: Defaults aus dem Modul importieren # Wichtig: Defaults aus dem Modul importieren
from app.simulation.modules.engine import ENGINE_DEFAULTS 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): def __init__(self, parent, sim):
self.sim = sim self.sim = sim
self.frame = ttk.Frame(parent, padding=8) self.frame = ttk.Frame(parent, padding=8)

View File

@@ -1,13 +1,19 @@
# ============================= # =============================
# app/tabs/gearbox.py # app/simulation/ui/gearbox.py
# ============================= # =============================
from __future__ import annotations from __future__ import annotations
import tkinter as tk import tkinter as tk
from tkinter import ttk from tkinter import ttk
from typing import Dict, Any, List 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): def __init__(self, parent, sim):
self.sim = sim self.sim = sim
self.frame = ttk.Frame(parent, padding=8) self.frame = ttk.Frame(parent, padding=8)

View File

@@ -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)

View File

@@ -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: ...

View File

@@ -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

View File

@@ -1,5 +1,5 @@
# main.py # main.py
from app.gui import launch_gui from app.app import launch_gui
if __name__ == "__main__": if __name__ == "__main__":
launch_gui() launch_gui()