Hardware

Our custom-built, wearable hardware enables precise, non-invasive optogenetic control for the LEGO gene therapy system.

Hardware

Introduction


Translating optogenetic therapies like our LEGO system from the laboratory to the clinic requires a practical, user-centric device for precise light delivery. While laboratory equipment offers precision, it is often bulky, expensive, and unsuitable for long-term wearable use. Similarly, current pharmacological treatments for metabolic diseases lack spatiotemporal control.

To bridge this gap, we developed the DiaLight, a wearable hardware device powered by an ESP32 microcontroller. This compact, low-cost solution creates its own Wi-Fi hotspot, allowing it to be controlled directly from any web browser for the programmable delivery of blue light, thereby making the precise and personalized activation of our therapeutic system a practical reality.

Key Features

The controller is built with patient convenience and practical application in mind.

  • Wireless Control: The device hosts its own Wi-Fi hotspot. Users can connect with any smartphone or computer and access the control panel through a web browser without needing an internet connection or a dedicated app.
  • Wearable & Compact: Its small form factor and battery power allow it to be worn discreetly, for example, as a patch on the skin or integrated into a belt.
  • Low Power Consumption: The ESP32's deep-sleep capabilities ensure long battery life, reducing the need for frequent recharging.
  • Precision & Flexibility: The web interface allows for real-time manual brightness adjustments and the creation of complex, multi-step light animation presets for tailored therapeutic regimens.
Core Components
  • ESP32 microcontroller (Wi-Fi + low power)
  • Battery (rechargeable Li-Po with protection circuit)
  • Blue LEDs (high-efficiency, tuned to 465-470nm spectrum)
ESP32 (MCU) Wi-Fi & Logic Li-Po Battery Blue LED Array Connects → Phone Browser (Hotspot) | Controls → LED PWM/Animations
#include <WiFi.h>
#include <WebServer.h>
#include <DNSServer.h>
#include <ArduinoJson.h>
#include "SPIFFS.h"
#include <vector>

const char* apSSID = "ESP32_LED_Control";
const char* apPassword = "";

IPAddress apIP(192, 168, 4, 1);
DNSServer dnsServer;
WebServer server(80);

const int ledPin = 21;
const int pwmFreq = 5000;
const int pwmResolution = 8;
const int MAX_DUTY = (1 << pwmResolution) - 1;

struct LedStep {
  int brightness; // 0-100
  unsigned long duration_ms;
};

struct LedSequence {
  String name;
  std::vector<LedStep> steps;
  bool isCustom;
};

std::vector<LedSequence> sequences;

volatile bool isPlaying = false;
volatile int currentSequenceId = -1;
volatile int currentStepIndex = 0;
volatile unsigned long stepStartTime = 0;
volatile int currentPercent = 0;

void handleRoot();
void handleSet();
void handleState();
void handleGetPresets();
void handlePlay();
void handleStop();
void handleAddPreset();
void handleDeletePreset();
void handleNotFound();
void updateAnimation();
void loadPresets();
void savePresets();
void setLedBrightness(int percent);


void setup() {
  Serial.begin(115200);
  delay(100);

  ledcAttach(ledPin, pwmFreq, pwmResolution);
  setLedBrightness(0);

  if(!SPIFFS.begin(true)){
    Serial.println("An Error has occurred while mounting SPIFFS");
    return;
  }
  Serial.println("SPIFFS mounted successfully");

  loadPresets();

  if (strlen(apPassword) >= 8) {
    WiFi.softAP(apSSID, apPassword);
  } else {
    WiFi.softAP(apSSID);
  }
  WiFi.softAPConfig(apIP, apIP, IPAddress(255,255,255,0));
  Serial.println();
  Serial.print("AP started. SSID: ");
  Serial.println(apSSID);
  Serial.print("AP IP: ");
  Serial.println(WiFi.softAPIP());

  dnsServer.start(53, "*", apIP);

  server.on("/", HTTP_GET, handleRoot);
  server.on("/set", HTTP_GET, handleSet);
  server.on("/state", HTTP_GET, handleState);
  server.on("/presets", HTTP_GET, handleGetPresets);
  server.on("/play", HTTP_GET, handlePlay);
  server.on("/stop", HTTP_GET, handleStop);
  server.on("/add", HTTP_POST, handleAddPreset);
  server.on("/delete", HTTP_GET, handleDeletePreset);
  server.onNotFound(handleNotFound);

  server.begin();
  Serial.println("HTTP server started");

  SPIFFS.remove("/presets.json");
}

void loop() {
  dnsServer.processNextRequest();
  server.handleClient();

  updateAnimation();
}

void setLedBrightness(int percent) {
  if (percent < 0) percent = 0;
  if (percent > 100) percent = 100;

  int duty = map(percent, 0, 100, 0, MAX_DUTY);
  ledcWrite(ledPin, duty);

  if (!isPlaying) {
    currentPercent = percent;
  }
}

void updateAnimation() {
  if (!isPlaying) {
    return;
  }

  unsigned long currentTime = millis();
  if (currentTime - stepStartTime >= sequences[currentSequenceId].steps[currentStepIndex].duration_ms) {
    currentStepIndex++;

    if (currentStepIndex >= sequences[currentSequenceId].steps.size()) {
      isPlaying = false;
      currentSequenceId = -1;
      setLedBrightness(0);
      Serial.println("Animation finished.");
    } else {
      stepStartTime = currentTime;
      LedStep nextStep = sequences[currentSequenceId].steps[currentStepIndex];
      setLedBrightness(nextStep.brightness);
      Serial.printf("Playing step %d: brightness %d%%", currentStepIndex, nextStep.brightness);
    }
  }
}



void handleSet() {
  if (isPlaying) {
    server.send(403, "application/json", "{"error":"Animation is playing, please stop it first."}");
    return;
  }

  if (!server.hasArg("value")) {
    server.send(400, "application/json", "{"error":"missing value"}");
    return;
  }
  int p = server.arg("value").toInt();
  setLedBrightness(p);

  String json = String("{"value":") + String(currentPercent) + String("}");
  server.send(200, "application/json", json);
}

void handleState() {
  String json = "{"value":" + String(currentPercent) + ", "isPlaying":" + (isPlaying ? "true" : "false") + "}";
  server.send(200, "application/json", json);
}

void handlePlay() {
  if (!server.hasArg("id")) {
    server.send(400, "application/json", "{"error":"missing id"}");
    return;
  }
  int id = server.arg("id").toInt();
  if (id < 0 || id >= sequences.size()) {
    server.send(404, "application/json", "{"error":"preset not found"}");
    return;
  }

  isPlaying = true;
  currentSequenceId = id;
  currentStepIndex = 0;
  stepStartTime = millis();

  LedStep firstStep = sequences[id].steps[0];
  setLedBrightness(firstStep.brightness);

  Serial.printf("Starting animation '%s'...", sequences[id].name.c_str());
  Serial.printf("Playing step 0: brightness %d%%", firstStep.brightness);

  server.send(200, "application/json", "{"status":"playing"}");
}

void handleStop() {
  if (isPlaying) {
    isPlaying = false;
    currentSequenceId = -1;
    setLedBrightness(0);
    Serial.println("Animation stopped by user.");
  }
  server.send(200, "application/json", "{"status":"stopped"}");
}


void handleGetPresets() {
  JsonDocument doc;
  JsonArray array = doc.to<JsonArray>();

  for (int i = 0; i < sequences.size(); i++) {
    JsonObject preset = array.add<JsonObject>();
    preset["id"] = i;
    preset["name"] = sequences[i].name;
    preset["isCustom"] = sequences[i].isCustom;
    // JsonArray steps = preset.createNestedArray("steps");
    // for(const auto& step : sequences[i].steps){
    //   JsonObject s = steps.createNestedObject();
    //   s["b"] = step.brightness;
    //   s["d"] = step.duration_ms;
    // }
  }

  String output;
  serializeJson(doc, output);
  server.send(200, "application/json", output);
}

void handleAddPreset() {
  if (server.hasArg("plain") == false) {
    server.send(400, "application/json", "{"error":"body is missing"}");
    return;
  }
  String body = server.arg("plain");
  JsonDocument doc;
  DeserializationError error = deserializeJson(doc, body);

  if (error) {
    Serial.print(F("deserializeJson() failed: "));
    Serial.println(error.c_str());
    server.send(400, "application/json", "{"error":"invalid json"}");
    return;
  }

  LedSequence newSeq;
  newSeq.name = doc["name"].as<String>();
  newSeq.isCustom = true;

  JsonArray steps = doc["steps"].as<JsonArray>();
  for (JsonObject step : steps) {
    int b = step["b"];
    unsigned long d = step["d"];
    newSeq.steps.push_back({b, d});
  }

  sequences.push_back(newSeq);
  savePresets();
  Serial.printf("Added new custom preset: %s
", newSeq.name.c_str());
  server.send(200, "application/json", "{"status":"success"}");
}

void handleDeletePreset() {
    if (!server.hasArg("id")) {
    server.send(400, "application/json", "{"error":"missing id"}");
    return;
  }
  int id = server.arg("id").toInt();
  if (id < 0 || id >= sequences.size() || !sequences[id].isCustom) {
    server.send(404, "application/json", "{"error":"preset not found or is not custom"}");
    return;
  }

  Serial.printf("Deleting preset: %s
", sequences[id].name.c_str());
  sequences.erase(sequences.begin() + id);
  savePresets();

  server.send(200, "application/json", "{"status":"deleted"}");
}


void loadPresets() {
  sequences.clear();


  if (SPIFFS.exists("/presets.json")) {
    File file = SPIFFS.open("/presets.json", "r");
    if (file) {
      JsonDocument doc;
      DeserializationError error = deserializeJson(doc, file);
      if (!error) {
        JsonArray array = doc.as<JsonArray>();
        for (JsonObject presetObj : array) {
          LedSequence seq;
          seq.name = presetObj["name"].as<String>();
          seq.isCustom = true;
          JsonArray stepsArr = presetObj["steps"].as<JsonArray>();
          for (JsonObject stepObj : stepsArr) {
            seq.steps.push_back({stepObj["b"], stepObj["d"]});
          }
          sequences.push_back(seq);
        }
        Serial.println("Loaded custom presets from SPIFFS.");
      } else {
        Serial.println("Failed to parse presets.json");
      }
      file.close();
    }
  } else {
    Serial.println("presets.json not found. No custom presets loaded.");
  }
}

void savePresets() {
  JsonDocument doc;
  JsonArray array = doc.to<JsonArray>();

  for (const auto& seq : sequences) {
    if (seq.isCustom) {
      JsonObject presetObj = array.add<JsonObject>();
      presetObj["name"] = seq.name;
      JsonArray stepsArr = presetObj.createNestedArray("steps");
      for (const auto& step : seq.steps) {
        JsonObject stepObj = stepsArr.add<JsonObject>();
        stepObj["b"] = step.brightness;
        stepObj["d"] = step.duration_ms;
      }
    }
  }

  File file = SPIFFS.open("/presets.json", "w");
  if (file) {
    serializeJson(doc, file);
    file.close();
    Serial.println("Saved custom presets to SPIFFS.");
  } else {
    Serial.println("Failed to open presets.json for writing.");
  }
}

void handleRoot() {
  const char* html = R"rawliteral(
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>ESP32 LED Control</title>
  <style>
    body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; text-align:center; padding:20px; background-color:#f0f2f5; color:#333; }
    .card { max-width:420px; margin:20px auto; padding:18px; border-radius:12px; box-shadow:0 6px 18px rgba(0,0,0,0.12); background-color:white; }
    h1, h2 { margin:6px 0 14px; font-size:20px; color:#1c1e21; border-bottom: 1px solid #ddd; padding-bottom: 10px;}
    h2 { font-size: 18px; margin-top: 25px; }
    input[type=range] { width:100%; -webkit-appearance: none; height: 8px; background: #ddd; border-radius: 5px; outline: none; transition: background 0.2s; }
    input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 20px; height: 20px; background: #007bff; border-radius: 50%; cursor: pointer; }
    input[type=range]::-moz-range-thumb { width: 20px; height: 20px; background: #007bff; border-radius: 50%; cursor: pointer; }
    .value { font-size:24px; margin-top:10px; font-weight: bold; color: #007bff;}
    .hint { font-size:12px; color:#666; margin-top:10px; }
    button { margin: 5px; padding:10px 14px; border-radius:8px; border:0; cursor:pointer; font-weight: bold; background-color: #007bff; color:white; transition: background-color 0.2s; }
    button:hover { background-color: #0056b3; }
    .btn-group { display:flex; justify-content: center; gap: 10px; }
    #stopBtn { background-color: #dc3545; }
    #stopBtn:hover { background-color: #c82333; }
    #presets-list { list-style: none; padding: 0; }
    #presets-list li { display: flex; justify-content: space-between; align-items: center; padding: 10px; border-bottom: 1px solid #eee; }
    .preset-name { text-align: left; }
    .btn-delete { background-color: #ffc107; color: #333; padding: 5px 10px; font-size: 12px; }
    .btn-delete:hover { background-color: #e0a800; }
    #add-preset-form input { width: calc(50% - 10px); margin: 5px; padding: 8px; border-radius: 5px; border: 1px solid #ccc; }
    #steps-container .step-row { display: flex; align-items: center; }
    #status { margin-top: 15px; font-weight: bold; color: #28a745; height: 20px;}
  </style>
</head>
<body>
  <div class="card">
    <h1>Manual Control</h1>
    <div id="manual-control-area">
      <input id="slider" type="range" min="0" max="100" value="0" />
      <div class="value"><span id="pct">0</span>%</div>
      <div class="hint">Slide to adjust brightness</div>
      <div class="btn-group">
        <button id="btnOff">Off (0%)</button>
        <button id="btnFull">Full (100%)</button>
      </div>
    </div>
    <div id="status"></div>
  </div>

  <div class="card">
    <h2>Animation Presets</h2>
    <ul id="presets-list"></ul>
    <button id="stopBtn">Stop Animation</button>
  </div>

  <div class="card">
    <h2>Add Custom Preset</h2>
    <form id="add-preset-form" onsubmit="return false;">
      <input type="text" id="preset-name" placeholder="Preset Name" required><br>
      <div id="steps-container">
        <div class="step-row">
          <input type="number" min="0" max="100" placeholder="Brightness (0-100)" required>
          <input type="number" min="1" placeholder="Duration (ms)" required>
        </div>
      </div>
      <button type="button" id="add-step-btn">Add Step</button>
      <button type="submit" id="save-preset-btn">Save Preset</button>
    </form>
  </div>

<script>
  // DOM Elements
  const slider = document.getElementById('slider');
  const pct = document.getElementById('pct');
  const btnOff = document.getElementById('btnOff');
  const btnFull = document.getElementById('btnFull');
  const presetsList = document.getElementById('presets-list');
  const stopBtn = document.getElementById('stopBtn');
  const manualControlArea = document.getElementById('manual-control-area');
  const statusDiv = document.getElementById('status');

  // Custom Preset Form
  const addPresetForm = document.getElementById('add-preset-form');
  const presetNameInput = document.getElementById('preset-name');
  const stepsContainer = document.getElementById('steps-container');
  const addStepBtn = document.getElementById('add-step-btn');
  const savePresetBtn = document.getElementById('save-preset-btn');

  let debounceTimer;

  // --- Core Functions ---
  function sendValue(v) {
    fetch('/set?value=' + v)
      .then(resp => resp.json())
      .then(data => { if(data.error) alert(data.error); })
      .catch(e => console.error('request failed', e));
  }

  function fetchState() {
    fetch('/state').then(r => r.json()).then(j => {
      slider.value = j.value;
      pct.innerText = j.value;
      if (j.isPlaying) {
        manualControlArea.style.opacity = '0.5';
        slider.disabled = true;
        statusDiv.innerText = 'Animation playing...';
      } else {
        manualControlArea.style.opacity = '1';
        slider.disabled = false;
        statusDiv.innerText = '';
      }
    }).catch(err => console.error('get state failed', err));
  }

  function fetchPresets() {
    fetch('/presets').then(r => r.json()).then(presets => {
      presetsList.innerHTML = '';
      presets.forEach(p => {
        const li = document.createElement('li');
        li.innerHTML = `<span class="preset-name">${p.name}</span>
          <div class="btn-group">
            ${p.isCustom ? `<button class="btn-delete" onclick="deletePreset(${p.id})">Del</button>` : ''}
            <button onclick="playPreset(${p.id})">Play</button>
          </div>`;
        presetsList.appendChild(li);
      });
    }).catch(err => console.error('get presets failed', err));
  }

  function playPreset(id) {
    fetch('/play?id=' + id)
      .then(r => r.json())
      .then(() => {
        setTimeout(fetchState, 100); // give esp32 a moment to update state
      });
  }

  function deletePreset(id) {
    if(!confirm('Are you sure you want to delete this preset?')) return;
    fetch('/delete?id=' + id)
      .then(r => r.json())
      .then(() => {
        fetchPresets(); // Refresh list
      });
  }

  // --- Event Listeners ---
  slider.addEventListener('input', () => {
    const v = slider.value;
    pct.innerText = v;
    // Debounce to avoid flooding the ESP32 with requests
    clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => sendValue(v), 50);
  });

  btnOff.addEventListener('click', () => {
    slider.value = 0;
    pct.innerText = 0;
    sendValue(0);
  });

  btnFull.addEventListener('click', () => {
    slider.value = 100;
    pct.innerText = 100;
    sendValue(100);
  });

  stopBtn.addEventListener('click', () => {
    fetch('/stop')
      .then(r => r.json())
      .then(() => {
        setTimeout(fetchState, 100);
      });
  });

  // --- Custom Preset Form Logic ---
  addStepBtn.addEventListener('click', () => {
    const newStepRow = document.createElement('div');
    newStepRow.className = 'step-row';
    newStepRow.innerHTML = `
      <input type="number" min="0" max="100" placeholder="Brightness (0-100)" required>
      <input type="number" min="1" placeholder="Duration (ms)" required>
    `;
    stepsContainer.appendChild(newStepRow);
  });

  addPresetForm.addEventListener('submit', () => {
    const presetData = {
      name: presetNameInput.value,
      steps: []
    };

    const stepRows = stepsContainer.querySelectorAll('.step-row');
    let isValid = true;
    stepRows.forEach(row => {
      const bInput = row.children[0];
      const dInput = row.children[1];
      if (bInput.value === '' || dInput.value === '') {
        isValid = false;
      }
      presetData.steps.push({
        b: parseInt(bInput.value),
        d: parseInt(dInput.value)
      });
    });

    if (!isValid || presetNameInput.value === '') {
      alert('Please fill in all fields for the preset.');
      return;
    }

    fetch('/add', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(presetData)
    })
    .then(r => r.json())
    .then(data => {
      if (data.status === 'success') {
        alert('Preset saved!');
        addPresetForm.reset();
        stepsContainer.innerHTML = `
          <div class="step-row">
            <input type="number" min="0" max="100" placeholder="Brightness (0-100)" required>
            <input type="number" min="1" placeholder="Duration (ms)" required>
          </div>`;
        fetchPresets(); // Refresh the presets list
      } else {
        alert('Error saving preset: ' + data.error);
      }
    })
    .catch(e => console.error('Save failed', e));
  });

  // --- Initial Load ---
  window.addEventListener('load', () => {
    fetchState();
    fetchPresets();
    // Periodically update state in case animation finishes
    setInterval(fetchState, 2000);
  });
</script>
</body>
</html>
)rawliteral";

  server.send(200, "text/html", html);
}

void handleNotFound() {
  String redirectURL = String("http://") + apIP.toString() + String("/");
  server.sendHeader("Location", redirectURL, true);
  server.send(302, "text/plain", "");
}
Show more ▼

Firmware Architecture


The controller's software is modularly designed for stability and extensibility. Here is a breakdown of the core components of the ESP32 firmware:

1. Configuration Module

Initializes system parameters. Sets up the Wi-Fi hotspot credentials (SSID, password), the static IP address for the server, and configures the GPIO pin for the LED with PWM (Pulse Width Modulation) channels for brightness control.

2. Data Structure Module

Defines how light patterns are stored. An "animation" is a sequence of steps, where each step has a specific brightness and duration. This allows for creating complex light exposure profiles.

3. Web Server Module

Runs a lightweight HTTP server on the ESP32. It handles incoming requests from the user's browser, serving the HTML/CSS/JS for the UI and providing API endpoints to control the LED.

4. Animation Control Module

The engine that plays back the light sequences. It's a non-blocking state machine that executes each animation step without halting the web server, ensuring the UI remains responsive at all times.

5. File Storage Module

Utilizes the ESP32's built-in SPIFFS (SPI Flash File System) to save user-defined animation presets. This makes custom treatment protocols persistent even after the device is powered off.

6. Main Loop & UI

The main loop() continuously services web server requests and updates the animation state machine. The self-contained webpage UI uses JavaScript to communicate with the server's endpoints.

7. Wearable Strap Module

A flexible strap with a Velcro fastener, enabling secure and adjustable attachment to the user's body for consistent light delivery.

Interactive Hardware Simulation

This simulator mirrors the original ESP32 web UI. It's client-only (no backend). Presets and custom animations live only while the page is open.

Manual Control

0%
Slide to adjust brightness
Animation Presets
    Add Custom Preset
    Simulated Wearable Device
    Device Status
    IDLE

    Usage

    Download the PDF from here.

    Your browser does not support PDF files. Download the file instead

    References

    1. Tarver, M., & FDA. (2024). FDA Clears First Device to Enable Automated Insulin Dosing for Individuals with Type 2 Diabetes. U.S. Food and Drug Administration. Retrieved from https://www.fda.gov/news-events/press-announcements/fda-clears-first-device-enable-automated-insulin-dosing-individuals-type-2-diabetes

    2. Mendelsohn, A. (2025). Lessons Learned From Vivani Medical's Study Of GLP-1 Implant. Family Doctor. Retrieved from https://m.familydoctor.cn/news/vivanimedical-zhiruwu-yanjiu-zhong-huode-64651.html