add top bar

This commit is contained in:
2026-06-16 11:17:28 +07:00
parent 9aee5f4100
commit 1156e1ab29
19 changed files with 1625 additions and 80 deletions

View File

@@ -6,6 +6,7 @@
#include "mission/mission_scheduler.hpp"
#include "mission/mission_store.hpp"
#include "mission/modbus_trigger_service.hpp"
#include "robot/robot_runtime.hpp"
#include "server/api_server.hpp"
#include "server/static_file_server.hpp"
#include "storage/state_repository.hpp"
@@ -31,6 +32,7 @@ int LidarManagerApp::run()
const std::filesystem::path missions_store_path = data_path_.parent_path() / "missions.json";
MissionQueue mission_queue(mission_queue_path);
MissionStore mission_store(missions_store_path);
RobotRuntime robot_runtime(data_path_.parent_path() / "robot_runtime.json", mission_queue);
const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool {
nlohmann::json payload;
@@ -49,7 +51,7 @@ int LidarManagerApp::run()
return auth.preRoute(req, res);
});
ApiServer api(repo, mission_queue, mission_store, modbus, scheduler);
ApiServer api(repo, mission_queue, mission_store, modbus, scheduler, robot_runtime);
api.registerRoutes(svr);
auth.registerRoutes(svr);
StaticFileServer::mount(svr, www_root_);

View File

@@ -157,7 +157,8 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
return std::nullopt;
if (path.rfind("/api/users", 0) == 0 || path.rfind("/api/user_groups", 0) == 0)
return "users";
if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0)
if (path.rfind("/api/missions", 0) == 0 || path.rfind("/api/mission_queue", 0) == 0 ||
path.rfind("/api/robot", 0) == 0)
return "missions";
if (path.rfind("/api/triggers", 0) == 0 || path.rfind("/api/schedules", 0) == 0 ||
path.rfind("/api/robots", 0) == 0 || path.rfind("/api/fleet", 0) == 0 ||
@@ -410,6 +411,47 @@ bool AuthService::changePassword(const std::string& token,
return false;
}
std::optional<nlohmann::json> AuthService::changeProfile(const std::string& token,
const nlohmann::json& payload,
std::string& err)
{
if (!payload.is_object())
{
err = "invalid payload";
return std::nullopt;
}
std::lock_guard<std::mutex> lock(mu_);
const auto it = sessions_.find(token);
if (it == sessions_.end())
{
err = "not authenticated";
return std::nullopt;
}
for (auto& user : data_["users"])
{
if (user.value("id", "") != it->second.user_id)
continue;
if (payload.contains("display_name") && payload["display_name"].is_string())
{
const std::string name = StringUtil::trimCopy(payload["display_name"].get<std::string>());
if (name.empty())
{
err = "display_name cannot be empty";
return std::nullopt;
}
user["display_name"] = name;
}
saveUnlocked();
const auto* group = findGroupByIdUnlocked(user.value("group_id", ""));
return userPublicView(user, group ? *group : nlohmann::json::object());
}
err = "user not found";
return std::nullopt;
}
nlohmann::json AuthService::listGroups() const
{
std::lock_guard<std::mutex> lock(mu_);
@@ -704,6 +746,31 @@ void AuthService::registerRoutes(httplib::Server& svr)
HttpUtil::addCors(res);
});
svr.Put("/api/auth/profile", [this](const httplib::Request& req, httplib::Response& res) {
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
HttpUtil::jsonError(res, 400, "invalid json");
return;
}
const std::string token = extractToken(req);
std::string err;
const auto profile = changeProfile(token, body, err);
if (!profile)
{
HttpUtil::jsonError(res, 400, err);
return;
}
nlohmann::json out = {{"user", *profile}};
res.set_content(out.dump(), "application/json; charset=utf-8");
HttpUtil::addCors(res);
});
svr.Get("/api/user_groups", [this](const httplib::Request&, httplib::Response& res) {
nlohmann::json out = {{"groups", listGroups()}};
res.set_content(out.dump(), "application/json; charset=utf-8");

View File

@@ -40,6 +40,9 @@ public:
const std::string& current_password,
const std::string& new_password,
std::string& err);
std::optional<nlohmann::json> changeProfile(const std::string& token,
const nlohmann::json& payload,
std::string& err);
nlohmann::json listGroups() const;
nlohmann::json listUsers() const;

265
src/robot/robot_runtime.cpp Normal file
View File

@@ -0,0 +1,265 @@
#include "robot/robot_runtime.hpp"
#include "mission/mission_queue.hpp"
#include "util/file_util.hpp"
#include "util/id_util.hpp"
#include <algorithm>
#include <chrono>
#include <cmath>
namespace lm {
namespace {
constexpr const char* kDefaultMessage = "Waiting for new missions...";
} // namespace
RobotRuntime::RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue)
: runtime_path_(std::move(runtime_path)), mission_queue_(mission_queue)
{
load();
ensureDefaultsUnlocked();
}
void RobotRuntime::load()
{
std::lock_guard<std::mutex> lock(mu_);
state_ = nlohmann::json::object();
if (!std::filesystem::exists(runtime_path_))
return;
try
{
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(runtime_path_));
if (parsed.is_object())
state_ = parsed;
}
catch (...)
{
state_ = nlohmann::json::object();
}
}
void RobotRuntime::saveUnlocked() const
{
FileUtil::writeBinaryAtomic(runtime_path_, state_.dump(2));
}
void RobotRuntime::ensureDefaultsUnlocked()
{
if (!state_.is_object())
state_ = nlohmann::json::object();
if (!state_.contains("motion"))
state_["motion"] = "paused";
if (!state_.contains("health"))
state_["health"] = "ok";
if (!state_.contains("message"))
state_["message"] = kDefaultMessage;
if (!state_.contains("error"))
state_["error"] = nullptr;
if (!state_.contains("battery_percent"))
state_["battery_percent"] = 54;
if (!state_.contains("battery_charging"))
state_["battery_charging"] = false;
if (!state_.contains("joystick_engaged"))
state_["joystick_engaged"] = false;
if (!state_.contains("joystick_speed"))
state_["joystick_speed"] = "fast";
if (!state_.contains("cmd_linear"))
state_["cmd_linear"] = 0.0;
if (!state_.contains("cmd_angular"))
state_["cmd_angular"] = 0.0;
if (!state_.contains("updated_at"))
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
}
nlohmann::json RobotRuntime::buildStatusUnlocked() const
{
const auto runner = mission_queue_.runnerStatus();
const auto queue = mission_queue_.list();
int pending = 0;
if (queue.is_array())
{
for (const auto& item : queue)
{
if (item.value("status", "") == "pending")
++pending;
}
}
const std::string motion = state_.value("motion", "paused");
const std::string health = state_.value("health", "ok");
const std::string runner_state = runner.value("state", "idle");
std::string message = state_.value("message", kDefaultMessage);
if (health == "ok")
{
if (!runner.value("message", "").empty())
message = runner.value("message", "");
else if (runner_state == "idle" && motion == "paused" && pending == 0)
message = kDefaultMessage;
}
nlohmann::json body = {{"motion", motion},
{"health", health},
{"message", message},
{"error", state_.contains("error") ? state_["error"] : nullptr},
{"battery_percent", state_.value("battery_percent", 54)},
{"battery_charging", state_.value("battery_charging", false)},
{"joystick_engaged", state_.value("joystick_engaged", false)},
{"joystick_speed", state_.value("joystick_speed", "fast")},
{"cmd_linear", state_.value("cmd_linear", 0.0)},
{"cmd_angular", state_.value("cmd_angular", 0.0)},
{"runner", runner},
{"queue_pending", pending},
{"updated_at", state_.value("updated_at", "")}};
return body;
}
nlohmann::json RobotRuntime::status() const
{
std::lock_guard<std::mutex> lock(mu_);
return buildStatusUnlocked();
}
bool RobotRuntime::start(std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (state_.value("health", "ok") == "error")
{
err = "cannot start while robot is in error state — reset error first";
return false;
}
state_["motion"] = "running";
state_["message"] = "Robot running";
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
if (runner_state == "paused")
return mission_queue_.resume(err);
return true;
}
bool RobotRuntime::pause(std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
state_["motion"] = "paused";
state_["message"] = "Robot paused";
state_["joystick_engaged"] = false;
state_["cmd_linear"] = 0.0;
state_["cmd_angular"] = 0.0;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
const std::string runner_state = mission_queue_.runnerStatus().value("state", "idle");
if (runner_state == "running" || runner_state == "paused")
return mission_queue_.pause(err);
return true;
}
bool RobotRuntime::resetError(std::string& err)
{
(void)err;
std::lock_guard<std::mutex> lock(mu_);
state_["health"] = "ok";
state_["error"] = nullptr;
state_["message"] = kDefaultMessage;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
bool RobotRuntime::setCmdVel(double linear, double angular, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (state_.value("health", "ok") == "error")
{
err = "robot is in error state";
return false;
}
if (!state_.value("joystick_engaged", false))
{
err = "joystick is not engaged";
return false;
}
if (state_.value("motion", "paused") != "running")
{
err = "robot motion is paused — press START first";
return false;
}
const std::string speed = state_.value("joystick_speed", "fast");
double scale = 1.0;
if (speed == "medium")
scale = 0.55;
else if (speed == "slow")
scale = 0.25;
linear = std::clamp(linear, -1.0, 1.0) * scale;
angular = std::clamp(angular, -1.0, 1.0) * scale;
state_["cmd_linear"] = linear;
state_["cmd_angular"] = angular;
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
bool RobotRuntime::setJoystick(bool engaged, const std::string& speed, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
if (engaged && state_.value("health", "ok") == "error")
{
err = "cannot engage joystick while robot is in error state";
return false;
}
if (engaged && state_.value("motion", "paused") != "running")
{
err = "start the robot before engaging the joystick";
return false;
}
state_["joystick_engaged"] = engaged;
if (!speed.empty())
{
if (speed != "fast" && speed != "medium" && speed != "slow")
{
err = "speed must be fast, medium, or slow";
return false;
}
state_["joystick_speed"] = speed;
}
if (!engaged)
{
state_["cmd_linear"] = 0.0;
state_["cmd_angular"] = 0.0;
}
state_["updated_at"] = IdUtil::nowIso8601();
saveUnlocked();
return true;
}
void RobotRuntime::tick()
{
std::lock_guard<std::mutex> lock(mu_);
const bool running = state_.value("motion", "paused") == "running";
const bool joy = state_.value("joystick_engaged", false);
double battery = state_.value("battery_percent", 54.0);
if (running && (joy || std::abs(state_.value("cmd_linear", 0.0)) > 0.01))
battery = std::max(0.0, battery - 0.02);
else if (state_.value("battery_charging", false))
battery = std::min(100.0, battery + 0.05);
else if (!running)
battery = std::min(100.0, battery + 0.005);
state_["battery_percent"] = static_cast<int>(std::lround(battery));
saveUnlocked();
}
} // namespace lm

View File

@@ -0,0 +1,38 @@
#pragma once
#include <nlohmann/json.hpp>
#include <filesystem>
#include <mutex>
#include <string>
namespace lm {
class MissionQueue;
class RobotRuntime
{
public:
explicit RobotRuntime(std::filesystem::path runtime_path, MissionQueue& mission_queue);
nlohmann::json status() const;
bool start(std::string& err);
bool pause(std::string& err);
bool resetError(std::string& err);
bool setCmdVel(double linear, double angular, std::string& err);
bool setJoystick(bool engaged, const std::string& speed, std::string& err);
void tick();
private:
std::filesystem::path runtime_path_;
MissionQueue& mission_queue_;
mutable std::mutex mu_;
nlohmann::json state_;
void load();
void saveUnlocked() const;
void ensureDefaultsUnlocked();
nlohmann::json buildStatusUnlocked() const;
};
} // namespace lm

View File

@@ -0,0 +1,85 @@
#include "server/api_server.hpp"
#include "util/http_util.hpp"
namespace lm {
void ApiServer::registerRobotRoutes(httplib::Server& svr)
{
svr.Get("/api/robot/status", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
robot_runtime_.tick();
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/start", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.start(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/pause", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.pause(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/errors/reset", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res);
std::string err;
if (!robot_runtime_.resetError(err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/cmd_vel", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
const double linear = body.value("linear", body.value("vx", 0.0));
const double angular = body.value("angular", body.value("omega", 0.0));
std::string err;
if (!robot_runtime_.setCmdVel(linear, angular, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
svr.Post("/api/robot/joystick", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
nlohmann::json body;
try
{
body = nlohmann::json::parse(req.body);
}
catch (...)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
if (!body.contains("engaged") || !body["engaged"].is_boolean())
return HttpUtil::jsonError(res, 400, "engaged (boolean) is required");
const std::string speed = body.value("speed", "");
std::string err;
if (!robot_runtime_.setJoystick(body["engaged"].get<bool>(), speed, err))
return HttpUtil::jsonError(res, 400, err);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = robot_runtime_.status().dump();
});
}
} // namespace lm

View File

@@ -14,12 +14,14 @@ ApiServer::ApiServer(StateRepository& repo,
MissionQueue& mission_queue,
MissionStore& mission_store,
ModbusTriggerService& modbus,
MissionScheduler& scheduler)
MissionScheduler& scheduler,
RobotRuntime& robot_runtime)
: repo_(repo),
mission_queue_(mission_queue),
mission_store_(mission_store),
modbus_(modbus),
scheduler_(scheduler)
scheduler_(scheduler),
robot_runtime_(robot_runtime)
{
}
@@ -538,6 +540,7 @@ void ApiServer::registerRoutes(httplib::Server& svr)
registerMissionRoutes(svr);
registerIntegrationRoutes(svr);
registerMirV2Routes(svr);
registerRobotRoutes(svr);
}
} // namespace lm

View File

@@ -6,6 +6,7 @@
#include "mission/mission_scheduler.hpp"
#include "mission/mission_store.hpp"
#include "mission/modbus_trigger_service.hpp"
#include "robot/robot_runtime.hpp"
#include "storage/state_repository.hpp"
namespace lm {
@@ -17,7 +18,8 @@ public:
MissionQueue& mission_queue,
MissionStore& mission_store,
ModbusTriggerService& modbus,
MissionScheduler& scheduler);
MissionScheduler& scheduler,
RobotRuntime& robot_runtime);
void registerRoutes(httplib::Server& svr);
@@ -27,6 +29,7 @@ private:
MissionStore& mission_store_;
ModbusTriggerService& modbus_;
MissionScheduler& scheduler_;
RobotRuntime& robot_runtime_;
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
std::optional<nlohmann::json> enqueueMission(const nlohmann::json& request, std::string& err);
@@ -34,6 +37,7 @@ private:
void registerMissionRoutes(httplib::Server& svr);
void registerMirV2Routes(httplib::Server& svr);
void registerIntegrationRoutes(httplib::Server& svr);
void registerRobotRoutes(httplib::Server& svr);
};
} // namespace lm