418 lines
12 KiB
C++
418 lines
12 KiB
C++
/**
|
|
* Claude's Eyes - Webserver Implementation
|
|
*
|
|
* REST API for controlling the robot
|
|
*/
|
|
|
|
#include "webserver.h"
|
|
#include "config.h"
|
|
#include "camera.h"
|
|
#include "motor_control.h"
|
|
#include "servo_control.h"
|
|
#include "ultrasonic.h"
|
|
#include "imu.h"
|
|
#include "display.h"
|
|
#include <WiFi.h>
|
|
|
|
// Global instance
|
|
WebServerModule WebServer;
|
|
|
|
WebServerModule::WebServerModule()
|
|
: _server(WEBSERVER_PORT)
|
|
, _claudeTextTimestamp(0)
|
|
, _claudeTextNew(false)
|
|
, _batteryPercent(100) // TODO: Implement battery reading
|
|
{
|
|
}
|
|
|
|
bool WebServerModule::begin() {
|
|
setupRoutes();
|
|
_server.begin();
|
|
Serial.printf("[WebServer] Started on port %d\n", WEBSERVER_PORT);
|
|
return true;
|
|
}
|
|
|
|
void WebServerModule::setupRoutes() {
|
|
// CORS preflight
|
|
_server.on("/*", HTTP_OPTIONS, [](AsyncWebServerRequest* request) {
|
|
AsyncWebServerResponse* response = request->beginResponse(200);
|
|
response->addHeader("Access-Control-Allow-Origin", "*");
|
|
response->addHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
response->addHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
request->send(response);
|
|
});
|
|
|
|
// GET /api/capture - Camera image
|
|
_server.on("/api/capture", HTTP_GET, [this](AsyncWebServerRequest* request) {
|
|
handleCapture(request);
|
|
});
|
|
|
|
// GET /api/status - Sensor data
|
|
_server.on("/api/status", HTTP_GET, [this](AsyncWebServerRequest* request) {
|
|
handleStatus(request);
|
|
});
|
|
|
|
// POST /api/command - Movement commands
|
|
_server.on("/api/command", HTTP_POST,
|
|
[](AsyncWebServerRequest* request) {},
|
|
NULL,
|
|
[this](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
|
|
handleCommand(request, data, len);
|
|
}
|
|
);
|
|
|
|
// GET /api/claude_text - Get Claude's message
|
|
_server.on("/api/claude_text", HTTP_GET, [this](AsyncWebServerRequest* request) {
|
|
handleGetClaudeText(request);
|
|
});
|
|
|
|
// POST /api/claude_text - Set Claude's message
|
|
_server.on("/api/claude_text", HTTP_POST,
|
|
[](AsyncWebServerRequest* request) {},
|
|
NULL,
|
|
[this](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
|
|
handlePostClaudeText(request, data, len);
|
|
}
|
|
);
|
|
|
|
// POST /api/display - Control display
|
|
_server.on("/api/display", HTTP_POST,
|
|
[](AsyncWebServerRequest* request) {},
|
|
NULL,
|
|
[this](AsyncWebServerRequest* request, uint8_t* data, size_t len, size_t index, size_t total) {
|
|
handleDisplay(request, data, len);
|
|
}
|
|
);
|
|
|
|
// Root - Simple status page
|
|
_server.on("/", HTTP_GET, [](AsyncWebServerRequest* request) {
|
|
String html = "<!DOCTYPE html><html><head><title>Claude's Eyes</title>";
|
|
html += "<meta name='viewport' content='width=device-width, initial-scale=1'>";
|
|
html += "<style>body{font-family:sans-serif;background:#1a1a2e;color:#eee;padding:20px;}";
|
|
html += "h1{color:#0ff;}a{color:#0ff;}</style></head><body>";
|
|
html += "<h1>Claude's Eyes</h1>";
|
|
html += "<p>Robot is online!</p>";
|
|
html += "<p>API Endpoints:</p><ul>";
|
|
html += "<li>GET /api/capture - Camera image</li>";
|
|
html += "<li>GET /api/status - Sensor data</li>";
|
|
html += "<li>POST /api/command - Movement</li>";
|
|
html += "<li>GET/POST /api/claude_text - Claude messages</li>";
|
|
html += "</ul>";
|
|
html += "<p><a href='/api/capture?key=" + String(API_KEY) + "'>View Camera</a></p>";
|
|
html += "</body></html>";
|
|
request->send(200, "text/html", html);
|
|
});
|
|
|
|
// 404 handler
|
|
_server.onNotFound([](AsyncWebServerRequest* request) {
|
|
request->send(404, "application/json", "{\"error\":\"Not found\"}");
|
|
});
|
|
}
|
|
|
|
bool WebServerModule::checkAuth(AsyncWebServerRequest* request) {
|
|
if (!request->hasParam("key")) {
|
|
sendError(request, 401, "Missing API key");
|
|
return false;
|
|
}
|
|
|
|
String key = request->getParam("key")->value();
|
|
if (key != API_KEY) {
|
|
sendError(request, 403, "Invalid API key");
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
void WebServerModule::sendError(AsyncWebServerRequest* request, int code, const char* message) {
|
|
JsonDocument doc;
|
|
doc["error"] = message;
|
|
doc["code"] = code;
|
|
|
|
String response;
|
|
serializeJson(doc, response);
|
|
|
|
AsyncWebServerResponse* resp = request->beginResponse(code, "application/json", response);
|
|
resp->addHeader("Access-Control-Allow-Origin", "*");
|
|
request->send(resp);
|
|
}
|
|
|
|
void WebServerModule::sendJson(AsyncWebServerRequest* request, JsonDocument& doc) {
|
|
String response;
|
|
serializeJson(doc, response);
|
|
|
|
AsyncWebServerResponse* resp = request->beginResponse(200, "application/json", response);
|
|
resp->addHeader("Access-Control-Allow-Origin", "*");
|
|
request->send(resp);
|
|
}
|
|
|
|
// ============================================================================
|
|
// API Handlers
|
|
// ============================================================================
|
|
|
|
void WebServerModule::handleCapture(AsyncWebServerRequest* request) {
|
|
if (!checkAuth(request)) return;
|
|
|
|
// Check resolution parameter
|
|
if (request->hasParam("resolution")) {
|
|
String res = request->getParam("resolution")->value();
|
|
CameraResolution camRes = RES_VGA;
|
|
|
|
if (res == "QVGA") camRes = RES_QVGA;
|
|
else if (res == "VGA") camRes = RES_VGA;
|
|
else if (res == "SVGA") camRes = RES_SVGA;
|
|
else if (res == "XGA") camRes = RES_XGA;
|
|
else if (res == "SXGA") camRes = RES_SXGA;
|
|
else if (res == "UXGA") camRes = RES_UXGA;
|
|
|
|
Camera.setResolution(camRes);
|
|
}
|
|
|
|
// Check quality parameter
|
|
int quality = -1;
|
|
if (request->hasParam("quality")) {
|
|
quality = request->getParam("quality")->value().toInt();
|
|
}
|
|
|
|
// Capture frame
|
|
camera_fb_t* fb = Camera.capture(quality);
|
|
if (!fb) {
|
|
sendError(request, 500, Camera.getLastError());
|
|
return;
|
|
}
|
|
|
|
// Send image
|
|
AsyncWebServerResponse* response = request->beginResponse_P(
|
|
200, "image/jpeg", fb->buf, fb->len
|
|
);
|
|
response->addHeader("Access-Control-Allow-Origin", "*");
|
|
response->addHeader("Cache-Control", "no-cache");
|
|
request->send(response);
|
|
|
|
// Return frame buffer
|
|
Camera.returnFrame(fb);
|
|
}
|
|
|
|
void WebServerModule::handleStatus(AsyncWebServerRequest* request) {
|
|
if (!checkAuth(request)) return;
|
|
|
|
JsonDocument doc;
|
|
|
|
// Distance
|
|
doc["distance_cm"] = Ultrasonic.getLastDistance();
|
|
|
|
// Battery (placeholder)
|
|
doc["battery_percent"] = _batteryPercent;
|
|
|
|
// Current action
|
|
doc["current_action"] = Motors.getDirectionString();
|
|
|
|
// IMU data
|
|
const IMUData& imu = IMU.getData();
|
|
doc["imu"]["accel_x"] = imu.accel_x;
|
|
doc["imu"]["accel_y"] = imu.accel_y;
|
|
doc["imu"]["accel_z"] = imu.accel_z;
|
|
doc["imu"]["gyro_x"] = imu.gyro_x;
|
|
doc["imu"]["gyro_y"] = imu.gyro_y;
|
|
doc["imu"]["gyro_z"] = imu.gyro_z;
|
|
doc["imu"]["pitch"] = imu.pitch;
|
|
doc["imu"]["roll"] = imu.roll;
|
|
doc["imu"]["yaw"] = imu.yaw;
|
|
doc["imu"]["temperature"] = imu.temperature;
|
|
|
|
// WiFi
|
|
doc["wifi_rssi"] = WiFi.RSSI();
|
|
|
|
// Uptime
|
|
doc["uptime_seconds"] = getUptime();
|
|
|
|
// Servos
|
|
doc["servo_pan"] = Servos.getPan();
|
|
doc["servo_tilt"] = Servos.getTilt();
|
|
|
|
// Safety flags
|
|
doc["obstacle_warning"] = Ultrasonic.isWarning();
|
|
doc["obstacle_danger"] = Ultrasonic.isDanger();
|
|
doc["is_tilted"] = IMU.isTilted();
|
|
doc["is_moving"] = Motors.isMoving();
|
|
|
|
sendJson(request, doc);
|
|
}
|
|
|
|
void WebServerModule::handleCommand(AsyncWebServerRequest* request, uint8_t* data, size_t len) {
|
|
if (!checkAuth(request)) return;
|
|
|
|
// Parse JSON
|
|
JsonDocument doc;
|
|
DeserializationError error = deserializeJson(doc, data, len);
|
|
|
|
if (error) {
|
|
sendError(request, 400, "Invalid JSON");
|
|
return;
|
|
}
|
|
|
|
String action = doc["action"] | "";
|
|
int speed = doc["speed"] | 50;
|
|
int duration = doc["duration_ms"] | 500;
|
|
|
|
JsonDocument response;
|
|
response["status"] = "ok";
|
|
response["executed"] = action;
|
|
|
|
// Handle movement commands
|
|
if (action == "forward") {
|
|
Motors.move(DIR_FORWARD, speed, duration);
|
|
response["message"] = "Moving forward";
|
|
}
|
|
else if (action == "backward") {
|
|
Motors.move(DIR_BACKWARD, speed, duration);
|
|
response["message"] = "Moving backward";
|
|
}
|
|
else if (action == "left") {
|
|
Motors.move(DIR_LEFT, speed, duration);
|
|
response["message"] = "Turning left";
|
|
}
|
|
else if (action == "right") {
|
|
Motors.move(DIR_RIGHT, speed, duration);
|
|
response["message"] = "Turning right";
|
|
}
|
|
else if (action == "stop") {
|
|
Motors.stop();
|
|
response["message"] = "Stopped";
|
|
}
|
|
// Servo commands
|
|
else if (action == "look_left") {
|
|
Servos.look(LOOK_LEFT);
|
|
response["message"] = "Looking left";
|
|
}
|
|
else if (action == "look_right") {
|
|
Servos.look(LOOK_RIGHT);
|
|
response["message"] = "Looking right";
|
|
}
|
|
else if (action == "look_up") {
|
|
Servos.look(LOOK_UP);
|
|
response["message"] = "Looking up";
|
|
}
|
|
else if (action == "look_down") {
|
|
Servos.look(LOOK_DOWN);
|
|
response["message"] = "Looking down";
|
|
}
|
|
else if (action == "look_center") {
|
|
Servos.look(LOOK_CENTER);
|
|
response["message"] = "Looking center";
|
|
}
|
|
else if (action == "look_custom") {
|
|
int pan = doc["pan"] | 90;
|
|
int tilt = doc["tilt"] | 90;
|
|
Servos.setPosition(pan, tilt);
|
|
response["message"] = "Custom look position";
|
|
}
|
|
else {
|
|
response["status"] = "error";
|
|
response["message"] = "Unknown action";
|
|
}
|
|
|
|
sendJson(request, response);
|
|
}
|
|
|
|
void WebServerModule::handleGetClaudeText(AsyncWebServerRequest* request) {
|
|
if (!checkAuth(request)) return;
|
|
|
|
JsonDocument doc;
|
|
doc["text"] = _claudeText;
|
|
doc["timestamp"] = _claudeTextTimestamp;
|
|
doc["new"] = _claudeTextNew;
|
|
|
|
// Mark as read
|
|
_claudeTextNew = false;
|
|
|
|
sendJson(request, doc);
|
|
}
|
|
|
|
void WebServerModule::handlePostClaudeText(AsyncWebServerRequest* request, uint8_t* data, size_t len) {
|
|
if (!checkAuth(request)) return;
|
|
|
|
JsonDocument doc;
|
|
DeserializationError error = deserializeJson(doc, data, len);
|
|
|
|
if (error) {
|
|
sendError(request, 400, "Invalid JSON");
|
|
return;
|
|
}
|
|
|
|
String text = doc["text"] | "";
|
|
setClaudeText(text);
|
|
|
|
JsonDocument response;
|
|
response["status"] = "ok";
|
|
sendJson(request, response);
|
|
}
|
|
|
|
void WebServerModule::handleDisplay(AsyncWebServerRequest* request, uint8_t* data, size_t len) {
|
|
if (!checkAuth(request)) return;
|
|
|
|
JsonDocument doc;
|
|
DeserializationError error = deserializeJson(doc, data, len);
|
|
|
|
if (error) {
|
|
sendError(request, 400, "Invalid JSON");
|
|
return;
|
|
}
|
|
|
|
String mode = doc["mode"] | "text";
|
|
String content = doc["content"] | "";
|
|
|
|
JsonDocument response;
|
|
response["status"] = "ok";
|
|
|
|
if (mode == "text") {
|
|
Display.showMessage(content.c_str());
|
|
response["message"] = "Text displayed";
|
|
}
|
|
else if (mode == "emoji") {
|
|
EmojiType emoji = EMOJI_HAPPY;
|
|
if (content == "happy") emoji = EMOJI_HAPPY;
|
|
else if (content == "thinking") emoji = EMOJI_THINKING;
|
|
else if (content == "surprised") emoji = EMOJI_SURPRISED;
|
|
else if (content == "sleepy") emoji = EMOJI_SLEEPY;
|
|
else if (content == "curious") emoji = EMOJI_CURIOUS;
|
|
else if (content == "confused") emoji = EMOJI_CONFUSED;
|
|
|
|
Display.showEmoji(emoji);
|
|
response["message"] = "Emoji displayed";
|
|
}
|
|
else if (mode == "status") {
|
|
Display.setMode(MODE_STATUS);
|
|
response["message"] = "Status mode";
|
|
}
|
|
else {
|
|
response["status"] = "error";
|
|
response["message"] = "Unknown mode";
|
|
}
|
|
|
|
sendJson(request, response);
|
|
}
|
|
|
|
void WebServerModule::setClaudeText(const String& text) {
|
|
_claudeText = text;
|
|
_claudeTextTimestamp = millis() / 1000;
|
|
_claudeTextNew = true;
|
|
|
|
// Also update display
|
|
Display.setClaudeText(text.c_str());
|
|
|
|
Serial.printf("[WebServer] Claude text set: %s\n", text.c_str());
|
|
}
|
|
|
|
bool WebServerModule::hasNewClaudeText() {
|
|
return _claudeTextNew;
|
|
}
|
|
|
|
const char* WebServerModule::getCurrentAction() {
|
|
return Motors.getDirectionString();
|
|
}
|
|
|
|
int WebServerModule::getWifiRssi() {
|
|
return WiFi.RSSI();
|
|
}
|