#include "server/api_server.hpp" #include "domain/layout_profile.hpp" #include "domain/layout_schema.hpp" #include "mission/mission_enqueue.hpp" #include "util/http_util.hpp" #include "util/id_util.hpp" #include "util/string_util.hpp" #include "validation/sensor_validator.hpp" namespace lm { ApiServer::ApiServer(StateRepository& repo, MissionQueue& mission_queue, MissionStore& mission_store, ModbusTriggerService& modbus, MissionScheduler& scheduler) : repo_(repo), mission_queue_(mission_queue), mission_store_(mission_store), modbus_(modbus), scheduler_(scheduler) { } void ApiServer::registerRoutes(httplib::Server& svr) { svr.Options(R"(/api/(.*))", [](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); res.status = 204; }); svr.Get("/api/health", [](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = nlohmann::json({{"ok", true}}).dump(); }); svr.Get("/api/state", [this](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); repo_.ensureSchema(); std::string active_name; const auto idx = LayoutProfile::findActiveIndex(repo_.app().state); if (idx) active_name = repo_.app().state["layouts"][*idx]["name"].get(); const nlohmann::json response = {{"version", repo_.app().state.value("version", 3)}, {"active_layout_id", repo_.app().state["active_layout_id"]}, {"active_layout_name", active_name}, {"layouts", LayoutProfile::buildCatalog(repo_.app().state)}, {"layout", repo_.app().state["layout"]}, {"lidars", repo_.app().state["lidars"]}, {"imus", repo_.app().state.contains("imus") ? repo_.app().state["imus"] : nlohmann::json::array()}}; res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = response.dump(); }); svr.Get("/api/layouts", [this](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); repo_.ensureSchema(); const nlohmann::json response = {{"active_layout_id", repo_.app().state["active_layout_id"]}, {"layouts", LayoutProfile::buildCatalog(repo_.app().state)}}; res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = response.dump(); }); svr.Post("/api/layouts", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); repo_.ensureSchema(); nlohmann::json payload; try { payload = nlohmann::json::parse(req.body); } catch (...) { return HttpUtil::jsonError(res, 400, "invalid JSON"); } if (!payload.is_object() || !payload.contains("name") || !payload["name"].is_string()) return HttpUtil::jsonError(res, 400, "name is required"); const std::string name = StringUtil::trimCopy(payload["name"].get()); if (name.empty()) return HttpUtil::jsonError(res, 400, "name is required"); if (LayoutProfile::nameExists(repo_.app().state, name)) return HttpUtil::jsonError(res, 409, "layout name already exists"); const bool clone = payload.contains("clone") && payload["clone"].is_boolean() && payload["clone"].get(); nlohmann::json layout = LayoutSchema::defaultLayoutObject(); nlohmann::json lidars = nlohmann::json::array(); nlohmann::json imus = nlohmann::json::array(); if (clone) { layout = repo_.app().state["layout"]; lidars = repo_.app().state["lidars"]; imus = repo_.app().state.contains("imus") && repo_.app().state["imus"].is_array() ? repo_.app().state["imus"] : nlohmann::json::array(); } nlohmann::json profile = LayoutProfile::make(name, layout, lidars, imus); LayoutSchema::ensure(profile["layout"]); if (!repo_.saveProfile(profile)) return HttpUtil::jsonError(res, 500, "failed to save layout file"); repo_.app().state["layouts"].push_back(LayoutProfile::catalogEntryFromProfile(profile)); repo_.app().state["active_layout_id"] = profile["id"].get(); repo_.reloadActiveCache(); repo_.save(); res.status = 201; res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = profile.dump(); }); svr.Post(R"(/api/layouts/([0-9a-fA-F]+)/activate)", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); const std::string id = req.matches[1].str(); repo_.ensureSchema(); if (!LayoutProfile::findIndex(repo_.app().state, id)) return HttpUtil::jsonError(res, 404, "layout not found"); repo_.app().state["active_layout_id"] = id; repo_.reloadActiveCache(); repo_.save(); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = nlohmann::json({{"ok", true}, {"active_layout_id", id}}).dump(); }); svr.Delete(R"(/api/layouts/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); const std::string id = req.matches[1].str(); repo_.ensureSchema(); if (!repo_.app().state.contains("layouts") || !repo_.app().state["layouts"].is_array()) return HttpUtil::jsonError(res, 404, "layout not found"); if (repo_.app().state["layouts"].size() <= 1) return HttpUtil::jsonError(res, 400, "cannot delete the last layout"); const auto idx = LayoutProfile::findIndex(repo_.app().state, id); if (!idx) return HttpUtil::jsonError(res, 404, "layout not found"); const bool was_active = repo_.app().state.contains("active_layout_id") && repo_.app().state["active_layout_id"].get() == id; repo_.deleteProfile(id); repo_.app().state["layouts"].erase(repo_.app().state["layouts"].begin() + static_cast(*idx)); if (was_active) repo_.app().state["active_layout_id"] = repo_.app().state["layouts"][0]["id"].get(); repo_.reloadActiveCache(); repo_.save(); res.status = 204; }); svr.Get("/api/lidars", [this](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = repo_.app().state["lidars"].dump(); }); svr.Post("/api/lidars", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); nlohmann::json payload; try { payload = nlohmann::json::parse(req.body); } catch (...) { return HttpUtil::jsonError(res, 400, "invalid JSON"); } std::string err; if (!SensorValidator::validateLidarPayload(payload, err)) return HttpUtil::jsonError(res, 400, err); const std::string name = StringUtil::trimCopy(payload["name"].get()); const std::string ip = StringUtil::trimCopy(payload["ip"].get()); const int port = payload["port"].get(); if (SensorValidator::lidarTripletExists(repo_.app().state, name, ip, port)) return HttpUtil::jsonError(res, 409, "lidar with same name, ip and port already exists"); nlohmann::json lidar = { {"id", IdUtil::newId()}, {"name", name}, {"ip", ip}, {"port", port}, }; repo_.app().state["lidars"].push_back(lidar); if (!repo_.saveAppState()) return HttpUtil::jsonError(res, 500, "failed to save layout"); res.status = 201; res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = lidar.dump(); }); svr.Put(R"(/api/lidars/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); const std::string id = req.matches[1].str(); nlohmann::json payload; try { payload = nlohmann::json::parse(req.body); } catch (...) { return HttpUtil::jsonError(res, 400, "invalid JSON"); } std::string err; if (!SensorValidator::validateLidarPayload(payload, err)) return HttpUtil::jsonError(res, 400, err); auto idx = SensorValidator::findLidarIndex(repo_.app().state, id); if (!idx) return HttpUtil::jsonError(res, 404, "lidar not found"); const std::string name = StringUtil::trimCopy(payload["name"].get()); const std::string ip = StringUtil::trimCopy(payload["ip"].get()); const int port = payload["port"].get(); if (SensorValidator::lidarTripletExists(repo_.app().state, name, ip, port, &id)) return HttpUtil::jsonError(res, 409, "lidar with same name, ip and port already exists"); auto& lidar = repo_.app().state["lidars"][*idx]; lidar["name"] = name; lidar["ip"] = ip; lidar["port"] = port; if (!repo_.saveAppState()) return HttpUtil::jsonError(res, 500, "failed to save layout"); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = lidar.dump(); }); svr.Delete(R"(/api/lidars/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); const std::string id = req.matches[1].str(); auto idx = SensorValidator::findLidarIndex(repo_.app().state, id); if (!idx) return HttpUtil::jsonError(res, 404, "lidar not found"); repo_.app().state["lidars"].erase(repo_.app().state["lidars"].begin() + static_cast(*idx)); // Also remove pose entry if present if (repo_.app().state.contains("layout") && repo_.app().state["layout"].is_object()) { if (repo_.app().state["layout"].contains("lidarPoses") && repo_.app().state["layout"]["lidarPoses"].is_object()) repo_.app().state["layout"]["lidarPoses"].erase(id); if (repo_.app().state["layout"].contains("lidarPositions") && repo_.app().state["layout"]["lidarPositions"].is_object()) repo_.app().state["layout"]["lidarPositions"].erase(id); } if (!repo_.saveAppState()) return HttpUtil::jsonError(res, 500, "failed to save layout"); res.status = 204; }); svr.Get("/api/layout", [this](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = repo_.app().state["layout"].dump(); }); svr.Put("/api/layout", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); nlohmann::json payload; try { payload = nlohmann::json::parse(req.body); } catch (...) { return HttpUtil::jsonError(res, 400, "invalid JSON"); } if (!payload.is_object()) return HttpUtil::jsonError(res, 400, "layout must be an object"); repo_.app().state["layout"] = payload; if (!repo_.saveAppState()) return HttpUtil::jsonError(res, 500, "failed to save layout"); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = repo_.app().state["layout"].dump(); }); svr.Put(R"(/api/layouts/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); const std::string id = req.matches[1].str(); nlohmann::json payload; try { payload = nlohmann::json::parse(req.body); } catch (...) { return HttpUtil::jsonError(res, 400, "invalid JSON"); } if (!payload.is_object()) return HttpUtil::jsonError(res, 400, "payload must be an object"); repo_.ensureSchema(); const auto idx = LayoutProfile::findIndex(repo_.app().state, id); if (!idx) return HttpUtil::jsonError(res, 404, "layout not found"); auto loaded = repo_.loadProfileById(id); if (!loaded) return HttpUtil::jsonError(res, 404, "layout file not found"); nlohmann::json profile = *loaded; if (payload.contains("name") && payload["name"].is_string()) { const std::string name = StringUtil::trimCopy(payload["name"].get()); if (name.empty()) return HttpUtil::jsonError(res, 400, "name is required"); if (LayoutProfile::nameExists(repo_.app().state, name, &id)) return HttpUtil::jsonError(res, 409, "layout name already exists"); profile["name"] = name; } if (payload.contains("layout") && payload["layout"].is_object()) profile["layout"] = payload["layout"]; if (payload.contains("lidars") && payload["lidars"].is_array()) profile["lidars"] = payload["lidars"]; if (payload.contains("imus") && payload["imus"].is_array()) profile["imus"] = payload["imus"]; if (!profile.contains("imus") || !profile["imus"].is_array()) profile["imus"] = nlohmann::json::array(); LayoutSchema::ensure(profile["layout"]); LayoutProfile::touch(profile); if (!repo_.saveProfile(profile)) return HttpUtil::jsonError(res, 500, "failed to save layout file"); repo_.app().state["layouts"][*idx] = LayoutProfile::catalogEntryFromProfile(profile); const bool is_active = repo_.app().state.contains("active_layout_id") && repo_.app().state["active_layout_id"].get() == id; if (is_active) { repo_.app().state["layout"] = profile["layout"]; repo_.app().state["lidars"] = profile["lidars"]; repo_.app().state["imus"] = profile["imus"]; } repo_.save(); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = profile.dump(); }); svr.Get("/api/imus", [this](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); repo_.ensureSchema(); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = (repo_.app().state.contains("imus") ? repo_.app().state["imus"] : nlohmann::json::array()).dump(); }); svr.Post("/api/imus", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); nlohmann::json payload; try { payload = nlohmann::json::parse(req.body); } catch (...) { return HttpUtil::jsonError(res, 400, "invalid JSON"); } std::string err; if (!SensorValidator::validateImuPayload(payload, err)) return HttpUtil::jsonError(res, 400, err); const std::string name = StringUtil::trimCopy(payload["name"].get()); const std::string frame_id = StringUtil::trimCopy(payload["frame_id"].get()); const std::string topic = StringUtil::trimCopy(payload["topic"].get()); if (SensorValidator::imuFrameExists(repo_.app().state, frame_id)) return HttpUtil::jsonError(res, 409, "imu with same frame_id already exists"); if (!repo_.app().state.contains("imus") || !repo_.app().state["imus"].is_array()) repo_.app().state["imus"] = nlohmann::json::array(); const std::string source = payload.contains("source") && payload["source"].is_string() ? payload["source"].get() : "external"; const bool enabled = !payload.contains("enabled") || payload["enabled"].get(); const double rate_hz = payload.contains("rate_hz") && payload["rate_hz"].is_number() ? payload["rate_hz"].get() : 100.0; nlohmann::json imu = {{"id", IdUtil::newId()}, {"name", name}, {"frame_id", frame_id}, {"topic", topic}, {"source", source}, {"enabled", enabled}, {"rate_hz", rate_hz}}; repo_.app().state["imus"].push_back(imu); if (!repo_.saveAppState()) return HttpUtil::jsonError(res, 500, "failed to save layout"); res.status = 201; res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = imu.dump(); }); svr.Put(R"(/api/imus/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); const std::string id = req.matches[1].str(); nlohmann::json payload; try { payload = nlohmann::json::parse(req.body); } catch (...) { return HttpUtil::jsonError(res, 400, "invalid JSON"); } std::string err; if (!SensorValidator::validateImuPayload(payload, err)) return HttpUtil::jsonError(res, 400, err); auto idx = SensorValidator::findImuIndex(repo_.app().state, id); if (!idx) return HttpUtil::jsonError(res, 404, "imu not found"); const std::string name = StringUtil::trimCopy(payload["name"].get()); const std::string frame_id = StringUtil::trimCopy(payload["frame_id"].get()); const std::string topic = StringUtil::trimCopy(payload["topic"].get()); if (SensorValidator::imuFrameExists(repo_.app().state, frame_id, &id)) return HttpUtil::jsonError(res, 409, "imu with same frame_id already exists"); auto& imu = repo_.app().state["imus"][*idx]; imu["name"] = name; imu["frame_id"] = frame_id; imu["topic"] = topic; if (payload.contains("source") && payload["source"].is_string()) imu["source"] = payload["source"]; if (payload.contains("enabled") && payload["enabled"].is_boolean()) imu["enabled"] = payload["enabled"]; if (payload.contains("rate_hz") && payload["rate_hz"].is_number()) imu["rate_hz"] = payload["rate_hz"]; if (!repo_.saveAppState()) return HttpUtil::jsonError(res, 500, "failed to save layout"); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = imu.dump(); }); svr.Delete(R"(/api/imus/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); const std::string id = req.matches[1].str(); auto idx = SensorValidator::findImuIndex(repo_.app().state, id); if (!idx) return HttpUtil::jsonError(res, 404, "imu not found"); repo_.app().state["imus"].erase(repo_.app().state["imus"].begin() + static_cast(*idx)); if (repo_.app().state.contains("layout") && repo_.app().state["layout"].is_object()) { if (repo_.app().state["layout"].contains("imuPoses") && repo_.app().state["layout"]["imuPoses"].is_object()) repo_.app().state["layout"]["imuPoses"].erase(id); } if (!repo_.saveAppState()) return HttpUtil::jsonError(res, 500, "failed to save layout"); res.status = 204; }); svr.Get("/api/mission_queue", [this](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = nlohmann::json({{"queue", mission_queue_.list()}, {"runner", mission_queue_.runnerStatus()}}).dump(); }); svr.Post("/api/mission_queue", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); nlohmann::json payload; try { payload = nlohmann::json::parse(req.body); } catch (...) { return HttpUtil::jsonError(res, 400, "invalid JSON"); } if (!payload.contains("source")) payload["source"] = "ui"; enqueueRequest(payload, res, 201); }); svr.Delete("/api/mission_queue", [this](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); std::string err; if (!mission_queue_.clearAll(err)) return HttpUtil::jsonError(res, 400, err); res.status = 204; }); svr.Put("/api/mission_queue/reorder", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); nlohmann::json payload; try { payload = nlohmann::json::parse(req.body); } catch (...) { return HttpUtil::jsonError(res, 400, "invalid JSON"); } if (!payload.contains("ordered_ids") || !payload["ordered_ids"].is_array()) return HttpUtil::jsonError(res, 400, "ordered_ids is required"); std::string err; if (!mission_queue_.reorder(payload["ordered_ids"], err)) return HttpUtil::jsonError(res, 400, err); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = nlohmann::json({{"ok", true}}).dump(); }); svr.Delete(R"(/api/mission_queue/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); const std::string id = req.matches[1].str(); std::string err; if (!mission_queue_.removeById(id, err)) return HttpUtil::jsonError(res, 400, err); res.status = 204; }); svr.Post("/api/mission_queue/pause", [this](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); std::string err; if (!mission_queue_.pause(err)) return HttpUtil::jsonError(res, 400, err); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = mission_queue_.runnerStatus().dump(); }); svr.Post("/api/mission_queue/continue", [this](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); std::string err; if (!mission_queue_.resume(err)) return HttpUtil::jsonError(res, 400, err); res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = mission_queue_.runnerStatus().dump(); }); registerMissionRoutes(svr); registerIntegrationRoutes(svr); registerMirV2Routes(svr); } } // namespace lm