Moksha Kumbhaj
Published © LGPL

Krishi Kavach

One system, three jobs: stop wildlife damage, stop fires before they spread, and grow smarter with real-time soil data.

AdvancedWork in progressOver 1 day48
Krishi Kavach

Things used in this project

Hardware components

Arduino UNO Q
Arduino UNO Q
×1
FireBeetle 2 Board ESP32-S3 (N16R8) AIoT Microcontroller with Camera
DFRobot FireBeetle 2 Board ESP32-S3 (N16R8) AIoT Microcontroller with Camera
×1
ESP32 Camera Module Development Board
M5Stack ESP32 Camera Module Development Board
×1
PIR Motion Sensor (generic)
PIR Motion Sensor (generic)
×1
SparkFun Soil Moisture Sensor (with Screw Terminals)
SparkFun Electronics SparkFun Soil Moisture Sensor (with Screw Terminals)
×1
NPK Sensor
×1
Gravity™ Analog pH Sensor
Atlas Scientific Gravity™ Analog pH Sensor
×1
TDS Sensor
×1
Turbidity Sensor, Phototransistor Output
Turbidity Sensor, Phototransistor Output
×1
Grove - Vibration Sensor (SW-420)
Seeed Studio Grove - Vibration Sensor (SW-420)
×1
1.8" ST7735 TFT Display
×1
GPRS Module (A7670C)
×1
MP135 Pressure Sensor
×1
Pi RTC (DS1307)
Seeed Studio Pi RTC (DS1307)
×1
Rotary potentiometer (generic)
Rotary potentiometer (generic)
×1
I2S MEMS Microphone
×1
SG90 Micro-servo motor
SG90 Micro-servo motor
×1
UltraSound Generator
×1
DHT22 Temperature Sensor
DHT22 Temperature Sensor
×1

Software apps and online services

Arduino App Lab
Arduino IDE
Arduino IDE

Story

Read more

Schematics

Circuit Diagram

A complete schematic for the Arduino UNO Q wasn't possible to include, as no Fritzing part currently exists for this board :/

Code

app.yaml

YAML
# App manifest for the UNO Q Speech-to-Text project
# Defines app metadata and entry points for Arduino App Lab

name: uno-q-inmp441-speech-to-text   # Unique internal identifier for the app (no spaces, used by App Lab)
title: UNO Q Speech-to-Text          # Display name shown to users in App Lab
description: Offline speech recognition running on Arduino UNO Q with INMP441 I2S microphone  # Short summary of what the app does
version: 1.0.0                       # App version, following semantic versioning (major.minor.patch)
author: Krishi Kavach                # App/project author name

entrypoint: python/main.py           # Main Python script that runs on the UNO Q's Linux side (handles speech processing)
sketch: sketch/sketch.ino            # Arduino sketch that runs on the microcontroller side (handles I2S mic input / hardware)

bricks: []                           # List of App Lab "bricks" (reusable modules/widgets) used by this app — currently none

sketch.yaml

YAML
default_profile: uno_q
profiles:
  uno_q:
    board: arduino:zephyr:uno_q

main.py

Python
#!/usr/bin/env python3
"""
Arduino UNO Q - Speech-to-Text Engine
Runs offline Vosk speech recognition on Linux MPU and bridges results to STM32 MCU.
"""

import sys
import os
import json
import queue
import time
import shutil
import zipfile
import urllib.request
import threading

if hasattr(sys.stdout, 'reconfigure'):
    sys.stdout.reconfigure(line_buffering=True)

audio_q = queue.Queue()
bridge_instance = None

SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
MODEL_DIR = os.path.join(SCRIPT_DIR, "model")
MODEL_URL = "https://alphacephei.com/vosk/models/vosk-model-small-en-us-0.15.zip"

def get_bridge():
    """Initializes the Arduino Bridge (supports App Lab app_utils & standalone)."""
    global bridge_instance
    if bridge_instance is not None:
        return bridge_instance

    try:
        from arduino.app_utils import Bridge
        bridge_instance = Bridge
        print("[BRIDGE] Connected to MCU via arduino.app_utils.Bridge", flush=True)
        return bridge_instance
    except Exception:
        pass

    try:
        from arduino_router_bridge import Bridge
        Bridge.begin()
        bridge_instance = Bridge
        print("[BRIDGE] Connected to MCU via arduino_router_bridge", flush=True)
        return bridge_instance
    except Exception as e:
        print(f"[BRIDGE] Standalone mode: {e}", flush=True)
        return None

def send_to_mcu(text):
    """Sends recognized text string to the STM32 MCU sketch over Bridge."""
    bridge = get_bridge()
    if bridge:
        try:
            bridge.call("on_speech", text)
            print(f"[BRIDGE -> MCU] Sent recognized text: \"{text}\"", flush=True)
        except Exception as e:
            print(f"[BRIDGE ERROR] Failed to send to MCU: {e}", flush=True)

def is_internet_available():
    """Checks if the system has active internet connection."""
    try:
        import socket
        socket.create_connection(("8.8.8.8", 53), timeout=3)
        return True
    except OSError:
        return False

def ensure_model_downloaded():
    """Downloads and extracts the offline Vosk speech model if missing."""
    if os.path.exists(MODEL_DIR) and os.path.isdir(MODEL_DIR) and len(os.listdir(MODEL_DIR)) > 0:
        print(f"[STT] Offline model located at: {MODEL_DIR}", flush=True)
        return True

    if not is_internet_available():
        print("[STT] Device is offline. Connect Wi-Fi to download speech model (~40MB).", flush=True)
        return False

    print("[STT] Downloading Vosk English speech model (~40MB)...", flush=True)
    zip_path = os.path.join(SCRIPT_DIR, "vosk_model.zip")
    extract_tmp = os.path.join(SCRIPT_DIR, "extracted_tmp")

    try:
        last_percent = [-1]
        def download_progress(block_num, block_size, total_size):
            if total_size > 0:
                percent = int(block_num * block_size * 100 / total_size)
                if percent != last_percent[0] and percent % 20 == 0:
                    last_percent[0] = percent
                    print(f"[DOWNLOAD] {min(percent, 100)}%...", flush=True)

        urllib.request.urlretrieve(MODEL_URL, zip_path, reporthook=download_progress)
        print("[STT] Download complete. Unpacking model files...", flush=True)

        with zipfile.ZipFile(zip_path, 'r') as zip_ref:
            zip_ref.extractall(extract_tmp)

        extracted_items = os.listdir(extract_tmp)
        if extracted_items:
            extracted_folder = os.path.join(extract_tmp, extracted_items[0])
            if os.path.exists(MODEL_DIR):
                shutil.rmtree(MODEL_DIR)
            shutil.move(extracted_folder, MODEL_DIR)

        shutil.rmtree(extract_tmp, ignore_errors=True)
        if os.path.exists(zip_path):
            os.remove(zip_path)

        print("[STT] Vosk model ready in python/model", flush=True)
        return True
    except Exception as e:
        print(f"[STT ERROR] Model setup failed: {e}", flush=True)
        return False

def audio_callback(indata, frames, time_info, status):
    """Callback for SoundDevice to capture audio chunks from microphone."""
    if status:
        print(f"[AUDIO WARN] {status}", file=sys.stderr, flush=True)
    audio_q.put(bytes(indata))

def run_vosk_stt():
    """Runs continuous streaming speech recognition."""
    try:
        import sounddevice as sd
        from vosk import Model, KaldiRecognizer
    except ImportError as e:
        print(f"[STT ERROR] Missing library: {e}. Please install requirements.", flush=True)
        return False

    SAMPLE_RATE = 16000

    if not ensure_model_downloaded():
        return False

    print("[STT] Loading Vosk neural model...", flush=True)
    model = Model(MODEL_DIR)
    rec = KaldiRecognizer(model, SAMPLE_RATE)

    print("[STT] Speech-to-Text active. Listening for voice input...", flush=True)

    try:
        devices = sd.query_devices()
        default_input = sd.default.device[0]
        dev_name = devices[default_input]['name'] if default_input is not None and default_input >= 0 else 'Default Audio Device'
        print(f"[AUDIO] Active input: {dev_name}", flush=True)

        with sd.RawInputStream(samplerate=SAMPLE_RATE, blocksize=4000, dtype='int16',
                               channels=1, callback=audio_callback):
            while True:
                data = audio_q.get()
                if rec.AcceptWaveform(data):
                    res = json.loads(rec.Result())
                    text = res.get("text", "").strip()
                    if text:
                        print(f"[STT] Recognized: \"{text}\"", flush=True)
                        send_to_mcu(text)
                else:
                    partial = json.loads(rec.PartialResult())
                    partial_text = partial.get("partial", "").strip()
                    if partial_text:
                        print(f"\r[Listening] {partial_text}...", end="", flush=True)
    except Exception as audio_err:
        print(f"\n[AUDIO NOTE] {audio_err}", flush=True)
        time.sleep(5)
        return False

    return True

def speech_thread_worker():
    """Background worker thread for speech processing."""
    print("[WORKER] Speech thread active", flush=True)
    bridge = get_bridge()

    if bridge:
        def on_mcu_trigger(arg):
            print(f"[MCU TRIGGER] Button pressed ({arg}) -> Listening for command...", flush=True)
        try:
            bridge.provide("trigger_record", on_mcu_trigger)
        except Exception:
            pass

    while True:
        try:
            if run_vosk_stt():
                break
        except Exception as e:
            print(f"[STT RETRY] {e}", flush=True)

        time.sleep(5)

def main():
    print("[UNO Q] Speech-to-Text service started", flush=True)

    stt_thread = threading.Thread(target=speech_thread_worker, daemon=True)
    stt_thread.start()

    try:
        from arduino.app_utils import App
        print("[APP] Registering with Arduino App Lab...", flush=True)
        App.run()
    except Exception as e:
        print(f"[STANDALONE] Main loop running ({e})", flush=True)
        while True:
            time.sleep(1)

if __name__ == "__main__":
    main()

requirements.txt

Python
vosk>=0.3.45
sounddevice>=0.4.6
numpy>=1.24.0
requests>=2.28.0

main.cpp

Arduino
#include <Adafruit_BMP280.h>
#include <Adafruit_GFX.h>
#include <Adafruit_ST7735.h>
#include <Arduino.h>
#include <DHT.h>
#include <ESPAsyncWebServer.h>
#include <RTClib.h>
#include <SPI.h>
#include <WiFi.h>
#include <Wire.h>
#include <driver/i2s.h>
#include <time.h>

#define WIFI_SSID "xxx" // put your wifi ssid
#define WIFI_PASS "xxx" // put your wifi password
#define DEVICE_IP "xxx.xxx.xxx.xxx" // put your ip

#define NTP_SERVER_1 "pool.ntp.org"
#define NTP_SERVER_2 "time.nist.gov"
#define GMT_OFFSET_SEC                                                         \
  19800
#define DAYLIGHT_OFFSET_S 0

#define TFT_CS 4
#define TFT_RST 5
#define TFT_DC 6
#define TFT_MOSI 7
#define TFT_SCLK 15

#define MIC_SCK 16
#define MIC_WS 17
#define MIC_SD 18

#define I2C_SDA 8
#define I2C_SCL 9
#define BMP_SDA I2C_SDA
#define BMP_SCL I2C_SCL
#define SEALEVELPRESSURE_HPA (1013.25)

#define DHTPIN 13
#define DHTTYPE DHT22

#define MQ4_ANALOG_PIN 1
#define TURB_ANALOG_PIN 2
#define TDS_ANALOG_PIN 3
#define MOISTURE_ANALOG_PIN                                                    \
  14

#define SOIL_DRY_AIR_RAW 3200
#define SOIL_WET_WATER_RAW 1300

#define ENCODER_CLK 10
#define ENCODER_DT 11
#define ENCODER_SW 12

#define SERVO_PIN 21
#define SERVO_MIN_ANGLE 0
#define SERVO_MAX_ANGLE 180
#define SERVO_CENTER_ANGLE 90
#define FIRE_SERVO_ANGLE 58

#define PUMP_ENA 38
#define PUMP_IN1 39
#define PUMP_IN2 40
bool pumpOn = false;

void startPump() {
  if (pumpOn)
    return;
  digitalWrite(PUMP_IN1, HIGH);
  digitalWrite(PUMP_IN2, LOW);
  analogWrite(PUMP_ENA, 100);
  pumpOn = true;
  Serial.println("[PUMP] Fire detected -> Water pump ON (GPIO 38/39/40)");
}

void stopPump() {
  if (!pumpOn)
    return;
  analogWrite(PUMP_ENA, 0);
  digitalWrite(PUMP_IN1, LOW);
  digitalWrite(PUMP_IN2, LOW);
  pumpOn = false;
  Serial.println("[PUMP] Fire clear -> Water pump OFF");
}

const int SERVO_PWM_CHANNEL = 0;
const int SERVO_PWM_FREQ = 50;
const int SERVO_PWM_RES =
    14;

int currentServoAngle = SERVO_CENTER_ANGLE;
int targetServoAngle = SERVO_CENTER_ANGLE;
bool fireTrackingActive = false;
unsigned long lastFireMsgTime = 0;

WiFiServer tcpServer(8888);

void applyServoPWM(int angle) {
  angle = constrain(angle, 0, 180);
  uint32_t duty = map(angle, 0, 180, 410, 2048);
  ledcWrite(SERVO_PWM_CHANNEL, duty);
}

void setServoAngle(int angle, bool fromFireTracker = false) {
  currentServoAngle = constrain(angle, SERVO_MIN_ANGLE, SERVO_MAX_ANGLE);
  targetServoAngle = currentServoAngle;
  applyServoPWM(
      currentServoAngle);
  if (fromFireTracker) {
    fireTrackingActive = true;
    lastFireMsgTime = millis();
  }
}

void initServo() {
  ledcSetup(SERVO_PWM_CHANNEL, SERVO_PWM_FREQ, SERVO_PWM_RES);
  ledcAttachPin(SERVO_PIN, SERVO_PWM_CHANNEL);
  setServoAngle(SERVO_CENTER_ANGLE, false);
  Serial.printf("[OK] Extinguisher Servo initialized on GPIO %d (Box limits: "
                "%d° to %d°)\n",
                SERVO_PIN, SERVO_MIN_ANGLE, SERVO_MAX_ANGLE);
}

void handleTcpClients() {
  WiFiClient client = tcpServer.available();
  if (client) {
    while (client.connected() && client.available()) {
      String line = client.readStringUntil('\n');
      line.trim();
      if (line.startsWith("F:")) {
        int val = line.substring(2).toInt();
        if (val == 0) {
          fireTrackingActive = false;
          setServoAngle(SERVO_CENTER_ANGLE, false);
          stopPump();
        } else {
          setServoAngle(val, true);
          startPump();
        }
        client.println("OK:" + String(currentServoAngle));
      } else if (line.startsWith("SET:")) {
        int val = line.substring(4).toInt();
        setServoAngle(val, false);
        client.println("OK:" + String(currentServoAngle));
      }
    }
    client.stop();
  }
}

#define MIC_SAMPLE_RATE 16000
#define MIC_RECORD_SECS 5
#define MIC_NUM_SAMPLES (MIC_SAMPLE_RATE * MIC_RECORD_SECS)
#define WAV_HEADER_SIZE 44
#define WAV_DATA_SIZE (MIC_NUM_SAMPLES * 2)
#define WAV_FILE_SIZE (WAV_HEADER_SIZE + WAV_DATA_SIZE)

#define COLOR_BG 0x0000
#define COLOR_HEADER_BG 0x0A0F
#define COLOR_TITLE 0xFFFF
#define COLOR_LABEL 0xB596
#define COLOR_TEMP 0xFD20
#define COLOR_HUMID 0x07FF
#define COLOR_PRESS 0x07E0
#define COLOR_ALT 0xF81F
#define COLOR_ERROR 0xF800
#define COLOR_OK 0x07E0
#define COLOR_ACCENT 0x07FF
#define COLOR_DIVIDER 0x2104
#define COLOR_REC 0xF800
#define COLOR_SOIL_DRY 0xF800
#define COLOR_SOIL_OPT 0x07E0
#define COLOR_SOIL_WET 0x07FF

enum ScreenMode {
  SCREEN_BMP280 = 0,
  SCREEN_DHT22 = 1,
  SCREEN_MQ4 = 2,
  SCREEN_TURB = 3,
  SCREEN_TDS = 4,
  SCREEN_MOISTURE = 5,
  SCREEN_SYSTEM = 6,
  NUM_SCREENS = 7
};

Adafruit_ST7735 tft = Adafruit_ST7735(TFT_CS, TFT_DC, TFT_RST);
Adafruit_BMP280 bmp;
RTC_DS3231 rtc;
DHT dht(DHTPIN, DHTTYPE);
AsyncWebServer webServer(80);

void renderCurrentScreen();
void recordAudio();
void updateCachedSensors();

bool bmpFound = false;
bool rtcFound = false;
int currentScreen = SCREEN_BMP280;
int lastClkState = HIGH;
unsigned long lastButtonPress = 0;

volatile bool isRecording = false;
volatile bool hasRecording = false;
volatile int recordingProgress = 0;
volatile int recordingElapsedSec = 0;
volatile bool triggerRecordWeb = false;
uint8_t *wavBuffer = nullptr;

float cachedBmpTemp = 0, cachedBmpPress = 0, cachedBmpAlt = 0;
float cachedDhtTemp = 0, cachedDhtHum = 0, cachedDhtHeat = 0;
bool cachedDhtValid = false;
int cachedMq4Raw = 0;
float cachedMq4Volt = 0.0;
String cachedMq4Status = "CLEAN";
int cachedTurbRaw = 0;
float cachedTurbVolt = 0.0;
String cachedTurbStatus = "--";
int cachedTdsRaw = 0;
float cachedTdsVolt = 0.0;
float cachedTdsPpm = 0.0;
String cachedTdsStatus = "--";

int cachedMoistureRaw = 0;
float cachedMoistureVolt = 0.0;
int cachedMoisturePercent = 0;
String cachedMoistureStatus = "OPTIMAL";

String cachedRtcDate = "----/--/--";
String cachedRtcTime = "--:--:--";
float cachedRtcTemp = 0.0;

void writeWavHeader(uint8_t *buf, uint32_t dataSize) {
  uint32_t fileSize = dataSize + 36;
  uint16_t numChannels = 1;
  uint32_t sampleRate = MIC_SAMPLE_RATE;
  uint16_t bitsPerSample = 16;
  uint32_t byteRate = sampleRate * numChannels * (bitsPerSample / 8);
  uint16_t blockAlign = numChannels * (bitsPerSample / 8);

  memcpy(buf + 0, "RIFF", 4);
  memcpy(buf + 4, &fileSize, 4);
  memcpy(buf + 8, "WAVE", 4);
  memcpy(buf + 12, "fmt ", 4);
  uint32_t subchunk1Size = 16;
  memcpy(buf + 16, &subchunk1Size, 4);
  uint16_t audioFormat = 1;
  memcpy(buf + 20, &audioFormat, 2);
  memcpy(buf + 22, &numChannels, 2);
  memcpy(buf + 24, &sampleRate, 4);
  memcpy(buf + 28, &byteRate, 4);
  memcpy(buf + 32, &blockAlign, 2);
  memcpy(buf + 34, &bitsPerSample, 2);
  memcpy(buf + 36, "data", 4);
  memcpy(buf + 40, &dataSize, 4);
}

void initMicrophone() {
  i2s_config_t i2s_config = {.mode =
                                 (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX),
                             .sample_rate = MIC_SAMPLE_RATE,
                             .bits_per_sample = I2S_BITS_PER_SAMPLE_32BIT,
                             .channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
                             .communication_format = I2S_COMM_FORMAT_STAND_I2S,
                             .intr_alloc_flags = ESP_INTR_FLAG_LEVEL1,
                             .dma_buf_count = 4,
                             .dma_buf_len = 512,
                             .use_apll = false,
                             .tx_desc_auto_clear = false,
                             .fixed_mclk = 0};

  i2s_pin_config_t pin_config = {.bck_io_num = MIC_SCK,
                                 .ws_io_num = MIC_WS,
                                 .data_out_num = I2S_PIN_NO_CHANGE,
                                 .data_in_num = MIC_SD};

  esp_err_t err = i2s_driver_install(I2S_NUM_1, &i2s_config, 0, NULL);
  if (err != ESP_OK) {
    Serial.printf("[ERROR] Failed installing I2S driver: %d\n", err);
    return;
  }
  err = i2s_set_pin(I2S_NUM_1, &pin_config);
  if (err != ESP_OK) {
    Serial.printf("[ERROR] Failed setting I2S pins: %d\n", err);
    return;
  }
  Serial.println("[OK] INMP441 I2S microphone driver initialized on Port 1");
}

void drawHeader(const char *subtitle) {
  tft.fillRect(0, 0, tft.width(), 28, COLOR_HEADER_BG);
  tft.setTextSize(1);
  tft.setTextColor(COLOR_TITLE);
  tft.setCursor(8, 4);
  tft.print("KRISHI KAVACH");
  tft.setTextColor(COLOR_OK);
  tft.setCursor(8, 16);
  tft.print(subtitle);
  tft.drawFastHLine(0, 28, tft.width(), COLOR_DIVIDER);
}

void drawStatusBar(const char *text, uint16_t color) {
  int y = tft.height() - 12;
  tft.fillRect(0, y, tft.width(), 12, COLOR_HEADER_BG);
  tft.setTextSize(1);
  tft.setTextColor(color);
  tft.setCursor(4, y + 2);
  tft.print(text);
}

void clearContent() {
  tft.fillRect(0, 30, tft.width(), tft.height() - 42, COLOR_BG);
}

void showStatus(const char *line1, const char *line2, uint16_t color) {
  clearContent();
  tft.setTextSize(1);
  tft.setTextColor(color);
  tft.setCursor(8, 44);
  tft.print(line1);
  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 62);
  tft.print(line2);
}

void drawBMP280Screen() {
  drawHeader("BMP280 Sensor (1/7)");
  clearContent();
  tft.setTextSize(1);

  if (!bmpFound) {
    tft.setTextColor(COLOR_ERROR);
    tft.setCursor(8, 38);
    tft.print("BMP280 NOT FOUND!");
    tft.setTextColor(COLOR_LABEL);
    tft.setCursor(8, 54);
    tft.print("Check SDA/SCL wiring");
    drawStatusBar("[Turn: Switch | Press: Rec]", COLOR_ERROR);
    return;
  }

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 36);
  tft.print("TEMP:");
  tft.setTextColor(COLOR_TEMP);
  tft.setCursor(55, 36);
  tft.print(String(cachedBmpTemp, 1) + " C");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 52);
  tft.print("PRESS:");
  tft.setTextColor(COLOR_PRESS);
  tft.setCursor(55, 52);
  tft.print(String(cachedBmpPress, 1) + " hPa");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 68);
  tft.print("ALT:");
  tft.setTextColor(COLOR_ALT);
  tft.setCursor(55, 68);
  tft.print(String(cachedBmpAlt, 1) + " m");

  tft.setTextColor(COLOR_TITLE);
  tft.setCursor(8, 86);
  tft.print("Press SW -> Record 5s");

  drawStatusBar("BMP280 OK | Click SW Rec", COLOR_OK);
}

void drawDHT22Screen() {
  drawHeader("DHT22 Sensor (2/7)");
  clearContent();
  tft.setTextSize(1);

  if (!cachedDhtValid) {
    tft.setTextColor(COLOR_ERROR);
    tft.setCursor(8, 38);
    tft.print("DHT22 DISCONNECTED!");
    tft.setTextColor(COLOR_LABEL);
    tft.setCursor(8, 54);
    tft.print("Check VCC/GND/GPIO13");
    drawStatusBar("DHT22 OFFLINE", COLOR_ERROR);
    return;
  }

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 36);
  tft.print("TEMP:");
  tft.setTextColor(COLOR_TEMP);
  tft.setCursor(55, 36);
  tft.print(String(cachedDhtTemp, 1) + " C");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 52);
  tft.print("HUMID:");
  tft.setTextColor(COLOR_HUMID);
  tft.setCursor(55, 52);
  tft.print(String(cachedDhtHum, 1) + " %");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 68);
  tft.print("HEAT ID:");
  tft.setTextColor(COLOR_ALT);
  tft.setCursor(55, 68);
  tft.print(String(cachedDhtHeat, 1) + " C");

  tft.setTextColor(COLOR_TITLE);
  tft.setCursor(8, 86);
  tft.print("Press SW -> Record 5s");

  drawStatusBar("DHT22 OK | Click SW Rec", COLOR_OK);
}

void drawMQ4Screen() {
  drawHeader("MQ-4 Gas Sensor (3/7)");
  clearContent();
  tft.setTextSize(1);

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 36);
  tft.print("RAW ADC:");
  tft.setTextColor(COLOR_TEMP);
  tft.setCursor(62, 36);
  tft.print(String(cachedMq4Raw) + " / 4095");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 52);
  tft.print("VOLTAGE:");
  tft.setTextColor(COLOR_ACCENT);
  tft.setCursor(62, 52);
  tft.print(String(cachedMq4Volt, 2) + " V");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 68);
  tft.print("STATUS:");
  if (cachedMq4Raw < 800) {
    tft.setTextColor(COLOR_OK);
  } else if (cachedMq4Raw < 1800) {
    tft.setTextColor(COLOR_TEMP);
  } else {
    tft.setTextColor(COLOR_ERROR);
  }
  tft.setCursor(55, 68);
  tft.print(cachedMq4Status);

  tft.setTextColor(COLOR_TITLE);
  tft.setCursor(8, 86);
  tft.print("Press SW -> Record 5s");

  uint16_t statusCol = (cachedMq4Raw >= 1800)
                           ? COLOR_ERROR
                           : ((cachedMq4Raw >= 800) ? COLOR_TEMP : COLOR_OK);
  drawStatusBar("MQ-4 (CH4) Sensor Active", statusCol);
}

void drawTurbScreen() {
  drawHeader("Turbidity (4/7)");
  clearContent();
  tft.setTextSize(1);

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 36);
  tft.print("RAW ADC:");
  tft.setTextColor(COLOR_TEMP);
  tft.setCursor(62, 36);
  tft.print(String(cachedTurbRaw) + " / 4095");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 52);
  tft.print("VOLTAGE:");
  tft.setTextColor(COLOR_ACCENT);
  tft.setCursor(62, 52);
  tft.print(String(cachedTurbVolt, 2) + " V");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 68);
  tft.print("CLARITY:");
  if (cachedTurbRaw > 3500) {
    tft.setTextColor(COLOR_OK);
  } else if (cachedTurbRaw > 2000) {
    tft.setTextColor(COLOR_ACCENT);
  } else if (cachedTurbRaw > 1000) {
    tft.setTextColor(COLOR_TEMP);
  } else {
    tft.setTextColor(COLOR_ERROR);
  }
  tft.setCursor(62, 68);
  tft.print(cachedTurbStatus);

  uint16_t statusCol = (cachedTurbRaw < 1000)
                           ? COLOR_ERROR
                           : ((cachedTurbRaw < 2000) ? COLOR_TEMP : COLOR_OK);
  drawStatusBar("Turbidity Sensor Active", statusCol);
}

void drawTDSScreen() {
  drawHeader("TDS Water Meter (5/7)");
  clearContent();
  tft.setTextSize(1);

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 36);
  tft.print("RAW ADC:");
  tft.setTextColor(COLOR_TEMP);
  tft.setCursor(62, 36);
  tft.print(String(cachedTdsRaw) + " / 4095");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 52);
  tft.print("VOLTAGE:");
  tft.setTextColor(COLOR_ACCENT);
  tft.setCursor(62, 52);
  tft.print(String(cachedTdsVolt, 2) + " V");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 68);
  tft.print("TDS:");
  if (cachedTdsPpm < 300) {
    tft.setTextColor(COLOR_OK);
  } else if (cachedTdsPpm < 600) {
    tft.setTextColor(COLOR_ACCENT);
  } else if (cachedTdsPpm < 900) {
    tft.setTextColor(COLOR_TEMP);
  } else {
    tft.setTextColor(COLOR_ERROR);
  }
  tft.setCursor(40, 68);
  tft.print(String(cachedTdsPpm, 0) + " ppm");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 84);
  tft.print("QUALITY:");
  tft.setCursor(62, 84);
  if (cachedTdsPpm < 300) {
    tft.setTextColor(COLOR_OK);
  } else if (cachedTdsPpm < 900) {
    tft.setTextColor(COLOR_TEMP);
  } else {
    tft.setTextColor(COLOR_ERROR);
  }
  tft.print(cachedTdsStatus);

  uint16_t statusCol = (cachedTdsPpm >= 900)
                           ? COLOR_ERROR
                           : ((cachedTdsPpm >= 300) ? COLOR_TEMP : COLOR_OK);
  drawStatusBar("TDS Water Quality Active", statusCol);
}

void drawMoistureScreen() {
  drawHeader("Soil Moisture (6/7)");
  clearContent();
  tft.setTextSize(1);

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 34);
  tft.print("MOISTURE:");

  uint16_t col;
  if (cachedMoisturePercent < 25)
    col = COLOR_SOIL_DRY;
  else if (cachedMoisturePercent <= 75)
    col = COLOR_SOIL_OPT;
  else
    col = COLOR_SOIL_WET;

  tft.setTextColor(col);
  tft.setCursor(72, 34);
  tft.print(String(cachedMoisturePercent) + " %");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 48);
  tft.print("RAW / VOLT:");
  tft.setTextColor(COLOR_TITLE);
  tft.setCursor(76, 48);
  tft.print(String(cachedMoistureRaw) + " | " + String(cachedMoistureVolt, 2) +
            "V");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 62);
  tft.print("STATUS:");
  tft.setTextColor(col);
  tft.setCursor(55, 62);
  tft.print(cachedMoistureStatus);

  int barX = 8;
  int barY = 78;
  int barW = 144;
  int barH = 10;
  tft.drawRect(barX, barY, barW, barH, COLOR_DIVIDER);
  int fillW =
      map(constrain(cachedMoisturePercent, 0, 100), 0, 100, 0, barW - 2);
  if (fillW > 0) {
    tft.fillRect(barX + 1, barY + 1, fillW, barH - 2, col);
  }
  if (fillW < barW - 2) {
    tft.fillRect(barX + 1 + fillW, barY + 1, (barW - 2) - fillW, barH - 2,
                 COLOR_BG);
  }

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 92);
  tft.print("0% (Dry) ------ 100% (Wet)");

  drawStatusBar("Soil Moisture Sensor Pin 14", col);
}

void drawSystemScreen() {
  drawHeader("System & RTC (7/7)");
  clearContent();
  tft.setTextSize(1);

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 34);
  tft.print("RTC:");
  if (rtcFound) {
    tft.setTextColor(COLOR_TEMP);
    tft.setCursor(34, 34);
    tft.print(cachedRtcTime);
    tft.setTextColor(COLOR_LABEL);
    tft.setCursor(90, 34);
    tft.print(cachedRtcDate.substring(5));
  } else {
    tft.setTextColor(COLOR_ERROR);
    tft.setCursor(34, 34);
    tft.print("NOT FOUND");
  }

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 50);
  tft.print("IP:");
  tft.setTextColor(COLOR_ACCENT);
  tft.setCursor(30, 50);
  tft.print(WiFi.localIP().toString());

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 66);
  tft.print("MIC:");
  tft.setTextColor(COLOR_OK);
  tft.setCursor(38, 66);
  tft.print(hasRecording ? "CLIP READY" : "IDLE");

  tft.setTextColor(COLOR_LABEL);
  tft.setCursor(8, 82);
  tft.print("PSRAM:");
  tft.setTextColor(COLOR_ALT);
  tft.setCursor(50, 82);
  tft.print(String(ESP.getFreePsram() / 1024) + " KB free");

  drawStatusBar("WiFi Connected | IP Ready", COLOR_OK);
}

void renderCurrentScreen() {
  switch (currentScreen) {
  case SCREEN_BMP280:
    drawBMP280Screen();
    break;
  case SCREEN_DHT22:
    drawDHT22Screen();
    break;
  case SCREEN_MQ4:
    drawMQ4Screen();
    break;
  case SCREEN_TURB:
    drawTurbScreen();
    break;
  case SCREEN_TDS:
    drawTDSScreen();
    break;
  case SCREEN_MOISTURE:
    drawMoistureScreen();
    break;
  case SCREEN_SYSTEM:
    drawSystemScreen();
    break;
  default:
    drawBMP280Screen();
    break;
  }
}

void recordAudio() {
  if (isRecording)
    return;
  isRecording = true;

  drawHeader("RECORDING MIC...");
  clearContent();
  tft.setTextSize(1);
  tft.setTextColor(COLOR_REC);
  tft.setCursor(8, 40);
  tft.print("RECORDING (5 SEC)...");
  tft.setTextColor(COLOR_TITLE);
  tft.setCursor(8, 56);
  tft.print("Speak into INMP441!");
  drawStatusBar("REC IN PROGRESS...", COLOR_REC);

  if (wavBuffer == nullptr) {
    wavBuffer = (uint8_t *)ps_malloc(WAV_FILE_SIZE);
    if (wavBuffer == nullptr) {
      Serial.println("[ERROR] Failed to allocate PSRAM for WAV buffer!");
      isRecording = false;
      showStatus("MIC Error!", "PSRAM alloc failed", COLOR_ERROR);
      vTaskDelay(pdMS_TO_TICKS(1500));
      renderCurrentScreen();
      return;
    }
  }

  writeWavHeader(wavBuffer, WAV_DATA_SIZE);
  int16_t *audioData = (int16_t *)(wavBuffer + WAV_HEADER_SIZE);

  i2s_zero_dma_buffer(I2S_NUM_1);

  const size_t CHUNK_SAMPLES = 1024;
  int32_t raw_i2s_chunk[CHUNK_SAMPLES];
  size_t samples_recorded = 0;
  size_t bytes_read = 0;

  Serial.println("[MIC] Starting 5-second audio capture...");
  unsigned long recStartTime = millis();

  while (samples_recorded < MIC_NUM_SAMPLES) {
    size_t samples_to_read = min((size_t)CHUNK_SAMPLES,
                                 (size_t)(MIC_NUM_SAMPLES - samples_recorded));
    esp_err_t res =
        i2s_read(I2S_NUM_1, raw_i2s_chunk, samples_to_read * sizeof(int32_t),
                 &bytes_read, portMAX_DELAY);

    if (res == ESP_OK && bytes_read > 0) {
      size_t actual_samples = bytes_read / sizeof(int32_t);
      for (size_t i = 0; i < actual_samples; i++) {
        audioData[samples_recorded + i] = (int16_t)(raw_i2s_chunk[i] >> 14);
      }
      samples_recorded += actual_samples;
    }

    recordingProgress = (samples_recorded * 100) / MIC_NUM_SAMPLES;
    recordingElapsedSec = (millis() - recStartTime) / 1000;

    int barW = map(recordingProgress, 0, 100, 0, tft.width() - 16);
    tft.fillRect(8, 76, barW, 8, COLOR_REC);
  }

  Serial.println("[MIC] Recording complete! Total samples: " +
                 String(samples_recorded));
  hasRecording = true;
  isRecording = false;
  recordingProgress = 100;

  showStatus("Recording Saved!", "5s WAV Ready in PSRAM", COLOR_OK);
  drawStatusBar("Audio Received & Ready", COLOR_OK);
  vTaskDelay(pdMS_TO_TICKS(1500));
  renderCurrentScreen();
}

const char DASHBOARD_HTML[] PROGMEM = R"rawliteral(
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>KRISHI KAVACH - Farm Node Dashboard</title>
  <style>
    :root {
      --bg: #0b0f19;
      --card-bg: rgba(22, 30, 49, 0.85);
      --border: rgba(255, 255, 255, 0.08);
      --text: #f1f5f9;
      --text-dim: #94a3b8;
      --accent-safe: #10b981;
      --accent-warn: #f59e0b;
      --accent-danger: #ef4444;
      --accent-blue: #38bdf8;
      --accent-cyan: #06b6d4;
    }
    * { box-sizing: border-box; margin: 0; padding: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
    body { background: var(--bg); color: var(--text); padding: 1.5rem; display: flex; flex-direction: column; gap: 1.5rem; min-height: 100vh; }
    header { background: var(--card-bg); padding: 1rem 1.5rem; border-radius: 12px; border: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; }
    .brand { font-size: 1.3rem; font-weight: bold; }
    .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 1.25rem; }
    .card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 12px; padding: 1.25rem; display: flex; flex-direction: column; gap: 0.75rem; }
    .card-title { font-size: 0.9rem; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.05em; display: flex; justify-content: space-between; }
    .val-large { font-size: 1.8rem; font-weight: 700; color: var(--text); }
    .val-sub { font-size: 0.85rem; color: var(--text-dim); }
    .badge { padding: 0.2rem 0.6rem; border-radius: 9999px; font-size: 0.75rem; font-weight: 600; }
    .badge-ok { background: rgba(16,185,129,0.2); color: var(--accent-safe); }
    .badge-warn { background: rgba(245,158,11,0.2); color: var(--accent-warn); }
    .badge-danger { background: rgba(239,68,68,0.2); color: var(--accent-danger); }
    .btn { background: #2563eb; color: white; border: none; padding: 0.6rem 1.2rem; border-radius: 8px; cursor: pointer; font-weight: 600; text-align: center; text-decoration: none; display: inline-block; }
    .btn:hover { background: #1d4ed8; }
    .progress-bar { width: 100%; height: 8px; background: rgba(255,255,255,0.1); border-radius: 4px; overflow: hidden; }
    .progress-fill { height: 100%; background: var(--accent-blue); width: 0%; transition: width 0.3s; }
  </style>
</head>
<body>
  <header>
    <div class="brand">🌱 KRISHI KAVACH <span style="font-size: 0.9rem; color: var(--accent-safe); font-weight: normal;">ESP32-S3 Agri-Node</span></div>
    <div id="clockDisplay" style="font-family: monospace; font-size: 1rem; color: var(--accent-cyan);">--:--:--</div>
  </header>

  <div class="grid">
    <!-- Soil Moisture -->
    <div class="card">
      <div class="card-title">
        <span>🌱 Soil Moisture (GPIO 14)</span>
        <span class="badge badge-ok" id="moistBadge">OPTIMAL</span>
      </div>

      <div class="val-large" id="moistPercent">-- %</div>
      <div class="progress-bar"><div class="progress-fill" id="moistBar"></div></div>
      <div class="val-sub" id="moistSub">Raw: -- | Voltage: -- V</div>
    </div>

    <!-- BMP280 -->
    <div class="card">
      <div class="card-title"><span>🌡️ Air Temp & Pressure</span></div>
      <div class="val-large" id="bmpTemp">-- °C</div>
      <div class="val-sub" id="bmpPress">Pressure: -- hPa | Alt: -- m</div>
    </div>

    <!-- DHT22 -->
    <div class="card">
      <div class="card-title"><span>💧 Humidity & Heat Index</span></div>
      <div class="val-large" id="dhtHum">-- %</div>
      <div class="val-sub" id="dhtHeat">Temp: -- °C | Heat Index: -- °C</div>
    </div>

    <!-- MQ-4 Gas -->
    <div class="card">
      <div class="card-title">
        <span>🔥 Methane / Gas (MQ-4)</span>
        <span class="badge badge-ok" id="mq4Badge">CLEAN</span>
      </div>
      <div class="val-large" id="mq4Raw">--</div>
      <div class="val-sub" id="mq4Volt">Voltage: -- V</div>
    </div>

    <!-- Turbidity -->
    <div class="card">
      <div class="card-title"><span>🌊 Water Turbidity</span></div>
      <div class="val-large" id="turbStatus">--</div>
      <div class="val-sub" id="turbRaw">ADC: -- / 4095 | Voltage: -- V</div>
    </div>

    <!-- TDS -->
    <div class="card">
      <div class="card-title"><span>🧪 TDS Water Quality</span></div>
      <div class="val-large" id="tdsPpm">-- ppm</div>
      <div class="val-sub" id="tdsStatus">Quality: --</div>
    </div>

    <!-- Microphone & Audio -->
    <div class="card">
      <div class="card-title"><span>🎙️ INMP441 Mic Audio</span></div>
      <div style="display: flex; gap: 0.5rem; flex-direction: column;">
        <button class="btn" onclick="triggerRecord()">🔴 Record 5s Clip</button>
        <a href="/audio.wav" class="btn" style="background: #059669;" download>⬇️ Download WAV</a>
      </div>
      <div class="val-sub" id="micStatus">Status: Idle</div>
    </div>

    <!-- Servo Control (Full Width Card) -->
    <div class="card" style="grid-column: 1 / -1;">
      <div class="card-title">
        <span>🎯 Extinguisher Aim Servo (GPIO 21 — Full Range: 0° to 180°)</span>
        <span class="badge badge-ok" id="servoBadge">90° (CENTER)</span>
      </div>
      <div style="display: flex; align-items: center; gap: 1.25rem; margin-top: 0.5rem;">
        <input type="range" id="servoSlider" min="0" max="180" value="90" style="flex: 1; height: 12px; accent-color: #38bdf8; cursor: pointer;" oninput="onServoSlider(this.value)">
        <span class="val-large" id="servoAngleDisplay" style="min-width: 90px; text-align: right; color: var(--accent-blue);">90°</span>
      </div>
      <div style="display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 0.75rem;">
        <button class="btn" style="background: #334155; padding: 0.5rem 1rem; font-size: 0.85rem;" onclick="setServo(0)">0° (Full Left)</button>
        <button class="btn" style="background: #334155; padding: 0.5rem 1rem; font-size: 0.85rem;" onclick="setServo(45)">45°</button>
        <button class="btn" style="background: #2563eb; padding: 0.5rem 1rem; font-size: 0.85rem;" onclick="setServo(90)">90° (Center)</button>
        <button class="btn" style="background: #334155; padding: 0.5rem 1rem; font-size: 0.85rem;" onclick="setServo(135)">135°</button>
        <button class="btn" style="background: #334155; padding: 0.5rem 1rem; font-size: 0.85rem;" onclick="setServo(180)">180° (Full Right)</button>
        <button class="btn" id="sweepBtn" style="background: #7c3aed; padding: 0.5rem 1rem; font-size: 0.85rem;" onclick="toggleAutoSweep()">🔄 Full Sweep</button>
      </div>
    </div>
  </div>

  <script>
    let isUserDragging = false;
    let autoSweepTimer = null;

    async function updateData() {
      try {
        const res = await fetch('/api/sensors');
        if (!res.ok) return;
        const d = await res.json();

        document.getElementById('clockDisplay').innerText = `${d.date} ${d.time}`;

        document.getElementById('moistPercent').innerText = d.moisture_percent + ' %';
        document.getElementById('moistBar').style.width = Math.min(100, Math.max(0, d.moisture_percent)) + '%';
        document.getElementById('moistSub').innerText = `Raw: ${d.moisture_raw} | Voltage: ${d.moisture_volt.toFixed(2)} V`;
        const mBadge = document.getElementById('moistBadge');
        mBadge.innerText = d.moisture_status;
        mBadge.className = 'badge ' + (d.moisture_percent < 25 ? 'badge-danger' : (d.moisture_percent <= 75 ? 'badge-ok' : 'badge-warn'));

        document.getElementById('bmpTemp').innerText = d.bmp_temp.toFixed(1) + ' °C';
        document.getElementById('bmpPress').innerText = `Pressure: ${d.bmp_press.toFixed(1)} hPa | Alt: ${d.bmp_alt.toFixed(0)} m`;

        document.getElementById('dhtHum').innerText = d.dht_hum.toFixed(1) + ' %';
        document.getElementById('dhtHeat').innerText = `Temp: ${d.dht_temp.toFixed(1)} °C | Heat Idx: ${d.dht_heat.toFixed(1)} °C`;

        document.getElementById('mq4Raw').innerText = d.mq4_raw;
        document.getElementById('mq4Volt').innerText = `Voltage: ${d.mq4_volt.toFixed(2)} V`;
        const mqBadge = document.getElementById('mq4Badge');
        mqBadge.innerText = d.mq4_status;
        mqBadge.className = 'badge ' + (d.mq4_raw >= 1800 ? 'badge-danger' : (d.mq4_raw >= 800 ? 'badge-warn' : 'badge-ok'));

        document.getElementById('turbStatus').innerText = d.turb_status;
        document.getElementById('turbRaw').innerText = `ADC: ${d.turb_raw} | Voltage: ${d.turb_volt.toFixed(2)} V`;

        document.getElementById('tdsPpm').innerText = d.tds_ppm.toFixed(0) + ' ppm';
        document.getElementById('tdsStatus').innerText = `Quality: ${d.tds_status}`;

        if (!isUserDragging && d.servo_angle !== undefined && !autoSweepTimer) {
          document.getElementById('servoSlider').value = d.servo_angle;
          document.getElementById('servoAngleDisplay').innerText = d.servo_angle + '°';
          updateServoBadge(d.servo_angle, d.fire_tracking);
        }
      } catch (e) {}
    }

    function updateServoBadge(angle, isFire) {
      const badge = document.getElementById('servoBadge');
      if (isFire) {
        badge.innerText = angle + '° (🚨 FIRE LOCKED)';
        badge.className = 'badge badge-danger';
      } else if (angle == 90) {
        badge.innerText = '90° (CENTER)';
        badge.className = 'badge badge-ok';
      } else if (angle <= 20) {
        badge.innerText = angle + '° (FULL LEFT)';
        badge.className = 'badge badge-warn';
      } else if (angle >= 160) {
        badge.innerText = angle + '° (FULL RIGHT)';
        badge.className = 'badge badge-warn';
      } else {
        badge.innerText = angle + '°';
        badge.className = 'badge badge-ok';
      }
    }

    let lastSent = 0;
    function onServoSlider(val) {
      isUserDragging = true;
      document.getElementById('servoAngleDisplay').innerText = val + '°';
      updateServoBadge(val, false);
      
      const now = Date.now();
      if (now - lastSent > 80) {
        lastSent = now;
        sendServoAngle(val);
      }
      clearTimeout(window.dragTimeout);
      window.dragTimeout = setTimeout(() => { isUserDragging = false; sendServoAngle(val); }, 300);
    }

    async function setServo(angle) {
      if (autoSweepTimer) toggleAutoSweep();
      document.getElementById('servoSlider').value = angle;
      document.getElementById('servoAngleDisplay').innerText = angle + '°';
      updateServoBadge(angle, false);
      await sendServoAngle(angle);
    }

    async function sendServoAngle(angle) {
      try {
        await fetch('/api/servo?angle=' + angle, { method: 'POST' });
      } catch (e) {}
    }

    function toggleAutoSweep() {
      const btn = document.getElementById('sweepBtn');
      if (autoSweepTimer) {
        clearInterval(autoSweepTimer);
        autoSweepTimer = null;
        btn.innerText = "🔄 Full Sweep";
        btn.style.background = "#7c3aed";
        setServo(90);
      } else {
        btn.innerText = "⏹️ Stop Sweep";
        btn.style.background = "#ef4444";
        let curAngle = 0;
        let dir = 5;
        autoSweepTimer = setInterval(() => {
          curAngle += dir;
          if (curAngle >= 180) dir = -5;
          if (curAngle <= 0) dir = 5;
          document.getElementById('servoSlider').value = curAngle;
          document.getElementById('servoAngleDisplay').innerText = curAngle + '°';
          updateServoBadge(curAngle, false);
          sendServoAngle(curAngle);
        }, 150);
      }
    }
...

This file has been truncated, please download it to see its full contents.

Credits

Moksha Kumbhaj
2 projects • 1 follower
Heya! I'm Moksh, a student from Shillong, Meghalaya. I enjoy exploring new ideas, building things and figuring out how stuff works.

Comments