API mission

This commit is contained in:
2026-06-13 13:35:00 +07:00
parent 6f6d925fdd
commit 1a8bddb037
23 changed files with 1930 additions and 23 deletions

View File

@@ -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)

View File

@@ -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"
}
}

View File

@@ -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
}
}

View File

@@ -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;

View 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

View 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

View File

@@ -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_);

View File

@@ -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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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 10012000 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":"&lt;mission_id&gt;","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
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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 (10012000).</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();
})();

View File

@@ -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();
}

View File

@@ -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; }