From 853acefac1dcd02539ef947eb2148f7ee3578d5c Mon Sep 17 00:00:00 2001 From: HiepLM Date: Sat, 13 Jun 2026 10:49:41 +0700 Subject: [PATCH] layout source from main --- CMakeLists.txt | 15 + src/app/app_state.hpp | 14 + src/app/lidar_manager_app.cpp | 40 + src/app/lidar_manager_app.hpp | 20 + src/domain/layout_profile.cpp | 105 ++ src/domain/layout_profile.hpp | 32 + src/domain/layout_schema.cpp | 310 ++++++ src/domain/layout_schema.hpp | 18 + src/main.cpp | 1470 +-------------------------- src/server/api_server.cpp | 441 ++++++++ src/server/api_server.hpp | 20 + src/server/static_file_server.cpp | 49 + src/server/static_file_server.hpp | 15 + src/storage/state_repository.cpp | 322 ++++++ src/storage/state_repository.hpp | 49 + src/util/file_util.cpp | 47 + src/util/file_util.hpp | 15 + src/util/http_util.cpp | 41 + src/util/http_util.hpp | 19 + src/util/id_util.cpp | 35 + src/util/id_util.hpp | 14 + src/util/string_util.cpp | 25 + src/util/string_util.hpp | 14 + src/validation/sensor_validator.cpp | 151 +++ src/validation/sensor_validator.hpp | 30 + 25 files changed, 1848 insertions(+), 1463 deletions(-) create mode 100644 src/app/app_state.hpp create mode 100644 src/app/lidar_manager_app.cpp create mode 100644 src/app/lidar_manager_app.hpp create mode 100644 src/domain/layout_profile.cpp create mode 100644 src/domain/layout_profile.hpp create mode 100644 src/domain/layout_schema.cpp create mode 100644 src/domain/layout_schema.hpp create mode 100644 src/server/api_server.cpp create mode 100644 src/server/api_server.hpp create mode 100644 src/server/static_file_server.cpp create mode 100644 src/server/static_file_server.hpp create mode 100644 src/storage/state_repository.cpp create mode 100644 src/storage/state_repository.hpp create mode 100644 src/util/file_util.cpp create mode 100644 src/util/file_util.hpp create mode 100644 src/util/http_util.cpp create mode 100644 src/util/http_util.hpp create mode 100644 src/util/id_util.cpp create mode 100644 src/util/id_util.hpp create mode 100644 src/util/string_util.cpp create mode 100644 src/util/string_util.hpp create mode 100644 src/validation/sensor_validator.cpp create mode 100644 src/validation/sensor_validator.hpp diff --git a/CMakeLists.txt b/CMakeLists.txt index f29f11c..2cd764b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -29,10 +29,25 @@ endif() add_executable(lidar_manager_web src/main.cpp + src/app/lidar_manager_app.cpp + src/util/file_util.cpp + src/util/string_util.cpp + src/util/id_util.cpp + src/util/http_util.cpp + src/domain/layout_schema.cpp + src/domain/layout_profile.cpp + src/storage/state_repository.cpp + src/validation/sensor_validator.cpp + src/server/static_file_server.cpp + src/server/api_server.cpp ) target_link_libraries(lidar_manager_web PRIVATE Threads::Threads) +target_include_directories(lidar_manager_web PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" +) + target_include_directories(lidar_manager_web SYSTEM PRIVATE "${cpp_httplib_SOURCE_DIR}" "${nlohmann_json_SOURCE_DIR}/single_include" diff --git a/src/app/app_state.hpp b/src/app/app_state.hpp new file mode 100644 index 0000000..337baf6 --- /dev/null +++ b/src/app/app_state.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include +#include + +namespace lm { + +struct AppState +{ + std::filesystem::path data_path; + nlohmann::json state; +}; + +} // namespace lm diff --git a/src/app/lidar_manager_app.cpp b/src/app/lidar_manager_app.cpp new file mode 100644 index 0000000..b9b2a2d --- /dev/null +++ b/src/app/lidar_manager_app.cpp @@ -0,0 +1,40 @@ +#include "app/lidar_manager_app.hpp" + +#include "server/api_server.hpp" +#include "server/static_file_server.hpp" +#include "storage/state_repository.hpp" + +#include +#include + +namespace lm { + +LidarManagerApp::LidarManagerApp(int port, + std::filesystem::path www_root, + std::filesystem::path data_path) + : port_(port), www_root_(std::move(www_root)), data_path_(std::move(data_path)) +{ +} + +int LidarManagerApp::run() +{ + StateRepository repo(data_path_); + repo.load(); + + httplib::Server svr; + ApiServer api(repo); + api.registerRoutes(svr); + StaticFileServer::mount(svr, www_root_); + + std::fprintf(stderr, + "lidar_manager_web listening on http://0.0.0.0:%d (www=%s, state=%s, models=%s)\n", + port_, + www_root_.string().c_str(), + data_path_.string().c_str(), + (data_path_.parent_path() / "models").string().c_str()); + + svr.listen("0.0.0.0", port_); + return 0; +} + +} // namespace lm diff --git a/src/app/lidar_manager_app.hpp b/src/app/lidar_manager_app.hpp new file mode 100644 index 0000000..18ef95d --- /dev/null +++ b/src/app/lidar_manager_app.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +namespace lm { + +class LidarManagerApp +{ +public: + LidarManagerApp(int port, std::filesystem::path www_root, std::filesystem::path data_path); + + int run(); + +private: + int port_; + std::filesystem::path www_root_; + std::filesystem::path data_path_; +}; + +} // namespace lm diff --git a/src/domain/layout_profile.cpp b/src/domain/layout_profile.cpp new file mode 100644 index 0000000..7bc80e2 --- /dev/null +++ b/src/domain/layout_profile.cpp @@ -0,0 +1,105 @@ +#include "domain/layout_profile.hpp" + +#include "util/id_util.hpp" +#include "util/string_util.hpp" + +namespace lm { + +nlohmann::json LayoutProfile::make(const std::string& name, + const nlohmann::json& layout, + const nlohmann::json& lidars, + const nlohmann::json& imus) +{ + const std::string ts = IdUtil::nowIso8601(); + return nlohmann::json{{"id", IdUtil::newId()}, + {"name", name}, + {"created_at", ts}, + {"updated_at", ts}, + {"layout", layout}, + {"lidars", lidars}, + {"imus", imus}}; +} + +void LayoutProfile::touch(nlohmann::json& profile) +{ + profile["updated_at"] = IdUtil::nowIso8601(); +} + +std::string LayoutProfile::modelFromLayout(const nlohmann::json& layout) +{ + if (layout.is_object() && layout.contains("robot") && layout["robot"].is_object() && + layout["robot"].contains("model") && layout["robot"]["model"].is_string()) + return layout["robot"]["model"].get(); + return "diff"; +} + +nlohmann::json LayoutProfile::catalogEntryFromProfile(const nlohmann::json& profile) +{ + const nlohmann::json& layout = profile.contains("layout") ? profile["layout"] : nlohmann::json::object(); + const size_t lidar_count = + profile.contains("lidars") && profile["lidars"].is_array() ? profile["lidars"].size() : 0; + const size_t imu_count = + profile.contains("imus") && profile["imus"].is_array() ? profile["imus"].size() : 0; + return nlohmann::json{{"id", profile["id"]}, + {"name", profile["name"]}, + {"model", modelFromLayout(layout)}, + {"created_at", profile.value("created_at", "")}, + {"updated_at", profile.value("updated_at", "")}, + {"lidar_count", lidar_count}, + {"imu_count", imu_count}}; +} + +std::optional LayoutProfile::findIndex(const nlohmann::json& state, const std::string& id) +{ + if (!state.contains("layouts") || !state["layouts"].is_array()) + return std::nullopt; + const auto& layouts = state["layouts"]; + for (size_t i = 0; i < layouts.size(); i++) + { + const auto& p = layouts[i]; + if (p.is_object() && p.contains("id") && p["id"].is_string() && p["id"].get() == id) + return i; + } + return std::nullopt; +} + +std::optional LayoutProfile::findActiveIndex(nlohmann::json& state) +{ + if (!state.contains("active_layout_id") || !state["active_layout_id"].is_string()) + return std::nullopt; + return findIndex(state, state["active_layout_id"].get()); +} + +bool LayoutProfile::nameExists(const nlohmann::json& state, + const std::string& name, + const std::string* exclude_id) +{ + if (!state.contains("layouts") || !state["layouts"].is_array()) + return false; + const std::string n = StringUtil::trimCopy(name); + for (const auto& p : state["layouts"]) + { + if (!p.is_object() || !p.contains("name")) + continue; + if (exclude_id && p.contains("id") && p["id"].get() == *exclude_id) + continue; + if (StringUtil::trimCopy(p["name"].get()) == n) + return true; + } + return false; +} + +nlohmann::json LayoutProfile::buildCatalog(const nlohmann::json& state) +{ + nlohmann::json catalog = nlohmann::json::array(); + if (!state.contains("layouts") || !state["layouts"].is_array()) + return catalog; + for (const auto& p : state["layouts"]) + { + if (!p.is_object() || !p.contains("id") || !p.contains("name")) + continue; + catalog.push_back(p); + } + return catalog; +} +} // namespace lm diff --git a/src/domain/layout_profile.hpp b/src/domain/layout_profile.hpp new file mode 100644 index 0000000..f423aa2 --- /dev/null +++ b/src/domain/layout_profile.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include + +namespace lm { + +class LayoutProfile +{ +public: + static nlohmann::json make(const std::string& name, + const nlohmann::json& layout, + const nlohmann::json& lidars, + const nlohmann::json& imus = nlohmann::json::array()); + + static void touch(nlohmann::json& profile); + static std::string modelFromLayout(const nlohmann::json& layout); + static nlohmann::json catalogEntryFromProfile(const nlohmann::json& profile); + + static std::optional findIndex(const nlohmann::json& state, const std::string& id); + static std::optional findActiveIndex(nlohmann::json& state); + + static bool nameExists(const nlohmann::json& state, + const std::string& name, + const std::string* exclude_id = nullptr); + + static nlohmann::json buildCatalog(const nlohmann::json& state); +}; + +} // namespace lm diff --git a/src/domain/layout_schema.cpp b/src/domain/layout_schema.cpp new file mode 100644 index 0000000..c9b46f0 --- /dev/null +++ b/src/domain/layout_schema.cpp @@ -0,0 +1,310 @@ +#include "domain/layout_schema.hpp" + +namespace lm { + +nlohmann::json LayoutSchema::defaultBicycleWheels() +{ + return nlohmann::json::array( + {nlohmann::json{{"id", "rear"}, + {"role", "drive"}, + {"x_m", 0}, + {"y_m", 0}, + {"joint_name", "rear_wheel_joint"}, + {"motor", + {{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}, + nlohmann::json{{"id", "front"}, + {"role", "steer"}, + {"x_m", 1.2}, + {"y_m", 0}, + {"joint_name", "front_steer_joint"}, + {"motor", + {{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}}); +} + +nlohmann::json LayoutSchema::defaultDiffWheels() +{ + return nlohmann::json::array( + {nlohmann::json{{"id", "left"}, + {"side", "left"}, + {"joint_name", "wheel_left_joint"}, + {"y_m", 0.5}, + {"motor", + {{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}, + nlohmann::json{{"id", "right"}, + {"side", "right"}, + {"joint_name", "wheel_right_joint"}, + {"y_m", -0.5}, + {"motor", + {{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}}); +} + +nlohmann::json LayoutSchema::defaultLayoutObject() +{ + return nlohmann::json{ + {"robot", + {{"x", 400}, + {"y", 300}, + {"yaw_deg", 0}, + {"frame_id", "base_footprint"}, + {"model", "diff"}, + {"diff", + {{"wheel_separation_m", 1.0}, + {"wheel_radius_m", 0.3}, + {"wheel_separation_multiplier", 1.0}, + {"wheel_radius_multiplier", 1.0}, + {"display", {{"scale_m_per_px", 0.005}, {"b_px", 200}, {"d_px", 120}}}, + {"limits", + {{"cmd_vel_timeout_s", 0.25}, + {"linear", + {{"max_velocity", 1.0}, + {"min_velocity", -0.5}, + {"max_acceleration", 0.8}, + {"min_acceleration", -0.4}}}, + {"angular", + {{"max_velocity", 1.7}, {"max_acceleration", 1.5}}}}, + {"wheels", defaultDiffWheels()}}}}, + {"footprint", + nlohmann::json::array({nlohmann::json{{"x", 120}, {"y", 80}}, + nlohmann::json{{"x", 120}, {"y", -80}}, + nlohmann::json{{"x", -90}, {"y", -80}}, + nlohmann::json{{"x", -90}, {"y", 80}}})}}}, + {"map", {{"width", 800}, {"height", 600}}}, + {"lidarPositions", nlohmann::json::object()}, + {"lidarPoses", nlohmann::json::object()}, + {"lidarPosesFrame", "robot"}, + {"imuPoses", nlohmann::json::object()}, + {"imuPosesFrame", "robot"}}; +} + +void LayoutSchema::ensure(nlohmann::json& layout) +{ + if (!layout.is_object()) + layout = nlohmann::json::object(); + if (!layout.contains("robot") || !layout["robot"].is_object()) + layout["robot"] = nlohmann::json::object(); + if (!layout.contains("map") || !layout["map"].is_object()) + layout["map"] = nlohmann::json::object(); + if (!layout.contains("lidarPositions") || !layout["lidarPositions"].is_object()) + layout["lidarPositions"] = nlohmann::json::object(); + if (!layout.contains("lidarPoses") || !layout["lidarPoses"].is_object()) + layout["lidarPoses"] = nlohmann::json::object(); + if (!layout.contains("lidarPosesFrame")) + layout["lidarPosesFrame"] = "robot"; + if (!layout.contains("imuPoses") || !layout["imuPoses"].is_object()) + layout["imuPoses"] = nlohmann::json::object(); + if (!layout.contains("imuPosesFrame")) + layout["imuPosesFrame"] = "robot"; + + auto& robot = layout["robot"]; + if (!robot.contains("x")) + robot["x"] = 400; + if (!robot.contains("y")) + robot["y"] = 300; + if (!robot.contains("yaw_deg")) + robot["yaw_deg"] = 0; + if (!robot.contains("frame_id")) + robot["frame_id"] = "base_footprint"; + if (!robot.contains("model")) + robot["model"] = "diff"; + if (!robot.contains("diff") || !robot["diff"].is_object()) + robot["diff"] = nlohmann::json::object(); + auto& diff = robot["diff"]; + + const double default_scale = 0.005; + if (!diff.contains("display") || !diff["display"].is_object()) + diff["display"] = nlohmann::json::object(); + auto& display = diff["display"]; + if (!display.contains("scale_m_per_px")) + display["scale_m_per_px"] = default_scale; + const double scale = display["scale_m_per_px"].get(); + + if (!diff.contains("wheel_separation_m")) + { + if (diff.contains("b")) + diff["wheel_separation_m"] = diff["b"].get() * scale; + else + diff["wheel_separation_m"] = 1.0; + } + if (!diff.contains("wheel_radius_m")) + { + if (diff.contains("d")) + diff["wheel_radius_m"] = diff["d"].get() * scale * 0.5; + else + diff["wheel_radius_m"] = 0.3; + } + if (!diff.contains("wheel_separation_multiplier")) + diff["wheel_separation_multiplier"] = 1.0; + if (!diff.contains("wheel_radius_multiplier")) + diff["wheel_radius_multiplier"] = 1.0; + + const double b_mult = diff["wheel_separation_multiplier"].get(); + const double r_mult = diff["wheel_radius_multiplier"].get(); + const double sep_m = diff["wheel_separation_m"].get(); + const double rad_m = diff["wheel_radius_m"].get(); + display["b_px"] = sep_m * b_mult / scale; + display["d_px"] = 2.0 * rad_m * r_mult / scale; + diff["b"] = display["b_px"]; + diff["d"] = display["d_px"]; + + if (!diff.contains("limits") || !diff["limits"].is_object()) + diff["limits"] = nlohmann::json::object(); + auto& limits = diff["limits"]; + if (!limits.contains("cmd_vel_timeout_s")) + limits["cmd_vel_timeout_s"] = 0.25; + if (!limits.contains("linear") || !limits["linear"].is_object()) + limits["linear"] = nlohmann::json::object(); + auto& linear = limits["linear"]; + if (!linear.contains("max_velocity")) + linear["max_velocity"] = 1.0; + if (!linear.contains("min_velocity")) + linear["min_velocity"] = -0.5; + if (!linear.contains("max_acceleration")) + linear["max_acceleration"] = 0.8; + if (!linear.contains("min_acceleration")) + linear["min_acceleration"] = -0.4; + if (!limits.contains("angular") || !limits["angular"].is_object()) + limits["angular"] = nlohmann::json::object(); + auto& angular = limits["angular"]; + if (!angular.contains("max_velocity")) + angular["max_velocity"] = 1.7; + if (!angular.contains("max_acceleration")) + angular["max_acceleration"] = 1.5; + + const double half_sep = sep_m / 2.0; + if (!diff.contains("wheels") || !diff["wheels"].is_array() || diff["wheels"].empty()) + { + diff["wheels"] = nlohmann::json::array( + {nlohmann::json{{"id", "left"}, + {"side", "left"}, + {"joint_name", "wheel_left_joint"}, + {"y_m", half_sep}, + {"motor", + nlohmann::json{{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}, + nlohmann::json{{"id", "right"}, + {"side", "right"}, + {"joint_name", "wheel_right_joint"}, + {"y_m", -half_sep}, + {"motor", + nlohmann::json{{"vendor", "moons"}, + {"model", "m2dc10a"}, + {"gear_ratio", 20}, + {"invert", false}}}}}); + } + else + { + for (auto& w : diff["wheels"]) + { + if (!w.is_object()) + continue; + if (!w.contains("id")) + w["id"] = "left"; + if (!w.contains("side")) + w["side"] = (w["id"].get() == "right") ? "right" : "left"; + if (!w.contains("joint_name")) + { + w["joint_name"] = (w["side"].get() == "right") ? "wheel_right_joint" + : "wheel_left_joint"; + } + if (!w.contains("y_m")) + { + w["y_m"] = (w["side"].get() == "right") ? -half_sep : half_sep; + } + if (!w.contains("motor") || !w["motor"].is_object()) + w["motor"] = nlohmann::json::object(); + auto& motor = w["motor"]; + if (!motor.contains("vendor")) + motor["vendor"] = "custom"; + if (!motor.contains("model")) + motor["model"] = "custom"; + if (!motor.contains("gear_ratio")) + motor["gear_ratio"] = 20; + if (!motor.contains("invert")) + motor["invert"] = false; + } + } + + if (!robot.contains("bicycle") || !robot["bicycle"].is_object()) + robot["bicycle"] = nlohmann::json::object(); + auto& bicycle = robot["bicycle"]; + if (!bicycle.contains("display") || !bicycle["display"].is_object()) + bicycle["display"] = nlohmann::json::object(); + auto& bdisplay = bicycle["display"]; + if (!bdisplay.contains("scale_m_per_px")) + bdisplay["scale_m_per_px"] = default_scale; + const double bscale = bdisplay["scale_m_per_px"].get(); + if (!bicycle.contains("wheelbase_m")) + bicycle["wheelbase_m"] = 1.2; + if (!bicycle.contains("wheel_radius_m")) + bicycle["wheel_radius_m"] = 0.15; + const double L_m = bicycle["wheelbase_m"].get(); + const double b_rad = bicycle["wheel_radius_m"].get(); + bdisplay["L_px"] = L_m / bscale; + bdisplay["r_px"] = 2.0 * b_rad / bscale; + if (!bicycle.contains("steer") || !bicycle["steer"].is_object()) + bicycle["steer"] = nlohmann::json::object(); + auto& steer = bicycle["steer"]; + if (!steer.contains("max_angle_deg")) + steer["max_angle_deg"] = 35; + if (!steer.contains("preview_deg")) + steer["preview_deg"] = 15; + if (!steer.contains("joint_name")) + steer["joint_name"] = "front_steer_joint"; + if (!bicycle.contains("drive") || !bicycle["drive"].is_object()) + bicycle["drive"] = nlohmann::json::object(); + if (!bicycle["drive"].contains("joint_name")) + bicycle["drive"]["joint_name"] = "rear_wheel_joint"; + if (!bicycle.contains("limits") || !bicycle["limits"].is_object()) + bicycle["limits"] = nlohmann::json::object(); + auto& blimits = bicycle["limits"]; + if (!blimits.contains("cmd_vel_timeout_s")) + blimits["cmd_vel_timeout_s"] = 0.25; + if (!blimits.contains("linear") || !blimits["linear"].is_object()) + blimits["linear"] = nlohmann::json::object(); + auto& blinear = blimits["linear"]; + if (!blinear.contains("max_velocity")) + blinear["max_velocity"] = 1.0; + if (!blinear.contains("max_acceleration")) + blinear["max_acceleration"] = 0.8; + if (!bicycle.contains("wheels") || !bicycle["wheels"].is_array() || bicycle["wheels"].empty()) + bicycle["wheels"] = defaultBicycleWheels(); + + if (!robot.contains("footprint") || !robot["footprint"].is_array()) + { + robot["footprint"] = + nlohmann::json::array({nlohmann::json{{"x", 120}, {"y", 80}}, + nlohmann::json{{"x", 120}, {"y", -80}}, + nlohmann::json{{"x", -90}, {"y", -80}}, + nlohmann::json{{"x", -90}, {"y", 80}}}); + } + if (!robot.contains("footprint_shape")) + robot["footprint_shape"] = "custom"; + if (!robot.contains("footprint_params") || !robot["footprint_params"].is_object()) + { + robot["footprint_params"] = nlohmann::json{{"length_m", 1.4}, + {"width_m", 1.1}, + {"radius_m", 0.55}, + {"sides", 6}, + {"segments", 32}}; + } + auto& map = layout["map"]; + if (!map.contains("width")) + map["width"] = 800; + if (!map.contains("height")) + map["height"] = 600; +} +} // namespace lm diff --git a/src/domain/layout_schema.hpp b/src/domain/layout_schema.hpp new file mode 100644 index 0000000..16dfcff --- /dev/null +++ b/src/domain/layout_schema.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +namespace lm { + +class LayoutSchema +{ +public: + static nlohmann::json defaultLayoutObject(); + static void ensure(nlohmann::json& layout); + +private: + static nlohmann::json defaultBicycleWheels(); + static nlohmann::json defaultDiffWheels(); +}; + +} // namespace lm diff --git a/src/main.cpp b/src/main.cpp index ab87896..c9dd331 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,1471 +1,15 @@ -#include -#include +#include "app/lidar_manager_app.hpp" -#include -#include -#include -#include -#include +#include #include -#include -#include -#include -#include -#include -#include -#include - -namespace fs = std::filesystem; -using json = nlohmann::json; - -static std::string read_file_binary(const fs::path& path) -{ - std::ifstream in(path, std::ios::binary); - if (!in) - return {}; - std::ostringstream ss; - ss << in.rdbuf(); - return ss.str(); -} - -static bool write_file_binary_atomic(const fs::path& path, const std::string& contents) -{ - fs::create_directories(path.parent_path()); - auto tmp = path; - tmp += ".tmp"; - - { - std::ofstream out(tmp, std::ios::binary | std::ios::trunc); - if (!out) - return false; - out.write(contents.data(), static_cast(contents.size())); - out.flush(); - if (!out) - return false; - } - - std::error_code ec; - fs::rename(tmp, path, ec); - if (!ec) - return true; - - // Fallback: some filesystems disallow rename-overwrite - fs::remove(path, ec); - ec.clear(); - fs::rename(tmp, path, ec); - return !ec; -} - -static std::string to_lower(std::string s) -{ - std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); - return s; -} - -static std::string content_type_for_path(const fs::path& p) -{ - const auto ext = to_lower(p.extension().string()); - if (ext == ".html") - return "text/html; charset=utf-8"; - if (ext == ".css") - return "text/css; charset=utf-8"; - if (ext == ".js") - return "application/javascript; charset=utf-8"; - if (ext == ".json") - return "application/json; charset=utf-8"; - if (ext == ".png") - return "image/png"; - if (ext == ".svg") - return "image/svg+xml"; - if (ext == ".ico") - return "image/x-icon"; - return "application/octet-stream"; -} - -static std::string new_id() -{ - static thread_local std::mt19937_64 rng{std::random_device{}()}; - static constexpr char kHex[] = "0123456789abcdef"; - std::string out; - out.resize(16); - for (int i = 0; i < 16; i++) - out[i] = kHex[static_cast(rng() & 0xF)]; - return out; -} - -static std::string now_iso8601() -{ - using clock = std::chrono::system_clock; - const std::time_t t = clock::to_time_t(clock::now()); - std::tm tm{}; -#if defined(_WIN32) - gmtime_s(&tm, &t); -#else - gmtime_r(&t, &tm); -#endif - char buf[32]; - std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm); - return buf; -} - -struct AppState -{ - fs::path data_path; - json state; -}; - -static json default_bicycle_wheels() -{ - return json::array( - {json{{"id", "rear"}, - {"role", "drive"}, - {"x_m", 0}, - {"y_m", 0}, - {"joint_name", "rear_wheel_joint"}, - {"motor", - {{"vendor", "moons"}, - {"model", "m2dc10a"}, - {"gear_ratio", 20}, - {"invert", false}}}}, - json{{"id", "front"}, - {"role", "steer"}, - {"x_m", 1.2}, - {"y_m", 0}, - {"joint_name", "front_steer_joint"}, - {"motor", - {{"vendor", "moons"}, - {"model", "m2dc10a"}, - {"gear_ratio", 20}, - {"invert", false}}}}}); -} - -static json default_diff_wheels() -{ - return json::array( - {json{{"id", "left"}, - {"side", "left"}, - {"joint_name", "wheel_left_joint"}, - {"y_m", 0.5}, - {"motor", - {{"vendor", "moons"}, - {"model", "m2dc10a"}, - {"gear_ratio", 20}, - {"invert", false}}}}, - json{{"id", "right"}, - {"side", "right"}, - {"joint_name", "wheel_right_joint"}, - {"y_m", -0.5}, - {"motor", - {{"vendor", "moons"}, - {"model", "m2dc10a"}, - {"gear_ratio", 20}, - {"invert", false}}}}}); -} - -static void ensure_layout_schema(json& layout); - -static json default_layout_object() -{ - return json{ - {"robot", - {{"x", 400}, - {"y", 300}, - {"yaw_deg", 0}, - {"frame_id", "base_footprint"}, - {"model", "diff"}, - {"diff", - {{"wheel_separation_m", 1.0}, - {"wheel_radius_m", 0.3}, - {"wheel_separation_multiplier", 1.0}, - {"wheel_radius_multiplier", 1.0}, - {"display", {{"scale_m_per_px", 0.005}, {"b_px", 200}, {"d_px", 120}}}, - {"limits", - {{"cmd_vel_timeout_s", 0.25}, - {"linear", - {{"max_velocity", 1.0}, - {"min_velocity", -0.5}, - {"max_acceleration", 0.8}, - {"min_acceleration", -0.4}}}, - {"angular", - {{"max_velocity", 1.7}, {"max_acceleration", 1.5}}}}, - {"wheels", default_diff_wheels()}}}}, - {"footprint", - json::array({json{{"x", 120}, {"y", 80}}, - json{{"x", 120}, {"y", -80}}, - json{{"x", -90}, {"y", -80}}, - json{{"x", -90}, {"y", 80}}})}}}, - {"map", {{"width", 800}, {"height", 600}}}, - {"lidarPositions", json::object()}, - {"lidarPoses", json::object()}, - {"lidarPosesFrame", "robot"}, - {"imuPoses", json::object()}, - {"imuPosesFrame", "robot"}}; -} - -static json make_layout_profile(const std::string& name, - const json& layout, - const json& lidars, - const json& imus = json::array()) -{ - const std::string ts = now_iso8601(); - return json{{"id", new_id()}, - {"name", name}, - {"created_at", ts}, - {"updated_at", ts}, - {"layout", layout}, - {"lidars", lidars}, - {"imus", imus}}; -} - -static std::string trim_copy(const std::string& s) -{ - size_t a = 0; - while (a < s.size() && std::isspace(static_cast(s[a]))) - a++; - size_t b = s.size(); - while (b > a && std::isspace(static_cast(s[b - 1]))) - b--; - return s.substr(a, b - a); -} - -static std::optional find_profile_index(const json& state, const std::string& id) -{ - if (!state.contains("layouts") || !state["layouts"].is_array()) - return std::nullopt; - const auto& layouts = state["layouts"]; - for (size_t i = 0; i < layouts.size(); i++) - { - const auto& p = layouts[i]; - if (p.is_object() && p.contains("id") && p["id"].is_string() && p["id"].get() == id) - return i; - } - return std::nullopt; -} - -static std::optional find_active_profile_index(json& state) -{ - if (!state.contains("active_layout_id") || !state["active_layout_id"].is_string()) - return std::nullopt; - return find_profile_index(state, state["active_layout_id"].get()); -} - -static void touch_profile(json& profile) -{ - profile["updated_at"] = now_iso8601(); -} - -static fs::path models_dir(const AppState& app) -{ - return app.data_path.parent_path() / "models"; -} - -static fs::path profile_file_path(const AppState& app, const std::string& id) -{ - return models_dir(app) / (id + ".json"); -} - -static std::string profile_model_from_layout(const json& layout) -{ - if (layout.is_object() && layout.contains("robot") && layout["robot"].is_object() && - layout["robot"].contains("model") && layout["robot"]["model"].is_string()) - return layout["robot"]["model"].get(); - return "diff"; -} - -static json catalog_entry_from_profile(const json& profile) -{ - const json& layout = profile.contains("layout") ? profile["layout"] : json::object(); - const size_t lidar_count = - profile.contains("lidars") && profile["lidars"].is_array() ? profile["lidars"].size() : 0; - const size_t imu_count = - profile.contains("imus") && profile["imus"].is_array() ? profile["imus"].size() : 0; - return json{{"id", profile["id"]}, - {"name", profile["name"]}, - {"model", profile_model_from_layout(layout)}, - {"created_at", profile.value("created_at", "")}, - {"updated_at", profile.value("updated_at", "")}, - {"lidar_count", lidar_count}, - {"imu_count", imu_count}}; -} - -static std::optional load_profile_from_disk(const AppState& app, const std::string& id) -{ - const auto raw = read_file_binary(profile_file_path(app, id)); - if (raw.empty()) - return std::nullopt; - try - { - return json::parse(raw); - } - catch (...) - { - return std::nullopt; - } -} - -static bool save_profile_to_disk(const AppState& app, const json& profile) -{ - if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string()) - return false; - std::error_code ec; - fs::create_directories(models_dir(app), ec); - auto body = profile.dump(2); - body.push_back('\n'); - return write_file_binary_atomic(profile_file_path(app, profile["id"].get()), body); -} - -static bool delete_profile_file(const AppState& app, const std::string& id) -{ - std::error_code ec; - fs::remove(profile_file_path(app, id), ec); - return true; -} - -static void load_active_cache(AppState& app) -{ - json& state = app.state; - const auto idx = find_active_profile_index(state); - if (!idx) - return; - - const std::string id = state["layouts"][*idx]["id"].get(); - json profile; - if (auto loaded = load_profile_from_disk(app, id)) - profile = *loaded; - else - { - profile = make_layout_profile(state["layouts"][*idx]["name"].get(), - default_layout_object(), - json::array()); - profile["id"] = id; - if (state["layouts"][*idx].contains("created_at")) - profile["created_at"] = state["layouts"][*idx]["created_at"]; - } - - if (!profile.contains("layout") || !profile["layout"].is_object()) - profile["layout"] = default_layout_object(); - if (!profile.contains("lidars") || !profile["lidars"].is_array()) - profile["lidars"] = json::array(); - if (!profile.contains("imus") || !profile["imus"].is_array()) - profile["imus"] = json::array(); - - ensure_layout_schema(profile["layout"]); - state["layout"] = profile["layout"]; - state["lidars"] = profile["lidars"]; - state["imus"] = profile["imus"]; -} - -static bool persist_active_profile(AppState& app) -{ - json& state = app.state; - const auto idx = find_active_profile_index(state); - if (!idx) - return false; - - auto& entry = state["layouts"][*idx]; - json profile; - profile["id"] = entry["id"]; - profile["name"] = entry.contains("name") ? entry["name"] : json("Layout"); - profile["created_at"] = entry.value("created_at", now_iso8601()); - profile["updated_at"] = now_iso8601(); - profile["layout"] = state["layout"]; - profile["lidars"] = state["lidars"]; - profile["imus"] = state.contains("imus") && state["imus"].is_array() ? state["imus"] : json::array(); - ensure_layout_schema(profile["layout"]); - if (!save_profile_to_disk(app, profile)) - return false; - entry = catalog_entry_from_profile(profile); - return true; -} - -static json global_state_for_disk(const json& state) -{ - json out = json::object(); - out["version"] = 3; - if (state.contains("active_layout_id")) - out["active_layout_id"] = state["active_layout_id"]; - out["layouts"] = json::array(); - if (state.contains("layouts") && state["layouts"].is_array()) - { - for (const auto& entry : state["layouts"]) - { - if (!entry.is_object() || !entry.contains("id") || !entry.contains("name")) - continue; - if (entry.contains("layout")) - out["layouts"].push_back(catalog_entry_from_profile(entry)); - else - out["layouts"].push_back(entry); - } - } - return out; -} - -static void strip_inline_profiles(json& state) -{ - if (!state.contains("layouts") || !state["layouts"].is_array()) - return; - json catalog = json::array(); - for (const auto& entry : state["layouts"]) - { - if (!entry.is_object() || !entry.contains("id")) - continue; - if (entry.contains("layout")) - catalog.push_back(catalog_entry_from_profile(entry)); - else - catalog.push_back(entry); - } - state["layouts"] = catalog; -} - -static void migrate_storage(AppState& app) -{ - json& s = app.state; - if (!s.is_object()) - s = json::object(); - - const int version = s.contains("version") && s["version"].is_number_integer() ? s["version"].get() : 1; - - if (!s.contains("layouts") || !s["layouts"].is_array() || s["layouts"].empty()) - { - json layout = s.contains("layout") && s["layout"].is_object() ? s["layout"] : default_layout_object(); - json lidars = s.contains("lidars") && s["lidars"].is_array() ? s["lidars"] : json::array(); - json profile = make_layout_profile("Mặc định", layout, lidars); - ensure_layout_schema(profile["layout"]); - save_profile_to_disk(app, profile); - s["layouts"] = json::array({catalog_entry_from_profile(profile)}); - s["active_layout_id"] = profile["id"].get(); - } - else if (version < 3) - { - if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() || - !find_profile_index(s, s["active_layout_id"].get())) - { - s["active_layout_id"] = s["layouts"][0]["id"].get(); - } - - json catalog = json::array(); - for (auto& entry : s["layouts"]) - { - if (!entry.is_object() || !entry.contains("id")) - continue; - json profile; - if (entry.contains("layout")) - { - profile = entry; - if (!profile.contains("name")) - profile["name"] = "Layout"; - if (!profile.contains("lidars") || !profile["lidars"].is_array()) - profile["lidars"] = json::array(); - if (!profile.contains("created_at")) - profile["created_at"] = now_iso8601(); - touch_profile(profile); - ensure_layout_schema(profile["layout"]); - save_profile_to_disk(app, profile); - catalog.push_back(catalog_entry_from_profile(profile)); - } - else - { - const std::string id = entry["id"].get(); - if (auto loaded = load_profile_from_disk(app, id)) - { - catalog.push_back(catalog_entry_from_profile(*loaded)); - } - else - { - profile = make_layout_profile(entry["name"].get(), default_layout_object(), json::array()); - profile["id"] = id; - profile["created_at"] = entry.value("created_at", now_iso8601()); - touch_profile(profile); - save_profile_to_disk(app, profile); - catalog.push_back(catalog_entry_from_profile(profile)); - } - } - } - s["layouts"] = catalog; - } - else - { - strip_inline_profiles(s); - if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() || - !find_profile_index(s, s["active_layout_id"].get())) - { - s["active_layout_id"] = s["layouts"][0]["id"].get(); - } - } - - s["version"] = 3; - s.erase("layout"); - s.erase("lidars"); - load_active_cache(app); -} - -static bool layout_name_exists(const json& state, - const std::string& name, - const std::string* exclude_id = nullptr) -{ - if (!state.contains("layouts") || !state["layouts"].is_array()) - return false; - const std::string n = trim_copy(name); - for (const auto& p : state["layouts"]) - { - if (!p.is_object() || !p.contains("name")) - continue; - if (exclude_id && p.contains("id") && p["id"].get() == *exclude_id) - continue; - if (trim_copy(p["name"].get()) == n) - return true; - } - return false; -} - -static json build_layouts_catalog(const json& state) -{ - json catalog = json::array(); - if (!state.contains("layouts") || !state["layouts"].is_array()) - return catalog; - for (const auto& p : state["layouts"]) - { - if (!p.is_object() || !p.contains("id") || !p.contains("name")) - continue; - catalog.push_back(p); - } - return catalog; -} - -static void bootstrap_default_state(AppState& app) -{ - const json layout = default_layout_object(); - json profile = make_layout_profile("Mặc định", layout, json::array()); - ensure_layout_schema(profile["layout"]); - save_profile_to_disk(app, profile); - app.state = json{{"version", 3}, - {"active_layout_id", profile["id"]}, - {"layouts", json::array({catalog_entry_from_profile(profile)})}}; - app.state["layout"] = profile["layout"]; - app.state["lidars"] = profile["lidars"]; - app.state["imus"] = profile.contains("imus") ? profile["imus"] : json::array(); -} - -static void ensure_layout_schema(json& layout) -{ - if (!layout.is_object()) - layout = json::object(); - if (!layout.contains("robot") || !layout["robot"].is_object()) - layout["robot"] = json::object(); - if (!layout.contains("map") || !layout["map"].is_object()) - layout["map"] = json::object(); - if (!layout.contains("lidarPositions") || !layout["lidarPositions"].is_object()) - layout["lidarPositions"] = json::object(); - if (!layout.contains("lidarPoses") || !layout["lidarPoses"].is_object()) - layout["lidarPoses"] = json::object(); - if (!layout.contains("lidarPosesFrame")) - layout["lidarPosesFrame"] = "robot"; - if (!layout.contains("imuPoses") || !layout["imuPoses"].is_object()) - layout["imuPoses"] = json::object(); - if (!layout.contains("imuPosesFrame")) - layout["imuPosesFrame"] = "robot"; - - auto& robot = layout["robot"]; - if (!robot.contains("x")) - robot["x"] = 400; - if (!robot.contains("y")) - robot["y"] = 300; - if (!robot.contains("yaw_deg")) - robot["yaw_deg"] = 0; - if (!robot.contains("frame_id")) - robot["frame_id"] = "base_footprint"; - if (!robot.contains("model")) - robot["model"] = "diff"; - if (!robot.contains("diff") || !robot["diff"].is_object()) - robot["diff"] = json::object(); - auto& diff = robot["diff"]; - - const double default_scale = 0.005; - if (!diff.contains("display") || !diff["display"].is_object()) - diff["display"] = json::object(); - auto& display = diff["display"]; - if (!display.contains("scale_m_per_px")) - display["scale_m_per_px"] = default_scale; - const double scale = display["scale_m_per_px"].get(); - - if (!diff.contains("wheel_separation_m")) - { - if (diff.contains("b")) - diff["wheel_separation_m"] = diff["b"].get() * scale; - else - diff["wheel_separation_m"] = 1.0; - } - if (!diff.contains("wheel_radius_m")) - { - if (diff.contains("d")) - diff["wheel_radius_m"] = diff["d"].get() * scale * 0.5; - else - diff["wheel_radius_m"] = 0.3; - } - if (!diff.contains("wheel_separation_multiplier")) - diff["wheel_separation_multiplier"] = 1.0; - if (!diff.contains("wheel_radius_multiplier")) - diff["wheel_radius_multiplier"] = 1.0; - - const double b_mult = diff["wheel_separation_multiplier"].get(); - const double r_mult = diff["wheel_radius_multiplier"].get(); - const double sep_m = diff["wheel_separation_m"].get(); - const double rad_m = diff["wheel_radius_m"].get(); - display["b_px"] = sep_m * b_mult / scale; - display["d_px"] = 2.0 * rad_m * r_mult / scale; - diff["b"] = display["b_px"]; - diff["d"] = display["d_px"]; - - if (!diff.contains("limits") || !diff["limits"].is_object()) - diff["limits"] = json::object(); - auto& limits = diff["limits"]; - if (!limits.contains("cmd_vel_timeout_s")) - limits["cmd_vel_timeout_s"] = 0.25; - if (!limits.contains("linear") || !limits["linear"].is_object()) - limits["linear"] = json::object(); - auto& linear = limits["linear"]; - if (!linear.contains("max_velocity")) - linear["max_velocity"] = 1.0; - if (!linear.contains("min_velocity")) - linear["min_velocity"] = -0.5; - if (!linear.contains("max_acceleration")) - linear["max_acceleration"] = 0.8; - if (!linear.contains("min_acceleration")) - linear["min_acceleration"] = -0.4; - if (!limits.contains("angular") || !limits["angular"].is_object()) - limits["angular"] = json::object(); - auto& angular = limits["angular"]; - if (!angular.contains("max_velocity")) - angular["max_velocity"] = 1.7; - if (!angular.contains("max_acceleration")) - angular["max_acceleration"] = 1.5; - - const double half_sep = sep_m / 2.0; - if (!diff.contains("wheels") || !diff["wheels"].is_array() || diff["wheels"].empty()) - { - diff["wheels"] = json::array( - {json{{"id", "left"}, - {"side", "left"}, - {"joint_name", "wheel_left_joint"}, - {"y_m", half_sep}, - {"motor", - json{{"vendor", "moons"}, - {"model", "m2dc10a"}, - {"gear_ratio", 20}, - {"invert", false}}}}, - json{{"id", "right"}, - {"side", "right"}, - {"joint_name", "wheel_right_joint"}, - {"y_m", -half_sep}, - {"motor", - json{{"vendor", "moons"}, - {"model", "m2dc10a"}, - {"gear_ratio", 20}, - {"invert", false}}}}}); - } - else - { - for (auto& w : diff["wheels"]) - { - if (!w.is_object()) - continue; - if (!w.contains("id")) - w["id"] = "left"; - if (!w.contains("side")) - w["side"] = (w["id"].get() == "right") ? "right" : "left"; - if (!w.contains("joint_name")) - { - w["joint_name"] = (w["side"].get() == "right") ? "wheel_right_joint" - : "wheel_left_joint"; - } - if (!w.contains("y_m")) - { - w["y_m"] = (w["side"].get() == "right") ? -half_sep : half_sep; - } - if (!w.contains("motor") || !w["motor"].is_object()) - w["motor"] = json::object(); - auto& motor = w["motor"]; - if (!motor.contains("vendor")) - motor["vendor"] = "custom"; - if (!motor.contains("model")) - motor["model"] = "custom"; - if (!motor.contains("gear_ratio")) - motor["gear_ratio"] = 20; - if (!motor.contains("invert")) - motor["invert"] = false; - } - } - - if (!robot.contains("bicycle") || !robot["bicycle"].is_object()) - robot["bicycle"] = json::object(); - auto& bicycle = robot["bicycle"]; - if (!bicycle.contains("display") || !bicycle["display"].is_object()) - bicycle["display"] = json::object(); - auto& bdisplay = bicycle["display"]; - if (!bdisplay.contains("scale_m_per_px")) - bdisplay["scale_m_per_px"] = default_scale; - const double bscale = bdisplay["scale_m_per_px"].get(); - if (!bicycle.contains("wheelbase_m")) - bicycle["wheelbase_m"] = 1.2; - if (!bicycle.contains("wheel_radius_m")) - bicycle["wheel_radius_m"] = 0.15; - const double L_m = bicycle["wheelbase_m"].get(); - const double b_rad = bicycle["wheel_radius_m"].get(); - bdisplay["L_px"] = L_m / bscale; - bdisplay["r_px"] = 2.0 * b_rad / bscale; - if (!bicycle.contains("steer") || !bicycle["steer"].is_object()) - bicycle["steer"] = json::object(); - auto& steer = bicycle["steer"]; - if (!steer.contains("max_angle_deg")) - steer["max_angle_deg"] = 35; - if (!steer.contains("preview_deg")) - steer["preview_deg"] = 15; - if (!steer.contains("joint_name")) - steer["joint_name"] = "front_steer_joint"; - if (!bicycle.contains("drive") || !bicycle["drive"].is_object()) - bicycle["drive"] = json::object(); - if (!bicycle["drive"].contains("joint_name")) - bicycle["drive"]["joint_name"] = "rear_wheel_joint"; - if (!bicycle.contains("limits") || !bicycle["limits"].is_object()) - bicycle["limits"] = json::object(); - auto& blimits = bicycle["limits"]; - if (!blimits.contains("cmd_vel_timeout_s")) - blimits["cmd_vel_timeout_s"] = 0.25; - if (!blimits.contains("linear") || !blimits["linear"].is_object()) - blimits["linear"] = json::object(); - auto& blinear = blimits["linear"]; - if (!blinear.contains("max_velocity")) - blinear["max_velocity"] = 1.0; - if (!blinear.contains("max_acceleration")) - blinear["max_acceleration"] = 0.8; - if (!bicycle.contains("wheels") || !bicycle["wheels"].is_array() || bicycle["wheels"].empty()) - bicycle["wheels"] = default_bicycle_wheels(); - - if (!robot.contains("footprint") || !robot["footprint"].is_array()) - { - robot["footprint"] = - json::array({json{{"x", 120}, {"y", 80}}, - json{{"x", 120}, {"y", -80}}, - json{{"x", -90}, {"y", -80}}, - json{{"x", -90}, {"y", 80}}}); - } - if (!robot.contains("footprint_shape")) - robot["footprint_shape"] = "custom"; - if (!robot.contains("footprint_params") || !robot["footprint_params"].is_object()) - { - robot["footprint_params"] = json{{"length_m", 1.4}, - {"width_m", 1.1}, - {"radius_m", 0.55}, - {"sides", 6}, - {"segments", 32}}; - } - auto& map = layout["map"]; - if (!map.contains("width")) - map["width"] = 800; - if (!map.contains("height")) - map["height"] = 600; -} - -static void ensure_schema(AppState& app) -{ - migrate_storage(app); -} - -static bool save_state(const AppState& app); - -static bool load_state(AppState& app) -{ - const auto raw = read_file_binary(app.data_path); - if (raw.empty()) - { - bootstrap_default_state(app); - save_state(app); - return true; - } - try - { - app.state = json::parse(raw); - ensure_schema(app); - save_state(app); - return true; - } - catch (...) - { - bootstrap_default_state(app); - save_state(app); - return false; - } -} - -static bool save_state(const AppState& app) -{ - try - { - const json disk = global_state_for_disk(app.state); - auto raw = disk.dump(2); - raw.push_back('\n'); - return write_file_binary_atomic(app.data_path, raw); - } - catch (...) - { - return false; - } -} - -static bool save_app_state(AppState& app) -{ - if (!persist_active_profile(app)) - return false; - return save_state(app); -} - -static std::optional find_lidar_index(const json& state, const std::string& id) -{ - if (!state.contains("lidars") || !state["lidars"].is_array()) - return std::nullopt; - const auto& lidars = state["lidars"]; - for (size_t i = 0; i < lidars.size(); i++) - { - const auto& l = lidars[i]; - if (l.is_object() && l.contains("id") && l["id"].is_string() && l["id"].get() == id) - return i; - } - return std::nullopt; -} - -static std::optional find_imu_index(const json& state, const std::string& id) -{ - if (!state.contains("imus") || !state["imus"].is_array()) - return std::nullopt; - const auto& imus = state["imus"]; - for (size_t i = 0; i < imus.size(); i++) - { - const auto& im = imus[i]; - if (im.is_object() && im.contains("id") && im["id"].is_string() && im["id"].get() == id) - return i; - } - return std::nullopt; -} - -static void json_error(httplib::Response& res, int status, const std::string& msg) -{ - res.status = status; - res.set_header("Content-Type", "application/json; charset=utf-8"); - res.body = json({{"error", msg}}).dump(); -} - -static void add_cors(httplib::Response& res) -{ - // Same-origin by default. This is helpful if you later host the frontend elsewhere. - res.set_header("Access-Control-Allow-Origin", "*"); - res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); - res.set_header("Access-Control-Allow-Headers", "Content-Type"); -} - -static bool validate_lidar_payload(const json& payload, std::string& err) -{ - if (!payload.is_object()) - { - err = "payload must be a JSON object"; - return false; - } - if (!payload.contains("name") || !payload["name"].is_string() || payload["name"].get().empty()) - { - err = "name is required"; - return false; - } - if (!payload.contains("ip") || !payload["ip"].is_string() || payload["ip"].get().empty()) - { - err = "ip is required"; - return false; - } - if (!payload.contains("port") || !payload["port"].is_number_integer()) - { - err = "port must be an integer"; - return false; - } - const int port = payload["port"].get(); - if (port < 1 || port > 65535) - { - err = "port must be in range 1..65535"; - return false; - } - return true; -} - -static bool lidar_triplet_exists(const json& state, - const std::string& name, - const std::string& ip, - int port, - const std::string* exclude_id = nullptr) -{ - if (!state.contains("lidars") || !state["lidars"].is_array()) - return false; - const std::string n = trim_copy(name); - const std::string i = trim_copy(ip); - for (const auto& l : state["lidars"]) - { - if (!l.is_object()) - continue; - if (exclude_id && l.contains("id") && l["id"].get() == *exclude_id) - continue; - if (!l.contains("name") || !l.contains("ip") || !l.contains("port")) - continue; - if (trim_copy(l["name"].get()) == n && trim_copy(l["ip"].get()) == i && - l["port"].get() == port) - return true; - } - return false; -} - -static bool validate_imu_payload(const json& payload, std::string& err) -{ - if (!payload.is_object()) - { - err = "payload must be a JSON object"; - return false; - } - if (!payload.contains("name") || !payload["name"].is_string() || payload["name"].get().empty()) - { - err = "name is required"; - return false; - } - if (!payload.contains("frame_id") || !payload["frame_id"].is_string() || - payload["frame_id"].get().empty()) - { - err = "frame_id is required"; - return false; - } - if (!payload.contains("topic") || !payload["topic"].is_string() || payload["topic"].get().empty()) - { - err = "topic is required"; - return false; - } - if (payload.contains("source") && payload["source"].is_string()) - { - const std::string src = payload["source"].get(); - if (src != "external" && src != "lidar_builtin" && src != "onboard") - { - err = "source must be external, lidar_builtin, or onboard"; - return false; - } - } - if (payload.contains("rate_hz") && !payload["rate_hz"].is_number()) - { - err = "rate_hz must be a number"; - return false; - } - return true; -} - -static bool imu_frame_exists(const json& state, - const std::string& frame_id, - const std::string* exclude_id = nullptr) -{ - if (!state.contains("imus") || !state["imus"].is_array()) - return false; - const std::string f = trim_copy(frame_id); - for (const auto& im : state["imus"]) - { - if (!im.is_object()) - continue; - if (exclude_id && im.contains("id") && im["id"].get() == *exclude_id) - continue; - if (!im.contains("frame_id")) - continue; - if (trim_copy(im["frame_id"].get()) == f) - return true; - } - return false; -} - -static void mount_static(httplib::Server& svr, const fs::path& www_root) -{ - svr.Get(R"(/(.*))", [www_root](const httplib::Request& req, httplib::Response& res) { - std::string rel = req.matches.size() >= 2 ? req.matches[1].str() : ""; - if (rel.empty()) - rel = "index.html"; - - // Basic traversal protection - if (rel.find("..") != std::string::npos) - { - res.status = 400; - res.set_content("Bad path", "text/plain; charset=utf-8"); - return; - } - - fs::path file_path = www_root / rel; - if (fs::is_directory(file_path)) - file_path /= "index.html"; - - std::error_code ec; - if (!fs::exists(file_path, ec) || ec) - { - res.status = 404; - res.set_content("Not Found", "text/plain; charset=utf-8"); - return; - } - - const auto body = read_file_binary(file_path); - if (body.empty()) - { - res.status = 500; - res.set_content("Failed to read file", "text/plain; charset=utf-8"); - return; - } - - res.set_header("Cache-Control", "no-store"); - res.set_content(body, content_type_for_path(file_path)); - }); -} int main(int argc, char** argv) { const int port = (argc >= 2) ? std::atoi(argv[1]) : 8080; - const fs::path www_root = (argc >= 3) ? fs::path(argv[2]) : fs::path("www"); - const fs::path data_path = (argc >= 4) ? fs::path(argv[3]) : fs::path("data/state.json"); + const std::filesystem::path www_root = (argc >= 3) ? std::filesystem::path(argv[2]) : std::filesystem::path("www"); + const std::filesystem::path data_path = + (argc >= 4) ? std::filesystem::path(argv[3]) : std::filesystem::path("data/state.json"); - AppState app; - app.data_path = data_path; - load_state(app); - - httplib::Server svr; - - svr.Options(R"(/api/(.*))", [](const httplib::Request&, httplib::Response& res) { - add_cors(res); - res.status = 204; - }); - - svr.Get("/api/health", [](const httplib::Request&, httplib::Response& res) { - add_cors(res); - res.set_header("Content-Type", "application/json; charset=utf-8"); - res.body = json({{"ok", true}}).dump(); - }); - - svr.Get("/api/state", [&app](const httplib::Request&, httplib::Response& res) { - add_cors(res); - ensure_schema(app); - std::string active_name; - const auto idx = find_active_profile_index(app.state); - if (idx) - active_name = app.state["layouts"][*idx]["name"].get(); - const json response = {{"version", app.state.value("version", 3)}, - {"active_layout_id", app.state["active_layout_id"]}, - {"active_layout_name", active_name}, - {"layouts", build_layouts_catalog(app.state)}, - {"layout", app.state["layout"]}, - {"lidars", app.state["lidars"]}, - {"imus", app.state.contains("imus") ? app.state["imus"] : json::array()}}; - res.set_header("Content-Type", "application/json; charset=utf-8"); - res.body = response.dump(); - }); - - svr.Get("/api/layouts", [&app](const httplib::Request&, httplib::Response& res) { - add_cors(res); - ensure_schema(app); - const json response = {{"active_layout_id", app.state["active_layout_id"]}, - {"layouts", build_layouts_catalog(app.state)}}; - res.set_header("Content-Type", "application/json; charset=utf-8"); - res.body = response.dump(); - }); - - svr.Post("/api/layouts", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - ensure_schema(app); - json payload; - try - { - payload = json::parse(req.body); - } - catch (...) - { - return json_error(res, 400, "invalid JSON"); - } - if (!payload.is_object() || !payload.contains("name") || !payload["name"].is_string()) - return json_error(res, 400, "name is required"); - const std::string name = trim_copy(payload["name"].get()); - if (name.empty()) - return json_error(res, 400, "name is required"); - if (layout_name_exists(app.state, name)) - return json_error(res, 409, "layout name already exists"); - - const bool clone = payload.contains("clone") && payload["clone"].is_boolean() && payload["clone"].get(); - json layout = default_layout_object(); - json lidars = json::array(); - json imus = json::array(); - if (clone) - { - layout = app.state["layout"]; - lidars = app.state["lidars"]; - imus = app.state.contains("imus") && app.state["imus"].is_array() ? app.state["imus"] : json::array(); - } - json profile = make_layout_profile(name, layout, lidars, imus); - ensure_layout_schema(profile["layout"]); - if (!save_profile_to_disk(app, profile)) - return json_error(res, 500, "failed to save layout file"); - app.state["layouts"].push_back(catalog_entry_from_profile(profile)); - app.state["active_layout_id"] = profile["id"].get(); - load_active_cache(app); - save_state(app); - - 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)", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - const std::string id = req.matches[1].str(); - ensure_schema(app); - if (!find_profile_index(app.state, id)) - return json_error(res, 404, "layout not found"); - app.state["active_layout_id"] = id; - load_active_cache(app); - save_state(app); - res.set_header("Content-Type", "application/json; charset=utf-8"); - res.body = json({{"ok", true}, {"active_layout_id", id}}).dump(); - }); - - svr.Delete(R"(/api/layouts/([0-9a-fA-F]+))", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - const std::string id = req.matches[1].str(); - ensure_schema(app); - if (!app.state.contains("layouts") || !app.state["layouts"].is_array()) - return json_error(res, 404, "layout not found"); - if (app.state["layouts"].size() <= 1) - return json_error(res, 400, "cannot delete the last layout"); - const auto idx = find_profile_index(app.state, id); - if (!idx) - return json_error(res, 404, "layout not found"); - - const bool was_active = - app.state.contains("active_layout_id") && app.state["active_layout_id"].get() == id; - delete_profile_file(app, id); - app.state["layouts"].erase(app.state["layouts"].begin() + static_cast(*idx)); - if (was_active) - app.state["active_layout_id"] = app.state["layouts"][0]["id"].get(); - load_active_cache(app); - save_state(app); - res.status = 204; - }); - - svr.Get("/api/lidars", [&app](const httplib::Request&, httplib::Response& res) { - add_cors(res); - res.set_header("Content-Type", "application/json; charset=utf-8"); - res.body = app.state["lidars"].dump(); - }); - - svr.Post("/api/lidars", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - json payload; - try - { - payload = json::parse(req.body); - } - catch (...) - { - return json_error(res, 400, "invalid JSON"); - } - std::string err; - if (!validate_lidar_payload(payload, err)) - return json_error(res, 400, err); - - const std::string name = trim_copy(payload["name"].get()); - const std::string ip = trim_copy(payload["ip"].get()); - const int port = payload["port"].get(); - if (lidar_triplet_exists(app.state, name, ip, port)) - return json_error(res, 409, "lidar with same name, ip and port already exists"); - - json lidar = { - {"id", new_id()}, - {"name", name}, - {"ip", ip}, - {"port", port}, - }; - app.state["lidars"].push_back(lidar); - if (!save_app_state(app)) - return json_error(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]+))", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - const std::string id = req.matches[1].str(); - - json payload; - try - { - payload = json::parse(req.body); - } - catch (...) - { - return json_error(res, 400, "invalid JSON"); - } - std::string err; - if (!validate_lidar_payload(payload, err)) - return json_error(res, 400, err); - - auto idx = find_lidar_index(app.state, id); - if (!idx) - return json_error(res, 404, "lidar not found"); - - const std::string name = trim_copy(payload["name"].get()); - const std::string ip = trim_copy(payload["ip"].get()); - const int port = payload["port"].get(); - if (lidar_triplet_exists(app.state, name, ip, port, &id)) - return json_error(res, 409, "lidar with same name, ip and port already exists"); - - auto& lidar = app.state["lidars"][*idx]; - lidar["name"] = name; - lidar["ip"] = ip; - lidar["port"] = port; - if (!save_app_state(app)) - return json_error(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]+))", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - const std::string id = req.matches[1].str(); - auto idx = find_lidar_index(app.state, id); - if (!idx) - return json_error(res, 404, "lidar not found"); - - app.state["lidars"].erase(app.state["lidars"].begin() + static_cast(*idx)); - // Also remove pose entry if present - if (app.state.contains("layout") && app.state["layout"].is_object()) - { - if (app.state["layout"].contains("lidarPoses") && app.state["layout"]["lidarPoses"].is_object()) - app.state["layout"]["lidarPoses"].erase(id); - if (app.state["layout"].contains("lidarPositions") && app.state["layout"]["lidarPositions"].is_object()) - app.state["layout"]["lidarPositions"].erase(id); - } - if (!save_app_state(app)) - return json_error(res, 500, "failed to save layout"); - res.status = 204; - }); - - svr.Get("/api/layout", [&app](const httplib::Request&, httplib::Response& res) { - add_cors(res); - res.set_header("Content-Type", "application/json; charset=utf-8"); - res.body = app.state["layout"].dump(); - }); - - svr.Put("/api/layout", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - json payload; - try - { - payload = json::parse(req.body); - } - catch (...) - { - return json_error(res, 400, "invalid JSON"); - } - if (!payload.is_object()) - return json_error(res, 400, "layout must be an object"); - - app.state["layout"] = payload; - if (!save_app_state(app)) - return json_error(res, 500, "failed to save layout"); - res.set_header("Content-Type", "application/json; charset=utf-8"); - res.body = app.state["layout"].dump(); - }); - - svr.Put(R"(/api/layouts/([0-9a-fA-F]+))", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - const std::string id = req.matches[1].str(); - json payload; - try - { - payload = json::parse(req.body); - } - catch (...) - { - return json_error(res, 400, "invalid JSON"); - } - if (!payload.is_object()) - return json_error(res, 400, "payload must be an object"); - - ensure_schema(app); - const auto idx = find_profile_index(app.state, id); - if (!idx) - return json_error(res, 404, "layout not found"); - - auto loaded = load_profile_from_disk(app, id); - if (!loaded) - return json_error(res, 404, "layout file not found"); - json profile = *loaded; - - if (payload.contains("name") && payload["name"].is_string()) - { - const std::string name = trim_copy(payload["name"].get()); - if (name.empty()) - return json_error(res, 400, "name is required"); - if (layout_name_exists(app.state, name, &id)) - return json_error(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"] = json::array(); - ensure_layout_schema(profile["layout"]); - touch_profile(profile); - if (!save_profile_to_disk(app, profile)) - return json_error(res, 500, "failed to save layout file"); - - app.state["layouts"][*idx] = catalog_entry_from_profile(profile); - const bool is_active = - app.state.contains("active_layout_id") && app.state["active_layout_id"].get() == id; - if (is_active) - { - app.state["layout"] = profile["layout"]; - app.state["lidars"] = profile["lidars"]; - app.state["imus"] = profile["imus"]; - } - save_state(app); - - res.set_header("Content-Type", "application/json; charset=utf-8"); - res.body = profile.dump(); - }); - - svr.Get("/api/imus", [&app](const httplib::Request&, httplib::Response& res) { - add_cors(res); - ensure_schema(app); - res.set_header("Content-Type", "application/json; charset=utf-8"); - res.body = (app.state.contains("imus") ? app.state["imus"] : json::array()).dump(); - }); - - svr.Post("/api/imus", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - json payload; - try - { - payload = json::parse(req.body); - } - catch (...) - { - return json_error(res, 400, "invalid JSON"); - } - std::string err; - if (!validate_imu_payload(payload, err)) - return json_error(res, 400, err); - - const std::string name = trim_copy(payload["name"].get()); - const std::string frame_id = trim_copy(payload["frame_id"].get()); - const std::string topic = trim_copy(payload["topic"].get()); - if (imu_frame_exists(app.state, frame_id)) - return json_error(res, 409, "imu with same frame_id already exists"); - - if (!app.state.contains("imus") || !app.state["imus"].is_array()) - app.state["imus"] = 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; - - json imu = {{"id", new_id()}, - {"name", name}, - {"frame_id", frame_id}, - {"topic", topic}, - {"source", source}, - {"enabled", enabled}, - {"rate_hz", rate_hz}}; - app.state["imus"].push_back(imu); - if (!save_app_state(app)) - return json_error(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]+))", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - const std::string id = req.matches[1].str(); - - json payload; - try - { - payload = json::parse(req.body); - } - catch (...) - { - return json_error(res, 400, "invalid JSON"); - } - std::string err; - if (!validate_imu_payload(payload, err)) - return json_error(res, 400, err); - - auto idx = find_imu_index(app.state, id); - if (!idx) - return json_error(res, 404, "imu not found"); - - const std::string name = trim_copy(payload["name"].get()); - const std::string frame_id = trim_copy(payload["frame_id"].get()); - const std::string topic = trim_copy(payload["topic"].get()); - if (imu_frame_exists(app.state, frame_id, &id)) - return json_error(res, 409, "imu with same frame_id already exists"); - - auto& imu = 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 (!save_app_state(app)) - return json_error(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]+))", [&app](const httplib::Request& req, httplib::Response& res) { - add_cors(res); - const std::string id = req.matches[1].str(); - auto idx = find_imu_index(app.state, id); - if (!idx) - return json_error(res, 404, "imu not found"); - - app.state["imus"].erase(app.state["imus"].begin() + static_cast(*idx)); - if (app.state.contains("layout") && app.state["layout"].is_object()) - { - if (app.state["layout"].contains("imuPoses") && app.state["layout"]["imuPoses"].is_object()) - app.state["layout"]["imuPoses"].erase(id); - } - if (!save_app_state(app)) - return json_error(res, 500, "failed to save layout"); - res.status = 204; - }); - - mount_static(svr, www_root); - - // Console hint - std::fprintf(stderr, - "lidar_manager_web listening on http://0.0.0.0:%d (www=%s, state=%s, models=%s)\n", - port, - www_root.string().c_str(), - data_path.string().c_str(), - (data_path.parent_path() / "models").string().c_str()); - - svr.listen("0.0.0.0", port); - return 0; + lm::LidarManagerApp app(port, www_root, data_path); + return app.run(); } - diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp new file mode 100644 index 0000000..6a5e943 --- /dev/null +++ b/src/server/api_server.cpp @@ -0,0 +1,441 @@ +#include "server/api_server.hpp" + +#include "domain/layout_profile.hpp" +#include "domain/layout_schema.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) : repo_(repo) {} + +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; + }); +} + +} // namespace lm diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp new file mode 100644 index 0000000..ea28163 --- /dev/null +++ b/src/server/api_server.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include + +#include "storage/state_repository.hpp" + +namespace lm { + +class ApiServer +{ +public: + explicit ApiServer(StateRepository& repo); + + void registerRoutes(httplib::Server& svr); + +private: + StateRepository& repo_; +}; + +} // namespace lm diff --git a/src/server/static_file_server.cpp b/src/server/static_file_server.cpp new file mode 100644 index 0000000..8dcb273 --- /dev/null +++ b/src/server/static_file_server.cpp @@ -0,0 +1,49 @@ +#include "server/static_file_server.hpp" + +#include "util/file_util.hpp" +#include "util/http_util.hpp" + +namespace fs = std::filesystem; + +namespace lm { + +void StaticFileServer::mount(httplib::Server& svr, const fs::path& www_root) +{ + svr.Get(R"(/(.*))", [www_root](const httplib::Request& req, httplib::Response& res) { + std::string rel = req.matches.size() >= 2 ? req.matches[1].str() : ""; + if (rel.empty()) + rel = "index.html"; + + if (rel.find("..") != std::string::npos) + { + res.status = 400; + res.set_content("Bad path", "text/plain; charset=utf-8"); + return; + } + + fs::path file_path = www_root / rel; + if (fs::is_directory(file_path)) + file_path /= "index.html"; + + std::error_code ec; + if (!fs::exists(file_path, ec) || ec) + { + res.status = 404; + res.set_content("Not Found", "text/plain; charset=utf-8"); + return; + } + + const auto body = FileUtil::readBinary(file_path); + if (body.empty()) + { + res.status = 500; + res.set_content("Failed to read file", "text/plain; charset=utf-8"); + return; + } + + res.set_header("Cache-Control", "no-store"); + res.set_content(body, HttpUtil::contentTypeForPath(file_path)); + }); +} + +} // namespace lm diff --git a/src/server/static_file_server.hpp b/src/server/static_file_server.hpp new file mode 100644 index 0000000..ce3397b --- /dev/null +++ b/src/server/static_file_server.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include + +#include + +namespace lm { + +class StaticFileServer +{ +public: + static void mount(httplib::Server& svr, const std::filesystem::path& www_root); +}; + +} // namespace lm diff --git a/src/storage/state_repository.cpp b/src/storage/state_repository.cpp new file mode 100644 index 0000000..efebb29 --- /dev/null +++ b/src/storage/state_repository.cpp @@ -0,0 +1,322 @@ +#include "storage/state_repository.hpp" + +#include "domain/layout_profile.hpp" +#include "domain/layout_schema.hpp" +#include "util/file_util.hpp" +#include "util/id_util.hpp" +#include "util/string_util.hpp" + +namespace lm { + +std::filesystem::path StateRepository::modelsDir() const +{ + return app_.data_path.parent_path() / "models"; +} + +std::filesystem::path StateRepository::profileFilePath(const std::string& id) const +{ + return modelsDir() / (id + ".json"); +} + +std::optional StateRepository::loadProfileFromDisk(const std::string& id) const +{ + const auto raw = FileUtil::readBinary(profileFilePath(id)); + if (raw.empty()) + return std::nullopt; + try + { + return nlohmann::json::parse(raw); + } + catch (...) + { + return std::nullopt; + } +} + +bool StateRepository::saveProfileToDisk(const nlohmann::json& profile) const +{ + if (!profile.is_object() || !profile.contains("id") || !profile["id"].is_string()) + return false; + std::error_code ec; + std::filesystem::create_directories(modelsDir(), ec); + auto body = profile.dump(2); + body.push_back('\n'); + return FileUtil::writeBinaryAtomic(profileFilePath(profile["id"].get()), body); +} + +bool StateRepository::deleteProfileFile(const std::string& id) const +{ + std::error_code ec; + std::filesystem::remove(profileFilePath(id), ec); + return true; +} + +void StateRepository::loadActiveCache() +{ + nlohmann::json& state = app_.state; + const auto idx = LayoutProfile::findActiveIndex(state); + if (!idx) + return; + + const std::string id = state["layouts"][*idx]["id"].get(); + nlohmann::json profile; + if (auto loaded = loadProfileFromDisk(id)) + profile = *loaded; + else + { + profile = LayoutProfile::make(state["layouts"][*idx]["name"].get(), + LayoutSchema::defaultLayoutObject(), + nlohmann::json::array()); + profile["id"] = id; + if (state["layouts"][*idx].contains("created_at")) + profile["created_at"] = state["layouts"][*idx]["created_at"]; + } + + if (!profile.contains("layout") || !profile["layout"].is_object()) + profile["layout"] = LayoutSchema::defaultLayoutObject(); + if (!profile.contains("lidars") || !profile["lidars"].is_array()) + profile["lidars"] = nlohmann::json::array(); + if (!profile.contains("imus") || !profile["imus"].is_array()) + profile["imus"] = nlohmann::json::array(); + + LayoutSchema::ensure(profile["layout"]); + state["layout"] = profile["layout"]; + state["lidars"] = profile["lidars"]; + state["imus"] = profile["imus"]; +} + +bool StateRepository::persistActiveProfile() +{ + nlohmann::json& state = app_.state; + const auto idx = LayoutProfile::findActiveIndex(state); + if (!idx) + return false; + + auto& entry = state["layouts"][*idx]; + nlohmann::json profile; + profile["id"] = entry["id"]; + profile["name"] = entry.contains("name") ? entry["name"] : nlohmann::json("Layout"); + profile["created_at"] = entry.value("created_at", IdUtil::nowIso8601()); + profile["updated_at"] = IdUtil::nowIso8601(); + profile["layout"] = state["layout"]; + profile["lidars"] = state["lidars"]; + profile["imus"] = state.contains("imus") && state["imus"].is_array() ? state["imus"] : nlohmann::json::array(); + LayoutSchema::ensure(profile["layout"]); + if (!saveProfileToDisk(profile)) + return false; + entry = LayoutProfile::catalogEntryFromProfile(profile); + return true; +} + +nlohmann::json StateRepository::globalStateForDisk(const nlohmann::json& state) const +{ + nlohmann::json out = nlohmann::json::object(); + out["version"] = 3; + if (state.contains("active_layout_id")) + out["active_layout_id"] = state["active_layout_id"]; + out["layouts"] = nlohmann::json::array(); + if (state.contains("layouts") && state["layouts"].is_array()) + { + for (const auto& entry : state["layouts"]) + { + if (!entry.is_object() || !entry.contains("id") || !entry.contains("name")) + continue; + if (entry.contains("layout")) + out["layouts"].push_back(LayoutProfile::catalogEntryFromProfile(entry)); + else + out["layouts"].push_back(entry); + } + } + return out; +} + +void StateRepository::stripInlineProfiles(nlohmann::json& state) const +{ + if (!state.contains("layouts") || !state["layouts"].is_array()) + return; + nlohmann::json catalog = nlohmann::json::array(); + for (const auto& entry : state["layouts"]) + { + if (!entry.is_object() || !entry.contains("id")) + continue; + if (entry.contains("layout")) + catalog.push_back(LayoutProfile::catalogEntryFromProfile(entry)); + else + catalog.push_back(entry); + } + state["layouts"] = catalog; +} + +void StateRepository::migrateStorage() +{ + nlohmann::json& s = app_.state; + if (!s.is_object()) + s = nlohmann::json::object(); + + const int version = s.contains("version") && s["version"].is_number_integer() ? s["version"].get() : 1; + + if (!s.contains("layouts") || !s["layouts"].is_array() || s["layouts"].empty()) + { + nlohmann::json layout = s.contains("layout") && s["layout"].is_object() ? s["layout"] : LayoutSchema::defaultLayoutObject(); + nlohmann::json lidars = s.contains("lidars") && s["lidars"].is_array() ? s["lidars"] : nlohmann::json::array(); + nlohmann::json profile = LayoutProfile::make("Mặc định", layout, lidars); + LayoutSchema::ensure(profile["layout"]); + saveProfileToDisk(profile); + s["layouts"] = nlohmann::json::array({LayoutProfile::catalogEntryFromProfile(profile)}); + s["active_layout_id"] = profile["id"].get(); + } + else if (version < 3) + { + if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() || + !LayoutProfile::findIndex(s, s["active_layout_id"].get())) + { + s["active_layout_id"] = s["layouts"][0]["id"].get(); + } + + nlohmann::json catalog = nlohmann::json::array(); + for (auto& entry : s["layouts"]) + { + if (!entry.is_object() || !entry.contains("id")) + continue; + nlohmann::json profile; + if (entry.contains("layout")) + { + profile = entry; + if (!profile.contains("name")) + profile["name"] = "Layout"; + if (!profile.contains("lidars") || !profile["lidars"].is_array()) + profile["lidars"] = nlohmann::json::array(); + if (!profile.contains("created_at")) + profile["created_at"] = IdUtil::nowIso8601(); + LayoutProfile::touch(profile); + LayoutSchema::ensure(profile["layout"]); + saveProfileToDisk(profile); + catalog.push_back(LayoutProfile::catalogEntryFromProfile(profile)); + } + else + { + const std::string id = entry["id"].get(); + if (auto loaded = loadProfileFromDisk(id)) + { + catalog.push_back(LayoutProfile::catalogEntryFromProfile(*loaded)); + } + else + { + profile = LayoutProfile::make(entry["name"].get(), LayoutSchema::defaultLayoutObject(), nlohmann::json::array()); + profile["id"] = id; + profile["created_at"] = entry.value("created_at", IdUtil::nowIso8601()); + LayoutProfile::touch(profile); + saveProfileToDisk(profile); + catalog.push_back(LayoutProfile::catalogEntryFromProfile(profile)); + } + } + } + s["layouts"] = catalog; + } + else + { + stripInlineProfiles(s); + if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() || + !LayoutProfile::findIndex(s, s["active_layout_id"].get())) + { + s["active_layout_id"] = s["layouts"][0]["id"].get(); + } + } + + s["version"] = 3; + s.erase("layout"); + s.erase("lidars"); + loadActiveCache(); +} + +void StateRepository::bootstrapDefaultState() +{ + const nlohmann::json layout = LayoutSchema::defaultLayoutObject(); + nlohmann::json profile = LayoutProfile::make("Mặc định", layout, nlohmann::json::array()); + LayoutSchema::ensure(profile["layout"]); + saveProfileToDisk(profile); + app_.state = nlohmann::json{{"version", 3}, + {"active_layout_id", profile["id"]}, + {"layouts", nlohmann::json::array({LayoutProfile::catalogEntryFromProfile(profile)})}}; + app_.state["layout"] = profile["layout"]; + app_.state["lidars"] = profile["lidars"]; + app_.state["imus"] = profile.contains("imus") ? profile["imus"] : nlohmann::json::array(); +} + +StateRepository::StateRepository(std::filesystem::path data_path) +{ + app_.data_path = std::move(data_path); +} + +bool StateRepository::load() +{ + const auto raw = FileUtil::readBinary(app_.data_path); + if (raw.empty()) + { + bootstrapDefaultState(); + save(); + return true; + } + try + { + app_.state = nlohmann::json::parse(raw); + ensureSchema(); + save(); + return true; + } + catch (...) + { + bootstrapDefaultState(); + save(); + return false; + } +} + +void StateRepository::ensureSchema() +{ + migrateStorage(); +} + +bool StateRepository::saveProfile(const nlohmann::json& profile) +{ + return saveProfileToDisk(profile); +} + +void StateRepository::reloadActiveCache() +{ + loadActiveCache(); +} + +bool StateRepository::deleteProfile(const std::string& id) +{ + return deleteProfileFile(id); +} + +std::optional StateRepository::loadProfileById(const std::string& id) const +{ + return loadProfileFromDisk(id); +} + +bool StateRepository::saveAppState() +{ + if (!persistActiveProfile()) + return false; + return save(); +} + +bool StateRepository::save() const +{ + try + { + const nlohmann::json disk = globalStateForDisk(app_.state); + auto raw = disk.dump(2); + raw.push_back('\n'); + return FileUtil::writeBinaryAtomic(app_.data_path, raw); + } + catch (...) + { + return false; + } +} + +} // namespace lm diff --git a/src/storage/state_repository.hpp b/src/storage/state_repository.hpp new file mode 100644 index 0000000..494286f --- /dev/null +++ b/src/storage/state_repository.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "app/app_state.hpp" + +#include + +#include +#include +#include + +namespace lm { + +class StateRepository +{ +public: + explicit StateRepository(std::filesystem::path data_path); + + AppState& app() { return app_; } + const AppState& app() const { return app_; } + + bool load(); + bool save() const; + bool saveAppState(); + void ensureSchema(); + + bool saveProfile(const nlohmann::json& profile); + void reloadActiveCache(); + bool deleteProfile(const std::string& id); + std::optional loadProfileById(const std::string& id) const; + +private: + AppState app_; + + std::filesystem::path modelsDir() const; + std::filesystem::path profileFilePath(const std::string& id) const; + + std::optional loadProfileFromDisk(const std::string& id) const; + bool saveProfileToDisk(const nlohmann::json& profile) const; + bool deleteProfileFile(const std::string& id) const; + + void loadActiveCache(); + bool persistActiveProfile(); + nlohmann::json globalStateForDisk(const nlohmann::json& state) const; + void stripInlineProfiles(nlohmann::json& state) const; + void migrateStorage(); + void bootstrapDefaultState(); +}; + +} // namespace lm diff --git a/src/util/file_util.cpp b/src/util/file_util.cpp new file mode 100644 index 0000000..1a64d2a --- /dev/null +++ b/src/util/file_util.cpp @@ -0,0 +1,47 @@ +#include "util/file_util.hpp" + +#include +#include + +namespace fs = std::filesystem; + +namespace lm { + +std::string FileUtil::readBinary(const fs::path& path) +{ + std::ifstream in(path, std::ios::binary); + if (!in) + return {}; + std::ostringstream ss; + ss << in.rdbuf(); + return ss.str(); +} + +bool FileUtil::writeBinaryAtomic(const fs::path& path, const std::string& contents) +{ + fs::create_directories(path.parent_path()); + auto tmp = path; + tmp += ".tmp"; + + { + std::ofstream out(tmp, std::ios::binary | std::ios::trunc); + if (!out) + return false; + out.write(contents.data(), static_cast(contents.size())); + out.flush(); + if (!out) + return false; + } + + std::error_code ec; + fs::rename(tmp, path, ec); + if (!ec) + return true; + + fs::remove(path, ec); + ec.clear(); + fs::rename(tmp, path, ec); + return !ec; +} + +} // namespace lm diff --git a/src/util/file_util.hpp b/src/util/file_util.hpp new file mode 100644 index 0000000..06179fe --- /dev/null +++ b/src/util/file_util.hpp @@ -0,0 +1,15 @@ +#pragma once + +#include +#include + +namespace lm { + +class FileUtil +{ +public: + static std::string readBinary(const std::filesystem::path& path); + static bool writeBinaryAtomic(const std::filesystem::path& path, const std::string& contents); +}; + +} // namespace lm diff --git a/src/util/http_util.cpp b/src/util/http_util.cpp new file mode 100644 index 0000000..c970b3a --- /dev/null +++ b/src/util/http_util.cpp @@ -0,0 +1,41 @@ +#include "util/http_util.hpp" + +#include "util/string_util.hpp" + +namespace lm { + +std::string HttpUtil::contentTypeForPath(const std::filesystem::path& p) +{ + const auto ext = StringUtil::toLower(p.extension().string()); + if (ext == ".html") + return "text/html; charset=utf-8"; + if (ext == ".css") + return "text/css; charset=utf-8"; + if (ext == ".js") + return "application/javascript; charset=utf-8"; + if (ext == ".json") + return "application/json; charset=utf-8"; + if (ext == ".png") + return "image/png"; + if (ext == ".svg") + return "image/svg+xml"; + if (ext == ".ico") + return "image/x-icon"; + return "application/octet-stream"; +} + +void HttpUtil::jsonError(httplib::Response& res, int status, const std::string& msg) +{ + res.status = status; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json({{"error", msg}}).dump(); +} + +void HttpUtil::addCors(httplib::Response& res) +{ + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); + res.set_header("Access-Control-Allow-Headers", "Content-Type"); +} + +} // namespace lm diff --git a/src/util/http_util.hpp b/src/util/http_util.hpp new file mode 100644 index 0000000..efca7d7 --- /dev/null +++ b/src/util/http_util.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +#include +#include + +namespace lm { + +class HttpUtil +{ +public: + static std::string contentTypeForPath(const std::filesystem::path& p); + static void jsonError(httplib::Response& res, int status, const std::string& msg); + static void addCors(httplib::Response& res); +}; + +} // namespace lm diff --git a/src/util/id_util.cpp b/src/util/id_util.cpp new file mode 100644 index 0000000..369ee1d --- /dev/null +++ b/src/util/id_util.cpp @@ -0,0 +1,35 @@ +#include "util/id_util.hpp" + +#include +#include +#include + +namespace lm { + +std::string IdUtil::newId() +{ + static thread_local std::mt19937_64 rng{std::random_device{}()}; + static constexpr char kHex[] = "0123456789abcdef"; + std::string out; + out.resize(16); + for (int i = 0; i < 16; i++) + out[i] = kHex[static_cast(rng() & 0xF)]; + return out; +} + +std::string IdUtil::nowIso8601() +{ + using clock = std::chrono::system_clock; + const std::time_t t = clock::to_time_t(clock::now()); + std::tm tm{}; +#if defined(_WIN32) + gmtime_s(&tm, &t); +#else + gmtime_r(&t, &tm); +#endif + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%SZ", &tm); + return buf; +} + +} // namespace lm diff --git a/src/util/id_util.hpp b/src/util/id_util.hpp new file mode 100644 index 0000000..20e53ea --- /dev/null +++ b/src/util/id_util.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace lm { + +class IdUtil +{ +public: + static std::string newId(); + static std::string nowIso8601(); +}; + +} // namespace lm diff --git a/src/util/string_util.cpp b/src/util/string_util.cpp new file mode 100644 index 0000000..bf862bb --- /dev/null +++ b/src/util/string_util.cpp @@ -0,0 +1,25 @@ +#include "util/string_util.hpp" + +#include +#include + +namespace lm { + +std::string StringUtil::toLower(std::string s) +{ + std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + return s; +} + +std::string StringUtil::trimCopy(const std::string& s) +{ + size_t a = 0; + while (a < s.size() && std::isspace(static_cast(s[a]))) + a++; + size_t b = s.size(); + while (b > a && std::isspace(static_cast(s[b - 1]))) + b--; + return s.substr(a, b - a); +} + +} // namespace lm diff --git a/src/util/string_util.hpp b/src/util/string_util.hpp new file mode 100644 index 0000000..1c582c8 --- /dev/null +++ b/src/util/string_util.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace lm { + +class StringUtil +{ +public: + static std::string toLower(std::string s); + static std::string trimCopy(const std::string& s); +}; + +} // namespace lm diff --git a/src/validation/sensor_validator.cpp b/src/validation/sensor_validator.cpp new file mode 100644 index 0000000..8f89423 --- /dev/null +++ b/src/validation/sensor_validator.cpp @@ -0,0 +1,151 @@ +#include "validation/sensor_validator.hpp" + +#include "util/string_util.hpp" + +namespace lm { + +std::optional SensorValidator::findLidarIndex(const nlohmann::json& state, const std::string& id) +{ + if (!state.contains("lidars") || !state["lidars"].is_array()) + return std::nullopt; + const auto& lidars = state["lidars"]; + for (size_t i = 0; i < lidars.size(); i++) + { + const auto& l = lidars[i]; + if (l.is_object() && l.contains("id") && l["id"].is_string() && l["id"].get() == id) + return i; + } + return std::nullopt; +} + +std::optional SensorValidator::findImuIndex(const nlohmann::json& state, const std::string& id) +{ + if (!state.contains("imus") || !state["imus"].is_array()) + return std::nullopt; + const auto& imus = state["imus"]; + for (size_t i = 0; i < imus.size(); i++) + { + const auto& im = imus[i]; + if (im.is_object() && im.contains("id") && im["id"].is_string() && im["id"].get() == id) + return i; + } + return std::nullopt; +} + +bool SensorValidator::validateLidarPayload(const nlohmann::json& payload, std::string& err) +{ + if (!payload.is_object()) + { + err = "payload must be a JSON object"; + return false; + } + if (!payload.contains("name") || !payload["name"].is_string() || payload["name"].get().empty()) + { + err = "name is required"; + return false; + } + if (!payload.contains("ip") || !payload["ip"].is_string() || payload["ip"].get().empty()) + { + err = "ip is required"; + return false; + } + if (!payload.contains("port") || !payload["port"].is_number_integer()) + { + err = "port must be an integer"; + return false; + } + const int port = payload["port"].get(); + if (port < 1 || port > 65535) + { + err = "port must be in range 1..65535"; + return false; + } + return true; +} + +bool SensorValidator::lidarTripletExists(const nlohmann::json& state, + const std::string& name, + const std::string& ip, + int port, + const std::string* exclude_id) +{ + if (!state.contains("lidars") || !state["lidars"].is_array()) + return false; + const std::string n = StringUtil::trimCopy(name); + const std::string i = StringUtil::trimCopy(ip); + for (const auto& l : state["lidars"]) + { + if (!l.is_object()) + continue; + if (exclude_id && l.contains("id") && l["id"].get() == *exclude_id) + continue; + if (!l.contains("name") || !l.contains("ip") || !l.contains("port")) + continue; + if (StringUtil::trimCopy(l["name"].get()) == n && StringUtil::trimCopy(l["ip"].get()) == i && + l["port"].get() == port) + return true; + } + return false; +} + +bool SensorValidator::validateImuPayload(const nlohmann::json& payload, std::string& err) +{ + if (!payload.is_object()) + { + err = "payload must be a JSON object"; + return false; + } + if (!payload.contains("name") || !payload["name"].is_string() || payload["name"].get().empty()) + { + err = "name is required"; + return false; + } + if (!payload.contains("frame_id") || !payload["frame_id"].is_string() || + payload["frame_id"].get().empty()) + { + err = "frame_id is required"; + return false; + } + if (!payload.contains("topic") || !payload["topic"].is_string() || payload["topic"].get().empty()) + { + err = "topic is required"; + return false; + } + if (payload.contains("source") && payload["source"].is_string()) + { + const std::string src = payload["source"].get(); + if (src != "external" && src != "lidar_builtin" && src != "onboard") + { + err = "source must be external, lidar_builtin, or onboard"; + return false; + } + } + if (payload.contains("rate_hz") && !payload["rate_hz"].is_number()) + { + err = "rate_hz must be a number"; + return false; + } + return true; +} + +bool SensorValidator::imuFrameExists(const nlohmann::json& state, + const std::string& frame_id, + const std::string* exclude_id) +{ + if (!state.contains("imus") || !state["imus"].is_array()) + return false; + const std::string f = StringUtil::trimCopy(frame_id); + for (const auto& im : state["imus"]) + { + if (!im.is_object()) + continue; + if (exclude_id && im.contains("id") && im["id"].get() == *exclude_id) + continue; + if (!im.contains("frame_id")) + continue; + if (StringUtil::trimCopy(im["frame_id"].get()) == f) + return true; + } + return false; +} +} // namespace lm diff --git a/src/validation/sensor_validator.hpp b/src/validation/sensor_validator.hpp new file mode 100644 index 0000000..2c33199 --- /dev/null +++ b/src/validation/sensor_validator.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include + +#include +#include + +namespace lm { + +class SensorValidator +{ +public: + static bool validateLidarPayload(const nlohmann::json& payload, std::string& err); + static bool validateImuPayload(const nlohmann::json& payload, std::string& err); + + static bool lidarTripletExists(const nlohmann::json& state, + const std::string& name, + const std::string& ip, + int port, + const std::string* exclude_id = nullptr); + + static bool imuFrameExists(const nlohmann::json& state, + const std::string& frame_id, + const std::string* exclude_id = nullptr); + + static std::optional findLidarIndex(const nlohmann::json& state, const std::string& id); + static std::optional findImuIndex(const nlohmann::json& state, const std::string& id); +}; + +} // namespace lm