/*
 * Loop A v2 — "Say it, the machine obeys"
 * Robonamix Build-With-AI cohort · voice/text -> AI -> hardware
 * ---------------------------------------------------------------------
 * Six projects on ONE board (project list by Mousa). Every v1 command still
 * works unchanged, so a v1 wiring diagram still does something sensible.
 *
 * Board:  Arduino Uno over USB. The browser (Web Serial) sends ONE text
 *         command per line at 9600 baud; this sketch runs it and prints a
 *         short "OK ..." back so the screen can show what happened.
 *
 * Design rule, unchanged from v1 and still the whole curriculum:
 *   The AI NEVER drives the board directly. It only TRANSLATES a kid's
 *   sentence into one of the commands below. The firmware stays dumb,
 *   deterministic and robust.
 *
 * ZERO EXTERNAL LIBRARIES. Servo.h ships with the IDE; the ultrasonic uses
 * pulseIn(), tone() is core, and the DHT11 is bit-banged below. That costs
 * ~40 lines of protocol and buys "nothing to install" — a library prompt in
 * the Arduino IDE is a station that stops in front of a paying parent.
 *
 * ---- PINS ----------------------------------------------------------------
 *   D2   IR obstacle sensor        (digital in, most modules are active LOW)
 *   D3   HC-SR04 TRIG              (digital out)
 *   D4   HC-SR04 ECHO              (digital in)
 *   D5   L298N ENA                 (PWM  - fan speed)
 *   D6   LED                       (PWM  - brightness)
 *   D7   piezo buzzer, 2-pin       (tone - notes)
 *   D8   spare LED                 (plain on/off, keeps v1 wiring alive)
 *   D9   servo signal              (red->5V, brown->GND)
 *   D10  L298N IN1                 (digital out)
 *   D11  L298N IN2                 (digital out)
 *   D12  DHT11 data                (one-wire; the module has its pull-up.
 *                                   KY-015 board: S -> D12, middle -> 5V, - -> GND)
 *   A0   LDR divider               (light)
 *   A1   MQ-2 analog out           (gas)
 *   D13  built-in LED, mirrors D6
 *   A4   screen SDA  \  optional 0.91" SSD1306 OLED, I2C 0x3C: it shows
 *   A5   screen SCL  /  the board's last answer. Absent = nothing changes.
 *
 * ---- WHY THOSE PINS (this is the part that bites) ------------------------
 * The Uno has three timers and each owns a different job:
 *   Servo library -> Timer1 -> analogWrite() DEAD on D9, D10
 *   tone()        -> Timer2 -> analogWrite() DEAD on D3, D11
 * So Timer0's D5 and D6 are the ONLY PWM pins safe beside both a servo and a
 * piezo, which is why the motor is on D5 and the dimmable LED on D6.
 * D10, D11 and D3 are spent on plain digital work precisely BECAUSE their PWM
 * was already gone - nothing still usable was given up.
 *
 * Get this wrong and the servo jitters the moment the piano plays a note.
 *
 * ---- MOTOR: the L298N has three traps ------------------------------------
 *  1. Its GND must join the Arduino GND as well as the battery -, or the
 *     logic pins float and the motor behaves at random.
 *  2. The bridge drops ~2 V. Feed VS about 7 V for a 5 V motor; feeding 5 V
 *     leaves the motor on ~3 V and it may never start.
 *  3. REMOVE THE ENA JUMPER. Left on, it ties ENA high and the motor only
 *     ever runs flat out, silently ignoring every FAN <n>.
 *
 * ---- COMMANDS (case-insensitive, one per line) ---------------------------
 *  v1, unchanged:
 *   SERVO <0-180>   SWEEP   LED ON | LED OFF   BLINK <n>
 *   MORSE <text>    BEEP <n>   READ   PING
 *
 *  1 Night light:   DIM <0-100>   LIGHT   DARK
 *  2 Distance:      DIST          WATCH <cm>
 *  3 Car barrier:   BARRIER OPEN | CLOSE | HALF    HOLD <s>   IR
 *  4 Weather:       TEMP   SHOW <text>   (and FAN <0-100> / FAN OFF, for a
 *                   rig with the motor; the kits ship the screen instead)
 *  Bonus radar:     RADAR <maxCm> <stepDeg> <nearCm>   STOP
 *                   (sent by /studio/radar's Start button, never by the AI)
 *  5 Gas detector:  GAS           SAFE
 *  6 Piano:         NOTE <A-G[#][octave]>   PLAY <notes>   TUNE <name>
 */

#include <Servo.h>
#include <Wire.h>

const int PIN_IR    = 2;
const int PIN_TRIG  = 3;
const int PIN_ECHO  = 4;
const int PIN_ENA   = 5;
const int PIN_LED   = 6;   // PWM — dimmable
const int PIN_BUZZ  = 7;   // piezo, tone()
const int PIN_LED2  = 8;   // v1's LED pin, kept as a plain mirror
const int PIN_SERVO = 9;
const int PIN_IN1   = 10;
const int PIN_IN2   = 11;
const int PIN_DHT   = 12;
const int PIN_LIGHT = A0;
const int PIN_GAS   = A1;

// Morse timing (ms)
const int DOT = 200;
const int DASH = DOT * 3;
const int GAP_SYMBOL = DOT;
const int GAP_LETTER = DOT * 3;
const int GAP_WORD   = DOT * 7;

// Thresholds. Deliberately named and in one place: a child asking "is it dark?"
// is really asking "where did you draw the line?", which is a better question
// than the answer and is worth being able to point at.
const int DARK_BELOW  = 300;   // A0 reading under this = dark
const int GAS_UNSAFE  = 400;   // A1 reading over this  = not safe

// What readTempC() returns when there is no temperature (see the DHT11 notes).
const int TEMP_NO_REPLY = -127;   // the sensor never answered: check the wiring
const int TEMP_BAD_READ = -126;   // it answered, but the reading didn't add up
unsigned long dhtLastRead = 0;    // set in setup(), so TEMP waits for power-up

Servo servo;
String buf = "";

// The built-in radar (see radarStart): on/off, where the arm is, its settings.
bool radarOn = false;
int radarAngle = 0, radarDir = 1, radarStepDeg = 3, radarNear = 0, radarMax = 50;
unsigned long radarNextStep = 0;
int ledLevel = 0;              // 0-255, remembered so DIM and LED agree

/* ---- the screen (optional) -----------------------------------------------
 * A 0.91" SSD1306 OLED on I2C shows the board's last answer, big, so a kit
 * with a screen reads the temperature or the gas level off the board itself:
 * "OK TEMP 27" shows as TEMP over 27 C. Every reply goes through say(), which
 * prints it for the browser first and draws it second.
 *
 * No screen, no change. setup() asks the bus once; when nothing answers,
 * say() is only Serial.println, and the wire timeout means a bad screen
 * connection can slow a reply but never freeze the board.
 *
 * Driven straight through Wire, which ships with the board package exactly
 * like Servo, so the zero-install rule above still holds. Text only, drawn
 * page by page: no 512-byte frame buffer on a chip with 2 KB of RAM.
 */
const uint8_t OLED_ADDR  = 0x3C;
const uint8_t OLED_ROWS  = 32;          // 0.91" screen. A 0.96" screen is 64.
const uint8_t OLED_PAGES = OLED_ROWS / 8;
bool oledOk = false;

// 5x7 letters, ' ' (32) to 'Z' (90). Lower case is drawn as upper case.
const uint8_t FONT[][5] PROGMEM = {
  {0x00,0x00,0x00,0x00,0x00}, {0x00,0x00,0x5F,0x00,0x00}, {0x00,0x07,0x00,0x07,0x00}, {0x14,0x7F,0x14,0x7F,0x14}, // space ! " #
  {0x24,0x2A,0x7F,0x2A,0x12}, {0x23,0x13,0x08,0x64,0x62}, {0x36,0x49,0x55,0x22,0x50}, {0x00,0x05,0x03,0x00,0x00}, // $ % & '
  {0x00,0x1C,0x22,0x41,0x00}, {0x00,0x41,0x22,0x1C,0x00}, {0x08,0x2A,0x1C,0x2A,0x08}, {0x08,0x08,0x3E,0x08,0x08}, // ( ) * +
  {0x00,0x50,0x30,0x00,0x00}, {0x08,0x08,0x08,0x08,0x08}, {0x00,0x60,0x60,0x00,0x00}, {0x20,0x10,0x08,0x04,0x02}, // , - . /
  {0x3E,0x51,0x49,0x45,0x3E}, {0x00,0x42,0x7F,0x40,0x00}, {0x42,0x61,0x51,0x49,0x46}, {0x21,0x41,0x45,0x4B,0x31}, // 0 1 2 3
  {0x18,0x14,0x12,0x7F,0x10}, {0x27,0x45,0x45,0x45,0x39}, {0x3C,0x4A,0x49,0x49,0x30}, {0x01,0x71,0x09,0x05,0x03}, // 4 5 6 7
  {0x36,0x49,0x49,0x49,0x36}, {0x06,0x49,0x49,0x29,0x1E}, {0x00,0x36,0x36,0x00,0x00}, {0x00,0x56,0x36,0x00,0x00}, // 8 9 : ;
  {0x08,0x14,0x22,0x41,0x00}, {0x14,0x14,0x14,0x14,0x14}, {0x00,0x41,0x22,0x14,0x08}, {0x02,0x01,0x51,0x09,0x06}, // < = > ?
  {0x32,0x49,0x79,0x41,0x3E}, {0x7E,0x11,0x11,0x11,0x7E}, {0x7F,0x49,0x49,0x49,0x36}, {0x3E,0x41,0x41,0x41,0x22}, // @ A B C
  {0x7F,0x41,0x41,0x22,0x1C}, {0x7F,0x49,0x49,0x49,0x41}, {0x7F,0x09,0x09,0x09,0x01}, {0x3E,0x41,0x49,0x49,0x7A}, // D E F G
  {0x7F,0x08,0x08,0x08,0x7F}, {0x00,0x41,0x7F,0x41,0x00}, {0x20,0x40,0x41,0x3F,0x01}, {0x7F,0x08,0x14,0x22,0x41}, // H I J K
  {0x7F,0x40,0x40,0x40,0x40}, {0x7F,0x02,0x0C,0x02,0x7F}, {0x7F,0x04,0x08,0x10,0x7F}, {0x3E,0x41,0x41,0x41,0x3E}, // L M N O
  {0x7F,0x09,0x09,0x09,0x06}, {0x3E,0x41,0x51,0x21,0x5E}, {0x7F,0x09,0x19,0x29,0x46}, {0x46,0x49,0x49,0x49,0x31}, // P Q R S
  {0x01,0x01,0x7F,0x01,0x01}, {0x3F,0x40,0x40,0x40,0x3F}, {0x1F,0x20,0x40,0x20,0x1F}, {0x3F,0x40,0x38,0x40,0x3F}, // T U V W
  {0x63,0x14,0x08,0x14,0x63}, {0x07,0x08,0x70,0x08,0x07}, {0x61,0x51,0x49,0x45,0x43}                               // X Y Z
};

void oledCmd(uint8_t c) {
  Wire.beginTransmission(OLED_ADDR);
  Wire.write(0x00);
  Wire.write(c);
  Wire.endTransmission();
}

void oledBegin() {
  Wire.begin();
  Wire.setWireTimeout(3000, true);   // microseconds; resets the bus if it sticks
  Wire.setClock(400000);
  Wire.beginTransmission(OLED_ADDR);
  oledOk = (Wire.endTransmission() == 0);
  if (!oledOk) return;
  const uint8_t init[] = {
    0xAE,                                 // off while we set it up
    0xD5, 0x80, 0xA8, (uint8_t)(OLED_ROWS - 1), 0xD3, 0x00, 0x40,
    0x8D, 0x14,                           // charge pump on
    0x20, 0x00,                           // horizontal addressing
    0xA1, 0xC8,                           // the right way up
    0xDA, (uint8_t)(OLED_ROWS == 32 ? 0x02 : 0x12),
    0x81, 0x8F, 0xD9, 0xF1, 0xDB, 0x40, 0xA4, 0xA6,
    0xAF                                  // on
  };
  for (uint8_t i = 0; i < sizeof(init); i++) oledCmd(init[i]);
}

// One column of one letter. scale 2 doubles every dot both ways, so a letter
// spans two pages: `half` 0 is its top four rows, 1 its bottom four.
uint8_t oledColumn(const String& text, int len, int x0, uint8_t scale, uint8_t half, int x) {
  int cell = 6 * scale;
  int rel = x - x0;
  if (rel < 0 || rel >= len * cell) return 0;
  int col = (rel % cell) / scale;
  if (col >= 5) return 0;                       // the gap between letters
  char ch = toupper(text.charAt(rel / cell));
  if (ch < 32 || ch > 90) ch = '?';
  uint8_t bits = pgm_read_byte(&FONT[ch - 32][col]);
  if (scale == 1) return bits;
  uint8_t nib = half ? (bits >> 4) : (bits & 0x0F);
  uint8_t out = 0;
  for (uint8_t b = 0; b < 4; b++) if (nib & (1 << b)) out |= (3 << (2 * b));
  return out;
}

// One line of text, centred, written straight to `scale` pages of the screen.
void oledLine(uint8_t page, const String& text, uint8_t scale) {
  int len = min((int)text.length(), 128 / (6 * scale));
  int x0 = (128 - len * 6 * scale) / 2;
  for (uint8_t half = 0; half < scale; half++) {
    oledCmd(0x21); oledCmd(0);                oledCmd(127);
    oledCmd(0x22); oledCmd(page + half);      oledCmd(page + half);
    for (int x = 0; x < 128; x += 16) {
      Wire.beginTransmission(OLED_ADDR);
      Wire.write(0x40);
      for (int i = x; i < x + 16; i++) Wire.write(oledColumn(text, len, x0, scale, half, i));
      Wire.endTransmission();
    }
  }
}

// "OK TEMP 27" -> TEMP over 27 C. "ERR DIST no echo" -> ERR DIST over NO ECHO.
// The first word is the headline; the rest goes big if it fits, small if not.
void show(String reply) {
  reply.trim();
  if (reply.startsWith("OK ")) reply = reply.substring(3);
  int sp = reply.indexOf(' ');
  if (reply.startsWith("ERR ")) sp = reply.indexOf(' ', 4);
  String head = sp < 0 ? reply : reply.substring(0, sp);
  String rest = sp < 0 ? String("") : reply.substring(sp + 1);
  if (head == "TEMP" && rest.length() > 0 && isDigit(rest.charAt(rest.length() - 1))) rest += " C";

  if ((int)head.length() <= 10) oledLine(0, head, 2);
  else { oledLine(0, head, 1); oledLine(1, "", 1); }  // too long to go big
  uint8_t page = 2;
  if ((int)rest.length() <= 10) {
    oledLine(page, rest, 2);
    page += 2;
  } else {
    while (page < OLED_PAGES && rest.length() > 0) {
      int cut = min((int)rest.length(), 21);
      if (cut < (int)rest.length()) { int b = rest.lastIndexOf(' ', cut); if (b > 0) cut = b; }
      oledLine(page++, rest.substring(0, cut), 1);
      rest = rest.substring(cut);
      rest.trim();
    }
  }
  while (page < OLED_PAGES) oledLine(page++, "", 1);
}

// Every reply: to the browser first, then to the screen if there is one.
void say(const String& line) {
  Serial.println(line);
  if (oledOk) show(line);
}

// SHOW: the child's own words, as big as they fit. Up to 10 letters go on one
// big line in the middle; up to 20 go on two big lines, split at a space when
// there is one.
void showWords(String text) {
  if ((int)text.length() <= 10) {
    oledLine(0, "", 1);
    oledLine(1, text, 2);
    oledLine(3, "", 1);
    return;
  }
  int cut = text.lastIndexOf(' ', 10);
  if (cut <= 0 || (int)text.length() - cut - 1 > 10) cut = 10;
  String top = text.substring(0, cut);
  String bottom = text.substring(cut);
  top.trim();
  bottom.trim();
  oledLine(0, top, 2);
  oledLine(2, bottom, 2);
}

/* ---- setup / loop ------------------------------------------------------- */

void setup() {
  Serial.begin(9600);
  pinMode(PIN_LED, OUTPUT);
  pinMode(PIN_LED2, OUTPUT);
  pinMode(PIN_BUZZ, OUTPUT);
  pinMode(LED_BUILTIN, OUTPUT);
  pinMode(PIN_IR, INPUT);
  pinMode(PIN_TRIG, OUTPUT);
  pinMode(PIN_ECHO, INPUT);
  pinMode(PIN_ENA, OUTPUT);
  pinMode(PIN_IN1, OUTPUT);
  pinMode(PIN_IN2, OUTPUT);
  pinMode(PIN_DHT, INPUT_PULLUP);
  dhtLastRead = millis();          // a DHT11 needs a second after power-up
  fanOff();
  servo.attach(PIN_SERVO);
  servo.write(90);
  Serial.println("READY Robonamix Guardian demo v2");
  oledBegin();
  if (oledOk) show("ROBONAMIX READY");
}

void loop() {
  while (Serial.available()) {
    char c = Serial.read();
    if (c == '\n') { handle(buf); buf = ""; }
    else if (c != '\r') { buf += c; }
  }
  if (radarOn) radarTick();
}

/* ---- the radar, built in --------------------------------------------------
 * The kit's bonus used to need its own sketch (radar.ino) uploaded over this
 * one. Now /studio/radar's Start button sends
 *   RADAR <maxCm> <stepDeg> <nearCm>     e.g. RADAR 50 3 0
 * and the board sweeps the servo on D9 with the distance sensor on D3/D4,
 * printing the same lines radar.ino prints, one per step:
 *   R <angle> <cm>                       cm 0 = no echo
 * until STOP, or any other command, ends it. A step never blocks for long
 * (one echo, at most 30 ms), so commands still get through mid-sweep.
 * radar.ino stays, as the "write the rules yourself" level-up.
 */
void radarStart(const String& args) {
  int a = args.indexOf(' ');
  int b = a < 0 ? -1 : args.indexOf(' ', a + 1);
  int maxCm = args.length() ? args.toInt() : 50;
  int step = a < 0 ? 3 : args.substring(a + 1).toInt();
  int near = b < 0 ? 0 : args.substring(b + 1).toInt();
  radarMax = constrain(maxCm <= 0 ? 50 : maxCm, 5, 400);
  radarStepDeg = constrain(step <= 0 ? 3 : step, 1, 30);
  radarNear = constrain(near, 0, 400);
  radarAngle = 0;
  radarDir = 1;
  servo.write(0);
  radarNextStep = millis() + 400;   // let the arm get to 0 before the first look
  radarOn = true;
  say("OK RADAR " + String(radarMax));
}

void radarStop() {
  radarOn = false;
  digitalWrite(PIN_BUZZ, LOW);
}

void radarTick() {
  if ((long)(millis() - radarNextStep) < 0) return;
  long cm = distanceCm();
  if (cm < 0) cm = 0;
  // The board decides to beep, all by itself, when something comes close.
  bool near = radarNear > 0 && cm > 0 && cm < radarNear;
  digitalWrite(PIN_BUZZ, near ? HIGH : LOW);
  Serial.print(F("R "));
  Serial.print(radarAngle);
  Serial.print(' ');
  Serial.println(cm);
  radarAngle += radarDir * radarStepDeg;
  if (radarAngle >= 180) { radarAngle = 180; radarDir = -1; }
  if (radarAngle <= 0) { radarAngle = 0; radarDir = 1; }
  servo.write(radarAngle);
  radarNextStep = millis() + 40;    // let the arm get there before the next look
}

/* ---- command dispatch --------------------------------------------------- */

void handle(String line) {
  line.trim();
  if (line.length() == 0) return;

  String cmd = line;
  String arg = "";
  int sp = line.indexOf(' ');
  if (sp > 0) { cmd = line.substring(0, sp); arg = line.substring(sp + 1); arg.trim(); }
  cmd.toUpperCase();

  // Anything but a new RADAR ends a sweep first, so the arm obeys at once.
  if (radarOn && cmd != "RADAR") radarStop();

  /* -- the radar (driven by /studio/radar, not by the AI) ---------------- */
  if (cmd == "RADAR") {
    radarStart(arg);

  } else if (cmd == "STOP") {
    servo.write(90);
    say("OK STOP");

  /* -- v1, unchanged ----------------------------------------------------- */
  } else if (cmd == "SERVO") {
    int a = constrain(arg.toInt(), 0, 180);
    servo.write(a);
    say("OK SERVO " + String(a));

  } else if (cmd == "SWEEP") {
    for (int a = 0; a <= 180; a += 5) { servo.write(a); delay(15); }
    for (int a = 180; a >= 90; a -= 5) { servo.write(a); delay(15); }
    say("OK SWEEP");

  } else if (cmd == "LED") {
    arg.toUpperCase();
    bool on = (arg == "ON");
    setLed(on);
    say(on ? "OK LED ON" : "OK LED OFF");

  } else if (cmd == "BLINK") {
    int n = constrain(arg.toInt(), 1, 50);
    for (int i = 0; i < n; i++) { setLed(true); delay(200); setLed(false); delay(200); }
    say("OK BLINK " + String(n));

  } else if (cmd == "MORSE") {
    morse(arg);
    say("OK MORSE " + arg);

  } else if (cmd == "BEEP") {
    int n = constrain(arg.toInt(), 1, 20);
    for (int i = 0; i < n; i++) { beepOnce(120); delay(120); }
    say("OK BEEP " + String(n));

  } else if (cmd == "READ") {
    say("OK READ " + String(analogRead(PIN_LIGHT)));

  } else if (cmd == "PING") {
    say("OK PONG");

  /* -- 1 · night light --------------------------------------------------- */
  } else if (cmd == "DIM") {
    int pct = constrain(arg.toInt(), 0, 100);
    applyLed(map(pct, 0, 100, 0, 255));
    say("OK DIM " + String(pct));

  } else if (cmd == "LIGHT") {
    say("OK LIGHT " + String(analogRead(PIN_LIGHT)));

  } else if (cmd == "DARK") {
    int v = analogRead(PIN_LIGHT);
    say(String("OK DARK ") + (v < DARK_BELOW ? "YES " : "NO ") + String(v));

  /* -- 2 · distance ------------------------------------------------------ */
  } else if (cmd == "DIST") {
    long cm = distanceCm();
    if (cm < 0) say("ERR DIST no echo");
    else say("OK DIST " + String(cm));

  } else if (cmd == "WATCH") {
    int near = constrain(arg.toInt(), 2, 200);
    // Bounded on purpose: a watch that runs forever is a board that stops
    // answering, and the child cannot tell that apart from a crash.
    unsigned long until = millis() + 5000UL;
    int hits = 0;
    while (millis() < until) {
      long cm = distanceCm();
      if (cm > 0 && cm < near) { beepOnce(80); hits++; delay(70); }
      else delay(60);
    }
    say("OK WATCH " + String(near) + " hits " + String(hits));

  /* -- 3 · car barrier --------------------------------------------------- */
  } else if (cmd == "BARRIER") {
    arg.toUpperCase();
    int a = -1;
    if (arg == "OPEN") a = 90;        // arm vertical
    else if (arg == "CLOSE") a = 0;   // arm down across the road
    else if (arg == "HALF") a = 45;
    if (a < 0) { say("ERR BARRIER use OPEN CLOSE or HALF"); return; }
    servo.write(a);
    say("OK BARRIER " + arg);

  } else if (cmd == "HOLD") {
    int s = constrain(arg.toInt(), 1, 10);
    servo.write(90);
    delay((unsigned long)s * 1000UL);
    servo.write(0);
    say("OK HOLD " + String(s));

  } else if (cmd == "IR") {
    // Most IR obstacle modules pull the line LOW when something is in front.
    bool blocked = (digitalRead(PIN_IR) == LOW);
    say(blocked ? "OK IR BLOCKED" : "OK IR CLEAR");

  /* -- 4 · fan ----------------------------------------------------------- */
  } else if (cmd == "FAN") {
    arg.toUpperCase();
    if (arg == "OFF") { fanOff(); say("OK FAN OFF"); return; }
    int pct = constrain(arg.toInt(), 0, 100);
    if (pct == 0) { fanOff(); say("OK FAN OFF"); return; }
    digitalWrite(PIN_IN1, HIGH);
    digitalWrite(PIN_IN2, LOW);
    analogWrite(PIN_ENA, map(pct, 0, 100, 0, 255));
    say("OK FAN " + String(pct));

  } else if (cmd == "TEMP") {
    int c = readTempC();
    if (c == TEMP_NO_REPLY) say("ERR TEMP no reply");
    else if (c == TEMP_BAD_READ) say("ERR TEMP bad reading");
    else say("OK TEMP " + String(c));

  } else if (cmd == "SHOW") {
    // The one command that cannot work without the screen, and the board can
    // tell: so a missing screen is ERR, never an OK over a blank strip. It
    // looks again first, in case the screen went in after the board started.
    String text = arg;
    text.trim();
    text.toUpperCase();
    if (text.length() == 0) { say("ERR SHOW what?"); return; }
    if ((int)text.length() > 20) text = text.substring(0, 20);
    if (!oledOk) oledBegin();
    if (!oledOk) { Serial.println("ERR SHOW no screen"); return; }
    // Printed, not said: say() would draw "SHOW" over the words.
    Serial.println("OK SHOW " + text);
    showWords(text);

  /* -- 5 · gas detector -------------------------------------------------- */
  } else if (cmd == "GAS") {
    say("OK GAS " + String(analogRead(PIN_GAS)));

  } else if (cmd == "SAFE") {
    int v = analogRead(PIN_GAS);
    say(String("OK SAFE ") + (v > GAS_UNSAFE ? "NO " : "YES ") + String(v));

  /* -- 6 · piano --------------------------------------------------------- */
  } else if (cmd == "NOTE") {
    int hz = noteHz(arg);
    if (hz <= 0) { say("ERR NOTE unknown: " + arg); return; }
    tone(PIN_BUZZ, hz, 300);
    delay(340);
    noTone(PIN_BUZZ);
    say("OK NOTE " + arg);

  } else if (cmd == "PLAY") {
    int played = playSequence(arg);
    if (played == 0) { say("ERR PLAY nothing playable in: " + arg); return; }
    say("OK PLAY " + String(played));

  } else if (cmd == "TUNE") {
    arg.toUpperCase();
    int played = 0;
    if (arg == "SCALE") played = playSequence("C D E F G A B C5");
    else if (arg == "TWINKLE") played = playSequence("C C G G A A G F F E E D D C");
    if (played == 0) { say("ERR TUNE try SCALE or TWINKLE"); return; }
    say("OK TUNE " + arg);

  } else {
    say("ERR unknown: " + cmd);
  }
}

/* ---- LED helpers -------------------------------------------------------- */

// D6 carries the real brightness; D8 and the built-in LED mirror it as on/off
// so a board wired to the v1 diagram still visibly does the right thing.
void applyLed(int level) {
  ledLevel = constrain(level, 0, 255);
  analogWrite(PIN_LED, ledLevel);
  digitalWrite(PIN_LED2, ledLevel > 127 ? HIGH : LOW);
  digitalWrite(LED_BUILTIN, ledLevel > 127 ? HIGH : LOW);
}

void setLed(bool on) { applyLed(on ? 255 : 0); }

/* ---- buzzer ------------------------------------------------------------- */

/*
 * BEEP and WATCH drive the buzzer with plain DC, NOT tone().
 *
 * The demo rig's BOM part is an ACTIVE buzzer: it has its own oscillator and
 * only wants to be switched on. Feeding it a tone() square wave chops that
 * oscillator on and off and it warbles or goes quiet. A PASSIVE piezo does the
 * opposite - DC just clicks, it needs tone().
 *
 * digitalWrite is the one that works on BOTH: an active buzzer sounds properly,
 * and a passive piezo still clicks audibly. So only the piano (NOTE / PLAY /
 * TUNE) uses tone(), and only the piano actually requires the piezo. Everything
 * a v1 board could do, a v1 board still does.
 */
void beepOnce(int ms) {
  digitalWrite(PIN_BUZZ, HIGH);
  delay(ms);
  digitalWrite(PIN_BUZZ, LOW);
}

/* ---- motor -------------------------------------------------------------- */

void fanOff() {
  analogWrite(PIN_ENA, 0);
  digitalWrite(PIN_IN1, LOW);
  digitalWrite(PIN_IN2, LOW);
}

/* ---- ultrasonic --------------------------------------------------------- */

// Returns centimetres, or -1 when nothing echoes back inside ~5 m.
long distanceCm() {
  digitalWrite(PIN_TRIG, LOW);
  delayMicroseconds(2);
  digitalWrite(PIN_TRIG, HIGH);
  delayMicroseconds(10);
  digitalWrite(PIN_TRIG, LOW);
  unsigned long us = pulseIn(PIN_ECHO, HIGH, 30000UL);
  if (us == 0) return -1;
  return (long)(us / 58UL);
}

/* ---- DHT11, bit-banged so nothing has to be installed --------------------
 * The method of Adafruit's DHT library, the one most Arduino sketches use,
 * written out here so the kit still needs no library (2026-10-08, after the
 * first real sensor on the bench answered ERR TEMP every time):
 *  - the start signal is 20 ms low, then the line is LET GO, never driven
 *    high, and the sensor gets 55 us to start answering
 *  - interrupts are off while the 40 bits arrive: millis, Serial and the
 *    servo would otherwise stretch a pulse
 *  - each bit is judged by its high pulse against the 50 us low before it,
 *    both counted in loop passes, so no clock is read with interrupts off
 *  - at least 1.1 s between readings, and after power-up: a DHT11 asked
 *    sooner answers with garbage or not at all
 *
 * WIRING. The black KY-015 board prints S and - by its outer pins:
 *   S -> D12      middle -> 5V      - -> GND
 * Boards that print DATA / VCC / GND are wired by those letters instead.
 */
// TEMP_NO_REPLY, TEMP_BAD_READ and dhtLastRead are declared at the top, with
// the other settings, because handle() and setup() use them.
const uint16_t DHT_TIMEOUT = 0xFFFF;

// Loop passes while the pin stays at `level`, or DHT_TIMEOUT if it never
// changes. Read straight off the port: digitalRead is slow enough to blur a
// 28 us pulse into a 70 us one.
uint16_t dhtPulse(uint8_t level) {
  volatile uint8_t* in = portInputRegister(digitalPinToPort(PIN_DHT));
  uint8_t mask = digitalPinToBitMask(PIN_DHT);
  uint8_t want = level ? mask : 0;
  uint16_t count = 0;
  while ((*in & mask) == want) {
    if (++count >= 16000) return DHT_TIMEOUT;   // far longer than any DHT11 pulse
  }
  return count;
}

// Whole degrees C, TEMP_NO_REPLY or TEMP_BAD_READ.
int readTempC() {
  unsigned long since = millis() - dhtLastRead;
  if (since < 1100) delay(1100 - since);
  dhtLastRead = millis();

  uint8_t data[5] = { 0, 0, 0, 0, 0 };
  uint16_t cycles[80];

  pinMode(PIN_DHT, INPUT_PULLUP);
  delay(1);
  pinMode(PIN_DHT, OUTPUT);
  digitalWrite(PIN_DHT, LOW);
  delay(20);                          // the start signal: at least 18 ms low

  noInterrupts();
  pinMode(PIN_DHT, INPUT_PULLUP);     // let go; the sensor answers in 20-40 us
  delayMicroseconds(55);
  // It answers 80 us low, then 80 us high, then the 40 bits.
  bool answered = dhtPulse(LOW) != DHT_TIMEOUT && dhtPulse(HIGH) != DHT_TIMEOUT;
  if (answered) {
    for (uint8_t i = 0; i < 80; i += 2) {
      cycles[i] = dhtPulse(LOW);        // the 50 us gap before each bit
      cycles[i + 1] = dhtPulse(HIGH);   // ~28 us = 0, ~70 us = 1
    }
  }
  interrupts();
  if (!answered) return TEMP_NO_REPLY;

  for (uint8_t i = 0; i < 40; i++) {
    uint16_t low = cycles[2 * i], high = cycles[2 * i + 1];
    if (low == DHT_TIMEOUT || high == DHT_TIMEOUT) return TEMP_BAD_READ;
    data[i / 8] <<= 1;
    if (high > low) data[i / 8] |= 1;   // longer than the gap = 1
  }

  uint8_t sum = data[0] + data[1] + data[2] + data[3];
  if (sum != data[4]) return TEMP_BAD_READ;
  return (int)data[2];
}

/* ---- piano -------------------------------------------------------------- */

// "C", "F#", "A5" -> Hz. 0 when it is not a note.
int noteHz(String n) {
  n.toUpperCase();
  n.trim();
  if (n.length() == 0) return 0;

  int idx;
  switch (n.charAt(0)) {
    case 'C': idx = 0;  break;
    case 'D': idx = 2;  break;
    case 'E': idx = 4;  break;
    case 'F': idx = 5;  break;
    case 'G': idx = 7;  break;
    case 'A': idx = 9;  break;
    case 'B': idx = 11; break;
    default: return 0;
  }

  unsigned int p = 1;
  if (p < n.length() && n.charAt(p) == '#') { idx++; p++; }

  int oct = 4;
  if (p < n.length() && isDigit(n.charAt(p))) oct = n.charAt(p) - '0';
  oct = constrain(oct, 2, 7);

  int midi = (oct + 1) * 12 + idx;                       // C4 = 60, A4 = 69
  return (int)(440.0 * pow(2.0, (midi - 69) / 12.0));
}

// Space-separated notes. Returns how many actually played.
int playSequence(String notes) {
  notes.trim();
  int played = 0;
  while (notes.length() > 0 && played < 32) {
    int sp = notes.indexOf(' ');
    String one = (sp < 0) ? notes : notes.substring(0, sp);
    notes = (sp < 0) ? "" : notes.substring(sp + 1);
    notes.trim();
    int hz = noteHz(one);
    if (hz > 0) { tone(PIN_BUZZ, hz, 220); delay(260); played++; }
  }
  noTone(PIN_BUZZ);
  return played;
}

/* ---- morse -------------------------------------------------------------- */

void symbol(int onMs) { setLed(true); delay(onMs); setLed(false); delay(GAP_SYMBOL); }

void morse(String text) {
  text.toUpperCase();
  for (unsigned int i = 0; i < text.length(); i++) {
    char ch = text.charAt(i);
    if (ch == ' ') { delay(GAP_WORD); continue; }
    String code = morseFor(ch);
    for (unsigned int j = 0; j < code.length(); j++) {
      symbol(code.charAt(j) == '.' ? DOT : DASH);
    }
    delay(GAP_LETTER);
  }
}

String morseFor(char c) {
  switch (c) {
    case 'A': return ".-";    case 'B': return "-...";  case 'C': return "-.-.";
    case 'D': return "-..";   case 'E': return ".";     case 'F': return "..-.";
    case 'G': return "--.";   case 'H': return "....";  case 'I': return "..";
    case 'J': return ".---";  case 'K': return "-.-";   case 'L': return ".-..";
    case 'M': return "--";    case 'N': return "-.";    case 'O': return "---";
    case 'P': return ".--.";  case 'Q': return "--.-";  case 'R': return ".-.";
    case 'S': return "...";   case 'T': return "-";     case 'U': return "..-";
    case 'V': return "...-";  case 'W': return ".--";   case 'X': return "-..-";
    case 'Y': return "-.--";  case 'Z': return "--..";
    case '0': return "-----"; case '1': return ".----"; case '2': return "..---";
    case '3': return "...--"; case '4': return "....-"; case '5': return ".....";
    case '6': return "-...."; case '7': return "--..."; case '8': return "---..";
    case '9': return "----.";
    default:  return "";
  }
}
