diff --git a/CMakeLists.txt b/CMakeLists.txt index 453dad0..8e7ba32 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 ) diff --git a/src/app/lidar_manager_app.cpp b/src/app/lidar_manager_app.cpp index 93ec463..b02db5e 100644 --- a/src/app/lidar_manager_app.cpp +++ b/src/app/lidar_manager_app.cpp @@ -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); diff --git a/src/auth/auth_service.cpp b/src/auth/auth_service.cpp index bcd183d..8bc9411 100644 --- a/src/auth/auth_service.cpp +++ b/src/auth/auth_service.cpp @@ -236,6 +236,7 @@ std::optional 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 || diff --git a/src/mission/mission_queue.cpp b/src/mission/mission_queue.cpp index eaa7909..e636a54 100644 --- a/src/mission/mission_queue.cpp +++ b/src/mission/mission_queue.cpp @@ -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 lock(mu_); + if (runner_.contains("last_position_id") && runner_["last_position_id"].is_string()) + from_id = runner_["last_position_id"].get(); + } + + 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(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 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; } } diff --git a/src/mission/mission_queue.hpp b/src/mission/mission_queue.hpp index 3a9f2d6..a4cb8df 100644 --- a/src/mission/mission_queue.hpp +++ b/src/mission/mission_queue.hpp @@ -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); }; diff --git a/src/path/path_planner.cpp b/src/path/path_planner.cpp new file mode 100644 index 0000000..6bf4c04 --- /dev/null +++ b/src/path/path_planner.cpp @@ -0,0 +1,287 @@ +#include "path/path_planner.hpp" + +#include +#include +#include +#include +#include + +namespace lm { + +namespace { + +constexpr double kInf = std::numeric_limits::infinity(); + +double mapResolution(const nlohmann::json& map) +{ + if (map.contains("resolution") && map["resolution"].is_number()) + return std::max(0.01, map["resolution"].get()); + return 0.05; +} + +double mapImageHeight(const nlohmann::json& map) +{ + if (map.contains("height") && map["height"].is_number()) + return map["height"].get(); + return 1000.0; +} + +} // namespace + +std::pair 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() / (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 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(std::ceil((max_x - min_x) / cell_m)) + 1, 8, 120); + const int rows = std::clamp(static_cast(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(std::round((wx - min_x) / cell_m)), 0, cols - 1); + const int cy = std::clamp(static_cast(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 costs(static_cast(cols * rows), kInf); + std::vector parent(static_cast(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, 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(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(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(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 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(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 diff --git a/src/path/path_planner.hpp b/src/path/path_planner.hpp new file mode 100644 index 0000000..021717a --- /dev/null +++ b/src/path/path_planner.hpp @@ -0,0 +1,41 @@ +#pragma once + +#include + +#include +#include +#include +#include + +namespace lm { + +struct WorldPoint +{ + double x = 0; + double y = 0; +}; + +class PathPlanner +{ +public: + static std::optional 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 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 diff --git a/src/path/path_service.cpp b/src/path/path_service.cpp new file mode 100644 index 0000000..c91f4d5 --- /dev/null +++ b/src/path/path_service.cpp @@ -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 +#include + +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> 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 diff --git a/src/path/path_service.hpp b/src/path/path_service.hpp new file mode 100644 index 0000000..2fe3392 --- /dev/null +++ b/src/path/path_service.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include +#include + +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 diff --git a/src/server/api_media_routes.cpp b/src/server/api_media_routes.cpp index ee0ec58..988efab 100644 --- a/src/server/api_media_routes.cpp +++ b/src/server/api_media_routes.cpp @@ -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 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(); }); diff --git a/src/server/api_path_routes.cpp b/src/server/api_path_routes.cpp new file mode 100644 index 0000000..bd33c17 --- /dev/null +++ b/src/server/api_path_routes.cpp @@ -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 diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp index a59a0f9..b21ba01 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -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); } diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp index 07a08c2..d2f7b91 100644 --- a/src/server/api_server.hpp +++ b/src/server/api_server.hpp @@ -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); }; diff --git a/src/storage/database.cpp b/src/storage/database.cpp index 2b4242c..e56cd12 100644 --- a/src/storage/database.cpp +++ b/src/storage/database.cpp @@ -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; } diff --git a/src/storage/path_store.cpp b/src/storage/path_store.cpp new file mode 100644 index 0000000..06f3552 --- /dev/null +++ b/src/storage/path_store.cpp @@ -0,0 +1,256 @@ +#include "storage/path_store.hpp" + +#include "storage/database.hpp" +#include "util/id_util.hpp" +#include "util/string_util.hpp" + +#include + +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(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 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 PathStore::find(const std::string& id) const +{ + std::lock_guard 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 out; + if (sqlite3_step(stmt) == SQLITE_ROW) + out = rowToJson(stmt); + sqlite3_finalize(stmt); + return out; +} + +std::optional 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 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 out; + if (sqlite3_step(stmt) == SQLITE_ROW) + out = rowToJson(stmt); + sqlite3_finalize(stmt); + return out; +} + +std::optional 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 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(sqlite3_column_text(lookup, 0)); + if (existing) + id = existing; + } + sqlite3_finalize(lookup); + } + } + + std::lock_guard 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 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 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 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 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 diff --git a/src/storage/path_store.hpp b/src/storage/path_store.hpp new file mode 100644 index 0000000..f4df7c9 --- /dev/null +++ b/src/storage/path_store.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +#include +#include +#include + +namespace lm { + +class Database; + +class PathStore +{ +public: + explicit PathStore(Database& db); + + nlohmann::json list(const std::string& site_id = "") const; + std::optional find(const std::string& id) const; + std::optional findBetween(const std::string& map_id, + const std::string& from_position_id, + const std::string& to_position_id) const; + std::optional 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 diff --git a/www/app.js b/www/app.js index 16f3f43..dcb5ce1 100644 --- a/www/app.js +++ b/www/app.js @@ -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(); diff --git a/www/auth.js b/www/auth.js index b9f2632..869a9b8 100644 --- a/www/auth.js +++ b/www/auth.js @@ -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() { diff --git a/www/i18n.js b/www/i18n.js index 07a6b3a..f388a1c 100644 --- a/www/i18n.js +++ b/www/i18n.js @@ -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", diff --git a/www/index.html b/www/index.html index 5df2e46..13193dd 100644 --- a/www/index.html +++ b/www/index.html @@ -1272,6 +1272,68 @@ + +