diff --git a/CMakeLists.txt b/CMakeLists.txt index 2cd764b..47109e6 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -40,6 +40,7 @@ add_executable(lidar_manager_web src/validation/sensor_validator.cpp src/server/static_file_server.cpp src/server/api_server.cpp + src/mission/mission_queue.cpp ) target_link_libraries(lidar_manager_web PRIVATE Threads::Threads) diff --git a/data/mission_queue.json b/data/mission_queue.json new file mode 100644 index 0000000..7602060 --- /dev/null +++ b/data/mission_queue.json @@ -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" + } +} \ No newline at end of file diff --git a/src/app/lidar_manager_app.cpp b/src/app/lidar_manager_app.cpp index b9b2a2d..ff9babc 100644 --- a/src/app/lidar_manager_app.cpp +++ b/src/app/lidar_manager_app.cpp @@ -1,5 +1,6 @@ #include "app/lidar_manager_app.hpp" +#include "mission/mission_queue.hpp" #include "server/api_server.hpp" #include "server/static_file_server.hpp" #include "storage/state_repository.hpp" @@ -21,8 +22,11 @@ int LidarManagerApp::run() StateRepository repo(data_path_); 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; - ApiServer api(repo); + ApiServer api(repo, mission_queue); api.registerRoutes(svr); StaticFileServer::mount(svr, www_root_); diff --git a/src/mission/mission_queue.cpp b/src/mission/mission_queue.cpp new file mode 100644 index 0000000..0bc2231 --- /dev/null +++ b/src/mission/mission_queue.cpp @@ -0,0 +1,446 @@ +#include "mission/mission_queue.hpp" + +#include "util/file_util.hpp" +#include "util/id_util.hpp" + +#include +#include +#include +#include +#include + +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(); + if (params.contains(key) && params[key].is_string()) + return params[key].get(); + 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(); + 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 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 lock(mu_); + return queue_; +} + +nlohmann::json MissionQueue::runnerStatus() const +{ + std::lock_guard lock(mu_); + return runner_; +} + +std::optional 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 lock(mu_); + queue_.push_back(entry); + saveUnlocked(); + } + + wake_ = true; + return entry; +} + +bool MissionQueue::removeById(const std::string& id, std::string& err) +{ + std::lock_guard 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 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 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 seen; + for (const auto& id_json : ordered_ids) + { + if (!id_json.is_string()) + continue; + const std::string id = id_json.get(); + 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 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(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(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 diff --git a/src/mission/mission_queue.hpp b/src/mission/mission_queue.hpp new file mode 100644 index 0000000..3d2906c --- /dev/null +++ b/src/mission/mission_queue.hpp @@ -0,0 +1,56 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +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 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 stop_{false}; + std::atomic 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 diff --git a/src/mission/mission_service.cpp b/src/mission/mission_service.cpp deleted file mode 100644 index 1ec370f..0000000 --- a/src/mission/mission_service.cpp +++ /dev/null @@ -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 -#include -#include - -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 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() == 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() == 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()); - if (name.empty()) - { - err = "name is required"; - return {}; - } - for (const auto& m : doc_["missions"]) - { - if (m.contains("name") && m["name"].get() == name) - { - err = "mission name already exists"; - return {}; - } - } - - nlohmann::json mission = payload; - if (payload.contains("id") && payload["id"].is_string() && !payload["id"].get().empty()) - mission["id"] = payload["id"].get(); - 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(); - 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() != id) - continue; - if (payload.contains("name") && payload["name"].is_string()) - { - const std::string name = StringUtil::trimCopy(payload["name"].get()); - if (name.empty()) - { - err = "name is required"; - return {}; - } - for (const auto& other : doc_["missions"]) - { - if (other.contains("id") && other["id"].get() != id && other.contains("name") && - other["name"].get() == 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(); - 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() == 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(); - 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(), {{"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() == 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() == queue_id) - { - if (queue_[i].value("state", "") != "pending") - { - err = "only pending missions can be reordered"; - return false; - } - item = queue_[i]; - found = static_cast(i); - break; - } - } - if (found < 0) - { - err = "queue entry not found"; - return false; - } - queue_.erase(queue_.begin() + found); - if (new_index > static_cast(queue_.size())) - new_index = static_cast(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(); - 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() == coil_id) - { - err = "coil_id already used"; - return {}; - } - } - const std::string mission_id = payload["mission_id"].get(); - if (findMissionUnlocked(mission_id).is_null()) - { - err = "mission not found"; - return {}; - } - nlohmann::json trigger = {{"id", IdUtil::newId()}, - {"name", StringUtil::trimCopy(payload["name"].get())}, - {"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() == 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() == coil_id) - { - trigger = t; - mission_id = t["mission_id"].get(); - 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() == "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() == "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()); - current_ = std::nullopt; - return; - } -} - -void MissionService::executeQueueEntryUnlocked(nlohmann::json& entry) -{ - const std::string mission_id = entry["mission_id"].get(); - 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()); - 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 diff --git a/src/mission/mission_service.hpp b/src/mission/mission_service.hpp deleted file mode 100644 index fb342a5..0000000 --- a/src/mission/mission_service.hpp +++ /dev/null @@ -1,92 +0,0 @@ -#pragma once - -#include - -#include -#include -#include -#include -#include -#include -#include - -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 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 current_; - int next_queue_num_{1}; - - std::atomic stop_thread_{false}; - std::atomic 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 diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp index 6a5e943..44d2cc8 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -9,7 +9,10 @@ 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) { @@ -436,6 +439,69 @@ void ApiServer::registerRoutes(httplib::Server& svr) return HttpUtil::jsonError(res, 500, "failed to save layout"); res.status = 204; }); + + svr.Get("/api/mission_queue", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json({{"queue", mission_queue_.list()}, {"runner", mission_queue_.runnerStatus()}}).dump(); + }); + + svr.Post("/api/mission_queue", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + nlohmann::json payload; + try + { + payload = nlohmann::json::parse(req.body); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + 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 diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp index ea28163..770cb3d 100644 --- a/src/server/api_server.hpp +++ b/src/server/api_server.hpp @@ -2,6 +2,7 @@ #include +#include "mission/mission_queue.hpp" #include "storage/state_repository.hpp" namespace lm { @@ -9,12 +10,13 @@ namespace lm { class ApiServer { public: - explicit ApiServer(StateRepository& repo); + ApiServer(StateRepository& repo, MissionQueue& mission_queue); void registerRoutes(httplib::Server& svr); private: StateRepository& repo_; + MissionQueue& mission_queue_; }; } // namespace lm diff --git a/www/app.js b/www/app.js index 3595c4d..1c540a0 100644 --- a/www/app.js +++ b/www/app.js @@ -142,6 +142,7 @@ function setActivePage(page) { } if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config"; if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow(); + else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide(); try { localStorage.setItem("activePage", p); } catch { diff --git a/www/dashboard.js b/www/dashboard.js deleted file mode 100644 index d3eae50..0000000 --- a/www/dashboard.js +++ /dev/null @@ -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 = `${widget.type.replace(/_/g, " ")}`; - 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) => - `
${q.mission_name || q.mission_id} ${q.state}
` - ) - .join("") - : `Queue trống`; - } - - function updateActionLogMini(node) { - const status = window.MissionsApp?.getRunnerStatus?.(); - const log = status?.action_log || []; - node.innerHTML = [...log] - .reverse() - .slice(0, 12) - .map((row) => `
${row.message || ""}
`) - .join("") || ``; - } - - 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(); -})(); diff --git a/www/index.html b/www/index.html index 82e440b..699ea94 100644 --- a/www/index.html +++ b/www/index.html @@ -532,6 +532,21 @@
+ +
+
+
+
Mission queue
+
Thêm mission bằng biểu tượng queue — robot chạy theo thứ tự từ trên xuống.
+
+ +
+
+
+
Queue trống. Bấm trên mission để thêm.
+
+
+
+
`; @@ -345,6 +617,10 @@ if (evt.target.closest("button")) return; openEditor(mission.id); }); + row.querySelector("[data-queue]").addEventListener("click", (evt) => { + evt.stopPropagation(); + openQueueDialog(mission.id); + }); row.querySelector("[data-edit]").addEventListener("click", (evt) => { evt.stopPropagation(); openEditor(mission.id); @@ -722,10 +998,17 @@ return select; }; + const addVariableToggle = (paramKey, fieldLabel) => { + const chk = document.createElement("label"); + chk.innerHTML = ` Biến — hỏi khi thêm vào queue`; + addField(`${fieldLabel} (biến)`, chk); + }; + switch (action.type) { case "move_to_position": case "adjust_localization": addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS)); + addVariableToggle("position", "Position"); if (action.type === "move_to_position") { const chk = document.createElement("label"); chk.innerHTML = ` Kiểm tra vị trí trống`; @@ -734,6 +1017,7 @@ break; case "move_to_marker": addField("Marker", selectInput("marker", p.marker, SAMPLE_MARKERS)); + addVariableToggle("marker", "Marker"); break; case "wait": addField("Giây", textInput("seconds", p.seconds, "number")); @@ -748,6 +1032,7 @@ case "if": addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"])); addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS)); + addVariableToggle("position", "Position"); break; case "set_digital_output": addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES)); @@ -775,10 +1060,13 @@ break; case "pick_cart": addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS)); + addVariableToggle("position", "Position"); addField("Cart", selectInput("cart", p.cart, SAMPLE_CARTS)); + addVariableToggle("cart", "Cart"); break; case "drop_cart": addField("Position", selectInput("position", p.position, SAMPLE_POSITIONS)); + addVariableToggle("position", "Position"); { const chk = document.createElement("label"); chk.innerHTML = ` Kiểm tra va chạm`; @@ -891,6 +1179,9 @@ document.addEventListener("click", (evt) => { if (!evt.target.closest(".missionGroupTab")) closeAllPaletteMenus(); }); + + el("missionQueueClearBtn")?.addEventListener("click", clearQueue); + el("missionQueueForm")?.addEventListener("submit", submitQueueDialog); } function init() { @@ -903,7 +1194,13 @@ init, onPageShow() { if (!missionEditorViewEl?.hidden) renderMissionEditor(); - else renderMissionList(); + else { + renderMissionList(); + startQueuePoll(); + } + }, + onPageHide() { + stopQueuePoll(); }, }; diff --git a/www/style.css b/www/style.css index 22d62ba..4345d68 100644 --- a/www/style.css +++ b/www/style.css @@ -569,8 +569,68 @@ canvas { } .missionListItemTitle { font-weight: 700; font-size: 14px; } .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; } +.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; } .missionEditorTop {