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

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