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

#include <Arduino.h>
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#include <RadioLib.h>
#include <WiFi.h>
#include <WiFiServer.h>
#include <WiFiClient.h>
#include "soc/soc.h"
#include "soc/rtc_cntl_reg.h"

// -- Gemeinsame PID-Definitionen -------------------------------
#include "pid_registry.h"

// -- WiFi ------------------------------------------------------
#define DEVICE_RECEIVER  // Geraetekennzeichnung fuer wifi_config.h
#include "wifi_config.h"

// AP-Konfiguration kommt aus wifi_config.h (WIFI_FALLBACK_*)

// -- Hardware-Konfiguration (Pins, LoRa-Parameter, OLED) -------
#include "hw_config.h"

// -- LoRa Protokoll-Konstanten ---------------------------------
#define LORA_PKT_OBD    0x01
#define LORA_PKT_CFG    0x02
#define LORA_PKT_EXT    0x03
#define LORA_PKT_CTRL   0x05  // Generisches Steuerprotokoll (bidirektional)
#define LORA_PKT_LEN      14
#define LORA_CTRL_LEN      8  // [TYPE][SEQ][SRC][DST][CMD][ARG1][ARG2][CRC]
#define LORA_CFG_LEN       5
#define LORA_EXT_MAX     160
// Geraete-IDs
#define LORA_DEV_BRIDGE   0x01
#define LORA_DEV_RECEIVER 0x02
#define LORA_DEV_BCAST    0xFF
// CTRL-Befehle
#define CTRL_BEEP_ON    0x01
#define CTRL_BEEP_OFF   0x02
#define CTRL_BEEP_ACK   0x03
#define CTRL_BEEP_PULSE 0x04  // ARG1=Dauer*10ms ARG2=Anzahl
#define CTRL_GPIO_SET   0x10  // ARG1=GPIO ARG2=0/1
#define CTRL_GPIO_GET   0x11  // ARG1=GPIO
#define CTRL_GPIO_PULSE 0x12  // ARG1=GPIO ARG2=Dauer*10ms
#define CTRL_PING       0xFF

#define DATA_TIMEOUT_MS  10000

// -- Paket-Modus -----------------------------------------------
uint8_t pktMode = 0;  // 0=auto, 1=nur basic, 2=nur extended

#include <stdarg.h>

// -- Forward-Deklarationen -------------------------------------
void applyRxConfig();   // Punkt 2: vor decodeConfig() deklariert
void printHelp();
void printStatus();
void tcpSendln(const String& s="");

// -- Nicht-blockierendes Serial-Lesen -------------------------
static String _serialLineBuf = "";
String serialReadLine() {
  while(Serial.available()) {
    char c=(char)Serial.read();
    if(c=='\n'||c=='\r') {
      if(_serialLineBuf.length()>0) {
        String l=_serialLineBuf; _serialLineBuf=""; l.trim(); return l;
      }
    } else if(c>=' ') {
      _serialLineBuf+=c;
      if(_serialLineBuf.length()>256) _serialLineBuf="";
    }
  }
  return "";
}

// -- Empfangene PID-Werte --------------------------------------
float    pidValues[MAX_PIDS];
bool     pidValid[MAX_PIDS];
uint32_t pidRxCount[MAX_PIDS];

// Komfort-Makros ueber PidIdx (Punkt 7)
#define RX_RPM     ((int)pidValues[PID_RPM])
#define RX_SPEED   ((int)pidValues[PID_SPEED])
#define RX_TEMP    ((int)pidValues[PID_TEMP])
#define RX_IAT     ((int)pidValues[PID_IAT])
#define RX_MAP     ((int)pidValues[PID_MAP])
#define RX_LOAD    (pidValues[PID_LOAD])
#define RX_TPS     (pidValues[PID_TPS])
#define RX_VOLT    (pidValues[PID_ECU_SPANNUNG])
#define RX_LAMBDA  (pidValues[PID_LAMBDA])
#define RX_OIL     (pidValues[PID_OELTEMP])
#define RX_FUEL    (pidValues[PID_TANK])
#define RX_IGN     (pidValues[PID_ZUENDWINKEL])

// -- Globale Objekte -------------------------------------------
Adafruit_SSD1306 display(OLED_WIDTH, OLED_HEIGHT, &Wire, OLED_RST);
SX1262 radio = new Module(LORA_CS, LORA_IRQ, LORA_RST, LORA_BUSY);

// -- WiFi / TCP ------------------------------------------------
WiFiServer  tcpServer(TCP_PORT);
WiFiClient  tcpClient;
bool        wifiOk      = false;
unsigned long bootTime  = 0;   // millis() beim Start

// -- Akku-Messung (Heltec V3 Lipo via GPIO1, 1:4-Teiler) -------
#define BAT_ADC_PIN_RX   1
#define BAT_MEASURE_MS  30000
#define BAT_SAMPLES      8
#define BAT_CAL_FACTOR   1.269f

struct BatState {
  float   volt    = -1.0f;
  int     pct     = -1;
  bool    valid   = false;
  unsigned long lastMs = 0;
} bat;

static int batVoltToPct(float v) {
  if(v >= 4.20f) return 100;
  if(v >= 4.00f) return (int)(85 + (v-4.00f)/(4.20f-4.00f)*15.0f);
  if(v >= 3.80f) return (int)(60 + (v-3.80f)/(4.00f-3.80f)*25.0f);
  if(v >= 3.60f) return (int)(30 + (v-3.60f)/(3.80f-3.60f)*30.0f);
  if(v >= 3.40f) return (int)(10 + (v-3.40f)/(3.60f-3.40f)*20.0f);
  if(v >= 3.00f) return (int)(     (v-3.00f)/(3.40f-3.00f)*10.0f);
  return 0;
}

void batMeasure() {
  if(millis() - bat.lastMs < BAT_MEASURE_MS && bat.valid) return;
  bat.lastMs = millis();
  analogSetPinAttenuation(BAT_ADC_PIN_RX, ADC_11db);
  analogRead(BAT_ADC_PIN_RX); delay(5);
  uint32_t sum = 0;
  for(int i = 0; i < BAT_SAMPLES; i++) {
    sum += analogRead(BAT_ADC_PIN_RX); delay(2);
  }
  float raw = (float)sum / BAT_SAMPLES;
  if(raw < 10) { bat.valid = false; return; }
  float v = raw * 3.3f / 4095.0f * 4.0f * BAT_CAL_FACTOR;
  if(v < 2.5f || v > 5.5f) { bat.valid = false; return; }
  bat.volt = v;
  bat.pct  = batVoltToPct(v);
  bat.valid = true;
}
bool        apMode      = false;
String      tcpInputBuf = "";
bool        liveMode    = false;

// -- Empfangs-Statistik ----------------------------------------
struct RxStats {
  float    rssi      = 0;
  float    snr       = 0;
  unsigned long lastRx  = 0;
  uint32_t pktBasic  = 0;
  uint32_t pktExt    = 0;
  uint32_t cfgCount  = 0;
  uint32_t rawCount  = 0;
  uint32_t errCount  = 0;
  uint32_t lostCount = 0;
  uint8_t  lastSeq   = 0;
  bool     seqInit   = false;
  bool     demo      = false;
  bool     mil       = false;
  int      lastPktType = 0;
} rx;

// -- HTTP-Server (nach apMode/wifiOk/rx einbinden) ---------
#include "http_server.h"

// -- IRQ -------------------------------------------------------
volatile bool rxFlag = false;
// CTRL-Queue: nächstes Telegramm an Bridge
volatile uint8_t cmdQueue    = 0;
volatile uint8_t cmdQueueA1  = 0;
volatile uint8_t cmdQueueA2  = 0;

// -- Beeper Receiver -------------------------------------------
bool rxBeeperOn  = false;  // Dauerton (beep on/off)
bool rxBeepOnRx  = false;  // Quittungs-Piep bei jedem LoRa-Empfang (beep rx on/off)
void rxBeepSet(bool on) {
  rxBeeperOn = on;
  digitalWrite(BEEP_PIN, on ? HIGH : LOW);
}
void rxBeepAck() {
  digitalWrite(BEEP_PIN, HIGH); delay(80);
  digitalWrite(BEEP_PIN, LOW);  delay(60);
  digitalWrite(BEEP_PIN, HIGH); delay(80);
  digitalWrite(BEEP_PIN, rxBeeperOn ? HIGH : LOW);
}
void IRAM_ATTR onRxDone() { rxFlag = true; }

// -- Empfangene LoRa-Konfiguration ----------------------------
struct RxConfig {
  uint8_t sf     = LORA_SF;
  uint8_t region = LORA_REGION;
  uint8_t power  = LORA_POWER;
  bool    valid  = false;
} rxCfg;

String cfgMsg = "";
unsigned long cfgMsgUntil = 0;

// +==============================================================+
// |           MULTI-PAGE DISPLAY                               |
// +==============================================================+
#define DISPLAY_PAGE_COUNT  5

uint8_t  displayPage      = 0;
uint8_t  oledBrightness   = 128;  // 0..255, Default: 50 %
uint16_t pageAutoTime     = 3;
unsigned long pageLastAuto      = 0;
bool     pageAutoPaused         = false;
unsigned long pageAutoPauseUntil = 0;
uint8_t  page0SizeRPM   = 3;
uint8_t  page0SizeSpeed = 2;

// -- OLED Hilfsfunktionen --------------------------------------
void oledClear() {
  display.clearDisplay(); display.setCursor(0,0);
  display.setTextSize(1); display.setTextColor(SSD1306_WHITE);
}
void oledSetBrightness(uint8_t v) {
  oledBrightness = v;
  display.ssd1306_command(SSD1306_SETCONTRAST);
  display.ssd1306_command(v);
}
void oledShow(const String& l1, const String& l2="",
              const String& l3="", const String& l4="") {
  oledClear();
  display.println(l1);
  if(l2.length()) display.println(l2);
  if(l3.length()) display.println(l3);
  if(l4.length()) display.println(l4);
  display.display();
}

// -- Status-Cluster (unten links, Font 1 = 6x8px) ----------------
// Format: "R ST OBD"  oder  "R AP SIM!"
// Rolle : R=Receiver
// WiFi  : ST=STA  AP=AP  --=kein WiFi
// Daten : OBD=echte Fahrzeugdaten  SIM=Bridge läuft im Demo-Modus
// Fehler: ! blinkt bei Datenverlust (Timeout) oder hoher Fehlerrate

void drawStatusCluster() {
  const char* role = "R";

  const char* wifi;
  if (!wifiOk)      wifi = "--";
  else if (apMode)  wifi = "AP";
  else              wifi = "ST";

  // Datenquelle aus Demo-Flag des letzten LoRa-Pakets
  const char* src;
  if (rx.rawCount == 0)  src = "---";  // noch keine Daten
  else if (rx.demo)      src = "SIM";
  else                   src = "OBD";

  // Fehler: Timeout oder Fehlerrate > 10%
  bool stale = isStale();
  bool highErr = rx.rawCount > 10 &&
                 rx.errCount * 10 > rx.rawCount;
  bool hasError = stale || highErr;
  bool blinkOn = (millis() / 500) % 2 == 0;

  display.setTextSize(1);
  display.setCursor(0, OLED_HEIGHT - 8);
  display.printf("%s %s %s", role, wifi, src);
  if (hasError && blinkOn) {
    display.print("!");
  }
}

void drawPageIndicator() {
  const int ds=3,sp=8,yp=OLED_HEIGHT-4;
  int tw=DISPLAY_PAGE_COUNT*sp-(sp-ds*2);
  int xs=OLED_WIDTH-tw-2;
  for(int i=0;i<DISPLAY_PAGE_COUNT;i++) {
    int cx=xs+i*sp+ds;
    if(i==displayPage) display.fillCircle(cx,yp,ds,SSD1306_WHITE);
    else               display.drawCircle(cx,yp,ds,SSD1306_WHITE);
  }
}

bool isStale() {
  return (rx.rawCount==0)||(millis()-rx.lastRx>DATA_TIMEOUT_MS);
}

// -- Page 0: RPM + Speed --------------------------------------
void oledPage0() {
  oledClear();
  bool stale=isStale();
  display.setTextSize(1); display.setCursor(0,0); display.print("RPM");
  display.setTextSize(page0SizeRPM); display.setCursor(0,9);
  if(!stale&&pidValid[PID_RPM]) display.printf("%4d",RX_RPM); else display.print("----");
  int sx=70;
  display.setTextSize(1); display.setCursor(sx,0); display.print("km/h");
  display.setTextSize(page0SizeSpeed); display.setCursor(sx,9);
  if(!stale&&pidValid[PID_SPEED]) display.printf("%3d",RX_SPEED); else display.print("---");
  int ly=9+max(page0SizeRPM,page0SizeSpeed)*8+2;
  if(ly>52) ly=52;
  display.drawLine(0,ly,OLED_WIDTH-1,ly,SSD1306_WHITE);
  display.setTextSize(1); display.setCursor(0,ly+2);
  if(!stale&&pidValid[PID_TEMP]) display.printf("%d%cC",RX_TEMP,(char)247); else display.print("-- GradC");
  display.setCursor(42,ly+2);
  if(!stale&&pidValid[PID_ECU_SPANNUNG]) display.printf("%.1fV",RX_VOLT); else display.print("-.--V");
  display.setCursor(82,ly+2);
  if(!stale&&pidValid[PID_LOAD]) display.printf("L%.0f%%",RX_LOAD); else display.print("L--%%");
  drawStatusCluster(); drawPageIndicator(); display.display();
}

// -- Page 1: Thermik & Druck -----------------------------------
void oledPage1() {
  oledClear();
  bool stale=isStale();
  display.setTextSize(1);
  display.setCursor(0,0); display.print("Thermik & Druck");
  display.drawLine(0,9,OLED_WIDTH-1,9,SSD1306_WHITE);
  display.setCursor(0,12); display.print("KWT: ");
  if(!stale&&pidValid[PID_TEMP]) display.printf("%d%cC",RX_TEMP,(char)247); else display.print("---");
  display.setCursor(0,22); display.print("IAT: ");
  if(!stale&&pidValid[PID_IAT]) display.printf("%d%cC",RX_IAT,(char)247); else display.print("---");
  display.drawLine(0,32,OLED_WIDTH-1,32,SSD1306_WHITE);
  display.setCursor(0,35); display.print("MAP: ");
  if(!stale&&pidValid[PID_MAP]) display.printf("%d kPa",RX_MAP); else display.print("---");
  display.setCursor(0,45); display.print("Last:");
  if(!stale&&pidValid[PID_LOAD]) display.printf("%.1f%%",RX_LOAD); else display.print("--.-%%");
  drawStatusCluster(); drawPageIndicator(); display.display();
}

// -- Page 2: TPS + Spannung + LoRa ----------------------------
void oledPage2() {
  oledClear();
  bool stale=isStale();
  uint8_t curSF=rxCfg.valid?rxCfg.sf:LORA_SF;
  display.setTextSize(1);
  display.setCursor(0,0); display.print("TPS / Volt / LoRa");
  display.drawLine(0,9,OLED_WIDTH-1,9,SSD1306_WHITE);
  display.setCursor(0,12); display.print("TPS: ");
  if(!stale&&pidValid[PID_TPS]) display.printf("%.1f%%",RX_TPS); else display.print("--.-%%");
  display.setCursor(0,22); display.print("Ubat:");
  if(!stale&&pidValid[PID_ECU_SPANNUNG]) display.printf("%.2fV",RX_VOLT); else display.print("--.--V");
  display.drawLine(0,32,OLED_WIDTH-1,32,SSD1306_WHITE);
  display.setCursor(0,35);
  if(rx.rawCount>0)
    display.printf("RSSI:%.0fdBm SNR:%.0fdB",rx.rssi,rx.snr);
  else
    display.printf("SF%d Warte...",curSF);
  display.setCursor(0,45);
  if(bat.valid)
    display.printf("Akku:%.2fV %d%%",bat.volt,bat.pct);
  else {
    float lp=(rx.pktBasic+rx.pktExt+rx.lostCount)>0?
      100.0f*rx.lostCount/(rx.pktBasic+rx.pktExt+rx.lostCount):0;
    display.printf("SF%d Vl:%.0f%% B:%u E:%u",
      curSF,lp,rx.pktBasic,rx.pktExt);
  }
  drawStatusCluster(); drawPageIndicator(); display.display();
}

// -- Page 3: Statistik + IP ------------------------------------
void oledPage3() {
  oledClear();
  unsigned long age=rx.lastRx>0?(millis()-rx.lastRx)/1000:0;
  bool stale=isStale();
  display.setTextSize(1);
  display.setCursor(0,0); display.print("Pakete & Netz");
  display.drawLine(0,9,OLED_WIDTH-1,9,SSD1306_WHITE);
  display.setCursor(0,12);
  display.printf("BSC:%u EXT:%u",rx.pktBasic,rx.pktExt);
  display.setCursor(0,22);
  display.printf("Vl:%u Err:%u CFG:%u",rx.lostCount,rx.errCount,rx.cfgCount);
  display.drawLine(0,32,OLED_WIDTH-1,32,SSD1306_WHITE);
  display.setCursor(0,35);
  if(rx.lastRx==0)       display.print("Warte auf Sender...");
  else if(stale)         display.printf("TIMEOUT %lus!",age);
  else                   display.printf("Letztes: vor %lus",age);
  if(!stale&&rx.mil) {
    display.setCursor(0,45); display.print("! MIL AN !");
  }
  display.setCursor(0,45);
  if(wifiOk) {
    char ipbuf[17];
    String ip=apMode?WiFi.softAPIP().toString():WiFi.localIP().toString();
    strncpy(ipbuf, ip.c_str(), 16); ipbuf[16]='\0';
    display.print(ipbuf);
  } else display.print("kein WiFi");
  drawStatusCluster(); drawPageIndicator(); display.display();
}

// -- Page 4: Extended PID-Werte (scrollend) -------------------
static int page4Offset = 0;
void oledPage4() {
  oledClear();
  bool stale=isStale();
  display.setTextSize(1);
  display.setCursor(0,0); display.print("Ext. PID-Werte");
  display.drawLine(0,9,OLED_WIDTH-1,9,SSD1306_WHITE);

  int shown=0, skip=0, y=12;
  for(int i=7;i<PID_INFO_COUNT&&shown<4;i++) {
    if(!pidValid[i]) continue;
    if(PID_INFO[i].unit[0]=='\0') continue;
    if(skip<page4Offset) { skip++; continue; }
    char buf[22];
    int dec=PID_INFO[i].decimals;
    if(dec==0)
      snprintf(buf,sizeof(buf),"%-9s%5d %-4s",
        PID_INFO[i].name,(int)pidValues[i],PID_INFO[i].unit);
    else
      snprintf(buf,sizeof(buf),"%-9s%5.*f %-4s",
        PID_INFO[i].name,dec,pidValues[i],PID_INFO[i].unit);
    display.setCursor(0,y);
    display.print(buf);
    y+=10; shown++;
  }
  if(shown==0) {
    display.setCursor(0,25);
    if(stale) display.print("Keine Daten");
    else      display.print("Nur Basic-Daten");
  }
  display.setCursor(OLED_WIDTH-12,0);
  display.printf("p%d",page4Offset+1);
  drawStatusCluster(); drawPageIndicator(); display.display();
}

void oledShowPage(int p) {
  switch(p) {
    case 0: oledPage0(); break;
    case 1: oledPage1(); break;
    case 2: oledPage2(); break;
    case 3: oledPage3(); break;
    case 4: oledPage4(); break;
    default: oledPage0();
  }
}
void oledDashboard() { oledShowPage(displayPage); }

// -- Page-Steuerung --------------------------------------------
void pageNext() {
  displayPage=(displayPage+1)%DISPLAY_PAGE_COUNT;
  if(displayPage==4) page4Offset=0;
  oledShowPage(displayPage);
}
void pagePrev() {
  displayPage=(displayPage+DISPLAY_PAGE_COUNT-1)%DISPLAY_PAGE_COUNT;
  oledShowPage(displayPage);
}
void pageSet(int p) {
  if(p<0||p>=DISPLAY_PAGE_COUNT) return;
  displayPage=p;
  if(p==4) page4Offset=0;
  oledShowPage(displayPage);
}
void pageAutoTick() {
  if(pageAutoTime==0) return;
  if(pageAutoPaused) {
    if(millis()>=pageAutoPauseUntil){pageAutoPaused=false;pageLastAuto=millis();}
    return;
  }
  if(millis()-pageLastAuto>=(uint32_t)pageAutoTime*1000UL) {
    pageLastAuto=millis();
    displayPage=(displayPage+1)%DISPLAY_PAGE_COUNT;
    oledShowPage(displayPage);
  }
}
bool handlePageCmd(const String& raw) {
  String cmd=raw; cmd.trim(); String cmdL=cmd; cmdL.toLowerCase();
  if(cmdL=="next") {
    pageAutoPaused=true; pageAutoPauseUntil=millis()+10000;
    pageNext(); return true;
  }
  if(cmdL=="prev") {
    pageAutoPaused=true; pageAutoPauseUntil=millis()+10000;
    pagePrev(); return true;
  }
  if(cmdL.startsWith("page ")&&cmdL.length()>5) {
    pageAutoPaused=true;
    pageAutoPauseUntil=0xFFFFFFFFUL;
    pageSet(cmd.substring(5).toInt()-1); return true;
  }
  if(cmdL.startsWith("pagetime")&&cmdL.length()>8) {
    String a=cmd.substring(8); a.trim();
    pageAutoTime=(uint16_t)constrain(a.toInt(),0,3600);
    pageLastAuto=millis();
    pageAutoPaused=false;
    return true;
  }
  if(cmdL.startsWith("pagelay")&&cmdL.length()>7) {
    String a=cmd.substring(7); a.trim();
    int sp=a.indexOf(' ');
    if(sp>0) {
      page0SizeRPM  =constrain(a.substring(0,sp).toInt(),1,3);
      page0SizeSpeed=constrain(a.substring(sp+1).toInt(),1,3);
      if(displayPage==0) oledShowPage(0);
    }
    return true;
  }
  // OLED-Helligkeit: "dim N" (0..255)
  if(cmdL.startsWith("dim")&&cmdL.length()>3) {
    String a=cmd.substring(3); a.trim();
    uint8_t v=(uint8_t)constrain(a.toInt(),0,255);
    oledSetBrightness(v);
    char buf[32]; snprintf(buf,sizeof(buf),"[OLED] Helligkeit: %d",v);
    tcpSendln(String(buf)); return true;
  }
  return false;
}

// +==============================================================+
// |              PAKET-DECODER                                 |
// +==============================================================+

void checkSeq(uint8_t seq) {
  if(rx.seqInit) {
    uint8_t expected=rx.lastSeq+1;
    if(seq!=expected) {
      uint8_t lost=(uint8_t)(seq-expected);
      rx.lostCount+=lost;
      Serial.printf("[SEQ] Sprung: erw=%u got=%u lost=%u\n",
        expected,seq,lost);
    }
  }
  rx.lastSeq=seq; rx.seqInit=true;
  rx.lastRx=millis();
}

// -- Basic-Paket (0x01) ----------------------------------------
bool decodeBasic(uint8_t* pkt, int len) {
  if(len<LORA_PKT_LEN) return false;
  uint8_t cs=0;
  for(int i=0;i<LORA_PKT_LEN-1;i++) cs^=pkt[i];
  if(cs!=pkt[LORA_PKT_LEN-1]) {
    Serial.printf("[BSC] CRC-Fehler: erw=%02X got=%02X\n",cs,pkt[LORA_PKT_LEN-1]);
    rx.errCount++; return false;
  }

  // Benannte Indizes statt Magic Numbers (Punkt 7)
  pidValues[PID_RPM]         = (float)((pkt[1]<<8)|pkt[2]);
  pidValues[PID_SPEED]       = (float)pkt[3];
  pidValues[PID_TEMP]        = (float)((int)pkt[4]-40);
  pidValues[PID_LOAD]        = pkt[5]*100.0f/255.0f;
  pidValues[PID_MAP]         = (float)pkt[6];
  pidValues[PID_TPS]         = pkt[7]*100.0f/255.0f;
  pidValues[PID_IAT]         = (float)((int)pkt[8]-40);
  pidValues[PID_ECU_SPANNUNG]= ((pkt[9]<<8)|pkt[10])/100.0f;

  pidValid[PID_RPM]=pidValid[PID_SPEED]=pidValid[PID_TEMP]=true;
  pidValid[PID_LOAD]=pidValid[PID_MAP]=pidValid[PID_TPS]=true;
  pidValid[PID_IAT]=pidValid[PID_ECU_SPANNUNG]=true;
  pidRxCount[PID_RPM]++;

  rx.demo = (pkt[11]&0x04)!=0;
  rx.mil  = (pkt[11]&0x01)!=0;
  checkSeq(pkt[12]);
  rx.pktBasic++;
  rx.lastPktType=LORA_PKT_OBD;
  return true;
}

// -- Extended-Paket (0x03, TLV) -------------------------------
bool decodeExtended(uint8_t* pkt, int len) {
  if(len<4) return false;
  uint8_t payloadLen=pkt[1];
  int minLen=2+payloadLen+2;  // Typ + LenByte + Payload + SeqNr + CS
  if(len<minLen) {
    Serial.printf("[EXT] Zu kurz: len=%d erw>=%d\n",len,minLen);
    rx.errCount++; return false;
  }
  uint8_t cs=0;
  for(int i=0;i<minLen-1;i++) cs^=pkt[i];
  if(cs!=pkt[minLen-1]) {
    Serial.printf("[EXT] CRC-Fehler: erw=%02X got=%02X\n",cs,pkt[minLen-1]);
    rx.errCount++; return false;
  }

  int pos=2, endPos=2+payloadLen, decoded=0;
  // Punkt 3: Korrigierter Off-by-one -- 4 Bytes noetig: Tag+Len+Hi+Lo
  while(pos+4 <= endPos) {
    uint8_t tag  = pkt[pos];
    uint8_t tlen = pkt[pos+1];
    if(tlen!=2 || pos+2+tlen > endPos) { pos++; continue; }
    int16_t raw  = (int16_t)((pkt[pos+2]<<8)|pkt[pos+3]);
    float   val  = raw / pidScaleDiv(tag);
    if(tag < MAX_PIDS) {
      pidValues[tag] = val;
      pidValid[tag]  = true;
      pidRxCount[tag]++;
      decoded++;
    }
    pos += 2+tlen;
  }

  if(pidValid[PID_MIL_STATUS]) rx.mil = (pidValues[PID_MIL_STATUS] > 0.5f);

  uint8_t seq = pkt[minLen-2];
  checkSeq(seq);
  rx.pktExt++;
  rx.lastPktType = LORA_PKT_EXT;
  Serial.printf("[EXT] %d Werte dekodiert (SeqNr=%u)\n", decoded, seq);
  return true;
}

// -- Config-Paket ----------------------------------------------
bool decodeConfig(uint8_t* pkt, int len) {
  if(len<LORA_CFG_LEN) return false;
  uint8_t cs=0;
  for(int i=0;i<LORA_CFG_LEN-1;i++) cs^=pkt[i];
  if(cs!=pkt[LORA_CFG_LEN-1]) { rx.errCount++; return false; }
  rxCfg.sf=pkt[1]; rxCfg.region=pkt[2]; rxCfg.power=pkt[3];
  rxCfg.valid=true; rx.cfgCount++;
  Serial.printf("[CFG] SF%d Region%d %ddBm\n",rxCfg.sf,rxCfg.region,rxCfg.power);
  applyRxConfig();  // Punkt 2: Forward-Deklaration vorhanden
  return true;
}

// -- Haupt-Dispatcher -----------------------------------------
bool decodePkt(uint8_t* pkt, int len) {
  if(len<2) return false;
  uint8_t t=pkt[0];

  if(t==LORA_PKT_CFG) return decodeConfig(pkt,len);

  if(t==LORA_PKT_OBD) {
    if(pktMode==2) { Serial.println("[PKT] Basic ignoriert (ext-only)"); return false; }
    return decodeBasic(pkt,len);
  }
  if(t==LORA_PKT_EXT) {
    if(pktMode==1) { Serial.println("[PKT] Extended ignoriert (basic-only)"); return false; }
    return decodeExtended(pkt,len);
  }

  Serial.printf("[PKT] Unbekannter Typ: 0x%02X len=%d\n",t,len);
  rx.errCount++; return false;
}

// -- LoRa Config anwenden --------------------------------------
void applyRxConfig() {
  float freq=(rxCfg.region==0)?868.0f:915.0f;
  radio.standby();
  radio.setFrequency(freq);
  radio.setSpreadingFactor(rxCfg.sf);
  radio.setOutputPower(rxCfg.power);
  radio.startReceive();
  // Punkt 10: snprintf statt String-Konkatenation
  const char* reg=(rxCfg.region==0)?"DE/EU":"INT";
  Serial.printf("[LoRa] Neu: %s %.0fMHz SF%d %ddBm\n",
    reg,freq,rxCfg.sf,rxCfg.power);
  char buf[32];
  snprintf(buf, sizeof(buf), "CFG:%s SF%d", reg, rxCfg.sf);
  cfgMsg = String(buf);
  cfgMsgUntil=millis()+3000;
}

// -- Einheitliche CLI-Ausgabe: Serial + TCP gleichzeitig ------
// Ersetzt tcpSend/tcpSendln -- identisches Verhalten wie Bridge println/print_f
void cliOut(const String& s) {
  Serial.println(s);
  if(wifiOk&&tcpClient&&tcpClient.connected())
    tcpClient.println(s);
}
void cliOutf(const char* fmt, ...) {
  char buf[256]; va_list a; va_start(a,fmt);
  vsnprintf(buf,sizeof(buf),fmt,a); va_end(a);
  Serial.print(buf);
  if(wifiOk&&tcpClient&&tcpClient.connected())
    tcpClient.print(buf);
}
// Kompatibilitaets-Wrapper (bestehende Aufrufe bleiben gueltig)
void tcpSend(const String& s)   { Serial.print(s);
  if(wifiOk&&tcpClient&&tcpClient.connected()) tcpClient.print(s); }
void tcpSendln(const String& s) { cliOut(s); }

void tcpCheck() {
  if(!wifiOk) return;
  if(tcpServer.hasClient()) {
    if(tcpClient&&tcpClient.connected()) tcpClient.stop();
    tcpClient=tcpServer.available();
    if(tcpClient) {
      tcpClient.setTimeout(3000);
      tcpClient.setNoDelay(true);
      tcpInputBuf=""; liveMode=false;
      tcpClient.println("\r\n=== obdBT LoRa Receiver v1.3 ===");
      tcpClient.println("'help' fuer Befehle");
      Serial.println("[TCP] "+tcpClient.remoteIP().toString());
      // Sofortiger Status damit der Nutzer weiss was laeuft
      printStatus();
    }
  }
}

String tcpReadLine() {
  if(!wifiOk||!tcpClient||!tcpClient.connected()) return "";
  while(tcpClient.available()) {
    uint8_t b=(uint8_t)tcpClient.read();
    if(b==0xFF) {
      if(tcpClient.available()) {
        uint8_t cmd2=(uint8_t)tcpClient.read();
        if(cmd2>=0xFB && cmd2<=0xFE && tcpClient.available())
          tcpClient.read();
      }
      continue;
    }
    char c=(char)b;
    if(c=='\n'||c=='\r') {
      if(tcpInputBuf.length()>0) {
        String l=tcpInputBuf; tcpInputBuf=""; l.trim(); return l;
      }
      continue;
    }
    if(b<32||b>=127) continue;
    tcpInputBuf+=c;
    if(tcpInputBuf.length()>128) tcpInputBuf="";
  }
  return "";
}

// -- Menue & Status ---------------------------------------------
void printPidList() {
  tcpSendln("============================================");
  tcpSendln("  Empfangene PID-Werte:");
  tcpSendln("  IDX  ID    Name             Wert        #Rx");
  tcpSendln("  -----------------------------------------");
  for(int i=0;i<PID_INFO_COUNT;i++) {
    if(!pidValid[i]) continue;
    if(PID_INFO[i].unit[0]=='\0') continue;
    char buf[70];
    int dec=PID_INFO[i].decimals;
    if(dec==0)
      snprintf(buf,sizeof(buf),"  %2d   %-6s %-16s %6d %-4s  %u",
        i,PID_INFO[i].id,PID_INFO[i].name,
        (int)pidValues[i],PID_INFO[i].unit,pidRxCount[i]);
    else
      snprintf(buf,sizeof(buf),"  %2d   %-6s %-16s %6.*f %-4s  %u",
        i,PID_INFO[i].id,PID_INFO[i].name,
        dec,pidValues[i],PID_INFO[i].unit,pidRxCount[i]);
    tcpSendln(String(buf));
  }
  tcpSendln("============================================");
}

void printStatus() {
  const char* regStr=(rxCfg.valid?rxCfg.region:LORA_REGION)==0?"DE/EU 868MHz":"INT 915MHz";
  uint8_t sf =rxCfg.valid?rxCfg.sf   :LORA_SF;
  uint8_t pw =rxCfg.valid?rxCfg.power:LORA_POWER;
  const char* wifiStr=wifiOk?(apMode?"AP":"STA"):"kein";
  char ipbuf[20];
  if(wifiOk) {
    String ip=apMode?WiFi.softAPIP().toString():WiFi.localIP().toString();
    strncpy(ipbuf, ip.c_str(), sizeof(ipbuf)-1); ipbuf[sizeof(ipbuf)-1]='\0';
  } else {
    strcpy(ipbuf,"-");
  }
  unsigned long age=rx.lastRx>0?(millis()-rx.lastRx)/1000:0;
  const char* pktModeStr[]={"auto","basic-only","ext-only"};

  tcpSendln("=== obdBT LoRa Receiver v1.3 ===");
  #ifdef BUILD_TIME
  tcpSendln("  Build : " BUILD_TIME);
  #endif
  #ifdef SKETCH_MTIME
  tcpSendln("  Quelle: " SKETCH_MTIME);
  #endif
  { unsigned long up=(millis()-bootTime)/1000;
    unsigned long d=up/86400, h=(up%86400)/3600, m=(up%3600)/60, s=up%60;
    char upbuf[40];
    if(d>0) snprintf(upbuf,sizeof(upbuf),"  Laufzeit: %lud %02lu:%02lu:%02lu",d,h,m,s);
    else    snprintf(upbuf,sizeof(upbuf),"  Laufzeit: %02lu:%02lu:%02lu",h,m,s);
    tcpSendln(String(upbuf)); }

  char buf[80];
  snprintf(buf,sizeof(buf),"  Region : %s",regStr);       tcpSendln(String(buf));
  snprintf(buf,sizeof(buf),"  SF: %d  Power: %d dBm",sf,pw); tcpSendln(String(buf));
  snprintf(buf,sizeof(buf),"  Pkt-Modus: %s",pktModeStr[pktMode]); tcpSendln(String(buf));
  snprintf(buf,sizeof(buf),"  Pakete gesamt: %u",rx.rawCount); tcpSendln(String(buf));
  snprintf(buf,sizeof(buf),"    Basic:  %u  Extended: %u",rx.pktBasic,rx.pktExt); tcpSendln(String(buf));
  snprintf(buf,sizeof(buf),"    Config: %u  Verlust:  %u  Fehler: %u",
    rx.cfgCount,rx.lostCount,rx.errCount); tcpSendln(String(buf));
  if(rx.rawCount>0) {
    snprintf(buf,sizeof(buf),"  Signal: RSSI %.0f dBm  SNR %.0f dB",rx.rssi,rx.snr);
    tcpSendln(String(buf));
  }
  if(rx.lastRx>0) {
    snprintf(buf,sizeof(buf),"  Letztes: vor %lus",age); tcpSendln(String(buf));
  }
  tcpSendln("  -- OBD-Basiswerte --");
  if(pidValid[PID_RPM]) {
    snprintf(buf,sizeof(buf),"  RPM:%d  Speed:%d km/h",RX_RPM,RX_SPEED);
    tcpSendln(String(buf));
    snprintf(buf,sizeof(buf),"  Temp:%d C  IAT:%d C  MAP:%d kPa",RX_TEMP,RX_IAT,RX_MAP);
    tcpSendln(String(buf));
    snprintf(buf,sizeof(buf),"  Last:%.1f%%  TPS:%.1f%%  Volt:%.2fV",RX_LOAD,RX_TPS,RX_VOLT);
    tcpSendln(String(buf));
    if(rx.demo) tcpSendln("  [DEMO-MODUS]");
    if(rx.mil)  tcpSendln("  ! MIL AN !");
  } else {
    tcpSendln("  Warte auf Pakete...");
  }
  int extCount=0;
  for(int i=7;i<PID_INFO_COUNT;i++)
    if(pidValid[i]&&PID_INFO[i].unit[0]!='\0') extCount++;
  if(extCount>0) {
    snprintf(buf,sizeof(buf),"  -- Extended (%d Werte) --",extCount);
    tcpSendln(String(buf));
    for(int i=7;i<PID_INFO_COUNT;i++) {
      if(!pidValid[i]||PID_INFO[i].unit[0]=='\0') continue;
      int dec=PID_INFO[i].decimals;
      if(dec==0) snprintf(buf,sizeof(buf),"  %-16s %d %s",PID_INFO[i].name,(int)pidValues[i],PID_INFO[i].unit);
      else       snprintf(buf,sizeof(buf),"  %-16s %.*f %s",PID_INFO[i].name,dec,pidValues[i],PID_INFO[i].unit);
      tcpSendln(String(buf));
    }
  }
  snprintf(buf,sizeof(buf),"  WiFi: %s  IP: %s",wifiStr,ipbuf); tcpSendln(String(buf));
  snprintf(buf,sizeof(buf),"  Port:  %d",TCP_PORT); tcpSendln(String(buf));
  if(bat.valid)
    snprintf(buf,sizeof(buf),"  Akku: %.2fV  %d%%",bat.volt,bat.pct);
  else
    snprintf(buf,sizeof(buf),"  Akku: nicht gemessen");
  tcpSendln(String(buf));
  snprintf(buf,sizeof(buf),"  Page:  %d/%d  Auto: %ds",
    displayPage+1,DISPLAY_PAGE_COUNT,pageAutoTime); tcpSendln(String(buf));
  tcpSendln("=== END ===");
}

void printHelp() {
  tcpSendln("================================");
  tcpSendln("  obdBT LoRa Receiver v1.3");
  tcpSendln("================================");
  tcpSendln("  -- System --");
  tcpSendln("  status/s/info - Systemstatus (RSSI, SNR, WLAN, Akku)");
  tcpSendln("  version/ver   - Build-Zeitstempel");
  tcpSendln("  wifi          - WLAN-Info");
  tcpSendln("  lora          - LoRa-Parameter");
  tcpSendln("  reboot/r      - Neustart");
  tcpSendln("  -- PIDs --");
  tcpSendln("  pid list      - alle empfangenen PID-Werte");
  tcpSendln("  -- Paketfilter --");
  tcpSendln("  pkt auto      - Basic+Extended (Standard)");
  tcpSendln("  pkt basic     - nur Basic (0x01)");
  tcpSendln("  pkt extended  - nur Extended (0x03)");
  tcpSendln("  -- Live-Stream (TCP) --");
  tcpSendln("  live/l        - Live-Ausgabe umschalten");
  tcpSendln("  live on/off   - Live explizit ein/aus");
  tcpSendln("  -- Display --");
  tcpSendln("  next/prev     - Display-Page weiterschalten");
  tcpSendln("  page N        - Page 1..5 direkt");
  tcpSendln("  pagetime N    - Auto-Cycle Sek. (0=aus)");
  tcpSendln("  pagelay R S   - Page0 Schriftgroesse RPM/Speed (1-3)");
  tcpSendln("  dim N         - OLED-Helligkeit 0..255");
  tcpSendln("  -- Beeper (lokal) --");
  tcpSendln("  beep on/off   - Dauerton ein/aus");
  tcpSendln("  beep ack      - Doppelpiep");
  tcpSendln("  beep rx on/off- Quittungs-Piep bei LoRa-Empfang");
  tcpSendln("  -- LoRa CTRL -> Bridge --");
  tcpSendln("  ctrl beep on|off|ack   - Bridge-Beeper per LoRa");
  tcpSendln("  ctrl beep pulse D N    - N Pieps a D*10ms an Bridge");
  tcpSendln("  ctrl gpio PIN          - Bridge-GPIO lesen");
  tcpSendln("  ctrl gpio PIN 0/1      - Bridge-GPIO setzen");
  tcpSendln("  ctrl ping              - Bridge anpingen");
  tcpSendln("  -- GPIO (lokal) --");
  tcpSendln("  gpio PIN      - GPIO lesen");
  tcpSendln("  gpio PIN 0/1  - GPIO setzen");
  tcpSendln("================================");
}

// -- CTRL-Queue: Befehl merken, wird nach nächstem RX gesendet -
void loraSendCtrl(uint8_t cmd, uint8_t arg1=0, uint8_t arg2=0) {
  cmdQueue   = cmd;
  cmdQueueA1 = arg1;
  cmdQueueA2 = arg2;
  tcpSendln("[CTRL] queued: CMD=0x"+String(cmd,HEX)+
            " ARG1="+String(arg1)+" ARG2="+String(arg2)+
            " — wird nach naechstem Paket gesendet");
}

// -- CTRL tatsaechlich senden (aus RX-Loop aufgerufen) ---------
static uint8_t ctrlSeqRx = 0;
void loraSendCtrlNow(uint8_t cmd, uint8_t arg1, uint8_t arg2) {
  uint8_t pkt[LORA_CTRL_LEN];
  pkt[0] = LORA_PKT_CTRL;
  pkt[1] = ctrlSeqRx++;
  pkt[2] = LORA_DEV_RECEIVER;
  pkt[3] = LORA_DEV_BRIDGE;
  pkt[4] = cmd;
  pkt[5] = arg1;
  pkt[6] = arg2;
  pkt[7] = 0;
  for(int i=0;i<LORA_CTRL_LEN-1;i++) pkt[7]^=pkt[i];
  radio.standby();
  int state = radio.transmit(pkt, LORA_CTRL_LEN);
  if(state == RADIOLIB_ERR_NONE)
    tcpSendln("[CTRL] gesendet CMD=0x"+String(cmd,HEX)+
              " ARG1="+String(arg1)+" ARG2="+String(arg2));
  else
    tcpSendln("[CTRL] TX Fehler: "+String(state));
  // Receiver geht danach wieder in RX (Aufrufer)
}

// -- CTRL-Paket vom Empfaenger verarbeiten (Bridge -> Receiver) -
void loraHandleCtrl(uint8_t* p, int len) {
  if(len < LORA_CTRL_LEN) return;
  uint8_t crc = 0;
  for(int i=0;i<LORA_CTRL_LEN-1;i++) crc^=p[i];
  if(crc != p[LORA_CTRL_LEN-1]) {
    Serial.println("[CTRL] CRC-Fehler"); return;
  }
  uint8_t dst=p[3];
  if(dst != LORA_DEV_RECEIVER && dst != LORA_DEV_BCAST) return;

  uint8_t cmd=p[4], arg1=p[5], arg2=p[6];
  switch(cmd) {
    case CTRL_BEEP_ON:
      rxBeepSet(true);  tcpSendln("[CTRL] BEEP AN"); break;
    case CTRL_BEEP_OFF:
      rxBeepSet(false); tcpSendln("[CTRL] BEEP AUS"); break;
    case CTRL_BEEP_ACK:
      rxBeepAck();      tcpSendln("[CTRL] BEEP ACK"); break;
    case CTRL_BEEP_PULSE:
      { uint8_t dur=arg1, cnt=arg2?arg2:1;
        for(uint8_t i=0;i<cnt;i++){
          digitalWrite(BEEP_PIN,HIGH); delay(dur*10);
          digitalWrite(BEEP_PIN,LOW);  if(i<cnt-1) delay(dur*5);
        }
        digitalWrite(BEEP_PIN, rxBeeperOn?HIGH:LOW);
        char m[40]; snprintf(m,sizeof(m),"[CTRL] BEEP %dx %dms",cnt,dur*10);
        tcpSendln(String(m)); break; }
    case CTRL_GPIO_SET:
      pinMode(arg1,OUTPUT); digitalWrite(arg1,arg2?HIGH:LOW);
      { char m[40]; snprintf(m,sizeof(m),"[CTRL] GPIO %d = %d",arg1,arg2);
        tcpSendln(String(m)); break; }
    case CTRL_GPIO_GET:
      { int val=digitalRead(arg1);
        char m[40]; snprintf(m,sizeof(m),"[CTRL] GPIO %d = %d",arg1,val);
        tcpSendln(String(m));
        // Antwort per LoRa zurück an Bridge: GPIO_SET mit aktuellem Wert als Echo
        loraSendCtrl(CTRL_GPIO_GET, arg1, (uint8_t)val);
        break; }
    case CTRL_GPIO_PULSE:
      { pinMode(arg1,OUTPUT);
        digitalWrite(arg1,HIGH); delay(arg2*10); digitalWrite(arg1,LOW);
        char m[40]; snprintf(m,sizeof(m),"[CTRL] GPIO %d pulse %dms",arg1,arg2*10);
        tcpSendln(String(m)); break; }
    case CTRL_PING:
      tcpSendln("[CTRL] PING von Bridge");
      // Pong zurueck queuen
      loraSendCtrl(CTRL_PING); break;
    default:
      { char m[40]; snprintf(m,sizeof(m),"[CTRL] unbekannt: 0x%02X",cmd);
        tcpSendln(String(m)); break; }
  }
}

void handleCmd(const String& raw) {
  String cmd=raw; cmd.trim();
  String cmdL=cmd; cmdL.toLowerCase();
  if(cmdL.length()==0) return;
  Serial.println("[CMD] "+cmd);

  if(handlePageCmd(cmd)) return;

  if(cmdL=="?"||cmdL=="help")   { printHelp();   return; }
  if(cmdL=="version"||cmdL=="ver") {
    tcpSendln("=== obdBT LoRa Receiver v1.3 ===");
    #ifdef BUILD_TIME
    tcpSendln("  Build : " BUILD_TIME);
    #endif
    #ifdef SKETCH_MTIME
    tcpSendln("  Quelle: " SKETCH_MTIME);
    #endif
    tcpSendln("=================================");
    return;
  }
  if(cmdL=="s"||cmdL=="status"||cmdL=="info") { printStatus(); return; }
  if(cmdL=="pid list"||cmdL=="pid") { printPidList(); return; }

  if(cmdL=="l"||cmdL=="live"||cmdL=="live on"||cmdL=="live off") {
    if(cmdL=="live on")       liveMode=true;
    else if(cmdL=="live off") liveMode=false;
    else                      liveMode=!liveMode;
    tcpSendln(liveMode?"Live AN":"Live AUS"); return;
  }
  if(cmdL=="r"||cmdL=="reboot") {
    tcpSendln("Reboot..."); delay(300); ESP.restart();
  }

  if(cmdL=="pkt auto")     { pktMode=0; tcpSendln("[PKT] Modus: auto");     return; }
  if(cmdL=="pkt basic")    { pktMode=1; tcpSendln("[PKT] Modus: basic");    return; }
  if(cmdL=="pkt extended") { pktMode=2; tcpSendln("[PKT] Modus: extended"); return; }

  if(cmdL=="wifi") {
    char buf[64];
    snprintf(buf,sizeof(buf),"WiFi: %s",wifiOk?(apMode?"AP":"STA"):"kein");
    tcpSendln(String(buf));
    if(wifiOk) {
      String ip=apMode?WiFi.softAPIP().toString():WiFi.localIP().toString();
      tcpSendln("IP:   "+ip);
      snprintf(buf,sizeof(buf),"Port: %d",TCP_PORT); tcpSendln(String(buf));
    }
    return;
  }
  if(cmdL=="lora") {
    uint8_t sf=rxCfg.valid?rxCfg.sf:LORA_SF;
    uint8_t pw=rxCfg.valid?rxCfg.power:LORA_POWER;
    const char* reg=(rxCfg.valid?rxCfg.region:LORA_REGION)==0?"DE/EU":"INT";
    char buf[64];
    snprintf(buf,sizeof(buf),"LoRa: %s SF%d %ddBm",reg,sf,pw); tcpSendln(String(buf));
    snprintf(buf,sizeof(buf),"BSC:%u EXT:%u Err:%u",rx.pktBasic,rx.pktExt,rx.errCount); tcpSendln(String(buf));
    snprintf(buf,sizeof(buf),"RSSI:%.0f SNR:%.0f",rx.rssi,rx.snr); tcpSendln(String(buf));
    return;
  }
  // Lokaler Beeper des Receivers
  if(cmdL=="beep on")     { rxBeepSet(true);   tcpSendln("[BEEP] AN");          return; }
  if(cmdL=="beep off")    { rxBeepSet(false);  tcpSendln("[BEEP] AUS");         return; }
  if(cmdL=="beep ack")    { rxBeepAck();       tcpSendln("[BEEP] ACK");         return; }
  if(cmdL=="beep rx on")  { rxBeepOnRx=true;   tcpSendln("[BEEP] RX-Piep AN"); return; }
  if(cmdL=="beep rx off") { rxBeepOnRx=false;  tcpSendln("[BEEP] RX-Piep AUS");return; }
  if(cmdL=="beep")        { rxBeepAck();       return; }

  // Lokaler GPIO-Zugriff: "gpio PIN" liest, "gpio PIN 0/1" setzt
  if(cmdL.startsWith("gpio ")&&cmdL.length()>5) {
    String rest=cmdL.substring(5); rest.trim();
    int sp=rest.indexOf(' ');
    int pin=rest.toInt();
    if(sp<0) {
      int val=digitalRead(pin);
      char m[40]; snprintf(m,sizeof(m),"[GPIO] %d = %d",pin,val);
      tcpSendln(String(m));
    } else {
      int val=rest.substring(sp+1).toInt();
      pinMode(pin, OUTPUT); digitalWrite(pin, val?HIGH:LOW);
      char m[40]; snprintf(m,sizeof(m),"[GPIO] %d = %d",pin,val);
      tcpSendln(String(m));
    }
    return;
  }
  // ctrl beep on|off|ack|pulse D N  |  ctrl gpio PIN VAL  |  ctrl ping
  if(cmdL.startsWith("ctrl ")) {
    String a=cmd.substring(5); a.trim();
    String aL=a; aL.toLowerCase();
    if(aL=="beep on")        loraSendCtrl(CTRL_BEEP_ON);
    else if(aL=="beep off")  loraSendCtrl(CTRL_BEEP_OFF);
    else if(aL=="beep ack")  loraSendCtrl(CTRL_BEEP_ACK);
    else if(aL=="ping")      loraSendCtrl(CTRL_PING);
    else if(aL.startsWith("beep pulse ")) {
      String rest=aL.substring(11);
      int d=rest.toInt(); rest=rest.substring(rest.indexOf(' ')+1);
      int n=rest.toInt();
      loraSendCtrl(CTRL_BEEP_PULSE,(uint8_t)d,(uint8_t)n);
    }
    else if(aL.startsWith("gpio ")) {
      String rest=aL.substring(5);
      int pin=rest.toInt(); rest=rest.substring(rest.indexOf(' ')+1);
      int val=rest.toInt();
      loraSendCtrl(CTRL_GPIO_SET,(uint8_t)pin,(uint8_t)val);
    }
    else tcpSendln("ctrl: beep on|off|ack|pulse D N  gpio PIN VAL  ping");
    return;
  }

  tcpSendln("Unbekannt: '"+raw+"'  Hilfe: ?");
}

// -- Setup -----------------------------------------------------
void setup() {
  WRITE_PERI_REG(RTC_CNTL_BROWN_OUT_REG,0);
  Serial.begin(115200); delay(500);
  Serial.println("\n=== obdBT LoRa Empfaenger v1.3 ===");

  for(int i=0;i<MAX_PIDS;i++){pidValues[i]=0;pidValid[i]=false;pidRxCount[i]=0;}

  pinMode(BTN_PIN,INPUT_PULLUP);
  pinMode(BEEP_PIN,OUTPUT); digitalWrite(BEEP_PIN,LOW); // Beeper aus
  pinMode(VEXT_PIN,OUTPUT); digitalWrite(VEXT_PIN,LOW); delay(100);

  Wire.begin(OLED_SDA,OLED_SCL);
  pinMode(OLED_RST,OUTPUT);
  digitalWrite(OLED_RST,LOW); delay(20);
  digitalWrite(OLED_RST,HIGH); delay(50);
  if(!display.begin(SSD1306_SWITCHCAPVCC,OLED_ADDR))
    Serial.println("[OLED] Fehler!");
  display.clearDisplay(); display.display();
  oledSetBrightness(oledBrightness);  // Default-Helligkeit setzen
  oledShow("obdBT LoRa","Empfaenger v1.3","Basic+Extended","Initialisiere...");
  delay(1000);

  // LoRa
  oledShow("LoRa","Init...","868MHz SF7");
  SPI.begin(LORA_SCK,LORA_MISO,LORA_MOSI,LORA_CS);
  int state=radio.begin(LORA_FREQ,LORA_BW,LORA_SF,LORA_CR,LORA_SYNC,LORA_POWER);
  if(state!=RADIOLIB_ERR_NONE) {
    Serial.printf("[LoRa] Fehler: %d\n",state);
    oledShow("LoRa FEHLER!",String(state),"Neustart noetig");
    while(true) delay(1000);
  }
  radio.setDio1Action(onRxDone);
  radio.startReceive();
  bootTime = millis();
  const char* rxReg=(LORA_REGION==LORA_REGION_DE)?"DE/EU":"INT";
  oledShow(String("LoRa OK ")+rxReg,
    String(LORA_FREQ,0)+"MHz SF"+String(LORA_SF),
    "Basic+Extended","Warte auf Sender...");
  Serial.printf("[LoRa] RX: %s %.0fMHz SF%d\n",rxReg,LORA_FREQ,LORA_SF);
  delay(1000);

  // WiFi
  oledShow("WiFi","Verbinde...",WIFI_SSID);
  WiFi.mode(WIFI_STA); WiFi.setTxPower(WIFI_POWER_19_5dBm);
  WiFi.begin(WIFI_SSID,WIFI_PASS);
  unsigned long ws=millis(); int dots=0;
  while(WiFi.status()!=WL_CONNECTED&&millis()-ws<WIFI_TIMEOUT) {
    delay(300); Serial.print(".");
    if(++dots%20==0) oledShow("WiFi...",WIFI_SSID,
      String((WIFI_TIMEOUT-(millis()-ws))/1000)+"s","Bridge-AP Fallback");
  }
  Serial.println();
  if(WiFi.status()==WL_CONNECTED) {
    wifiOk=true; apMode=false;
    String ip=WiFi.localIP().toString();
    tcpServer.begin(); tcpServer.setNoDelay(true);
    httpServer.begin();
    oledShow("WiFi OK (STA)",ip,"Port:"+String(TCP_PORT),
             "nc "+ip+" "+String(TCP_PORT));
    delay(3000);
  } else {
    // Heimnetz nicht erreichbar -> als STA-Client zum Bridge-AP verbinden
    // (Receiver wird KEIN eigener AP -- verhindert IP-Konflikt mit Bridge)
    WiFi.disconnect(); delay(200);
    WiFi.mode(WIFI_STA); WiFi.setTxPower(WIFI_POWER_19_5dBm);
    Serial.printf("[WiFi] Verbinde zu Bridge-AP '%s'...\n", WIFI_FALLBACK_SSID);
    oledShow("Verbinde zu","Bridge-AP:",WIFI_FALLBACK_SSID,"(kein Heimnetz)");
    WiFi.begin(WIFI_FALLBACK_SSID, WIFI_FALLBACK_PASS);
    unsigned long bws=millis(); int bdots=0;
    while(WiFi.status()!=WL_CONNECTED && millis()-bws<WIFI_TIMEOUT) {
      delay(300); Serial.print(".");
      if(++bdots%20==0)
        oledShow("Bridge-AP...",WIFI_FALLBACK_SSID,
          String((WIFI_TIMEOUT-(millis()-bws))/1000)+"s","Bridge eingeschaltet?");
    }
    Serial.println();
    if(WiFi.status()==WL_CONNECTED) {
      wifiOk=true; apMode=false;
      String ip=WiFi.localIP().toString();
      tcpServer.begin(); tcpServer.setNoDelay(true);
      httpServer.begin();
      Serial.printf("[WiFi] STA (Bridge-AP): %s  Port:%d\n", ip.c_str(), TCP_PORT);
      oledShow("WiFi OK (Bridge)",ip,"Port:"+String(TCP_PORT),
               "nc "+ip+" "+String(TCP_PORT));
      delay(3000);
    } else {
      Serial.println("[WiFi] Bridge-AP nicht erreichbar - nur LoRa+Serial");
      oledShow("Kein WiFi","Nur LoRa+Serial"); delay(1500);
    }
  }

  pageLastAuto=millis();

  // -- Einheitliches Startup-Banner auf Serial ------------------
  Serial.println();
  Serial.println("=========================================");
  Serial.println("  obdBT LoRa Receiver v1.3 bereit");
  Serial.println("-----------------------------------------");
  if(wifiOk) {
    String ip=apMode?WiFi.softAPIP().toString():WiFi.localIP().toString();
    Serial.printf("  WiFi  : %s '%s'\n", apMode?"AP":"STA",
                  apMode?AP_SSID:WIFI_SSID);
    Serial.printf("  Telnet: nc %s %d\n", ip.c_str(), TCP_PORT);
    Serial.printf("  HTTP  : http://%s/\n", ip.c_str());
  } else {
    Serial.println("  WiFi  : nicht verbunden -- nur Serial");
  }
  Serial.println("  Befehle: help | status | pid list | live");
  Serial.println("=========================================\n");
}

// -- Loop ------------------------------------------------------
void loop() {
  if(wifiOk) tcpCheck();
  if(wifiOk) httpCheck();
  batMeasure();

  // -- LoRa Paket empfangen ----------------------------------
  if(rxFlag) {
    rxFlag=false;
    size_t rxLen=radio.getPacketLength(true);
    uint8_t pkt[LORA_EXT_MAX]={0};
    int state=radio.readData(pkt,
      min((size_t)LORA_EXT_MAX, rxLen>0?rxLen:(size_t)LORA_EXT_MAX));

    if(state==RADIOLIB_ERR_NONE) {
      rx.rssi=radio.getRSSI();
      rx.snr =radio.getSNR();
      rx.rawCount++;

      if(rxLen==0) {
        if     (pkt[0]==LORA_PKT_CFG) rxLen=LORA_CFG_LEN;
        else if(pkt[0]==LORA_PKT_OBD) rxLen=LORA_PKT_LEN;
        else if(pkt[0]==LORA_PKT_EXT) rxLen=pkt[1]+4;
        else                          rxLen=LORA_PKT_LEN;
      }

      Serial.printf("[RX] RSSI=%.1f SNR=%.1f Len=%d Typ=0x%02X\n",
        rx.rssi,rx.snr,(int)rxLen,pkt[0]);

      if(decodePkt(pkt,(int)rxLen)) {
        if(rxBeepOnRx) rxBeepAck();  // Empfangs-Quittung (beep rx on/off)
        if(liveMode&&wifiOk&&tcpClient&&tcpClient.connected()) {
          if(pkt[0]==LORA_PKT_OBD) {
            char buf[120];
            snprintf(buf,sizeof(buf),
              "BSC RPM:%d SPD:%d T:%d Ld:%.1f MAP:%d V:%.2f RSSI:%.0f%s",
              RX_RPM,RX_SPEED,RX_TEMP,RX_LOAD,RX_MAP,RX_VOLT,rx.rssi,
              rx.demo?" [DEMO]":"");
            tcpClient.println(buf);
          } else if(pkt[0]==LORA_PKT_EXT) {
            char buf[200];
            snprintf(buf,sizeof(buf),
              "BSC RPM:%d SPD:%d T:%d Ld:%.1f MAP:%d V:%.2f RSSI:%.0f%s",
              RX_RPM,RX_SPEED,RX_TEMP,RX_LOAD,RX_MAP,RX_VOLT,rx.rssi,
              rx.demo?" [DEMO]":"");
            tcpClient.println(buf);
            // Punkt 10: char[] statt String+= in Hot-Path
            char line[200];
            int pos = snprintf(line, sizeof(line), "EXT");
            for(int i=0;i<PID_INFO_COUNT;i++) {
              if(!pidValid[i]||PID_INFO[i].unit[0]=='\0') continue;
              char tmp[32];
              int dec=PID_INFO[i].decimals;
              if(dec==0) snprintf(tmp,sizeof(tmp)," %s:%d",
                PID_INFO[i].name,(int)pidValues[i]);
              else       snprintf(tmp,sizeof(tmp)," %s:%.*f",
                PID_INFO[i].name,dec,pidValues[i]);
              int tlen = strlen(tmp);
              if(pos+tlen >= (int)sizeof(line)-1) {
                tcpClient.println(line);
                pos = snprintf(line, sizeof(line), "EXT");
              }
              memcpy(line+pos, tmp, tlen); pos+=tlen; line[pos]='\0';
            }
            if(pos>3) tcpClient.println(line);
          }
        }
      }
    } else if(state==RADIOLIB_ERR_CRC_MISMATCH) {
      Serial.println("[RX] CRC-Fehler"); rx.errCount++;
    } else {
      Serial.printf("[RX] Fehler: %d\n",state); rx.errCount++;
    }
    // Eingehende CTRL-Pakete von Bridge verarbeiten
    if(pkt[0] == LORA_PKT_CTRL) {
      loraHandleCtrl(pkt, (int)rxLen);
    }
    // Ausstehenden CTRL-CMD an Bridge senden
    if(cmdQueue != 0) {
      uint8_t pending=cmdQueue, a1=cmdQueueA1, a2=cmdQueueA2;
      cmdQueue=0; cmdQueueA1=0; cmdQueueA2=0;
      loraSendCtrlNow(pending, a1, a2);
    }
    radio.startReceive();
  }

  // -- Button ------------------------------------------------
  if(digitalRead(BTN_PIN)==LOW) {
    delay(50);
    if(digitalRead(BTN_PIN)==LOW) {
      unsigned long ps=millis();
      while(digitalRead(BTN_PIN)==LOW) delay(10);
      unsigned long dur=millis()-ps;
      if(dur>1000) {
        // Lang (>1s): aktuelle Page dauerhaft fixieren
        pageAutoPaused=true; pageAutoPauseUntil=0xFFFFFFFFUL;
        char buf[22];
        snprintf(buf,sizeof(buf),"Page %d fixiert",displayPage+1);
        oledShow("Page fixiert",buf,"kurz: weiter","lang: fixieren");
        delay(1000);
        oledShowPage(displayPage);
      } else {
        // Kurz (<1s): naechste Page -- auf Page 4 Extended-PIDs scrollen,
        // zweiter Kurzdruck auf Page 4 wechselt zu Page 0
        pageAutoPaused=true; pageAutoPauseUntil=millis()+10000;
        if(displayPage==4) {
          int validExt=0;
          for(int i=7;i<PID_INFO_COUNT;i++)
            if(pidValid[i]&&PID_INFO[i].unit[0]!='\0') validExt++;
          int maxOffset = validExt>0 ? (validExt-1)/4 : 0;
          if(page4Offset < maxOffset) {
            // Noch mehr Eintraege: scrollen
            page4Offset++;
            oledPage4();
          } else {
            // Letzte Seite von Page 4: weiter zu Page 0
            page4Offset=0;
            pageNext();  // Page 4->0 (wrap)
          }
        } else {
          pageNext();
        }
      }
    }
  }

  // -- TCP / Serial Eingaben ---------------------------------
  if(wifiOk&&tcpClient&&tcpClient.connected()) {
    String cmd=tcpReadLine();
    if(cmd.length()>0) handleCmd(cmd);
  }
  {
    String cmd=serialReadLine();
    if(cmd.length()>0) handleCmd(cmd);
  }

  // -- Config-Meldung kurz einblenden -----------------------
  pageAutoTick();
  static unsigned long lastDisp=0;
  if(millis()-lastDisp>300) {
    lastDisp=millis();
    if(cfgMsgUntil>millis()) {
      uint8_t sf=rxCfg.valid?rxCfg.sf:LORA_SF;
      oledShow("LoRa Config!",cfgMsg,
        (rxCfg.region==0?"868MHz DE/EU":"915MHz INT"),
        "SF"+String(sf)+" "+String(rxCfg.valid?rxCfg.power:LORA_POWER)+"dBm");
    } else {
      oledDashboard();
    }
  }

  delay(20);
}
