API mission
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<bool>(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;
|
||||
|
||||
72
src/mission/mission_enqueue.cpp
Normal file
72
src/mission/mission_enqueue.cpp
Normal file
@@ -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<std::string>()] = item["value"];
|
||||
else if (item.contains("key") && item.contains("value"))
|
||||
out[item["key"].get<std::string>()] = 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<std::string>());
|
||||
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<std::string>();
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
22
src/mission/mission_enqueue.hpp
Normal file
22
src/mission/mission_enqueue.hpp
Normal file
@@ -0,0 +1,22 @@
|
||||
#pragma once
|
||||
|
||||
#include "mission/mission_store.hpp"
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
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
|
||||
@@ -137,6 +137,9 @@ std::optional<nlohmann::json> 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<int>() : 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<nlohmann::json> MissionQueue::enqueue(const nlohmann::json& payloa
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
queue_.push_back(entry);
|
||||
insertByPriorityUnlocked(entry);
|
||||
saveUnlocked();
|
||||
}
|
||||
|
||||
@@ -153,6 +156,29 @@ std::optional<nlohmann::json> 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<nlohmann::json::difference_type>(insert_at), entry);
|
||||
}
|
||||
|
||||
bool MissionQueue::removeById(const std::string& id, std::string& err)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mu_);
|
||||
|
||||
@@ -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
|
||||
|
||||
97
src/mission/mission_scheduler.cpp
Normal file
97
src/mission/mission_scheduler.cpp
Normal file
@@ -0,0 +1,97 @@
|
||||
#include "mission/mission_scheduler.hpp"
|
||||
|
||||
#include "util/id_util.hpp"
|
||||
|
||||
#include <chrono>
|
||||
#include <ctime>
|
||||
|
||||
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<std::string>();
|
||||
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
|
||||
38
src/mission/mission_scheduler.hpp
Normal file
38
src/mission/mission_scheduler.hpp
Normal file
@@ -0,0 +1,38 @@
|
||||
#pragma once
|
||||
|
||||
#include "mission/mission_store.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class MissionScheduler
|
||||
{
|
||||
public:
|
||||
using EnqueueFn = std::function<bool(const nlohmann::json& request, std::string& err)>;
|
||||
|
||||
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<bool> stop_{false};
|
||||
std::thread worker_;
|
||||
|
||||
void workerLoop();
|
||||
bool queueSchedule(const nlohmann::json& schedule, std::string& err);
|
||||
void markQueued(const std::string& id);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
346
src/mission/mission_store.cpp
Normal file
346
src/mission/mission_store.cpp
Normal file
@@ -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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> lock(mu_);
|
||||
return data_["missions"];
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> MissionStore::findMission(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::mutex> lock(mu_);
|
||||
return data_["triggers"];
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> 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<int>();
|
||||
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<std::string>();
|
||||
if (!findMission(mission_id))
|
||||
{
|
||||
err = "mission not found";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::lock_guard<std::mutex> 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<std::string>())},
|
||||
{"coil_id", coil_id},
|
||||
{"mission_id", mission_id},
|
||||
{"enabled", !payload.contains("enabled") || payload["enabled"].get<bool>()},
|
||||
{"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<std::mutex> 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<nlohmann::json> MissionStore::findTriggerByCoil(int coil_id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::mutex> lock(mu_);
|
||||
return data_["schedules"];
|
||||
}
|
||||
|
||||
std::optional<nlohmann::json> 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<std::string>();
|
||||
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<std::string>()
|
||||
: "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<std::string>())},
|
||||
{"mission_id", mission_id},
|
||||
{"robot_id", payload.value("robot_id", "default")},
|
||||
{"priority", payload.contains("priority") && payload["priority"].is_number()
|
||||
? payload["priority"].get<int>()
|
||||
: 0},
|
||||
{"start_mode", start_mode},
|
||||
{"start_at", payload.contains("start_at") ? payload["start_at"] : nullptr},
|
||||
{"enabled", !payload.contains("enabled") || payload["enabled"].get<bool>()},
|
||||
{"last_queued_at", nullptr},
|
||||
{"created_at", IdUtil::nowIso8601()}};
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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<nlohmann::json> MissionStore::findSchedule(const std::string& id) const
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<std::mutex> lock(mu_);
|
||||
return data_["robots"];
|
||||
}
|
||||
|
||||
} // namespace lm
|
||||
46
src/mission/mission_store.hpp
Normal file
46
src/mission/mission_store.hpp
Normal file
@@ -0,0 +1,46 @@
|
||||
#pragma once
|
||||
|
||||
#include <nlohmann/json.hpp>
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
|
||||
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<nlohmann::json> findMission(const std::string& id) const;
|
||||
|
||||
nlohmann::json listTriggers() const;
|
||||
std::optional<nlohmann::json> addTrigger(const nlohmann::json& payload, std::string& err);
|
||||
bool deleteTrigger(const std::string& id, std::string& err);
|
||||
std::optional<nlohmann::json> findTriggerByCoil(int coil_id) const;
|
||||
|
||||
nlohmann::json listSchedules() const;
|
||||
std::optional<nlohmann::json> 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<nlohmann::json> 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
|
||||
156
src/mission/modbus_trigger_service.cpp
Normal file
156
src/mission/modbus_trigger_service.cpp
Normal file
@@ -0,0 +1,156 @@
|
||||
#include "mission/modbus_trigger_service.hpp"
|
||||
|
||||
#include <arpa/inet.h>
|
||||
#include <netinet/in.h>
|
||||
#include <sys/socket.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <cstring>
|
||||
#include <vector>
|
||||
|
||||
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<int>(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<std::mutex> 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<std::string> mission_id;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<uint8_t> 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<uint16_t>(buffer[8]) << 8) | buffer[9];
|
||||
const uint16_t value = (static_cast<uint16_t>(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<size_t>(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<uint16_t>(tcp_port_));
|
||||
if (bind(server_fd, reinterpret_cast<sockaddr*>(&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
|
||||
48
src/mission/modbus_trigger_service.hpp
Normal file
48
src/mission/modbus_trigger_service.hpp
Normal file
@@ -0,0 +1,48 @@
|
||||
#pragma once
|
||||
|
||||
#include "mission/mission_queue.hpp"
|
||||
#include "mission/mission_store.hpp"
|
||||
|
||||
#include <atomic>
|
||||
#include <functional>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
#include <unordered_map>
|
||||
|
||||
namespace lm {
|
||||
|
||||
class ModbusTriggerService
|
||||
{
|
||||
public:
|
||||
using EnqueueFn = std::function<bool(const nlohmann::json& request, std::string& err)>;
|
||||
|
||||
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<int, bool> coils_;
|
||||
std::unordered_map<int, bool> prev_coils_;
|
||||
|
||||
std::atomic<bool> stop_{false};
|
||||
std::thread tcp_thread_;
|
||||
|
||||
void onCoilRisingEdgeUnlocked(int coil_id);
|
||||
void tcpLoop();
|
||||
void handleTcpClient(int client_fd);
|
||||
};
|
||||
|
||||
} // namespace lm
|
||||
278
src/server/api_mission_routes.cpp
Normal file
278
src/server/api_mission_routes.cpp
Normal file
@@ -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<bool>();
|
||||
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
|
||||
@@ -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
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
#include <httplib.h>
|
||||
|
||||
#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
|
||||
|
||||
@@ -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
|
||||
|
||||
16
www/app.js
16
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 {
|
||||
|
||||
147
www/index.html
147
www/index.html
@@ -35,6 +35,10 @@
|
||||
<span class="navDot"></span>
|
||||
Missions
|
||||
</a>
|
||||
<a class="navItem" href="#" data-page="integrations">
|
||||
<span class="navDot"></span>
|
||||
Tích hợp
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<div class="sidebarFooter">
|
||||
@@ -587,6 +591,79 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="page" id="pageIntegrations" data-page-content="integrations" hidden>
|
||||
<div class="integrationsPage">
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">Modbus trigger</div>
|
||||
<div class="cardSub">System → Triggers — coil 1001–2000 gắn mission_id. Thiết bị remote bật coil (Modbus TCP :5502) → mission vào queue.</div>
|
||||
</div>
|
||||
<button id="integrationAddTriggerBtn" type="button" class="btn primary">Thêm trigger</button>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="integrationTriggerEmpty" class="mutedNote">Chưa có trigger Modbus.</div>
|
||||
<div id="integrationTriggerList" class="missionList"></div>
|
||||
<div class="integrationCoilSection">
|
||||
<div class="integrationSectionLabel">Coil đã gán (bấm để mô phỏng rising edge)</div>
|
||||
<div id="integrationCoilGrid" class="integrationCoilGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">REST API — MiR v2.0.0</div>
|
||||
<div class="cardSub">Hệ thống bên ngoài POST mission vào queue qua REST.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody integrationApiBody">
|
||||
<div class="row rowWide">
|
||||
<label>Base URL</label>
|
||||
<div id="integrationApiBaseUrl" class="mono integrationCode">—</div>
|
||||
</div>
|
||||
<div class="integrationApiBlock">
|
||||
<div class="integrationSectionLabel">POST /mission_queue</div>
|
||||
<pre class="integrationPre">curl -X POST "<span class="integrationCurlHost">http://localhost:8080</span>/api/v2.0.0/mission_queue" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"mission_id":"<mission_id>","priority":0,"robot_id":"default"}'</pre>
|
||||
</div>
|
||||
<div class="integrationApiBlock">
|
||||
<div class="integrationSectionLabel">GET /mission_queue · GET /missions · GET /status</div>
|
||||
<pre class="integrationPre">GET /api/v2.0.0/mission_queue
|
||||
GET /api/v2.0.0/missions
|
||||
GET /api/v2.0.0/status</pre>
|
||||
</div>
|
||||
<div class="row rowWide integrationTestRow">
|
||||
<label for="integrationRestMission">Thử nhanh</label>
|
||||
<div class="integrationTestActions">
|
||||
<select id="integrationRestMission"></select>
|
||||
<button id="integrationRestTestBtn" type="button" class="btn subtle">POST queue</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="cardHeader">
|
||||
<div>
|
||||
<div class="cardTitle">MiRFleet — Lên lịch mission</div>
|
||||
<div class="cardSub">Ưu tiên, gán robot, chạy ASAP hoặc theo thời gian.</div>
|
||||
</div>
|
||||
<div class="integrationHeaderActions">
|
||||
<button id="integrationRefreshBtn" type="button" class="btn subtle">Tải lại</button>
|
||||
<button id="integrationAddScheduleBtn" type="button" class="btn primary">Thêm lịch</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cardBody">
|
||||
<div id="integrationScheduleEmpty" class="mutedNote">Chưa có lịch fleet.</div>
|
||||
<div id="integrationScheduleList" class="missionList"></div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="configSplitter" class="splitter" role="separator" aria-orientation="vertical" tabindex="0"></div>
|
||||
|
||||
<div class="contentRight" id="contentRight">
|
||||
@@ -773,8 +850,78 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="integrationAddTriggerDialog" class="missionDialog">
|
||||
<form id="integrationAddTriggerForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Modbus trigger</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddTriggerDialog" aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
<label for="integrationTriggerName">Tên trigger</label>
|
||||
<input id="integrationTriggerName" type="text" required placeholder="VD: PLC line 1 start" />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationTriggerCoil">Coil ID</label>
|
||||
<input id="integrationTriggerCoil" type="number" min="1001" max="2000" value="1001" required />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationTriggerMission">Mission</label>
|
||||
<select id="integrationTriggerMission" required></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="integrationAddTriggerDialog">Hủy</button>
|
||||
<button type="submit" class="btn primary">Thêm</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="integrationAddScheduleDialog" class="missionDialog">
|
||||
<form id="integrationAddScheduleForm" method="dialog" class="missionDialogForm">
|
||||
<div class="missionDialogHeader">
|
||||
<h3>Lịch MiRFleet</h3>
|
||||
<button type="button" class="iconBtn missionDialogClose" data-close-dialog="integrationAddScheduleDialog" aria-label="Đóng">×</button>
|
||||
</div>
|
||||
<div class="missionDialogBody">
|
||||
<div class="row rowWide">
|
||||
<label for="integrationScheduleName">Tên lịch</label>
|
||||
<input id="integrationScheduleName" type="text" required placeholder="VD: Ca sáng — đi dock" />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationScheduleMission">Mission</label>
|
||||
<select id="integrationScheduleMission" required></select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationScheduleRobot">Robot</label>
|
||||
<select id="integrationScheduleRobot"></select>
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationSchedulePriority">Ưu tiên</label>
|
||||
<input id="integrationSchedulePriority" type="number" value="0" />
|
||||
</div>
|
||||
<div class="row rowWide">
|
||||
<label for="integrationScheduleMode">Chế độ</label>
|
||||
<select id="integrationScheduleMode">
|
||||
<option value="asap">ASAP — vào queue ngay</option>
|
||||
<option value="scheduled">Scheduled — theo thời gian</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row rowWide" id="integrationScheduleStartAtRow" hidden>
|
||||
<label for="integrationScheduleStartAt">Thời gian bắt đầu</label>
|
||||
<input id="integrationScheduleStartAt" type="datetime-local" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionDialogFooter">
|
||||
<button type="button" class="btn subtle" data-close-dialog="integrationAddScheduleDialog">Hủy</button>
|
||||
<button type="submit" class="btn primary">Thêm</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<script src="/missions.js"></script>
|
||||
<script src="/dashboard.js"></script>
|
||||
<script src="/integrations.js"></script>
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
444
www/integrations.js
Normal file
444
www/integrations.js
Normal file
@@ -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, ">")
|
||||
.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 = `
|
||||
<div>
|
||||
<div class="missionListItemTitle">${escapeHtml(t.name)}</div>
|
||||
<div class="missionListItemMeta">
|
||||
Coil <span class="mono">${coil}</span>
|
||||
→ ${escapeHtml(missionName(t.mission_id))}
|
||||
· ${t.enabled === false ? "Tắt" : "Bật"}
|
||||
· coil hiện tại: <span class="mono">${on ? "ON" : "OFF"}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionListItemActions">
|
||||
<button type="button" class="btn subtle" data-fire-coil="${coil}">Kích hoạt</button>
|
||||
<button type="button" class="btn subtle danger" data-delete-trigger="${escapeHtml(t.id)}">Xóa</button>
|
||||
</div>`;
|
||||
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(
|
||||
`<button type="button" class="integrationCoilChip${on ? " on" : ""}" data-fire-coil="${coilId}" title="${escapeHtml(t.name)}">
|
||||
${coilId}
|
||||
</button>`
|
||||
);
|
||||
});
|
||||
coilGridEl.innerHTML =
|
||||
chips.length > 0
|
||||
? chips.join("")
|
||||
: `<span class="mutedNote">Chưa gán coil. Thêm trigger bên trên (1001–2000).</span>`;
|
||||
}
|
||||
|
||||
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 = `
|
||||
<div>
|
||||
<div class="missionListItemTitle">${escapeHtml(s.name)}</div>
|
||||
<div class="missionListItemMeta">
|
||||
${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"}
|
||||
</div>
|
||||
</div>
|
||||
<div class="missionListItemActions">
|
||||
<button type="button" class="btn subtle" data-run-schedule="${escapeHtml(s.id)}">Chạy ngay</button>
|
||||
<button type="button" class="btn subtle danger" data-delete-schedule="${escapeHtml(s.id)}">Xóa</button>
|
||||
</div>`;
|
||||
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();
|
||||
})();
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user