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
|
||||
@@ -316,9 +318,10 @@ class EngineModule(Module):
|
||||
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))
|
||||
|
@@ -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