#!/usr/bin/env python3
# Copyright (C) 2026 Ralf Burger
# ralf@RalfBurger.com
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.

"""
OBD LoRa GUI Dashboard v2.1 - 3D High-Tech Cockpit
Grafische Analoganzeige fuer obdBT LoRa-Receiver und Bridge

Style-Auswahl per Kommandozeile:
  --style f1        Formel-1 / Motorsport (default)
  --style scifi     Sci-Fi / Raumschiff
  --style aviation  Aviation / Militaer-Cockpit
  --style luxury    Supercar / Luxus

Verwendung:
  python3 obd_gui2.py                          # TCP default, F1-Style
  python3 obd_gui2.py --host 192.168.1.100     # TCP mit IP
  python3 obd_gui2.py --serial /dev/ttyUSB0   # Seriell
  python3 obd_gui2.py --style scifi            # Sci-Fi Style
  python3 obd_gui2.py --bridge                 # Direkt mit Bridge verbinden
  python3 obd_gui2.py --bridge --host 192.168.4.1  # Bridge-AP

Tastenkuerzel:
  1-4   Style wechseln
  q/Q   Beenden

Neue Instrumente einfach in INSTRUMENT_DEFS hinzufuegen -
keine weiteren Code-Aenderungen noetig.

3D-Verbesserungen gegenueber v2.0:
  - Gauge: radiales Pseudo-Gradient durch viele konzentrische Ringe
  - Gauge-Rand: Chrom-Effekt (Hell oben-links, Schatten unten-rechts)
  - Gauge-Glas: semitransparenter Glanzschimmer oben
  - Zeiger: verjuengtes Polygon (Trapez) statt einfacher Linie
  - Mittelpunkt: Kugeleffekt mit Highlight
  - Wert-Box: eingepraegtes Relief (Inset-Optik)
  - Balken: Inset-Rahmen + echter Glanzstreifen als Gradient
  - Balken: separate Glanz-Schicht (kein stipple mehr)

Abhaengigkeiten:
  Python   : 3.8 oder neuer
  tkinter  : im Lieferumfang der meisten Python-Installationen enthalten
             Linux (falls fehlend): sudo apt install python3-tk
             Windows/macOS: in der Standardinstallation enthalten
  Sonstige : keine externen Pakete erforderlich (nur Python-Stdlib)
"""

import tkinter as tk
import math
import socket
import threading
import time
import re
import argparse
import csv
import os
from dataclasses import dataclass
from typing import Optional

try:
    import serial as pyserial
    HAS_SERIAL = True
except ImportError:
    HAS_SERIAL = False

# +==============================================================+
# |                    HILFSFUNKTIONEN                         |
# +==============================================================+

def _lerp_hex(c1: str, c2: str, t: float) -> str:
    """Lineare Interpolation zwischen zwei Hex-Farben (#rrggbb)."""
    c1 = c1.lstrip('#'); c2 = c2.lstrip('#')
    r1,g1,b1 = int(c1[0:2],16), int(c1[2:4],16), int(c1[4:6],16)
    r2,g2,b2 = int(c2[0:2],16), int(c2[2:4],16), int(c2[4:6],16)
    r = int(r1 + (r2-r1)*t)
    g = int(g1 + (g2-g1)*t)
    b = int(b1 + (b2-b1)*t)
    return f'#{r:02x}{g:02x}{b:02x}'

def _lighten(col: str, factor: float = 0.4) -> str:
    return _lerp_hex(col, '#ffffff', factor)

def _darken(col: str, factor: float = 0.4) -> str:
    return _lerp_hex(col, '#000000', factor)

# +==============================================================+
# |                    STYLE-DEFINITIONEN                      |
# +==============================================================+
STYLES = {
    'f1': {
        'name':       'Formel-1 / Motorsport',
        'bg':         '#0c0c0c',
        'gauge_bg':   '#141414',
        'gauge_rim':  '#cc0000',
        'rim2':       '#880000',
        'scale':      '#666666',
        'scale_hi':   '#ffffff',
        'text':       '#cccccc',
        'value':      '#ffffff',
        'unit':       '#888888',
        'needle':     '#ff2200',
        'needle_glow':'#ff6600',
        'center':     '#cc0000',
        'zone_green': '#00aa33',
        'zone_yellow':'#ffaa00',
        'zone_red':   '#cc0000',
        'zone_blue':  '#2244cc',
        'bar_bg':     '#1a1a1a',
        'bar_border': '#333333',
        'header':     '#ff2200',
        'header2':    '#ffffff',
        'lora':       '#4488ff',
        'demo':       '#ffaa00',
        'conn_ok':    '#00cc44',
        'conn_err':   '#cc0000',
        'dim':        '#444444',
        'font':       'Helvetica',
        'font_mono':  'Courier',
    },
    'scifi': {
        'name':       'Sci-Fi / Raumschiff',
        'bg':         '#000814',
        'gauge_bg':   '#000d1a',
        'gauge_rim':  '#0066ff',
        'rim2':       '#003388',
        'scale':      '#004488',
        'scale_hi':   '#00ccff',
        'text':       '#88ccff',
        'value':      '#00ffff',
        'unit':       '#0088aa',
        'needle':     '#00ffff',
        'needle_glow':'#0088ff',
        'center':     '#0066ff',
        'zone_green': '#00ffaa',
        'zone_yellow':'#ffff00',
        'zone_red':   '#ff4400',
        'zone_blue':  '#0044ff',
        'bar_bg':     '#000d1a',
        'bar_border': '#002244',
        'header':     '#00ccff',
        'header2':    '#00ffff',
        'lora':       '#00ffaa',
        'demo':       '#ffff00',
        'conn_ok':    '#00ffaa',
        'conn_err':   '#ff4400',
        'dim':        '#002244',
        'font':       'Helvetica',
        'font_mono':  'Courier',
    },
    'aviation': {
        'name':       'Aviation / Militaer',
        'bg':         '#050a05',
        'gauge_bg':   '#080f08',
        'gauge_rim':  '#226622',
        'rim2':       '#114411',
        'scale':      '#335533',
        'scale_hi':   '#88ff88',
        'text':       '#88cc88',
        'value':      '#aaffaa',
        'unit':       '#446644',
        'needle':     '#88ff44',
        'needle_glow':'#44ff44',
        'center':     '#226622',
        'zone_green': '#00cc44',
        'zone_yellow':'#aaff00',
        'zone_red':   '#ff4400',
        'zone_blue':  '#4488ff',
        'bar_bg':     '#080f08',
        'bar_border': '#224422',
        'header':     '#44ff44',
        'header2':    '#aaffaa',
        'lora':       '#44ffaa',
        'demo':       '#aaff00',
        'conn_ok':    '#44ff44',
        'conn_err':   '#ff4400',
        'dim':        '#224422',
        'font':       'Helvetica',
        'font_mono':  'Courier',
    },
    'luxury': {
        'name':       'Supercar / Luxus',
        'bg':         '#0a0a08',
        'gauge_bg':   '#111108',
        'gauge_rim':  '#aa8833',
        'rim2':       '#665522',
        'scale':      '#554433',
        'scale_hi':   '#ddcc88',
        'text':       '#ccbb88',
        'value':      '#ffeeaa',
        'unit':       '#887755',
        'needle':     '#ffcc00',
        'needle_glow':'#ffaa00',
        'center':     '#aa8833',
        'zone_green': '#66aa44',
        'zone_yellow':'#ffcc00',
        'zone_red':   '#cc4400',
        'zone_blue':  '#4466aa',
        'bar_bg':     '#111108',
        'bar_border': '#443322',
        'header':     '#ffcc00',
        'header2':    '#ffeeaa',
        'lora':       '#88ccff',
        'demo':       '#ffcc00',
        'conn_ok':    '#66aa44',
        'conn_err':   '#cc4400',
        'dim':        '#443322',
        'font':       'Helvetica',
        'font_mono':  'Courier',
    },
}

S = STYLES['f1']

# +==============================================================+
# |              INSTRUMENT-DEFINITIONEN                       |
# +==============================================================+
INSTRUMENT_DEFS = [
    # -- Runde Instrumente -------------------------------------
    {   'type': 'gauge', 'pos': 0,
        'key': 'rpm', 'label': 'RPM', 'unit': '1/min',
        'min': 0, 'max': 7000, 'decimals': 0,
        'start_angle': 220, 'sweep': 260,
        'zones': [(2000,'green'),(4500,'yellow'),(7000,'red')],
        'warn_hi': 5500,
    },
    {   'type': 'gauge', 'pos': 1,
        'key': 'speed', 'label': 'Speed', 'unit': 'km/h',
        'min': 0, 'max': 220, 'decimals': 0,
        'start_angle': 220, 'sweep': 260,
        'zones': [(80,'green'),(140,'yellow'),(220,'red')],
        'warn_hi': 150,
    },
    {   'type': 'gauge', 'pos': 2,
        'key': 'temp', 'label': 'Kuehlmittel', 'unit': ' GradC',
        'min': -20, 'max': 130, 'decimals': 0,
        'start_angle': 210, 'sweep': 240,
        'zones': [(40,'blue'),(90,'green'),(105,'yellow'),(130,'red')],
        'warn_hi': 105, 'warn_lo': -10,
    },
    {   'type': 'gauge', 'pos': 3,
        'key': 'voltage', 'label': 'Spannung', 'unit': 'V',
        'min': 8, 'max': 16, 'decimals': 1,
        'start_angle': 210, 'sweep': 240,
        'zones': [(11.5,'red'),(12.5,'yellow'),(14.8,'green'),(16,'red')],
        'warn_lo': 11.5, 'warn_hi': 15.2,
    },
    # -- Weitere Gauge-Instrumente hier einfuegen ---------------
    # {   'type': 'gauge', 'pos': 4,
    #     'key': 'lambda_', 'label': 'Lambda', 'unit': '?',
    #     'min': 0.7, 'max': 1.3, 'decimals': 2,
    #     'start_angle': 210, 'sweep': 240,
    #     'zones': [(0.9,'red'),(0.95,'yellow'),(1.05,'green'),(1.3,'yellow')],
    # },

    # -- Balken-Instrumente ------------------------------------
    {   'type': 'bar', 'row': 0,
        'key': 'load', 'label': 'Motorlast', 'unit': '%',
        'min': 0, 'max': 100, 'decimals': 1,
        'zones': [(50,'green'),(80,'yellow'),(100,'red')],
        'warn_hi': 90,
    },
    {   'type': 'bar', 'row': 1,
        'key': 'tps', 'label': 'TPS', 'unit': '%',
        'min': 0, 'max': 100, 'decimals': 1,
        'zones': [(50,'green'),(80,'yellow'),(100,'red')],
    },
    {   'type': 'bar', 'row': 2,
        'key': 'map_kpa', 'label': 'MAP', 'unit': 'kPa',
        'min': 0, 'max': 110, 'decimals': 0,
        'zones': [(40,'green'),(80,'yellow'),(110,'red')],
    },
    {   'type': 'bar', 'row': 3,
        'key': 'rssi', 'label': 'LoRa RSSI', 'unit': 'dBm',
        'min': -120, 'max': -40, 'decimals': 0,
        'zones': [(-90,'red'),(-70,'yellow'),(-40,'green')],
    },
    # -- Weitere Balken hier einfuegen --------------------------
    # {   'type': 'bar', 'row': 4,
    #     'key': 'iat', 'label': 'Ansaugluft', 'unit': ' GradC',
    #     'min': -20, 'max': 80, 'decimals': 0,
    #     'zones': [(20,'blue'),(40,'green'),(60,'yellow'),(80,'red')],
    # },
]

# +==============================================================+
# |                    DATENMODELL                             |
# +==============================================================+
@dataclass
class OBDData:
    rpm:       float = 0.0
    speed:     float = 0.0
    temp:      float = 0.0
    iat:       float = 0.0
    map_kpa:   float = 0.0
    load:      float = 0.0
    tps:       float = 0.0
    voltage:   float = 12.0
    rssi:      float = -120.0
    snr:       float = 0.0
    lambda_:   float = 1.0
    ignition:  float = 0.0
    demo:      bool  = False
    pkt_obd:   int   = 0
    pkt_err:   int   = 0
    pkt_lost:  int   = 0
    last_rx:   float = 0.0
    connected: bool  = False
    status:    str   = "Verbinde..."
    ext:       dict  = None

    def __post_init__(self):
        if self.ext is None:
            self.ext = {}

_data      = OBDData()
data_lock  = threading.Lock()
send_queue: list[str] = []
send_lock  = threading.Lock()
running    = True
bridge_mode = False

# -- CSV-Logger ------------------------------------------------
BSC_FIELDS = ["timestamp","rpm","speed","temp","iat","map_kpa",
               "load","tps","voltage","rssi","snr","demo"]

_log_file   = None
_log_writer = None
_log_path   = ""

def log_init():
    global _log_file, _log_writer, _log_path
    ts = time.strftime("%Y%m%d_%H%M%S")
    _log_path = f"obd_log_{ts}.csv"
    _log_file = open(_log_path, "w", newline="", encoding="utf-8")
    _log_writer = csv.DictWriter(_log_file, fieldnames=BSC_FIELDS)
    _log_writer.writeheader()
    _log_file.flush()
    print(f"[LOG] {_log_path}")

def _clean(val, sentinel=-999, default=""):
    """Gibt leeren String fuer Sentinel-/Nullwerte zurueck."""
    if val is None or val == sentinel or val < -900:
        return default
    return val

def log_row(d):
    if _log_writer is None:
        return
    # Nicht loggen wenn keine gueltigen Fahrzeugdaten (rpm=0 und speed=0)
    if d.rpm == 0.0 and d.speed == 0.0 and not d.demo:
        return
    _log_writer.writerow({
        "timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
        "rpm":     int(d.rpm),
        "speed":   int(d.speed),
        "temp":    round(d.temp,   1) if d.temp > -900 else "",
        "iat":     round(d.iat,    1) if d.iat  > -900 else "",
        "map_kpa": round(d.map_kpa,1) if d.map_kpa >= 0 else "",
        "load":    round(d.load,   1) if d.load  >= 0 else "",
        "tps":     round(d.tps,    1) if d.tps   >= 0 else "",
        "voltage": round(d.voltage,2) if d.voltage >= 0 else "",
        "rssi":    round(d.rssi,   0) if d.rssi  != 0 else "",
        "snr":     round(d.snr,    1) if d.snr   != 0 else "",
        "demo":    int(d.demo),
    })
    _log_file.flush()

def log_close():
    global _log_file, _log_writer
    if _log_file:
        _log_file.close()
        _log_file = None
        _log_writer = None

def get_data() -> OBDData:
    with data_lock:
        d = OBDData(**_data.__dict__)
        d.ext = dict(_data.ext)
        return d

def set_field(key: str, val):
    with data_lock:
        if hasattr(_data, key):
            setattr(_data, key, val)

# -- Parser ----------------------------------------------------
RE_LIVE_BSC = re.compile(
    r'BSC\s+RPM:(-?\d+).*?SPD:(-?\d+).*?T:(-?\d+).*?Ld:([\d.]+).*?'
    r'MAP:(-?\d+).*?V:([\d.]+).*?RSSI:(-?[\d.]+)'
)
RE_LIVE_EXT = re.compile(r'EXT\s+(.*)')
RE_SNR      = re.compile(r'SNR:([-\d.]+)')
RE_KV       = re.compile(r'(\w+):([-\d.]+)')

EXT_FIELD_MAP = {
    'RPM':          'rpm',
    'Speed':        'speed',
    'Kuehlmittel':  'temp',
    'Ansaugluft':   'iat',
    'MAP':          'map_kpa',
    'Motorlast':    'load',
    'TPS':          'tps',
    'ECU_Spannung': 'voltage',
    'Lambda':       'lambda_',
    'Zuendwinkel':  'ignition',
    'Oeltemp':      'ignition',
    'RSSI':         'rssi',
    'SNR':          'snr',
}

RE_STAT = {
    'pkt_obd':  re.compile(r'OBD\s*:\s*(\d+)'),
    'pkt_err':  re.compile(r'Fehler\s*:\s*(\d+)'),
    'pkt_lost': re.compile(r'Verlust\s*:\s*(\d+)'),
    'rssi':     re.compile(r'RSSI\s+([-\d.]+)\s*dBm'),
    'snr':      re.compile(r'SNR\s+([-\d.]+)\s*dB'),
    'tps':      re.compile(r'TPS\s*:\s*([\d.]+)'),
    'iat':      re.compile(r'IAT\s*:\s*(-?\d+)'),
}

def parse_line(line: str):
    line = line.strip()
    if not line:
        return
    m = RE_LIVE_BSC.search(line)
    if m:
        _log_bsc = False
        with data_lock:
            _data.rpm     = float(m.group(1))
            _data.speed   = float(m.group(2))
            _data.temp    = float(m.group(3))
            _data.load    = float(m.group(4))
            _data.map_kpa = float(m.group(5))
            _data.voltage = float(m.group(6))
            _data.rssi    = float(m.group(7))
            _data.demo    = '[DEMO]' in line
            _data.last_rx = time.time()
            ms = RE_SNR.search(line)
            if ms:
                _data.snr = float(ms.group(1))
            _log_bsc = True
            # Mirror BSC values into ext dict too
            _data.ext['RPM']         = _data.rpm
            _data.ext['Speed']       = _data.speed
            _data.ext['Kuehlmittel'] = _data.temp
            _data.ext['Motorlast']   = _data.load
            _data.ext['MAP']         = _data.map_kpa
            _data.ext['ECU_Spannung']= _data.voltage
        if _log_bsc:
            log_row(_data)
        return
    me = RE_LIVE_EXT.match(line)
    if me:
        kvs = RE_KV.findall(me.group(1))
        with data_lock:
            for key, val_str in kvs:
                # Map to OBDData fields for gauges
                field = EXT_FIELD_MAP.get(key)
                if field and hasattr(_data, field):
                    try:
                        setattr(_data, field, float(val_str))
                    except ValueError:
                        pass
                # Store ALL values in ext dict
                try:
                    _data.ext[key] = float(val_str)
                except ValueError:
                    _data.ext[key] = val_str
            _data.demo    = '[DEMO]' in line
            _data.last_rx = time.time()
        return
    with data_lock:
        for key, rx in RE_STAT.items():
            m2 = rx.search(line)
            if m2:
                val = m2.group(1)
                if key in ('pkt_obd','pkt_err','pkt_lost'):
                    setattr(_data, key, int(val))
                else:
                    setattr(_data, key, float(val))

# -- Empfaenger-Threads -----------------------------------------
def tcp_receiver(host: str, port: int, bridge: bool = False):
    global running
    mode_label = "Bridge" if bridge else "LoRa-Receiver"
    attempt = 0
    while running:
        buf = ""
        attempt += 1
        sock = None
        try:
            with data_lock:
                _data.connected = False
                _data.status = f"Verbinde {mode_label} {host}:{port}"\
                               f" (Versuch {attempt})..."
            sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
            sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
            sock.settimeout(10.0)          # 10s fuer den Verbindungsaufbau
            sock.connect((host, port))
            sock.settimeout(15.0)          # 15s recv-Timeout
            attempt = 0                    # Erfolgreich -- Zaehler reset
            with data_lock:
                _data.connected = True
                _data.status    = f"{mode_label} {host}:{port} verbunden"
            if bridge:
                sock.sendall(b"live on\r\n")
            else:
                sock.sendall(b"live on\r\nstatus\r\n")
            last_data = time.time()
            while running:
                with send_lock:
                    cmds = send_queue[:]
                    send_queue.clear()
                for cmd in cmds:
                    try:
                        sock.sendall((cmd + "\r\n").encode())
                    except Exception:
                        pass
                try:
                    chunk = sock.recv(4096).decode('utf-8', errors='replace')
                    if not chunk:
                        raise ConnectionResetError("Verbindung geschlossen")
                    last_data = time.time()
                    buf += chunk
                    while '\n' in buf:
                        line, buf = buf.split('\n', 1)
                        parse_line(line)
                except socket.timeout:
                    # Watchdog: kein Daten seit 30s -> Reconnect
                    if time.time() - last_data > 20:
                        with data_lock:
                            _data.connected = False
                            _data.status = f"{mode_label} Timeout - Reconnect..."
                        break
                    continue
                except Exception as e:
                    with data_lock:
                        _data.connected = False
                        _data.status = f"{mode_label} getrennt: {e}"
                    break
        except Exception as e:
            with data_lock:
                _data.connected = False
                _data.status = f"{mode_label} Verbindungsfehler: {e}"
        finally:
            if sock:
                try: sock.close()
                except Exception: pass
        # Pause vor Reconnect -- laenger bei vielen Fehlversuchen
        pause = min(3 + attempt, 15)
        for _ in range(pause * 10):
            if not running: break
            time.sleep(0.1)

def serial_receiver(dev: str, baud: int):
    global running
    while running:
        try:
            with data_lock:
                _data.status    = f"Serial {dev}..."
                _data.connected = False
            ser = pyserial.Serial(dev, baud, timeout=2)
            with data_lock:
                _data.connected = True
                _data.status    = f"Serial {dev}@{baud}"
            ser.write(b"live\r\nstatus\r\n")
            while running:
                with send_lock:
                    cmds = send_queue[:]
                    send_queue.clear()
                for cmd in cmds:
                    ser.write((cmd + "\r\n").encode())
                line = ser.readline().decode('utf-8', errors='replace')
                if line:
                    parse_line(line)
        except Exception as e:
            with data_lock:
                _data.connected = False
                _data.status    = f"Serial Fehler: {e}"
            time.sleep(3)
        finally:
            try:
                ser.close()
            except Exception:
                pass

def send_cmd(cmd: str):
    with send_lock:
        send_queue.append(cmd)

# +==============================================================+
# |              GLOW-HILFSFUNKTIONEN                          |
# +==============================================================+
def glow_oval(canvas, x1, y1, x2, y2, color, glow_color, width=2, layers=3):
    for i in range(layers, 0, -1):
        d = i * 2
        canvas.create_oval(x1-d, y1-d, x2+d, y2+d,
                           outline=glow_color, width=width+i)
    canvas.create_oval(x1, y1, x2, y2, outline=color, width=width)

def glow_arc(canvas, x1, y1, x2, y2,
             start, extent, color, width=4, layers=3):
    for i in range(layers, 0, -1):
        d = i * 2
        canvas.create_arc(x1-d, y1-d, x2+d, y2+d,
                          start=start, extent=extent,
                          style=tk.ARC, outline=S['gauge_bg'],
                          width=width + i * 2)
    canvas.create_arc(x1, y1, x2, y2,
                      start=start, extent=extent,
                      style=tk.ARC, outline=color, width=width)

def zone_color(zone_key: str) -> str:
    return {
        'green':  S['zone_green'],
        'yellow': S['zone_yellow'],
        'red':    S['zone_red'],
        'blue':   S['zone_blue'],
    }.get(zone_key, S['zone_green'])

# +==============================================================+
# |              3D-ANALOGES RUNDINSTRUMENT                    |
# +==============================================================+
class Gauge:
    SMOOTH_FACTOR = 0.15

    def __init__(self, canvas, defn: dict, cx, cy, r):
        self.canvas      = canvas
        self.defn        = defn
        self.cx, self.cy = cx, cy
        self.r           = r
        self.current_val = defn['min']
        self.target_val  = defn['min']
        self._ids        = []
        self._static_drawn = False

    # -- Statischer Teil (schlicht, hoher Kontrast, kein 3D) ---
    def draw_static(self):
        c  = self.canvas
        cx, cy, r = self.cx, self.cy, self.r
        d  = self.defn
        bg = S['gauge_bg']

        # -- 1. Gehaeuse-Flaeche
        c.create_oval(cx-r, cy-r, cx+r, cy+r,
                      fill=bg, outline=S['gauge_rim'],
                      width=max(2, r//18))

        # -- 2. Farbzonen als schmaler Bogen am Aussenrand
        zones    = d.get('zones', [])
        prev_val = d['min']
        r_zone   = int(r * 0.88)
        zone_w   = max(3, r // 12)
        for zone_val, zone_key in zones:
            col  = zone_color(zone_key)
            a_st = d['start_angle'] - (prev_val - d['min']) / (d['max'] - d['min']) * d['sweep']
            ext  = -((zone_val - prev_val) / (d['max'] - d['min'])) * d['sweep']
            c.create_arc(cx-r_zone, cy-r_zone, cx+r_zone, cy+r_zone,
                         start=a_st, extent=ext,
                         style=tk.ARC, outline=col, width=zone_w)
            prev_val = zone_val

        # -- 3. Skalenteilung
        n_major = 10
        n_minor = 5
        n_ticks = n_major * n_minor
        for i in range(n_ticks + 1):
            frac     = i / n_ticks
            deg      = d['start_angle'] - frac * d['sweep']
            rad      = math.radians(deg)
            is_major = (i % n_minor == 0)
            is_med   = (i % n_minor == 2 or i % n_minor == 3)
            r_out    = r * 0.86
            r_in     = r * (0.72 if is_major else 0.79 if is_med else 0.82)
            lw       = 2 if is_major else 1
            col      = S['scale_hi'] if is_major else S['scale']
            x1 = cx + r_out * math.cos(rad)
            y1 = cy - r_out * math.sin(rad)
            x2 = cx + r_in  * math.cos(rad)
            y2 = cy - r_in  * math.sin(rad)
            c.create_line(x1, y1, x2, y2, fill=col, width=lw)
            if is_major:
                val = d['min'] + frac * (d['max'] - d['min'])
                rx  = cx + r * 0.54 * math.cos(rad)
                ry  = cy - r * 0.54 * math.sin(rad)
                dec = d.get('decimals', 0)
                txt = f"{val:.{dec}f}" if dec > 0 else str(int(val))
                fs  = max(6, min(r // 13, 11))
                c.create_text(rx, ry, text=txt, fill=S['scale_hi'],
                              font=(S['font'], fs))

        # -- 4. Innerer Trennring
        r2 = int(r * 0.90)
        c.create_oval(cx-r2, cy-r2, cx+r2, cy+r2,
                      fill='', outline=_darken(S['gauge_rim'], 0.35), width=1)

        # -- 5. Label und Einheit
        c.create_text(cx, cy + r * 0.38,
                      text=d['label'],
                      fill=S['unit'],
                      font=(S['font'], max(8, r//8), 'bold'))
        c.create_text(cx, cy + r * 0.54,
                      text=d['unit'],
                      fill=S['dim'],
                      font=(S['font'], max(7, r//10)))

        self._static_drawn = True

    # -- Dynamischer Teil --------------------------------------
    def _val_to_angle_rad(self, val: float) -> float:
        d    = self.defn
        val  = max(d['min'], min(d['max'], val))
        frac = (val - d['min']) / (d['max'] - d['min'])
        deg  = d['start_angle'] - frac * d['sweep']
        return math.radians(deg)

    def update_animation(self):
        diff = self.target_val - self.current_val
        if abs(diff) > 0.1:
            self.current_val += diff * self.SMOOTH_FACTOR

    def redraw_needle(self, stale: bool, warn: bool):
        c  = self.canvas
        cx, cy, r = self.cx, self.cy, self.r

        for item_id in self._ids:
            c.delete(item_id)
        self._ids.clear()

        rad    = self._val_to_angle_rad(self.current_val)
        r_tip  = r * 0.76
        r_back = r * 0.14

        x_tip  = cx + r_tip  * math.cos(rad)
        y_tip  = cy - r_tip  * math.sin(rad)
        x_back = cx - r_back * math.cos(rad)
        y_back = cy + r_back * math.sin(rad)

        if stale:
            n_col = S['dim']
        elif warn:
            n_col = S['zone_red']
        else:
            n_col = S['needle']

        # feiner Zeiger
        lw = max(1, r // 28)
        self._ids.append(
            c.create_line(x_back, y_back, x_tip, y_tip,
                          fill=n_col, width=lw, capstyle=tk.ROUND))

        # Mittelpunkt-Hub
        mr = max(4, r // 14)
        self._ids.append(
            c.create_oval(cx-mr, cy-mr, cx+mr, cy+mr,
                          fill=S['gauge_bg'], outline=n_col, width=max(1, mr//3)))
        self._ids.append(
            c.create_oval(cx-mr//3, cy-mr//3, cx+mr//3, cy+mr//3,
                          fill=n_col, outline=''))

        # Wert-Anzeige (schlicht, kein Relief)
        dec  = self.defn.get('decimals', 0)
        vtxt = '---' if stale else (
            f"{self.current_val:.{dec}f}" if dec > 0
            else str(int(self.current_val)))
        val_col = (S['zone_red'] if warn and not stale
                   else (S['dim'] if stale else S['value']))
        self._ids.append(
            c.create_text(cx, cy - r * 0.22,
                          text=vtxt,
                          fill=val_col,
                          font=(S['font_mono'], max(12, r//5), 'bold')))

    def set_target(self, val: float):
        self.target_val = val

# +==============================================================+
# |              3D BALKEN-INSTRUMENT                          |
# +==============================================================+
class BarGauge:
    SMOOTH_FACTOR = 0.12

    def __init__(self, canvas, defn: dict, x, y, w, h):
        self.canvas = canvas
        self.defn   = defn
        self.x, self.y = x, y
        self.w, self.h = w, h
        self.current_val = defn['min']
        self.target_val  = defn['min']
        self._ids        = []
        self._static_drawn = False

    def draw_static(self, shared_lw: int = 0, shared_uw: int = 0):
        c = self.canvas
        x, y, w, h = self.x, self.y, self.w, self.h
        d = self.defn

        fs_label = min(10, max(7, h - 8))
        fs_unit  = min(9,  max(6, h - 10))

        import tkinter.font as tkfont
        lw = shared_lw if shared_lw > 0 else (
            tkfont.Font(family=S['font'], size=fs_label, weight='bold').measure(d['label']) + 8)
        uw = shared_uw if shared_uw > 0 else (
            tkfont.Font(family=S['font'], size=fs_unit).measure(d['unit']) + 40)

        bx = x + lw
        bw = max(10, w - lw - uw)

        # Label
        c.create_text(x + lw - 4, y + h//2,
                      text=d['label'],
                      fill=S['unit'], anchor='e',
                      font=(S['font'], fs_label, 'bold'))

        # -- Aeusserer Rahmen: eingepraegter Inset-Effekt ---------
        # Schatten oben+links (dunkel), Highlight unten+rechts (hell)
        shadow = _darken(S['bar_border'], 0.3)
        light  = _lighten(S['bar_border'], 0.4)
        # Aeusserer Schatten-Rahmen
        c.create_rectangle(bx-2, y-2, bx+bw+2, y+h+2,
                           fill='', outline=shadow, width=1)
        # Innerer Highlight-Rahmen
        c.create_rectangle(bx-1, y-1, bx+bw+1, y+h+1,
                           fill='', outline=light, width=1)
        # Hintergrund-Flaeche
        c.create_rectangle(bx, y, bx+bw, y+h,
                           fill=S['bar_bg'],
                           outline=S['bar_border'], width=1)

        # Zonen-Streifen am unteren Rand
        zones  = d.get('zones', [])
        prev_v = d['min']
        for zone_val, zone_key in zones:
            col  = zone_color(zone_key)
            fx   = (prev_v  - d['min']) / (d['max'] - d['min'])
            fx2  = (zone_val - d['min']) / (d['max'] - d['min'])
            x1z  = bx + int(fx  * bw)
            x2z  = bx + int(fx2 * bw)
            c.create_rectangle(x1z, y + h - 3,
                               x2z, y + h,
                               fill=col, outline='')
            prev_v = zone_val

        self._bx     = bx
        self._bw     = bw
        self._fs_val = fs_label
        self._static_drawn = True

    def update_animation(self):
        diff = self.target_val - self.current_val
        if abs(diff) > 0.05:
            self.current_val += diff * self.SMOOTH_FACTOR

    def redraw_bar(self, stale: bool, warn: bool):
        c = self.canvas
        d = self.defn
        x, y, w, h = self.x, self.y, self.w, self.h
        bx, bw     = self._bx, self._bw

        for item_id in self._ids:
            c.delete(item_id)
        self._ids.clear()

        val  = self.current_val
        frac = max(0.0, min(1.0, (val - d['min']) / (d['max'] - d['min'])))
        bar_w = int(bw * frac)

        # Balkenfarbe
        col = S['zone_green']
        if not stale:
            for zone_val, zone_key in d.get('zones', []):
                if val <= zone_val:
                    col = zone_color(zone_key)
                    break
        if stale:  col = S['dim']
        if warn and not stale: col = S['zone_red']

        if bar_w > 0:
            # -- Haupt-Balken
            self._ids.append(
                c.create_rectangle(bx, y+1, bx+bar_w, y+h-1,
                                   fill=col, outline=''))

            # -- Gradient-Effekt: dunkler am rechten Rand
            # Simuliert durch ein schmales dunkles Rechteck rechts
            edge_w = max(2, bar_w // 10)
            self._ids.append(
                c.create_rectangle(bx + bar_w - edge_w, y+1,
                                   bx + bar_w, y+h-1,
                                   fill=_darken(col, 0.35), outline=''))

            # -- Glanzstreifen oben (echter Gradient durch mehrere Streifen)
            gloss_h = max(2, (h-2) // 3)
            n_gloss = max(1, gloss_h)
            for gi in range(n_gloss):
                t      = gi / n_gloss          # 0=oben, 1=unten
                g_col  = _lerp_hex(_lighten(col, 0.55), col, t)
                gy1    = y + 2 + gi
                self._ids.append(
                    c.create_rectangle(bx, gy1, bx+bar_w, gy1+1,
                                       fill=g_col, outline=''))

            # -- Untere Schatten-Linie
            self._ids.append(
                c.create_rectangle(bx, y+h-2, bx+bar_w, y+h-1,
                                   fill=_darken(col, 0.4), outline=''))

            # -- Linke Highlight-Kante
            self._ids.append(
                c.create_rectangle(bx, y+1, bx+1, y+h-1,
                                   fill=_lighten(col, 0.4), outline=''))

        # Wert + Einheit rechts
        dec  = d.get('decimals', 1)
        unit = d.get('unit', '')
        if stale:    vtxt = f"--- {unit}"
        elif dec > 0: vtxt = f"{val:.{dec}f} {unit}"
        else:         vtxt = f"{int(val)} {unit}"
        tc   = S['dim'] if stale else (S['zone_red'] if warn else S['scale_hi'])
        fs   = min(10, getattr(self, '_fs_val', max(7, self.h - 8)))
        self._ids.append(
            c.create_text(bx + bw + 4, self.y + self.h // 2,
                          text=vtxt, fill=tc, anchor='w',
                          font=(S['font_mono'], fs, 'bold')))

    def set_target(self, val: float):
        self.target_val = val

# +==============================================================+
# |                   HAUPTFENSTER                             |
# +==============================================================+

# +==============================================================+
# |                  EXT-WERTE FENSTER                         |
# +==============================================================+
# PID name -> unit mapping (from pid_registry)
PID_UNITS = {
    'RPM':'1/min','Speed':'km/h','Kuehlmittel':'C','Ansaugluft':'C',
    'MAP':'kPa','Motorlast':'%','TPS':'%','Tankfuellstand':'%',
    'Verbrauch':'L/h','MAF':'g/s','Baro':'kPa','AbsTPS':'%',
    'RelTPS':'%','Gaspedal':'%','Schubventil':'%',
    'O2_B1S1_V':'V','O2_B1S2_V':'V','O2_B1S3_V':'V','O2_B1S4_V':'V',
    'O2_B1S1_WB':'lam','O2_B1S2_WB':'lam','Lambda':'lam',
    'Umgebungsluft':'C','Kruemmerdruck':'kPa','Zuendwinkel':'KW',
    'ECU_Spannung':'V','Oeltemp':'C','Einspritzdruck':'kPa',
    'Kuehlmittel2':'C','MIL_Status':'','Dist_MIL_an':'km',
    'Warmlaufzyklen':'','Dist_Reset':'km','Zeit_MIL_an':'min',
    'Zeit_Reset':'min','Kraftstofftyp':'','VIN':'','KalibID':'',
    'ECU_Name':'','RSSI':'dBm','SNR':'dB',
}

class ExtWindow:
    """Separates Fenster mit allen empfangenen EXT-PID-Werten."""

    def __init__(self, parent: tk.Tk):
        self.win = tk.Toplevel(parent)
        self.win.title("EXT PID-Werte")
        self.win.geometry("480x520")
        self.win.configure(bg=S['bg'])
        self.win.protocol("WM_DELETE_WINDOW", self._on_close)
        self._open = True
        self._rows: dict[str, tk.Label] = {}   # key -> value label
        self._build()

    def _build(self):
        # Header
        hdr = tk.Frame(self.win, bg=S['bg'])
        hdr.pack(fill=tk.X, padx=8, pady=(6,2))
        tk.Label(hdr, text="EXT PID-Werte (live)",
                 bg=S['bg'], fg=S['header'],
                 font=(S['font'], 11, 'bold')).pack(side=tk.LEFT)
        tk.Label(hdr, text="schliesst sich beim Beenden",
                 bg=S['bg'], fg=S['dim'],
                 font=(S['font_mono'], 7)).pack(side=tk.RIGHT)

        # Column headers
        col_hdr = tk.Frame(self.win, bg=S['gauge_bg'])
        col_hdr.pack(fill=tk.X, padx=8)
        for txt, w, anchor in [("PID-Name",18,'w'),("Wert",8,'e'),("Einheit",6,'w')]:
            tk.Label(col_hdr, text=txt, width=w, anchor=anchor,
                     bg=S['gauge_bg'], fg=S['scale_hi'],
                     font=(S['font_mono'], 8, 'bold')).pack(side=tk.LEFT, padx=2)

        # Scrollable table
        frame = tk.Frame(self.win, bg=S['bg'])
        frame.pack(fill=tk.BOTH, expand=True, padx=8, pady=4)
        vsb = tk.Scrollbar(frame, orient=tk.VERTICAL)
        vsb.pack(side=tk.RIGHT, fill=tk.Y)
        self.canvas = tk.Canvas(frame, bg=S['bg'], highlightthickness=0,
                                yscrollcommand=vsb.set)
        self.canvas.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        vsb.config(command=self.canvas.yview)
        self.table = tk.Frame(self.canvas, bg=S['bg'])
        self._cwin = self.canvas.create_window((0,0), window=self.table, anchor='nw')
        self.table.bind('<Configure>', self._on_table_resize)
        self.canvas.bind('<Configure>', self._on_canvas_resize)

        # Status label
        self.lbl_count = tk.Label(self.win, text="Warte auf Daten...",
                                  bg=S['bg'], fg=S['dim'],
                                  font=(S['font_mono'], 7))
        self.lbl_count.pack(pady=(0,4))

    def _on_table_resize(self, e):
        self.canvas.configure(scrollregion=self.canvas.bbox('all'))

    def _on_canvas_resize(self, e):
        self.canvas.itemconfig(self._cwin, width=e.width)

    def _on_close(self):
        self._open = False
        self.win.destroy()

    def update(self, ext: dict):
        if not self._open:
            return
        # Show all known PIDs + any received ones
        all_keys = list(PID_UNITS.keys()) + \
                   [k for k in sorted(ext.keys()) if k not in PID_UNITS]
        for key in all_keys:
            if key not in self._rows:
                row = tk.Frame(self.table, bg=S['bg'])
                row.pack(fill=tk.X, pady=0)
                tk.Label(row, text=key, width=18, anchor='w',
                         bg=S['bg'], fg=S['text'],
                         font=(S['font_mono'], 9)).pack(side=tk.LEFT, padx=2)
                lbl_val = tk.Label(row, text="--", width=8, anchor='e',
                                   bg=S['bg'], fg=S['value'],
                                   font=(S['font_mono'], 9, 'bold'))
                lbl_val.pack(side=tk.LEFT, padx=2)
                unit = PID_UNITS.get(key, '')
                tk.Label(row, text=unit, width=6, anchor='w',
                         bg=S['bg'], fg=S['unit'],
                         font=(S['font_mono'], 8)).pack(side=tk.LEFT, padx=2)
                self._rows[key] = lbl_val
        # Update values
        for key, lbl in self._rows.items():
            val = ext.get(key)
            if val is None:
                lbl.config(text="---", fg=S['dim'])
            elif isinstance(val, float):
                # Format nicely
                if abs(val) >= 1000:
                    lbl.config(text=f"{val:.0f}", fg=S['value'])
                elif abs(val) >= 10:
                    lbl.config(text=f"{val:.1f}", fg=S['value'])
                else:
                    lbl.config(text=f"{val:.2f}", fg=S['value'])
            else:
                lbl.config(text=str(val)[:10], fg=S['value'])
        received = sum(1 for k in PID_UNITS if k in ext)
        self.lbl_count.config(
            text=f"{received} von {len(PID_UNITS)} bekannten PIDs empfangen  |  "
                 f"{len(ext)} gesamt")

class OBDGui:
    REFRESH_MS  = 50
    WARN_PERIOD = 500

    def __init__(self, root: tk.Tk, style_name: str):
        self._ext_win: 'ExtWindow | None' = None
        global S
        S = STYLES.get(style_name, STYLES['f1'])

        self.root   = root
        self.gauges: list[Gauge]    = []
        self.bars:   list[BarGauge] = []
        self._built = False
        self._last_size = (0, 0)
        if hasattr(self, '_btn_ext'):
            self._btn_ext.configure(bg=S['bar_bg'], fg=S['text'])
        self._update_ext_button()
        if self._ext_win and self._ext_win._open:
            self._ext_win.win.configure(bg=S['bg'])
        self._warn_blink = False
        self._blink_timer = 0
        self._current_style = style_name

        root.title(f"OBD LoRa Dashboard - {S['name']}")
        root.configure(bg=S['bg'])
        root.minsize(600, 400)

        self._build_ui()
        self._schedule()

    def _build_ui(self):
        root = self.root

        self._hdr = tk.Frame(root, bg=S['bg'], pady=4)
        self._hdr.pack(fill=tk.X, padx=10)

        self.lbl_title = tk.Label(
            self._hdr, text="?  OBD LoRa Dashboard",
            bg=S['bg'], fg=S['header'],
            font=(S['font'], 13, 'bold'))
        self.lbl_title.pack(side=tk.LEFT)

        self.lbl_demo = tk.Label(
            self._hdr, text="",
            bg=S['bg'], fg=S['demo'],
            font=(S['font'], 10, 'bold'))
        self.lbl_demo.pack(side=tk.LEFT, padx=12)

        # EXT button -- packed LEFT before any RIGHT widgets so it's always visible
        self._btn_ext = tk.Button(
            self._hdr, text='EXT',
            font=(S['font'], 8, 'bold'),
            relief=tk.RAISED, cursor='hand2',
            padx=8, pady=2,
            command=self._toggle_ext)
        self._btn_ext.pack(side=tk.LEFT, padx=(16,4))

        self.lbl_time = tk.Label(
            self._hdr, text="",
            bg=S['bg'], fg=S['dim'],
            font=(S['font_mono'], 10))
        self.lbl_time.pack(side=tk.RIGHT)

        self.lbl_conn = tk.Label(
            self._hdr, text="? Verbinde...",
            bg=S['bg'], fg=S['demo'],
            font=(S['font'], 10, 'bold'))
        self.lbl_conn.pack(side=tk.RIGHT, padx=10)

        # Style-Buttons
        self._style_btns: dict[str, tk.Button] = {}
        btn_frame = tk.Frame(self._hdr, bg=S['bg'])
        btn_frame.pack(side=tk.RIGHT, padx=6)
        style_labels = {'f1':'F1','scifi':'SCI-FI','aviation':'AVIAT','luxury':'LUXUS'}
        for key, label in style_labels.items():
            btn = tk.Button(
                btn_frame, text=label,
                font=(S['font'], 7, 'bold'),
                relief=tk.FLAT, cursor='hand2',
                padx=4, pady=1,
                command=lambda k=key: self._switch_style(k))
            btn.pack(side=tk.LEFT, padx=1)
            self._style_btns[key] = btn
        self._update_style_buttons()

        self.canvas = tk.Canvas(root, bg=S['bg'], highlightthickness=0)
        self.canvas.pack(fill=tk.BOTH, expand=True, padx=6, pady=2)
        self.canvas.bind('<Configure>', self._on_resize)

        self.lbl_status = tk.Label(
            root, text="",
            bg=S['bg'], fg=S['dim'],
            font=(S['font_mono'], 8))
        self.lbl_status.pack(fill=tk.X, padx=10, pady=(0, 4))
        # Log filename label
        if _log_path:
            tk.Label(root, text=f"LOG: {os.path.basename(_log_path)}",
                     bg=S['bg'], fg=S['dim'],
                     font=(S['font_mono'], 7)).pack(pady=(0,2))

        root.bind('<q>', lambda e: self._quit())
        root.bind('<Q>', lambda e: self._quit())
        root.bind('1', lambda e: self._switch_style('f1'))
        root.bind('2', lambda e: self._switch_style('scifi'))
        root.bind('3', lambda e: self._switch_style('aviation'))
        root.bind('4', lambda e: self._switch_style('luxury'))
        root.bind('e', lambda e: self._toggle_ext())
        root.bind('E', lambda e: self._toggle_ext())

    def _switch_style(self, style_key: str):
        global S
        if style_key not in STYLES:
            return
        S = STYLES[style_key]
        self._current_style = style_key
        self.root.configure(bg=S['bg'])
        self._hdr.configure(bg=S['bg'])
        self.lbl_title.configure( bg=S['bg'], fg=S['header'])
        self.lbl_demo.configure(  bg=S['bg'], fg=S['demo'])
        self.lbl_time.configure(  bg=S['bg'], fg=S['dim'])
        self.lbl_conn.configure(  bg=S['bg'])
        self.lbl_status.configure(bg=S['bg'], fg=S['dim'])
        self.canvas.configure(bg=S['bg'])
        for btn in self._style_btns.values():
            btn.master.configure(bg=S['bg'])
        self._update_style_buttons()
        self._built = False
        self._last_size = (0, 0)
        W = self.canvas.winfo_width()
        H = self.canvas.winfo_height()
        if W > 50 and H > 50:
            self._build_instruments(W, H)

    def _update_style_buttons(self):
        cur = getattr(self, '_current_style', 'f1')
        for key, btn in self._style_btns.items():
            if key == cur:
                btn.configure(bg=S['gauge_rim'], fg=S['value'], relief=tk.SOLID)
            else:
                btn.configure(bg=S['gauge_bg'], fg=S['dim'], relief=tk.FLAT)

    def _toggle_ext(self):
        if self._ext_win and self._ext_win._open:
            self._ext_win._on_close()
            self._ext_win = None
        else:
            self._ext_win = ExtWindow(self.root)
        self._update_ext_button()

    def _update_ext_button(self):
        if not hasattr(self, '_btn_ext'):
            return
        open_ = self._ext_win is not None and self._ext_win._open
        self._btn_ext.configure(
            bg=S['gauge_rim']  if open_ else S['bar_bg'],
            fg=S['value']      if open_ else S['text'],
            relief=tk.SUNKEN   if open_ else tk.RAISED)

    def _build_instruments(self, W: int, H: int):
        self.canvas.delete("all")
        self.gauges.clear()
        self.bars.clear()

        pad        = 8
        gauge_defs = [d for d in INSTRUMENT_DEFS if d['type']=='gauge']
        bar_defs   = [d for d in INSTRUMENT_DEFS if d['type']=='bar'
                      and not (bridge_mode and d.get('key')=='rssi')]
        n_gauges   = len(gauge_defs)
        n_cols     = min(n_gauges, 4)
        n_rows_g   = math.ceil(n_gauges / n_cols)
        n_bars     = len(bar_defs)
        bar_h_each = max(20, min(32, (H // 5) // max(n_bars, 1)))
        bars_total = n_bars * (bar_h_each + 4) + pad
        gauge_area_h = H - bars_total - pad * 2
        row_h      = gauge_area_h // n_rows_g if n_rows_g > 0 else gauge_area_h
        col_w      = (W - pad * (n_cols + 1)) // n_cols

        for i, defn in enumerate(gauge_defs):
            pos  = defn.get('pos', i)
            row  = pos // n_cols
            col  = pos  % n_cols
            cx   = pad + col * (col_w + pad) + col_w // 2
            cy   = pad + row * row_h + row_h // 2
            r    = max(30, min(col_w // 2, row_h // 2) - pad)
            g    = Gauge(self.canvas, defn, cx, cy, r)
            g.draw_static()
            self.gauges.append(g)

        import tkinter.font as tkfont
        bar_y0   = pad + n_rows_g * row_h + pad
        bar_w    = W - pad * 2
        fs_label = min(10, max(7, bar_h_each - 8))
        fs_unit  = min(9,  max(6, bar_h_each - 10))
        f_lbl    = tkfont.Font(family=S['font'], size=fs_label, weight='bold')
        f_unt    = tkfont.Font(family=S['font'], size=fs_unit)
        max_lw   = max((f_lbl.measure(d['label']) for d in bar_defs), default=60) + 10
        max_uw   = max((f_unt.measure(d['unit'])  for d in bar_defs), default=30) + 70

        for defn in bar_defs:
            row = defn.get('row', 0)
            by  = bar_y0 + row * (bar_h_each + 4)
            b   = BarGauge(self.canvas, defn, pad, by, bar_w, bar_h_each)
            b.draw_static(shared_lw=max_lw, shared_uw=max_uw)
            self.bars.append(b)

        self._built = True

    def _on_resize(self, event=None):
        W = self.canvas.winfo_width()
        H = self.canvas.winfo_height()
        if W < 50 or H < 50:
            return
        if (W, H) == self._last_size:
            return
        self._last_size = (W, H)
        self._built     = False
        self.root.after(80, self._delayed_build)

    def _delayed_build(self):
        W = self.canvas.winfo_width()
        H = self.canvas.winfo_height()
        if W > 50 and H > 50:
            self._build_instruments(W, H)

    def _update(self):
        d   = get_data()
        now = time.time()
        age = now - d.last_rx if d.last_rx > 0 else 999
        stale = age > 10

        if now - self._blink_timer > (self.WARN_PERIOD / 1000):
            self._warn_blink  = not self._warn_blink
            self._blink_timer = now

        self.lbl_time.config(text=time.strftime('%H:%M:%S'))
        self.lbl_conn.config(
            text="? Online"   if d.connected else "? Getrennt",
            fg  =S['conn_ok'] if d.connected else S['conn_err'])
        self.lbl_demo.config(text="? DEMO-MODUS" if d.demo else "")

        if stale and d.last_rx > 0:
            self.lbl_status.config(
                text=f"? TIMEOUT - kein Signal seit {int(age)}s  |  "
                     f"Pkts:{d.pkt_obd}  Vl:{d.pkt_lost}  Err:{d.pkt_err}",
                fg=S['conn_err'])
        else:
            self.lbl_status.config(
                text=f"{d.status}  |  Pkts:{d.pkt_obd}  "
                     f"Vl:{d.pkt_lost}  Err:{d.pkt_err}  |  "
                     f"SNR:{d.snr:.0f}dB  vor {int(age)}s",
                fg=S['dim'])

        if not self._built:
            return

        gauge_defs = [dd for dd in INSTRUMENT_DEFS if dd['type']=='gauge']
        bar_defs   = [dd for dd in INSTRUMENT_DEFS if dd['type']=='bar'
                      and not (bridge_mode and dd.get('key')=='rssi')]

        for g, defn in zip(self.gauges, gauge_defs):
            val = getattr(d, defn['key'], 0.0)
            g.set_target(val)
            g.update_animation()
            warn = False
            if not stale:
                if 'warn_hi' in defn and val >= defn['warn_hi']: warn = self._warn_blink
                if 'warn_lo' in defn and val <= defn['warn_lo']: warn = self._warn_blink
            g.redraw_needle(stale, warn)

        for b, defn in zip(self.bars, bar_defs):
            val = getattr(d, defn['key'], 0.0)
            b.set_target(val)
            b.update_animation()
            warn = (not stale and 'warn_hi' in defn and val >= defn['warn_hi']
                    and self._warn_blink)
            b.redraw_bar(stale, warn)

        # Update EXT window if open
        try:
            if self._ext_win and self._ext_win._open:
                self._ext_win.update(d.ext)
            self._update_ext_button()
        except Exception:
            pass

    def _schedule(self):
        try:
            self._update()
        except Exception as _e:
            import traceback; traceback.print_exc()
        self.root.after(self.REFRESH_MS, self._schedule)

    def _quit(self):
        global running
        running = False
        self.root.quit()

# -- Argumente -------------------------------------------------
def parse_args():
    p = argparse.ArgumentParser(description='OBD LoRa GUI Dashboard v2.1')
    p.add_argument('--host',   default='192.168.28.217')
    p.add_argument('--port',   type=int, default=1234)
    p.add_argument('--serial', default=None)
    p.add_argument('--baud',   type=int, default=115200)
    p.add_argument('--style',  choices=list(STYLES.keys()), default='f1')
    p.add_argument('--bridge', action='store_true',
                   help='Direkt mit obdBT-Bridge verbinden (statt LoRa-Receiver)')
    return p.parse_args()

# -- Main ------------------------------------------------------
def main():
    global running
    args = parse_args()

    global bridge_mode
    bridge_mode = args.bridge

    log_init()
    if args.serial:
        if not HAS_SERIAL:
            print("pyserial fehlt: pip install pyserial")
            return
        t = threading.Thread(
            target=serial_receiver,
            args=(args.serial, args.baud), daemon=True)
    else:
        t = threading.Thread(
            target=tcp_receiver,
            args=(args.host, args.port, args.bridge), daemon=True)
    t.start()

    root = tk.Tk()
    root.geometry("1000x680")
    OBDGui(root, args.style)

    try:
        root.mainloop()
    finally:
        running = False
        log_close()

if __name__ == '__main__':
    main()
