diff --git a/CMakeLists.txt b/CMakeLists.txt index 47109e6..511c316 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -41,6 +41,11 @@ add_executable(lidar_manager_web src/server/static_file_server.cpp src/server/api_server.cpp src/mission/mission_queue.cpp + src/mission/mission_store.cpp + src/mission/mission_enqueue.cpp + src/mission/modbus_trigger_service.cpp + src/mission/mission_scheduler.cpp + src/server/api_mission_routes.cpp ) target_link_libraries(lidar_manager_web PRIVATE Threads::Threads) diff --git a/data/mission_queue.json b/data/mission_queue.json index 0283222..6a662ed 100644 --- a/data/mission_queue.json +++ b/data/mission_queue.json @@ -1,11 +1,51 @@ { - "queue": [], + "queue": [ + { + "created_at": "2026-06-13T06:34:14Z", + "finished_at": "2026-06-13T06:34:15Z", + "id": "e164539b35bf3886", + "log": [ + { + "level": "info", + "message": "Wait 1000ms", + "ts": "2026-06-13T06:34:14Z" + } + ], + "mission": { + "actions": [ + { + "id": "a1", + "kind": "action", + "label": "Wait", + "params": { + "seconds": 1 + }, + "type": "wait" + } + ], + "description": "", + "group": "Missions", + "id": "5ae9dbcb0722dffb", + "name": "Test run", + "updated_at": "2026-06-13T04:44:03Z" + }, + "mission_group": "Missions", + "mission_id": "5ae9dbcb0722dffb", + "mission_name": "Test run", + "parameters": {}, + "priority": 0, + "robot_id": "default", + "source": "rest_api_v2", + "started_at": "2026-06-13T06:34:14Z", + "status": "completed" + } + ], "runner": { "current_action": null, "current_queue_id": null, - "message": "", + "message": "Hoàn thành: Test run", "paused": false, "state": "idle", - "updated_at": "2026-06-13T05:43:11Z" + "updated_at": "2026-06-13T06:34:15Z" } } \ No newline at end of file diff --git a/data/missions.json b/data/missions.json index 2d217d5..f12c04c 100644 --- a/data/missions.json +++ b/data/missions.json @@ -48,6 +48,15 @@ "updated_at": "2026-06-13T04:45:08Z" } ], + "robots": [ + { + "id": "default", + "name": "Robot chính", + "online": true, + "serial": "PX-001" + } + ], + "schedules": [], "triggers": [], "version": 1 -} +} \ No newline at end of file diff --git a/src/app/lidar_manager_app.cpp b/src/app/lidar_manager_app.cpp index ff9babc..84138b0 100644 --- a/src/app/lidar_manager_app.cpp +++ b/src/app/lidar_manager_app.cpp @@ -1,6 +1,10 @@ #include "app/lidar_manager_app.hpp" +#include "mission/mission_enqueue.hpp" #include "mission/mission_queue.hpp" +#include "mission/mission_scheduler.hpp" +#include "mission/mission_store.hpp" +#include "mission/modbus_trigger_service.hpp" #include "server/api_server.hpp" #include "server/static_file_server.hpp" #include "storage/state_repository.hpp" @@ -23,10 +27,22 @@ int LidarManagerApp::run() repo.load(); const std::filesystem::path mission_queue_path = data_path_.parent_path() / "mission_queue.json"; + const std::filesystem::path missions_store_path = data_path_.parent_path() / "missions.json"; MissionQueue mission_queue(mission_queue_path); + MissionStore mission_store(missions_store_path); + + const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool { + nlohmann::json payload; + if (!MissionEnqueue::buildPayload(mission_store, request, payload, err)) + return false; + return static_cast(mission_queue.enqueue(payload, err)); + }; + + ModbusTriggerService modbus(mission_store, enqueue_fn, 5502); + MissionScheduler scheduler(mission_store, enqueue_fn); httplib::Server svr; - ApiServer api(repo, mission_queue); + ApiServer api(repo, mission_queue, mission_store, modbus, scheduler); api.registerRoutes(svr); StaticFileServer::mount(svr, www_root_); @@ -36,6 +52,8 @@ int LidarManagerApp::run() www_root_.string().c_str(), data_path_.string().c_str(), (data_path_.parent_path() / "models").string().c_str()); + std::fprintf(stderr, "MiR REST API: http://0.0.0.0:%d/api/v2.0.0/mission_queue\n", port_); + std::fprintf(stderr, "Modbus TCP triggers: port 5502 (coils 1001-2000)\n"); svr.listen("0.0.0.0", port_); return 0; diff --git a/src/mission/mission_enqueue.cpp b/src/mission/mission_enqueue.cpp new file mode 100644 index 0000000..72c2ee6 --- /dev/null +++ b/src/mission/mission_enqueue.cpp @@ -0,0 +1,72 @@ +#include "mission/mission_enqueue.hpp" + +namespace lm { + +nlohmann::json MissionEnqueue::normalizeParameters(const nlohmann::json& parameters) +{ + if (parameters.is_object()) + return parameters; + if (!parameters.is_array()) + return nlohmann::json::object(); + + nlohmann::json out = nlohmann::json::object(); + for (const auto& item : parameters) + { + if (!item.is_object()) + continue; + if (item.contains("id") && item.contains("value")) + out[item["id"].get()] = item["value"]; + else if (item.contains("key") && item.contains("value")) + out[item["key"].get()] = item["value"]; + } + return out; +} + +bool MissionEnqueue::buildPayload(const MissionStore& store, + const nlohmann::json& request, + nlohmann::json& payload, + std::string& err) +{ + if (!request.is_object()) + { + err = "request must be an object"; + return false; + } + + nlohmann::json mission; + if (request.contains("mission") && request["mission"].is_object()) + { + mission = request["mission"]; + } + else if (request.contains("mission_id") && request["mission_id"].is_string()) + { + const auto found = store.findMission(request["mission_id"].get()); + if (!found) + { + err = "mission not found"; + return false; + } + mission = *found; + } + else + { + err = "mission or mission_id is required"; + return false; + } + + payload = nlohmann::json::object(); + payload["mission"] = mission; + payload["parameters"] = request.contains("parameters") ? normalizeParameters(request["parameters"]) + : nlohmann::json::object(); + if (request.contains("priority") && request["priority"].is_number()) + payload["priority"] = request["priority"]; + if (request.contains("robot_id") && request["robot_id"].is_string()) + payload["robot_id"] = request["robot_id"]; + if (request.contains("source") && request["source"].is_string()) + payload["source"] = request["source"]; + else if (request.contains("message") && request["message"].is_string()) + payload["source"] = request["message"].get(); + return true; +} + +} // namespace lm diff --git a/src/mission/mission_enqueue.hpp b/src/mission/mission_enqueue.hpp new file mode 100644 index 0000000..ddceb79 --- /dev/null +++ b/src/mission/mission_enqueue.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "mission/mission_store.hpp" + +#include + +#include +#include + +namespace lm { + +class MissionEnqueue +{ +public: + static nlohmann::json normalizeParameters(const nlohmann::json& parameters); + static bool buildPayload(const MissionStore& store, + const nlohmann::json& request, + nlohmann::json& payload, + std::string& err); +}; + +} // namespace lm diff --git a/src/mission/mission_queue.cpp b/src/mission/mission_queue.cpp index cd82fb1..740a763 100644 --- a/src/mission/mission_queue.cpp +++ b/src/mission/mission_queue.cpp @@ -137,6 +137,9 @@ std::optional MissionQueue::enqueue(const nlohmann::json& payloa entry["mission"] = payload["mission"]; entry["parameters"] = payload.contains("parameters") && payload["parameters"].is_object() ? payload["parameters"] : nlohmann::json::object(); + entry["priority"] = payload.contains("priority") && payload["priority"].is_number() ? payload["priority"].get() : 0; + entry["robot_id"] = payload.value("robot_id", "default"); + entry["source"] = payload.value("source", "ui"); entry["status"] = "pending"; entry["created_at"] = IdUtil::nowIso8601(); entry["started_at"] = nullptr; @@ -145,7 +148,7 @@ std::optional MissionQueue::enqueue(const nlohmann::json& payloa { std::lock_guard lock(mu_); - queue_.push_back(entry); + insertByPriorityUnlocked(entry); saveUnlocked(); } @@ -153,6 +156,29 @@ std::optional MissionQueue::enqueue(const nlohmann::json& payloa return entry; } +void MissionQueue::insertByPriorityUnlocked(nlohmann::json& entry) +{ + const int priority = entry.value("priority", 0); + size_t insert_at = queue_.size(); + for (size_t i = 0; i < queue_.size(); ++i) + { + if (!queue_[i].is_object()) + continue; + if (queue_[i].value("status", "") != "pending") + continue; + const int existing = queue_[i].value("priority", 0); + if (priority > existing) + { + insert_at = i; + break; + } + } + if (insert_at >= queue_.size()) + queue_.push_back(entry); + else + queue_.insert(queue_.begin() + static_cast(insert_at), entry); +} + bool MissionQueue::removeById(const std::string& id, std::string& err) { std::lock_guard lock(mu_); diff --git a/src/mission/mission_queue.hpp b/src/mission/mission_queue.hpp index 763923d..e419949 100644 --- a/src/mission/mission_queue.hpp +++ b/src/mission/mission_queue.hpp @@ -54,6 +54,7 @@ private: int loop_depth); void sleepMs(int ms); void setRunnerState(const std::string& state, const std::string& message = ""); + void insertByPriorityUnlocked(nlohmann::json& entry); }; } // namespace lm diff --git a/src/mission/mission_scheduler.cpp b/src/mission/mission_scheduler.cpp new file mode 100644 index 0000000..8473e2d --- /dev/null +++ b/src/mission/mission_scheduler.cpp @@ -0,0 +1,97 @@ +#include "mission/mission_scheduler.hpp" + +#include "util/id_util.hpp" + +#include +#include + +namespace lm { + +MissionScheduler::MissionScheduler(MissionStore& store, EnqueueFn enqueue_fn) + : store_(store), enqueue_fn_(std::move(enqueue_fn)) +{ + worker_ = std::thread([this] { workerLoop(); }); +} + +MissionScheduler::~MissionScheduler() +{ + stop_ = true; + if (worker_.joinable()) + worker_.join(); +} + +bool MissionScheduler::queueSchedule(const nlohmann::json& schedule, std::string& err) +{ + nlohmann::json req = {{"mission_id", schedule.value("mission_id", "")}, + {"priority", schedule.value("priority", 0)}, + {"robot_id", schedule.value("robot_id", "default")}, + {"source", "fleet:" + schedule.value("name", "schedule")}}; + if (!enqueue_fn_(req, err)) + return false; + markQueued(schedule.value("id", "")); + return true; +} + +void MissionScheduler::markQueued(const std::string& id) +{ + if (id.empty()) + return; + nlohmann::json patch = {{"last_queued_at", IdUtil::nowIso8601()}}; + std::string err; + store_.updateSchedule(id, patch, err); +} + +bool MissionScheduler::runScheduleNow(const std::string& id, std::string& err) +{ + const auto schedule = store_.findSchedule(id); + if (!schedule) + { + err = "schedule not found"; + return false; + } + if (!schedule->value("enabled", true)) + { + err = "schedule is disabled"; + return false; + } + return queueSchedule(*schedule, err); +} + +void MissionScheduler::workerLoop() +{ + while (!stop_) + { + const auto schedules = store_.listSchedules(); + const std::string now = IdUtil::nowIso8601(); + for (const auto& schedule : schedules) + { + if (!schedule.is_object() || !schedule.value("enabled", true)) + continue; + const std::string mode = schedule.value("start_mode", "asap"); + if (mode == "asap") + { + if (schedule.contains("last_queued_at") && !schedule["last_queued_at"].is_null()) + continue; + std::string err; + queueSchedule(schedule, err); + continue; + } + if (mode == "scheduled") + { + if (!schedule.contains("start_at") || schedule["start_at"].is_null()) + continue; + const std::string start_at = schedule["start_at"].get(); + if (start_at > now) + continue; + if (schedule.contains("last_queued_at") && !schedule["last_queued_at"].is_null()) + continue; + std::string err; + queueSchedule(schedule, err); + } + } + for (int i = 0; i < 20 && !stop_; ++i) + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } +} + +} // namespace lm diff --git a/src/mission/mission_scheduler.hpp b/src/mission/mission_scheduler.hpp new file mode 100644 index 0000000..cba27cd --- /dev/null +++ b/src/mission/mission_scheduler.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "mission/mission_store.hpp" + +#include +#include +#include +#include +#include + +namespace lm { + +class MissionScheduler +{ +public: + using EnqueueFn = std::function; + + MissionScheduler(MissionStore& store, EnqueueFn enqueue_fn); + ~MissionScheduler(); + + MissionScheduler(const MissionScheduler&) = delete; + MissionScheduler& operator=(const MissionScheduler&) = delete; + + bool runScheduleNow(const std::string& id, std::string& err); + +private: + MissionStore& store_; + EnqueueFn enqueue_fn_; + + std::atomic stop_{false}; + std::thread worker_; + + void workerLoop(); + bool queueSchedule(const nlohmann::json& schedule, std::string& err); + void markQueued(const std::string& id); +}; + +} // namespace lm diff --git a/src/mission/mission_store.cpp b/src/mission/mission_store.cpp new file mode 100644 index 0000000..a7c57db --- /dev/null +++ b/src/mission/mission_store.cpp @@ -0,0 +1,346 @@ +#include "mission/mission_store.hpp" + +#include "util/file_util.hpp" +#include "util/id_util.hpp" +#include "util/string_util.hpp" + +namespace lm { + +namespace { + +constexpr int kMinCoilId = 1001; +constexpr int kMaxCoilId = 2000; + +bool coilIdValid(int coil_id) +{ + return coil_id >= kMinCoilId && coil_id <= kMaxCoilId; +} + +} // namespace + +MissionStore::MissionStore(std::filesystem::path store_path) : store_path_(std::move(store_path)) +{ + load(); +} + +void MissionStore::load() +{ + std::lock_guard lock(mu_); + data_ = nlohmann::json::object(); + if (!std::filesystem::exists(store_path_)) + { + ensureSchemaUnlocked(); + saveUnlocked(); + return; + } + try + { + data_ = nlohmann::json::parse(FileUtil::readBinary(store_path_)); + } + catch (...) + { + data_ = nlohmann::json::object(); + } + ensureSchemaUnlocked(); +} + +void MissionStore::ensureSchemaUnlocked() +{ + if (!data_.contains("version")) + data_["version"] = 1; + if (!data_.contains("missions") || !data_["missions"].is_array()) + data_["missions"] = nlohmann::json::array(); + if (!data_.contains("triggers") || !data_["triggers"].is_array()) + data_["triggers"] = nlohmann::json::array(); + if (!data_.contains("schedules") || !data_["schedules"].is_array()) + data_["schedules"] = nlohmann::json::array(); + if (!data_.contains("groups") || !data_["groups"].is_array()) + data_["groups"] = nlohmann::json::array({"Missions", "Move", "Logic", "I/O", "Cart", "Misc"}); + if (!data_.contains("robots") || !data_["robots"].is_array()) + { + data_["robots"] = nlohmann::json::array({{{"id", "default"}, + {"name", "Robot chính"}, + {"serial", "PX-001"}, + {"online", true}}}); + } + if (!data_.contains("dashboard") || !data_["dashboard"].is_object()) + data_["dashboard"] = nlohmann::json::object({{"widgets", nlohmann::json::array()}}); +} + +void MissionStore::saveUnlocked() const +{ + FileUtil::writeBinaryAtomic(store_path_, data_.dump(2)); +} + +nlohmann::json MissionStore::snapshot() const +{ + std::lock_guard lock(mu_); + return data_; +} + +bool MissionStore::replace(const nlohmann::json& payload, std::string& err) +{ + if (!payload.is_object()) + { + err = "payload must be an object"; + return false; + } + std::lock_guard lock(mu_); + if (payload.contains("missions") && payload["missions"].is_array()) + data_["missions"] = payload["missions"]; + if (payload.contains("groups") && payload["groups"].is_array()) + data_["groups"] = payload["groups"]; + if (payload.contains("triggers") && payload["triggers"].is_array()) + data_["triggers"] = payload["triggers"]; + if (payload.contains("schedules") && payload["schedules"].is_array()) + data_["schedules"] = payload["schedules"]; + ensureSchemaUnlocked(); + saveUnlocked(); + return true; +} + +nlohmann::json MissionStore::listMissions() const +{ + std::lock_guard lock(mu_); + return data_["missions"]; +} + +std::optional MissionStore::findMission(const std::string& id) const +{ + std::lock_guard lock(mu_); + if (!data_.contains("missions") || !data_["missions"].is_array()) + return std::nullopt; + for (const auto& m : data_["missions"]) + { + if (m.is_object() && m.value("id", "") == id) + return m; + } + return std::nullopt; +} + +nlohmann::json MissionStore::listTriggers() const +{ + std::lock_guard lock(mu_); + return data_["triggers"]; +} + +std::optional MissionStore::addTrigger(const nlohmann::json& payload, std::string& err) +{ + if (!payload.is_object()) + { + err = "payload must be an object"; + return std::nullopt; + } + if (!payload.contains("name") || !payload["name"].is_string()) + { + err = "name is required"; + return std::nullopt; + } + if (!payload.contains("coil_id") || !payload["coil_id"].is_number_integer()) + { + err = "coil_id is required"; + return std::nullopt; + } + if (!payload.contains("mission_id") || !payload["mission_id"].is_string()) + { + err = "mission_id is required"; + return std::nullopt; + } + const int coil_id = payload["coil_id"].get(); + if (!coilIdValid(coil_id)) + { + err = "coil_id must be between 1001 and 2000"; + return std::nullopt; + } + const std::string mission_id = payload["mission_id"].get(); + if (!findMission(mission_id)) + { + err = "mission not found"; + return std::nullopt; + } + + std::lock_guard lock(mu_); + for (const auto& t : data_["triggers"]) + { + if (t.is_object() && t.value("coil_id", 0) == coil_id) + { + err = "coil_id already assigned"; + return std::nullopt; + } + } + + nlohmann::json trigger = {{"id", IdUtil::newId()}, + {"name", StringUtil::trimCopy(payload["name"].get())}, + {"coil_id", coil_id}, + {"mission_id", mission_id}, + {"enabled", !payload.contains("enabled") || payload["enabled"].get()}, + {"created_at", IdUtil::nowIso8601()}}; + data_["triggers"].push_back(trigger); + saveUnlocked(); + return trigger; +} + +bool MissionStore::deleteTrigger(const std::string& id, std::string& err) +{ + std::lock_guard lock(mu_); + if (!data_["triggers"].is_array()) + { + err = "triggers unavailable"; + return false; + } + const auto before = data_["triggers"].size(); + nlohmann::json next = nlohmann::json::array(); + for (const auto& t : data_["triggers"]) + { + if (t.is_object() && t.value("id", "") == id) + continue; + next.push_back(t); + } + if (next.size() == before) + { + err = "trigger not found"; + return false; + } + data_["triggers"] = std::move(next); + saveUnlocked(); + return true; +} + +std::optional MissionStore::findTriggerByCoil(int coil_id) const +{ + std::lock_guard lock(mu_); + if (!data_["triggers"].is_array()) + return std::nullopt; + for (const auto& t : data_["triggers"]) + { + if (!t.is_object()) + continue; + if (t.value("coil_id", 0) == coil_id && t.value("enabled", true)) + return t; + } + return std::nullopt; +} + +nlohmann::json MissionStore::listSchedules() const +{ + std::lock_guard lock(mu_); + return data_["schedules"]; +} + +std::optional MissionStore::addSchedule(const nlohmann::json& payload, std::string& err) +{ + if (!payload.is_object()) + { + err = "payload must be an object"; + return std::nullopt; + } + if (!payload.contains("name") || !payload["name"].is_string()) + { + err = "name is required"; + return std::nullopt; + } + if (!payload.contains("mission_id") || !payload["mission_id"].is_string()) + { + err = "mission_id is required"; + return std::nullopt; + } + const std::string mission_id = payload["mission_id"].get(); + if (!findMission(mission_id)) + { + err = "mission not found"; + return std::nullopt; + } + + const std::string start_mode = + payload.contains("start_mode") && payload["start_mode"].is_string() ? payload["start_mode"].get() + : "asap"; + if (start_mode != "asap" && start_mode != "scheduled") + { + err = "start_mode must be asap or scheduled"; + return std::nullopt; + } + + nlohmann::json schedule = {{"id", IdUtil::newId()}, + {"name", StringUtil::trimCopy(payload["name"].get())}, + {"mission_id", mission_id}, + {"robot_id", payload.value("robot_id", "default")}, + {"priority", payload.contains("priority") && payload["priority"].is_number() + ? payload["priority"].get() + : 0}, + {"start_mode", start_mode}, + {"start_at", payload.contains("start_at") ? payload["start_at"] : nullptr}, + {"enabled", !payload.contains("enabled") || payload["enabled"].get()}, + {"last_queued_at", nullptr}, + {"created_at", IdUtil::nowIso8601()}}; + std::lock_guard lock(mu_); + data_["schedules"].push_back(schedule); + saveUnlocked(); + return schedule; +} + +bool MissionStore::updateSchedule(const std::string& id, const nlohmann::json& payload, std::string& err) +{ + std::lock_guard lock(mu_); + if (!data_["schedules"].is_array()) + { + err = "schedules unavailable"; + return false; + } + for (auto& s : data_["schedules"]) + { + if (!s.is_object() || s.value("id", "") != id) + continue; + if (payload.contains("enabled") && payload["enabled"].is_boolean()) + s["enabled"] = payload["enabled"]; + if (payload.contains("priority") && payload["priority"].is_number()) + s["priority"] = payload["priority"]; + if (payload.contains("start_at")) + s["start_at"] = payload["start_at"]; + if (payload.contains("start_mode") && payload["start_mode"].is_string()) + s["start_mode"] = payload["start_mode"]; + saveUnlocked(); + return true; + } + err = "schedule not found"; + return false; +} + +bool MissionStore::deleteSchedule(const std::string& id, std::string& err) +{ + std::lock_guard lock(mu_); + const auto before = data_["schedules"].size(); + nlohmann::json next = nlohmann::json::array(); + for (const auto& s : data_["schedules"]) + { + if (s.is_object() && s.value("id", "") == id) + continue; + next.push_back(s); + } + if (next.size() == before) + { + err = "schedule not found"; + return false; + } + data_["schedules"] = std::move(next); + saveUnlocked(); + return true; +} + +std::optional MissionStore::findSchedule(const std::string& id) const +{ + std::lock_guard lock(mu_); + for (const auto& s : data_["schedules"]) + { + if (s.is_object() && s.value("id", "") == id) + return s; + } + return std::nullopt; +} + +nlohmann::json MissionStore::listRobots() const +{ + std::lock_guard lock(mu_); + return data_["robots"]; +} + +} // namespace lm diff --git a/src/mission/mission_store.hpp b/src/mission/mission_store.hpp new file mode 100644 index 0000000..fd0b27d --- /dev/null +++ b/src/mission/mission_store.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace lm { + +class MissionStore +{ +public: + explicit MissionStore(std::filesystem::path store_path); + + nlohmann::json snapshot() const; + bool replace(const nlohmann::json& payload, std::string& err); + + nlohmann::json listMissions() const; + std::optional findMission(const std::string& id) const; + + nlohmann::json listTriggers() const; + std::optional addTrigger(const nlohmann::json& payload, std::string& err); + bool deleteTrigger(const std::string& id, std::string& err); + std::optional findTriggerByCoil(int coil_id) const; + + nlohmann::json listSchedules() const; + std::optional addSchedule(const nlohmann::json& payload, std::string& err); + bool updateSchedule(const std::string& id, const nlohmann::json& payload, std::string& err); + bool deleteSchedule(const std::string& id, std::string& err); + std::optional findSchedule(const std::string& id) const; + + nlohmann::json listRobots() const; + +private: + std::filesystem::path store_path_; + mutable std::mutex mu_; + nlohmann::json data_; + + void load(); + void saveUnlocked() const; + void ensureSchemaUnlocked(); +}; + +} // namespace lm diff --git a/src/mission/modbus_trigger_service.cpp b/src/mission/modbus_trigger_service.cpp new file mode 100644 index 0000000..668b6ea --- /dev/null +++ b/src/mission/modbus_trigger_service.cpp @@ -0,0 +1,156 @@ +#include "mission/modbus_trigger_service.hpp" + +#include +#include +#include +#include + +#include +#include + +namespace lm { + +namespace { + +constexpr int kMinCoilId = 1001; +constexpr int kMaxCoilId = 2000; + +bool coilIdValid(int coil_id) +{ + return coil_id >= kMinCoilId && coil_id <= kMaxCoilId; +} + +int modbusAddressToCoilId(uint16_t address) +{ + return static_cast(address); +} + +} // namespace + +ModbusTriggerService::ModbusTriggerService(MissionStore& store, EnqueueFn enqueue_fn, int tcp_port) + : store_(store), enqueue_fn_(std::move(enqueue_fn)), tcp_port_(tcp_port) +{ + tcp_thread_ = std::thread([this] { tcpLoop(); }); +} + +ModbusTriggerService::~ModbusTriggerService() +{ + stop_ = true; + if (tcp_thread_.joinable()) + tcp_thread_.join(); +} + +nlohmann::json ModbusTriggerService::coilStates() const +{ + std::lock_guard lock(mu_); + nlohmann::json out = nlohmann::json::array(); + for (int coil = kMinCoilId; coil <= kMaxCoilId; ++coil) + { + const bool value = coils_.count(coil) ? coils_.at(coil) : false; + if (value || store_.findTriggerByCoil(coil)) + { + out.push_back({{"coil_id", coil}, {"value", value}}); + } + } + return out; +} + +void ModbusTriggerService::onCoilRisingEdgeUnlocked(int coil_id) +{ + (void)coil_id; +} + +bool ModbusTriggerService::writeCoil(int coil_id, bool value, std::string& err) +{ + if (!coilIdValid(coil_id)) + { + err = "coil_id must be between 1001 and 2000"; + return false; + } + std::optional mission_id; + { + std::lock_guard lock(mu_); + const bool prev = coils_.count(coil_id) ? coils_.at(coil_id) : false; + coils_[coil_id] = value; + prev_coils_[coil_id] = value; + if (!prev && value) + { + const auto trigger = store_.findTriggerByCoil(coil_id); + if (trigger) + mission_id = trigger->value("mission_id", ""); + } + } + if (mission_id) + { + nlohmann::json req = {{"mission_id", *mission_id}, {"source", "modbus:" + std::to_string(coil_id)}}; + return enqueue_fn_(req, err); + } + return true; +} + +bool ModbusTriggerService::fireCoil(int coil_id, std::string& err) +{ + return writeCoil(coil_id, true, err); +} + +void ModbusTriggerService::handleTcpClient(int client_fd) +{ + std::vector buffer(260); + const ssize_t n = recv(client_fd, buffer.data(), buffer.size(), 0); + if (n < 12) + return; + + const uint8_t function = buffer[7]; + if (function == 0x05 && n >= 12) + { + const uint16_t address = (static_cast(buffer[8]) << 8) | buffer[9]; + const uint16_t value = (static_cast(buffer[10]) << 8) | buffer[11]; + const int coil_id = modbusAddressToCoilId(address); + std::string err; + writeCoil(coil_id, value == 0xFF00, err); + send(client_fd, buffer.data(), static_cast(n), 0); + } +} + +void ModbusTriggerService::tcpLoop() +{ + const int server_fd = socket(AF_INET, SOCK_STREAM, 0); + if (server_fd < 0) + return; + + int opt = 1; + setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); + + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = INADDR_ANY; + addr.sin_port = htons(static_cast(tcp_port_)); + if (bind(server_fd, reinterpret_cast(&addr), sizeof(addr)) < 0) + { + close(server_fd); + return; + } + if (listen(server_fd, 8) < 0) + { + close(server_fd); + return; + } + + while (!stop_) + { + fd_set fds; + FD_ZERO(&fds); + FD_SET(server_fd, &fds); + timeval tv{0, 200000}; + if (select(server_fd + 1, &fds, nullptr, nullptr, &tv) <= 0) + continue; + const int client = accept(server_fd, nullptr, nullptr); + if (client < 0) + continue; + handleTcpClient(client); + close(client); + } + close(server_fd); +} + +} // namespace lm diff --git a/src/mission/modbus_trigger_service.hpp b/src/mission/modbus_trigger_service.hpp new file mode 100644 index 0000000..1c8776d --- /dev/null +++ b/src/mission/modbus_trigger_service.hpp @@ -0,0 +1,48 @@ +#pragma once + +#include "mission/mission_queue.hpp" +#include "mission/mission_store.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace lm { + +class ModbusTriggerService +{ +public: + using EnqueueFn = std::function; + + ModbusTriggerService(MissionStore& store, EnqueueFn enqueue_fn, int tcp_port = 5502); + ~ModbusTriggerService(); + + ModbusTriggerService(const ModbusTriggerService&) = delete; + ModbusTriggerService& operator=(const ModbusTriggerService&) = delete; + + nlohmann::json coilStates() const; + bool writeCoil(int coil_id, bool value, std::string& err); + bool fireCoil(int coil_id, std::string& err); + +private: + MissionStore& store_; + EnqueueFn enqueue_fn_; + int tcp_port_; + + mutable std::mutex mu_; + std::unordered_map coils_; + std::unordered_map prev_coils_; + + std::atomic stop_{false}; + std::thread tcp_thread_; + + void onCoilRisingEdgeUnlocked(int coil_id); + void tcpLoop(); + void handleTcpClient(int client_fd); +}; + +} // namespace lm diff --git a/src/server/api_mission_routes.cpp b/src/server/api_mission_routes.cpp new file mode 100644 index 0000000..ace1ff6 --- /dev/null +++ b/src/server/api_mission_routes.cpp @@ -0,0 +1,278 @@ +#include "server/api_server.hpp" + +#include "mission/mission_enqueue.hpp" +#include "util/http_util.hpp" + +namespace lm { + +namespace { + +nlohmann::json mirError(const std::string& msg) +{ + return nlohmann::json{{"error", msg}, {"error_code", 400}}; +} + +} // namespace + +bool ApiServer::enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code) +{ + nlohmann::json payload; + std::string err; + if (!MissionEnqueue::buildPayload(mission_store_, request, payload, err)) + { + HttpUtil::jsonError(res, 400, err); + return false; + } + const auto entry = mission_queue_.enqueue(payload, err); + if (!entry) + { + HttpUtil::jsonError(res, 400, err); + return false; + } + HttpUtil::addCors(res); + res.status = status_code; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = entry->dump(); + return true; +} + +nlohmann::json ApiServer::toMirQueueEntry(const nlohmann::json& entry) const +{ + return nlohmann::json{{"id", entry.value("id", 0)}, + {"mission_id", entry.value("mission_id", "")}, + {"state", entry.value("status", "pending")}, + {"message", entry.value("mission_name", "")}, + {"priority", entry.value("priority", 0)}, + {"robot_id", entry.value("robot_id", "default")}}; +} + +void ApiServer::registerMissionRoutes(httplib::Server& svr) +{ + svr.Get("/api/missions", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = mission_store_.snapshot().dump(); + }); + + svr.Put("/api/missions", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + nlohmann::json payload; + try + { + payload = nlohmann::json::parse(req.body); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + std::string err; + if (!mission_store_.replace(payload, err)) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = mission_store_.snapshot().dump(); + }); +} + +void ApiServer::registerIntegrationRoutes(httplib::Server& svr) +{ + svr.Get("/api/triggers", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = mission_store_.listTriggers().dump(); + }); + + svr.Post("/api/triggers", [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 trigger = mission_store_.addTrigger(payload, err); + if (!trigger) + return HttpUtil::jsonError(res, 400, err); + res.status = 201; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = trigger->dump(); + }); + + svr.Delete(R"(/api/triggers/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + std::string err; + if (!mission_store_.deleteTrigger(req.matches[1].str(), err)) + return HttpUtil::jsonError(res, 400, err); + res.status = 204; + }); + + svr.Get("/api/modbus/coils", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = modbus_.coilStates().dump(); + }); + + svr.Put(R"(/api/modbus/coils/([0-9]+))", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + nlohmann::json payload; + try + { + payload = nlohmann::json::parse(req.body.empty() ? "{}" : req.body); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + const int coil_id = std::stoi(req.matches[1].str()); + const bool value = !payload.contains("value") || payload["value"].get(); + std::string err; + if (!modbus_.writeCoil(coil_id, value, err)) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json({{"coil_id", coil_id}, {"value", value}}).dump(); + }); + + svr.Post(R"(/api/modbus/coils/([0-9]+)/trigger)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const int coil_id = std::stoi(req.matches[1].str()); + std::string err; + if (!modbus_.fireCoil(coil_id, err)) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json({{"ok", true}, {"coil_id", coil_id}}).dump(); + }); + + svr.Get("/api/fleet/robots", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = mission_store_.listRobots().dump(); + }); + + svr.Get("/api/fleet/schedules", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = mission_store_.listSchedules().dump(); + }); + + svr.Post("/api/fleet/schedules", [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 schedule = mission_store_.addSchedule(payload, err); + if (!schedule) + return HttpUtil::jsonError(res, 400, err); + if (schedule->value("start_mode", "asap") == "asap" && schedule->value("enabled", true)) + scheduler_.runScheduleNow(schedule->value("id", ""), err); + res.status = 201; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = schedule->dump(); + }); + + svr.Put(R"(/api/fleet/schedules/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + nlohmann::json payload; + try + { + payload = nlohmann::json::parse(req.body); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + std::string err; + if (!mission_store_.updateSchedule(req.matches[1].str(), payload, 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/fleet/schedules/([0-9a-fA-F]+))", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + std::string err; + if (!mission_store_.deleteSchedule(req.matches[1].str(), err)) + return HttpUtil::jsonError(res, 400, err); + res.status = 204; + }); + + svr.Post(R"(/api/fleet/schedules/([0-9a-fA-F]+)/run)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + std::string err; + if (!scheduler_.runScheduleNow(req.matches[1].str(), err)) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json({{"ok", true}}).dump(); + }); +} + +void ApiServer::registerMirV2Routes(httplib::Server& svr) +{ + svr.Get("/api/v2.0.0/missions", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = mission_store_.listMissions().dump(); + }); + + svr.Get("/api/v2.0.0/mission_queue", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + nlohmann::json out = nlohmann::json::array(); + for (const auto& item : mission_queue_.list()) + { + if (item.is_object()) + out.push_back(toMirQueueEntry(item)); + } + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = out.dump(); + }); + + svr.Post("/api/v2.0.0/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"); + } + if (!payload.contains("source")) + payload["source"] = "rest_api_v2"; + if (!enqueueRequest(payload, res, 201)) + return; + nlohmann::json created = nlohmann::json::parse(res.body); + res.body = toMirQueueEntry(created).dump(); + }); + + svr.Delete("/api/v2.0.0/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.Get("/api/v2.0.0/status", [this](const httplib::Request&, httplib::Response& res) { + HttpUtil::addCors(res); + const auto runner = mission_queue_.runnerStatus(); + nlohmann::json body = {{"state_id", runner.value("state", "idle") == "running" ? 3 + : runner.value("state", "") == "paused" ? 4 + : 1}, + {"state_text", runner.value("state", "idle")}, + {"message", runner.value("message", "")}}; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = body.dump(); + }); +} + +} // namespace lm diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp index 6bec843..50a0ea3 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -2,6 +2,7 @@ #include "domain/layout_profile.hpp" #include "domain/layout_schema.hpp" +#include "mission/mission_enqueue.hpp" #include "util/http_util.hpp" #include "util/id_util.hpp" #include "util/string_util.hpp" @@ -9,8 +10,16 @@ namespace lm { -ApiServer::ApiServer(StateRepository& repo, MissionQueue& mission_queue) - : repo_(repo), mission_queue_(mission_queue) +ApiServer::ApiServer(StateRepository& repo, + MissionQueue& mission_queue, + MissionStore& mission_store, + ModbusTriggerService& modbus, + MissionScheduler& scheduler) + : repo_(repo), + mission_queue_(mission_queue), + mission_store_(mission_store), + modbus_(modbus), + scheduler_(scheduler) { } @@ -457,13 +466,9 @@ void ApiServer::registerRoutes(httplib::Server& svr) { 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(); + if (!payload.contains("source")) + payload["source"] = "ui"; + enqueueRequest(payload, res, 201); }); svr.Delete("/api/mission_queue", [this](const httplib::Request&, httplib::Response& res) { @@ -520,6 +525,10 @@ void ApiServer::registerRoutes(httplib::Server& svr) res.set_header("Content-Type", "application/json; charset=utf-8"); res.body = mission_queue_.runnerStatus().dump(); }); + + registerMissionRoutes(svr); + registerIntegrationRoutes(svr); + registerMirV2Routes(svr); } } // namespace lm diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp index 770cb3d..ca98cd8 100644 --- a/src/server/api_server.hpp +++ b/src/server/api_server.hpp @@ -3,6 +3,9 @@ #include #include "mission/mission_queue.hpp" +#include "mission/mission_scheduler.hpp" +#include "mission/mission_store.hpp" +#include "mission/modbus_trigger_service.hpp" #include "storage/state_repository.hpp" namespace lm { @@ -10,13 +13,26 @@ namespace lm { class ApiServer { public: - ApiServer(StateRepository& repo, MissionQueue& mission_queue); + ApiServer(StateRepository& repo, + MissionQueue& mission_queue, + MissionStore& mission_store, + ModbusTriggerService& modbus, + MissionScheduler& scheduler); void registerRoutes(httplib::Server& svr); private: StateRepository& repo_; MissionQueue& mission_queue_; + MissionStore& mission_store_; + ModbusTriggerService& modbus_; + MissionScheduler& scheduler_; + + bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201); + nlohmann::json toMirQueueEntry(const nlohmann::json& entry) const; + void registerMissionRoutes(httplib::Server& svr); + void registerMirV2Routes(httplib::Server& svr); + void registerIntegrationRoutes(httplib::Server& svr); }; } // namespace lm diff --git a/src/util/http_util.cpp b/src/util/http_util.cpp index c970b3a..0151810 100644 --- a/src/util/http_util.cpp +++ b/src/util/http_util.cpp @@ -35,7 +35,7 @@ void HttpUtil::addCors(httplib::Response& res) { res.set_header("Access-Control-Allow-Origin", "*"); res.set_header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS"); - res.set_header("Access-Control-Allow-Headers", "Content-Type"); + res.set_header("Access-Control-Allow-Headers", "Content-Type, Authorization, Accept-Language"); } } // namespace lm diff --git a/www/app.js b/www/app.js index c1cc8f0..35ea719 100644 --- a/www/app.js +++ b/www/app.js @@ -8,6 +8,7 @@ const navItemEls = Array.from(document.querySelectorAll(".navItem[data-page]")); const pageOverviewEl = el("pageOverview"); const pageConfigEl = el("pageConfig"); const pageMissionsEl = el("pageMissions"); +const pageIntegrationsEl = el("pageIntegrations"); const contentEl = document.querySelector(".content"); const contentRightEl = el("contentRight"); const overviewBackendEl = el("overviewBackend"); @@ -120,7 +121,7 @@ const state = { }; function setActivePage(page) { - const valid = ["dashboard", "config", "missions"]; + const valid = ["dashboard", "config", "missions", "integrations"]; let p = valid.includes(page) ? page : "config"; if (page === "overview") p = "dashboard"; navItemEls.forEach((a) => { @@ -129,23 +130,32 @@ function setActivePage(page) { if (on) a.setAttribute("aria-current", "page"); else a.removeAttribute("aria-current"); }); - const titles = { dashboard: "Dashboard", config: "Cấu Hình", missions: "Missions" }; + const titles = { + dashboard: "Dashboard", + config: "Cấu Hình", + missions: "Missions", + integrations: "Tích hợp", + }; if (pageTitleEl) pageTitleEl.textContent = titles[p] || "Cấu Hình"; if (pageOverviewEl) pageOverviewEl.hidden = p !== "dashboard"; if (pageConfigEl) pageConfigEl.hidden = p !== "config"; if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions"; + if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations"; if (configSplitterEl) configSplitterEl.hidden = p !== "config"; if (contentRightEl) contentRightEl.hidden = p !== "config"; if (contentEl) { contentEl.classList.toggle("content--dashboard", p === "dashboard"); contentEl.classList.toggle("content--config", p === "config"); contentEl.classList.toggle("content--missions", p === "missions"); + contentEl.classList.toggle("content--integrations", p === "integrations"); } if (saveLayoutBtn) saveLayoutBtn.hidden = p !== "config"; if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow(); else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide(); if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow(); else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide(); + if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow(); + else if (window.IntegrationsApp?.onPageHide) window.IntegrationsApp.onPageHide(); try { localStorage.setItem("activePage", p); } catch { @@ -164,7 +174,7 @@ function initNavigation() { let initial = "config"; try { const saved = localStorage.getItem("activePage"); - if (saved === "dashboard" || saved === "overview" || saved === "config" || saved === "missions") { + if (saved === "dashboard" || saved === "overview" || saved === "config" || saved === "missions" || saved === "integrations") { initial = saved === "overview" ? "dashboard" : saved; } } catch { diff --git a/www/index.html b/www/index.html index c3bc5cc..cf68348 100644 --- a/www/index.html +++ b/www/index.html @@ -35,6 +35,10 @@ Missions + + + Tích hợp +
@@ -587,6 +591,79 @@
+ +
@@ -773,8 +850,78 @@ + +
+
+

Modbus trigger

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+

Lịch MiRFleet

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+
+ + +
+
+
+ + diff --git a/www/integrations.js b/www/integrations.js new file mode 100644 index 0000000..0049765 --- /dev/null +++ b/www/integrations.js @@ -0,0 +1,444 @@ +(() => { + const COIL_MIN = 1001; + const COIL_MAX = 2000; + + const el = (id) => document.getElementById(id); + const triggerListEl = el("integrationTriggerList"); + const triggerEmptyEl = el("integrationTriggerEmpty"); + const coilGridEl = el("integrationCoilGrid"); + const scheduleListEl = el("integrationScheduleList"); + const scheduleEmptyEl = el("integrationScheduleEmpty"); + const addTriggerDialogEl = el("integrationAddTriggerDialog"); + const addScheduleDialogEl = el("integrationAddScheduleDialog"); + const apiBaseUrlEl = el("integrationApiBaseUrl"); + + const store = { + triggers: [], + schedules: [], + robots: [], + coils: {}, + missions: [], + pollTimer: null, + }; + + function escapeHtml(str) { + return String(str) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function missionName(id) { + const m = store.missions.find((x) => x.id === id); + return m ? m.name : id; + } + + function robotLabel(id) { + const r = store.robots.find((x) => x.id === id); + return r ? r.name || r.id : id || "default"; + } + + async function apiJson(url, opts = {}) { + const res = await fetch(url, opts); + const text = await res.text(); + let data = null; + try { + data = text ? JSON.parse(text) : null; + } catch { + data = null; + } + if (!res.ok) { + const msg = (data && data.error) || text || res.statusText; + throw new Error(msg); + } + return data; + } + + async function refreshMissions() { + try { + const data = await apiJson("/api/missions"); + store.missions = Array.isArray(data.missions) ? data.missions : []; + } catch { + store.missions = window.MissionsApp?.getMissions?.() || []; + } + } + + async function refreshRobots() { + try { + const data = await apiJson("/api/fleet/robots"); + store.robots = Array.isArray(data) ? data : []; + } catch { + store.robots = [{ id: "default", name: "Robot chính" }]; + } + } + + async function refreshTriggers() { + store.triggers = await apiJson("/api/triggers"); + if (!Array.isArray(store.triggers)) store.triggers = []; + } + + async function refreshSchedules() { + store.schedules = await apiJson("/api/fleet/schedules"); + if (!Array.isArray(store.schedules)) store.schedules = []; + } + + async function refreshCoils() { + store.coils = (await apiJson("/api/modbus/coils")) || {}; + } + + function fillMissionSelect(selectEl, selected) { + if (!selectEl) return; + selectEl.innerHTML = ""; + if (!store.missions.length) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "— Chưa có mission —"; + selectEl.appendChild(opt); + return; + } + store.missions.forEach((m) => { + const opt = document.createElement("option"); + opt.value = m.id; + opt.textContent = m.name; + if (m.id === selected) opt.selected = true; + selectEl.appendChild(opt); + }); + } + + function fillRobotSelect(selectEl, selected) { + if (!selectEl) return; + selectEl.innerHTML = ""; + store.robots.forEach((r) => { + const opt = document.createElement("option"); + opt.value = r.id; + opt.textContent = r.name || r.id; + if (r.id === selected) opt.selected = true; + selectEl.appendChild(opt); + }); + if (!store.robots.length) { + const opt = document.createElement("option"); + opt.value = "default"; + opt.textContent = "Robot chính"; + opt.selected = selected === "default"; + selectEl.appendChild(opt); + } + } + + function renderTriggers() { + if (!triggerListEl) return; + triggerListEl.innerHTML = ""; + if (triggerEmptyEl) triggerEmptyEl.hidden = store.triggers.length > 0; + + store.triggers.forEach((t) => { + const row = document.createElement("div"); + row.className = "missionListItem integrationRow"; + const coil = t.coil_id; + const on = store.coils[String(coil)] === true; + row.innerHTML = ` +
+
${escapeHtml(t.name)}
+
+ Coil ${coil} + → ${escapeHtml(missionName(t.mission_id))} + · ${t.enabled === false ? "Tắt" : "Bật"} + · coil hiện tại: ${on ? "ON" : "OFF"} +
+
+
+ + +
`; + triggerListEl.appendChild(row); + }); + } + + function renderCoilGrid() { + if (!coilGridEl) return; + const assigned = new Map(store.triggers.map((t) => [t.coil_id, t])); + const chips = []; + assigned.forEach((t, coilId) => { + const on = store.coils[String(coilId)] === true; + chips.push( + `` + ); + }); + coilGridEl.innerHTML = + chips.length > 0 + ? chips.join("") + : `Chưa gán coil. Thêm trigger bên trên (1001–2000).`; + } + + function formatScheduleTime(s) { + if (!s.start_at) return s.start_mode === "scheduled" ? "—" : "Ngay (asap)"; + try { + return new Date(s.start_at).toLocaleString("vi-VN"); + } catch { + return String(s.start_at); + } + } + + function renderSchedules() { + if (!scheduleListEl) return; + scheduleListEl.innerHTML = ""; + if (scheduleEmptyEl) scheduleEmptyEl.hidden = store.schedules.length > 0; + + store.schedules.forEach((s) => { + const row = document.createElement("div"); + row.className = "missionListItem integrationRow"; + row.innerHTML = ` +
+
${escapeHtml(s.name)}
+
+ ${escapeHtml(missionName(s.mission_id))} + · robot: ${escapeHtml(robotLabel(s.robot_id))} + · ưu tiên ${s.priority ?? 0} + · ${s.start_mode === "scheduled" ? "Lên lịch" : "ASAP"} + · ${formatScheduleTime(s)} + · ${s.enabled === false ? "Tắt" : "Bật"} +
+
+
+ + +
`; + scheduleListEl.appendChild(row); + }); + } + + function updateApiBaseUrl() { + const origin = window.location.origin; + if (apiBaseUrlEl) apiBaseUrlEl.textContent = `${origin}/api/v2.0.0`; + document.querySelectorAll(".integrationCurlHost").forEach((node) => { + node.textContent = origin; + }); + } + + async function refreshAll() { + await refreshMissions(); + await Promise.all([refreshRobots(), refreshTriggers(), refreshSchedules(), refreshCoils()]); + renderTriggers(); + renderCoilGrid(); + renderSchedules(); + updateApiBaseUrl(); + } + + async function openAddTriggerDialog() { + await refreshMissions(); + fillMissionSelect(el("integrationTriggerMission")); + const coilInput = el("integrationTriggerCoil"); + if (coilInput) coilInput.value = "1001"; + el("integrationTriggerName").value = ""; + addTriggerDialogEl?.showModal(); + } + + async function submitAddTrigger(evt) { + evt.preventDefault(); + const name = el("integrationTriggerName").value.trim(); + const coil_id = Number(el("integrationTriggerCoil").value); + const mission_id = el("integrationTriggerMission").value; + if (!name || !mission_id) return; + if (coil_id < COIL_MIN || coil_id > COIL_MAX) { + alert(`Coil phải từ ${COIL_MIN} đến ${COIL_MAX}`); + return; + } + try { + await apiJson("/api/triggers", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, coil_id, mission_id, enabled: true }), + }); + addTriggerDialogEl?.close(); + await refreshAll(); + } catch (err) { + alert(err.message || "Không thêm được trigger"); + } + } + + async function openAddScheduleDialog() { + await Promise.all([refreshMissions(), refreshRobots()]); + fillMissionSelect(el("integrationScheduleMission")); + fillRobotSelect(el("integrationScheduleRobot"), "default"); + el("integrationScheduleName").value = ""; + el("integrationSchedulePriority").value = "0"; + el("integrationScheduleMode").value = "asap"; + el("integrationScheduleStartAt").value = ""; + toggleScheduleStartAt(); + addScheduleDialogEl?.showModal(); + } + + function toggleScheduleStartAt() { + const mode = el("integrationScheduleMode")?.value || "asap"; + const row = el("integrationScheduleStartAtRow"); + if (row) row.hidden = mode !== "scheduled"; + } + + async function submitAddSchedule(evt) { + evt.preventDefault(); + const name = el("integrationScheduleName").value.trim(); + const mission_id = el("integrationScheduleMission").value; + const robot_id = el("integrationScheduleRobot").value || "default"; + const priority = Number(el("integrationSchedulePriority").value) || 0; + const start_mode = el("integrationScheduleMode").value || "asap"; + const startRaw = el("integrationScheduleStartAt").value; + if (!name || !mission_id) return; + const payload = { name, mission_id, robot_id, priority, start_mode, enabled: true }; + if (start_mode === "scheduled" && startRaw) { + payload.start_at = new Date(startRaw).toISOString(); + } + try { + await apiJson("/api/fleet/schedules", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + addScheduleDialogEl?.close(); + await refreshAll(); + } catch (err) { + alert(err.message || "Không thêm được lịch"); + } + } + + async function fireCoil(coilId) { + try { + await apiJson(`/api/modbus/coils/${coilId}/trigger`, { method: "POST" }); + await refreshCoils(); + renderTriggers(); + renderCoilGrid(); + } catch (err) { + alert(err.message || "Không kích hoạt được coil"); + } + } + + async function deleteTrigger(id) { + if (!confirm("Xóa trigger Modbus này?")) return; + try { + await apiJson(`/api/triggers/${id}`, { method: "DELETE" }); + await refreshAll(); + } catch (err) { + alert(err.message || "Không xóa được"); + } + } + + async function deleteSchedule(id) { + if (!confirm("Xóa lịch fleet này?")) return; + try { + await apiJson(`/api/fleet/schedules/${id}`, { method: "DELETE" }); + await refreshAll(); + } catch (err) { + alert(err.message || "Không xóa được"); + } + } + + async function runSchedule(id) { + try { + await apiJson(`/api/fleet/schedules/${id}/run`, { method: "POST" }); + window.MissionsApp?.refreshQueue?.(); + } catch (err) { + alert(err.message || "Không chạy được lịch"); + } + } + + async function testRestEnqueue() { + const missionId = el("integrationRestMission")?.value; + if (!missionId) { + alert("Chọn mission để thử"); + return; + } + try { + const data = await apiJson("/api/v2.0.0/mission_queue", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mission_id: missionId, priority: 0 }), + }); + alert(`Đã thêm vào queue — id ${data.id}`); + window.MissionsApp?.refreshQueue?.(); + } catch (err) { + alert(err.message || "POST thất bại"); + } + } + + function bindEvents() { + el("integrationAddTriggerBtn")?.addEventListener("click", openAddTriggerDialog); + el("integrationAddScheduleBtn")?.addEventListener("click", openAddScheduleDialog); + el("integrationRefreshBtn")?.addEventListener("click", () => refreshAll()); + el("integrationAddTriggerForm")?.addEventListener("submit", submitAddTrigger); + el("integrationAddScheduleForm")?.addEventListener("submit", submitAddSchedule); + el("integrationScheduleMode")?.addEventListener("change", toggleScheduleStartAt); + el("integrationRestTestBtn")?.addEventListener("click", testRestEnqueue); + + triggerListEl?.addEventListener("click", (evt) => { + const coilBtn = evt.target.closest("[data-fire-coil]"); + if (coilBtn) { + fireCoil(Number(coilBtn.getAttribute("data-fire-coil"))); + return; + } + const delBtn = evt.target.closest("[data-delete-trigger]"); + if (delBtn) deleteTrigger(delBtn.getAttribute("data-delete-trigger")); + }); + + coilGridEl?.addEventListener("click", (evt) => { + const btn = evt.target.closest("[data-fire-coil]"); + if (btn) fireCoil(Number(btn.getAttribute("data-fire-coil"))); + }); + + scheduleListEl?.addEventListener("click", (evt) => { + const runBtn = evt.target.closest("[data-run-schedule]"); + if (runBtn) { + runSchedule(runBtn.getAttribute("data-run-schedule")); + return; + } + const delBtn = evt.target.closest("[data-delete-schedule]"); + if (delBtn) deleteSchedule(delBtn.getAttribute("data-delete-schedule")); + }); + + document.querySelectorAll("[data-close-dialog]").forEach((btn) => { + btn.addEventListener("click", () => { + const id = btn.getAttribute("data-close-dialog"); + el(id)?.close(); + }); + }); + } + + function startPoll() { + stopPoll(); + store.pollTimer = setInterval(async () => { + try { + await refreshCoils(); + renderTriggers(); + renderCoilGrid(); + } catch { + /* ignore */ + } + }, 3000); + } + + function stopPoll() { + if (store.pollTimer) { + clearInterval(store.pollTimer); + store.pollTimer = null; + } + } + + async function onPageShow() { + await refreshAll(); + fillMissionSelect(el("integrationRestMission")); + startPoll(); + } + + function init() { + bindEvents(); + updateApiBaseUrl(); + } + + window.IntegrationsApp = { + init, + onPageShow, + onPageHide: stopPoll, + refreshAll, + }; + + init(); +})(); diff --git a/www/missions.js b/www/missions.js index 339c1dc..5e9a64d 100644 --- a/www/missions.js +++ b/www/missions.js @@ -173,7 +173,7 @@ }; } - function loadStore() { + function loadStoreLocal() { try { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return; @@ -186,11 +186,49 @@ ensureDefaultGroups(); } + async function loadStoreFromBackend() { + try { + const res = await fetch("/api/missions"); + if (!res.ok) return false; + const data = await res.json(); + if (Array.isArray(data.missions)) store.missions = data.missions; + if (Array.isArray(data.groups)) store.groups = data.groups; + ensureDefaultGroups(); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ missions: store.missions, groups: store.groups }) + ); + return true; + } catch { + return false; + } + } + + let persistTimer = null; + + async function syncStoreToBackend() { + try { + await fetch("/api/missions", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ missions: store.missions, groups: store.groups }), + }); + } catch { + /* ignore */ + } + } + + function loadStore() { + loadStoreLocal(); + } + function persistStore() { localStorage.setItem( STORAGE_KEY, JSON.stringify({ missions: store.missions, groups: store.groups }) ); + clearTimeout(persistTimer); + persistTimer = setTimeout(syncStoreToBackend, 400); } function ensureDefaultGroups() { @@ -1253,8 +1291,9 @@ el("missionQueueForm")?.addEventListener("submit", submitQueueDialog); } - function init() { + async function init() { loadStore(); + await loadStoreFromBackend(); bindEvents(); renderMissionList(); } diff --git a/www/style.css b/www/style.css index 798be4b..1e50d8b 100644 --- a/www/style.css +++ b/www/style.css @@ -537,7 +537,8 @@ canvas { .viewHint { color: var(--muted); font-size: 12px; width: 100%; } .canvasWrap canvas.edit-footprint { cursor: crosshair; } -.content.content--missions { +.content.content--missions, +.content.content--integrations { grid-template-columns: minmax(0, 1fr); max-width: 1100px; } @@ -960,3 +961,42 @@ canvas { .contentLeft { max-height: none; overflow: visible; } } +.integrationsPage { min-width: 0; width: 100%; display: grid; gap: 16px; } +.integrationRow { cursor: default; } +.integrationHeaderActions { display: flex; gap: 8px; flex-wrap: wrap; } +.integrationCoilSection { margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); } +.integrationSectionLabel { font-size: 12px; font-weight: 600; color: var(--muted); margin-bottom: 8px; } +.integrationCoilGrid { display: flex; flex-wrap: wrap; gap: 8px; } +.integrationCoilChip { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + padding: 6px 10px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel2); + color: var(--text); + cursor: pointer; +} +.integrationCoilChip.on { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 18%, var(--panel2)); +} +.integrationCoilChip:hover { border-color: var(--accent); } +.integrationApiBody { display: grid; gap: 14px; } +.integrationApiBlock { display: grid; gap: 6px; } +.integrationPre { + margin: 0; + padding: 12px 14px; + border-radius: 10px; + border: 1px solid var(--border); + background: var(--panel2); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1.5; + overflow-x: auto; + white-space: pre-wrap; + word-break: break-all; +} +.integrationCode { font-size: 13px; word-break: break-all; } +.integrationTestRow .integrationTestActions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } +.integrationTestRow select { min-width: 220px; }