Jelajahi Sumber

Initial commit: Audi A4 B5 HVAC Diagnostic Tool

- KWP1281 protocol over K-Line (5-baud slow init)
- Live measurement block reading with 70 VAG formulas
- DTC reading/clearing with HVAC-specific fault database
- Interactive dashboard with arc gauges (blower, compressor, temps, voltage)
- SVG car sensor map showing all HVAC sensor positions (G17, G56, G65, G86, G89, G107, G263)
- Live sensor health indication (normal/warning/fault colors)
- Export to JSON for AI analysis (DTCs + measurement blocks)
- Glassmorphism dark UI with sparkline charts
Murad Jabir 2 bulan lalu
melakukan
9ea5609bdb
14 mengubah file dengan 3269 tambahan dan 0 penghapusan
  1. 25 0
      .gitignore
  2. 47 0
      README.md
  3. 214 0
      app.py
  4. 98 0
      dtc_db.py
  5. 48 0
      ecu_defs.py
  6. 184 0
      kline.py
  7. 198 0
      kwp1281.py
  8. 245 0
      meas_decoder.py
  9. 47 0
      port_scanner.py
  10. 2 0
      requirements.txt
  11. 841 0
      static/css/style.css
  12. 866 0
      static/js/app.js
  13. 389 0
      templates/index.html
  14. 65 0
      test_init.py

+ 25 - 0
.gitignore

@@ -0,0 +1,25 @@
+# Python
+__pycache__/
+*.py[cod]
+*.egg-info/
+dist/
+build/
+*.egg
+.eggs/
+venv/
+.venv/
+env/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+*.swo
+*~
+.DS_Store
+
+# Logs
+*.log
+
+# Environment
+.env

+ 47 - 0
README.md

@@ -0,0 +1,47 @@
+# Audi A4 B5 (8D2) HVAC Diagnostic Tool
+
+A web-based diagnostic tool for the Audi A4 B5 (1994-2001) Auto HVAC (Climate Control) module over the KWP1281 protocol, using a standard KKL (FTDI) diagnostic cable.
+
+## Features
+
+- **Web GUI:** A modern, dark-themed automotive dashboard accessible via a web browser.
+- **Port Auto-Detection:** Automatically finds your KKL/FTDI serial port.
+- **Read & Clear Faults (DTCs):** Built-in database of HVAC-specific fault codes with elaboration details (Active, Intermittent, etc.).
+- **Measurement Blocks:** View all sensor data (temperatures, flap positions, compressor voltages) across all 8 HVAC measurement groups.
+- **Live Data Monitor:** Stream live sensor data via Server-Sent Events (SSE) with real-time sparkline charts.
+
+## Requirements
+
+- Python 3.9+
+- A genuine FTDI FT232RL based KKL VAG-COM cable. *(Warning: Cheap clone chips often struggle with the precise 5-baud timing required to wake up the ECU).*
+
+### Python Dependencies
+
+```bash
+pip install -r requirements.txt
+```
+
+*(This project uses only `pyserial` for hardware communication and `Flask` for the web server).*
+
+## Usage
+
+1. Connect your KKL cable to your vehicle's OBD-II port.
+2. Turn the ignition to the ON position (engine does not need to be running, but climate control must have power).
+3. Connect the KKL cable to your computer's USB port.
+4. Start the server:
+   ```bash
+   python app.py
+   ```
+5. Open your web browser and navigate to `http://127.0.0.1:5000`
+6. Select your port and click **Connect**.
+
+## Safety Warning
+
+> **CAUTION:** This tool communicates directly with vehicle electronics on the K-Line. While it is designed to be read-only (with explicit confirmation required for clearing faults), using incorrect or cheap counterfeit cables can potentially introduce noise on the K-Line or cause communication errors. Do not use this tool while the vehicle is in motion.
+
+## Technical Details
+
+- **Protocol:** KWP1281 (Key-Word Protocol 1281) via K-Line (ISO 9141).
+- **Initialization:** 5-baud "slow init" bit-banging via standard UART Break signals.
+- **Module Address:** 0x08 (Auto HVAC).
+- **Communication:** Byte-level complement acknowledgement system.

+ 214 - 0
app.py

@@ -0,0 +1,214 @@
+# app.py
+from flask import Flask, render_template, jsonify, request, Response
+import threading
+import time
+import json
+import logging
+from port_scanner import scan_ports, auto_select
+from kwp1281 import KWP1281, KWP1281Exception
+from dtc_db import decode_dtc
+from meas_decoder import decode_measurement
+from ecu_defs import HVAC_GROUPS, HVAC_ADDRESS
+
+logging.basicConfig(level=logging.INFO)
+logger = logging.getLogger("WebAPI")
+
+app = Flask(__name__)
+
+# Global state for KWP1281 connection
+protocol = None
+connection_lock = threading.Lock()
+is_connected = False
+ecu_info = {}
+live_mode = False
+live_group = 1
+
+def keep_alive_thread():
+    global is_connected, protocol, live_mode
+    while True:
+        time.sleep(0.5)
+        with connection_lock:
+            if is_connected and protocol and not live_mode:
+                try:
+                    # Send ACK periodically to keep connection alive if we aren't doing live polling
+                    protocol.send_ack()
+                    # Wait for ECU to respond (usually with an ACK)
+                    protocol.receive_block()
+                except Exception as e:
+                    logger.error(f"Keep-alive failed: {e}")
+                    is_connected = False
+
+threading.Thread(target=keep_alive_thread, daemon=True).start()
+
+
+@app.route('/')
+def index():
+    return render_template('index.html')
+
+@app.route('/api/ports')
+def get_ports():
+    ports = scan_ports()
+    return jsonify({
+        "ports": ports,
+        "auto": auto_select()
+    })
+
+@app.route('/api/connect', methods=['POST'])
+def connect():
+    global protocol, is_connected, ecu_info
+    data = request.json
+    port = data.get('port')
+    
+    if not port:
+        return jsonify({"success": False, "error": "No port provided"}), 400
+        
+    with connection_lock:
+        if is_connected and protocol:
+            try:
+                protocol.disconnect()
+            except:
+                pass
+                
+        protocol = KWP1281(port)
+        try:
+            ecu_info = protocol.connect(HVAC_ADDRESS)
+            is_connected = True
+            return jsonify({
+                "success": True,
+                "ecu_info": ecu_info
+            })
+        except KWP1281Exception as e:
+            is_connected = False
+            return jsonify({"success": False, "error": str(e)}), 500
+
+@app.route('/api/disconnect', methods=['POST'])
+def disconnect():
+    global protocol, is_connected
+    with connection_lock:
+        if protocol and is_connected:
+            try:
+                protocol.disconnect()
+            except:
+                pass
+        is_connected = False
+        return jsonify({"success": True})
+
+@app.route('/api/status')
+def status():
+    return jsonify({
+        "connected": is_connected,
+        "ecu_info": ecu_info if is_connected else None
+    })
+
+@app.route('/api/dtcs')
+def get_dtcs():
+    global protocol, is_connected
+    with connection_lock:
+        if not is_connected or not protocol:
+            return jsonify({"success": False, "error": "Not connected"}), 400
+            
+        try:
+            raw_dtcs = protocol.read_dtcs()
+            decoded = [decode_dtc(d['hi'], d['lo'], d['elaboration']) for d in raw_dtcs]
+            return jsonify({
+                "success": True,
+                "dtcs": decoded
+            })
+        except Exception as e:
+            return jsonify({"success": False, "error": str(e)}), 500
+
+@app.route('/api/dtcs/clear', methods=['POST'])
+def clear_dtcs():
+    global protocol, is_connected
+    with connection_lock:
+        if not is_connected or not protocol:
+            return jsonify({"success": False, "error": "Not connected"}), 400
+            
+        try:
+            protocol.clear_dtcs()
+            return jsonify({"success": True})
+        except Exception as e:
+            return jsonify({"success": False, "error": str(e)}), 500
+
+@app.route('/api/meas/<int:group>')
+def get_meas(group):
+    global protocol, is_connected
+    
+    if group < 1 or group > 255:
+         return jsonify({"success": False, "error": "Invalid group"}), 400
+         
+    with connection_lock:
+        if not is_connected or not protocol:
+            return jsonify({"success": False, "error": "Not connected"}), 400
+            
+        try:
+            raw_meas = protocol.read_meas_block(group)
+            decoded = [decode_measurement(m['formula'], m['a'], m['b']) for m in raw_meas]
+            
+            # Add labels if available
+            group_info = HVAC_GROUPS.get(group, {"name": f"Group {group}", "fields": []})
+            for i, meas in enumerate(decoded):
+                if i < len(group_info["fields"]):
+                    meas["label"] = group_info["fields"][i]
+                else:
+                    meas["label"] = f"Field {i+1}"
+                    
+            return jsonify({
+                "success": True,
+                "group": group,
+                "name": group_info["name"],
+                "measurements": decoded
+            })
+        except Exception as e:
+            return jsonify({"success": False, "error": str(e)}), 500
+
+
+@app.route('/api/meas/live/<int:group>')
+def live_meas(group):
+    global protocol, is_connected, live_mode, live_group
+    
+    live_mode = True
+    live_group = group
+    
+    def generate():
+        global is_connected, protocol, live_mode
+        try:
+            while live_mode and is_connected:
+                with connection_lock:
+                    if not is_connected or not protocol:
+                        break
+                    try:
+                        raw_meas = protocol.read_meas_block(group)
+                        decoded = [decode_measurement(m['formula'], m['a'], m['b']) for m in raw_meas]
+                        
+                        group_info = HVAC_GROUPS.get(group, {"name": f"Group {group}", "fields": []})
+                        for i, meas in enumerate(decoded):
+                            if i < len(group_info["fields"]):
+                                meas["label"] = group_info["fields"][i]
+                            else:
+                                meas["label"] = f"Field {i+1}"
+                                
+                        data = {
+                            "group": group,
+                            "measurements": decoded
+                        }
+                        yield f"data: {json.dumps(data)}\n\n"
+                    except Exception as e:
+                        logger.error(f"Live polling error: {e}")
+                        is_connected = False
+                        break
+                time.sleep(0.5) # Poll interval
+        finally:
+            live_mode = False
+
+    return Response(generate(), mimetype='text/event-stream')
+    
+@app.route('/api/meas/live/stop', methods=['POST'])
+def stop_live():
+    global live_mode
+    live_mode = False
+    return jsonify({"success": True})
+
+
+if __name__ == '__main__':
+    app.run(host='127.0.0.1', port=5000, debug=True, threaded=True)

+ 98 - 0
dtc_db.py

@@ -0,0 +1,98 @@
+# dtc_db.py
+
+HVAC_DTC_DATABASE = {
+    # === Temperature Sensors ===
+    "00785": "Temperature Sensor in Instrument Panel (G56)",
+    "00779": "Outside Air Temperature Sensor (G17)",
+    "00797": "Sunlight Photo Sensor (G107)",
+    "00256": "A/C Pressure/Temperature Sensor (G395) - No Signal",
+    "00258": "Interior Temp Sensor (G56) - Open/Short",
+    "00259": "Interior Temp Sensor (G56) - Signal Out of Range",
+    "00260": "Outside Temp Sensor (G17) - Open/Short",
+    "00261": "Outside Temp Sensor (G17) - Signal Out of Range",
+    "00262": "Dashboard Temp Sensor (G86) - Open/Short",
+    "00263": "Fresh Air Intake Temp Sensor (G89) - Open/Short",
+    "00818": "Evaporator Outlet Temp Sensor (G263) - Open/Short",
+    "00819": "A/C High Pressure Sensor (G65) - Signal Too Low",
+    "00820": "A/C High Pressure Sensor (G65) - Signal Too High",
+    
+    # === Flap Positioning Motors ===
+    "01272": "Central Flap Positioning Motor (V70) - Fault",
+    "01273": "Central Flap Potentiometer (G112) - Open/Short",
+    "01274": "Air Flow Flap Positioning Motor (V71) - Fault",
+    "01275": "Air Flow Flap Potentiometer (G113) - Open/Short",
+    "01276": "Footwell/Defroster Flap Motor (V68) - Fault",
+    "01277": "Footwell/Defroster Potentiometer (G114) - Open/Short",
+    "01278": "Temp Regulator Flap Motor (V68) - Fault",
+    "01279": "Temp Regulator Potentiometer (G92) - Open/Short",
+    
+    # === Compressor & Pressure ===
+    "00816": "A/C Compressor Control - Open Circuit",
+    "00817": "A/C Compressor Clutch (N25) - Short to Ground",
+    "01206": "A/C Refrigerant Pressure Too High",
+    "01207": "A/C Refrigerant Pressure Too Low",
+    
+    # === Blower & Electrical ===
+    "00898": "Blower Motor Control (V2) - Open Circuit",
+    "00899": "Blower Motor (V2) - Short to Ground",
+    "00600": "Fresh Air Blower (V2) - Final Stage Fault",
+    "01325": "Supply Voltage - Too Low",
+    "01326": "Supply Voltage - Too High",
+    
+    # === Control Module ===
+    "01330": "Climate Control Module - Internal Fault",
+    "00668": "Supply Voltage Terminal 30 - Voltage Too Low",
+    "65535": "Internal Control Module Memory Error",
+}
+
+ELABORATION_FLAGS = {
+    0x00: "No Further Info",
+    0x01: "Upper Limit Exceeded",
+    0x02: "Lower Limit Exceeded",
+    0x04: "No Signal",
+    0x08: "Implausible Signal",
+    0x10: "Currently Active",
+    0x20: "Not Currently Active",
+    0x40: "Intermittent",
+    0x80: "Not Tested / No Signal",
+}
+
+def decode_dtc(code_hi: int, code_lo: int, elaboration: int) -> dict:
+    code_val = (code_hi << 8) | code_lo
+    code_str = f"{code_val:05d}"
+    
+    desc = HVAC_DTC_DATABASE.get(code_str, f"Unknown Code {code_str} (Hex: {code_hi:02X} {code_lo:02X})")
+    
+    status_str = []
+    # Simple check for active/intermittent based on elaboration flags
+    is_active = False
+    is_intermittent = False
+    is_stored = True
+
+    if elaboration & 0x10:
+        is_active = True
+        status_str.append("Currently Active")
+    if elaboration & 0x40:
+        is_intermittent = True
+        status_str.append("Intermittent")
+    
+    if elaboration == 0x00:
+        status_str.append("No Further Info")
+    
+    for mask, meaning in ELABORATION_FLAGS.items():
+        if mask != 0x00 and mask != 0x10 and mask != 0x40 and (elaboration & mask):
+            status_str.append(meaning)
+            
+    status = "Stored"
+    if is_active:
+        status = "Active"
+    elif is_intermittent:
+        status = "Intermittent"
+
+    return {
+        "code": code_str,
+        "description": desc,
+        "status": status,
+        "details": ", ".join(status_str) if status_str else "Unknown Elaboration",
+        "elaboration_hex": f"0x{elaboration:02X}"
+    }

+ 48 - 0
ecu_defs.py

@@ -0,0 +1,48 @@
+# ecu_defs.py
+
+HVAC_ADDRESS = 0x08
+
+# Measuring block definitions for 8L0 820 043 E
+# These labels are based on the actual formula IDs and data patterns observed from the ECU.
+HVAC_GROUPS = {
+    1: {
+        "name": "Compressor & Blower Status",
+        "fields": ["Compressor Shut-off Code", "Target Blower Voltage",
+                   "System Voltage", "Blower Status/Duty"]
+    },
+    2: {
+        "name": "Temperature Sensors (Raw)",
+        "fields": ["Interior Sensor (G56) ADC", "Dash Sensor (G86) ADC",
+                   "Fresh Air Sensor (G89) ADC", "Set Temperature (Raw)"]
+    },
+    3: {
+        "name": "External Sensors (Raw)",
+        "fields": ["Outside Air Sensor (G17) ADC", "Evaporator Sensor (G263) ADC",
+                   "Coolant Temp ADC", "Sunlight Sensor (G107) ADC"]
+    },
+    4: {
+        "name": "Temp Regulator Flap",
+        "fields": ["V68 Position (G92)", "V68 Target Position",
+                   "V68 Motor Power", "V68 Deviation"]
+    },
+    5: {
+        "name": "Central Flap",
+        "fields": ["V70 Position (G112)", "V70 Target Position",
+                   "V70 Motor Power", "V70 Deviation"]
+    },
+    6: {
+        "name": "Footwell/Defroster Temps",
+        "fields": ["Vent Temp 1", "Vent Temp 2",
+                   "Vent Temp 3 (-49C = Open/Disconnected)", "Flap Deviation"]
+    },
+    7: {
+        "name": "Air Flow Temps",
+        "fields": ["Vent Temp 1", "Vent Temp 2",
+                   "Vent Temp 3 (-49C = Open/Disconnected)", "Unknown Status"]
+    },
+    8: {
+        "name": "Sensor Voltages",
+        "fields": ["Voltage 1", "Voltage 2",
+                   "Voltage 3", "Voltage 4"]
+    },
+}

+ 184 - 0
kline.py

@@ -0,0 +1,184 @@
+# kline.py
+import serial
+import time
+import struct
+import logging
+
+logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
+logger = logging.getLogger("KLine")
+
+class KLineException(Exception):
+    pass
+
+class KLineTransport:
+    def __init__(self, port: str):
+        self.port_name = port
+        self.ser = None
+        self.BAUD_RATE = 4800
+        self.INTER_BYTE_DELAY = 0.002 # 2ms
+
+    def _open_serial(self, baudrate=9600):
+        if self.ser and self.ser.is_open:
+            self.ser.close()
+        try:
+            self.ser = serial.Serial(
+                port=self.port_name,
+                baudrate=baudrate,
+                bytesize=serial.EIGHTBITS,
+                parity=serial.PARITY_NONE,
+                stopbits=serial.STOPBITS_ONE,
+                timeout=2.0
+            )
+            # Ensure lines are idle high
+            self.ser.rts = False
+            self.ser.dtr = False
+            self.ser.break_condition = False
+        except Exception as e:
+            raise KLineException(f"Failed to open port {self.port_name}: {e}")
+
+    def _precise_sleep(self, duration: float):
+        """Busy-wait for precise timing, as time.sleep() can be too inaccurate for bit-banging."""
+        end_time = time.perf_counter() + duration
+        while time.perf_counter() < end_time:
+            pass
+
+    def _set_lines_low(self, is_low: bool):
+        """Sets K and L lines low (dominant) or high (recessive)."""
+        self.ser.break_condition = is_low
+        self.ser.rts = is_low
+        self.ser.dtr = is_low
+
+    def slow_init(self, address: int = 0x08) -> bool:
+        """Performs the 5-baud initialization sequence on K and L lines."""
+        logger.info(f"Starting slow init for address 0x{address:02X} at {self.BAUD_RATE} baud")
+        
+        # We need to bit-bang the 5 baud signal
+        # Each bit is 200ms
+        
+        self._open_serial(baudrate=self.BAUD_RATE)
+        
+        # Idle high
+        self._set_lines_low(False)
+        time.sleep(1.0)
+        
+        logger.debug("Bit-banging address...")
+        
+        # Start bit (low)
+        self._set_lines_low(True)
+        self._precise_sleep(0.2)
+        
+        # 8 data bits (LSB first)
+        for i in range(8):
+            bit = (address >> i) & 1
+            if bit == 0:
+                self._set_lines_low(True)
+            else:
+                self._set_lines_low(False)
+            self._precise_sleep(0.2)
+            
+        # Stop bit (high)
+        self._set_lines_low(False)
+        self._precise_sleep(0.2)
+        
+        logger.debug("Address sent. Waiting for sync byte...")
+        
+        # Clear out any garbage/echo bytes that arrived in our RX buffer while we were bit-banging TX
+        self.ser.reset_input_buffer()
+        
+        # Now wait for response at normal baud rate
+        # ECU should respond with 0x55 (Sync), then two keyword bytes (0x01, 0x8A)
+        
+        self.ser.timeout = 0.5
+        sync_byte = None
+        
+        # Read up to 10 times to clear any lingering line noise and find the 0x55
+        for _ in range(10):
+            b = self.ser.read(1)
+            if not b:
+                continue
+            if b[0] == 0x55:
+                sync_byte = b
+                break
+            else:
+                logger.debug(f"Ignoring unexpected byte before sync: {b.hex()}")
+        
+        if not sync_byte:
+            raise KLineException("No response to 5-baud init (timeout).")
+                
+        logger.debug("Received sync byte 0x55")
+        
+        kw1 = self.ser.read(1)
+        kw2 = self.ser.read(1)
+        
+        if len(kw1) == 0 or len(kw2) == 0:
+             raise KLineException("Failed to read keyword bytes.")
+             
+        logger.debug(f"Received keywords: 0x{kw1[0]:02X}, 0x{kw2[0]:02X}")
+        
+        # Send complement of kw2
+        kw2_comp = (~kw2[0]) & 0xFF
+        logger.debug(f"Sending complement of KW2: 0x{kw2_comp:02X}")
+        
+        # Give ECU a little time
+        time.sleep(0.025)
+        self.ser.write(bytes([kw2_comp]))
+        
+        # Read our own echo from the K-Line
+        echo = self.ser.read(1)
+        if not echo or echo[0] != kw2_comp:
+            logger.warning("Did not receive correct echo for KW2 complement.")
+        
+        # Read back ECU's complement of the address
+        addr_comp = self.ser.read(1)
+        
+        expected_addr_comp = (~address) & 0xFF
+        
+        if not addr_comp:
+            # Some ECUs might not send this, but we'll log it
+            logger.warning("Did not receive address complement from ECU.")
+        elif addr_comp[0] != expected_addr_comp:
+            logger.warning(f"Expected address comp 0x{expected_addr_comp:02X}, got 0x{addr_comp[0]:02X}")
+            
+        logger.info("Init sequence complete.")
+        return True
+
+    def read_byte(self, complement: bool = True) -> int:
+        """Reads a byte and optionally sends its complement back."""
+        b = self.ser.read(1)
+        if not b:
+            raise KLineException("Timeout reading byte from K-Line.")
+            
+        val = b[0]
+        if complement:
+            comp = (~val) & 0xFF
+            time.sleep(self.INTER_BYTE_DELAY)
+            self.ser.write(bytes([comp]))
+            # Consume our own echo
+            echo = self.ser.read(1)
+            if not echo or echo[0] != comp:
+                raise KLineException(f"Echo mismatch in read_byte. Sent {comp:02X}, got {echo[0] if echo else 'None'}")
+            
+        return val
+
+    def write_byte(self, val: int, verify_complement: bool = True):
+        """Writes a byte and optionally reads back ECU's complement."""
+        time.sleep(self.INTER_BYTE_DELAY)
+        self.ser.write(bytes([val]))
+        
+        # Consume our own echo from the K-Line
+        echo = self.ser.read(1)
+        if not echo or echo[0] != val:
+            raise KLineException(f"Echo mismatch in write_byte. Sent {val:02X}, got {echo[0] if echo else 'None'}")
+        
+        if verify_complement:
+            b = self.ser.read(1)
+            if not b:
+                raise KLineException(f"Timeout waiting for complement of 0x{val:02X}")
+            comp = b[0]
+            expected = (~val) & 0xFF
+            if comp != expected:
+                 logger.warning(f"Byte write complement mismatch. Sent 0x{val:02X}, expected 0x{expected:02X}, got 0x{comp:02X}")
+
+    def close(self):
+        if self.ser and self.ser.is_open:
+            self.ser.close()

+ 198 - 0
kwp1281.py

@@ -0,0 +1,198 @@
+# kwp1281.py
+import time
+import logging
+from kline import KLineTransport, KLineException
+
+logger = logging.getLogger("KWP1281")
+
+class KWP1281Exception(Exception):
+    pass
+
+class KWP1281:
+    def __init__(self, port: str):
+        self.transport = KLineTransport(port)
+        self.block_counter = 0
+        self.connected = False
+        
+    def connect(self, address: int = 0x08) -> dict:
+        """Connect to ECU and read identification."""
+        try:
+            self.transport.slow_init(address)
+            self.connected = True
+            
+            # The ECU will now send identification blocks.
+            # We read them until we get a block that doesn't say "more info follows"
+            
+            ecu_info = {
+                "part_number": "",
+                "coding": "",
+                "workshop_code": "",
+                "extra": []
+            }
+            
+            # Usually ECUs send 0xF6 (ASCII string) blocks
+            while True:
+                length, counter, title, data = self.receive_block()
+                if title == 0xF6:
+                    text = "".join(chr(c) for c in data if 32 <= c <= 126)
+                    if not ecu_info["part_number"]:
+                        ecu_info["part_number"] = text
+                    else:
+                        ecu_info["extra"].append(text)
+                
+                # Check if this is the last block of the connection sequence
+                if title == 0x09: # ACK, shouldn't really happen here but just in case
+                    break
+                    
+                # To get next block, we must send an ACK (0x09)
+                self.send_ack()
+                
+                # In real KWP1281, 0x03 as title means end of communication, but we are just looking for the end of the ID phase.
+                # Actually, some ECUs just send all info, then wait for command. 
+                # A common pattern is: ECU sends 0xF6 (info), Tester sends 0x09 (ACK), ECU sends next 0xF6, Tester sends 0x09...
+                # Finally ECU sends 0x09 to say "I'm ready for commands". Let's assume we break when ECU sends 0x09.
+                if title == 0x09:
+                    break
+                    
+            return ecu_info
+            
+        except KLineException as e:
+            self.connected = False
+            self.transport.close()
+            raise KWP1281Exception(f"Connection failed: {e}")
+
+    def disconnect(self):
+        """End communication gracefully."""
+        if self.connected:
+            try:
+                self.send_block(0x06, []) # 0x06 is often End Communication
+            except Exception:
+                pass
+            self.connected = False
+        self.transport.close()
+
+    def receive_block(self):
+        """Receives a KWP1281 block."""
+        try:
+            length = self.transport.read_byte()
+            if length == 0:
+                raise KWP1281Exception("Received block length 0")
+                
+            counter = self.transport.read_byte()
+            self.block_counter = (counter + 1) % 256
+            
+            title = self.transport.read_byte()
+            
+            data = []
+            for _ in range(length - 3):
+                data.append(self.transport.read_byte())
+                
+            end_byte = self.transport.read_byte(complement=False) # 0x03 has no complement
+            
+            if end_byte != 0x03:
+                logger.warning(f"Block did not end with 0x03, got 0x{end_byte:02X}")
+                
+            logger.debug(f"Received block: len={length}, ctr={counter}, title=0x{title:02X}, data={[hex(d) for d in data]}")
+            return length, counter, title, data
+            
+        except KLineException as e:
+            self.connected = False
+            raise KWP1281Exception(f"Failed to receive block: {e}")
+
+    def send_block(self, title: int, data: list):
+        """Sends a KWP1281 block."""
+        length = len(data) + 3
+        
+        logger.debug(f"Sending block: len={length}, ctr={self.block_counter}, title=0x{title:02X}, data={[hex(d) for d in data]}")
+        
+        try:
+            self.transport.write_byte(length)
+            self.transport.write_byte(self.block_counter)
+            self.transport.write_byte(title)
+            
+            for d in data:
+                self.transport.write_byte(d)
+                
+            self.transport.write_byte(0x03, verify_complement=False) # End byte has no complement
+            
+            self.block_counter = (self.block_counter + 1) % 256
+            
+        except KLineException as e:
+            self.connected = False
+            raise KWP1281Exception(f"Failed to send block: {e}")
+
+    def send_ack(self):
+        """Sends an ACK block."""
+        self.send_block(0x09, [])
+
+    def read_dtcs(self) -> list:
+        """Reads Diagnostic Trouble Codes."""
+        if not self.connected:
+            raise KWP1281Exception("Not connected")
+            
+        self.send_block(0x07, [])
+        
+        dtcs_raw = []
+        
+        while True:
+            length, counter, title, data = self.receive_block()
+            
+            if title == 0x09:
+                break
+                
+            if title == 0xFC: # DTC response
+                # Data comes in triplets usually: Code_High, Code_Low, Elaboration/Status
+                for i in range(0, len(data), 3):
+                    if i + 2 < len(data):
+                        hi = data[i]
+                        lo = data[i+1]
+                        elab = data[i+2]
+                        if hi == 0xFF and lo == 0xFF:
+                            # 0xFFFF means end of faults
+                            pass
+                        else:
+                            dtcs_raw.append({"hi": hi, "lo": lo, "elaboration": elab})
+            
+            self.send_ack()
+                
+        return dtcs_raw
+
+    def clear_dtcs(self):
+        """Clears stored DTCs."""
+        if not self.connected:
+            raise KWP1281Exception("Not connected")
+            
+        self.send_block(0x05, [])
+        while True:
+            length, counter, title, data = self.receive_block()
+            if title == 0x09:
+                break
+            self.send_ack()
+        
+    def read_meas_block(self, group: int) -> list:
+        """Reads a specific measurement group."""
+        if not self.connected:
+            raise KWP1281Exception("Not connected")
+            
+        self.send_block(0x29, [group])
+        
+        raw_measurements = []
+        while True:
+            length, counter, title, data = self.receive_block()
+            
+            if title == 0x09:
+                break
+                
+            if title == 0xE7:
+                # Data comes in triplets: Formula, A, B
+                for i in range(0, len(data), 3):
+                    if i + 2 < len(data):
+                        raw_measurements.append({
+                            "formula": data[i],
+                            "a": data[i+1],
+                            "b": data[i+2]
+                        })
+            
+            self.send_ack()
+            
+        return raw_measurements

+ 245 - 0
meas_decoder.py

@@ -0,0 +1,245 @@
+# meas_decoder.py
+# KWP1281 Measuring Block Value Formulas
+# Reference: http://nefariousmotorsports.com/forum/index.php?topic=22.0
+# Formula IDs are DECIMAL (1-70), matching the first byte in each 3-byte measurement triplet.
+
+def decode_measurement(formula: int, a: int, b: int) -> dict:
+    val = 0.0
+    unit = ""
+    raw = f"0x{formula:02X}, {a}, {b}"
+    
+    try:
+        if formula == 1:
+            val = a * 0.2 * b
+            unit = "RPM"
+        elif formula == 2:
+            val = a * 0.002 * b
+            unit = "%"
+        elif formula == 3:
+            val = a * 0.002 * b
+            unit = "°"
+        elif formula == 4:
+            val = abs(b - 127) * 0.01 * a
+            unit = "°ATDC" if b > 127 else "°BTDC"
+        elif formula == 5:
+            val = 0.1 * a * b - 10 * a
+            unit = "°C"
+        elif formula == 6:
+            val = 0.001 * a * b
+            unit = "V"
+        elif formula == 7:
+            val = 0.01 * a * b
+            unit = "km/h"
+        elif formula == 8:
+            val = 0.1 * a * b
+            unit = ""
+        elif formula == 9:
+            val = (b - 127) * 0.02 * a
+            unit = "°"
+        elif formula == 10:
+            val = b
+            unit = "COLD" if b == 0 else "WARM"
+        elif formula == 11:
+            val = 0.0001 * a * (b - 128) + 1
+            unit = ""
+        elif formula == 12:
+            val = 0.001 * a * b
+            unit = "Ω"
+        elif formula == 13:
+            val = (b - 127) * 0.001 * a
+            unit = "mm"
+        elif formula == 14:
+            val = 0.005 * a * b
+            unit = "bar"
+        elif formula == 15:
+            val = 0.01 * a * b
+            unit = "ms"
+        elif formula == 16:
+            # Bitfield: b = bit flags, a = bitmask of which bits are used
+            val = b
+            unit = "Bits"
+        elif formula == 17:
+            # Two ASCII characters
+            val = 0
+            unit = chr(a) + chr(b) if 32 <= a <= 126 and 32 <= b <= 126 else f"{a:02X}{b:02X}"
+        elif formula == 18:
+            val = 0.04 * a * b
+            unit = "mbar"
+        elif formula == 19:
+            val = a * b * 0.01
+            unit = "L"
+        elif formula == 20:
+            val = a * (b - 128) / 128
+            unit = "%"
+        elif formula == 21:
+            val = 0.001 * a * b
+            unit = "V"
+        elif formula == 22:
+            val = 0.001 * a * b
+            unit = "ms"
+        elif formula == 23:
+            val = (b / 256) * a
+            unit = "%"
+        elif formula == 24:
+            val = 0.001 * a * b
+            unit = "A"
+        elif formula == 25:
+            val = (b * 1.421) + (a / 182)
+            unit = "g/s"
+        elif formula == 26:
+            val = b - a
+            unit = "°C"
+        elif formula == 27:
+            val = abs(b - 128) * 0.01 * a
+            unit = "°ATDC" if b < 128 else "°BTDC"
+        elif formula == 28:
+            val = b - a
+            unit = ""
+        elif formula == 29:
+            val = 0
+            unit = "Map 1" if b < a else "Map 2"
+        elif formula == 30:
+            val = (b / 12) * a
+            unit = "°C kW"
+        elif formula == 31:
+            val = (b / 2560) * a
+            unit = "°C"
+        elif formula == 32:
+            val = (b - 256) if b > 128 else b
+            unit = ""
+        elif formula == 33:
+            val = (100 * b / a) if a != 0 else (100 * b)
+            unit = "%"
+        elif formula == 34:
+            val = (b - 128) * 0.01 * a
+            unit = "kW"
+        elif formula == 35:
+            val = 0.01 * a * b
+            unit = "L/h"
+        elif formula == 36:
+            val = b * 10 + a * 2560
+            unit = "km"
+        elif formula == 37:
+            val = (a << 8) | b  # Unknown formula, show raw
+            unit = "raw"
+        elif formula == 38:
+            val = (b - 128) * 0.001 * a
+            unit = "°C kW"
+        elif formula == 39:
+            val = (b / 256) * a
+            unit = "mg/h"
+        elif formula == 40:
+            val = b * 0.1 + (25.5 * a) - 400
+            unit = "A"
+        elif formula == 41:
+            val = b + a * 255
+            unit = "Ah"
+        elif formula == 42:
+            val = b * 0.1 + (25.5 * a) - 400
+            unit = "kW"
+        elif formula == 43:
+            val = b * 0.1 + (25.5 * a)
+            unit = "V"
+        elif formula == 44:
+            # Time display
+            val = 0
+            unit = f"{a}:{b:02d}"
+        elif formula == 45:
+            val = 0.1 * a * b / 100
+            unit = ""
+        elif formula == 46:
+            val = (a * b - 3200) * 0.0027
+            unit = "°C kW"
+        elif formula == 47:
+            val = (b - 128) * a
+            unit = "ms"
+        elif formula == 48:
+            val = b + a * 255
+            unit = ""
+        elif formula == 49:
+            val = (b / 4) * a
+            unit = "mg/h"
+        elif formula == 50:
+            val = ((b - 128) / (0.01 * a)) if a != 0 else ((b - 128) / 0.01)
+            unit = "mbar"
+        elif formula == 51:
+            val = ((b - 128) / 255) * a
+            unit = "mg/h"
+        elif formula == 52:
+            val = b * 0.02 * a - a
+            unit = "Nm"
+        elif formula == 53:
+            val = (b - 128) * 1.4222 + 0.006 * a
+            unit = "g/s"
+        elif formula == 54:
+            val = a * 256 + b
+            unit = ""
+        elif formula == 55:
+            val = a * b / 200
+            unit = "s"
+        elif formula == 56:
+            val = a * 256 + b
+            unit = "WSC"
+        elif formula == 57:
+            val = a * 256 + b + 65536
+            unit = "WSC"
+        elif formula == 58:
+            val = 1.0225 * (256 - b) if b > 128 else 1.0225 * b
+            unit = ""
+        elif formula == 59:
+            val = (a * 256 + b) / 32768
+            unit = ""
+        elif formula == 60:
+            val = (a * 256 + b) * 0.01
+            unit = "s"
+        elif formula == 61:
+            val = ((b - 128) / a) if a != 0 else (b - 128)
+            unit = ""
+        elif formula == 62:
+            val = 0.256 * a * b
+            unit = "S"
+        elif formula == 63:
+            # ASCII + "?"
+            val = 0
+            unit = (chr(a) if 32 <= a <= 126 else "?") + (chr(b) if 32 <= b <= 126 else "?") + "?"
+        elif formula == 64:
+            val = a + b
+            unit = "Ω"
+        elif formula == 65:
+            val = 0.01 * a * (b - 127)
+            unit = "mm"
+        elif formula == 66:
+            val = (a * b) / 511.12
+            unit = "V"
+        elif formula == 67:
+            val = (640 * a) + b * 2.5
+            unit = "°"
+        elif formula == 68:
+            val = (256 * a + b) / 7.365
+            unit = "°/s"
+        elif formula == 69:
+            val = (256 * a + b) * 0.3254
+            unit = "bar"
+        elif formula == 70:
+            val = (256 * a + b) * 0.192
+            unit = "m/s²"
+        else:
+            val = (a << 8) | b  # Fallback raw value
+            unit = "raw"
+            
+    except Exception as e:
+        val = 0
+        unit = "Err"
+        
+    # Round to 2 decimal places if it's a float
+    if isinstance(val, float):
+        val = round(val, 2)
+        
+    return {
+        "value": val,
+        "unit": unit,
+        "raw": raw,
+        "formula_id": formula,
+        "a": a,
+        "b": b
+    }

+ 47 - 0
port_scanner.py

@@ -0,0 +1,47 @@
+# port_scanner.py
+import serial.tools.list_ports
+
+def scan_ports() -> list[dict]:
+    """Returns list of candidate KKL ports."""
+    ports = serial.tools.list_ports.comports()
+    candidates = []
+    
+    for port in ports:
+        # Prioritize FTDI and common USB-to-Serial
+        is_likely = False
+        desc = port.description.lower()
+        hwid = port.hwid.lower()
+        
+        if "ftdi" in desc or "ftdi" in hwid or "0403" in hwid:
+            is_likely = True
+        elif "usb" in desc or "ch340" in desc or "serial" in desc:
+            is_likely = True
+            
+        candidates.append({
+            "device": port.device,
+            "description": port.description,
+            "is_likely": is_likely
+        })
+        
+    # Sort likely ports first
+    candidates.sort(key=lambda x: not x["is_likely"])
+    return candidates
+
+def auto_select() -> str | None:
+    """Returns the best-guess port, or None if ambiguous or none found."""
+    ports = scan_ports()
+    if not ports:
+        return None
+        
+    likely_ports = [p for p in ports if p["is_likely"]]
+    
+    if len(likely_ports) == 1:
+        return likely_ports[0]["device"]
+    elif len(likely_ports) > 1:
+        # If multiple likely, return None to force user choice
+        return None
+    elif len(ports) > 0:
+        # Fallback to first available if no obvious candidate
+        return ports[0]["device"]
+        
+    return None

+ 2 - 0
requirements.txt

@@ -0,0 +1,2 @@
+pyserial>=3.5
+Flask>=3.0.0

+ 841 - 0
static/css/style.css

@@ -0,0 +1,841 @@
+:root {
+    /* Colors */
+    --bg-primary: #0a0a0f;
+    --bg-card: rgba(18, 18, 26, 0.7);
+    --bg-card-hover: rgba(26, 26, 40, 0.85);
+    
+    --text-main: #f0f0f5;
+    --text-muted: #8b8b9e;
+    
+    --border-subtle: rgba(255, 255, 255, 0.08);
+    --border-highlight: rgba(255, 255, 255, 0.15);
+    
+    --accent-red: #ff3366;
+    --accent-red-hover: #ff4d79;
+    --accent-blue: #00d4ff;
+    --accent-blue-hover: #33ddff;
+    --accent-green: #00e676;
+    
+    --status-active: #ff4757;
+    --status-intermittent: #ffa502;
+    --status-stored: #2ed573;
+    --status-disconnected: #636e72;
+
+    /* Fonts */
+    --font-display: 'Outfit', system-ui, sans-serif;
+    --font-mono: 'JetBrains Mono', monospace;
+}
+
+* {
+    box-sizing: border-box;
+    margin: 0;
+    padding: 0;
+}
+
+body {
+    background-color: var(--bg-primary);
+    background-image: 
+        radial-gradient(circle at 15% 50%, rgba(0, 212, 255, 0.05), transparent 25%),
+        radial-gradient(circle at 85% 30%, rgba(255, 51, 102, 0.05), transparent 25%);
+    background-attachment: fixed;
+    color: var(--text-main);
+    font-family: var(--font-display);
+    line-height: 1.5;
+    height: 100vh;
+    overflow: hidden;
+}
+
+.app-container {
+    display: flex;
+    flex-direction: column;
+    height: 100vh;
+    padding: 1rem;
+    gap: 1rem;
+}
+
+/* Glassmorphism utility */
+.glass-panel {
+    background: var(--bg-card);
+    backdrop-filter: blur(12px);
+    -webkit-backdrop-filter: blur(12px);
+    border: 1px solid var(--border-subtle);
+    border-radius: 12px;
+    box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.3);
+}
+
+/* Typography */
+h1 {
+    font-size: 1.5rem;
+    font-weight: 800;
+    letter-spacing: 1px;
+}
+h1 span {
+    color: var(--accent-red);
+    font-weight: 300;
+}
+h2 {
+    font-size: 1.2rem;
+    font-weight: 600;
+    color: var(--text-main);
+}
+h3 {
+    font-size: 1rem;
+    font-weight: 600;
+}
+
+.font-mono {
+    font-family: var(--font-mono);
+}
+
+/* Header */
+.header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 1rem 1.5rem;
+    flex-shrink: 0;
+}
+
+.logo-area {
+    display: flex;
+    align-items: center;
+    gap: 1rem;
+}
+
+.rings {
+    display: flex;
+    gap: -8px; /* Overlap */
+}
+.ring {
+    width: 24px;
+    height: 24px;
+    border: 3px solid var(--text-main);
+    border-radius: 50%;
+    margin-right: -8px;
+}
+
+.connection-area {
+    display: flex;
+    align-items: center;
+    gap: 1rem;
+}
+
+.port-selector {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+select, input {
+    background: rgba(0,0,0,0.3);
+    border: 1px solid var(--border-subtle);
+    color: var(--text-main);
+    padding: 0.5rem 1rem;
+    border-radius: 6px;
+    font-family: var(--font-display);
+    font-size: 0.9rem;
+    outline: none;
+    transition: all 0.2s;
+}
+
+select:focus, input:focus {
+    border-color: var(--accent-blue);
+    box-shadow: 0 0 0 2px rgba(0, 212, 255, 0.2);
+}
+
+/* Buttons */
+.btn {
+    padding: 0.5rem 1.2rem;
+    border: none;
+    border-radius: 6px;
+    font-family: var(--font-display);
+    font-size: 0.9rem;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.2s ease;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+}
+
+.btn:disabled {
+    opacity: 0.5;
+    cursor: not-allowed;
+}
+
+.btn:active {
+    transform: scale(0.98);
+}
+
+.btn-primary {
+    background: var(--accent-blue);
+    color: #000;
+}
+.btn-primary:hover:not(:disabled) {
+    background: var(--accent-blue-hover);
+    box-shadow: 0 0 15px rgba(0, 212, 255, 0.4);
+}
+
+.btn-secondary {
+    background: rgba(255,255,255,0.1);
+    color: var(--text-main);
+}
+.btn-secondary:hover:not(:disabled) {
+    background: rgba(255,255,255,0.2);
+}
+
+.btn-danger {
+    background: rgba(255, 51, 102, 0.2);
+    color: var(--accent-red);
+    border: 1px solid var(--accent-red);
+}
+.btn-danger:hover:not(:disabled) {
+    background: var(--accent-red);
+    color: #fff;
+    box-shadow: 0 0 15px rgba(255, 51, 102, 0.4);
+}
+
+.icon-btn {
+    background: none;
+    border: none;
+    color: var(--text-muted);
+    font-size: 1.2rem;
+    cursor: pointer;
+    transition: color 0.2s;
+}
+.icon-btn:hover {
+    color: var(--text-main);
+}
+
+/* Status Indicator */
+.status-indicator {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    font-size: 0.85rem;
+    font-weight: 600;
+    text-transform: uppercase;
+    width: 100px;
+}
+
+.dot {
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+}
+.dot.disconnected { background: var(--status-disconnected); }
+.dot.connected { 
+    background: var(--accent-green); 
+    box-shadow: 0 0 10px var(--accent-green);
+    animation: pulse 2s infinite;
+}
+
+@keyframes pulse {
+    0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(0, 230, 118, 0.7); }
+    70% { transform: scale(1); box-shadow: 0 0 0 6px rgba(0, 230, 118, 0); }
+    100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(0, 230, 118, 0); }
+}
+
+/* Layout Utilities */
+.hidden { display: none !important; }
+
+.main-content {
+    display: flex;
+    flex-direction: column;
+    gap: 1rem;
+    flex: 1;
+    overflow-y: auto;
+    overflow-x: hidden;
+}
+
+/* ECU Info Bar */
+.ecu-info-bar {
+    display: flex;
+    padding: 1rem 1.5rem;
+    gap: 2rem;
+    flex-shrink: 0;
+}
+
+.ecu-item {
+    display: flex;
+    flex-direction: column;
+}
+.ecu-item .label {
+    font-size: 0.7rem;
+    color: var(--text-muted);
+    text-transform: uppercase;
+    letter-spacing: 1px;
+}
+.ecu-item .value {
+    font-size: 1.1rem;
+    font-weight: 600;
+    color: var(--accent-blue);
+}
+
+/* Dashboard Grid */
+.dashboard-grid {
+    display: grid;
+    grid-template-columns: 1fr 1.5fr;
+    gap: 1rem;
+    flex: 1;
+    min-height: 500px;
+}
+
+.panel {
+    display: flex;
+    flex-direction: column;
+    padding: 1.5rem;
+}
+
+.panel-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 1.5rem;
+}
+
+.panel-actions {
+    display: flex;
+    gap: 0.5rem;
+}
+
+.scrollable {
+    overflow-y: auto;
+    /* Custom Scrollbar */
+    scrollbar-width: thin;
+    scrollbar-color: var(--border-highlight) transparent;
+}
+.scrollable::-webkit-scrollbar { width: 6px; }
+.scrollable::-webkit-scrollbar-track { background: transparent; }
+.scrollable::-webkit-scrollbar-thumb { background-color: var(--border-highlight); border-radius: 3px; }
+
+/* DTC List */
+.dtc-list {
+    display: flex;
+    flex-direction: column;
+    gap: 0.8rem;
+    flex: 1;
+}
+
+.empty-state {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    height: 100%;
+    color: var(--text-muted);
+    text-align: center;
+}
+.empty-state .icon {
+    font-size: 3rem;
+    margin-bottom: 1rem;
+    color: var(--border-highlight);
+}
+.empty-state.success .icon {
+    color: var(--accent-green);
+}
+
+.dtc-card {
+    background: rgba(0,0,0,0.2);
+    border: 1px solid var(--border-subtle);
+    border-radius: 8px;
+    padding: 1rem;
+    transition: transform 0.2s;
+}
+.dtc-card:hover {
+    transform: translateY(-2px);
+    background: rgba(255,255,255,0.02);
+}
+
+.dtc-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: flex-start;
+    margin-bottom: 0.5rem;
+}
+
+.dtc-code {
+    font-family: var(--font-mono);
+    font-size: 1.2rem;
+    font-weight: 700;
+    color: var(--text-main);
+}
+
+.dtc-badge {
+    padding: 0.2rem 0.6rem;
+    border-radius: 12px;
+    font-size: 0.75rem;
+    font-weight: 600;
+    text-transform: uppercase;
+}
+.dtc-badge.active { background: rgba(255, 71, 87, 0.2); color: var(--status-active); border: 1px solid var(--status-active); }
+.dtc-badge.intermittent { background: rgba(255, 165, 2, 0.2); color: var(--status-intermittent); border: 1px solid var(--status-intermittent); }
+.dtc-badge.stored { background: rgba(46, 213, 115, 0.2); color: var(--status-stored); border: 1px solid var(--status-stored); }
+
+.dtc-desc {
+    font-size: 0.95rem;
+    color: var(--text-main);
+    margin-bottom: 0.5rem;
+}
+
+.dtc-details {
+    font-size: 0.8rem;
+    color: var(--text-muted);
+}
+
+/* Measurements */
+.group-selector {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+.group-pills {
+    display: flex;
+    gap: 0.3rem;
+}
+
+.pill {
+    background: rgba(255,255,255,0.05);
+    border: 1px solid var(--border-subtle);
+    color: var(--text-muted);
+    padding: 0.3rem 0.6rem;
+    border-radius: 4px;
+    cursor: pointer;
+    font-weight: 600;
+    transition: all 0.2s;
+}
+.pill:hover { background: rgba(255,255,255,0.1); color: var(--text-main); }
+.pill.active { background: var(--accent-blue); color: #000; border-color: var(--accent-blue); }
+
+#customGroupInput {
+    width: 60px;
+    padding: 0.3rem 0.5rem;
+}
+
+.meas-controls {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    margin-bottom: 1.5rem;
+    background: rgba(0,0,0,0.2);
+    padding: 0.8rem 1rem;
+    border-radius: 8px;
+}
+
+.group-name {
+    font-weight: 600;
+    color: var(--accent-blue);
+    font-size: 1.1rem;
+}
+
+/* Toggle Switch */
+.switch {
+  position: relative;
+  display: inline-block;
+  width: 44px;
+  height: 24px;
+}
+.switch input { opacity: 0; width: 0; height: 0; }
+.slider {
+  position: absolute;
+  cursor: pointer;
+  top: 0; left: 0; right: 0; bottom: 0;
+  background-color: rgba(255,255,255,0.1);
+  transition: .4s;
+}
+.slider:before {
+  position: absolute;
+  content: "";
+  height: 16px; width: 16px;
+  left: 4px; bottom: 4px;
+  background-color: var(--text-muted);
+  transition: .4s;
+}
+input:checked + .slider { background-color: rgba(0, 212, 255, 0.3); border: 1px solid var(--accent-blue); }
+input:checked + .slider:before { transform: translateX(20px); background-color: var(--accent-blue); }
+.slider.round { border-radius: 24px; }
+.slider.round:before { border-radius: 50%; }
+
+.live-toggle {
+    display: flex;
+    align-items: center;
+    gap: 0.8rem;
+    font-size: 0.9rem;
+    font-weight: 600;
+}
+
+/* Measurement Grid */
+.meas-grid {
+    display: grid;
+    grid-template-columns: 1fr 1fr;
+    grid-template-rows: 1fr 1fr;
+    gap: 1rem;
+    flex: 1;
+}
+
+.meas-card {
+    background: rgba(0,0,0,0.2);
+    border: 1px solid var(--border-subtle);
+    border-radius: 12px;
+    padding: 1.5rem;
+    display: flex;
+    flex-direction: column;
+    justify-content: space-between;
+    position: relative;
+    overflow: hidden;
+    transition: border-color 0.3s;
+}
+.meas-card.updated {
+    border-color: rgba(0, 212, 255, 0.5);
+}
+
+.meas-label {
+    font-size: 0.85rem;
+    color: var(--text-muted);
+    font-weight: 600;
+    text-transform: uppercase;
+    z-index: 2;
+}
+
+.meas-value-area {
+    display: flex;
+    align-items: baseline;
+    gap: 0.3rem;
+    z-index: 2;
+}
+
+.meas-val {
+    font-family: var(--font-mono);
+    font-size: 2.5rem;
+    font-weight: 700;
+    color: var(--text-main);
+}
+
+.meas-unit {
+    font-size: 1.2rem;
+    color: var(--accent-blue);
+    font-weight: 600;
+}
+
+.meas-sparkline {
+    position: absolute;
+    bottom: 0;
+    left: 0;
+    width: 100%;
+    height: 40px;
+    opacity: 0.4;
+    z-index: 1;
+}
+
+/* Log Panel */
+.log-panel {
+    flex-shrink: 0;
+    display: flex;
+    flex-direction: column;
+}
+
+.log-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 0.8rem 1.5rem;
+    border-bottom: 1px solid var(--border-subtle);
+}
+
+.log-content {
+    height: 150px;
+    padding: 1rem 1.5rem;
+    font-size: 0.8rem;
+    transition: height 0.3s ease;
+}
+.log-content.collapsed {
+    height: 0;
+    padding: 0 1.5rem;
+    border: none;
+}
+
+.log-line {
+    margin-bottom: 0.3rem;
+    color: var(--text-muted);
+}
+.log-line.system { color: var(--accent-blue); }
+.log-line.error { color: var(--accent-red); }
+.log-line.data { color: var(--text-main); }
+
+/* Modal */
+.modal {
+    position: fixed;
+    top: 0; left: 0; right: 0; bottom: 0;
+    background: rgba(0,0,0,0.6);
+    backdrop-filter: blur(5px);
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    z-index: 1000;
+}
+
+.modal-content {
+    padding: 2rem;
+    width: 400px;
+    text-align: center;
+}
+
+.modal h3 {
+    font-size: 1.5rem;
+    margin-bottom: 1rem;
+}
+
+.warning {
+    color: var(--accent-red);
+    margin-bottom: 2rem;
+}
+
+.modal-actions {
+    display: flex;
+    justify-content: center;
+    gap: 1rem;
+}
+
+/* Responsive */
+@media (max-width: 1024px) {
+    .dashboard-grid {
+        grid-template-columns: 1fr;
+        overflow-y: auto;
+    }
+    .main-content {
+        overflow-y: auto;
+    }
+    .dtc-panel {
+        height: 400px;
+        flex-shrink: 0;
+    }
+    .meas-panel {
+        min-height: 500px;
+        flex-shrink: 0;
+    }
+    .gauge-grid {
+        grid-template-columns: repeat(3, 1fr) !important;
+    }
+}
+
+/* ECU Actions */
+.ecu-actions {
+    margin-left: auto;
+    display: flex;
+    align-items: center;
+}
+
+/* Export Buttons */
+.btn-export {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+    padding: 0.5rem 1.2rem;
+    background: linear-gradient(135deg, rgba(0, 212, 255, 0.15), rgba(0, 230, 118, 0.15));
+    border: 1px solid rgba(0, 212, 255, 0.3);
+    color: var(--accent-blue);
+    border-radius: 6px;
+    font-family: var(--font-display);
+    font-size: 0.85rem;
+    font-weight: 600;
+    cursor: pointer;
+    text-transform: uppercase;
+    letter-spacing: 0.5px;
+    transition: all 0.3s ease;
+}
+.btn-export:hover {
+    background: linear-gradient(135deg, rgba(0, 212, 255, 0.25), rgba(0, 230, 118, 0.25));
+    border-color: var(--accent-blue);
+    box-shadow: 0 0 20px rgba(0, 212, 255, 0.2);
+    transform: translateY(-1px);
+}
+.btn-export svg {
+    flex-shrink: 0;
+}
+
+.btn-export-sm {
+    padding: 0.35rem 0.7rem;
+    background: rgba(0, 212, 255, 0.1);
+    border: 1px solid rgba(0, 212, 255, 0.2);
+    color: var(--accent-blue);
+    border-radius: 4px;
+    font-family: var(--font-display);
+    font-size: 0.75rem;
+    font-weight: 600;
+    cursor: pointer;
+    transition: all 0.2s ease;
+}
+.btn-export-sm:hover {
+    background: rgba(0, 212, 255, 0.2);
+    border-color: var(--accent-blue);
+}
+
+/* Gauge Dashboard */
+.gauge-dashboard {
+    padding: 1.5rem;
+    flex-shrink: 0;
+}
+
+.gauge-grid {
+    display: grid;
+    grid-template-columns: repeat(6, 1fr);
+    gap: 1rem;
+}
+
+.gauge-card {
+    display: flex;
+    flex-direction: column;
+    align-items: center;
+    justify-content: center;
+    padding: 1rem 0.5rem;
+    background: rgba(0, 0, 0, 0.25);
+    border: 1px solid var(--border-subtle);
+    border-radius: 12px;
+    transition: all 0.3s ease;
+    position: relative;
+}
+
+.gauge-card:hover {
+    border-color: rgba(0, 212, 255, 0.3);
+    background: rgba(0, 0, 0, 0.35);
+}
+
+.gauge-card canvas {
+    margin-bottom: 0.3rem;
+}
+
+.gauge-info {
+    display: flex;
+    align-items: baseline;
+    gap: 0.2rem;
+    margin-bottom: 0.2rem;
+}
+
+.gauge-value {
+    font-family: var(--font-mono);
+    font-size: 1.6rem;
+    font-weight: 700;
+    color: var(--text-main);
+}
+
+.gauge-unit {
+    font-size: 0.85rem;
+    color: var(--accent-blue);
+    font-weight: 600;
+}
+
+.gauge-label {
+    font-size: 0.7rem;
+    color: var(--text-muted);
+    text-transform: uppercase;
+    letter-spacing: 1px;
+    font-weight: 600;
+    text-align: center;
+}
+
+/* Compressor Status */
+.compressor-status {
+    margin-bottom: 0.3rem;
+}
+
+.compressor-ring {
+    transition: all 0.4s ease;
+}
+
+.compressor-ring.off {
+    color: var(--text-muted);
+    opacity: 0.4;
+}
+
+.compressor-ring.on {
+    color: var(--accent-green);
+    filter: drop-shadow(0 0 8px rgba(0, 230, 118, 0.6));
+    animation: compressor-pulse 1.5s ease-in-out infinite;
+}
+
+@keyframes compressor-pulse {
+    0%, 100% { filter: drop-shadow(0 0 6px rgba(0, 230, 118, 0.4)); }
+    50% { filter: drop-shadow(0 0 14px rgba(0, 230, 118, 0.8)); }
+}
+
+/* Gauge arc colors */
+.gauge-card[data-warn] {
+    border-color: rgba(255, 165, 2, 0.3);
+}
+.gauge-card[data-danger] {
+    border-color: rgba(255, 51, 102, 0.3);
+}
+
+/* Car Sensor Diagram */
+.car-diagram-section {
+    padding: 1.5rem;
+    flex-shrink: 0;
+}
+
+.car-diagram-container {
+    display: flex;
+    justify-content: center;
+    padding: 0.5rem 0;
+}
+
+#carDiagram {
+    width: 100%;
+    max-width: 850px;
+    height: auto;
+}
+
+.diagram-legend {
+    display: flex;
+    gap: 1.2rem;
+    align-items: center;
+}
+
+.legend-item {
+    display: flex;
+    align-items: center;
+    gap: 0.4rem;
+    font-size: 0.8rem;
+    color: var(--text-muted);
+    font-weight: 600;
+}
+
+.legend-dot {
+    width: 10px;
+    height: 10px;
+    border-radius: 50%;
+}
+
+.legend-dot.ok {
+    background: var(--accent-blue);
+    box-shadow: 0 0 6px rgba(0, 212, 255, 0.5);
+}
+
+.legend-dot.warn {
+    background: #ffa502;
+    box-shadow: 0 0 6px rgba(255, 165, 2, 0.5);
+}
+
+.legend-dot.fault {
+    background: var(--accent-red);
+    box-shadow: 0 0 6px rgba(255, 51, 102, 0.5);
+    animation: fault-blink 1.5s ease-in-out infinite;
+}
+
+@keyframes fault-blink {
+    0%, 100% { opacity: 1; }
+    50% { opacity: 0.4; }
+}
+
+.sensor-group {
+    cursor: pointer;
+    transition: opacity 0.3s;
+}
+
+.sensor-group:hover {
+    opacity: 0.8;
+}
+
+@keyframes sensor-pulse-fault {
+    0%, 100% { r: 8; opacity: 1; }
+    50% { r: 12; opacity: 0.6; }
+}

+ 866 - 0
static/js/app.js

@@ -0,0 +1,866 @@
+document.addEventListener('DOMContentLoaded', () => {
+    // --- State ---
+    let isConnected = false;
+    let liveMode = false;
+    let currentGroup = 1;
+    let eventSource = null;
+    let sparklineData = {};
+    let dashRefreshTimer = null;
+
+    // Cached data for export
+    let lastDTCs = [];
+    let lastMeasurements = {};
+    let ecuInfoCache = {};
+
+    // --- DOM Elements ---
+    const portSelect = document.getElementById('portSelect');
+    const refreshPortsBtn = document.getElementById('refreshPortsBtn');
+    const connectBtn = document.getElementById('connectBtn');
+    const statusDot = document.getElementById('statusDot');
+    const statusText = document.getElementById('statusText');
+    const mainContent = document.getElementById('mainContent');
+    
+    const ecuPartNum = document.getElementById('ecuPartNum');
+    const ecuExtra = document.getElementById('ecuExtra');
+    
+    const dtcContainer = document.getElementById('dtcContainer');
+    const readDTCBtn = document.getElementById('readDTCBtn');
+    const clearDTCBtn = document.getElementById('clearDTCBtn');
+    const exportDTCBtn = document.getElementById('exportDTCBtn');
+    
+    const groupPills = document.getElementById('groupPills');
+    const customGroupInput = document.getElementById('customGroupInput');
+    const readGroupBtn = document.getElementById('readGroupBtn');
+    const groupNameLabel = document.getElementById('groupNameLabel');
+    const measContainer = document.getElementById('measContainer');
+    const liveModeToggle = document.getElementById('liveModeToggle');
+    const exportMeasBtn = document.getElementById('exportMeasBtn');
+    
+    const toggleLogBtn = document.getElementById('toggleLogBtn');
+    const logContainer = document.getElementById('logContainer');
+    
+    const confirmModal = document.getElementById('confirmModal');
+    const cancelModalBtn = document.getElementById('cancelModalBtn');
+    const confirmClearBtn = document.getElementById('confirmClearBtn');
+
+    const dashLiveToggle = document.getElementById('dashLiveToggle');
+    const exportAllBtn = document.getElementById('exportAllBtn');
+
+    // --- Initialization ---
+    initGroupPills();
+    fetchPorts();
+    
+    // --- Event Listeners ---
+    refreshPortsBtn.addEventListener('click', fetchPorts);
+    
+    connectBtn.addEventListener('click', () => {
+        if (isConnected) {
+            disconnect();
+        } else {
+            connect();
+        }
+    });
+
+    readDTCBtn.addEventListener('click', fetchDTCs);
+    
+    clearDTCBtn.addEventListener('click', () => {
+        confirmModal.classList.remove('hidden');
+    });
+    
+    cancelModalBtn.addEventListener('click', () => {
+        confirmModal.classList.add('hidden');
+    });
+    
+    confirmClearBtn.addEventListener('click', () => {
+        confirmModal.classList.add('hidden');
+        clearDTCs();
+    });
+
+    readGroupBtn.addEventListener('click', () => {
+        let group = customGroupInput.value ? parseInt(customGroupInput.value) : currentGroup;
+        if (liveModeToggle.checked) {
+            startLiveMode(group);
+        } else {
+            fetchMeasGroup(group);
+        }
+    });
+
+    liveModeToggle.addEventListener('change', (e) => {
+        if (e.target.checked) {
+            startLiveMode(currentGroup);
+        } else {
+            stopLiveMode();
+        }
+    });
+
+    toggleLogBtn.addEventListener('click', () => {
+        logContainer.classList.toggle('collapsed');
+        toggleLogBtn.textContent = logContainer.classList.contains('collapsed') ? '▼' : '▲';
+    });
+
+    // Dashboard auto-refresh toggle
+    dashLiveToggle.addEventListener('change', (e) => {
+        if (e.target.checked) {
+            startDashboardRefresh();
+        } else {
+            stopDashboardRefresh();
+        }
+    });
+
+    // Export buttons
+    exportDTCBtn.addEventListener('click', exportDTCsToFile);
+    exportMeasBtn.addEventListener('click', exportMeasToFile);
+    exportAllBtn.addEventListener('click', exportFullReport);
+
+    // --- Gauge Drawing ---
+    function drawGauge(canvasId, value, min, max, color = '#00d4ff', warnThresh = null, dangerThresh = null) {
+        const canvas = document.getElementById(canvasId);
+        if (!canvas) return;
+        
+        const ctx = canvas.getContext('2d');
+        const w = canvas.width;
+        const h = canvas.height;
+        const cx = w / 2;
+        const cy = h / 2 + 10;
+        const r = Math.min(w, h) / 2 - 15;
+
+        ctx.clearRect(0, 0, w, h);
+
+        // Angle range: from 220° to -40° (sweeping 260°)
+        const startAngle = (220 * Math.PI) / 180;
+        const endAngle = (-40 * Math.PI) / 180;
+        const totalAngle = startAngle - endAngle;
+
+        // Background arc
+        ctx.beginPath();
+        ctx.arc(cx, cy, r, endAngle, startAngle, false);
+        ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
+        ctx.lineWidth = 10;
+        ctx.lineCap = 'round';
+        ctx.stroke();
+
+        // Value arc
+        if (typeof value === 'number' && !isNaN(value)) {
+            const clamped = Math.max(min, Math.min(max, value));
+            const pct = (clamped - min) / (max - min);
+            const valAngle = startAngle - pct * totalAngle;
+
+            // Determine color based on thresholds
+            let arcColor = color;
+            if (dangerThresh !== null && value >= dangerThresh) {
+                arcColor = '#ff3366';
+            } else if (warnThresh !== null && value >= warnThresh) {
+                arcColor = '#ffa502';
+            }
+
+            ctx.beginPath();
+            ctx.arc(cx, cy, r, startAngle, valAngle, true);
+            ctx.strokeStyle = arcColor;
+            ctx.lineWidth = 10;
+            ctx.lineCap = 'round';
+            ctx.stroke();
+
+            // Glow effect
+            ctx.beginPath();
+            ctx.arc(cx, cy, r, startAngle, valAngle, true);
+            ctx.strokeStyle = arcColor;
+            ctx.lineWidth = 10;
+            ctx.lineCap = 'round';
+            ctx.shadowColor = arcColor;
+            ctx.shadowBlur = 12;
+            ctx.stroke();
+            ctx.shadowBlur = 0;
+
+            // Min/Max labels
+            ctx.font = '10px Outfit, sans-serif';
+            ctx.fillStyle = 'rgba(255,255,255,0.3)';
+            ctx.textAlign = 'center';
+            
+            const minLabelX = cx + (r + 12) * Math.cos(startAngle);
+            const minLabelY = cy + (r + 12) * Math.sin(startAngle);
+            ctx.fillText(min, minLabelX, minLabelY);
+
+            const maxLabelX = cx + (r + 12) * Math.cos(endAngle);
+            const maxLabelY = cy + (r + 12) * Math.sin(endAngle);
+            ctx.fillText(max, maxLabelX, maxLabelY);
+        }
+    }
+
+    function updateGauges(group, measurements) {
+        if (!measurements || measurements.length < 1) return;
+
+        if (group === 1) {
+            // Group 1: Compressor & Blower Status
+            // Field 0: Compressor Shut-off Code
+            if (measurements[0]) {
+                const compCode = measurements[0].value;
+                const compRing = document.querySelector('.compressor-ring');
+                const compValEl = document.getElementById('gaugeValCompressor');
+                
+                // Compressor is typically ON if shut-off code is 0
+                const isOn = (compCode === 0);
+                compRing.className = `compressor-ring ${isOn ? 'on' : 'off'}`;
+                compValEl.textContent = isOn ? 'ON' : 'OFF';
+                compValEl.style.color = isOn ? 'var(--accent-green)' : 'var(--text-muted)';
+            }
+            // Field 1: Target Blower Voltage
+            if (measurements[1]) {
+                const blowerVal = measurements[1].value;
+                document.getElementById('gaugeValBlower').textContent = typeof blowerVal === 'number' ? blowerVal.toFixed(1) : blowerVal;
+                drawGauge('gaugeCanvasBlower', blowerVal, 0, 15, '#00d4ff');
+            }
+            // Field 2: System Voltage
+            if (measurements[2]) {
+                const sysV = measurements[2].value;
+                document.getElementById('gaugeValSysV').textContent = typeof sysV === 'number' ? sysV.toFixed(1) : sysV;
+                drawGauge('gaugeCanvasSysV', sysV, 10, 16, '#00e676', null, null);
+            }
+        }
+
+        if (group === 2) {
+            // Group 2: Temperature Sensors (Raw)
+            // Field 0: Interior Sensor (G56)
+            if (measurements[0]) {
+                const intTemp = measurements[0].value;
+                document.getElementById('gaugeValInterior').textContent = typeof intTemp === 'number' ? intTemp.toFixed(1) : intTemp;
+                // Using generic 0-255 range for ADC counts since we don't have the exact formula mapping yet
+                drawGauge('gaugeCanvasInterior', intTemp, 0, 255, '#00d4ff', 200, 240);
+            }
+        }
+
+        if (group === 3) {
+            // Group 3: External Sensors (Raw)
+            // Field 0: Outside Air Sensor (G17)
+            if (measurements[0]) {
+                const outTemp = measurements[0].value;
+                document.getElementById('gaugeValOutside').textContent = typeof outTemp === 'number' ? outTemp.toFixed(1) : outTemp;
+                drawGauge('gaugeCanvasOutside', outTemp, 0, 255, '#00d4ff', 200, 240);
+            }
+            // Field 1: Evaporator Sensor (G263)
+            if (measurements[1]) {
+                const evapTemp = measurements[1].value;
+                document.getElementById('gaugeValEvap').textContent = typeof evapTemp === 'number' ? evapTemp.toFixed(1) : evapTemp;
+                drawGauge('gaugeCanvasEvap', evapTemp, 0, 255, '#33ddff', 200, 240);
+            }
+        }
+    }
+
+    // --- Car Diagram Updates ---
+    function updateDiagram(group, measurements) {
+        if (!measurements || measurements.length < 1) return;
+
+        if (group === 2) {
+            // Group 2: Interior Sensor (G56) ADC, Dash Sensor (G86) ADC, Fresh Air Sensor (G89) ADC, Set Temperature
+            updateSensorDot('sensorG56', measurements[0], 'diag_G56', '°C');
+            updateSensorDot('sensorG86', measurements[1], 'diag_G86', '°C');
+            updateSensorDot('sensorG89', measurements[2], 'diag_G89', '°C');
+        }
+
+        if (group === 3) {
+            // Group 3: Outside Air (G17) ADC, Evaporator (G263) ADC, Coolant Temp ADC, Sunlight (G107) ADC
+            updateSensorDot('sensorG17', measurements[0], 'diag_G17', '°C');
+            updateSensorDot('sensorG263', measurements[1], 'diag_G263', '°C');
+            updateSensorDot('sensorCoolant', measurements[2], 'diag_Coolant', '°C');
+            if (measurements[3]) {
+                updateSensorDot('sensorG107', measurements[3], 'diag_G107', '');
+            }
+        }
+
+        if (group === 8) {
+            // Group 8: Sensor Voltages - Field 0 is G65 High Pressure
+            if (measurements[0]) {
+                updateSensorDot('sensorG65', measurements[0], 'diag_G65', ' V');
+            }
+        }
+    }
+
+    function updateSensorDot(groupId, meas, textId, suffix) {
+        if (!meas) return;
+
+        const textEl = document.getElementById(textId);
+        const groupEl = document.getElementById(groupId);
+        if (!textEl || !groupEl) return;
+
+        const val = meas.value;
+        const displayVal = typeof val === 'number' ? val.toFixed(1) : val;
+        textEl.textContent = `${displayVal}${suffix}`;
+
+        // Determine sensor health from value
+        const circles = groupEl.querySelectorAll('circle');
+        const outerCircle = circles[0];
+        const innerCircle = circles[1];
+        const rect = groupEl.querySelector('rect');
+
+        if (typeof val === 'number') {
+            if (val <= -40 || val >= 220) {
+                // Fault / Open circuit
+                setSensorColor(outerCircle, innerCircle, rect, '#ff3366', 'glow-fault');
+                outerCircle.style.animation = 'sensor-pulse-fault 1.5s ease-in-out infinite';
+            } else if (val >= 100 || val <= -20) {
+                // Warning range
+                setSensorColor(outerCircle, innerCircle, rect, '#ffa502', 'glow-ok');
+                outerCircle.style.animation = '';
+            } else {
+                // Normal
+                setSensorColor(outerCircle, innerCircle, rect, '#00d4ff', 'glow-ok');
+                outerCircle.style.animation = '';
+            }
+        }
+    }
+
+    function setSensorColor(outerCircle, innerCircle, rect, color, filterName) {
+        const colorMap = {
+            '#ff3366': { fill: 'rgba(255,51,102,0.15)', stroke: 'rgba(255,51,102,0.3)' },
+            '#ffa502': { fill: 'rgba(255,165,2,0.15)', stroke: 'rgba(255,165,2,0.3)' },
+            '#00d4ff': { fill: 'rgba(0,212,255,0.15)', stroke: 'rgba(0,212,255,0.3)' },
+            '#00e676': { fill: 'rgba(0,230,118,0.15)', stroke: 'rgba(0,230,118,0.3)' }
+        };
+        const mapped = colorMap[color] || { fill: 'rgba(0,212,255,0.15)', stroke: 'rgba(0,212,255,0.3)' };
+
+        outerCircle.setAttribute('stroke', color);
+        outerCircle.setAttribute('fill', mapped.fill);
+        innerCircle.setAttribute('fill', color);
+        outerCircle.setAttribute('filter', `url(#${filterName})`);
+        if (rect) {
+            rect.setAttribute('stroke', mapped.stroke);
+        }
+    }
+
+    // Dashboard auto-refresh: cycles through groups 1, 2, 3, 8 to populate gauges + diagram
+    let dashGroupCycle = [1, 2, 3, 8];
+    let dashCycleIdx = 0;
+
+    function startDashboardRefresh() {
+        if (dashRefreshTimer) clearInterval(dashRefreshTimer);
+        dashCycleIdx = 0;
+        addLog('Dashboard auto-refresh started.', 'system');
+        
+        // Immediately fetch first group
+        fetchDashGroup();
+
+        dashRefreshTimer = setInterval(() => {
+            if (!isConnected) {
+                stopDashboardRefresh();
+                return;
+            }
+            fetchDashGroup();
+        }, 2000);
+    }
+
+    function stopDashboardRefresh() {
+        if (dashRefreshTimer) {
+            clearInterval(dashRefreshTimer);
+            dashRefreshTimer = null;
+        }
+        addLog('Dashboard auto-refresh stopped.', 'system');
+    }
+
+    async function fetchDashGroup() {
+        const group = dashGroupCycle[dashCycleIdx % dashGroupCycle.length];
+        dashCycleIdx++;
+
+        try {
+            const res = await fetch(`/api/meas/${group}`);
+            const data = await res.json();
+            if (data.success) {
+                updateGauges(group, data.measurements);
+                updateDiagram(group, data.measurements);
+                // Cache for export
+                lastMeasurements[group] = {
+                    name: data.name,
+                    group: group,
+                    measurements: data.measurements,
+                    timestamp: new Date().toISOString()
+                };
+            }
+        } catch (e) {
+            // Silently fail on dash refresh
+        }
+    }
+
+    // --- Export Functions ---
+    function exportDTCsToFile() {
+        if (lastDTCs.length === 0) {
+            alert('No fault codes to export. Read faults first.');
+            return;
+        }
+        const report = {
+            export_type: 'Fault Codes',
+            vehicle: 'Audi A4 B5 (8D2)',
+            module: '08 - Auto HVAC',
+            ecu_info: ecuInfoCache,
+            timestamp: new Date().toISOString(),
+            fault_codes: lastDTCs
+        };
+        downloadJSON(report, `audi_hvac_dtcs_${dateStamp()}.json`);
+        addLog(`Exported ${lastDTCs.length} fault codes.`, 'data');
+    }
+
+    function exportMeasToFile() {
+        if (Object.keys(lastMeasurements).length === 0) {
+            alert('No measurement data to export. Read some groups first.');
+            return;
+        }
+        const report = {
+            export_type: 'Measurement Blocks',
+            vehicle: 'Audi A4 B5 (8D2)',
+            module: '08 - Auto HVAC',
+            ecu_info: ecuInfoCache,
+            timestamp: new Date().toISOString(),
+            measurement_blocks: lastMeasurements
+        };
+        downloadJSON(report, `audi_hvac_meas_${dateStamp()}.json`);
+        addLog('Exported measurement blocks.', 'data');
+    }
+
+    async function exportFullReport() {
+        addLog('Generating full diagnostic report...', 'system');
+        
+        // Read fresh DTCs
+        try {
+            const dtcRes = await fetch('/api/dtcs');
+            const dtcData = await dtcRes.json();
+            if (dtcData.success) {
+                lastDTCs = dtcData.dtcs;
+                renderDTCs(lastDTCs);
+            }
+        } catch (e) {}
+
+        // Read all 8 known groups
+        for (let g = 1; g <= 8; g++) {
+            try {
+                const res = await fetch(`/api/meas/${g}`);
+                const data = await res.json();
+                if (data.success) {
+                    lastMeasurements[g] = {
+                        name: data.name,
+                        group: g,
+                        measurements: data.measurements,
+                        timestamp: new Date().toISOString()
+                    };
+                    updateGauges(g, data.measurements);
+                    updateDiagram(g, data.measurements);
+                }
+            } catch (e) {}
+        }
+
+        const report = {
+            export_type: 'Full Diagnostic Report',
+            vehicle: 'Audi A4 B5 (8D2)',
+            module: '08 - Auto HVAC',
+            ecu_info: ecuInfoCache,
+            generated_at: new Date().toISOString(),
+            fault_codes: lastDTCs,
+            measurement_blocks: lastMeasurements,
+            analysis_prompt: 'Please analyze this Audi A4 B5 HVAC diagnostic data. Identify potential issues based on the fault codes and measurement values. Check for out-of-range sensor readings, abnormal temperatures, compressor issues, and any correlations between fault codes and measurement data.'
+        };
+
+        downloadJSON(report, `audi_hvac_full_report_${dateStamp()}.json`);
+        addLog('Full diagnostic report exported.', 'data');
+    }
+
+    function downloadJSON(obj, filename) {
+        const blob = new Blob([JSON.stringify(obj, null, 2)], { type: 'application/json' });
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement('a');
+        a.href = url;
+        a.download = filename;
+        a.click();
+        URL.revokeObjectURL(url);
+    }
+
+    function dateStamp() {
+        const d = new Date();
+        return `${d.getFullYear()}${(d.getMonth()+1).toString().padStart(2,'0')}${d.getDate().toString().padStart(2,'0')}_${d.getHours().toString().padStart(2,'0')}${d.getMinutes().toString().padStart(2,'0')}`;
+    }
+
+    // --- API Functions ---
+    async function fetchPorts() {
+        try {
+            portSelect.innerHTML = '<option value="">Scanning...</option>';
+            const res = await fetch('/api/ports');
+            const data = await res.json();
+            
+            portSelect.innerHTML = '';
+            if (data.ports.length === 0) {
+                portSelect.innerHTML = '<option value="">No ports found</option>';
+            } else {
+                data.ports.forEach(p => {
+                    const opt = document.createElement('option');
+                    opt.value = p.device;
+                    opt.textContent = `${p.device} ${p.is_likely ? '(KKL)' : ''}`;
+                    portSelect.appendChild(opt);
+                });
+                if (data.auto) {
+                    portSelect.value = data.auto;
+                }
+            }
+        } catch (e) {
+            addLog(`Error fetching ports: ${e}`, 'error');
+        }
+    }
+
+    async function connect() {
+        const port = portSelect.value;
+        if (!port) {
+            alert('Please select a port');
+            return;
+        }
+
+        connectBtn.textContent = 'Connecting...';
+        connectBtn.disabled = true;
+        addLog(`Connecting to ${port} (Address 0x08)...`, 'system');
+
+        try {
+            const res = await fetch('/api/connect', {
+                method: 'POST',
+                headers: { 'Content-Type': 'application/json' },
+                body: JSON.stringify({ port })
+            });
+            const data = await res.json();
+
+            if (data.success) {
+                isConnected = true;
+                connectBtn.textContent = 'Disconnect';
+                connectBtn.disabled = false;
+                connectBtn.classList.replace('btn-primary', 'btn-danger');
+                statusDot.classList.replace('disconnected', 'connected');
+                statusText.textContent = 'Connected (0x08)';
+                
+                ecuInfoCache = data.ecu_info;
+                ecuPartNum.textContent = data.ecu_info.part_number || 'Unknown';
+                ecuExtra.textContent = data.ecu_info.extra.join(', ') || 'N/A';
+                
+                mainContent.classList.remove('hidden');
+                addLog(`Connected successfully. ECU: ${data.ecu_info.part_number}`, 'data');
+                
+                // Auto-read faults and group 1
+                fetchDTCs();
+                fetchMeasGroup(1);
+            } else {
+                throw new Error(data.error);
+            }
+        } catch (e) {
+            connectBtn.textContent = 'Connect';
+            connectBtn.disabled = false;
+            addLog(`Connection failed: ${e.message}`, 'error');
+            alert(`Connection failed: ${e.message}`);
+        }
+    }
+
+    async function disconnect() {
+        if (liveModeToggle.checked) {
+            liveModeToggle.click();
+        }
+        stopDashboardRefresh();
+        dashLiveToggle.checked = false;
+        
+        try {
+            await fetch('/api/disconnect', { method: 'POST' });
+        } catch(e) {}
+
+        isConnected = false;
+        connectBtn.textContent = 'Connect';
+        connectBtn.classList.replace('btn-danger', 'btn-primary');
+        statusDot.classList.replace('connected', 'disconnected');
+        statusText.textContent = 'Disconnected';
+        mainContent.classList.add('hidden');
+        addLog('Disconnected.', 'system');
+    }
+
+    async function fetchDTCs() {
+        if (!isConnected) return;
+        readDTCBtn.disabled = true;
+        readDTCBtn.textContent = 'Reading...';
+        addLog('Requesting Fault Codes...', 'system');
+        
+        try {
+            const res = await fetch('/api/dtcs');
+            const data = await res.json();
+            
+            if (data.success) {
+                lastDTCs = data.dtcs;
+                renderDTCs(data.dtcs);
+                addLog(`Read ${data.dtcs.length} faults.`, 'data');
+            } else {
+                throw new Error(data.error);
+            }
+        } catch (e) {
+            addLog(`Error reading DTCs: ${e.message}`, 'error');
+        } finally {
+            readDTCBtn.disabled = false;
+            readDTCBtn.textContent = 'Read Faults';
+        }
+    }
+
+    async function clearDTCs() {
+        if (!isConnected) return;
+        clearDTCBtn.disabled = true;
+        addLog('Sending clear faults command...', 'system');
+        
+        try {
+            const res = await fetch('/api/dtcs/clear', { method: 'POST' });
+            const data = await res.json();
+            if (data.success) {
+                addLog('Faults cleared successfully.', 'data');
+                setTimeout(fetchDTCs, 1000);
+            } else {
+                throw new Error(data.error);
+            }
+        } catch (e) {
+            addLog(`Error clearing DTCs: ${e.message}`, 'error');
+        } finally {
+            clearDTCBtn.disabled = false;
+        }
+    }
+
+    async function fetchMeasGroup(group) {
+        if (!isConnected) return;
+        addLog(`Requesting Measurement Group ${group}...`, 'system');
+        
+        try {
+            const res = await fetch(`/api/meas/${group}`);
+            const data = await res.json();
+            if (data.success) {
+                renderMeasurements(data);
+                updateGauges(group, data.measurements);
+                updateDiagram(group, data.measurements);
+                // Cache for export
+                lastMeasurements[group] = {
+                    name: data.name,
+                    group: group,
+                    measurements: data.measurements,
+                    timestamp: new Date().toISOString()
+                };
+                addLog(`Group ${group} read success.`, 'data');
+            } else {
+                 throw new Error(data.error);
+            }
+        } catch (e) {
+             addLog(`Error reading group ${group}: ${e.message}`, 'error');
+        }
+    }
+
+    // --- Live Mode (SSE) ---
+    function startLiveMode(group) {
+        if (eventSource) {
+            eventSource.close();
+        }
+        
+        addLog(`Starting Live Monitor for Group ${group}...`, 'system');
+        eventSource = new EventSource(`/api/meas/live/${group}`);
+        
+        eventSource.onmessage = (e) => {
+            const data = JSON.parse(e.data);
+            renderMeasurements(data, true);
+            updateGauges(group, data.measurements);
+            updateDiagram(group, data.measurements);
+        };
+        
+        eventSource.onerror = (e) => {
+            addLog('Live monitor connection error.', 'error');
+            stopLiveMode();
+            liveModeToggle.checked = false;
+        };
+    }
+    
+    async function stopLiveMode() {
+        if (eventSource) {
+            eventSource.close();
+            eventSource = null;
+        }
+        try {
+            await fetch('/api/meas/live/stop', { method: 'POST' });
+        } catch(e) {}
+        addLog('Stopped live monitor.', 'system');
+    }
+
+    // --- UI Rendering ---
+    function renderDTCs(dtcs) {
+        dtcContainer.innerHTML = '';
+        
+        if (dtcs.length === 0) {
+            dtcContainer.innerHTML = `
+                <div class="empty-state success">
+                    <div class="icon">✓</div>
+                    <p>No fault codes stored in module.</p>
+                </div>
+            `;
+            return;
+        }
+        
+        dtcs.forEach(dtc => {
+            const card = document.createElement('div');
+            card.className = 'dtc-card';
+            
+            const badgeClass = dtc.status.toLowerCase();
+            
+            card.innerHTML = `
+                <div class="dtc-header">
+                    <span class="dtc-code">${dtc.code}</span>
+                    <span class="dtc-badge ${badgeClass}">${dtc.status}</span>
+                </div>
+                <div class="dtc-desc">${dtc.description}</div>
+                <div class="dtc-details">Elaboration: ${dtc.details} (${dtc.elaboration_hex})</div>
+            `;
+            dtcContainer.appendChild(card);
+        });
+    }
+
+    function initGroupPills() {
+        for(let i=1; i<=8; i++) {
+            const pill = document.createElement('div');
+            pill.className = `pill ${i === 1 ? 'active' : ''}`;
+            pill.textContent = i;
+            pill.dataset.group = i;
+            
+            pill.addEventListener('click', () => {
+                document.querySelectorAll('.pill').forEach(p => p.classList.remove('active'));
+                pill.classList.add('active');
+                currentGroup = i;
+                customGroupInput.value = '';
+                
+                if (liveModeToggle.checked) {
+                    startLiveMode(i);
+                } else {
+                    fetchMeasGroup(i);
+                }
+            });
+            groupPills.appendChild(pill);
+        }
+        
+        customGroupInput.addEventListener('change', (e) => {
+            const val = parseInt(e.target.value);
+            if (val > 0 && val <= 255) {
+                document.querySelectorAll('.pill').forEach(p => p.classList.remove('active'));
+                currentGroup = val;
+            }
+        });
+    }
+
+    function renderMeasurements(data, isLive = false) {
+        if (!data.measurements) return;
+        
+        groupNameLabel.textContent = data.name || `Group ${data.group}`;
+        
+        if (!isLive || measContainer.children.length !== 4) {
+            measContainer.innerHTML = '';
+            sparklineData = {};
+            
+            for(let i=0; i<4; i++) {
+                const card = document.createElement('div');
+                card.className = 'meas-card';
+                card.id = `measCard_${i}`;
+                
+                const canvas = document.createElement('canvas');
+                canvas.className = 'meas-sparkline';
+                canvas.id = `sparkline_${i}`;
+                
+                card.innerHTML = `
+                    <div class="meas-label" id="measLabel_${i}">---</div>
+                    <div class="meas-value-area">
+                        <span class="meas-val" id="measVal_${i}">--</span>
+                        <span class="meas-unit" id="measUnit_${i}"></span>
+                    </div>
+                `;
+                card.appendChild(canvas);
+                measContainer.appendChild(card);
+                sparklineData[i] = [];
+            }
+        }
+        
+        data.measurements.forEach((meas, i) => {
+            if (i >= 4) return;
+            
+            const card = document.getElementById(`measCard_${i}`);
+            const valEl = document.getElementById(`measVal_${i}`);
+            
+            document.getElementById(`measLabel_${i}`).textContent = meas.label;
+            document.getElementById(`measUnit_${i}`).textContent = meas.unit;
+            
+            if (valEl.textContent != meas.value) {
+                valEl.textContent = meas.value;
+                card.classList.add('updated');
+                setTimeout(() => card.classList.remove('updated'), 200);
+            }
+            
+            if (isLive && typeof meas.value === 'number') {
+                updateSparkline(i, meas.value);
+            }
+        });
+    }
+
+    function updateSparkline(idx, val) {
+        const arr = sparklineData[idx];
+        arr.push(val);
+        if (arr.length > 30) arr.shift();
+        
+        const canvas = document.getElementById(`sparkline_${idx}`);
+        if (!canvas) return;
+        
+        canvas.width = canvas.parentElement.clientWidth;
+        canvas.height = 40;
+        
+        const ctx = canvas.getContext('2d');
+        const w = canvas.width;
+        const h = canvas.height;
+        
+        ctx.clearRect(0, 0, w, h);
+        
+        if (arr.length < 2) return;
+        
+        const max = Math.max(...arr);
+        const min = Math.min(...arr);
+        const range = (max - min) || 1;
+        
+        const step = w / (30 - 1);
+        
+        ctx.beginPath();
+        ctx.moveTo(0, h - ((arr[0] - min) / range) * (h - 5) - 2);
+        
+        for (let i = 1; i < arr.length; i++) {
+            const x = i * step;
+            const y = h - ((arr[i] - min) / range) * (h - 5) - 2;
+            ctx.lineTo(x, y);
+        }
+        
+        ctx.strokeStyle = '#00d4ff';
+        ctx.lineWidth = 2;
+        ctx.stroke();
+        
+        ctx.lineTo(w, h);
+        ctx.lineTo(0, h);
+        ctx.closePath();
+        
+        const gradient = ctx.createLinearGradient(0, 0, 0, h);
+        gradient.addColorStop(0, 'rgba(0, 212, 255, 0.2)');
+        gradient.addColorStop(1, 'rgba(0, 212, 255, 0)');
+        
+        ctx.fillStyle = gradient;
+        ctx.fill();
+    }
+
+    function addLog(msg, type = 'system') {
+        const line = document.createElement('div');
+        line.className = `log-line ${type}`;
+        
+        const now = new Date();
+        const time = `${now.getHours().toString().padStart(2,'0')}:${now.getMinutes().toString().padStart(2,'0')}:${now.getSeconds().toString().padStart(2,'0')}`;
+        
+        line.textContent = `[${time}] ${msg}`;
+        logContainer.appendChild(line);
+        
+        if (logContainer.children.length > 100) {
+            logContainer.removeChild(logContainer.children[0]);
+        }
+        
+        logContainer.scrollTop = logContainer.scrollHeight;
+    }
+
+    // Draw initial empty gauges
+    drawGauge('gaugeCanvasInterior', null, -10, 60, '#00d4ff');
+    drawGauge('gaugeCanvasOutside', null, -30, 60, '#00d4ff');
+    drawGauge('gaugeCanvasEvap', null, -10, 50, '#33ddff');
+    drawGauge('gaugeCanvasBlower', null, 0, 14, '#00d4ff');
+    drawGauge('gaugeCanvasSysV', null, 10, 16, '#00e676');
+});

+ 389 - 0
templates/index.html

@@ -0,0 +1,389 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1.0">
+    <title>Audi A4 B5 HVAC Diagnostic</title>
+    <!-- Fonts -->
+    <link rel="preconnect" href="https://fonts.googleapis.com">
+    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+    <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Outfit:wght@300;400;600;800&display=swap" rel="stylesheet">
+    <link rel="stylesheet" href="/static/css/style.css">
+</head>
+<body>
+    <div class="app-container">
+        <!-- Header -->
+        <header class="header glass-panel">
+            <div class="logo-area">
+                <div class="rings">
+                    <div class="ring"></div><div class="ring"></div><div class="ring"></div><div class="ring"></div>
+                </div>
+                <h1>A4 B5 <span>HVAC DIAGNOSTIC</span></h1>
+            </div>
+            
+            <div class="connection-area">
+                <div class="port-selector">
+                    <select id="portSelect">
+                        <option value="">Scanning ports...</option>
+                    </select>
+                    <button id="refreshPortsBtn" class="icon-btn" title="Refresh Ports">↻</button>
+                </div>
+                <button id="connectBtn" class="btn btn-primary">Connect</button>
+                <div class="status-indicator">
+                    <div id="statusDot" class="dot disconnected"></div>
+                    <span id="statusText">Disconnected</span>
+                </div>
+            </div>
+        </header>
+
+        <!-- Main Content (Hidden until connected) -->
+        <main id="mainContent" class="main-content hidden">
+            <!-- ECU Info Bar -->
+            <div class="ecu-info-bar glass-panel">
+                <div class="ecu-item">
+                    <span class="label">MODULE</span>
+                    <span class="value">08 - Auto HVAC</span>
+                </div>
+                <div class="ecu-item">
+                    <span class="label">PART NUMBER</span>
+                    <span class="value" id="ecuPartNum">--</span>
+                </div>
+                <div class="ecu-item">
+                    <span class="label">EXTRA INFO</span>
+                    <span class="value" id="ecuExtra">--</span>
+                </div>
+                <div class="ecu-actions">
+                    <button id="exportAllBtn" class="btn btn-export" title="Export all data for AI analysis">
+                        <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0a1 1 0 0 1 1 1v5.586l2.293-2.293a1 1 0 0 1 1.414 1.414l-4 4a1 1 0 0 1-1.414 0l-4-4a1 1 0 0 1 1.414-1.414L7 6.586V1a1 1 0 0 1 1-1zM1 10a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2a1 1 0 1 1 2 0v2a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3v-2a1 1 0 0 1 1-1z"/></svg>
+                        Export Report
+                    </button>
+                </div>
+            </div>
+
+            <!-- Live Dashboard Gauges -->
+            <section id="gaugeDashboard" class="gauge-dashboard glass-panel">
+                <div class="panel-header">
+                    <h2>Live Dashboard</h2>
+                    <div class="panel-actions">
+                        <div class="live-toggle">
+                            <label class="switch">
+                                <input type="checkbox" id="dashLiveToggle">
+                                <span class="slider round"></span>
+                            </label>
+                            <span>Auto-Refresh</span>
+                        </div>
+                    </div>
+                </div>
+                <div class="gauge-grid">
+                    <!-- Row 1: Temperature Gauges -->
+                    <div class="gauge-card" id="gaugeInteriorTemp">
+                        <canvas id="gaugeCanvasInterior" width="160" height="160"></canvas>
+                        <div class="gauge-info">
+                            <span class="gauge-value" id="gaugeValInterior">--</span>
+                            <span class="gauge-unit">°C</span>
+                        </div>
+                        <span class="gauge-label">Interior Temp</span>
+                    </div>
+                    <div class="gauge-card" id="gaugeOutsideTemp">
+                        <canvas id="gaugeCanvasOutside" width="160" height="160"></canvas>
+                        <div class="gauge-info">
+                            <span class="gauge-value" id="gaugeValOutside">--</span>
+                            <span class="gauge-unit">°C</span>
+                        </div>
+                        <span class="gauge-label">Outside Temp</span>
+                    </div>
+                    <div class="gauge-card" id="gaugeEvapTemp">
+                        <canvas id="gaugeCanvasEvap" width="160" height="160"></canvas>
+                        <div class="gauge-info">
+                            <span class="gauge-value" id="gaugeValEvap">--</span>
+                            <span class="gauge-unit">°C</span>
+                        </div>
+                        <span class="gauge-label">Evaporator Temp</span>
+                    </div>
+
+                    <!-- Row 2: Operational Gauges -->
+                    <div class="gauge-card" id="gaugeBlower">
+                        <canvas id="gaugeCanvasBlower" width="160" height="160"></canvas>
+                        <div class="gauge-info">
+                            <span class="gauge-value" id="gaugeValBlower">--</span>
+                            <span class="gauge-unit">V</span>
+                        </div>
+                        <span class="gauge-label">Blower Speed</span>
+                    </div>
+                    <div class="gauge-card" id="gaugeCompressor">
+                        <div class="compressor-status" id="compressorStatusIcon">
+                            <div class="compressor-ring off">
+                                <svg width="60" height="60" viewBox="0 0 60 60" fill="none">
+                                    <circle cx="30" cy="30" r="24" stroke="currentColor" stroke-width="3" fill="none"/>
+                                    <text x="30" y="34" text-anchor="middle" fill="currentColor" font-size="14" font-weight="700">A/C</text>
+                                </svg>
+                            </div>
+                        </div>
+                        <div class="gauge-info">
+                            <span class="gauge-value" id="gaugeValCompressor">OFF</span>
+                            <span class="gauge-unit"></span>
+                        </div>
+                        <span class="gauge-label">Compressor</span>
+                    </div>
+                    <div class="gauge-card" id="gaugeSystemV">
+                        <canvas id="gaugeCanvasSysV" width="160" height="160"></canvas>
+                        <div class="gauge-info">
+                            <span class="gauge-value" id="gaugeValSysV">--</span>
+                            <span class="gauge-unit">V</span>
+                        </div>
+                        <span class="gauge-label">System Voltage</span>
+                    </div>
+                </div>
+            </section>
+
+            <!-- Car Sensor Diagram -->
+            <section class="car-diagram-section glass-panel">
+                <div class="panel-header">
+                    <h2>Sensor Map — Audi A4 B5</h2>
+                    <span class="diagram-legend">
+                        <span class="legend-item"><span class="legend-dot ok"></span> Normal</span>
+                        <span class="legend-item"><span class="legend-dot warn"></span> Warning</span>
+                        <span class="legend-item"><span class="legend-dot fault"></span> Fault / Open</span>
+                    </span>
+                </div>
+                <div class="car-diagram-container">
+                    <svg id="carDiagram" viewBox="0 0 900 420" xmlns="http://www.w3.org/2000/svg">
+                        <defs>
+                            <filter id="glow-ok">
+                                <feGaussianBlur stdDeviation="3" result="blur"/>
+                                <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
+                            </filter>
+                            <filter id="glow-fault">
+                                <feGaussianBlur stdDeviation="4" result="blur"/>
+                                <feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
+                            </filter>
+                            <linearGradient id="bodyGrad" x1="0" y1="0" x2="1" y2="1">
+                                <stop offset="0%" stop-color="rgba(30,30,50,0.6)"/>
+                                <stop offset="100%" stop-color="rgba(15,15,30,0.8)"/>
+                            </linearGradient>
+                            <linearGradient id="windowGrad" x1="0" y1="0" x2="0" y2="1">
+                                <stop offset="0%" stop-color="rgba(0,212,255,0.08)"/>
+                                <stop offset="100%" stop-color="rgba(0,212,255,0.02)"/>
+                            </linearGradient>
+                        </defs>
+
+                        <!-- Car Body - Top-down view (sedan profile) -->
+                        <!-- Main body outline -->
+                        <path d="M 150,60 Q 120,60 100,80 L 80,120 Q 70,150 70,210 Q 70,270 80,300 L 100,340 Q 120,360 150,360 L 750,360 Q 780,360 800,340 L 820,300 Q 830,270 830,210 Q 830,150 820,120 L 800,80 Q 780,60 750,60 Z"
+                              fill="url(#bodyGrad)" stroke="rgba(255,255,255,0.15)" stroke-width="2"/>
+
+                        <!-- Hood area -->
+                        <path d="M 150,75 L 300,75 L 300,345 L 150,345 Q 130,345 115,325 L 95,290 Q 85,265 85,210 Q 85,155 95,130 L 115,95 Q 130,75 150,75 Z"
+                              fill="rgba(255,255,255,0.02)" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>
+                        <text x="190" y="215" fill="rgba(255,255,255,0.15)" font-size="13" text-anchor="middle" font-family="Outfit">ENGINE BAY</text>
+
+                        <!-- Windshield -->
+                        <path d="M 300,90 L 380,90 L 380,330 L 300,330 Z"
+                              fill="url(#windowGrad)" stroke="rgba(0,212,255,0.15)" stroke-width="1.5"/>
+                        <text x="340" y="215" fill="rgba(255,255,255,0.1)" font-size="10" text-anchor="middle" font-family="Outfit">WINDSHIELD</text>
+
+                        <!-- Dashboard / Firewall area -->
+                        <line x1="300" y1="75" x2="300" y2="345" stroke="rgba(255,255,255,0.1)" stroke-width="1" stroke-dasharray="4,4"/>
+
+                        <!-- Cabin area -->
+                        <rect x="380" y="90" width="220" height="240" rx="5"
+                              fill="rgba(255,255,255,0.01)" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>
+                        <text x="490" y="215" fill="rgba(255,255,255,0.1)" font-size="13" text-anchor="middle" font-family="Outfit">CABIN</text>
+
+                        <!-- Rear window -->
+                        <path d="M 600,95 L 670,95 L 670,325 L 600,325 Z"
+                              fill="url(#windowGrad)" stroke="rgba(0,212,255,0.12)" stroke-width="1"/>
+
+                        <!-- Trunk area -->
+                        <path d="M 670,80 L 750,80 Q 770,80 785,95 L 805,125 Q 815,150 815,210 Q 815,270 805,295 L 785,325 Q 770,340 750,340 L 670,340 Z"
+                              fill="rgba(255,255,255,0.01)" stroke="rgba(255,255,255,0.06)" stroke-width="1"/>
+                        <text x="740" y="215" fill="rgba(255,255,255,0.1)" font-size="11" text-anchor="middle" font-family="Outfit">TRUNK</text>
+
+                        <!-- Wheels -->
+                        <ellipse cx="200" cy="65" rx="35" ry="10" fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.1)" stroke-width="1.5"/>
+                        <ellipse cx="200" cy="355" rx="35" ry="10" fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.1)" stroke-width="1.5"/>
+                        <ellipse cx="680" cy="65" rx="35" ry="10" fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.1)" stroke-width="1.5"/>
+                        <ellipse cx="680" cy="355" rx="35" ry="10" fill="rgba(255,255,255,0.04)" stroke="rgba(255,255,255,0.1)" stroke-width="1.5"/>
+
+                        <!-- HVAC Box (behind dashboard) -->
+                        <rect x="310" y="140" width="65" height="140" rx="4"
+                              fill="rgba(0,212,255,0.05)" stroke="rgba(0,212,255,0.2)" stroke-width="1" stroke-dasharray="3,3"/>
+                        <text x="342" y="195" fill="rgba(0,212,255,0.3)" font-size="8" text-anchor="middle" font-family="Outfit" transform="rotate(-90,342,195)">HVAC UNIT</text>
+
+                        <!-- ======= SENSOR MARKERS ======= -->
+
+                        <!-- G17 - Outside Air Temp - Front bumper/radiator area -->
+                        <g class="sensor-group" id="sensorG17" data-sensor="G17">
+                            <circle cx="95" cy="210" r="8" fill="rgba(0,212,255,0.15)" stroke="#00d4ff" stroke-width="2" filter="url(#glow-ok)"/>
+                            <circle cx="95" cy="210" r="3" fill="#00d4ff"/>
+                            <line x1="103" y1="210" x2="130" y2="175" stroke="rgba(0,212,255,0.4)" stroke-width="1"/>
+                            <rect x="65" y="155" width="95" height="36" rx="4" fill="rgba(0,0,0,0.7)" stroke="rgba(0,212,255,0.3)" stroke-width="1"/>
+                            <text x="112" y="168" fill="#00d4ff" font-size="9" text-anchor="middle" font-family="Outfit" font-weight="600">G17 — OUTSIDE</text>
+                            <text x="112" y="183" fill="#f0f0f5" font-size="14" text-anchor="middle" font-family="JetBrains Mono" font-weight="700" id="diag_G17">--°C</text>
+                        </g>
+
+                        <!-- G65 - High Pressure Sensor - Engine bay, near A/C lines -->
+                        <g class="sensor-group" id="sensorG65" data-sensor="G65">
+                            <circle cx="240" cy="130" r="8" fill="rgba(0,230,118,0.15)" stroke="#00e676" stroke-width="2" filter="url(#glow-ok)"/>
+                            <circle cx="240" cy="130" r="3" fill="#00e676"/>
+                            <line x1="248" y1="130" x2="270" y2="100" stroke="rgba(0,230,118,0.4)" stroke-width="1"/>
+                            <rect x="210" y="78" width="95" height="36" rx="4" fill="rgba(0,0,0,0.7)" stroke="rgba(0,230,118,0.3)" stroke-width="1"/>
+                            <text x="257" y="91" fill="#00e676" font-size="9" text-anchor="middle" font-family="Outfit" font-weight="600">G65 — A/C PRESS</text>
+                            <text x="257" y="106" fill="#f0f0f5" font-size="14" text-anchor="middle" font-family="JetBrains Mono" font-weight="700" id="diag_G65">-- V</text>
+                        </g>
+
+                        <!-- G263 - Evaporator Temp - Inside HVAC box -->
+                        <g class="sensor-group" id="sensorG263" data-sensor="G263">
+                            <circle cx="335" cy="180" r="8" fill="rgba(0,212,255,0.15)" stroke="#00d4ff" stroke-width="2" filter="url(#glow-ok)"/>
+                            <circle cx="335" cy="180" r="3" fill="#00d4ff"/>
+                            <line x1="343" y1="175" x2="375" y2="135" stroke="rgba(0,212,255,0.4)" stroke-width="1"/>
+                            <rect x="325" y="110" width="100" height="36" rx="4" fill="rgba(0,0,0,0.7)" stroke="rgba(0,212,255,0.3)" stroke-width="1"/>
+                            <text x="375" y="123" fill="#00d4ff" font-size="9" text-anchor="middle" font-family="Outfit" font-weight="600">G263 — EVAP</text>
+                            <text x="375" y="138" fill="#f0f0f5" font-size="14" text-anchor="middle" font-family="JetBrains Mono" font-weight="700" id="diag_G263">--°C</text>
+                        </g>
+
+                        <!-- G89 - Fresh Air Intake - Plenum, below cabin filter -->
+                        <g class="sensor-group" id="sensorG89" data-sensor="G89">
+                            <circle cx="310" cy="105" r="8" fill="rgba(0,212,255,0.15)" stroke="#00d4ff" stroke-width="2" filter="url(#glow-ok)"/>
+                            <circle cx="310" cy="105" r="3" fill="#00d4ff"/>
+                            <line x1="310" y1="97" x2="310" y2="50" stroke="rgba(0,212,255,0.4)" stroke-width="1"/>
+                            <rect x="260" y="22" width="100" height="36" rx="4" fill="rgba(0,0,0,0.7)" stroke="rgba(0,212,255,0.3)" stroke-width="1"/>
+                            <text x="310" y="35" fill="#00d4ff" font-size="9" text-anchor="middle" font-family="Outfit" font-weight="600">G89 — FRESH AIR</text>
+                            <text x="310" y="50" fill="#f0f0f5" font-size="14" text-anchor="middle" font-family="JetBrains Mono" font-weight="700" id="diag_G89">--°C</text>
+                        </g>
+
+                        <!-- G107 - Sunlight Sensor - Top of dashboard, near defroster -->
+                        <g class="sensor-group" id="sensorG107" data-sensor="G107">
+                            <circle cx="395" cy="210" r="8" fill="rgba(255,165,2,0.15)" stroke="#ffa502" stroke-width="2" filter="url(#glow-ok)"/>
+                            <circle cx="395" cy="210" r="3" fill="#ffa502"/>
+                            <line x1="403" y1="210" x2="440" y2="210" stroke="rgba(255,165,2,0.4)" stroke-width="1"/>
+                            <rect x="440" y="195" width="100" height="36" rx="4" fill="rgba(0,0,0,0.7)" stroke="rgba(255,165,2,0.3)" stroke-width="1"/>
+                            <text x="490" y="208" fill="#ffa502" font-size="9" text-anchor="middle" font-family="Outfit" font-weight="600">G107 — SUNLIGHT</text>
+                            <text x="490" y="223" fill="#f0f0f5" font-size="14" text-anchor="middle" font-family="JetBrains Mono" font-weight="700" id="diag_G107">--</text>
+                        </g>
+
+                        <!-- G56 - Dash Panel Temp - Behind climate control faceplate, center console -->
+                        <g class="sensor-group" id="sensorG56" data-sensor="G56">
+                            <circle cx="395" cy="260" r="8" fill="rgba(0,212,255,0.15)" stroke="#00d4ff" stroke-width="2" filter="url(#glow-ok)"/>
+                            <circle cx="395" cy="260" r="3" fill="#00d4ff"/>
+                            <line x1="403" y1="260" x2="440" y2="260" stroke="rgba(0,212,255,0.4)" stroke-width="1"/>
+                            <rect x="440" y="245" width="115" height="36" rx="4" fill="rgba(0,0,0,0.7)" stroke="rgba(0,212,255,0.3)" stroke-width="1"/>
+                            <text x="497" y="258" fill="#00d4ff" font-size="9" text-anchor="middle" font-family="Outfit" font-weight="600">G56 — DASH PANEL</text>
+                            <text x="497" y="273" fill="#f0f0f5" font-size="14" text-anchor="middle" font-family="JetBrains Mono" font-weight="700" id="diag_G56">--°C</text>
+                        </g>
+
+                        <!-- G86 - Interior Temp (Headliner) - In the headliner -->
+                        <g class="sensor-group" id="sensorG86" data-sensor="G86">
+                            <circle cx="490" cy="150" r="8" fill="rgba(0,212,255,0.15)" stroke="#00d4ff" stroke-width="2" filter="url(#glow-ok)"/>
+                            <circle cx="490" cy="150" r="3" fill="#00d4ff"/>
+                            <line x1="490" y1="142" x2="490" y2="80" stroke="rgba(0,212,255,0.4)" stroke-width="1"/>
+                            <rect x="440" y="48" width="100" height="36" rx="4" fill="rgba(0,0,0,0.7)" stroke="rgba(0,212,255,0.3)" stroke-width="1"/>
+                            <text x="490" y="61" fill="#00d4ff" font-size="9" text-anchor="middle" font-family="Outfit" font-weight="600">G86 — HEADLINER</text>
+                            <text x="490" y="76" fill="#f0f0f5" font-size="14" text-anchor="middle" font-family="JetBrains Mono" font-weight="700" id="diag_G86">--°C</text>
+                        </g>
+
+                        <!-- Flap Motors label cluster (inside HVAC box) -->
+                        <g class="sensor-group" id="sensorFlaps">
+                            <rect x="315" y="235" width="55" height="40" rx="3" fill="rgba(0,0,0,0.5)" stroke="rgba(255,255,255,0.1)" stroke-width="1"/>
+                            <text x="342" y="250" fill="rgba(255,255,255,0.4)" font-size="7" text-anchor="middle" font-family="Outfit">FLAP MOTORS</text>
+                            <text x="342" y="262" fill="rgba(255,255,255,0.25)" font-size="7" text-anchor="middle" font-family="JetBrains Mono">V68 V70</text>
+                            <text x="342" y="272" fill="rgba(255,255,255,0.25)" font-size="7" text-anchor="middle" font-family="JetBrains Mono">V71 V85</text>
+                        </g>
+
+                        <!-- Coolant Temp - Engine block area -->
+                        <g class="sensor-group" id="sensorCoolant">
+                            <circle cx="240" cy="250" r="8" fill="rgba(255,51,102,0.15)" stroke="#ff3366" stroke-width="2" filter="url(#glow-ok)"/>
+                            <circle cx="240" cy="250" r="3" fill="#ff3366"/>
+                            <line x1="248" y1="255" x2="270" y2="290" stroke="rgba(255,51,102,0.4)" stroke-width="1"/>
+                            <rect x="215" y="290" width="100" height="36" rx="4" fill="rgba(0,0,0,0.7)" stroke="rgba(255,51,102,0.3)" stroke-width="1"/>
+                            <text x="265" y="303" fill="#ff3366" font-size="9" text-anchor="middle" font-family="Outfit" font-weight="600">COOLANT TEMP</text>
+                            <text x="265" y="318" fill="#f0f0f5" font-size="14" text-anchor="middle" font-family="JetBrains Mono" font-weight="700" id="diag_Coolant">--°C</text>
+                        </g>
+
+                        <!-- Direction arrow -->
+                        <text x="110" y="390" fill="rgba(255,255,255,0.15)" font-size="11" font-family="Outfit">◀ FRONT</text>
+                        <text x="750" y="390" fill="rgba(255,255,255,0.15)" font-size="11" font-family="Outfit" text-anchor="end">REAR ▶</text>
+                    </svg>
+                </div>
+            </section>
+
+            <div class="dashboard-grid">
+                <!-- Fault Codes Panel -->
+                <section class="panel dtc-panel glass-panel">
+                    <div class="panel-header">
+                        <h2>Fault Codes (DTCs)</h2>
+                        <div class="panel-actions">
+                            <button id="exportDTCBtn" class="btn btn-export-sm" title="Export fault codes">⤓ Export</button>
+                            <button id="readDTCBtn" class="btn btn-secondary">Read Faults</button>
+                            <button id="clearDTCBtn" class="btn btn-danger">Clear</button>
+                        </div>
+                    </div>
+                    
+                    <div id="dtcContainer" class="dtc-list scrollable">
+                        <div class="empty-state">
+                            <div class="icon">✓</div>
+                            <p>Click "Read Faults" to check for codes.</p>
+                        </div>
+                    </div>
+                </section>
+
+                <!-- Measurements Panel -->
+                <section class="panel meas-panel glass-panel">
+                    <div class="panel-header">
+                        <h2>Measurement Blocks</h2>
+                        <div class="group-selector">
+                            <label>Group:</label>
+                            <div class="group-pills" id="groupPills">
+                                <!-- Generated by JS -->
+                            </div>
+                            <input type="number" id="customGroupInput" min="1" max="255" placeholder="Custom">
+                            <button id="readGroupBtn" class="btn btn-secondary">Read</button>
+                            <button id="exportMeasBtn" class="btn btn-export-sm" title="Export measurement blocks">⤓ Export</button>
+                        </div>
+                    </div>
+
+                    <div class="meas-controls">
+                        <div class="live-toggle">
+                            <label class="switch">
+                                <input type="checkbox" id="liveModeToggle">
+                                <span class="slider round"></span>
+                            </label>
+                            <span>Live Monitor</span>
+                        </div>
+                        <span id="groupNameLabel" class="group-name">Select a group</span>
+                    </div>
+
+                    <div id="measContainer" class="meas-grid">
+                        <!-- Measurement Cards (Generated by JS) -->
+                    </div>
+                </section>
+            </div>
+        </main>
+
+        <!-- Log Panel -->
+        <footer class="log-panel glass-panel">
+            <div class="log-header">
+                <h3>Communication Log</h3>
+                <button id="toggleLogBtn" class="icon-btn">▼</button>
+            </div>
+            <div id="logContainer" class="log-content scrollable font-mono collapsed">
+                <div class="log-line system">Ready. Please connect to continue.</div>
+            </div>
+        </footer>
+    </div>
+
+    <!-- Modals -->
+    <div id="confirmModal" class="modal hidden">
+        <div class="modal-content glass-panel">
+            <h3>Clear Fault Codes</h3>
+            <p class="warning">Are you sure you want to clear all stored fault codes from the HVAC module? This action cannot be undone.</p>
+            <div class="modal-actions">
+                <button id="cancelModalBtn" class="btn btn-secondary">Cancel</button>
+                <button id="confirmClearBtn" class="btn btn-danger">Yes, Clear Faults</button>
+            </div>
+        </div>
+    </div>
+
+    <script src="/static/js/app.js"></script>
+</body>
+</html>

+ 65 - 0
test_init.py

@@ -0,0 +1,65 @@
+import serial
+import time
+import sys
+
+def precise_sleep(duration):
+    end = time.perf_counter() + duration
+    while time.perf_counter() < end:
+        pass
+
+def try_init(port, baudrate):
+    print(f"\n--- Trying {baudrate} baud ---")
+    try:
+        ser = serial.Serial(port, baudrate=baudrate, timeout=0.5)
+        ser.rts = False
+        ser.dtr = False
+        ser.break_condition = False
+        time.sleep(1.0)
+        
+        # Bit-bang 0x08 at 5 baud
+        address = 0x08
+        
+        # Start bit
+        ser.break_condition = True
+        ser.rts = True
+        ser.dtr = True
+        precise_sleep(0.2)
+        
+        for i in range(8):
+            bit = (address >> i) & 1
+            low = (bit == 0)
+            ser.break_condition = low
+            ser.rts = low
+            ser.dtr = low
+            precise_sleep(0.2)
+            
+        # Stop bit
+        ser.break_condition = False
+        ser.rts = False
+        ser.dtr = False
+        precise_sleep(0.2)
+        
+        ser.reset_input_buffer()
+        
+        print("Waiting for response...")
+        response = ser.read(10)
+        print(f"Received ({len(response)} bytes): {response.hex()}")
+        
+        ser.close()
+    except Exception as e:
+        print(f"Error: {e}")
+
+if __name__ == "__main__":
+    import serial.tools.list_ports
+    ports = [p.device for p in serial.tools.list_ports.comports() if 'usbserial' in p.device.lower() or 'ftdi' in p.description.lower()]
+    if not ports:
+        print("No FTDI ports found.")
+        sys.exit(1)
+    port = ports[0]
+    print(f"Using port: {port}")
+    
+    try_init(port, 9600)
+    time.sleep(2)
+    try_init(port, 10400)
+    time.sleep(2)
+    try_init(port, 4800)