thêm phần paths
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-23 17:51:54 +07:00
parent 50a2587cef
commit 2ce8a23ce9
25 changed files with 1411 additions and 31 deletions

View File

@@ -49,6 +49,9 @@ add_executable(lidar_manager_web
src/io/io_module_service.cpp
src/io/io_zone_runtime.cpp
src/io/io_module_usage.cpp
src/storage/path_store.cpp
src/path/path_planner.cpp
src/path/path_service.cpp
src/storage/dashboard_store.cpp
src/storage/state_repository.cpp
src/validation/sensor_validator.cpp
@@ -66,6 +69,7 @@ add_executable(lidar_manager_web
src/server/api_media_routes.cpp
src/server/api_transition_routes.cpp
src/server/api_io_module_routes.cpp
src/server/api_path_routes.cpp
src/server/api_dashboard_routes.cpp
)

View File

@@ -3,6 +3,8 @@
#include "auth/auth_service.hpp"
#include "io/io_module_service.hpp"
#include "io/io_zone_runtime.hpp"
#include "path/path_service.hpp"
#include "storage/path_store.hpp"
#include "mission/mission_enqueue.hpp"
#include "mission/mission_queue.hpp"
#include "mission/mission_scheduler.hpp"
@@ -70,8 +72,11 @@ int LidarManagerApp::run()
IoModuleStore io_module_store(database);
IoModuleService io_module_service(io_module_store);
IoZoneRuntime io_zone_runtime;
PathStore path_store(database);
PathService path_service(path_store, map_store);
MissionStore mission_store(database);
MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime);
MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime,
path_service);
RobotRuntime robot_runtime(database, mission_queue);
SiteStore site_store(database);
site_store.ensureDefaultSiteId();
@@ -112,6 +117,8 @@ int LidarManagerApp::run()
transition_store,
io_module_store,
io_module_service,
path_store,
path_service,
dashboard_store);
api.registerRoutes(svr);
auth.registerRoutes(svr);

View File

@@ -236,6 +236,7 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
if (path == "/api/robot/active_map")
return "maps";
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 ||
path.rfind("/api/paths", 0) == 0 || path.rfind("/api/transitions", 0) == 0 ||
path.rfind("/api/recordings", 0) == 0)
return "maps";
if (path.rfind("/api/layout", 0) == 0 || path.rfind("/api/lidars", 0) == 0 ||

View File

@@ -2,6 +2,7 @@
#include "io/io_module_service.hpp"
#include "io/io_zone_runtime.hpp"
#include "path/path_service.hpp"
#include "mission/mission_store.hpp"
#include "mission/position_resolver.hpp"
#include "storage/database.hpp"
@@ -93,13 +94,15 @@ MissionQueue::MissionQueue(Database& db,
TransitionStore& transitions,
MissionStore& missions,
IoModuleService& io_modules,
IoZoneRuntime& io_zones)
IoZoneRuntime& io_zones,
PathService& paths)
: db_(db),
maps_(maps),
transitions_(transitions),
missions_(missions),
io_modules_(io_modules),
io_zones_(io_zones),
paths_(paths),
position_resolver_(maps)
{
load();
@@ -153,6 +156,8 @@ void MissionQueue::ensureRunnerDefaults()
runner_["current_action"] = nullptr;
if (!runner_.contains("paused"))
runner_["paused"] = false;
if (!runner_.contains("last_position_id"))
runner_["last_position_id"] = nullptr;
}
void MissionQueue::startWorkerIfNeeded()
@@ -602,6 +607,57 @@ void MissionQueue::runAutoTransitionIfNeeded(const std::string& from_map_id,
}
}
void MissionQueue::completeMoveToPosition(const ResolvedPosition& resolved,
const std::string& label,
nlohmann::json& log)
{
std::string from_id;
{
std::lock_guard<std::recursive_mutex> lock(mu_);
if (runner_.contains("last_position_id") && runner_["last_position_id"].is_string())
from_id = runner_["last_position_id"].get<std::string>();
}
const auto map = maps_.find(resolved.map_id);
const std::string site_id = map ? map->value("site_id", "") : "";
if (!from_id.empty() && from_id != resolved.position_id)
{
std::string path_err;
const nlohmann::json path =
paths_.getOrCreatePath(resolved.map_id, site_id, from_id, resolved.position_id, path_err);
const int pt_count =
path.contains("points") && path["points"].is_array() ? static_cast<int>(path["points"].size()) : 0;
if (!path_err.empty())
{
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "warn"},
{"message", "Path planning: " + path_err}});
}
else
{
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", "Path " + from_id + "" + resolved.position_id + " (" + std::to_string(pt_count) +
" pts)"}});
}
}
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", label + "" + resolved.label}});
sleepMs(1200);
if (cancel_)
throw MissionCancelled();
io_zones_.onRobotAtPosition(db_, maps_, io_modules_, resolved.map_id, resolved.position_id, log);
{
std::lock_guard<std::recursive_mutex> lock(mu_);
runner_["last_position_id"] = resolved.position_id;
runner_["last_map_id"] = resolved.map_id;
saveUnlocked();
}
}
MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions,
const nlohmann::json& parameters,
nlohmann::json& log,
@@ -725,30 +781,7 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j
if (cancel_)
throw MissionCancelled();
}
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", label + "" + resolved->label}});
sleepMs(1200);
if (cancel_)
throw MissionCancelled();
io_zones_.onRobotAtPosition(db_, maps_, io_modules_, resolved->map_id, resolved->position_id, log);
continue;
}
}
if (allow_auto_transition && type == "move_to_position" && !pos_ref.empty())
{
const std::string active_map = getActiveMapIdFromDb(db_);
const auto resolved = position_resolver_.resolve(pos_ref, active_map);
if (resolved)
{
log.push_back({{"ts", IdUtil::nowIso8601()},
{"level", "info"},
{"message", label + "" + resolved->label}});
sleepMs(1200);
if (cancel_)
throw MissionCancelled();
io_zones_.onRobotAtPosition(db_, maps_, io_modules_, resolved->map_id, resolved->position_id, log);
completeMoveToPosition(*resolved, label, log);
continue;
}
}

View File

@@ -17,6 +17,7 @@ class IoModuleService;
class IoZoneRuntime;
class MapStore;
class MissionStore;
class PathService;
class TransitionStore;
class MissionQueue
@@ -27,7 +28,8 @@ public:
TransitionStore& transitions,
MissionStore& missions,
IoModuleService& io_modules,
IoZoneRuntime& io_zones);
IoZoneRuntime& io_zones,
PathService& paths);
~MissionQueue();
MissionQueue(const MissionQueue&) = delete;
@@ -53,6 +55,7 @@ private:
MissionStore& missions_;
IoModuleService& io_modules_;
IoZoneRuntime& io_zones_;
PathService& paths_;
PositionResolver position_resolver_;
mutable std::recursive_mutex mu_;
nlohmann::json queue_;
@@ -82,6 +85,7 @@ private:
int loop_depth);
void sleepMs(int ms);
void setRunnerState(const std::string& state, const std::string& message = "");
void completeMoveToPosition(const ResolvedPosition& resolved, const std::string& label, nlohmann::json& log);
void insertByPriorityUnlocked(nlohmann::json& entry);
};

287
src/path/path_planner.cpp Normal file
View File

@@ -0,0 +1,287 @@
#include "path/path_planner.hpp"
#include <algorithm>
#include <cmath>
#include <limits>
#include <queue>
#include <unordered_map>
namespace lm {
namespace {
constexpr double kInf = std::numeric_limits<double>::infinity();
double mapResolution(const nlohmann::json& map)
{
if (map.contains("resolution") && map["resolution"].is_number())
return std::max(0.01, map["resolution"].get<double>());
return 0.05;
}
double mapImageHeight(const nlohmann::json& map)
{
if (map.contains("height") && map["height"].is_number())
return map["height"].get<double>();
return 1000.0;
}
} // namespace
std::pair<double, double> PathPlanner::worldToPixel(const nlohmann::json& map, double wx, double wy, double img_h)
{
const double res = mapResolution(map);
const double ox = map.value("origin_x", 0.0);
const double oy = map.value("origin_y", 0.0);
return {((wx - ox) / res), (img_h - (wy - oy) / res)};
}
WorldPoint PathPlanner::pixelToWorld(const nlohmann::json& map, double px, double py, double img_h)
{
const double res = mapResolution(map);
const double ox = map.value("origin_x", 0.0);
const double oy = map.value("origin_y", 0.0);
return {ox + px * res, oy + (img_h - py) * res};
}
bool PathPlanner::pointInPolygon(double px, double py, const nlohmann::json& points)
{
if (!points.is_array() || points.size() < 3)
return false;
bool inside = false;
size_t j = points.size() - 1;
for (size_t i = 0; i < points.size(); ++i)
{
const auto& pi = points[i];
const auto& pj = points[j];
if (!pi.is_object() || !pj.is_object())
continue;
const double xi = pi.value("x", 0.0);
const double yi = pi.value("y", 0.0);
const double xj = pj.value("x", 0.0);
const double yj = pj.value("y", 0.0);
const bool intersect = ((yi > py) != (yj > py)) && (px < (xj - xi) * (py - yi) / ((yj - yi) + 1e-12) + xi);
if (intersect)
inside = !inside;
j = i;
}
return inside;
}
bool PathPlanner::pointNearPolyline(double px, double py, const nlohmann::json& points, double half_width)
{
if (!points.is_array() || points.size() < 2)
return false;
const double limit = half_width + 4.0;
for (size_t j = 0; j + 1 < points.size(); ++j)
{
const auto& p1 = points[j];
const auto& p2 = points[j + 1];
if (!p1.is_object() || !p2.is_object())
continue;
const double x1 = p1.value("x", 0.0);
const double y1 = p1.value("y", 0.0);
const double x2 = p2.value("x", 0.0);
const double y2 = p2.value("y", 0.0);
const double dx = x2 - x1;
const double dy = y2 - y1;
const double len_sq = dx * dx + dy * dy;
double dist = 0;
if (len_sq == 0)
dist = std::hypot(px - x1, py - y1);
else
{
double t = ((px - x1) * dx + (py - y1) * dy) / len_sq;
t = std::clamp(t, 0.0, 1.0);
dist = std::hypot(px - (x1 + t * dx), py - (y1 + t * dy));
}
if (dist <= limit)
return true;
}
return false;
}
double PathPlanner::zoneTraversalCost(const nlohmann::json& zones, double px, double py, const nlohmann::json& map)
{
if (!zones.is_array())
return 1.0;
for (auto it = zones.rbegin(); it != zones.rend(); ++it)
{
const auto& z = *it;
if (!z.is_object())
continue;
const std::string type = z.value("type", "");
if (type != "forbidden" && type != "preferred" && type != "unpreferred")
continue;
const auto& points = z.value("points", nlohmann::json::array());
bool hit = false;
if (z.value("geometry", "") == "line")
{
double half = 8.0;
if (z.contains("line_width_cm") && z["line_width_cm"].is_number())
{
const double res = mapResolution(map);
half = std::max(4.0, z["line_width_cm"].get<double>() / (res * 100.0) / 2.0);
}
hit = pointNearPolyline(px, py, points, half);
}
else
{
hit = pointInPolygon(px, py, points);
}
if (!hit)
continue;
if (type == "forbidden")
return kInf;
if (type == "unpreferred")
return 4.0;
if (type == "preferred")
return 0.35;
}
return 1.0;
}
double PathPlanner::cellCost(const nlohmann::json& map, double wx, double wy, double img_h)
{
const auto px_py = worldToPixel(map, wx, wy, img_h);
const auto& zones = map.value("zones", nlohmann::json::array());
return zoneTraversalCost(zones, px_py.first, px_py.second, map);
}
std::optional<WorldPoint> PathPlanner::positionWorld(const nlohmann::json& map, const std::string& position_id)
{
const auto& zones = map.value("zones", nlohmann::json::array());
if (!zones.is_array())
return std::nullopt;
for (const auto& z : zones)
{
if (!z.is_object() || z.value("type", "") != "position")
continue;
if (z.value("id", "") != position_id)
continue;
return WorldPoint{z.value("x", 0.0), z.value("y", 0.0)};
}
return std::nullopt;
}
nlohmann::json PathPlanner::plan(const nlohmann::json& map, const WorldPoint& from, const WorldPoint& to)
{
nlohmann::json fallback = nlohmann::json::array();
fallback.push_back({{"x", from.x}, {"y", from.y}});
fallback.push_back({{"x", to.x}, {"y", to.y}});
const double img_h = mapImageHeight(map);
const double res = mapResolution(map);
const double cell_m = std::max(res * 4.0, 0.2);
const double min_x = std::min(from.x, to.x) - 2.0;
const double max_x = std::max(from.x, to.x) + 2.0;
const double min_y = std::min(from.y, to.y) - 2.0;
const double max_y = std::max(from.y, to.y) + 2.0;
const int cols = std::clamp(static_cast<int>(std::ceil((max_x - min_x) / cell_m)) + 1, 8, 120);
const int rows = std::clamp(static_cast<int>(std::ceil((max_y - min_y) / cell_m)) + 1, 8, 120);
auto toCell = [&](double wx, double wy) -> GridCell {
const int cx = std::clamp(static_cast<int>(std::round((wx - min_x) / cell_m)), 0, cols - 1);
const int cy = std::clamp(static_cast<int>(std::round((wy - min_y) / cell_m)), 0, rows - 1);
return {cx, cy};
};
auto cellWorld = [&](int cx, int cy) -> WorldPoint {
return {min_x + cx * cell_m, min_y + cy * cell_m};
};
const GridCell start = toCell(from.x, from.y);
const GridCell goal = toCell(to.x, to.y);
const auto key = [](const GridCell& c) { return std::to_string(c.x) + "," + std::to_string(c.y); };
std::vector<double> costs(static_cast<size_t>(cols * rows), kInf);
std::vector<GridCell> parent(static_cast<size_t>(cols * rows), {-1, -1});
auto idx = [&](int x, int y) { return y * cols + x; };
struct Node
{
double f;
int x;
int y;
};
auto cmp = [](const Node& a, const Node& b) { return a.f > b.f; };
std::priority_queue<Node, std::vector<Node>, decltype(cmp)> open(cmp);
const auto start_cost = cellCost(map, from.x, from.y, img_h);
if (!std::isfinite(start_cost))
return fallback;
costs[static_cast<size_t>(idx(start.x, start.y))] = 0;
open.push({0, start.x, start.y});
const int dx[8] = {1, -1, 0, 0, 1, 1, -1, -1};
const int dy[8] = {0, 0, 1, -1, 1, -1, 1, -1};
bool found = false;
while (!open.empty())
{
const Node cur = open.top();
open.pop();
if (cur.x == goal.x && cur.y == goal.y)
{
found = true;
break;
}
const size_t cur_i = static_cast<size_t>(idx(cur.x, cur.y));
if (cur.f > costs[cur_i] + 1e-6)
continue;
for (int k = 0; k < 8; ++k)
{
const int nx = cur.x + dx[k];
const int ny = cur.y + dy[k];
if (nx < 0 || ny < 0 || nx >= cols || ny >= rows)
continue;
const WorldPoint wp = cellWorld(nx, ny);
const double step = cellCost(map, wp.x, wp.y, img_h);
if (!std::isfinite(step))
continue;
const double move = (k < 4) ? 1.0 : 1.414;
const size_t ni = static_cast<size_t>(idx(nx, ny));
const double next = costs[cur_i] + step * move;
if (next + 1e-9 < costs[ni])
{
costs[ni] = next;
parent[ni] = {cur.x, cur.y};
const double h = std::hypot(nx - goal.x, ny - goal.y);
open.push({next + h, nx, ny});
}
}
}
if (!found)
return fallback;
std::vector<GridCell> cells;
GridCell c = goal;
while (c.x >= 0)
{
cells.push_back(c);
if (c.x == start.x && c.y == start.y)
break;
const size_t pi = static_cast<size_t>(idx(c.x, c.y));
c = parent[pi];
if (c.x < 0)
break;
}
std::reverse(cells.begin(), cells.end());
nlohmann::json out = nlohmann::json::array();
out.push_back({{"x", from.x}, {"y", from.y}});
for (size_t i = 1; i + 1 < cells.size(); ++i)
{
const WorldPoint wp = cellWorld(cells[i].x, cells[i].y);
out.push_back({{"x", wp.x}, {"y", wp.y}});
}
out.push_back({{"x", to.x}, {"y", to.y}});
return out;
}
} // namespace lm

41
src/path/path_planner.hpp Normal file
View File

@@ -0,0 +1,41 @@
#pragma once
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
#include <utility>
#include <vector>
namespace lm {
struct WorldPoint
{
double x = 0;
double y = 0;
};
class PathPlanner
{
public:
static std::optional<WorldPoint> positionWorld(const nlohmann::json& map, const std::string& position_id);
static nlohmann::json plan(const nlohmann::json& map,
const WorldPoint& from,
const WorldPoint& to);
private:
struct GridCell
{
int x = 0;
int y = 0;
};
static double cellCost(const nlohmann::json& map, double wx, double wy, double img_h);
static std::pair<double, double> worldToPixel(const nlohmann::json& map, double wx, double wy, double img_h);
static WorldPoint pixelToWorld(const nlohmann::json& map, double px, double py, double img_h);
static bool pointInPolygon(double px, double py, const nlohmann::json& points);
static bool pointNearPolyline(double px, double py, const nlohmann::json& points, double half_width);
static double zoneTraversalCost(const nlohmann::json& zones, double px, double py, const nlohmann::json& map);
};
} // namespace lm

104
src/path/path_service.cpp Normal file
View File

@@ -0,0 +1,104 @@
#include "path/path_service.hpp"
#include "path/path_planner.hpp"
#include "storage/map_store.hpp"
#include "storage/path_store.hpp"
#include <cmath>
#include <unordered_map>
namespace lm {
PathService::PathService(PathStore& paths, MapStore& maps) : paths_(paths), maps_(maps) {}
nlohmann::json PathService::getOrCreatePath(const std::string& map_id,
const std::string& site_id,
const std::string& from_position_id,
const std::string& to_position_id,
std::string& err)
{
if (auto cached = paths_.findBetween(map_id, from_position_id, to_position_id))
return *cached;
const auto map = maps_.find(map_id);
if (!map)
{
err = "map not found";
return nlohmann::json::object();
}
const auto from = PathPlanner::positionWorld(*map, from_position_id);
const auto to = PathPlanner::positionWorld(*map, to_position_id);
if (!from || !to)
{
err = "position not found on map";
return nlohmann::json::object();
}
const nlohmann::json points = PathPlanner::plan(*map, *from, *to);
nlohmann::json payload = {{"site_id", site_id.empty() ? map->value("site_id", "") : site_id},
{"map_id", map_id},
{"from_position_id", from_position_id},
{"to_position_id", to_position_id},
{"points", points},
{"auto_created", true}};
const auto saved = paths_.upsert(payload, err);
return saved ? *saved : nlohmann::json::object();
}
nlohmann::json PathService::pathsBetweenPositions(const std::string& map_id,
const std::string& from_position_id,
const std::string& to_position_id) const
{
if (auto hit = paths_.findBetween(map_id, from_position_id, to_position_id))
return *hit;
return nlohmann::json::object();
}
void PathService::invalidateMapPositions(const nlohmann::json& old_map, const nlohmann::json& new_map)
{
if (!old_map.is_object() || !new_map.is_object())
return;
const std::string map_id = new_map.value("id", old_map.value("id", ""));
if (map_id.empty())
return;
auto positionSnapshot = [](const nlohmann::json& map) {
std::unordered_map<std::string, std::pair<double, double>> out;
const auto& zones = map.value("zones", nlohmann::json::array());
if (!zones.is_array())
return out;
for (const auto& z : zones)
{
if (!z.is_object() || z.value("type", "") != "position")
continue;
const std::string id = z.value("id", "");
if (id.empty())
continue;
out[id] = {z.value("x", 0.0), z.value("y", 0.0)};
}
return out;
};
const auto before = positionSnapshot(old_map);
const auto after = positionSnapshot(new_map);
for (const auto& [id, coords] : after)
{
const auto it = before.find(id);
if (it == before.end())
{
paths_.removeForPosition(map_id, id);
continue;
}
const double eps = 1e-4;
if (std::abs(it->second.first - coords.first) > eps || std::abs(it->second.second - coords.second) > eps)
paths_.removeForPosition(map_id, id);
}
for (const auto& [id, _] : before)
{
if (after.find(id) == after.end())
paths_.removeForPosition(map_id, id);
}
}
} // namespace lm

35
src/path/path_service.hpp Normal file
View File

@@ -0,0 +1,35 @@
#pragma once
#include <nlohmann/json.hpp>
#include <optional>
#include <string>
namespace lm {
class MapStore;
class PathStore;
class PathService
{
public:
PathService(PathStore& paths, MapStore& maps);
nlohmann::json getOrCreatePath(const std::string& map_id,
const std::string& site_id,
const std::string& from_position_id,
const std::string& to_position_id,
std::string& err);
void invalidateMapPositions(const nlohmann::json& old_map, const nlohmann::json& new_map);
nlohmann::json pathsBetweenPositions(const std::string& map_id,
const std::string& from_position_id,
const std::string& to_position_id) const;
private:
PathStore& paths_;
MapStore& maps_;
};
} // namespace lm

View File

@@ -87,6 +87,7 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
{"maps", maps},
{"transitions", transition_store_.list(site_id)},
{"io_modules", io_module_store_.list(site_id)},
{"paths", path_store_.list(site_id)},
{"sounds", sound_store_.list()}};
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = bundle.dump(2);
@@ -112,6 +113,7 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
nlohmann::json imported = {{"maps", nlohmann::json::array()},
{"io_modules", nlohmann::json::array()},
{"paths", nlohmann::json::array()},
{"transitions", nlohmann::json::array()}};
std::unordered_map<std::string, std::string> map_id_remap;
@@ -159,6 +161,26 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
}
}
if (bundle.contains("paths") && bundle["paths"].is_array())
{
for (const auto& path : bundle["paths"])
{
if (!path.is_object())
continue;
nlohmann::json payload = path;
payload.erase("id");
payload["site_id"] = site_id;
const std::string old_map = payload.value("map_id", "");
if (map_id_remap.count(old_map))
payload["map_id"] = map_id_remap[old_map];
std::string err;
const auto saved = path_store_.upsert(payload, err);
if (!saved)
return HttpUtil::jsonError(res, 400, "path import failed: " + err);
imported["paths"].push_back(*saved);
}
}
if (bundle.contains("transitions") && bundle["transitions"].is_array())
{
for (const auto& tr : bundle["transitions"])
@@ -240,10 +262,13 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
{
return HttpUtil::jsonError(res, 400, "invalid JSON");
}
const auto existing = map_store_.find(id);
std::string err;
if (!map_store_.update(id, body, err))
return HttpUtil::jsonError(res, 404, err);
const auto updated = map_store_.find(id);
if (existing && updated)
path_service_.invalidateMapPositions(*existing, *updated);
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = updated ? updated->dump() : nlohmann::json::object().dump();
});

View File

@@ -0,0 +1,36 @@
#include "server/api_server.hpp"
#include "util/http_util.hpp"
namespace lm {
void ApiServer::registerPathRoutes(httplib::Server& svr)
{
svr.Get("/api/paths", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string site_id = req.has_param("site_id") ? req.get_param_value("site_id") : "";
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = nlohmann::json({{"paths", path_store_.list(site_id)}}).dump();
});
svr.Get(R"(/api/paths/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
const auto path = path_store_.find(id);
if (!path)
return HttpUtil::jsonError(res, 404, "path not found");
res.set_header("Content-Type", "application/json; charset=utf-8");
res.body = path->dump();
});
svr.Delete(R"(/api/paths/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) {
HttpUtil::addCors(res);
const std::string id = req.matches[1];
std::string err;
if (!path_store_.remove(id, err))
return HttpUtil::jsonError(res, 404, err);
res.status = 204;
});
}
} // namespace lm

View File

@@ -22,6 +22,8 @@ ApiServer::ApiServer(StateRepository& repo,
TransitionStore& transition_store,
IoModuleStore& io_module_store,
IoModuleService& io_module_service,
PathStore& path_store,
PathService& path_service,
DashboardStore& dashboard_store)
: repo_(repo),
mission_queue_(mission_queue),
@@ -35,6 +37,8 @@ ApiServer::ApiServer(StateRepository& repo,
transition_store_(transition_store),
io_module_store_(io_module_store),
io_module_service_(io_module_service),
path_store_(path_store),
path_service_(path_service),
dashboard_store_(dashboard_store)
{
}
@@ -558,6 +562,7 @@ void ApiServer::registerRoutes(httplib::Server& svr)
registerMediaRoutes(svr);
registerTransitionRoutes(svr);
registerIoModuleRoutes(svr);
registerPathRoutes(svr);
registerDashboardRoutes(svr);
}

View File

@@ -13,6 +13,8 @@
#include "storage/sound_store.hpp"
#include "storage/io_module_store.hpp"
#include "io/io_module_service.hpp"
#include "storage/path_store.hpp"
#include "path/path_service.hpp"
#include "storage/transition_store.hpp"
#include "storage/state_repository.hpp"
@@ -33,6 +35,8 @@ public:
TransitionStore& transition_store,
IoModuleStore& io_module_store,
IoModuleService& io_module_service,
PathStore& path_store,
PathService& path_service,
DashboardStore& dashboard_store);
void registerRoutes(httplib::Server& svr);
@@ -50,6 +54,8 @@ private:
TransitionStore& transition_store_;
IoModuleStore& io_module_store_;
IoModuleService& io_module_service_;
PathStore& path_store_;
PathService& path_service_;
DashboardStore& dashboard_store_;
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
@@ -62,6 +68,7 @@ private:
void registerMediaRoutes(httplib::Server& svr);
void registerTransitionRoutes(httplib::Server& svr);
void registerIoModuleRoutes(httplib::Server& svr);
void registerPathRoutes(httplib::Server& svr);
void registerDashboardRoutes(httplib::Server& svr);
};

View File

@@ -122,6 +122,21 @@ CREATE TABLE IF NOT EXISTS io_modules (
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS paths (
id TEXT PRIMARY KEY,
site_id TEXT NOT NULL,
map_id TEXT NOT NULL,
from_position_id TEXT NOT NULL,
to_position_id TEXT NOT NULL,
auto_created INTEGER NOT NULL DEFAULT 1,
points_json TEXT NOT NULL DEFAULT '[]',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(map_id, from_position_id, to_position_id),
FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE,
FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS dashboards (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
@@ -431,6 +446,29 @@ bool Database::applySchemaMigrations(std::string& err)
setMeta("schema_version", "7");
}
ver = getMeta("schema_version").value_or("1");
if (ver == "7")
{
if (!execSql(db_,
"CREATE TABLE IF NOT EXISTS paths ("
"id TEXT PRIMARY KEY, "
"site_id TEXT NOT NULL, "
"map_id TEXT NOT NULL, "
"from_position_id TEXT NOT NULL, "
"to_position_id TEXT NOT NULL, "
"auto_created INTEGER NOT NULL DEFAULT 1, "
"points_json TEXT NOT NULL DEFAULT '[]', "
"created_at TEXT NOT NULL, "
"updated_at TEXT NOT NULL, "
"UNIQUE(map_id, from_position_id, to_position_id), "
"FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE, "
"FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE CASCADE"
")",
err))
return false;
setMeta("schema_version", "8");
}
return true;
}

256
src/storage/path_store.cpp Normal file
View File

@@ -0,0 +1,256 @@
#include "storage/path_store.hpp"
#include "storage/database.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
#include <sqlite3.h>
namespace lm {
namespace {
nlohmann::json rowToJson(sqlite3_stmt* stmt)
{
auto text = [&](int col) -> std::string {
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
return "";
const char* v = reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
return v ? std::string(v) : "";
};
nlohmann::json points = nlohmann::json::array();
try
{
const std::string raw = text(6);
if (!raw.empty())
points = nlohmann::json::parse(raw);
}
catch (...)
{
points = nlohmann::json::array();
}
return {{"id", text(0)},
{"site_id", text(1)},
{"map_id", text(2)},
{"from_position_id", text(3)},
{"to_position_id", text(4)},
{"auto_created", sqlite3_column_int(stmt, 5) != 0},
{"points", points.is_array() ? points : nlohmann::json::array()},
{"created_at", text(7)},
{"updated_at", text(8)}};
}
constexpr const char* kSelect =
"SELECT id, site_id, map_id, from_position_id, to_position_id, auto_created, points_json, created_at, updated_at "
"FROM paths";
} // namespace
PathStore::PathStore(Database& db) : db_(db) {}
nlohmann::json PathStore::list(const std::string& site_id) const
{
std::lock_guard<std::mutex> lock(mu_);
nlohmann::json out = nlohmann::json::array();
std::string sql = kSelect;
if (!site_id.empty())
sql += " WHERE site_id = ?1";
sql += " ORDER BY map_id, from_position_id, to_position_id";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return out;
if (!site_id.empty())
sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT);
while (sqlite3_step(stmt) == SQLITE_ROW)
out.push_back(rowToJson(stmt));
sqlite3_finalize(stmt);
return out;
}
std::optional<nlohmann::json> PathStore::find(const std::string& id) const
{
std::lock_guard<std::mutex> lock(mu_);
const std::string sql = std::string(kSelect) + " WHERE id = ?1";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return std::nullopt;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
std::optional<nlohmann::json> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
out = rowToJson(stmt);
sqlite3_finalize(stmt);
return out;
}
std::optional<nlohmann::json> PathStore::findBetween(const std::string& map_id,
const std::string& from_position_id,
const std::string& to_position_id) const
{
if (map_id.empty() || from_position_id.empty() || to_position_id.empty())
return std::nullopt;
std::lock_guard<std::mutex> lock(mu_);
const std::string sql = std::string(kSelect) +
" WHERE map_id = ?1 AND from_position_id = ?2 AND to_position_id = ?3 LIMIT 1";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return std::nullopt;
sqlite3_bind_text(stmt, 1, map_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, from_position_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, to_position_id.c_str(), -1, SQLITE_TRANSIENT);
std::optional<nlohmann::json> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
out = rowToJson(stmt);
sqlite3_finalize(stmt);
return out;
}
std::optional<nlohmann::json> PathStore::upsert(const nlohmann::json& payload, std::string& err)
{
if (!payload.is_object())
{
err = "payload must be an object";
return std::nullopt;
}
const std::string site_id = StringUtil::trimCopy(payload.value("site_id", ""));
const std::string map_id = StringUtil::trimCopy(payload.value("map_id", ""));
const std::string from_id = StringUtil::trimCopy(payload.value("from_position_id", ""));
const std::string to_id = StringUtil::trimCopy(payload.value("to_position_id", ""));
if (site_id.empty() || map_id.empty() || from_id.empty() || to_id.empty())
{
err = "missing required fields";
return std::nullopt;
}
if (from_id == to_id)
{
err = "from and to positions must differ";
return std::nullopt;
}
const auto& points = payload.contains("points") && payload["points"].is_array() ? payload["points"]
: nlohmann::json::array();
const std::string points_json = points.dump();
const bool auto_created = payload.value("auto_created", true);
const std::string now = IdUtil::nowIso8601();
std::string id = payload.value("id", IdUtil::newId());
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* lookup = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"SELECT id FROM paths WHERE map_id = ?1 AND from_position_id = ?2 AND to_position_id = ?3",
-1,
&lookup,
nullptr) == SQLITE_OK)
{
sqlite3_bind_text(lookup, 1, map_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(lookup, 2, from_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(lookup, 3, to_id.c_str(), -1, SQLITE_TRANSIENT);
if (sqlite3_step(lookup) == SQLITE_ROW)
{
const char* existing = reinterpret_cast<const char*>(sqlite3_column_text(lookup, 0));
if (existing)
id = existing;
}
sqlite3_finalize(lookup);
}
}
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"INSERT INTO paths(id, site_id, map_id, from_position_id, to_position_id, auto_created, "
"points_json, created_at, updated_at) "
"VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9) "
"ON CONFLICT(map_id, from_position_id, to_position_id) DO UPDATE SET "
"points_json=excluded.points_json, auto_created=excluded.auto_created, updated_at=excluded.updated_at",
-1,
&stmt,
nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return std::nullopt;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 3, map_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 4, from_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 5, to_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_int(stmt, 6, auto_created ? 1 : 0);
sqlite3_bind_text(stmt, 7, points_json.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 9, now.c_str(), -1, SQLITE_TRANSIENT);
const bool ok = sqlite3_step(stmt) == SQLITE_DONE;
sqlite3_finalize(stmt);
if (!ok)
{
err = sqlite3_errmsg(db_.handle());
return std::nullopt;
}
sqlite3_stmt* get_stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), (std::string(kSelect) + " WHERE id = ?1").c_str(), -1, &get_stmt, nullptr) !=
SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return std::nullopt;
}
sqlite3_bind_text(get_stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
std::optional<nlohmann::json> out;
if (sqlite3_step(get_stmt) == SQLITE_ROW)
out = rowToJson(get_stmt);
sqlite3_finalize(get_stmt);
return out;
}
bool PathStore::remove(const std::string& id, std::string& err)
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM paths WHERE id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
{
err = sqlite3_errmsg(db_.handle());
return false;
}
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
const bool ok = sqlite3_changes(db_.handle()) > 0;
sqlite3_finalize(stmt);
if (!ok)
err = "path not found";
return ok;
}
int PathStore::removeForMap(const std::string& map_id)
{
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM paths WHERE map_id = ?1", -1, &stmt, nullptr) != SQLITE_OK)
return 0;
sqlite3_bind_text(stmt, 1, map_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
const int n = sqlite3_changes(db_.handle());
sqlite3_finalize(stmt);
return n;
}
int PathStore::removeForPosition(const std::string& map_id, const std::string& position_id)
{
if (map_id.empty() || position_id.empty())
return 0;
std::lock_guard<std::mutex> lock(mu_);
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(),
"DELETE FROM paths WHERE map_id = ?1 AND (from_position_id = ?2 OR to_position_id = ?2)",
-1,
&stmt,
nullptr) != SQLITE_OK)
return 0;
sqlite3_bind_text(stmt, 1, map_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_bind_text(stmt, 2, position_id.c_str(), -1, SQLITE_TRANSIENT);
sqlite3_step(stmt);
const int n = sqlite3_changes(db_.handle());
sqlite3_finalize(stmt);
return n;
}
} // namespace lm

View File

@@ -0,0 +1,33 @@
#pragma once
#include <nlohmann/json.hpp>
#include <mutex>
#include <optional>
#include <string>
namespace lm {
class Database;
class PathStore
{
public:
explicit PathStore(Database& db);
nlohmann::json list(const std::string& site_id = "") const;
std::optional<nlohmann::json> find(const std::string& id) const;
std::optional<nlohmann::json> findBetween(const std::string& map_id,
const std::string& from_position_id,
const std::string& to_position_id) const;
std::optional<nlohmann::json> upsert(const nlohmann::json& payload, std::string& err);
bool remove(const std::string& id, std::string& err);
int removeForMap(const std::string& map_id);
int removeForPosition(const std::string& map_id, const std::string& position_id);
private:
Database& db_;
mutable std::mutex mu_;
};
} // namespace lm

View File

@@ -15,6 +15,7 @@ const pageTransitionsEl = el("pageTransitions");
const pageUsersEl = el("pageUsers");
const pageUserGroupsEl = el("pageUserGroups");
const pageIoModulesEl = el("pageIoModules");
const pagePathsEl = el("pagePaths");
const pageMonitoringEl = el("pageMonitoring");
const pageHelpEl = el("pageHelp");
const contentEl = document.querySelector(".content");
@@ -129,7 +130,7 @@ const state = {
};
function setActivePage(page) {
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "users", "integrations", "monitoring", "help"];
const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "paths", "users", "integrations", "monitoring", "help"];
let p = valid.includes(page) ? page : "missions";
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
@@ -144,6 +145,7 @@ function setActivePage(page) {
if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions";
if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups";
if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules";
if (pagePathsEl) pagePathsEl.hidden = p !== "paths";
if (pageUsersEl) pageUsersEl.hidden = p !== "users";
if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
@@ -159,6 +161,7 @@ function setActivePage(page) {
contentEl.classList.toggle("content--transitions", p === "transitions");
contentEl.classList.toggle("content--user-groups", p === "user-groups");
contentEl.classList.toggle("content--io-modules", p === "io-modules");
contentEl.classList.toggle("content--paths", p === "paths");
contentEl.classList.toggle("content--users", p === "users");
contentEl.classList.toggle("content--integrations", p === "integrations");
contentEl.classList.toggle("content--monitoring", p === "monitoring");
@@ -175,6 +178,8 @@ function setActivePage(page) {
else if (window.UserGroupsApp?.onPageHide) window.UserGroupsApp.onPageHide();
if (p === "io-modules" && window.IoModulesApp) window.IoModulesApp.onPageShow();
else if (window.IoModulesApp?.onPageHide) window.IoModulesApp.onPageHide();
if (p === "paths" && window.PathsApp) window.PathsApp.onPageShow();
else if (window.PathsApp?.onPageHide) window.PathsApp.onPageHide();
if (p === "users" && window.UsersApp) window.UsersApp.onPageShow();
else if (window.UsersApp?.onPageHide) window.UsersApp.onPageHide();
if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();

View File

@@ -153,6 +153,7 @@
transitions: "maps",
"user-groups": "users",
"io-modules": "integrations",
paths: "maps",
users: "users",
integrations: "integrations",
};
@@ -177,6 +178,7 @@
document.body.classList.toggle("auth-readonly-users", !canWrite("users"));
document.body.classList.toggle("auth-readonly-user-groups", !canWrite("users"));
document.body.classList.toggle("auth-readonly-io-modules", !canWrite("integrations"));
document.body.classList.toggle("auth-readonly-paths", !canWrite("maps"));
}
function updateUserMenu() {

View File

@@ -76,6 +76,7 @@
"nav.transitions": "Transitions",
"nav.user-groups": "User groups",
"nav.io-modules": "I/O modules",
"nav.paths": "Paths",
"nav.users": "Users",
"nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log",
@@ -317,7 +318,7 @@
"maps.view": "Xem",
"maps.importComingSoon": "Import site sẽ có trong phiên bản sau.",
"maps.importNoSite": "Chọn site trước khi import.",
"maps.importSuccess": "Import xong: {maps} map(s), {io} I/O module(s), {transitions} transition(s).",
"maps.importSuccess": "Import xong: {maps} map(s), {io} I/O module(s), {transitions} transition(s), {paths} path(s).",
"maps.helpTitle": "Trợ giúp Maps",
"maps.helpText": "Tạo map mới, upload ảnh PNG qua menu ⋮, sau đó kích hoạt map cho robot.",
"maps.createDialog.title": "Tạo map",
@@ -401,6 +402,7 @@
"maps.editor.canvasTip": "Kéo map để di chuyển vùng nhìn hoặc dùng nút zoom in/out để phóng to/thu nhỏ.",
"maps.editor.unsaved": "Chưa lưu",
"maps.editor.unsavedLeave": "Có thay đổi chưa lưu. Rời editor?",
"maps.editor.preferredPathWarning": "Map này có path đã lưu. Thay đổi preferred zone có thể không áp dụng cho đến khi bạn xóa các path liên quan trong Setup → Paths. Tiếp tục lưu?",
"maps.editor.menu": "Menu",
"maps.editor.undo": "Hoàn tác",
"maps.editor.save": "Lưu",
@@ -692,6 +694,26 @@
"ioModules.testConnection": "Kiểm tra kết nối",
"ioModules.testOk": "Kết nối TCP thành công.",
"paths.title": "Paths",
"paths.subtitle": "Xem và xóa tuyến đường đã lưu giữa các position.",
"paths.helpTitle": "Trợ giúp Paths",
"paths.helpBody": "Path là tuyến đường giữa hai position trên cùng một map. Path được tạo tự động lần đầu robot chạy từ position A đến B và được tái sử dụng cho các lần sau. Xóa path để buộc tính lại (ví dụ sau khi thêm preferred zone). Không thể tạo path thủ công.",
"paths.clearFilters": "Xóa bộ lọc",
"paths.filterLabel": "Lọc:",
"paths.filterPlaceholder": "Lọc theo position hoặc map...",
"paths.itemsFound": "{n} mục",
"paths.pageOf": "Trang {page} / {total}",
"paths.colFrom": "From position",
"paths.colTo": "To position",
"paths.colMap": "Map",
"paths.colFunctions": "Functions",
"paths.empty": "Chưa có path. Path được tạo tự động khi robot di chuyển giữa hai position.",
"paths.emptyFilter": "Không có path khớp bộ lọc.",
"paths.view": "Xem trên map",
"paths.viewUnavailable": "Không mở được map editor.",
"paths.deleteTitle": "Xóa path?",
"paths.deleteConfirmText": "Xóa path từ \"{from}\" đến \"{to}\"? Path sẽ được tạo lại khi robot chạy lại giữa hai position này.",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
"missions.create": "Tạo mission",
@@ -887,6 +909,7 @@
"nav.transitions": "Transitions",
"nav.user-groups": "User groups",
"nav.io-modules": "I/O modules",
"nav.paths": "Paths",
"nav.users": "Users",
"nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log",
@@ -1128,7 +1151,7 @@
"maps.view": "View",
"maps.importComingSoon": "Import site will be available in a future release.",
"maps.importNoSite": "Select a site before importing.",
"maps.importSuccess": "Import complete: {maps} map(s), {io} I/O module(s), {transitions} transition(s).",
"maps.importSuccess": "Import complete: {maps} map(s), {io} I/O module(s), {transitions} transition(s), {paths} path(s).",
"maps.helpTitle": "Maps help",
"maps.helpText": "Create a new map, upload a PNG via the ⋮ menu, then activate the map for the robot.",
"maps.createDialog.title": "Create map",
@@ -1212,6 +1235,7 @@
"maps.editor.canvasTip": "Drag the map to move your view or use the zoom-in and -out buttons to zoom.",
"maps.editor.unsaved": "Unsaved",
"maps.editor.unsavedLeave": "You have unsaved changes. Leave the editor?",
"maps.editor.preferredPathWarning": "This map has cached paths. Preferred zone changes may not apply until you delete the related paths under Setup → Paths. Save anyway?",
"maps.editor.menu": "Menu",
"maps.editor.undo": "Undo",
"maps.editor.save": "Save",
@@ -1503,6 +1527,26 @@
"ioModules.testConnection": "Test connection",
"ioModules.testOk": "TCP connection successful.",
"paths.title": "Paths",
"paths.subtitle": "View and delete cached routes between positions.",
"paths.helpTitle": "Paths help",
"paths.helpBody": "A path is a saved route between two positions on the same map. It is created automatically the first time the robot drives from position A to B and reused afterward. Delete a path to force recalculation (for example after adding preferred zones). Paths cannot be created manually.",
"paths.clearFilters": "Clear filters",
"paths.filterLabel": "Filter:",
"paths.filterPlaceholder": "Filter by position or map...",
"paths.itemsFound": "{n} item(s) found",
"paths.pageOf": "Page {page} of {total}",
"paths.colFrom": "From position",
"paths.colTo": "To position",
"paths.colMap": "Map",
"paths.colFunctions": "Functions",
"paths.empty": "No paths yet. Paths are created automatically when the robot drives between two positions.",
"paths.emptyFilter": "No paths match the filter.",
"paths.view": "View on map",
"paths.viewUnavailable": "Could not open the map editor.",
"paths.deleteTitle": "Delete path?",
"paths.deleteConfirmText": "Delete path from \"{from}\" to \"{to}\"? It will be recreated when the robot drives between these positions again.",
"missions.title": "Missions",
"missions.subtitle": "Setup → Missions — robot task list.",
"missions.create": "Create mission",

View File

@@ -1272,6 +1272,68 @@
</dialog>
</div>
<div class="page" id="pagePaths" data-page-content="paths" hidden>
<div id="pathsListView" class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="paths.title">Paths</h1>
<p class="mapsMirSubtitle">
<span data-i18n="paths.subtitle">View and delete cached routes between positions.</span>
<button type="button" class="mapsMirHelpBtn" id="pathsHelpBtn" data-i18n-title="paths.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="pathsClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="paths.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="pathsFilterInput" data-i18n="paths.filterLabel">Filter:</label>
<input type="search" id="pathsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="paths.filterPlaceholder" placeholder="Filter by position or map..." autocomplete="off" />
<span id="pathsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="pathsPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="pathsPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="pathsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="pathsPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="pathsPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--paths" id="pathsTable">
<thead>
<tr>
<th class="pathsMirThIcon" aria-hidden="true"></th>
<th data-i18n="paths.colFrom">From position</th>
<th data-i18n="paths.colTo">To position</th>
<th data-i18n="paths.colMap">Map</th>
<th class="mapsMirThFunctions" data-i18n="paths.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="pathList"></tbody>
</table>
<div id="pathListEmpty" class="mapsMirEmpty" hidden data-i18n="paths.empty">No paths yet. Paths are created automatically when the robot drives between two positions.</div>
</div>
</div>
<dialog id="pathDeleteConfirmDialog" class="mapsMirDialog">
<div class="mapsMirDialogBody">
<h2 class="mapsMirDialogTitle" data-i18n="paths.deleteTitle">Delete path?</h2>
<p id="pathDeleteConfirmText" class="mapsMirDialogText"></p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="pathDeleteCancelBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="pathDeleteYesBtn" data-i18n="common.yes">Yes</button>
</div>
</div>
</dialog>
</div>
<div class="page" id="pageUsers" data-page-content="users" hidden>
<div id="usersListView" class="mapsMirPage">
<header class="mapsMirHeader">
@@ -2557,6 +2619,7 @@ GET /api/v2.0.0/status</pre>
<script src="/users.js"></script>
<script src="/user-groups.js"></script>
<script src="/io-modules.js"></script>
<script src="/paths.js"></script>
<script src="/map-editor.js"></script>
<script src="/topbar.js"></script>
<script src="/dashboard.js"></script>

View File

@@ -61,6 +61,10 @@
yamlMeta: null,
/** Pending ROS metadata from upload dialog (set before PNG picker). */
uploadMeta: null,
/** Dotted path overlay when opened from Setup → Paths (view). */
pathPreview: null,
/** Fingerprint of preferred zones when map was loaded (for save warning). */
initialPreferredFp: "",
};
const titleEl = el("mapEditorTitle");
@@ -500,6 +504,46 @@
return mapMetaForOriginDisplay() || state.map || {};
}
function preferredZonesFingerprint(zones) {
return zones
.filter((z) => z && z.type === "preferred")
.map((z) =>
JSON.stringify({
id: z.id,
points: z.points,
geometry: z.geometry,
line_width_cm: z.line_width_cm,
}),
)
.sort()
.join("|");
}
function renderPathPreview() {
if (!objectsSvgEl || !state.pathPreview?.points?.length) return;
const geo = Geo();
const meta = mapMetaForEditor();
const { width, height } = floorPlanSize();
if (!geo || !width) return;
const pts = state.pathPreview.points
.map((p) => {
const px = geo.worldToPixel(meta, width, height, p.x, p.y);
return `${px.x},${px.y}`;
})
.join(" ");
const poly = document.createElementNS("http://www.w3.org/2000/svg", "polyline");
poly.setAttribute("points", pts);
poly.setAttribute("fill", "none");
poly.setAttribute("stroke", "#2563eb");
poly.setAttribute("stroke-width", "3");
poly.setAttribute("stroke-dasharray", "8 6");
poly.setAttribute("stroke-linecap", "round");
poly.setAttribute("stroke-linejoin", "round");
poly.classList.add("mapEditorPathPreview");
poly.setAttribute("pointer-events", "none");
objectsSvgEl.appendChild(poly);
}
function renderObjects() {
const obj = Objects();
if (!objectsSvgEl || !obj) return;
@@ -530,6 +574,7 @@
draft: state.draft,
selectionRect: state.selectionRect,
});
renderPathPreview();
}
function imagePointFromEvent(evt) {
@@ -1769,6 +1814,7 @@
state.map = await api(`/api/maps/${encodeURIComponent(state.mapId)}`);
await loadYamlMeta();
syncZonesFromMap();
state.initialPreferredFp = preferredZonesFingerprint(state.zones);
updateHeader();
renderMapImage();
fillSettingsForm();
@@ -1796,6 +1842,8 @@
state.selectionRect = null;
state.positionDrag = null;
state.pendingPosition = null;
state.pathPreview = callbacks.pathPreview || null;
state.initialPreferredFp = "";
updateObjectTypePickerUi();
state.view = Geo()?.createView(1, 0, 0) || { scale: 1, panX: 0, panY: 0 };
if (tipEl) {
@@ -1829,6 +1877,8 @@
state.selectionRect = null;
state.positionDrag = null;
state.pendingPosition = null;
state.pathPreview = null;
state.initialPreferredFp = "";
drawLineDialogEl?.close();
menuDialogEl?.close();
settingsDialogEl?.close();
@@ -2062,6 +2112,19 @@
if (imageUpdated) state.map = { ...state.map, ...imageUpdated };
state.rasterDirty = false;
}
const preferredChanged =
preferredZonesFingerprint(state.zones) !== state.initialPreferredFp;
if (preferredChanged && state.map?.id) {
try {
const pathsData = await api(
`/api/paths?site_id=${encodeURIComponent(state.map.site_id || "")}`,
);
const onMap = (pathsData.paths || []).filter((p) => p.map_id === state.map.id);
if (onMap.length && !confirm(t("maps.editor.preferredPathWarning"))) return;
} catch {
/* continue save */
}
}
const updated = await api(`/api/maps/${encodeURIComponent(state.map.id)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
@@ -2069,6 +2132,7 @@
});
state.map = updated;
syncZonesFromMap();
state.initialPreferredFp = preferredZonesFingerprint(state.zones);
state.callbacks.onMapUpdated?.(updated);
setDirty(false);
updateHeader();

View File

@@ -301,6 +301,7 @@
}
window.MapEditorApp?.open?.(mapId, {
readOnly: opts.readOnly,
pathPreview: opts.pathPreview || null,
onMapUpdated: (updated) => {
const idx = store.maps.findIndex((m) => m.id === updated.id);
if (idx >= 0) store.maps[idx] = updated;
@@ -326,6 +327,18 @@
});
}
function openEditorWithPath(path) {
if (!path?.map_id) return;
openEditor(path.map_id, {
readOnly: !canWrite(),
pathPreview: {
points: Array.isArray(path.points) ? path.points : [],
fromPositionId: path.from_position_id,
toPositionId: path.to_position_id,
},
});
}
function confirmDeleteMap(map) {
return new Promise((resolve) => {
deleteDialogResolve = resolve;
@@ -554,8 +567,16 @@
Array.isArray(result.maps) ? result.maps.length : 0,
Array.isArray(result.io_modules) ? result.io_modules.length : 0,
Array.isArray(result.transitions) ? result.transitions.length : 0,
Array.isArray(result.paths) ? result.paths.length : 0,
];
alert(t("maps.importSuccess", { maps: counts[0], io: counts[1], transitions: counts[2] }));
alert(
t("maps.importSuccess", {
maps: counts[0],
io: counts[1],
transitions: counts[2],
paths: counts[3],
}),
);
} catch (e) {
alert(e.message);
}
@@ -659,6 +680,7 @@
getMaps: () => [...store.maps],
getMapById: findMap,
activateMap,
openEditorWithPath,
};
function boot() {

View File

@@ -19,6 +19,7 @@
{ section: "transitions", page: "transitions" },
{ section: "user-groups", page: "user-groups" },
{ section: "io-modules", page: "io-modules" },
{ section: "paths", page: "paths" },
{ section: "users", page: "users" },
{ section: "build-robot", page: "config" },
],
@@ -43,6 +44,7 @@
transitions: { module: "setup", section: "transitions" },
"user-groups": { module: "setup", section: "user-groups" },
"io-modules": { module: "setup", section: "io-modules" },
paths: { module: "setup", section: "paths" },
users: { module: "setup", section: "users" },
integrations: { module: "system", section: "integrations" },
monitoring: { module: "monitoring", section: "monitoring-log" },

257
www/paths.js Normal file
View File

@@ -0,0 +1,257 @@
(() => {
const PAGE_SIZE = 10;
const ICONS = {
path: `<svg class="pathsMirIcon" width="22" height="22" viewBox="0 0 22 22" aria-hidden="true"><circle cx="5" cy="11" r="2.5" fill="currentColor"/><circle cx="17" cy="11" r="2.5" fill="currentColor"/><path d="M7.5 11h7" stroke="currentColor" stroke-width="1.5" stroke-dasharray="2 2" stroke-linecap="round"/></svg>`,
view: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M1 7s2.5-4 6-4 6 4 6 4-2.5 4-6 4-6-4-6-4z" fill="none" stroke="currentColor" stroke-width="1.2"/><circle cx="7" cy="7" r="1.8" fill="none" stroke="currentColor" stroke-width="1.2"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("pathList");
const emptyEl = el("pathListEmpty");
const tableEl = el("pathsTable");
const filterInputEl = el("pathsFilterInput");
const filterCountEl = el("pathsFilterCount");
const pageLabelEl = el("pathsPageLabel");
const deleteConfirmDialogEl = el("pathDeleteConfirmDialog");
const deleteConfirmTextEl = el("pathDeleteConfirmText");
const store = {
paths: [],
maps: [],
sites: [],
pendingDeleteId: null,
filter: "",
page: 1,
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("maps");
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
async function refreshAll() {
const [sitesData, mapsData, pathsData] = await Promise.all([
apiJson("/api/sites"),
apiJson("/api/maps"),
apiJson("/api/paths"),
]);
store.sites = Array.isArray(sitesData.sites) ? sitesData.sites : [];
store.maps = Array.isArray(mapsData.maps) ? mapsData.maps : [];
store.paths = Array.isArray(pathsData.paths) ? pathsData.paths : [];
return store.paths;
}
function mapName(id) {
return store.maps.find((m) => m.id === id)?.name || id || "—";
}
function positionLabel(mapId, positionId) {
const map = store.maps.find((m) => m.id === mapId);
const zones = Array.isArray(map?.zones) ? map.zones : [];
const hit = zones.find((z) => z && z.type === "position" && z.id === positionId);
const pname = hit?.name || positionId || "—";
return `${mapName(mapId)} / ${pname}`;
}
function filteredPaths() {
const q = store.filter.trim().toLowerCase();
let items = [...store.paths];
if (q) {
items = items.filter((p) => {
const from = positionLabel(p.map_id, p.from_position_id).toLowerCase();
const to = positionLabel(p.map_id, p.to_position_id).toLowerCase();
const map = mapName(p.map_id).toLowerCase();
return from.includes(q) || to.includes(q) || map.includes(q);
});
}
return items.sort((a, b) => {
const ma = mapName(a.map_id).localeCompare(mapName(b.map_id));
if (ma !== 0) return ma;
return positionLabel(a.map_id, a.from_position_id).localeCompare(
positionLabel(b.map_id, b.from_position_id),
);
});
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function renderList() {
if (!listEl) return;
const items = filteredPaths();
const total = items.length;
const pages = pageCount(total);
if (store.page > pages) store.page = pages;
const start = (store.page - 1) * PAGE_SIZE;
const pageItems = items.slice(start, start + PAGE_SIZE);
if (filterCountEl) filterCountEl.textContent = t("paths.itemsFound", { n: total });
if (pageLabelEl) pageLabelEl.textContent = t("paths.pageOf", { page: store.page, total: pages });
listEl.innerHTML = "";
if (tableEl) tableEl.hidden = total === 0;
if (emptyEl) {
emptyEl.hidden = total > 0;
emptyEl.textContent = store.filter ? t("paths.emptyFilter") : t("paths.empty");
}
pageItems.forEach((path) => {
const tr = document.createElement("tr");
tr.className = "pathsMirRow";
tr.innerHTML = `
<td class="pathsMirTdIcon">${ICONS.path}</td>
<td>${escapeHtml(positionLabel(path.map_id, path.from_position_id))}</td>
<td>${escapeHtml(positionLabel(path.map_id, path.to_position_id))}</td>
<td>${escapeHtml(mapName(path.map_id))}</td>
<td class="mapsMirTdFunctions">
<button type="button" class="mapsMirIconBtn" data-view="${escapeHtml(path.id)}" title="${escapeHtml(t("paths.view"))}">${ICONS.view}</button>
${canWrite() ? `<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(path.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>` : ""}
</td>`;
listEl.appendChild(tr);
});
document.body.classList.toggle("auth-readonly-paths", !canWrite());
}
async function viewPath(id) {
const path = store.paths.find((p) => p.id === id);
if (!path) return;
let full = path;
try {
full = await apiJson(`/api/paths/${encodeURIComponent(id)}`);
} catch {
/* use list row */
}
if (window.MapsApp?.openEditorWithPath) {
window.NavApp?.selectSection?.("maps", "maps");
window.MapsApp.openEditorWithPath(full);
} else {
alert(t("paths.viewUnavailable"));
}
}
function openDeleteConfirm(id) {
const path = store.paths.find((p) => p.id === id);
if (!path) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("paths.deleteConfirmText", {
from: positionLabel(path.map_id, path.from_position_id),
to: positionLabel(path.map_id, path.to_position_id),
});
}
deleteConfirmDialogEl?.showModal();
}
async function confirmDelete() {
const id = store.pendingDeleteId;
if (!id) return;
try {
await apiJson(`/api/paths/${encodeURIComponent(id)}`, { method: "DELETE" });
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
await refreshAll();
renderList();
} catch (e) {
alert(e.message);
}
}
function bindEvents() {
listEl?.addEventListener("click", (evt) => {
const viewBtn = evt.target.closest("[data-view]");
const deleteBtn = evt.target.closest("[data-delete]");
if (viewBtn?.dataset.view) void viewPath(viewBtn.dataset.view);
else if (deleteBtn?.dataset.delete && canWrite()) openDeleteConfirm(deleteBtn.dataset.delete);
});
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("pathsClearFiltersBtn")?.addEventListener("click", () => {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
});
el("pathsPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("pathsPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("pathsPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("pathsPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredPaths().length);
renderList();
});
el("pathsHelpBtn")?.addEventListener("click", () => alert(t("paths.helpBody")));
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("pathDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("pathDeleteYesBtn")?.addEventListener("click", () => confirmDelete().catch((e) => alert(e.message)));
window.addEventListener("lm:locale-change", () => renderList());
}
async function onPageShow() {
if (!window.AuthApp?.canAccessPage?.("paths")) return;
await refreshAll();
renderList();
}
function onPageHide() {}
function init() {
bindEvents();
}
window.PathsApp = { init, onPageShow, onPageHide, refresh: refreshAll };
function boot() {
init();
}
if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true });
})();

View File

@@ -3935,6 +3935,11 @@ body.auth-readonly-io-modules .ioModulesMirRow .ioModuleDeleteBtn { pointer-even
background: #fff;
}
.mapsMirTable--paths thead th.pathsMirThIcon { width: 52px; }
.pathsMirIcon { color: var(--mir-accent, #0d9488); }
body.auth-readonly-paths .pathsMirRow .mapsMirIconBtn--danger { pointer-events: none; opacity: 0.55; }
.mapEditorPathPreview { filter: drop-shadow(0 0 2px rgba(37, 99, 235, 0.45)); }
.transMirIcon {
color: var(--mir-green, #5cb85c);
display: block;