excuting misstion from queue
This commit is contained in:
@@ -40,6 +40,7 @@ add_executable(lidar_manager_web
|
|||||||
src/validation/sensor_validator.cpp
|
src/validation/sensor_validator.cpp
|
||||||
src/server/static_file_server.cpp
|
src/server/static_file_server.cpp
|
||||||
src/server/api_server.cpp
|
src/server/api_server.cpp
|
||||||
|
src/mission/mission_queue.cpp
|
||||||
)
|
)
|
||||||
|
|
||||||
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads)
|
target_link_libraries(lidar_manager_web PRIVATE Threads::Threads)
|
||||||
|
|||||||
115
data/mission_queue.json
Normal file
115
data/mission_queue.json
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
{
|
||||||
|
"queue": [
|
||||||
|
{
|
||||||
|
"created_at": "2026-06-13T05:07:41Z",
|
||||||
|
"finished_at": "2026-06-13T05:07:43Z",
|
||||||
|
"id": "cc23d24643aba46b",
|
||||||
|
"log": [
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"message": "Go to marker → Marker 1",
|
||||||
|
"ts": "2026-06-13T05:07:41Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"message": "Set digital output (set_digital_output) simulated",
|
||||||
|
"ts": "2026-06-13T05:07:43Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mission": {
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "ad9e5143-5b78-42ba-a381-ec15cfd6e0ce",
|
||||||
|
"kind": "action",
|
||||||
|
"label": "Go to marker",
|
||||||
|
"params": {
|
||||||
|
"marker": "Marker 1"
|
||||||
|
},
|
||||||
|
"type": "move_to_marker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8ec5c14a-9439-40d2-844c-5db52232f79f",
|
||||||
|
"kind": "action",
|
||||||
|
"label": "Set digital output",
|
||||||
|
"params": {
|
||||||
|
"module": "GPIO module 1",
|
||||||
|
"pin": 1,
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"type": "set_digital_output"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "",
|
||||||
|
"group": "Move",
|
||||||
|
"id": "f45948d5-9598-4aa8-b3d1-52bc212e69fa",
|
||||||
|
"name": "ABC",
|
||||||
|
"updated_at": "2026-06-13T04:48:24.794Z"
|
||||||
|
},
|
||||||
|
"mission_group": "Move",
|
||||||
|
"mission_id": "f45948d5-9598-4aa8-b3d1-52bc212e69fa",
|
||||||
|
"mission_name": "ABC",
|
||||||
|
"parameters": {},
|
||||||
|
"started_at": "2026-06-13T05:07:41Z",
|
||||||
|
"status": "completed"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"created_at": "2026-06-13T05:17:38Z",
|
||||||
|
"finished_at": "2026-06-13T05:17:40Z",
|
||||||
|
"id": "708c564e8cc6bd1a",
|
||||||
|
"log": [
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"message": "Go to marker → Marker 1",
|
||||||
|
"ts": "2026-06-13T05:17:38Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"level": "info",
|
||||||
|
"message": "Set digital output (set_digital_output) simulated",
|
||||||
|
"ts": "2026-06-13T05:17:39Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mission": {
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"id": "ad9e5143-5b78-42ba-a381-ec15cfd6e0ce",
|
||||||
|
"kind": "action",
|
||||||
|
"label": "Go to marker",
|
||||||
|
"params": {
|
||||||
|
"marker": "Marker 1"
|
||||||
|
},
|
||||||
|
"type": "move_to_marker"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "8ec5c14a-9439-40d2-844c-5db52232f79f",
|
||||||
|
"kind": "action",
|
||||||
|
"label": "Set digital output",
|
||||||
|
"params": {
|
||||||
|
"module": "GPIO module 1",
|
||||||
|
"pin": 1,
|
||||||
|
"value": true
|
||||||
|
},
|
||||||
|
"type": "set_digital_output"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "",
|
||||||
|
"group": "Move",
|
||||||
|
"id": "f45948d5-9598-4aa8-b3d1-52bc212e69fa",
|
||||||
|
"name": "ABC",
|
||||||
|
"updated_at": "2026-06-13T04:48:24.794Z"
|
||||||
|
},
|
||||||
|
"mission_group": "Move",
|
||||||
|
"mission_id": "f45948d5-9598-4aa8-b3d1-52bc212e69fa",
|
||||||
|
"mission_name": "ABC",
|
||||||
|
"parameters": {},
|
||||||
|
"started_at": "2026-06-13T05:17:38Z",
|
||||||
|
"status": "completed"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"runner": {
|
||||||
|
"current_action": null,
|
||||||
|
"current_queue_id": null,
|
||||||
|
"message": "Hoàn thành: ABC",
|
||||||
|
"state": "idle",
|
||||||
|
"updated_at": "2026-06-13T05:17:40Z"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "app/lidar_manager_app.hpp"
|
#include "app/lidar_manager_app.hpp"
|
||||||
|
|
||||||
|
#include "mission/mission_queue.hpp"
|
||||||
#include "server/api_server.hpp"
|
#include "server/api_server.hpp"
|
||||||
#include "server/static_file_server.hpp"
|
#include "server/static_file_server.hpp"
|
||||||
#include "storage/state_repository.hpp"
|
#include "storage/state_repository.hpp"
|
||||||
@@ -21,8 +22,11 @@ int LidarManagerApp::run()
|
|||||||
StateRepository repo(data_path_);
|
StateRepository repo(data_path_);
|
||||||
repo.load();
|
repo.load();
|
||||||
|
|
||||||
|
const std::filesystem::path mission_queue_path = data_path_.parent_path() / "mission_queue.json";
|
||||||
|
MissionQueue mission_queue(mission_queue_path);
|
||||||
|
|
||||||
httplib::Server svr;
|
httplib::Server svr;
|
||||||
ApiServer api(repo);
|
ApiServer api(repo, mission_queue);
|
||||||
api.registerRoutes(svr);
|
api.registerRoutes(svr);
|
||||||
StaticFileServer::mount(svr, www_root_);
|
StaticFileServer::mount(svr, www_root_);
|
||||||
|
|
||||||
|
|||||||
446
src/mission/mission_queue.cpp
Normal file
446
src/mission/mission_queue.cpp
Normal file
@@ -0,0 +1,446 @@
|
|||||||
|
#include "mission/mission_queue.hpp"
|
||||||
|
|
||||||
|
#include "util/file_util.hpp"
|
||||||
|
#include "util/id_util.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <chrono>
|
||||||
|
#include <stdexcept>
|
||||||
|
#include <thread>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
std::string paramValue(const std::string& action_id,
|
||||||
|
const nlohmann::json& params,
|
||||||
|
const std::string& key,
|
||||||
|
const nlohmann::json& parameters)
|
||||||
|
{
|
||||||
|
const std::string lookup = action_id + ":" + key;
|
||||||
|
if (parameters.is_object() && parameters.contains(lookup))
|
||||||
|
return parameters[lookup].get<std::string>();
|
||||||
|
if (params.contains(key) && params[key].is_string())
|
||||||
|
return params[key].get<std::string>();
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
double paramNumber(const nlohmann::json& params, const std::string& key, double fallback)
|
||||||
|
{
|
||||||
|
if (params.contains(key) && params[key].is_number())
|
||||||
|
return params[key].get<double>();
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
MissionQueue::MissionQueue(std::filesystem::path queue_path) : queue_path_(std::move(queue_path))
|
||||||
|
{
|
||||||
|
load();
|
||||||
|
ensureRunnerDefaults();
|
||||||
|
startWorkerIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
MissionQueue::~MissionQueue()
|
||||||
|
{
|
||||||
|
stop_ = true;
|
||||||
|
wake_ = true;
|
||||||
|
if (worker_.joinable())
|
||||||
|
worker_.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MissionQueue::load()
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
queue_ = nlohmann::json::array();
|
||||||
|
runner_ = nlohmann::json::object();
|
||||||
|
if (!std::filesystem::exists(queue_path_))
|
||||||
|
return;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const auto parsed = nlohmann::json::parse(FileUtil::readBinary(queue_path_));
|
||||||
|
if (parsed.is_object())
|
||||||
|
{
|
||||||
|
if (parsed.contains("queue") && parsed["queue"].is_array())
|
||||||
|
queue_ = parsed["queue"];
|
||||||
|
if (parsed.contains("runner") && parsed["runner"].is_object())
|
||||||
|
runner_ = parsed["runner"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
queue_ = nlohmann::json::array();
|
||||||
|
}
|
||||||
|
ensureRunnerDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
void MissionQueue::saveUnlocked() const
|
||||||
|
{
|
||||||
|
const nlohmann::json payload = {{"queue", queue_}, {"runner", runner_}};
|
||||||
|
FileUtil::writeBinaryAtomic(queue_path_, payload.dump(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MissionQueue::ensureRunnerDefaults()
|
||||||
|
{
|
||||||
|
if (!runner_.is_object())
|
||||||
|
runner_ = nlohmann::json::object();
|
||||||
|
if (!runner_.contains("state"))
|
||||||
|
runner_["state"] = "idle";
|
||||||
|
if (!runner_.contains("message"))
|
||||||
|
runner_["message"] = "";
|
||||||
|
if (!runner_.contains("current_queue_id"))
|
||||||
|
runner_["current_queue_id"] = nullptr;
|
||||||
|
if (!runner_.contains("current_action"))
|
||||||
|
runner_["current_action"] = nullptr;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MissionQueue::startWorkerIfNeeded()
|
||||||
|
{
|
||||||
|
if (worker_.joinable())
|
||||||
|
return;
|
||||||
|
worker_ = std::thread([this] { workerLoop(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json MissionQueue::list() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
return queue_;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json MissionQueue::runnerStatus() const
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
return runner_;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> MissionQueue::enqueue(const nlohmann::json& payload, std::string& err)
|
||||||
|
{
|
||||||
|
if (!payload.is_object())
|
||||||
|
{
|
||||||
|
err = "payload must be an object";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
if (!payload.contains("mission") || !payload["mission"].is_object())
|
||||||
|
{
|
||||||
|
err = "mission is required";
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json entry = nlohmann::json::object();
|
||||||
|
entry["id"] = IdUtil::newId();
|
||||||
|
entry["mission_id"] = payload["mission"].value("id", "");
|
||||||
|
entry["mission_name"] = payload["mission"].value("name", "Mission");
|
||||||
|
entry["mission_group"] = payload["mission"].value("group", "Missions");
|
||||||
|
entry["mission"] = payload["mission"];
|
||||||
|
entry["parameters"] = payload.contains("parameters") && payload["parameters"].is_object() ? payload["parameters"]
|
||||||
|
: nlohmann::json::object();
|
||||||
|
entry["status"] = "pending";
|
||||||
|
entry["created_at"] = IdUtil::nowIso8601();
|
||||||
|
entry["started_at"] = nullptr;
|
||||||
|
entry["finished_at"] = nullptr;
|
||||||
|
entry["log"] = nlohmann::json::array();
|
||||||
|
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
queue_.push_back(entry);
|
||||||
|
saveUnlocked();
|
||||||
|
}
|
||||||
|
|
||||||
|
wake_ = true;
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MissionQueue::removeById(const std::string& id, std::string& err)
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (!queue_.is_array())
|
||||||
|
{
|
||||||
|
err = "queue unavailable";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const auto before = queue_.size();
|
||||||
|
nlohmann::json next = nlohmann::json::array();
|
||||||
|
for (const auto& item : queue_)
|
||||||
|
{
|
||||||
|
if (!item.is_object())
|
||||||
|
continue;
|
||||||
|
if (item.value("id", "") == id)
|
||||||
|
{
|
||||||
|
const std::string status = item.value("status", "");
|
||||||
|
if (status == "executing")
|
||||||
|
{
|
||||||
|
err = "cannot remove executing mission";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
next.push_back(item);
|
||||||
|
}
|
||||||
|
if (next.size() == before)
|
||||||
|
{
|
||||||
|
err = "queue item not found";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
queue_ = std::move(next);
|
||||||
|
saveUnlocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MissionQueue::clearAll(std::string& err)
|
||||||
|
{
|
||||||
|
(void)err;
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
nlohmann::json next = nlohmann::json::array();
|
||||||
|
for (const auto& item : queue_)
|
||||||
|
{
|
||||||
|
if (!item.is_object())
|
||||||
|
continue;
|
||||||
|
if (item.value("status", "") == "executing")
|
||||||
|
next.push_back(item);
|
||||||
|
}
|
||||||
|
queue_ = std::move(next);
|
||||||
|
setRunnerState(next.empty() ? "idle" : "running", next.empty() ? "" : "Đang thực thi mission");
|
||||||
|
saveUnlocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool MissionQueue::reorder(const nlohmann::json& ordered_ids, std::string& err)
|
||||||
|
{
|
||||||
|
if (!ordered_ids.is_array())
|
||||||
|
{
|
||||||
|
err = "ordered_ids must be an array";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
if (!queue_.is_array())
|
||||||
|
{
|
||||||
|
err = "queue unavailable";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json by_id = nlohmann::json::object();
|
||||||
|
for (auto& item : queue_)
|
||||||
|
{
|
||||||
|
if (item.is_object())
|
||||||
|
by_id[item.value("id", "")] = item;
|
||||||
|
}
|
||||||
|
|
||||||
|
nlohmann::json next = nlohmann::json::array();
|
||||||
|
std::unordered_set<std::string> seen;
|
||||||
|
for (const auto& id_json : ordered_ids)
|
||||||
|
{
|
||||||
|
if (!id_json.is_string())
|
||||||
|
continue;
|
||||||
|
const std::string id = id_json.get<std::string>();
|
||||||
|
if (!by_id.contains(id))
|
||||||
|
continue;
|
||||||
|
next.push_back(by_id[id]);
|
||||||
|
seen.insert(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const auto& item : queue_)
|
||||||
|
{
|
||||||
|
if (!item.is_object())
|
||||||
|
continue;
|
||||||
|
const std::string id = item.value("id", "");
|
||||||
|
if (seen.count(id))
|
||||||
|
continue;
|
||||||
|
next.push_back(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
queue_ = std::move(next);
|
||||||
|
saveUnlocked();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MissionQueue::workerLoop()
|
||||||
|
{
|
||||||
|
while (!stop_)
|
||||||
|
{
|
||||||
|
{
|
||||||
|
std::lock_guard<std::mutex> lock(mu_);
|
||||||
|
processQueueUnlocked();
|
||||||
|
}
|
||||||
|
for (int i = 0; i < 20 && !wake_; ++i)
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
||||||
|
wake_ = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MissionQueue::processQueueUnlocked()
|
||||||
|
{
|
||||||
|
if (!queue_.is_array())
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (auto& item : queue_)
|
||||||
|
{
|
||||||
|
if (!item.is_object())
|
||||||
|
continue;
|
||||||
|
if (item.value("status", "") != "pending")
|
||||||
|
continue;
|
||||||
|
executeMissionUnlocked(item);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runner_.value("state", "") == "running")
|
||||||
|
setRunnerState("idle", "Queue trống");
|
||||||
|
}
|
||||||
|
|
||||||
|
void MissionQueue::executeMissionUnlocked(nlohmann::json& entry)
|
||||||
|
{
|
||||||
|
entry["status"] = "executing";
|
||||||
|
entry["started_at"] = IdUtil::nowIso8601();
|
||||||
|
runner_["current_queue_id"] = entry.value("id", "");
|
||||||
|
runner_["current_action"] = nullptr;
|
||||||
|
setRunnerState("running", "Đang chạy: " + entry.value("mission_name", "Mission"));
|
||||||
|
saveUnlocked();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
nlohmann::json log = nlohmann::json::array();
|
||||||
|
const auto& mission = entry["mission"];
|
||||||
|
const auto& parameters = entry["parameters"];
|
||||||
|
const auto& actions =
|
||||||
|
mission.contains("actions") && mission["actions"].is_array() ? mission["actions"] : nlohmann::json::array();
|
||||||
|
executeActionsUnlocked(actions, parameters, log, 0);
|
||||||
|
entry["log"] = log;
|
||||||
|
entry["status"] = "completed";
|
||||||
|
entry["finished_at"] = IdUtil::nowIso8601();
|
||||||
|
setRunnerState("idle", "Hoàn thành: " + entry.value("mission_name", "Mission"));
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
entry["status"] = "failed";
|
||||||
|
entry["finished_at"] = IdUtil::nowIso8601();
|
||||||
|
setRunnerState("error", "Lỗi khi chạy: " + entry.value("mission_name", "Mission"));
|
||||||
|
}
|
||||||
|
|
||||||
|
runner_["current_queue_id"] = nullptr;
|
||||||
|
runner_["current_action"] = nullptr;
|
||||||
|
saveUnlocked();
|
||||||
|
wake_ = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
|
||||||
|
const nlohmann::json& parameters,
|
||||||
|
nlohmann::json& log,
|
||||||
|
int loop_depth)
|
||||||
|
{
|
||||||
|
if (loop_depth > 8)
|
||||||
|
throw std::runtime_error("loop depth exceeded");
|
||||||
|
|
||||||
|
for (const auto& action : actions)
|
||||||
|
{
|
||||||
|
if (!action.is_object())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const std::string action_id = action.value("id", "");
|
||||||
|
const std::string kind = action.value("kind", "action");
|
||||||
|
const std::string type = action.value("type", "");
|
||||||
|
const std::string label = action.value("label", type);
|
||||||
|
const auto& params = action.contains("params") && action["params"].is_object() ? action["params"]
|
||||||
|
: nlohmann::json::object();
|
||||||
|
|
||||||
|
runner_["current_action"] = label;
|
||||||
|
saveUnlocked();
|
||||||
|
|
||||||
|
if (kind == "mission")
|
||||||
|
{
|
||||||
|
const std::string ref_id = action.value("refId", "");
|
||||||
|
nlohmann::json ref_mission = nlohmann::json::object();
|
||||||
|
// Nested mission snapshot should be resolved by frontend before queueing.
|
||||||
|
(void)ref_id;
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Sub-mission: " + label}});
|
||||||
|
if (action.contains("resolved_mission") && action["resolved_mission"].is_object())
|
||||||
|
{
|
||||||
|
const auto& nested_actions = action["resolved_mission"]["actions"];
|
||||||
|
executeActionsUnlocked(nested_actions, parameters, log, loop_depth);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == "loop")
|
||||||
|
{
|
||||||
|
const std::string mode = params.value("mode", "count");
|
||||||
|
const int count = static_cast<int>(paramNumber(params, "count", 1));
|
||||||
|
const auto& children =
|
||||||
|
action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array();
|
||||||
|
const int iterations = mode == "endless" ? 1 : std::max(1, count);
|
||||||
|
for (int i = 0; i < iterations; ++i)
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}});
|
||||||
|
executeActionsUnlocked(children, parameters, log, loop_depth + 1);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == "wait")
|
||||||
|
{
|
||||||
|
const int ms = static_cast<int>(paramNumber(params, "seconds", 1) * 1000);
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Wait " + std::to_string(ms) + "ms"}});
|
||||||
|
sleepMs(ms);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == "move_to_position" || type == "adjust_localization" || type == "pick_cart" || type == "drop_cart")
|
||||||
|
{
|
||||||
|
const std::string pos = paramValue(action_id, params, "position", parameters);
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message", label + " → " + (pos.empty() ? "?" : pos)}});
|
||||||
|
sleepMs(1200);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == "move_to_marker")
|
||||||
|
{
|
||||||
|
const std::string marker = paramValue(action_id, params, "marker", parameters);
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()},
|
||||||
|
{"level", "info"},
|
||||||
|
{"message", label + " → " + (marker.empty() ? "?" : marker)}});
|
||||||
|
sleepMs(1200);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == "user_log")
|
||||||
|
{
|
||||||
|
const std::string message = params.value("message", "Mission step");
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "user"}, {"message", message}});
|
||||||
|
sleepMs(200);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type == "pause")
|
||||||
|
{
|
||||||
|
log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "warn"}, {"message", "Pause (simulated)"}});
|
||||||
|
sleepMs(500);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.push_back(
|
||||||
|
{{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}});
|
||||||
|
sleepMs(400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void MissionQueue::sleepMs(int ms)
|
||||||
|
{
|
||||||
|
if (ms <= 0)
|
||||||
|
return;
|
||||||
|
const int step = 100;
|
||||||
|
for (int elapsed = 0; elapsed < ms && !stop_; elapsed += step)
|
||||||
|
std::this_thread::sleep_for(std::chrono::milliseconds(std::min(step, ms - elapsed)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void MissionQueue::setRunnerState(const std::string& state, const std::string& message)
|
||||||
|
{
|
||||||
|
runner_["state"] = state;
|
||||||
|
runner_["message"] = message;
|
||||||
|
runner_["updated_at"] = IdUtil::nowIso8601();
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
56
src/mission/mission_queue.hpp
Normal file
56
src/mission/mission_queue.hpp
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <nlohmann/json.hpp>
|
||||||
|
|
||||||
|
#include <atomic>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <mutex>
|
||||||
|
#include <optional>
|
||||||
|
#include <string>
|
||||||
|
#include <thread>
|
||||||
|
|
||||||
|
namespace lm {
|
||||||
|
|
||||||
|
class MissionQueue
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit MissionQueue(std::filesystem::path queue_path);
|
||||||
|
~MissionQueue();
|
||||||
|
|
||||||
|
MissionQueue(const MissionQueue&) = delete;
|
||||||
|
MissionQueue& operator=(const MissionQueue&) = delete;
|
||||||
|
|
||||||
|
nlohmann::json list() const;
|
||||||
|
nlohmann::json runnerStatus() const;
|
||||||
|
|
||||||
|
std::optional<nlohmann::json> enqueue(const nlohmann::json& payload, std::string& err);
|
||||||
|
bool removeById(const std::string& id, std::string& err);
|
||||||
|
bool clearAll(std::string& err);
|
||||||
|
bool reorder(const nlohmann::json& ordered_ids, std::string& err);
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::filesystem::path queue_path_;
|
||||||
|
mutable std::mutex mu_;
|
||||||
|
nlohmann::json queue_;
|
||||||
|
nlohmann::json runner_;
|
||||||
|
|
||||||
|
std::thread worker_;
|
||||||
|
std::atomic<bool> stop_{false};
|
||||||
|
std::atomic<bool> wake_{false};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
void saveUnlocked() const;
|
||||||
|
void ensureRunnerDefaults();
|
||||||
|
void startWorkerIfNeeded();
|
||||||
|
void workerLoop();
|
||||||
|
void processQueueUnlocked();
|
||||||
|
void executeMissionUnlocked(nlohmann::json& entry);
|
||||||
|
void executeActionsUnlocked(const nlohmann::json& actions,
|
||||||
|
const nlohmann::json& parameters,
|
||||||
|
nlohmann::json& log,
|
||||||
|
int loop_depth);
|
||||||
|
void sleepMs(int ms);
|
||||||
|
void setRunnerState(const std::string& state, const std::string& message = "");
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace lm
|
||||||
@@ -1,723 +0,0 @@
|
|||||||
#include "mission/mission_service.hpp"
|
|
||||||
|
|
||||||
#include "util/file_util.hpp"
|
|
||||||
#include "util/id_util.hpp"
|
|
||||||
#include "util/string_util.hpp"
|
|
||||||
|
|
||||||
#include <algorithm>
|
|
||||||
#include <chrono>
|
|
||||||
#include <thread>
|
|
||||||
|
|
||||||
namespace lm {
|
|
||||||
|
|
||||||
namespace {
|
|
||||||
|
|
||||||
constexpr int kLogMax = 200;
|
|
||||||
|
|
||||||
nlohmann::json defaultDoc()
|
|
||||||
{
|
|
||||||
return nlohmann::json{{"version", 1},
|
|
||||||
{"missions", nlohmann::json::array()},
|
|
||||||
{"groups", nlohmann::json::array({"Missions", "Move", "Logic", "I/O", "Cart", "Misc"})},
|
|
||||||
{"triggers", nlohmann::json::array()},
|
|
||||||
{"dashboard", nlohmann::json{{"widgets", nlohmann::json::array()}}}};
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace
|
|
||||||
|
|
||||||
MissionService::MissionService(std::filesystem::path data_dir)
|
|
||||||
: data_dir_(std::move(data_dir)), missions_path_(data_dir_ / "missions.json")
|
|
||||||
{
|
|
||||||
load();
|
|
||||||
worker_ = std::thread([this] { workerLoop(); });
|
|
||||||
}
|
|
||||||
|
|
||||||
MissionService::~MissionService()
|
|
||||||
{
|
|
||||||
stop_thread_ = true;
|
|
||||||
cv_.notify_all();
|
|
||||||
if (worker_.joinable())
|
|
||||||
worker_.join();
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionService::load()
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
const auto raw = FileUtil::readBinary(missions_path_);
|
|
||||||
if (raw.empty())
|
|
||||||
{
|
|
||||||
doc_ = defaultDoc();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try
|
|
||||||
{
|
|
||||||
doc_ = nlohmann::json::parse(raw);
|
|
||||||
}
|
|
||||||
catch (...)
|
|
||||||
{
|
|
||||||
doc_ = defaultDoc();
|
|
||||||
}
|
|
||||||
ensureDocSchema();
|
|
||||||
queue_ = nlohmann::json::array();
|
|
||||||
action_log_ = nlohmann::json::array();
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionService::ensureDocSchema()
|
|
||||||
{
|
|
||||||
if (!doc_.is_object())
|
|
||||||
doc_ = defaultDoc();
|
|
||||||
if (!doc_.contains("version"))
|
|
||||||
doc_["version"] = 1;
|
|
||||||
if (!doc_.contains("missions") || !doc_["missions"].is_array())
|
|
||||||
doc_["missions"] = nlohmann::json::array();
|
|
||||||
if (!doc_.contains("groups") || !doc_["groups"].is_array())
|
|
||||||
doc_["groups"] = defaultDoc()["groups"];
|
|
||||||
if (!doc_.contains("triggers") || !doc_["triggers"].is_array())
|
|
||||||
doc_["triggers"] = nlohmann::json::array();
|
|
||||||
if (!doc_.contains("dashboard") || !doc_["dashboard"].is_object())
|
|
||||||
doc_["dashboard"] = nlohmann::json{{"widgets", nlohmann::json::array()}};
|
|
||||||
if (!doc_["dashboard"].contains("widgets") || !doc_["dashboard"]["widgets"].is_array())
|
|
||||||
doc_["dashboard"]["widgets"] = nlohmann::json::array();
|
|
||||||
}
|
|
||||||
|
|
||||||
bool MissionService::saveDocument() const
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
std::error_code ec;
|
|
||||||
std::filesystem::create_directories(data_dir_, ec);
|
|
||||||
auto body = doc_.dump(2);
|
|
||||||
body.push_back('\n');
|
|
||||||
return FileUtil::writeBinaryAtomic(missions_path_, body);
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::snapshot() const
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
return doc_;
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::runnerStatus() const
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
return nlohmann::json{{"mode", mode_},
|
|
||||||
{"state_id", state_id_},
|
|
||||||
{"queue", queue_},
|
|
||||||
{"current", current_ ? *current_ : nlohmann::json(nullptr)},
|
|
||||||
{"action_log", action_log_}};
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::listMissions() const
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
return doc_;
|
|
||||||
}
|
|
||||||
|
|
||||||
std::optional<nlohmann::json> MissionService::getMission(const std::string& id) const
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
for (const auto& m : doc_["missions"])
|
|
||||||
{
|
|
||||||
if (m.contains("id") && m["id"].get<std::string>() == id)
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
return std::nullopt;
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::findMissionUnlocked(const std::string& id) const
|
|
||||||
{
|
|
||||||
for (const auto& m : doc_["missions"])
|
|
||||||
{
|
|
||||||
if (m.contains("id") && m["id"].get<std::string>() == id)
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
return nlohmann::json();
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::createMission(const nlohmann::json& payload, std::string& err)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
if (!payload.is_object() || !payload.contains("name") || !payload["name"].is_string())
|
|
||||||
{
|
|
||||||
err = "name is required";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
|
|
||||||
if (name.empty())
|
|
||||||
{
|
|
||||||
err = "name is required";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
for (const auto& m : doc_["missions"])
|
|
||||||
{
|
|
||||||
if (m.contains("name") && m["name"].get<std::string>() == name)
|
|
||||||
{
|
|
||||||
err = "mission name already exists";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json mission = payload;
|
|
||||||
if (payload.contains("id") && payload["id"].is_string() && !payload["id"].get<std::string>().empty())
|
|
||||||
mission["id"] = payload["id"].get<std::string>();
|
|
||||||
else
|
|
||||||
mission["id"] = IdUtil::newId();
|
|
||||||
mission["name"] = name;
|
|
||||||
if (!mission.contains("group") || !mission["group"].is_string())
|
|
||||||
mission["group"] = "Missions";
|
|
||||||
if (!mission.contains("description"))
|
|
||||||
mission["description"] = "";
|
|
||||||
if (!mission.contains("actions") || !mission["actions"].is_array())
|
|
||||||
mission["actions"] = nlohmann::json::array();
|
|
||||||
mission["updated_at"] = IdUtil::nowIso8601();
|
|
||||||
|
|
||||||
const std::string group = mission["group"].get<std::string>();
|
|
||||||
if (std::find(doc_["groups"].begin(), doc_["groups"].end(), group) == doc_["groups"].end())
|
|
||||||
doc_["groups"].push_back(group);
|
|
||||||
|
|
||||||
doc_["missions"].push_back(mission);
|
|
||||||
return mission;
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::updateMission(const std::string& id, const nlohmann::json& payload, std::string& err)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
for (auto& m : doc_["missions"])
|
|
||||||
{
|
|
||||||
if (!m.contains("id") || m["id"].get<std::string>() != id)
|
|
||||||
continue;
|
|
||||||
if (payload.contains("name") && payload["name"].is_string())
|
|
||||||
{
|
|
||||||
const std::string name = StringUtil::trimCopy(payload["name"].get<std::string>());
|
|
||||||
if (name.empty())
|
|
||||||
{
|
|
||||||
err = "name is required";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
for (const auto& other : doc_["missions"])
|
|
||||||
{
|
|
||||||
if (other.contains("id") && other["id"].get<std::string>() != id && other.contains("name") &&
|
|
||||||
other["name"].get<std::string>() == name)
|
|
||||||
{
|
|
||||||
err = "mission name already exists";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
m["name"] = name;
|
|
||||||
}
|
|
||||||
if (payload.contains("group") && payload["group"].is_string())
|
|
||||||
{
|
|
||||||
m["group"] = payload["group"];
|
|
||||||
const std::string group = m["group"].get<std::string>();
|
|
||||||
if (std::find(doc_["groups"].begin(), doc_["groups"].end(), group) == doc_["groups"].end())
|
|
||||||
doc_["groups"].push_back(group);
|
|
||||||
}
|
|
||||||
if (payload.contains("description") && payload["description"].is_string())
|
|
||||||
m["description"] = payload["description"];
|
|
||||||
if (payload.contains("actions") && payload["actions"].is_array())
|
|
||||||
m["actions"] = payload["actions"];
|
|
||||||
m["updated_at"] = IdUtil::nowIso8601();
|
|
||||||
return m;
|
|
||||||
}
|
|
||||||
err = "mission not found";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
bool MissionService::deleteMission(const std::string& id, std::string& err)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
auto& missions = doc_["missions"];
|
|
||||||
for (auto it = missions.begin(); it != missions.end(); ++it)
|
|
||||||
{
|
|
||||||
if (it->contains("id") && (*it)["id"].get<std::string>() == id)
|
|
||||||
{
|
|
||||||
missions.erase(it);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = "mission not found";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::listQueue() const
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
return queue_;
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::enqueueMission(const nlohmann::json& payload, std::string& err)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
if (!payload.is_object() || !payload.contains("mission_id") || !payload["mission_id"].is_string())
|
|
||||||
{
|
|
||||||
err = "mission_id is required";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const std::string mission_id = payload["mission_id"].get<std::string>();
|
|
||||||
const auto mission = findMissionUnlocked(mission_id);
|
|
||||||
if (mission.is_null())
|
|
||||||
{
|
|
||||||
err = "mission not found";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (mode_ == "stop")
|
|
||||||
{
|
|
||||||
err = "robot is stopped; cannot queue mission";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
if (mode_ != "autonomous")
|
|
||||||
{
|
|
||||||
err = "robot must be in autonomous mode to queue missions";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json parameters = nlohmann::json::object();
|
|
||||||
if (payload.contains("parameters") && payload["parameters"].is_object())
|
|
||||||
parameters = payload["parameters"];
|
|
||||||
|
|
||||||
nlohmann::json entry = {{"id", IdUtil::newId()},
|
|
||||||
{"queue_num", next_queue_num_++},
|
|
||||||
{"mission_id", mission_id},
|
|
||||||
{"mission_name", mission["name"]},
|
|
||||||
{"parameters", parameters},
|
|
||||||
{"state", "pending"},
|
|
||||||
{"created_at", IdUtil::nowIso8601()}};
|
|
||||||
queue_.push_back(entry);
|
|
||||||
appendLogUnlocked("info", "Mission queued: " + mission["name"].get<std::string>(), {{"queue_id", entry["id"]}});
|
|
||||||
cv_.notify_all();
|
|
||||||
return entry;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool MissionService::dequeueEntry(const std::string& queue_id, std::string& err)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
for (auto it = queue_.begin(); it != queue_.end(); ++it)
|
|
||||||
{
|
|
||||||
if ((*it).contains("id") && (*it)["id"].get<std::string>() == queue_id)
|
|
||||||
{
|
|
||||||
if ((*it).value("state", "") == "executing")
|
|
||||||
{
|
|
||||||
err = "cannot remove executing mission";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
queue_.erase(it);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = "queue entry not found";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool MissionService::clearQueue(std::string& err)
|
|
||||||
{
|
|
||||||
(void)err;
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
nlohmann::json kept = nlohmann::json::array();
|
|
||||||
for (auto& e : queue_)
|
|
||||||
{
|
|
||||||
if (e.value("state", "") == "executing")
|
|
||||||
kept.push_back(e);
|
|
||||||
}
|
|
||||||
queue_ = kept;
|
|
||||||
if (state_id_ == 3)
|
|
||||||
cv_.notify_all();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool MissionService::reorderQueue(const std::string& queue_id, int new_index, std::string& err)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
if (new_index < 0)
|
|
||||||
{
|
|
||||||
err = "invalid index";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
nlohmann::json item;
|
|
||||||
int found = -1;
|
|
||||||
for (size_t i = 0; i < queue_.size(); ++i)
|
|
||||||
{
|
|
||||||
if (queue_[i].contains("id") && queue_[i]["id"].get<std::string>() == queue_id)
|
|
||||||
{
|
|
||||||
if (queue_[i].value("state", "") != "pending")
|
|
||||||
{
|
|
||||||
err = "only pending missions can be reordered";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
item = queue_[i];
|
|
||||||
found = static_cast<int>(i);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (found < 0)
|
|
||||||
{
|
|
||||||
err = "queue entry not found";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
queue_.erase(queue_.begin() + found);
|
|
||||||
if (new_index > static_cast<int>(queue_.size()))
|
|
||||||
new_index = static_cast<int>(queue_.size());
|
|
||||||
queue_.insert(queue_.begin() + new_index, item);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool MissionService::setMode(const std::string& mode, std::string& err)
|
|
||||||
{
|
|
||||||
if (mode != "autonomous" && mode != "manual" && mode != "stop")
|
|
||||||
{
|
|
||||||
err = "mode must be autonomous, manual, or stop";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
mode_ = mode;
|
|
||||||
if (mode == "stop")
|
|
||||||
state_id_ = 4;
|
|
||||||
appendLogUnlocked("info", "Operating mode: " + mode);
|
|
||||||
cv_.notify_all();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool MissionService::setRunnerState(int state_id, std::string& err)
|
|
||||||
{
|
|
||||||
if (state_id != 3 && state_id != 4)
|
|
||||||
{
|
|
||||||
err = "state_id must be 3 (play) or 4 (pause)";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
state_id_ = state_id;
|
|
||||||
runner_paused_ = (state_id == 4);
|
|
||||||
appendLogUnlocked("info", state_id == 3 ? "Runner play" : "Runner pause");
|
|
||||||
cv_.notify_all();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::listTriggers() const
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
return doc_["triggers"];
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::createTrigger(const nlohmann::json& payload, std::string& err)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
if (!payload.is_object() || !payload.contains("name") || !payload.contains("coil_id") ||
|
|
||||||
!payload.contains("mission_id"))
|
|
||||||
{
|
|
||||||
err = "name, coil_id, mission_id required";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
const int coil_id = payload["coil_id"].get<int>();
|
|
||||||
if (coil_id < 1001 || coil_id > 2000)
|
|
||||||
{
|
|
||||||
err = "coil_id must be between 1001 and 2000";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
for (const auto& t : doc_["triggers"])
|
|
||||||
{
|
|
||||||
if (t.contains("coil_id") && t["coil_id"].get<int>() == coil_id)
|
|
||||||
{
|
|
||||||
err = "coil_id already used";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const std::string mission_id = payload["mission_id"].get<std::string>();
|
|
||||||
if (findMissionUnlocked(mission_id).is_null())
|
|
||||||
{
|
|
||||||
err = "mission not found";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
nlohmann::json trigger = {{"id", IdUtil::newId()},
|
|
||||||
{"name", StringUtil::trimCopy(payload["name"].get<std::string>())},
|
|
||||||
{"coil_id", coil_id},
|
|
||||||
{"mission_id", mission_id}};
|
|
||||||
doc_["triggers"].push_back(trigger);
|
|
||||||
return trigger;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool MissionService::deleteTrigger(const std::string& id, std::string& err)
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
auto& triggers = doc_["triggers"];
|
|
||||||
for (auto it = triggers.begin(); it != triggers.end(); ++it)
|
|
||||||
{
|
|
||||||
if (it->contains("id") && (*it)["id"].get<std::string>() == id)
|
|
||||||
{
|
|
||||||
triggers.erase(it);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
err = "trigger not found";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::fireTriggerByCoil(int coil_id, std::string& err)
|
|
||||||
{
|
|
||||||
nlohmann::json trigger;
|
|
||||||
std::string mission_id;
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
for (const auto& t : doc_["triggers"])
|
|
||||||
{
|
|
||||||
if (t.contains("coil_id") && t["coil_id"].get<int>() == coil_id)
|
|
||||||
{
|
|
||||||
trigger = t;
|
|
||||||
mission_id = t["mission_id"].get<std::string>();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (mission_id.empty())
|
|
||||||
{
|
|
||||||
err = "trigger not found for coil";
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return enqueueMission(nlohmann::json{{"mission_id", mission_id}, {"parameters", nlohmann::json::object()}}, err);
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::getDashboard() const
|
|
||||||
{
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
return doc_["dashboard"];
|
|
||||||
}
|
|
||||||
|
|
||||||
bool MissionService::setDashboard(const nlohmann::json& payload, std::string& err)
|
|
||||||
{
|
|
||||||
if (!payload.is_object() || !payload.contains("widgets") || !payload["widgets"].is_array())
|
|
||||||
{
|
|
||||||
err = "widgets array required";
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
std::lock_guard lock(mu_);
|
|
||||||
doc_["dashboard"] = payload;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionService::appendLogUnlocked(const std::string& level,
|
|
||||||
const std::string& message,
|
|
||||||
const nlohmann::json& extra)
|
|
||||||
{
|
|
||||||
nlohmann::json row = {{"ts", IdUtil::nowIso8601()}, {"level", level}, {"message", message}};
|
|
||||||
if (!extra.empty())
|
|
||||||
row["extra"] = extra;
|
|
||||||
action_log_.push_back(row);
|
|
||||||
while (action_log_.size() > kLogMax)
|
|
||||||
action_log_.erase(action_log_.begin());
|
|
||||||
}
|
|
||||||
|
|
||||||
nlohmann::json MissionService::paramValue(const nlohmann::json& v)
|
|
||||||
{
|
|
||||||
if (v.is_object() && v.contains("kind") && v["kind"].get<std::string>() == "variable")
|
|
||||||
return v.value("default", "");
|
|
||||||
return v;
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionService::resolveParamsUnlocked(nlohmann::json& params, const nlohmann::json& runtime) const
|
|
||||||
{
|
|
||||||
if (!params.is_object())
|
|
||||||
return;
|
|
||||||
for (auto it = params.begin(); it != params.end(); ++it)
|
|
||||||
{
|
|
||||||
if (it.value().is_object() && it.value().contains("kind") &&
|
|
||||||
it.value()["kind"].get<std::string>() == "variable")
|
|
||||||
{
|
|
||||||
const std::string key = it.key();
|
|
||||||
if (runtime.contains(key))
|
|
||||||
params[key] = runtime[key];
|
|
||||||
else
|
|
||||||
params[key] = it.value().value("default", "");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionService::waitWhilePausedUnlocked()
|
|
||||||
{
|
|
||||||
while (!stop_thread_ && (state_id_ == 4 || runner_paused_))
|
|
||||||
{
|
|
||||||
mu_.unlock();
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(100));
|
|
||||||
mu_.lock();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionService::sleepMs(int ms)
|
|
||||||
{
|
|
||||||
if (ms <= 0)
|
|
||||||
return;
|
|
||||||
mu_.unlock();
|
|
||||||
std::this_thread::sleep_for(std::chrono::milliseconds(ms));
|
|
||||||
mu_.lock();
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionService::workerLoop()
|
|
||||||
{
|
|
||||||
while (!stop_thread_)
|
|
||||||
{
|
|
||||||
{
|
|
||||||
std::unique_lock lock(mu_);
|
|
||||||
cv_.wait_for(lock, std::chrono::milliseconds(200), [this] {
|
|
||||||
if (stop_thread_)
|
|
||||||
return true;
|
|
||||||
if (mode_ != "autonomous" || state_id_ != 3)
|
|
||||||
return false;
|
|
||||||
if (current_)
|
|
||||||
return true;
|
|
||||||
for (const auto& e : queue_)
|
|
||||||
{
|
|
||||||
if (e.value("state", "") == "pending")
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
if (stop_thread_)
|
|
||||||
break;
|
|
||||||
tickRunnerUnlocked();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionService::tickRunnerUnlocked()
|
|
||||||
{
|
|
||||||
if (mode_ != "autonomous" || state_id_ != 3 || current_)
|
|
||||||
return;
|
|
||||||
|
|
||||||
for (auto& entry : queue_)
|
|
||||||
{
|
|
||||||
if (entry.value("state", "") != "pending")
|
|
||||||
continue;
|
|
||||||
entry["state"] = "executing";
|
|
||||||
entry["started_at"] = IdUtil::nowIso8601();
|
|
||||||
current_ = entry;
|
|
||||||
executeQueueEntryUnlocked(entry);
|
|
||||||
entry["state"] = "finished";
|
|
||||||
entry["finished_at"] = IdUtil::nowIso8601();
|
|
||||||
appendLogUnlocked("info", "Mission finished: " + entry["mission_name"].get<std::string>());
|
|
||||||
current_ = std::nullopt;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionService::executeQueueEntryUnlocked(nlohmann::json& entry)
|
|
||||||
{
|
|
||||||
const std::string mission_id = entry["mission_id"].get<std::string>();
|
|
||||||
const auto mission = findMissionUnlocked(mission_id);
|
|
||||||
if (mission.is_null())
|
|
||||||
{
|
|
||||||
entry["state"] = "failed";
|
|
||||||
entry["error"] = "mission definition missing";
|
|
||||||
appendLogUnlocked("error", "Mission missing: " + mission_id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
nlohmann::json parameters = entry.value("parameters", nlohmann::json::object());
|
|
||||||
if (!parameters.is_object())
|
|
||||||
parameters = nlohmann::json::object();
|
|
||||||
current_ = entry;
|
|
||||||
current_->value("progress", nlohmann::json::object())["mission_name"] = mission["name"];
|
|
||||||
executeActionsUnlocked(mission.value("actions", nlohmann::json::array()), parameters, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
void MissionService::executeActionsUnlocked(const nlohmann::json& actions,
|
|
||||||
const nlohmann::json& parameters,
|
|
||||||
int depth)
|
|
||||||
{
|
|
||||||
if (depth > 32)
|
|
||||||
return;
|
|
||||||
for (const auto& action : actions)
|
|
||||||
{
|
|
||||||
waitWhilePausedUnlocked();
|
|
||||||
if (stop_thread_ || mode_ != "autonomous")
|
|
||||||
return;
|
|
||||||
if (!executeActionUnlocked(action, parameters, depth))
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool MissionService::executeActionUnlocked(const nlohmann::json& action,
|
|
||||||
const nlohmann::json& parameters,
|
|
||||||
int depth)
|
|
||||||
{
|
|
||||||
if (!action.is_object())
|
|
||||||
return true;
|
|
||||||
|
|
||||||
const std::string kind = action.value("kind", "action");
|
|
||||||
if (kind == "mission")
|
|
||||||
{
|
|
||||||
const std::string ref = action.value("refId", "");
|
|
||||||
const auto sub = findMissionUnlocked(ref);
|
|
||||||
if (sub.is_null())
|
|
||||||
{
|
|
||||||
appendLogUnlocked("error", "Embedded mission not found");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
appendLogUnlocked("info", "Run sub-mission: " + sub["name"].get<std::string>());
|
|
||||||
executeActionsUnlocked(sub.value("actions", nlohmann::json::array()), parameters, depth + 1);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const std::string type = action.value("type", "");
|
|
||||||
nlohmann::json params = action.value("params", nlohmann::json::object());
|
|
||||||
resolveParamsUnlocked(params, parameters);
|
|
||||||
|
|
||||||
if (current_)
|
|
||||||
{
|
|
||||||
(*current_)["current_action"] = {{"type", type}, {"label", action.value("label", type)}, {"params", params}};
|
|
||||||
}
|
|
||||||
|
|
||||||
appendLogUnlocked("action", action.value("label", type), {{"type", type}, {"params", params}});
|
|
||||||
|
|
||||||
if (type == "pause")
|
|
||||||
{
|
|
||||||
runner_paused_ = true;
|
|
||||||
state_id_ = 4;
|
|
||||||
appendLogUnlocked("info", "Pause action — waiting for continue");
|
|
||||||
waitWhilePausedUnlocked();
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (type == "break" || type == "continue")
|
|
||||||
return type != "break";
|
|
||||||
|
|
||||||
if (type == "wait")
|
|
||||||
{
|
|
||||||
const int sec = params.value("seconds", 1);
|
|
||||||
sleepMs(sec * 1000);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type == "loop" && action.contains("children") && action["children"].is_array())
|
|
||||||
{
|
|
||||||
const std::string loop_mode = params.value("mode", "count");
|
|
||||||
const int count = params.value("count", 1);
|
|
||||||
if (loop_mode == "endless")
|
|
||||||
{
|
|
||||||
int guard = 0;
|
|
||||||
while (!stop_thread_ && mode_ == "autonomous" && guard < 1000)
|
|
||||||
{
|
|
||||||
waitWhilePausedUnlocked();
|
|
||||||
executeActionsUnlocked(action["children"], parameters, depth + 1);
|
|
||||||
++guard;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
for (int i = 0; i < count && !stop_thread_; ++i)
|
|
||||||
{
|
|
||||||
waitWhilePausedUnlocked();
|
|
||||||
executeActionsUnlocked(action["children"], parameters, depth + 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type == "move_to_position" || type == "move_to_marker" || type == "adjust_localization")
|
|
||||||
sleepMs(1500);
|
|
||||||
else if (type == "pick_cart" || type == "drop_cart")
|
|
||||||
sleepMs(2000);
|
|
||||||
else if (type == "set_digital_output" || type == "wait_digital_input" || type == "set_plc_register")
|
|
||||||
sleepMs(800);
|
|
||||||
else if (type == "user_log" || type == "play_sound")
|
|
||||||
sleepMs(300);
|
|
||||||
else
|
|
||||||
sleepMs(500);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
} // namespace lm
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#pragma once
|
|
||||||
|
|
||||||
#include <nlohmann/json.hpp>
|
|
||||||
|
|
||||||
#include <atomic>
|
|
||||||
#include <condition_variable>
|
|
||||||
#include <filesystem>
|
|
||||||
#include <mutex>
|
|
||||||
#include <optional>
|
|
||||||
#include <string>
|
|
||||||
#include <thread>
|
|
||||||
|
|
||||||
namespace lm {
|
|
||||||
|
|
||||||
class MissionService
|
|
||||||
{
|
|
||||||
public:
|
|
||||||
explicit MissionService(std::filesystem::path data_dir);
|
|
||||||
|
|
||||||
~MissionService();
|
|
||||||
|
|
||||||
MissionService(const MissionService&) = delete;
|
|
||||||
MissionService& operator=(const MissionService&) = delete;
|
|
||||||
|
|
||||||
nlohmann::json snapshot() const;
|
|
||||||
nlohmann::json runnerStatus() const;
|
|
||||||
|
|
||||||
bool saveDocument() const;
|
|
||||||
|
|
||||||
nlohmann::json listMissions() const;
|
|
||||||
std::optional<nlohmann::json> getMission(const std::string& id) const;
|
|
||||||
nlohmann::json createMission(const nlohmann::json& payload, std::string& err);
|
|
||||||
nlohmann::json updateMission(const std::string& id, const nlohmann::json& payload, std::string& err);
|
|
||||||
bool deleteMission(const std::string& id, std::string& err);
|
|
||||||
|
|
||||||
nlohmann::json listQueue() const;
|
|
||||||
nlohmann::json enqueueMission(const nlohmann::json& payload, std::string& err);
|
|
||||||
bool dequeueEntry(const std::string& queue_id, std::string& err);
|
|
||||||
bool clearQueue(std::string& err);
|
|
||||||
bool reorderQueue(const std::string& queue_id, int new_index, std::string& err);
|
|
||||||
|
|
||||||
bool setMode(const std::string& mode, std::string& err);
|
|
||||||
bool setRunnerState(int state_id, std::string& err);
|
|
||||||
|
|
||||||
nlohmann::json listTriggers() const;
|
|
||||||
nlohmann::json createTrigger(const nlohmann::json& payload, std::string& err);
|
|
||||||
bool deleteTrigger(const std::string& id, std::string& err);
|
|
||||||
nlohmann::json fireTriggerByCoil(int coil_id, std::string& err);
|
|
||||||
|
|
||||||
nlohmann::json getDashboard() const;
|
|
||||||
bool setDashboard(const nlohmann::json& payload, std::string& err);
|
|
||||||
|
|
||||||
private:
|
|
||||||
std::filesystem::path data_dir_;
|
|
||||||
std::filesystem::path missions_path_;
|
|
||||||
|
|
||||||
mutable std::mutex mu_;
|
|
||||||
nlohmann::json doc_;
|
|
||||||
nlohmann::json queue_;
|
|
||||||
nlohmann::json action_log_;
|
|
||||||
std::string mode_{"autonomous"};
|
|
||||||
int state_id_{3};
|
|
||||||
std::optional<nlohmann::json> current_;
|
|
||||||
int next_queue_num_{1};
|
|
||||||
|
|
||||||
std::atomic<bool> stop_thread_{false};
|
|
||||||
std::atomic<bool> runner_paused_{false};
|
|
||||||
std::condition_variable cv_;
|
|
||||||
std::thread worker_;
|
|
||||||
|
|
||||||
void load();
|
|
||||||
void ensureDocSchema();
|
|
||||||
nlohmann::json findMissionUnlocked(const std::string& id) const;
|
|
||||||
void workerLoop();
|
|
||||||
void tickRunnerUnlocked();
|
|
||||||
void executeQueueEntryUnlocked(nlohmann::json& entry);
|
|
||||||
void executeActionsUnlocked(const nlohmann::json& actions,
|
|
||||||
const nlohmann::json& parameters,
|
|
||||||
int depth);
|
|
||||||
bool executeActionUnlocked(const nlohmann::json& action,
|
|
||||||
const nlohmann::json& parameters,
|
|
||||||
int depth);
|
|
||||||
void appendLogUnlocked(const std::string& level,
|
|
||||||
const std::string& message,
|
|
||||||
const nlohmann::json& extra = nlohmann::json::object());
|
|
||||||
void resolveParamsUnlocked(nlohmann::json& params, const nlohmann::json& runtime) const;
|
|
||||||
static nlohmann::json paramValue(const nlohmann::json& v);
|
|
||||||
void waitWhilePausedUnlocked();
|
|
||||||
void sleepMs(int ms);
|
|
||||||
};
|
|
||||||
|
|
||||||
} // namespace lm
|
|
||||||
@@ -9,7 +9,10 @@
|
|||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
|
|
||||||
ApiServer::ApiServer(StateRepository& repo) : repo_(repo) {}
|
ApiServer::ApiServer(StateRepository& repo, MissionQueue& mission_queue)
|
||||||
|
: repo_(repo), mission_queue_(mission_queue)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
void ApiServer::registerRoutes(httplib::Server& svr)
|
void ApiServer::registerRoutes(httplib::Server& svr)
|
||||||
{
|
{
|
||||||
@@ -436,6 +439,69 @@ void ApiServer::registerRoutes(httplib::Server& svr)
|
|||||||
return HttpUtil::jsonError(res, 500, "failed to save layout");
|
return HttpUtil::jsonError(res, 500, "failed to save layout");
|
||||||
res.status = 204;
|
res.status = 204;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
svr.Get("/api/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = nlohmann::json({{"queue", mission_queue_.list()}, {"runner", mission_queue_.runnerStatus()}}).dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Post("/api/mission_queue", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
nlohmann::json payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
std::string err;
|
||||||
|
const auto entry = mission_queue_.enqueue(payload, err);
|
||||||
|
if (!entry)
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.status = 201;
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = entry->dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Delete("/api/mission_queue", [this](const httplib::Request&, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
std::string err;
|
||||||
|
if (!mission_queue_.clearAll(err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.status = 204;
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Put("/api/mission_queue/reorder", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
nlohmann::json payload;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
payload = nlohmann::json::parse(req.body);
|
||||||
|
}
|
||||||
|
catch (...)
|
||||||
|
{
|
||||||
|
return HttpUtil::jsonError(res, 400, "invalid JSON");
|
||||||
|
}
|
||||||
|
if (!payload.contains("ordered_ids") || !payload["ordered_ids"].is_array())
|
||||||
|
return HttpUtil::jsonError(res, 400, "ordered_ids is required");
|
||||||
|
std::string err;
|
||||||
|
if (!mission_queue_.reorder(payload["ordered_ids"], err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.set_header("Content-Type", "application/json; charset=utf-8");
|
||||||
|
res.body = nlohmann::json({{"ok", true}}).dump();
|
||||||
|
});
|
||||||
|
|
||||||
|
svr.Delete(R"(/api/mission_queue/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) {
|
||||||
|
HttpUtil::addCors(res);
|
||||||
|
const std::string id = req.matches[1].str();
|
||||||
|
std::string err;
|
||||||
|
if (!mission_queue_.removeById(id, err))
|
||||||
|
return HttpUtil::jsonError(res, 400, err);
|
||||||
|
res.status = 204;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
} // namespace lm
|
} // namespace lm
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
#include <httplib.h>
|
#include <httplib.h>
|
||||||
|
|
||||||
|
#include "mission/mission_queue.hpp"
|
||||||
#include "storage/state_repository.hpp"
|
#include "storage/state_repository.hpp"
|
||||||
|
|
||||||
namespace lm {
|
namespace lm {
|
||||||
@@ -9,12 +10,13 @@ namespace lm {
|
|||||||
class ApiServer
|
class ApiServer
|
||||||
{
|
{
|
||||||
public:
|
public:
|
||||||
explicit ApiServer(StateRepository& repo);
|
ApiServer(StateRepository& repo, MissionQueue& mission_queue);
|
||||||
|
|
||||||
void registerRoutes(httplib::Server& svr);
|
void registerRoutes(httplib::Server& svr);
|
||||||
|
|
||||||
private:
|
private:
|
||||||
StateRepository& repo_;
|
StateRepository& repo_;
|
||||||
|
MissionQueue& mission_queue_;
|
||||||
};
|
};
|
||||||
|
|
||||||
} // namespace lm
|
} // namespace lm
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ function setActivePage(page) {
|
|||||||
}
|
}
|
||||||
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
|
if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config";
|
||||||
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow();
|
||||||
|
else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide();
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("activePage", p);
|
localStorage.setItem("activePage", p);
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
322
www/dashboard.js
322
www/dashboard.js
@@ -1,322 +0,0 @@
|
|||||||
(() => {
|
|
||||||
const el = (id) => document.getElementById(id);
|
|
||||||
const canvasEl = el("dashboardCanvas");
|
|
||||||
const saveBtn = el("dashboardSaveBtn");
|
|
||||||
const modeSelectEl = el("dashboardModeSelect");
|
|
||||||
|
|
||||||
const store = {
|
|
||||||
widgets: [],
|
|
||||||
pollTimer: null,
|
|
||||||
};
|
|
||||||
|
|
||||||
async function api(path, opts = {}) {
|
|
||||||
if (window.MissionsApp?.missionsApi) return window.MissionsApp.missionsApi(path, opts);
|
|
||||||
const res = await fetch(path, { headers: { "Content-Type": "application/json" }, ...opts });
|
|
||||||
if (res.status === 204) return null;
|
|
||||||
const data = res.ok ? await res.json() : null;
|
|
||||||
if (!res.ok) throw new Error(data?.error || `HTTP ${res.status}`);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
function newWidgetId() {
|
|
||||||
return `w_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadDashboard() {
|
|
||||||
const data = await api("/api/dashboard");
|
|
||||||
store.widgets = Array.isArray(data.widgets) ? data.widgets : [];
|
|
||||||
if (!store.widgets.length) {
|
|
||||||
store.widgets = [
|
|
||||||
{ id: newWidgetId(), type: "mission_queue" },
|
|
||||||
{ id: newWidgetId(), type: "pause_continue" },
|
|
||||||
{ id: newWidgetId(), type: "action_log" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
renderCanvas();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveDashboard() {
|
|
||||||
await api("/api/dashboard", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ widgets: store.widgets }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function missions() {
|
|
||||||
return window.MissionsApp?.getMissions?.() || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function groups() {
|
|
||||||
const g = new Set();
|
|
||||||
missions().forEach((m) => g.add(m.group || "Missions"));
|
|
||||||
return [...g].sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderCanvas() {
|
|
||||||
if (!canvasEl) return;
|
|
||||||
canvasEl.innerHTML = "";
|
|
||||||
if (!store.widgets.length) {
|
|
||||||
const empty = document.createElement("p");
|
|
||||||
empty.className = "mutedNote";
|
|
||||||
empty.textContent = "Thêm widget từ panel trái.";
|
|
||||||
canvasEl.appendChild(empty);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
store.widgets.forEach((widget) => {
|
|
||||||
canvasEl.appendChild(buildWidget(widget));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWidget(widget) {
|
|
||||||
const box = document.createElement("div");
|
|
||||||
box.className = "dashboardWidget";
|
|
||||||
box.dataset.widgetId = widget.id;
|
|
||||||
|
|
||||||
const head = document.createElement("div");
|
|
||||||
head.className = "dashboardWidgetHead";
|
|
||||||
head.innerHTML = `<span class="dashboardWidgetTitle">${widget.type.replace(/_/g, " ")}</span>`;
|
|
||||||
const removeBtn = document.createElement("button");
|
|
||||||
removeBtn.type = "button";
|
|
||||||
removeBtn.className = "dashboardWidgetRemove";
|
|
||||||
removeBtn.textContent = "×";
|
|
||||||
removeBtn.addEventListener("click", () => {
|
|
||||||
store.widgets = store.widgets.filter((w) => w.id !== widget.id);
|
|
||||||
renderCanvas();
|
|
||||||
});
|
|
||||||
head.appendChild(removeBtn);
|
|
||||||
box.appendChild(head);
|
|
||||||
|
|
||||||
const body = document.createElement("div");
|
|
||||||
body.className = "dashboardWidgetBody";
|
|
||||||
|
|
||||||
switch (widget.type) {
|
|
||||||
case "mission_button":
|
|
||||||
renderMissionButton(body, widget);
|
|
||||||
break;
|
|
||||||
case "mission_group":
|
|
||||||
renderMissionGroup(body, widget);
|
|
||||||
break;
|
|
||||||
case "mission_queue":
|
|
||||||
renderMissionQueue(body);
|
|
||||||
break;
|
|
||||||
case "pause_continue":
|
|
||||||
renderPauseContinue(body);
|
|
||||||
break;
|
|
||||||
case "action_log":
|
|
||||||
renderActionLog(body);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
body.textContent = "Unknown widget";
|
|
||||||
}
|
|
||||||
box.appendChild(body);
|
|
||||||
return box;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMissionButton(container, widget) {
|
|
||||||
const select = document.createElement("select");
|
|
||||||
missions().forEach((m) => {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = m.id;
|
|
||||||
opt.textContent = m.name;
|
|
||||||
if (m.id === widget.mission_id) opt.selected = true;
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
select.addEventListener("change", () => {
|
|
||||||
widget.mission_id = select.value;
|
|
||||||
});
|
|
||||||
container.appendChild(select);
|
|
||||||
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "dashboardMissionBtn";
|
|
||||||
btn.textContent = "Start mission";
|
|
||||||
btn.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
if (window.MissionsApp?.queueMission) {
|
|
||||||
await window.MissionsApp.queueMission(select.value, {});
|
|
||||||
} else {
|
|
||||||
await api("/api/mission_queue", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ mission_id: select.value, parameters: {} }),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await refreshWidgetsDynamic();
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
container.appendChild(btn);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMissionGroup(container, widget) {
|
|
||||||
const select = document.createElement("select");
|
|
||||||
groups().forEach((g) => {
|
|
||||||
const opt = document.createElement("option");
|
|
||||||
opt.value = g;
|
|
||||||
opt.textContent = g;
|
|
||||||
if (g === (widget.group || "Missions")) opt.selected = true;
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
select.addEventListener("change", () => {
|
|
||||||
widget.group = select.value;
|
|
||||||
renderCanvas();
|
|
||||||
});
|
|
||||||
container.appendChild(select);
|
|
||||||
|
|
||||||
const list = document.createElement("div");
|
|
||||||
list.className = "dashboardMissionGroup";
|
|
||||||
missions()
|
|
||||||
.filter((m) => (m.group || "Missions") === (widget.group || select.value))
|
|
||||||
.forEach((m) => {
|
|
||||||
const btn = document.createElement("button");
|
|
||||||
btn.type = "button";
|
|
||||||
btn.className = "btn subtle btnBlock";
|
|
||||||
btn.textContent = m.name;
|
|
||||||
btn.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await window.MissionsApp.queueMission(m.id, {});
|
|
||||||
await refreshWidgetsDynamic();
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
list.appendChild(btn);
|
|
||||||
});
|
|
||||||
container.appendChild(list);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderMissionQueue(container) {
|
|
||||||
const wrap = document.createElement("div");
|
|
||||||
wrap.className = "dashboardQueueMini";
|
|
||||||
wrap.dataset.dynamic = "queue";
|
|
||||||
container.appendChild(wrap);
|
|
||||||
updateQueueMini(wrap);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderPauseContinue(container) {
|
|
||||||
const play = document.createElement("button");
|
|
||||||
play.type = "button";
|
|
||||||
play.className = "btn primary dashboardPauseBtn";
|
|
||||||
play.textContent = "▶ Continue";
|
|
||||||
play.addEventListener("click", async () => {
|
|
||||||
await api("/api/mission_runner/state", { method: "PUT", body: JSON.stringify({ state_id: 3 }) });
|
|
||||||
await refreshWidgetsDynamic();
|
|
||||||
});
|
|
||||||
const pause = document.createElement("button");
|
|
||||||
pause.type = "button";
|
|
||||||
pause.className = "btn subtle dashboardPauseBtn";
|
|
||||||
pause.textContent = "⏸ Pause";
|
|
||||||
pause.addEventListener("click", async () => {
|
|
||||||
await api("/api/mission_runner/state", { method: "PUT", body: JSON.stringify({ state_id: 4 }) });
|
|
||||||
await refreshWidgetsDynamic();
|
|
||||||
});
|
|
||||||
container.appendChild(play);
|
|
||||||
container.appendChild(pause);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderActionLog(container) {
|
|
||||||
const wrap = document.createElement("div");
|
|
||||||
wrap.className = "dashboardQueueMini";
|
|
||||||
wrap.dataset.dynamic = "log";
|
|
||||||
container.appendChild(wrap);
|
|
||||||
updateActionLogMini(wrap);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateQueueMini(node) {
|
|
||||||
const status = window.MissionsApp?.getRunnerStatus?.();
|
|
||||||
const queue = status?.queue || [];
|
|
||||||
node.innerHTML = queue.length
|
|
||||||
? queue
|
|
||||||
.map(
|
|
||||||
(q) =>
|
|
||||||
`<div><strong>${q.mission_name || q.mission_id}</strong> <span class="mutedNote">${q.state}</span></div>`
|
|
||||||
)
|
|
||||||
.join("")
|
|
||||||
: `<span class="mutedNote">Queue trống</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateActionLogMini(node) {
|
|
||||||
const status = window.MissionsApp?.getRunnerStatus?.();
|
|
||||||
const log = status?.action_log || [];
|
|
||||||
node.innerHTML = [...log]
|
|
||||||
.reverse()
|
|
||||||
.slice(0, 12)
|
|
||||||
.map((row) => `<div>${row.message || ""}</div>`)
|
|
||||||
.join("") || `<span class="mutedNote">—</span>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshWidgetsDynamic() {
|
|
||||||
if (window.MissionsApp?.refreshRunnerStatus) await window.MissionsApp.refreshRunnerStatus();
|
|
||||||
canvasEl?.querySelectorAll("[data-dynamic=queue]").forEach(updateQueueMini);
|
|
||||||
canvasEl?.querySelectorAll("[data-dynamic=log]").forEach(updateActionLogMini);
|
|
||||||
}
|
|
||||||
|
|
||||||
function addWidget(type) {
|
|
||||||
const widget = { id: newWidgetId(), type };
|
|
||||||
if (type === "mission_button") {
|
|
||||||
widget.mission_id = missions()[0]?.id || "";
|
|
||||||
}
|
|
||||||
if (type === "mission_group") {
|
|
||||||
widget.group = groups()[0] || "Missions";
|
|
||||||
}
|
|
||||||
store.widgets.push(widget);
|
|
||||||
renderCanvas();
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindEvents() {
|
|
||||||
document.querySelectorAll(".dashboardAddWidget").forEach((btn) => {
|
|
||||||
btn.addEventListener("click", () => addWidget(btn.dataset.widget));
|
|
||||||
});
|
|
||||||
saveBtn?.addEventListener("click", async () => {
|
|
||||||
try {
|
|
||||||
await saveDashboard();
|
|
||||||
alert("Đã lưu dashboard.");
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
modeSelectEl?.addEventListener("change", async () => {
|
|
||||||
try {
|
|
||||||
await api("/api/mission_runner/mode", {
|
|
||||||
method: "PUT",
|
|
||||||
body: JSON.stringify({ mode: modeSelectEl.value }),
|
|
||||||
});
|
|
||||||
await refreshWidgetsDynamic();
|
|
||||||
} catch (e) {
|
|
||||||
alert(e.message);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function startPolling() {
|
|
||||||
if (store.pollTimer) clearInterval(store.pollTimer);
|
|
||||||
store.pollTimer = setInterval(() => {
|
|
||||||
const page = el("pageDashboard");
|
|
||||||
if (page && !page.hidden) refreshWidgetsDynamic();
|
|
||||||
}, 2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function init() {
|
|
||||||
bindEvents();
|
|
||||||
try {
|
|
||||||
await loadDashboard();
|
|
||||||
} catch {
|
|
||||||
store.widgets = [
|
|
||||||
{ id: newWidgetId(), type: "mission_queue" },
|
|
||||||
{ id: newWidgetId(), type: "pause_continue" },
|
|
||||||
];
|
|
||||||
renderCanvas();
|
|
||||||
}
|
|
||||||
startPolling();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.DashboardApp = {
|
|
||||||
init,
|
|
||||||
onPageShow() {
|
|
||||||
loadDashboard().catch(() => renderCanvas());
|
|
||||||
refreshWidgetsDynamic();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
init();
|
|
||||||
})();
|
|
||||||
@@ -532,6 +532,21 @@
|
|||||||
<div id="missionList" class="missionList"></div>
|
<div id="missionList" class="missionList"></div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card" id="missionQueueCard">
|
||||||
|
<div class="cardHeader">
|
||||||
|
<div>
|
||||||
|
<div class="cardTitle">Mission queue</div>
|
||||||
|
<div class="cardSub">Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.</div>
|
||||||
|
</div>
|
||||||
|
<button id="missionQueueClearBtn" type="button" class="btn subtle danger">Xóa queue</button>
|
||||||
|
</div>
|
||||||
|
<div class="cardBody">
|
||||||
|
<div id="missionQueueRunner" class="missionQueueRunner mutedNote">—</div>
|
||||||
|
<div id="missionQueueEmpty" class="mutedNote">Queue trống. Bấm <span class="mono">▤</span> trên mission để thêm.</div>
|
||||||
|
<div id="missionQueueList" class="missionQueueList"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="missionEditorView" class="missionsPage" hidden>
|
<div id="missionEditorView" class="missionsPage" hidden>
|
||||||
@@ -687,6 +702,24 @@
|
|||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<dialog id="missionQueueDialog" class="missionDialog">
|
||||||
|
<form id="missionQueueForm" method="dialog" class="missionDialogForm">
|
||||||
|
<div class="missionDialogHeader">
|
||||||
|
<h3>Thêm vào mission queue</h3>
|
||||||
|
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="missionQueueDialog" aria-label="Đóng">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="missionDialogBody">
|
||||||
|
<p id="missionQueueDialogMission" class="mutedNote">—</p>
|
||||||
|
<div id="missionQueueVarFields" class="missionConfigGrid"></div>
|
||||||
|
<p id="missionQueueVarHint" class="mutedNote" hidden>Tham số đã chọn sẽ hiển thị màu xanh trong queue.</p>
|
||||||
|
</div>
|
||||||
|
<div class="missionDialogFooter">
|
||||||
|
<button type="button" class="btn subtle" data-close-dialog="missionQueueDialog">Hủy</button>
|
||||||
|
<button type="submit" class="btn primary">Thêm vào queue</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<script src="/missions.js"></script>
|
<script src="/missions.js"></script>
|
||||||
<script src="/app.js"></script>
|
<script src="/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
305
www/missions.js
305
www/missions.js
@@ -56,6 +56,25 @@
|
|||||||
const missionActionConfigDialogEl = el("missionActionConfigDialog");
|
const missionActionConfigDialogEl = el("missionActionConfigDialog");
|
||||||
const missionActionConfigBodyEl = el("missionActionConfigBody");
|
const missionActionConfigBodyEl = el("missionActionConfigBody");
|
||||||
const missionActionConfigTitleEl = el("missionActionConfigTitle");
|
const missionActionConfigTitleEl = el("missionActionConfigTitle");
|
||||||
|
const missionQueueListEl = el("missionQueueList");
|
||||||
|
const missionQueueEmptyEl = el("missionQueueEmpty");
|
||||||
|
const missionQueueRunnerEl = el("missionQueueRunner");
|
||||||
|
const missionQueueDialogEl = el("missionQueueDialog");
|
||||||
|
const missionQueueVarFieldsEl = el("missionQueueVarFields");
|
||||||
|
const missionQueueDialogMissionEl = el("missionQueueDialogMission");
|
||||||
|
const missionQueueVarHintEl = el("missionQueueVarHint");
|
||||||
|
|
||||||
|
const VARIABLE_FIELD_DEFS = {
|
||||||
|
move_to_position: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
|
||||||
|
adjust_localization: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
|
||||||
|
move_to_marker: [{ key: "marker", label: "Marker", options: SAMPLE_MARKERS }],
|
||||||
|
if: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
|
||||||
|
pick_cart: [
|
||||||
|
{ key: "position", label: "Position", options: SAMPLE_POSITIONS },
|
||||||
|
{ key: "cart", label: "Cart", options: SAMPLE_CARTS },
|
||||||
|
],
|
||||||
|
drop_cart: [{ key: "position", label: "Position", options: SAMPLE_POSITIONS }],
|
||||||
|
};
|
||||||
|
|
||||||
const store = {
|
const store = {
|
||||||
missions: [],
|
missions: [],
|
||||||
@@ -66,6 +85,10 @@
|
|||||||
drag: null,
|
drag: null,
|
||||||
configActionId: null,
|
configActionId: null,
|
||||||
configListPath: "root",
|
configListPath: "root",
|
||||||
|
queue: [],
|
||||||
|
runner: { state: "idle", message: "" },
|
||||||
|
queuePollTimer: null,
|
||||||
|
pendingQueueMissionId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function newId() {
|
function newId() {
|
||||||
@@ -209,11 +232,12 @@
|
|||||||
function actionSummary(action) {
|
function actionSummary(action) {
|
||||||
if (action.kind === "mission") return `Mission con: ${action.label}`;
|
if (action.kind === "mission") return `Mission con: ${action.label}`;
|
||||||
const p = action.params || {};
|
const p = action.params || {};
|
||||||
|
const fmtVar = (key, val) => (p[`${key}_var`] ? `${val} (biến)` : val);
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "move_to_position":
|
case "move_to_position":
|
||||||
return `Position: ${p.position}${p.check_free ? " • kiểm tra trống" : ""}`;
|
return `Position: ${fmtVar("position", p.position)}${p.check_free ? " • kiểm tra trống" : ""}`;
|
||||||
case "move_to_marker":
|
case "move_to_marker":
|
||||||
return `Marker: ${p.marker}`;
|
return `Marker: ${fmtVar("marker", p.marker)}`;
|
||||||
case "wait":
|
case "wait":
|
||||||
return `${p.seconds}s`;
|
return `${p.seconds}s`;
|
||||||
case "set_speed":
|
case "set_speed":
|
||||||
@@ -230,7 +254,7 @@
|
|||||||
return `Reg ${p.register}: ${p.action} ${p.value}`;
|
return `Reg ${p.register}: ${p.action} ${p.value}`;
|
||||||
case "pick_cart":
|
case "pick_cart":
|
||||||
case "drop_cart":
|
case "drop_cart":
|
||||||
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${p.position}`;
|
return `${action.type === "pick_cart" ? "Pick" : "Drop"} @ ${fmtVar("position", p.position)}${action.type === "pick_cart" ? ` • ${fmtVar("cart", p.cart)}` : ""}`;
|
||||||
case "user_log":
|
case "user_log":
|
||||||
return p.message || "—";
|
return p.message || "—";
|
||||||
case "play_sound":
|
case "play_sound":
|
||||||
@@ -323,6 +347,253 @@
|
|||||||
renderMissionEditor();
|
renderMissionEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function missionApi(path, opts = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
headers: { "Content-Type": "application/json", ...(opts.headers || {}) },
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = res.statusText;
|
||||||
|
try {
|
||||||
|
const err = await res.json();
|
||||||
|
if (err.error) msg = err.error;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function walkActions(actions, visit, depth = 0) {
|
||||||
|
if (!Array.isArray(actions) || depth > 12) return;
|
||||||
|
actions.forEach((action) => {
|
||||||
|
visit(action, depth);
|
||||||
|
if (Array.isArray(action.children)) walkActions(action.children, visit, depth + 1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActionSnapshot(action, depth = 0) {
|
||||||
|
const copy = JSON.parse(JSON.stringify(action));
|
||||||
|
if (copy.kind === "mission") {
|
||||||
|
const ref = findMission(copy.refId);
|
||||||
|
if (ref && depth < 8) copy.resolved_mission = resolveMissionSnapshot(ref, depth + 1);
|
||||||
|
}
|
||||||
|
if (Array.isArray(copy.children)) {
|
||||||
|
copy.children = copy.children.map((child) => resolveActionSnapshot(child, depth));
|
||||||
|
}
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMissionSnapshot(mission, depth = 0) {
|
||||||
|
const copy = JSON.parse(JSON.stringify(mission));
|
||||||
|
copy.actions = (copy.actions || []).map((a) => resolveActionSnapshot(a, depth));
|
||||||
|
return copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectMissionVariables(mission) {
|
||||||
|
const vars = [];
|
||||||
|
walkActions(mission.actions, (action) => {
|
||||||
|
if (action.kind === "mission" || !action.params) return;
|
||||||
|
const defs = VARIABLE_FIELD_DEFS[action.type] || [];
|
||||||
|
defs.forEach((def) => {
|
||||||
|
if (!action.params[`${def.key}_var`]) return;
|
||||||
|
vars.push({
|
||||||
|
key: `${action.id}:${def.key}`,
|
||||||
|
label: `${action.label} — ${def.label}`,
|
||||||
|
options: def.options,
|
||||||
|
default: action.params[def.key] || def.options[0] || "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQueueParameters(entry) {
|
||||||
|
const params = entry.parameters || {};
|
||||||
|
const keys = Object.keys(params);
|
||||||
|
if (!keys.length) return "";
|
||||||
|
return keys
|
||||||
|
.map((key) => `<span class="missionQueueParamVar">${escapeHtml(params[key])}</span>`)
|
||||||
|
.join(" • ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueStatusLabel(status) {
|
||||||
|
const map = {
|
||||||
|
pending: "Chờ",
|
||||||
|
executing: "Đang chạy",
|
||||||
|
completed: "Xong",
|
||||||
|
failed: "Lỗi",
|
||||||
|
};
|
||||||
|
return map[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshQueue() {
|
||||||
|
try {
|
||||||
|
const data = await missionApi("/api/mission_queue");
|
||||||
|
store.queue = Array.isArray(data.queue) ? data.queue : [];
|
||||||
|
store.runner = data.runner && typeof data.runner === "object" ? data.runner : { state: "idle", message: "" };
|
||||||
|
renderQueuePanel();
|
||||||
|
} catch (e) {
|
||||||
|
if (missionQueueRunnerEl) missionQueueRunnerEl.textContent = `Không tải được queue: ${e.message}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderQueuePanel() {
|
||||||
|
if (!missionQueueListEl) return;
|
||||||
|
missionQueueListEl.innerHTML = "";
|
||||||
|
if (missionQueueEmptyEl) missionQueueEmptyEl.hidden = store.queue.length > 0;
|
||||||
|
|
||||||
|
if (missionQueueRunnerEl) {
|
||||||
|
const st = store.runner.state || "idle";
|
||||||
|
missionQueueRunnerEl.classList.toggle("running", st === "running");
|
||||||
|
const action = store.runner.current_action ? ` • ${store.runner.current_action}` : "";
|
||||||
|
missionQueueRunnerEl.textContent = store.runner.message
|
||||||
|
? `${store.runner.message}${action}`
|
||||||
|
: st === "idle"
|
||||||
|
? "Robot sẵn sàng — queue trống hoặc chờ mission mới."
|
||||||
|
: "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
store.queue.forEach((entry, index) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = `missionQueueItem status-${entry.status || "pending"}`;
|
||||||
|
const paramHtml = formatQueueParameters(entry);
|
||||||
|
const canReorder = entry.status === "pending";
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="missionQueueOrder">
|
||||||
|
<button type="button" class="iconBtn" data-queue-up="${entry.id}" title="Lên" ${canReorder && index > 0 ? "" : "disabled"}>↑</button>
|
||||||
|
<button type="button" class="iconBtn" data-queue-down="${entry.id}" title="Xuống" ${canReorder && index < store.queue.length - 1 ? "" : "disabled"}>↓</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="missionQueueItemTitle">${escapeHtml(entry.mission_name || "Mission")}</div>
|
||||||
|
<div class="missionQueueItemMeta">${escapeHtml(entry.mission_group || "")} • #${index + 1}</div>
|
||||||
|
${paramHtml ? `<div class="missionQueueItemParams">${paramHtml}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:6px;">
|
||||||
|
<span class="missionQueueStatus ${escapeHtml(entry.status || "pending")}">${queueStatusLabel(entry.status)}</span>
|
||||||
|
${entry.status === "pending" ? `<button type="button" class="btn subtle danger" data-queue-remove="${entry.id}">Xóa</button>` : ""}
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
row.querySelector("[data-queue-up]")?.addEventListener("click", () => moveQueueItem(entry.id, -1));
|
||||||
|
row.querySelector("[data-queue-down]")?.addEventListener("click", () => moveQueueItem(entry.id, 1));
|
||||||
|
row.querySelector("[data-queue-remove]")?.addEventListener("click", () => removeQueueItem(entry.id));
|
||||||
|
missionQueueListEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveQueueItem(id, delta) {
|
||||||
|
const ids = store.queue.map((q) => q.id);
|
||||||
|
const idx = ids.indexOf(id);
|
||||||
|
if (idx < 0) return;
|
||||||
|
if (store.queue[idx].status !== "pending") return;
|
||||||
|
const next = idx + delta;
|
||||||
|
if (next < 0 || next >= store.queue.length) return;
|
||||||
|
if (store.queue[next].status !== "pending") return;
|
||||||
|
[ids[idx], ids[next]] = [ids[next], ids[idx]];
|
||||||
|
try {
|
||||||
|
await missionApi("/api/mission_queue/reorder", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ ordered_ids: ids }),
|
||||||
|
});
|
||||||
|
await refreshQueue();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeQueueItem(id) {
|
||||||
|
try {
|
||||||
|
await missionApi(`/api/mission_queue/${id}`, { method: "DELETE" });
|
||||||
|
await refreshQueue();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearQueue() {
|
||||||
|
if (!confirm("Xóa các mission đang chờ trong queue?")) return;
|
||||||
|
try {
|
||||||
|
await missionApi("/api/mission_queue", { method: "DELETE" });
|
||||||
|
await refreshQueue();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openQueueDialog(missionId) {
|
||||||
|
const mission = findMission(missionId);
|
||||||
|
if (!mission) return;
|
||||||
|
store.pendingQueueMissionId = missionId;
|
||||||
|
const vars = collectMissionVariables(mission);
|
||||||
|
if (missionQueueDialogMissionEl) {
|
||||||
|
missionQueueDialogMissionEl.textContent = `Mission: ${mission.name}`;
|
||||||
|
}
|
||||||
|
if (missionQueueVarFieldsEl) {
|
||||||
|
missionQueueVarFieldsEl.innerHTML = "";
|
||||||
|
if (!vars.length) {
|
||||||
|
missionQueueVarFieldsEl.innerHTML = `<p class="mutedNote">Mission không có tham số biến. Bấm «Thêm vào queue» để chạy.</p>`;
|
||||||
|
} else {
|
||||||
|
vars.forEach((v) => {
|
||||||
|
const row = document.createElement("div");
|
||||||
|
row.className = "row rowWide";
|
||||||
|
const lab = document.createElement("label");
|
||||||
|
lab.textContent = v.label;
|
||||||
|
const sel = document.createElement("select");
|
||||||
|
sel.dataset.varKey = v.key;
|
||||||
|
v.options.forEach((opt) => {
|
||||||
|
const o = document.createElement("option");
|
||||||
|
o.value = opt;
|
||||||
|
o.textContent = opt;
|
||||||
|
if (opt === v.default) o.selected = true;
|
||||||
|
sel.appendChild(o);
|
||||||
|
});
|
||||||
|
row.appendChild(lab);
|
||||||
|
row.appendChild(sel);
|
||||||
|
missionQueueVarFieldsEl.appendChild(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (missionQueueVarHintEl) missionQueueVarHintEl.hidden = vars.length === 0;
|
||||||
|
missionQueueDialogEl.showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitQueueDialog(evt) {
|
||||||
|
evt.preventDefault();
|
||||||
|
const mission = findMission(store.pendingQueueMissionId);
|
||||||
|
if (!mission) return;
|
||||||
|
const parameters = {};
|
||||||
|
missionQueueVarFieldsEl?.querySelectorAll("[data-var-key]").forEach((sel) => {
|
||||||
|
parameters[sel.dataset.varKey] = sel.value;
|
||||||
|
});
|
||||||
|
const payload = {
|
||||||
|
mission: resolveMissionSnapshot(mission),
|
||||||
|
parameters,
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
await missionApi("/api/mission_queue", { method: "POST", body: JSON.stringify(payload) });
|
||||||
|
missionQueueDialogEl.close();
|
||||||
|
store.pendingQueueMissionId = null;
|
||||||
|
await refreshQueue();
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startQueuePoll() {
|
||||||
|
stopQueuePoll();
|
||||||
|
refreshQueue();
|
||||||
|
store.queuePollTimer = setInterval(refreshQueue, 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopQueuePoll() {
|
||||||
|
if (store.queuePollTimer) {
|
||||||
|
clearInterval(store.queuePollTimer);
|
||||||
|
store.queuePollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderMissionList() {
|
function renderMissionList() {
|
||||||
if (!missionListEl) return;
|
if (!missionListEl) return;
|
||||||
missionListEl.innerHTML = "";
|
missionListEl.innerHTML = "";
|
||||||
@@ -338,6 +609,7 @@
|
|||||||
<div class="missionListItemMeta">${escapeHtml(mission.group)} • ${mission.actions.length} action(s)${mission.description ? ` • ${escapeHtml(mission.description)}` : ""}</div>
|
<div class="missionListItemMeta">${escapeHtml(mission.group)} • ${mission.actions.length} action(s)${mission.description ? ` • ${escapeHtml(mission.description)}` : ""}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="missionListItemActions">
|
<div class="missionListItemActions">
|
||||||
|
<button type="button" class="iconBtn missionQueueBtn" data-queue="${mission.id}" title="Thêm vào mission queue" aria-label="Thêm vào queue">▤</button>
|
||||||
<button type="button" class="btn subtle" data-edit="${mission.id}">Sửa</button>
|
<button type="button" class="btn subtle" data-edit="${mission.id}">Sửa</button>
|
||||||
<button type="button" class="btn subtle danger" data-delete="${mission.id}">Xóa</button>
|
<button type="button" class="btn subtle danger" data-delete="${mission.id}">Xóa</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -345,6 +617,10 @@
|
|||||||
if (evt.target.closest("button")) return;
|
if (evt.target.closest("button")) return;
|
||||||
openEditor(mission.id);
|
openEditor(mission.id);
|
||||||
});
|
});
|
||||||
|
row.querySelector("[data-queue]").addEventListener("click", (evt) => {
|
||||||
|
evt.stopPropagation();
|
||||||
|
openQueueDialog(mission.id);
|
||||||
|
});
|
||||||
row.querySelector("[data-edit]").addEventListener("click", (evt) => {
|
row.querySelector("[data-edit]").addEventListener("click", (evt) => {
|
||||||
evt.stopPropagation();
|
evt.stopPropagation();
|
||||||
openEditor(mission.id);
|
openEditor(mission.id);
|
||||||
@@ -722,10 +998,17 @@
|
|||||||
return select;
|
return select;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addVariableToggle = (paramKey, fieldLabel) => {
|
||||||
|
const chk = document.createElement("label");
|
||||||
|
chk.innerHTML = `<input type="checkbox" data-param="${paramKey}_var" ${p[`${paramKey}_var`] ? "checked" : ""} /> Biến — hỏi khi thêm vào queue`;
|
||||||
|
addField(`${fieldLabel} (biến)`, chk);
|
||||||
|
};
|
||||||
|
|
||||||
switch (action.type) {
|
switch (action.type) {
|
||||||
case "move_to_position":
|
case "move_to_position":
|
||||||
case "adjust_localization":
|
case "adjust_localization":
|
||||||
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
||||||
|
addVariableToggle("position", "Position");
|
||||||
if (action.type === "move_to_position") {
|
if (action.type === "move_to_position") {
|
||||||
const chk = document.createElement("label");
|
const chk = document.createElement("label");
|
||||||
chk.innerHTML = `<input type="checkbox" data-param="check_free" ${p.check_free ? "checked" : ""} /> Kiểm tra vị trí trống`;
|
chk.innerHTML = `<input type="checkbox" data-param="check_free" ${p.check_free ? "checked" : ""} /> Kiểm tra vị trí trống`;
|
||||||
@@ -734,6 +1017,7 @@
|
|||||||
break;
|
break;
|
||||||
case "move_to_marker":
|
case "move_to_marker":
|
||||||
addField("Marker", selectInput("marker", p.marker, SAMPLE_MARKERS));
|
addField("Marker", selectInput("marker", p.marker, SAMPLE_MARKERS));
|
||||||
|
addVariableToggle("marker", "Marker");
|
||||||
break;
|
break;
|
||||||
case "wait":
|
case "wait":
|
||||||
addField("Giây", textInput("seconds", p.seconds, "number"));
|
addField("Giây", textInput("seconds", p.seconds, "number"));
|
||||||
@@ -748,6 +1032,7 @@
|
|||||||
case "if":
|
case "if":
|
||||||
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"]));
|
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"]));
|
||||||
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
||||||
|
addVariableToggle("position", "Position");
|
||||||
break;
|
break;
|
||||||
case "set_digital_output":
|
case "set_digital_output":
|
||||||
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
|
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES));
|
||||||
@@ -775,10 +1060,13 @@
|
|||||||
break;
|
break;
|
||||||
case "pick_cart":
|
case "pick_cart":
|
||||||
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
||||||
|
addVariableToggle("position", "Position");
|
||||||
addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS));
|
addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS));
|
||||||
|
addVariableToggle("cart", "Cart");
|
||||||
break;
|
break;
|
||||||
case "drop_cart":
|
case "drop_cart":
|
||||||
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS));
|
||||||
|
addVariableToggle("position", "Position");
|
||||||
{
|
{
|
||||||
const chk = document.createElement("label");
|
const chk = document.createElement("label");
|
||||||
chk.innerHTML = `<input type="checkbox" data-param="collision_check" ${p.collision_check ? "checked" : ""} /> Kiểm tra va chạm`;
|
chk.innerHTML = `<input type="checkbox" data-param="collision_check" ${p.collision_check ? "checked" : ""} /> Kiểm tra va chạm`;
|
||||||
@@ -891,6 +1179,9 @@
|
|||||||
document.addEventListener("click", (evt) => {
|
document.addEventListener("click", (evt) => {
|
||||||
if (!evt.target.closest(".missionGroupTab")) closeAllPaletteMenus();
|
if (!evt.target.closest(".missionGroupTab")) closeAllPaletteMenus();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
el("missionQueueClearBtn")?.addEventListener("click", clearQueue);
|
||||||
|
el("missionQueueForm")?.addEventListener("submit", submitQueueDialog);
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
@@ -903,7 +1194,13 @@
|
|||||||
init,
|
init,
|
||||||
onPageShow() {
|
onPageShow() {
|
||||||
if (!missionEditorViewEl?.hidden) renderMissionEditor();
|
if (!missionEditorViewEl?.hidden) renderMissionEditor();
|
||||||
else renderMissionList();
|
else {
|
||||||
|
renderMissionList();
|
||||||
|
startQueuePoll();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onPageHide() {
|
||||||
|
stopQueuePoll();
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -569,8 +569,68 @@ canvas {
|
|||||||
}
|
}
|
||||||
.missionListItemTitle { font-weight: 700; font-size: 14px; }
|
.missionListItemTitle { font-weight: 700; font-size: 14px; }
|
||||||
.missionListItemMeta { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
.missionListItemMeta { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||||
.missionListItemActions { display: flex; gap: 8px; }
|
.missionListItemActions { display: flex; gap: 8px; align-items: center; }
|
||||||
.missionListItemActions .btn { padding: 6px 10px; font-size: 12px; }
|
.missionListItemActions .btn { padding: 6px 10px; font-size: 12px; }
|
||||||
|
.missionQueueBtn {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
.missionQueueBtn:hover {
|
||||||
|
border-color: rgba(37, 99, 235, 0.35);
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.missionQueueList { display: grid; gap: 8px; margin-top: 12px; }
|
||||||
|
.missionQueueItem {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: start;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.missionQueueItem.status-executing {
|
||||||
|
border-color: rgba(37, 99, 235, 0.45);
|
||||||
|
background: #eff6ff;
|
||||||
|
}
|
||||||
|
.missionQueueItem.status-completed { opacity: 0.72; }
|
||||||
|
.missionQueueItem.status-failed {
|
||||||
|
border-color: rgba(239, 68, 68, 0.35);
|
||||||
|
background: #fef2f2;
|
||||||
|
}
|
||||||
|
.missionQueueOrder { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.missionQueueOrder .iconBtn { width: 28px; height: 28px; font-size: 12px; }
|
||||||
|
.missionQueueItemTitle { font-weight: 700; font-size: 13px; }
|
||||||
|
.missionQueueItemMeta { font-size: 12px; color: var(--muted); margin-top: 4px; }
|
||||||
|
.missionQueueItemParams { font-size: 12px; margin-top: 6px; }
|
||||||
|
.missionQueueParamVar { color: var(--accent); font-weight: 600; }
|
||||||
|
.missionQueueStatus {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.missionQueueStatus.executing { background: #dbeafe; color: #1d4ed8; }
|
||||||
|
.missionQueueStatus.completed { background: #d1fae5; color: #047857; }
|
||||||
|
.missionQueueStatus.failed { background: #fee2e2; color: #b91c1c; }
|
||||||
|
.missionQueueRunner {
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--panel2);
|
||||||
|
font-size: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.missionQueueRunner.running { border-color: rgba(37, 99, 235, 0.35); background: #eff6ff; color: #1e3a8a; }
|
||||||
|
.missionVarHint { font-size: 11px; color: var(--muted); margin-top: 4px; }
|
||||||
|
|
||||||
.missionEditorCard { overflow: hidden; }
|
.missionEditorCard { overflow: hidden; }
|
||||||
.missionEditorTop {
|
.missionEditorTop {
|
||||||
|
|||||||
Reference in New Issue
Block a user