layout source from main
This commit is contained in:
@@ -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"
|
||||
|
||||
14
src/app/app_state.hpp
Normal file
14
src/app/app_state.hpp
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
namespace lm {
|
||||
|
||||
struct AppState
|
||||
{
|
||||
std::filesystem::path data_path;
|
||||
nlohmann::json state;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
40
src/app/lidar_manager_app.cpp
Normal file
40
src/app/lidar_manager_app.cpp
Normal file
@@ -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 <httplib.h>
|
||||
#include <cstdio>
|
||||
|
||||
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
|
||||
20
src/app/lidar_manager_app.hpp
Normal file
20
src/app/lidar_manager_app.hpp
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
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
|
||||
105
src/domain/layout_profile.cpp
Normal file
105
src/domain/layout_profile.cpp
Normal file
@@ -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<std::string>();
|
||||
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<size_t> 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<std::string>() == id)
|
||||
return i;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<size_t> 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<std::string>());
|
||||
}
|
||||
|
||||
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<std::string>() == *exclude_id)
|
||||
continue;
|
||||
if (StringUtil::trimCopy(p["name"].get<std::string>()) == 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
|
||||
32
src/domain/layout_profile.hpp
Normal file
32
src/domain/layout_profile.hpp
Normal file
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
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<size_t> findIndex(const nlohmann::json& state, const std::string& id);
|
||||
static std::optional<size_t> 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
|
||||
310
src/domain/layout_schema.cpp
Normal file
310
src/domain/layout_schema.cpp
Normal file
@@ -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<double>();
|
||||
|
||||
if (!diff.contains("wheel_separation_m"))
|
||||
{
|
||||
if (diff.contains("b"))
|
||||
diff["wheel_separation_m"] = diff["b"].get<double>() * 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<double>() * 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<double>();
|
||||
const double r_mult = diff["wheel_radius_multiplier"].get<double>();
|
||||
const double sep_m = diff["wheel_separation_m"].get<double>();
|
||||
const double rad_m = diff["wheel_radius_m"].get<double>();
|
||||
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<std::string>() == "right") ? "right" : "left";
|
||||
if (!w.contains("joint_name"))
|
||||
{
|
||||
w["joint_name"] = (w["side"].get<std::string>() == "right") ? "wheel_right_joint"
|
||||
: "wheel_left_joint";
|
||||
}
|
||||
if (!w.contains("y_m"))
|
||||
{
|
||||
w["y_m"] = (w["side"].get<std::string>() == "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<double>();
|
||||
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<double>();
|
||||
const double b_rad = bicycle["wheel_radius_m"].get<double>();
|
||||
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
|
||||
18
src/domain/layout_schema.hpp
Normal file
18
src/domain/layout_schema.hpp
Normal file
@@ -0,0 +1,18 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
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
|
||||
1470
src/main.cpp
1470
src/main.cpp
File diff suppressed because it is too large
Load Diff
441
src/server/api_server.cpp
Normal file
441
src/server/api_server.cpp
Normal file
@@ -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<std::string>();
|
||||
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<std::string>());
|
||||
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<bool>();
|
||||
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<std::string>();
|
||||
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<std::string>() == id;
|
||||
repo_.deleteProfile(id);
|
||||
repo_.app().state["layouts"].erase(repo_.app().state["layouts"].begin() + static_cast<nlohmann::json::difference_type>(*idx));
|
||||
if (was_active)
|
||||
repo_.app().state["active_layout_id"] = repo_.app().state["layouts"][0]["id"].get<std::string>();
|
||||
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<std::string>());
|
||||
const std::string ip = StringUtil::trimCopy(payload["ip"].get<std::string>());
|
||||
const int port = payload["port"].get<int>();
|
||||
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<std::string>());
|
||||
const std::string ip = StringUtil::trimCopy(payload["ip"].get<std::string>());
|
||||
const int port = payload["port"].get<int>();
|
||||
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<nlohmann::json::difference_type>(*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<std::string>());
|
||||
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<std::string>() == 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<std::string>());
|
||||
const std::string frame_id = StringUtil::trimCopy(payload["frame_id"].get<std::string>());
|
||||
const std::string topic = StringUtil::trimCopy(payload["topic"].get<std::string>());
|
||||
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<std::string>()
|
||||
: "external";
|
||||
const bool enabled = !payload.contains("enabled") || payload["enabled"].get<bool>();
|
||||
const double rate_hz =
|
||||
payload.contains("rate_hz") && payload["rate_hz"].is_number() ? payload["rate_hz"].get<double>() : 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<std::string>());
|
||||
const std::string frame_id = StringUtil::trimCopy(payload["frame_id"].get<std::string>());
|
||||
const std::string topic = StringUtil::trimCopy(payload["topic"].get<std::string>());
|
||||
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<nlohmann::json::difference_type>(*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
|
||||
20
src/server/api_server.hpp
Normal file
20
src/server/api_server.hpp
Normal file
@@ -0,0 +1,20 @@
|
||||
#pragma once
|
||||
|
||||
#include <httplib.h>
|
||||
|
||||
#include "storage/state_repository.hpp"
|
||||
|
||||
namespace lm {
|
||||
|
||||
class ApiServer
|
||||
{
|
||||
public:
|
||||
explicit ApiServer(StateRepository& repo);
|
||||
|
||||
void registerRoutes(httplib::Server& svr);
|
||||
|
||||
private:
|
||||
StateRepository& repo_;
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
49
src/server/static_file_server.cpp
Normal file
49
src/server/static_file_server.cpp
Normal file
@@ -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
|
||||
15
src/server/static_file_server.hpp
Normal file
15
src/server/static_file_server.hpp
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <httplib.h>
|
||||
|
||||
#include <filesystem>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class StaticFileServer
|
||||
{
|
||||
public:
|
||||
static void mount(httplib::Server& svr, const std::filesystem::path& www_root);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
322
src/storage/state_repository.cpp
Normal file
322
src/storage/state_repository.cpp
Normal file
@@ -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<nlohmann::json> 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<std::string>()), 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<std::string>();
|
||||
nlohmann::json profile;
|
||||
if (auto loaded = loadProfileFromDisk(id))
|
||||
profile = *loaded;
|
||||
else
|
||||
{
|
||||
profile = LayoutProfile::make(state["layouts"][*idx]["name"].get<std::string>(),
|
||||
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<int>() : 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<std::string>();
|
||||
}
|
||||
else if (version < 3)
|
||||
{
|
||||
if (!s.contains("active_layout_id") || !s["active_layout_id"].is_string() ||
|
||||
!LayoutProfile::findIndex(s, s["active_layout_id"].get<std::string>()))
|
||||
{
|
||||
s["active_layout_id"] = s["layouts"][0]["id"].get<std::string>();
|
||||
}
|
||||
|
||||
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<std::string>();
|
||||
if (auto loaded = loadProfileFromDisk(id))
|
||||
{
|
||||
catalog.push_back(LayoutProfile::catalogEntryFromProfile(*loaded));
|
||||
}
|
||||
else
|
||||
{
|
||||
profile = LayoutProfile::make(entry["name"].get<std::string>(), 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<std::string>()))
|
||||
{
|
||||
s["active_layout_id"] = s["layouts"][0]["id"].get<std::string>();
|
||||
}
|
||||
}
|
||||
|
||||
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<nlohmann::json> 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
|
||||
49
src/storage/state_repository.hpp
Normal file
49
src/storage/state_repository.hpp
Normal file
@@ -0,0 +1,49 @@
|
||||
#pragma once
|
||||
|
||||
#include "app/app_state.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
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<nlohmann::json> 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<nlohmann::json> 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
|
||||
47
src/util/file_util.cpp
Normal file
47
src/util/file_util.cpp
Normal file
@@ -0,0 +1,47 @@
|
||||
#include "util/file_util.hpp"
|
||||
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
|
||||
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<std::streamsize>(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
|
||||
15
src/util/file_util.hpp
Normal file
15
src/util/file_util.hpp
Normal file
@@ -0,0 +1,15 @@
|
||||
#pragma once
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
41
src/util/http_util.cpp
Normal file
41
src/util/http_util.cpp
Normal file
@@ -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
|
||||
19
src/util/http_util.hpp
Normal file
19
src/util/http_util.hpp
Normal file
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include <httplib.h>
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
35
src/util/id_util.cpp
Normal file
35
src/util/id_util.cpp
Normal file
@@ -0,0 +1,35 @@
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
#include <random>
|
||||
|
||||
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<size_t>(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
|
||||
14
src/util/id_util.hpp
Normal file
14
src/util/id_util.hpp
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class IdUtil
|
||||
{
|
||||
public:
|
||||
static std::string newId();
|
||||
static std::string nowIso8601();
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
25
src/util/string_util.cpp
Normal file
25
src/util/string_util.cpp
Normal file
@@ -0,0 +1,25 @@
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
|
||||
namespace lm {
|
||||
|
||||
std::string StringUtil::toLower(std::string s)
|
||||
{
|
||||
std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return static_cast<char>(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<unsigned char>(s[a])))
|
||||
a++;
|
||||
size_t b = s.size();
|
||||
while (b > a && std::isspace(static_cast<unsigned char>(s[b - 1])))
|
||||
b--;
|
||||
return s.substr(a, b - a);
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
14
src/util/string_util.hpp
Normal file
14
src/util/string_util.hpp
Normal file
@@ -0,0 +1,14 @@
|
||||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class StringUtil
|
||||
{
|
||||
public:
|
||||
static std::string toLower(std::string s);
|
||||
static std::string trimCopy(const std::string& s);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
151
src/validation/sensor_validator.cpp
Normal file
151
src/validation/sensor_validator.cpp
Normal file
@@ -0,0 +1,151 @@
|
||||
#include "validation/sensor_validator.hpp"
|
||||
|
||||
#include "util/string_util.hpp"
|
||||
|
||||
namespace lm {
|
||||
|
||||
std::optional<size_t> 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<std::string>() == id)
|
||||
return i;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<size_t> 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<std::string>() == 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<std::string>().empty())
|
||||
{
|
||||
err = "name is required";
|
||||
return false;
|
||||
}
|
||||
if (!payload.contains("ip") || !payload["ip"].is_string() || payload["ip"].get<std::string>().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<int>();
|
||||
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<std::string>() == *exclude_id)
|
||||
continue;
|
||||
if (!l.contains("name") || !l.contains("ip") || !l.contains("port"))
|
||||
continue;
|
||||
if (StringUtil::trimCopy(l["name"].get<std::string>()) == n && StringUtil::trimCopy(l["ip"].get<std::string>()) == i &&
|
||||
l["port"].get<int>() == 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<std::string>().empty())
|
||||
{
|
||||
err = "name is required";
|
||||
return false;
|
||||
}
|
||||
if (!payload.contains("frame_id") || !payload["frame_id"].is_string() ||
|
||||
payload["frame_id"].get<std::string>().empty())
|
||||
{
|
||||
err = "frame_id is required";
|
||||
return false;
|
||||
}
|
||||
if (!payload.contains("topic") || !payload["topic"].is_string() || payload["topic"].get<std::string>().empty())
|
||||
{
|
||||
err = "topic is required";
|
||||
return false;
|
||||
}
|
||||
if (payload.contains("source") && payload["source"].is_string())
|
||||
{
|
||||
const std::string src = payload["source"].get<std::string>();
|
||||
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<std::string>() == *exclude_id)
|
||||
continue;
|
||||
if (!im.contains("frame_id"))
|
||||
continue;
|
||||
if (StringUtil::trimCopy(im["frame_id"].get<std::string>()) == f)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
} // namespace lm
|
||||
30
src/validation/sensor_validator.hpp
Normal file
30
src/validation/sensor_validator.hpp
Normal file
@@ -0,0 +1,30 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
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<size_t> findLidarIndex(const nlohmann::json& state, const std::string& id);
|
||||
static std::optional<size_t> findImuIndex(const nlohmann::json& state, const std::string& id);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
Reference in New Issue
Block a user