/*
  RYP2850Demo.ino
  Control and feature showcase for the REYAX RYP2850 intelligent power module
  (4.5-28V in, 3V/3.3V/5V 4A out, UART telemetry + AT commands).

  Author:  Bill - Taste The Code
  YouTube: https://www.youtube.com/@TasteTheCode

  Board: NodeMCU v1/v2/v3 (ESP8266), ESP8266 Arduino core 3.x

  Wiring (NodeMCU <-> RYP2850):
    D5 (GPIO14)  <-  pin 12 TXD      (SoftwareSerial RX)
    D6 (GPIO12)  ->  pin 13 RXD      (SoftwareSerial TX)
    D1 (GPIO5)   ->  pin 4  ENABLE   (HIGH = enabled, LOW = disabled)
    4.7k from ENABLE to GND (optional): keeps the output off until the sketch runs.
    GND          --  pins 1/7/8/14   (common ground)
    VS_INPUT1/2 and NRST left unconnected (internal pull-ups -> 5V default at boot).
    VIN (pins 10/11): 4.5-28V supply, must be > VOUT + 1.5V.
    VOUT (pins 2/3): test load, 220uF + 0.1uF to GND as in the datasheet.
    The NodeMCU is powered from USB, NOT from the module's VOUT.

  Serial Monitor: 115200 baud, line ending "Newline" or "Both NL & CR".
  Type 'h' for the command menu.
*/

#include <SoftwareSerial.h>

// ---------- Pins ----------
const uint8_t PIN_MOD_RX = D5;  // <- RYP2850 TXD
const uint8_t PIN_MOD_TX = D6;  // -> RYP2850 RXD
const uint8_t PIN_ENABLE = D1;  // -> RYP2850 ENABLE

// ---------- Config ----------
const uint32_t USB_BAUD = 115200;
const uint32_t MODULE_BAUD = 9600;
const uint32_t AT_TIMEOUT_MS = 1000;
const uint32_t RESET_TIMEOUT_MS = 3000;
const uint32_t BOOT_WAIT_MS = 2500;
const uint32_t TOUR_STEP_PAUSE_MS = 4000;  // pause between tour steps
const uint32_t TOUR_HOLD_MS = 6000;        // how long each tour state is observed
const uint32_t ENABLE_REARM_MS = 50;       // ENABLE release time before re-disabling
const uint32_t DISABLE_GRACE_MS = 1500;    // telemetry still in flight after disabling

const float TEMP_WARN_C = 85.0;         // shield-can temperature warning
const float TEMP_CLEAR_C = 80.0;        // hysteresis
const float VIN_HEADROOM_V = 1.5;       // datasheet: VIN > VOUT + 1.5V
const float NOMINAL_EFFICIENCY = 0.91;  // datasheet typical @12V in, 5V/3A out
const float VOUT_TOLERANCE_PCT = 3.0;   // datasheet VOUT measurement accuracy

// Output derating curve, datasheet p.7 (25C ambient, no heatsink)
const float DERATE_VIN[] = { 10.0, 14.0, 18.0, 20.0, 24.0, 26.0, 28.0 };
const float DERATE_IOUT[] = { 3.5, 3.5, 3.0, 3.0, 2.5, 2.5, 2.0 };
const uint8_t DERATE_POINTS = sizeof(DERATE_VIN) / sizeof(DERATE_VIN[0]);

SoftwareSerial modSerial;

// ---------- Telemetry ----------
struct Telemetry {
  uint8_t rateHz;
  float vin, vout, iout, temp;
  uint32_t lastMs;
  uint32_t count;
  bool valid;
};
Telemetry telem = {};

struct Stat {
  float min, max, sum;
  uint32_t n;
};
Stat statVin, statVout, statIout, statPout, statTemp;

// ---------- State ----------
bool liveView = true;
bool passthrough = false;
bool outputEnabled = true;
uint32_t countAtDisable = 0;  // telem.count when ENABLE was last pulled low
uint32_t disabledAtMs = 0;    // millis() when ENABLE was last pulled low
bool moduleReady = false;
bool hiTempSeen = false;
bool tempWarned = false;
bool deratingWarned = false;
bool headroomWarned = false;
bool streamLostWarned = false;
uint8_t voutSetting = 50;  // 50 = 5V, 33 = 3.3V, 30 = 3V
uint8_t rateSetting = 1;   // Hz

// Pending AT command (only one in flight at a time)
const char* pendingExpect = nullptr;
char pendingReply[64];
bool replyReceived = false;
int errorCode = 0;

char modLine[128];
size_t modLen = 0;
char usbLine[64];
size_t usbLen = 0;

// =====================================================================
// Helpers
// =====================================================================

bool startsWith(const char* s, const char* prefix) {
  return strncmp(s, prefix, strlen(prefix)) == 0;
}

// Matches s against a '|' separated list of prefixes, e.g. "+VOUT=|+OK".
bool matchExpect(const char* s, const char* expect) {
  while (*expect) {
    const char* bar = strchr(expect, '|');
    size_t len = bar ? (size_t)(bar - expect) : strlen(expect);
    if (strncmp(s, expect, len) == 0) return true;
    if (!bar) break;
    expect = bar + 1;
  }
  return false;
}

// Copies src to dst without spaces (datasheet shows "+ UPDATERATE=10").
void stripSpaces(const char* src, char* dst, size_t dstLen) {
  size_t j = 0;
  for (size_t i = 0; src[i] && j < dstLen - 1; i++) {
    if (src[i] != ' ') dst[j++] = src[i];
  }
  dst[j] = '\0';
}

float voutSettingVolts() {
  return voutSetting / 10.0;
}

float deratingLimit(float vin) {
  if (vin <= DERATE_VIN[0]) return DERATE_IOUT[0];
  for (uint8_t i = 1; i < DERATE_POINTS; i++) {
    if (vin <= DERATE_VIN[i]) {
      float t = (vin - DERATE_VIN[i - 1]) / (DERATE_VIN[i] - DERATE_VIN[i - 1]);
      return DERATE_IOUT[i - 1] + t * (DERATE_IOUT[i] - DERATE_IOUT[i - 1]);
    }
  }
  return DERATE_IOUT[DERATE_POINTS - 1];
}

void statReset(Stat& s) {
  s.min = 1e9;
  s.max = -1e9;
  s.sum = 0;
  s.n = 0;
}

void statAdd(Stat& s, float v) {
  if (v < s.min) s.min = v;
  if (v > s.max) s.max = v;
  s.sum += v;
  s.n++;
}

void resetStats() {
  statReset(statVin);
  statReset(statVout);
  statReset(statIout);
  statReset(statPout);
  statReset(statTemp);
}

void printError(int code) {
  Serial.printf("!! +ERR=%d: ", code);
  switch (code) {
    case 1: Serial.println(F("command not terminated with CR/LF")); break;
    case 2: Serial.println(F("command does not start with \"AT\"")); break;
    case 4: Serial.println(F("unknown command or given in incorrect state")); break;
    default: Serial.println(F("undocumented error code")); break;
  }
}

// =====================================================================
// Module line handling
// =====================================================================

bool isTelemetryLine(const char* line) {
  return isdigit((unsigned char)line[0]) && strstr(line, "Hz,") != nullptr;
}

void printTelemetryRow() {
  float pout = telem.vout * telem.iout;
  Serial.printf("%2uHz  VIN %6.3fV  VOUT %5.3fV  IOUT %5.2fA  POUT %5.2fW  PIN~%5.2fW  TEMP %6.2fC\n",
                telem.rateHz, telem.vin, telem.vout, telem.iout, pout,
                pout / NOMINAL_EFFICIENCY, telem.temp);
}

void checkHealth() {
  if (telem.temp >= TEMP_WARN_C && !tempWarned) {
    Serial.printf("!! High temperature: %.1fC (warn at %.0fC). PMIC core runs hotter than this reading.\n",
                  telem.temp, TEMP_WARN_C);
    tempWarned = true;
  } else if (telem.temp < TEMP_CLEAR_C) {
    tempWarned = false;
  }

  float limit = deratingLimit(telem.vin);
  if (telem.iout > 0.9 * limit && !deratingWarned) {
    Serial.printf("!! IOUT %.2fA is near the %.2fA derating limit at VIN %.1fV (no heatsink).\n",
                  telem.iout, limit, telem.vin);
    deratingWarned = true;
  } else if (telem.iout < 0.8 * limit) {
    deratingWarned = false;
  }

  float minVin = voutSettingVolts() + VIN_HEADROOM_V;
  if (telem.vin < minVin && !headroomWarned) {
    Serial.printf("!! VIN %.2fV is below VOUT + %.1fV (%.1fV); regulation may fail.\n",
                  telem.vin, VIN_HEADROOM_V, minVin);
    headroomWarned = true;
  } else if (telem.vin >= minVin) {
    headroomWarned = false;
  }
}

void parseTelemetry(char* line) {
  char raw[128];
  strlcpy(raw, line, sizeof(raw));

  uint8_t found = 0;
  float vin = 0, vout = 0, iout = 0, temp = 0;
  uint8_t rate = atoi(line);

  for (char* tok = strtok(line, ","); tok; tok = strtok(nullptr, ",")) {
    while (*tok == ' ') tok++;
    if (startsWith(tok, "VIN=")) { vin = atof(tok + 4); found |= 1; }
    else if (startsWith(tok, "VOUT=")) { vout = atof(tok + 5); found |= 2; }
    else if (startsWith(tok, "IOUT=")) { iout = atof(tok + 5); found |= 4; }
    else if (startsWith(tok, "TEMP=")) { temp = atof(tok + 5); found |= 8; }
  }

  if (found != 0x0F) {
    Serial.print(F("[RYP] malformed telemetry: "));
    Serial.println(raw);
    return;
  }

  telem.rateHz = rate;
  telem.vin = vin;
  telem.vout = vout;
  telem.iout = iout;
  telem.temp = temp;
  telem.lastMs = millis();
  telem.count++;
  telem.valid = true;
  // A line already being sent when ENABLE went low doesn't mean the output is on.
  if (!outputEnabled && millis() - disabledAtMs < DISABLE_GRACE_MS) countAtDisable = telem.count;
  streamLostWarned = false;

  statAdd(statVin, vin);
  statAdd(statVout, vout);
  statAdd(statIout, iout);
  statAdd(statPout, vout * iout);
  statAdd(statTemp, temp);

  checkHealth();
  if (liveView && !passthrough) printTelemetryRow();
}

void handleLine(char* line) {
  if (passthrough) {
    Serial.print(F("< "));
    Serial.println(line);
  }

  if (isTelemetryLine(line)) {
    parseTelemetry(line);
    return;
  }

  if (line[0] == '+') {
    char norm[64];
    stripSpaces(line, norm, sizeof(norm));

    if (startsWith(norm, "+HITEMP")) {
      hiTempSeen = true;
      Serial.println(F("!! +HITEMP: module exceeded max operating temperature, protection active"));
      return;
    }
    if (startsWith(norm, "+ERR=")) {
      int code = atoi(norm + 5);
      if (!passthrough) printError(code);
      if (pendingExpect) errorCode = code ? code : -1;
      return;
    }
    if (startsWith(norm, "+READY")) {
      moduleReady = true;
      if (!passthrough) Serial.println(F("[RYP] +READY"));
      return;
    }
    if (pendingExpect && !replyReceived && matchExpect(norm, pendingExpect)) {
      strlcpy(pendingReply, norm, sizeof(pendingReply));
      replyReceived = true;
      return;
    }
  }

  if (!passthrough) {
    Serial.print(F("[RYP] "));
    Serial.println(line);
  }
}

void pumpModule() {
  while (modSerial.available()) {
    char c = modSerial.read();
    if (c == '\r') continue;
    if (c == '\n') {
      modLine[modLen] = '\0';
      if (modLen) handleLine(modLine);
      modLen = 0;
      continue;
    }
    if (modLen < sizeof(modLine) - 1) modLine[modLen++] = c;
  }
}

// =====================================================================
// AT commands
// =====================================================================

// Sends cmd + CRLF and waits for a reply matching one of the '|' separated
// prefixes in expect. Telemetry arriving meanwhile is still parsed.
bool sendAT(const char* cmd, const char* expect, uint32_t timeoutMs = AT_TIMEOUT_MS) {
  pendingExpect = expect;
  replyReceived = false;
  errorCode = 0;
  pendingReply[0] = '\0';

  Serial.print(F("> "));
  Serial.println(cmd);
  modSerial.print(cmd);
  modSerial.print("\r\n");

  uint32_t start = millis();
  while (millis() - start < timeoutMs) {
    pumpModule();
    if (replyReceived) {
      pendingExpect = nullptr;
      Serial.print(F("< "));
      Serial.println(pendingReply);
      return true;
    }
    if (errorCode) {
      pendingExpect = nullptr;
      return false;
    }
    yield();
  }
  pendingExpect = nullptr;
  Serial.println(F("!! No reply (timeout)"));
  return false;
}

bool atTest() {
  return sendAT("AT", "+OK");
}

bool getUID() {
  return sendAT("AT+UID?", "+UID=");
}

bool getVout() {
  if (!sendAT("AT+VOUT?", "+VOUT=")) return false;
  voutSetting = atoi(pendingReply + 6);
  return true;
}

bool setVout(uint8_t code) {
  if (code != 50 && code != 33 && code != 30) {
    Serial.println(F("!! VOUT must be 50, 33 or 30"));
    return false;
  }
  float target = code / 10.0;
  if (telem.valid && telem.vin < target + VIN_HEADROOM_V) {
    Serial.printf("!! Warning: last VIN %.2fV < %.1fV needed for %.1fV out\n",
                  telem.vin, target + VIN_HEADROOM_V, target);
  }
  char cmd[16];
  snprintf(cmd, sizeof(cmd), "AT+VOUT=%u", code);
  // Datasheet shows "+VOUT=<v>" for this command; accept "+OK" too.
  if (!sendAT(cmd, "+VOUT=|+OK")) return false;
  voutSetting = code;
  headroomWarned = false;
  if (!outputEnabled) {
    // AT+VOUT turns the output on even while ENABLE is held low.
    Serial.println(F("VOUT changed while output was OFF; re-applying disable"));
    rearmDisable();
  }
  return true;
}

bool getRate() {
  if (!sendAT("AT+UPDATERATE?", "+UPDATERATE=")) return false;
  rateSetting = atoi(pendingReply + 12);
  return true;
}

bool setRate(int hz) {
  if (hz < 1 || hz > 10) {
    Serial.println(F("!! Update rate must be 1..10 Hz"));
    return false;
  }
  char cmd[24];
  snprintf(cmd, sizeof(cmd), "AT+UPDATERATE=%d", hz);
  if (!sendAT(cmd, "+OK|+UPDATERATE=")) return false;
  rateSetting = hz;
  streamLostWarned = false;
  return true;
}

bool atReset() {
  moduleReady = false;
  if (!sendAT("AT+RESET", "+RESET", RESET_TIMEOUT_MS)) return false;

  uint32_t start = millis();
  while (!moduleReady && millis() - start < RESET_TIMEOUT_MS) {
    pumpModule();
    yield();
  }
  telem.lastMs = millis();
  streamLostWarned = false;
  if (!moduleReady) {
    Serial.println(F("!! +READY not received after reset"));
    return false;
  }
  return true;
}

// ENABLE is driven push-pull: HIGH (3.3V) to enable, LOW to disable. Driving it
// HIGH (instead of releasing to the module's weak pull-up) lets an external
// pull-down on ENABLE keep the output off while the ESP8266 boots.
// The module acts on a falling edge on ENABLE, not on the level. If something
// turned the output back on while the pin was already low (e.g. AT+VOUT),
// pulse the pin HIGH briefly so pulling it low again produces a new edge.
void rearmDisable() {
  digitalWrite(PIN_ENABLE, HIGH);
  delay(ENABLE_REARM_MS);
  digitalWrite(PIN_ENABLE, LOW);
  countAtDisable = telem.count;
  disabledAtMs = millis();
}

void hwEnable(bool on) {
  if (on) {
    digitalWrite(PIN_ENABLE, HIGH);
    pinMode(PIN_ENABLE, OUTPUT);
  } else if (!outputEnabled && telem.count != countAtDisable) {
    // Telemetry kept arriving although ENABLE is low, so the output is on.
    Serial.println(F("Output came back on while ENABLE was low; re-arming ENABLE"));
    rearmDisable();
  } else {
    digitalWrite(PIN_ENABLE, LOW);
    pinMode(PIN_ENABLE, OUTPUT);
    countAtDisable = telem.count;
    disabledAtMs = millis();
  }
  outputEnabled = on;
  streamLostWarned = false;
  telem.lastMs = millis();
  Serial.println(on ? F("ENABLE pin driven HIGH: output ON")
                    : F("ENABLE pin driven LOW: output OFF"));
}

// =====================================================================
// Monitoring
// =====================================================================

void checkStreamLost() {
  if (!outputEnabled || !telem.valid || streamLostWarned || passthrough) return;
  uint8_t hz = telem.rateHz ? telem.rateHz : rateSetting;
  if (hz == 0) hz = 1;
  uint32_t timeout = 3000 / hz + 500;
  if (millis() - telem.lastMs > timeout) {
    Serial.printf("!! Telemetry stream lost (nothing for %lu ms)\n", (unsigned long)(millis() - telem.lastMs));
    streamLostWarned = true;
  }
}

// =====================================================================
// User interface
// =====================================================================

void printMenu() {
  Serial.println();
  Serial.println(F("===== RYP2850 Demo - commands ====="));
  Serial.println(F(" h      this menu"));
  Serial.println(F(" t      AT test"));
  Serial.println(F(" i      module info (UID, VOUT setting, update rate)"));
  Serial.println(F(" 5      set VOUT = 5V    (AT+VOUT=50)"));
  Serial.println(F(" 3      set VOUT = 3.3V  (AT+VOUT=33)"));
  Serial.println(F(" 0      set VOUT = 3V    (AT+VOUT=30)"));
  Serial.println(F(" r<n>   set update rate 1..10 Hz, e.g. r10 (r alone = query)"));
  Serial.println(F(" e / d  ENABLE / DISABLE output via ENABLE pin (D1)"));
  Serial.println(F(" s      software reset (AT+RESET)"));
  Serial.println(F(" l      toggle live telemetry view"));
  Serial.println(F(" m      min/avg/max statistics"));
  Serial.println(F(" c      clear statistics"));
  Serial.println(F(" a      raw AT passthrough mode ('~' to exit)"));
  Serial.println(F(" x      run feature showcase tour (any key aborts)"));
  Serial.println(F("==================================="));
  Serial.println(F(" Made by Taste The Code"));
  Serial.println(F(" Enjoying this demo? Subscribe for more projects:"));
  Serial.println(F(" https://www.youtube.com/@TasteTheCode"));
  Serial.println(F("==================================="));
}

void printStatLine(const char* name, const Stat& s, const char* unit) {
  if (s.n == 0) {
    Serial.printf("  %-5s  no data\n", name);
    return;
  }
  Serial.printf("  %-5s  min %7.3f%s  avg %7.3f%s  max %7.3f%s\n",
                name, s.min, unit, s.sum / s.n, unit, s.max, unit);
}

void printStats() {
  Serial.printf("Statistics over %lu samples:\n", (unsigned long)statVin.n);
  printStatLine("VIN", statVin, "V");
  printStatLine("VOUT", statVout, "V");
  printStatLine("IOUT", statIout, "A");
  printStatLine("POUT", statPout, "W");
  printStatLine("TEMP", statTemp, "C");
}

void showInfo() {
  getUID();
  getVout();
  getRate();
  Serial.printf("VOUT setting: %.1fV | update rate: %u Hz | output: %s | +HITEMP seen: %s\n",
                voutSettingVolts(), rateSetting, outputEnabled ? "ON" : "OFF",
                hiTempSeen ? "yes" : "no");
  if (telem.valid) {
    Serial.printf("Last reading (%lu ms ago): ", (unsigned long)(millis() - telem.lastMs));
    printTelemetryRow();
  } else {
    Serial.println(F("No telemetry received yet."));
  }
}

void handleUsbLine(char* line) {
  while (*line == ' ') line++;
  if (!*line) return;

  if (passthrough) {
    if (strcmp(line, "~") == 0) {
      passthrough = false;
      Serial.println(F("-- passthrough OFF --"));
      return;
    }
    Serial.print(F("> "));
    Serial.println(line);
    modSerial.print(line);
    modSerial.print("\r\n");
    return;
  }

  switch (tolower(line[0])) {
    case 'h': case '?': printMenu(); break;
    case 't': atTest(); break;
    case 'i': showInfo(); break;
    case '5': setVout(50); break;
    case '3': setVout(33); break;
    case '0': setVout(30); break;
    case 'r':
      if (line[1]) setRate(atoi(line + 1));
      else getRate();
      break;
    case 'e': hwEnable(true); break;
    case 'd': hwEnable(false); break;
    case 's': atReset(); break;
    case 'l':
      liveView = !liveView;
      Serial.println(liveView ? F("Live view ON") : F("Live view OFF"));
      break;
    case 'm': printStats(); break;
    case 'c':
      resetStats();
      Serial.println(F("Statistics cleared"));
      break;
    case 'a':
      passthrough = true;
      Serial.println(F("-- passthrough ON: lines go to module with CRLF, '~' exits --"));
      break;
    case 'x': runTour(); break;
    default:
      Serial.print(F("Unknown command: "));
      Serial.println(line);
      Serial.println(F("Type 'h' for help."));
      break;
  }
}

void pumpUsb() {
  while (Serial.available()) {
    char c = Serial.read();
    if (c == '\r' || c == '\n') {
      if (usbLen) {
        usbLine[usbLen] = '\0';
        usbLen = 0;
        handleUsbLine(usbLine);
      }
      continue;
    }
    if (usbLen < sizeof(usbLine) - 1) usbLine[usbLen++] = c;
  }
}

// =====================================================================
// Feature showcase tour
// =====================================================================

// Pumps the module for ms milliseconds. Returns false if the user pressed a key.
bool watch(uint32_t ms) {
  uint32_t start = millis();
  while (millis() - start < ms) {
    pumpModule();
    if (Serial.available()) {
      while (Serial.available()) Serial.read();
      return false;
    }
    yield();
  }
  return true;
}

// Pauses before every step but the first so results can be read.
// Returns false if the user aborted during the pause.
bool tourStep(uint8_t n, const __FlashStringHelper* title) {
  if (n > 1) {
    Serial.printf("    (next step in %lu s...)\n", (unsigned long)(TOUR_STEP_PAUSE_MS / 1000));
    if (!watch(TOUR_STEP_PAUSE_MS)) return false;
  }
  Serial.println();
  Serial.printf("--- Step %u: ", n);
  Serial.println(title);
  return true;
}

bool reportVout(uint8_t code) {
  float target = code / 10.0;
  if (!telem.valid || millis() - telem.lastMs > 2500) {
    Serial.println(F("    No fresh telemetry to verify VOUT"));
    return false;
  }
  float errPct = (telem.vout - target) / target * 100.0;
  bool pass = fabs(errPct) <= VOUT_TOLERANCE_PCT;
  Serial.printf("    Set %.1fV, measured %.3fV, error %+.2f%% -> %s (spec +/-%.0f%%)\n",
                target, telem.vout, errPct, pass ? "PASS" : "CHECK", VOUT_TOLERANCE_PCT);
  return pass;
}

void runTour() {
  static const uint8_t codes[] = { 50, 33, 30 };
  bool savedLive = liveView;
  liveView = true;
  uint8_t passed = 0, total = 0;

  Serial.println();
  Serial.println(F("========== RYP2850 FEATURE TOUR (press any key to abort) =========="));

  bool savedEnabled = outputEnabled;
  if (!outputEnabled) {
    hwEnable(true);
    if (!watch(3000)) goto aborted;
  }

  if (!tourStep(1, F("AT command interface"))) goto aborted;
  total++; if (atTest()) passed++;

  if (!tourStep(2, F("Unique 24-byte module ID"))) goto aborted;
  total++; if (getUID()) passed++;

  if (!tourStep(3, F("Current settings"))) goto aborted;
  total++; if (getVout() && getRate()) passed++;
  Serial.printf("    VOUT setting %.1fV, update rate %u Hz\n", voutSettingVolts(), rateSetting);

  if (!tourStep(4, F("Output voltage sweep 5V -> 3.3V -> 3V (AT+VOUT)"))) goto aborted;
  for (uint8_t code : codes) {
    total++;
    if (!setVout(code)) continue;
    Serial.printf("    Holding %.1fV for %lu s...\n", code / 10.0, (unsigned long)(TOUR_HOLD_MS / 1000));
    if (!watch(TOUR_HOLD_MS)) goto aborted;
    if (reportVout(code)) passed++;
  }

  if (!tourStep(5, F("Telemetry update rate 10 Hz (AT+UPDATERATE)"))) goto aborted;
  total++;
  if (setRate(10)) {
    uint32_t c0 = telem.count;
    Serial.println(F("    Watching for 3 s..."));
    if (!watch(3000)) goto aborted;
    float measured = (telem.count - c0) / 3.0;
    Serial.printf("    Received %.1f lines/s (expected ~10)\n", measured);
    if (measured >= 7.0) passed++;
  }
  setRate(1);
  if (!watch(TOUR_STEP_PAUSE_MS)) goto aborted;

  if (!tourStep(6, F("Hardware ENABLE pin: disable then re-enable output"))) goto aborted;
  total++;
  {
    hwEnable(false);
    Serial.printf("    Output OFF for %lu s...\n", (unsigned long)(TOUR_HOLD_MS / 1000));
    if (!watch(TOUR_HOLD_MS)) { hwEnable(true); goto aborted; }
    // countAtDisable absorbs a line that was already in flight when ENABLE went low.
    uint32_t during = telem.count - countAtDisable;
    Serial.printf("    Telemetry lines while disabled: %lu (expected 0)\n", (unsigned long)during);
    hwEnable(true);
    uint32_t c0 = telem.count;
    Serial.printf("    Output ON, watching for %lu s...\n", (unsigned long)(TOUR_HOLD_MS / 1000));
    if (!watch(TOUR_HOLD_MS)) goto aborted;
    uint32_t after = telem.count - c0;
    Serial.printf("    Telemetry lines after re-enable: %lu\n", (unsigned long)after);
    if (during == 0 && after > 0) passed++;
  }

  if (!tourStep(7, F("Restore 5V and software reset (AT+RESET)"))) goto aborted;
  total++;
  setVout(50);
  if (atReset()) {
    if (!watch(3000)) goto aborted;
    if (getVout()) {
      Serial.printf("    VOUT setting after reset: %.1fV\n", voutSettingVolts());
      passed++;
    }
  }

  Serial.println();
  Serial.printf("========== TOUR COMPLETE: %u/%u checks passed ==========\n", passed, total);
  printStats();
  liveView = savedLive;
  if (!savedEnabled) hwEnable(false);
  return;

aborted:
  Serial.println(F("!! Tour aborted by user"));
  liveView = savedLive;
  if (!savedEnabled) hwEnable(false);
}

// =====================================================================
// Arduino entry points
// =====================================================================

void setup() {
  // Pull ENABLE low first thing so the output stays off by default.
  digitalWrite(PIN_ENABLE, LOW);
  pinMode(PIN_ENABLE, OUTPUT);

  Serial.begin(USB_BAUD);
  delay(200);
  Serial.println();
  Serial.println(F("RYP2850 Intelligent Power Module demo (NodeMCU / ESP8266)"));
  Serial.println(F("by Taste The Code - https://www.youtube.com/@TasteTheCode"));

  hwEnable(false);
  Serial.println(F("Output is OFF by default. Type 'e' to enable it."));
  modSerial.begin(MODULE_BAUD, SWSERIAL_8N1, PIN_MOD_RX, PIN_MOD_TX, false, 256);
  resetStats();

  Serial.println(F("Waiting for module..."));
  uint32_t start = millis();
  while (millis() - start < BOOT_WAIT_MS && !telem.valid && !moduleReady) {
    pumpModule();
    yield();
  }

  if (atTest()) {
    showInfo();
  } else {
    Serial.println(F("!! Module did not answer AT. Check wiring (D5<-TXD, D6->RXD, GND) and VIN."));
    Serial.println(F("   It may also stay silent while the output is disabled; try 'e' then 'i'."));
  }
  printMenu();
}

void loop() {
  pumpModule();
  pumpUsb();
  checkStreamLost();
}
