05 / CONNECTED DEVICES

From a scenario to a light.

Configure home-device tasks, understand the local demo and build an ESP32 Arduino light controller.

One scenario per task.

Say “Set the living room light to 25% brightness.” Lilith selects home_brightness, and the host resolves a configured device name. Separate home_on, home_off and home_color scenarios handle the other changes. home_state reads actual reported state. Addresses and API keys come from trusted host configuration, never model-supplied URLs.

A local demonstration first.

The V15 chat uses an isolated dummy living_room_light. Its state persists, but every receipt says simulated and no hardware is contacted. Ask prepares an exact device/task/value proposal for /home-approve or /home-reject. YOLO is selected at boot and sends immediately. Brightness accepts whole percentages from 0 to 100, colour accepts #RRGGBB, and turning power off retains the chosen brightness and colour.

ESP32 with the Arduino framework.

This sketch targets an ESP32 DevKit with Arduino-ESP32 3.x and ArduinoJson 7.x. Use a common-cathode low-voltage RGB LED with a current-limiting resistor on each colour, connected to GPIO 25, 26 and 27. A strip requires suitable external MOSFET drivers and its own supply; do not power a strip from GPIO. This example does not control mains wiring.

Set your Wi-Fi credentials and a random private API key in the sketch before uploading it. The serial monitor prints the device IP. Use a DHCP reservation, configure that IP in home-devices.arduino.example.json, and put the same key in the LILITH_LIVING_ROOM_KEY environment variable. Set LILITH_HOME_CONFIG to the configuration file before starting Lilith. Keep this plain-HTTP example on a trusted isolated LAN; it is not an Internet endpoint.

The payload contract.

POST /api/light accepts requestId, device, action and value with the X-Lilith-Key header. Power uses a boolean, brightness an integer percentage, and color a hexadecimal string. GET /api/light returns state. A write must echo the request ID and device with status applied and the requested state. An acknowledgement is not sensor verification of the physical light. Timeouts or mismatched replies are unverified; read state before issuing another command. Lilith does not automatically resend uncertain physical commands.

JSON
{"requestId":"0123456789abcdef0123456789abcdef","device":"living_room_light","action":"brightness","value":25}

The complete sketch.

This is the exact sketch from V18, commit 8d896b8a86e5. The localhost emulator tests its JSON contract; a physical board and firmware upload have not been tested.

ARDUINODirectivesKeywordsTypesFunctionsStringsValuesComments
// ESP32 DevKit, Arduino-ESP32 core 3.x, ArduinoJson 7.x.
// Common-cathode low-voltage RGB LED, one current-limiting resistor per colour.
// For a strip, use suitable external MOSFET drivers and a separate supply.
#include <WiFi.h>
#include <WebServer.h>
#include <ArduinoJson.h>

const char* WIFI_SSID = "REPLACE_WIFI_SSID";
const char* WIFI_PASSWORD = "REPLACE_WIFI_PASSWORD";
const char* API_KEY = "REPLACE_WITH_A_RANDOM_32_CHARACTER_KEY";
const char* DEVICE_ID = "living_room_light";
constexpr uint8_t RED_PIN = 25, GREEN_PIN = 26, BLUE_PIN = 27;
WebServer server(80);
bool powerOn = true;
int brightness = 100;
String colour = "#FFFFFF";
String lastId, lastPayload, lastResponse;

bool authorized() {
  if (server.header("X-Lilith-Key") == API_KEY) return true;
  server.send(401, "application/json", "{\"status\":\"unauthorized\"}");
  return false;
}
bool validHex(const String& value, size_t length) {
  if (value.length() != length) return false;
  for (size_t i = 0; i < length; ++i) if (!isxdigit(value[i])) return false;
  return true;
}
void errorResponse(int code, const char* status) {
  JsonDocument doc; doc["status"] = status;
  String body; serializeJson(doc, body); server.send(code, "application/json", body);
}
bool applyLight(bool enabled, int percent, const String& hex) {
  unsigned long rgb = strtoul(hex.substring(1).c_str(), nullptr, 16);
  unsigned int level = enabled ? percent : 0;
  bool r = ledcWrite(RED_PIN, ((rgb >> 16) & 255) * level / 100);
  bool g = ledcWrite(GREEN_PIN, ((rgb >> 8) & 255) * level / 100);
  bool b = ledcWrite(BLUE_PIN, (rgb & 255) * level / 100);
  return r && g && b;
}
String stateResponse(const char* status, const String& requestId = "") {
  JsonDocument doc; doc["device"] = DEVICE_ID; doc["status"] = status;
  if (requestId.length()) doc["requestId"] = requestId;
  doc["state"]["power"] = powerOn;
  doc["state"]["brightness"] = brightness;
  doc["state"]["color"] = colour;
  String body; serializeJson(doc, body); return body;
}
void readLight() {
  if (!authorized()) return;
  server.send(200, "application/json", stateResponse("state"));
}
void writeLight() {
  if (!authorized()) return;
  String body = server.arg("plain");
  if (body.length() > 512) { errorResponse(413, "too_large"); return; }
  JsonDocument doc;
  if (deserializeJson(doc, body) || !doc.is<JsonObject>() || doc.size() != 4 ||
      !doc["requestId"].is<const char*>() || !doc["device"].is<const char*>() ||
      !doc["action"].is<const char*>() || !doc.containsKey("value")) {
    errorResponse(400, "invalid_request"); return;
  }
  String id = doc["requestId"].as<String>();
  String action = doc["action"].as<String>();
  if (!validHex(id, 32) || doc["device"].as<String>() != DEVICE_ID) { errorResponse(400, "wrong_identity"); return; }
  String serializedValue; serializeJson(doc["value"], serializedValue);
  String payload = action + "|" + serializedValue;
  if (id == lastId) {
    if (payload != lastPayload) { errorResponse(409, "request_id_conflict"); return; }
    server.send(200, "application/json", lastResponse); return;
  }
  bool nextPower = powerOn; int nextBrightness = brightness; String nextColour = colour;
  if (action == "power" && doc["value"].is<bool>()) nextPower = doc["value"].as<bool>();
  else if (action == "brightness" && doc["value"].is<int>() && doc["value"].as<int>() >= 0 && doc["value"].as<int>() <= 100) nextBrightness = doc["value"].as<int>();
  else if (action == "color" && doc["value"].is<const char*>()) {
    nextColour = doc["value"].as<String>();
    if (nextColour.length() != 7 || nextColour[0] != '#' || !validHex(nextColour.substring(1), 6)) { errorResponse(400, "invalid_color"); return; }
    nextColour.toUpperCase();
  } else { errorResponse(400, "unsupported_action_or_value"); return; }
  if (!applyLight(nextPower, nextBrightness, nextColour)) { errorResponse(500, "pwm_failed"); return; }
  powerOn = nextPower; brightness = nextBrightness; colour = nextColour;
  lastId = id; lastPayload = payload; lastResponse = stateResponse("applied", id);
  server.send(200, "application/json", lastResponse);
}
void setup() {
  Serial.begin(115200);
  if (String(API_KEY).startsWith("REPLACE_") || String(WIFI_SSID).startsWith("REPLACE_")) {
    Serial.println("Set WiFi credentials and a private API key before use.");
    while (true) delay(1000);
  }
  if (!ledcAttach(RED_PIN, 5000, 8) || !ledcAttach(GREEN_PIN, 5000, 8) || !ledcAttach(BLUE_PIN, 5000, 8)) {
    Serial.println("PWM initialization failed."); while (true) delay(1000);
  }
  applyLight(powerOn, brightness, colour);
  WiFi.begin(WIFI_SSID, WIFI_PASSWORD);
  while (WiFi.status() != WL_CONNECTED) delay(500);
  Serial.print("Lilith light IP: "); Serial.println(WiFi.localIP());
  const char* headers[] = {"X-Lilith-Key"}; server.collectHeaders(headers, 1);
  server.on("/api/light", HTTP_GET, readLight);
  server.on("/api/light", HTTP_POST, writeLight);
  server.onNotFound([]() { errorResponse(404, "not_found"); });
  server.begin();
}
void loop() { server.handleClient(); }

Reference APIs.

Espressif LEDC PWM API and ArduinoJson 7 parser. No credentials are included in this downloadable example.