diff --git a/CMakeLists.txt b/CMakeLists.txt index 5d22efd..10fda76 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,12 +44,14 @@ add_executable(lidar_manager_web src/storage/map_store.cpp src/storage/site_store.cpp src/storage/sound_store.cpp + src/storage/transition_store.cpp src/storage/dashboard_store.cpp src/storage/state_repository.cpp src/validation/sensor_validator.cpp src/server/static_file_server.cpp src/server/api_server.cpp src/mission/mission_queue.cpp + src/mission/position_resolver.cpp src/mission/mission_store.cpp src/mission/mission_enqueue.cpp src/mission/modbus_trigger_service.cpp @@ -58,6 +60,7 @@ add_executable(lidar_manager_web src/server/api_mission_routes.cpp src/server/api_robot_routes.cpp src/server/api_media_routes.cpp + src/server/api_transition_routes.cpp src/server/api_dashboard_routes.cpp ) diff --git a/README.md b/README.md index a970aec..fc5a267 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,14 @@ Mở trình duyệt: `http://localhost:8080/` | POST | `/api/sounds` | Tạo sound | | GET/PUT/DELETE | `/api/sounds/{id}` | CRUD sound | | GET/POST | `/api/sounds/{id}/file` | Tải/upload file âm thanh | +| POST | `/api/sounds/{id}/play` | Phát sound trên robot (volume 0–100) | +| GET/POST | `/api/transitions` | Danh sách / tạo transition (`?site_id=`) | +| GET/PUT/DELETE | `/api/transitions/{id}` | CRUD transition | | GET/PUT | `/api/dashboards` | Dashboard (server-side, thay localStorage) | | GET | `/api/recordings` | Stub — trả về `[]` (Phase sau) | +**Transitions (MiR §4.4):** Setup → Transitions — cấu hình chuyển map (from/to, start/goal position, mission có `switch_map`). Khi mission chạy `move_to_position` hoặc `adjust_localization` tới position trên map khác `active_map_id`, runner tự chèn: di chuyển tới start position → chạy transition mission → chuyển map → di chuyển tới goal position → tiếp tục bước gốc. Position trong mission editor lấy từ zones `type: position` trên từng map. + ### Đăng nhập (Signing in — MiR §2.1) Trang web **bắt buộc đăng nhập**. Hai tab: tên/mật khẩu hoặc **Mã PIN** (keypad 4 số). Tài khoản mặc định (trong `data/RBS.db`, seed lần đầu): diff --git a/src/app/lidar_manager_app.cpp b/src/app/lidar_manager_app.cpp index 83e7fe2..53cb4ab 100644 --- a/src/app/lidar_manager_app.cpp +++ b/src/app/lidar_manager_app.cpp @@ -14,6 +14,7 @@ #include "storage/map_store.hpp" #include "storage/site_store.hpp" #include "storage/sound_store.hpp" +#include "storage/transition_store.hpp" #include "storage/state_repository.hpp" #include @@ -61,13 +62,19 @@ int LidarManagerApp::run() StateRepository repo(data_path_, database); repo.load(); - MissionQueue mission_queue(database); - MissionStore mission_store(database); - RobotRuntime robot_runtime(database, mission_queue); MapStore map_store(database); + TransitionStore transition_store(database); + MissionStore mission_store(database); + MissionQueue mission_queue(database, map_store, transition_store, mission_store); + RobotRuntime robot_runtime(database, mission_queue); SiteStore site_store(database); site_store.ensureDefaultSiteId(); SoundStore sound_store(database); + { + std::string sound_err; + if (!sound_store.ensureSystemDefaults(sound_err)) + std::fprintf(stderr, "Warning: sound defaults: %s\n", sound_err.c_str()); + } DashboardStore dashboard_store(database); const auto enqueue_fn = [&mission_store, &mission_queue](const nlohmann::json& request, std::string& err) -> bool { @@ -96,6 +103,7 @@ int LidarManagerApp::run() map_store, site_store, sound_store, + transition_store, dashboard_store); api.registerRoutes(svr); auth.registerRoutes(svr); diff --git a/src/auth/auth_service.cpp b/src/auth/auth_service.cpp index e2db99c..9e2570d 100644 --- a/src/auth/auth_service.cpp +++ b/src/auth/auth_service.cpp @@ -24,6 +24,7 @@ nlohmann::json defaultPermissionsAllWrite() {"config", "write"}, {"maps", "write"}, {"missions", "write"}, + {"sounds", "write"}, {"integrations", "write"}, {"users", "write"}}; } @@ -34,6 +35,7 @@ nlohmann::json defaultPermissionsAdministrator() {"config", "none"}, {"maps", "write"}, {"missions", "write"}, + {"sounds", "write"}, {"integrations", "write"}, {"users", "write"}}; } @@ -44,6 +46,7 @@ nlohmann::json defaultPermissionsUserGroup() {"config", "none"}, {"maps", "none"}, {"missions", "read"}, + {"sounds", "read"}, {"integrations", "read"}, {"users", "none"}}; } @@ -144,6 +147,22 @@ void AuthService::loadOrSeed() data_["version"] = 3; } + if (data_.value("version", 1) < 4) + { + if (data_.contains("groups") && data_["groups"].is_array()) + { + for (auto& g : data_["groups"]) + { + if (!g.contains("permissions") || !g["permissions"].is_object()) + continue; + auto& perms = g["permissions"]; + if (!perms.contains("sounds")) + perms["sounds"] = perms.value("integrations", "none"); + } + } + data_["version"] = 4; + } + saveUnlocked(); } @@ -211,7 +230,7 @@ std::optional AuthService::resourceForApiPath(const std::string& pa if (path.rfind("/api/dashboards", 0) == 0) return "dashboard"; if (path.rfind("/api/sounds", 0) == 0) - return "integrations"; + return "sounds"; if (path == "/api/robot/active_map") return "maps"; if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 || diff --git a/src/mission/mission_queue.cpp b/src/mission/mission_queue.cpp index 6dfb464..0e8b599 100644 --- a/src/mission/mission_queue.cpp +++ b/src/mission/mission_queue.cpp @@ -1,6 +1,10 @@ #include "mission/mission_queue.hpp" +#include "mission/mission_store.hpp" +#include "mission/position_resolver.hpp" #include "storage/database.hpp" +#include "storage/map_store.hpp" +#include "storage/transition_store.hpp" #include "util/id_util.hpp" #include @@ -40,7 +44,8 @@ double paramNumber(const nlohmann::json& params, const std::string& key, double } // namespace -MissionQueue::MissionQueue(Database& db) : db_(db) +MissionQueue::MissionQueue(Database& db, MapStore& maps, TransitionStore& transitions, MissionStore& missions) + : db_(db), maps_(maps), transitions_(transitions), missions_(missions), position_resolver_(maps) { load(); ensureRunnerDefaults(); @@ -450,10 +455,103 @@ void MissionQueue::runMissionActions(nlohmann::json& entry) cancel_ = false; } +void MissionQueue::runAutoTransitionIfNeeded(const std::string& from_map_id, + const std::string& to_map_id, + const nlohmann::json& parameters, + nlohmann::json& log, + int loop_depth) +{ + if (from_map_id.empty() || to_map_id.empty() || from_map_id == to_map_id) + return; + + const auto transition = transitions_.findBetween(from_map_id, to_map_id); + if (!transition) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "error"}, + {"message", "No transition configured: " + from_map_id + " → " + to_map_id}}); + throw std::runtime_error("no transition between maps"); + } + + std::string from_label = from_map_id; + std::string to_label = to_map_id; + const auto maps = maps_.list(); + if (maps.is_array()) + { + for (const auto& map : maps) + { + if (!map.is_object()) + continue; + if (map.value("id", "") == from_map_id) + from_label = map.value("name", from_map_id); + if (map.value("id", "") == to_map_id) + to_label = map.value("name", to_map_id); + } + } + + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "Auto transition: " + from_label + " → " + to_label}}); + + const std::string start_id = transition->value("start_position_id", ""); + const std::string goal_id = transition->value("goal_position_id", ""); + if (!start_id.empty()) + { + const auto start_pos = position_resolver_.resolve(start_id, from_map_id); + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", + "Move to transition start → " + (start_pos ? start_pos->label : start_id)}}); + sleepMs(1200); + if (cancel_) + throw MissionCancelled(); + } + + const std::string mission_id = transition->value("mission_id", ""); + if (!mission_id.empty()) + { + const auto mission = missions_.findMission(mission_id); + if (mission && mission->contains("actions") && (*mission)["actions"].is_array()) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "Run transition mission: " + mission->value("name", mission_id)}}); + executeActionsUnlocked((*mission)["actions"], parameters, log, loop_depth + 1, false); + if (cancel_) + throw MissionCancelled(); + } + else + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "warn"}, + {"message", "Transition mission not found: " + mission_id}}); + } + } + + std::string err; + if (!setActiveMapOnRobotRuntimeDoc(db_, to_map_id, err)) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Switch map failed: " + err}}); + throw std::runtime_error("switch_map failed after transition"); + } + + if (!goal_id.empty()) + { + const auto goal_pos = position_resolver_.resolve(goal_id, to_map_id); + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "Move to transition goal → " + (goal_pos ? goal_pos->label : goal_id)}}); + sleepMs(1200); + if (cancel_) + throw MissionCancelled(); + } +} + MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::json& actions, const nlohmann::json& parameters, nlohmann::json& log, - int loop_depth) + int loop_depth, + bool allow_auto_transition) { if (loop_depth > 8) throw std::runtime_error("loop depth exceeded"); @@ -491,7 +589,7 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j if (action.contains("resolved_mission") && action["resolved_mission"].is_object()) { const auto& nested_actions = action["resolved_mission"]["actions"]; - const LoopControl nested = executeActionsUnlocked(nested_actions, parameters, log, loop_depth); + const LoopControl nested = executeActionsUnlocked(nested_actions, parameters, log, loop_depth, allow_auto_transition); if (nested == LoopControl::Break) return LoopControl::Break; if (nested == LoopControl::Continue) @@ -533,7 +631,7 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j {"level", "info"}, {"message", "Loop " + std::to_string(i + 1) + "/" + std::to_string(iterations)}}); } - const LoopControl ctrl = executeActionsUnlocked(children, parameters, log, loop_depth + 1); + const LoopControl ctrl = executeActionsUnlocked(children, parameters, log, loop_depth + 1, allow_auto_transition); if (ctrl == LoopControl::Break) break; if (ctrl == LoopControl::Continue) @@ -556,16 +654,58 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j if (type == "move_to_position" || type == "adjust_localization" || type == "pick_cart" || type == "drop_cart") { - const std::string pos = paramValue(action_id, params, "position", parameters); + std::string pos_ref = paramValue(action_id, params, "position_id", parameters); + if (pos_ref.empty()) + pos_ref = paramValue(action_id, params, "position", parameters); + + if (allow_auto_transition && (type == "move_to_position" || type == "adjust_localization") && !pos_ref.empty()) + { + const std::string active_map = getActiveMapIdFromDb(db_); + const auto resolved = position_resolver_.resolve(pos_ref, active_map); + if (resolved) + { + if (!active_map.empty() && active_map != resolved->map_id) + { + runAutoTransitionIfNeeded(active_map, resolved->map_id, parameters, log, loop_depth); + if (cancel_) + throw MissionCancelled(); + } + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", label + " → " + resolved->label}}); + sleepMs(1200); + if (cancel_) + throw MissionCancelled(); + continue; + } + } + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "info"}, - {"message", label + " → " + (pos.empty() ? "?" : pos)}}); + {"message", label + " → " + (pos_ref.empty() ? "?" : pos_ref)}}); sleepMs(1200); if (cancel_) throw MissionCancelled(); continue; } + if (type == "switch_map") + { + const std::string map_id = paramValue(action_id, params, "map_id", parameters); + std::string err; + if (!setActiveMapOnRobotRuntimeDoc(db_, map_id, err)) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Switch map failed: " + err}}); + throw std::runtime_error("switch_map failed"); + } + log.push_back( + {{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", "Switch map → " + map_id}}); + sleepMs(300); + if (cancel_) + throw MissionCancelled(); + continue; + } + if (type == "move_to_marker") { const std::string marker = paramValue(action_id, params, "marker", parameters); diff --git a/src/mission/mission_queue.hpp b/src/mission/mission_queue.hpp index 3a90d04..e88ffb7 100644 --- a/src/mission/mission_queue.hpp +++ b/src/mission/mission_queue.hpp @@ -1,5 +1,7 @@ #pragma once +#include "mission/position_resolver.hpp" + #include #include @@ -11,11 +13,14 @@ namespace lm { class Database; +class MapStore; +class MissionStore; +class TransitionStore; class MissionQueue { public: - explicit MissionQueue(Database& db); + MissionQueue(Database& db, MapStore& maps, TransitionStore& transitions, MissionStore& missions); ~MissionQueue(); MissionQueue(const MissionQueue&) = delete; @@ -36,6 +41,10 @@ private: enum class LoopControl { None, Break, Continue }; Database& db_; + MapStore& maps_; + TransitionStore& transitions_; + MissionStore& missions_; + PositionResolver position_resolver_; mutable std::recursive_mutex mu_; nlohmann::json queue_; nlohmann::json runner_; @@ -55,7 +64,13 @@ private: LoopControl executeActionsUnlocked(const nlohmann::json& actions, const nlohmann::json& parameters, nlohmann::json& log, - int loop_depth); + int loop_depth, + bool allow_auto_transition = true); + void runAutoTransitionIfNeeded(const std::string& from_map_id, + const std::string& to_map_id, + const nlohmann::json& parameters, + nlohmann::json& log, + int loop_depth); void sleepMs(int ms); void setRunnerState(const std::string& state, const std::string& message = ""); void insertByPriorityUnlocked(nlohmann::json& entry); diff --git a/src/mission/position_resolver.cpp b/src/mission/position_resolver.cpp new file mode 100644 index 0000000..2ceaa3d --- /dev/null +++ b/src/mission/position_resolver.cpp @@ -0,0 +1,156 @@ +#include "mission/position_resolver.hpp" + +#include "storage/database.hpp" +#include "storage/map_store.hpp" +#include "util/id_util.hpp" +#include "util/string_util.hpp" + +namespace lm { + +namespace { + +std::string lowerCopy(const std::string& s) +{ + return StringUtil::toLower(StringUtil::trimCopy(s)); +} + +} // namespace + +PositionResolver::PositionResolver(MapStore& maps) : maps_(maps) {} + +nlohmann::json PositionResolver::listPositions() const +{ + nlohmann::json out = nlohmann::json::array(); + const auto maps = maps_.list(); + if (!maps.is_array()) + return out; + + for (const auto& map : maps) + { + if (!map.is_object()) + continue; + const std::string map_id = map.value("id", ""); + const std::string map_name = map.value("name", map_id); + const auto& zones = map.value("zones", nlohmann::json::array()); + if (!zones.is_array()) + continue; + for (const auto& z : zones) + { + if (!z.is_object() || z.value("type", "") != "position") + continue; + const std::string pid = z.value("id", ""); + if (pid.empty()) + continue; + const std::string pname = z.value("name", pid); + out.push_back({{"map_id", map_id}, + {"map_name", map_name}, + {"position_id", pid}, + {"name", pname}, + {"label", map_name + " / " + pname}}); + } + } + return out; +} + +std::optional PositionResolver::resolve(const std::string& ref, + const std::string& prefer_map_id) const +{ + const std::string needle = StringUtil::trimCopy(ref); + if (needle.empty()) + return std::nullopt; + + const auto maps = maps_.list(); + if (!maps.is_array()) + return std::nullopt; + + auto tryMatch = [&](const nlohmann::json& map, bool by_id) -> std::optional { + if (!map.is_object()) + return std::nullopt; + const std::string map_id = map.value("id", ""); + const std::string map_name = map.value("name", map_id); + const auto& zones = map.value("zones", nlohmann::json::array()); + if (!zones.is_array()) + return std::nullopt; + const std::string ref_lower = lowerCopy(needle); + for (const auto& z : zones) + { + if (!z.is_object() || z.value("type", "") != "position") + continue; + const std::string pid = z.value("id", ""); + const std::string pname = z.value("name", ""); + if (by_id) + { + if (pid != needle) + continue; + } + else if (lowerCopy(pname) != ref_lower) + { + continue; + } + ResolvedPosition out; + out.map_id = map_id; + out.position_id = pid; + out.name = pname; + out.label = map_name + " / " + (pname.empty() ? pid : pname); + return out; + } + return std::nullopt; + }; + + if (!prefer_map_id.empty()) + { + for (const auto& map : maps) + { + if (map.value("id", "") != prefer_map_id) + continue; + if (auto hit = tryMatch(map, true)) + return hit; + if (auto hit = tryMatch(map, false)) + return hit; + } + } + + for (const auto& map : maps) + { + if (auto hit = tryMatch(map, true)) + return hit; + } + for (const auto& map : maps) + { + if (auto hit = tryMatch(map, false)) + return hit; + } + return std::nullopt; +} + +std::string getActiveMapIdFromDb(Database& db) +{ + nlohmann::json rt; + if (!db.getDocument("robot_runtime", rt) || !rt.is_object()) + return ""; + if (!rt.contains("active_map_id") || rt["active_map_id"].is_null()) + return ""; + return rt.value("active_map_id", ""); +} + +bool setActiveMapOnRobotRuntimeDoc(Database& db, const std::string& map_id, std::string& err) +{ + if (map_id.empty()) + { + err = "map_id is required"; + return false; + } + nlohmann::json rt; + if (!db.getDocument("robot_runtime", rt) || !rt.is_object()) + rt = nlohmann::json::object(); + rt["active_map_id"] = map_id; + rt["updated_at"] = IdUtil::nowIso8601(); + if (!db.setDocument("robot_runtime", rt)) + { + err = "failed to update robot_runtime"; + return false; + } + return true; +} + +} // namespace lm diff --git a/src/mission/position_resolver.hpp b/src/mission/position_resolver.hpp new file mode 100644 index 0000000..1f4ad64 --- /dev/null +++ b/src/mission/position_resolver.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +#include +#include + +namespace lm { + +class MapStore; + +struct ResolvedPosition +{ + std::string map_id; + std::string position_id; + std::string name; + std::string label; +}; + +class PositionResolver +{ +public: + explicit PositionResolver(MapStore& maps); + + std::optional resolve(const std::string& ref, const std::string& prefer_map_id = "") const; + nlohmann::json listPositions() const; + +private: + MapStore& maps_; +}; + +std::string getActiveMapIdFromDb(class Database& db); +bool setActiveMapOnRobotRuntimeDoc(class Database& db, const std::string& map_id, std::string& err); + +} // namespace lm diff --git a/src/robot/robot_runtime.cpp b/src/robot/robot_runtime.cpp index 7d81619..1a9d243 100644 --- a/src/robot/robot_runtime.cpp +++ b/src/robot/robot_runtime.cpp @@ -262,6 +262,29 @@ void RobotRuntime::clearActiveMapIf(const std::string& map_id) } } +bool RobotRuntime::queueSoundPlay(const std::string& sound_id, int volume, std::string& err) +{ + if (sound_id.empty()) + { + err = "sound_id is required"; + return false; + } + int vol = volume; + if (vol < 0) + vol = 0; + if (vol > 100) + vol = 100; + + std::lock_guard lock(mu_); + state_["sound_play"] = {{"sound_id", sound_id}, + {"volume", vol}, + {"requested_at", IdUtil::nowIso8601()}}; + state_["message"] = "Playing sound on robot"; + state_["updated_at"] = IdUtil::nowIso8601(); + saveUnlocked(); + return true; +} + void RobotRuntime::tick() { std::lock_guard lock(mu_); diff --git a/src/robot/robot_runtime.hpp b/src/robot/robot_runtime.hpp index ce30880..3485e11 100644 --- a/src/robot/robot_runtime.hpp +++ b/src/robot/robot_runtime.hpp @@ -23,6 +23,7 @@ public: bool setJoystick(bool engaged, const std::string& speed, std::string& err); bool setActiveMap(const std::string& map_id, std::string& err); void clearActiveMapIf(const std::string& map_id); + bool queueSoundPlay(const std::string& sound_id, int volume, std::string& err); void tick(); private: diff --git a/src/server/api_media_routes.cpp b/src/server/api_media_routes.cpp index 77d4918..1a5aa9a 100644 --- a/src/server/api_media_routes.cpp +++ b/src/server/api_media_routes.cpp @@ -323,6 +323,37 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr) res.body = updated ? updated->dump() : nlohmann::json::object().dump(); }); + svr.Post(R"(/api/sounds/([^/]+)/play$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + const auto sound = sound_store_.find(id); + if (!sound) + return HttpUtil::jsonError(res, 404, "sound not found"); + if (sound->value("enabled", true) == false) + return HttpUtil::jsonError(res, 400, "sound is disabled"); + + int volume = sound->value("volume", 100); + if (!req.body.empty()) + { + try + { + const auto body = nlohmann::json::parse(req.body); + if (body.contains("volume") && body["volume"].is_number_integer()) + volume = body["volume"].get(); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + } + + std::string err; + if (!robot_runtime_.queueSoundPlay(id, volume, err)) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json({{"ok", true}, {"sound_id", id}, {"volume", volume}}).dump(); + }); + svr.Get("/api/recordings", [](const httplib::Request&, httplib::Response& res) { HttpUtil::addCors(res); res.set_header("Content-Type", "application/json; charset=utf-8"); diff --git a/src/server/api_server.cpp b/src/server/api_server.cpp index 699afef..e1097c9 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -19,6 +19,7 @@ ApiServer::ApiServer(StateRepository& repo, MapStore& map_store, SiteStore& site_store, SoundStore& sound_store, + TransitionStore& transition_store, DashboardStore& dashboard_store) : repo_(repo), mission_queue_(mission_queue), @@ -29,6 +30,7 @@ ApiServer::ApiServer(StateRepository& repo, map_store_(map_store), site_store_(site_store), sound_store_(sound_store), + transition_store_(transition_store), dashboard_store_(dashboard_store) { } @@ -550,6 +552,7 @@ void ApiServer::registerRoutes(httplib::Server& svr) registerMirV2Routes(svr); registerRobotRoutes(svr); registerMediaRoutes(svr); + registerTransitionRoutes(svr); registerDashboardRoutes(svr); } diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp index d325ecc..ad609bd 100644 --- a/src/server/api_server.hpp +++ b/src/server/api_server.hpp @@ -11,6 +11,7 @@ #include "storage/map_store.hpp" #include "storage/site_store.hpp" #include "storage/sound_store.hpp" +#include "storage/transition_store.hpp" #include "storage/state_repository.hpp" namespace lm { @@ -27,6 +28,7 @@ public: MapStore& map_store, SiteStore& site_store, SoundStore& sound_store, + TransitionStore& transition_store, DashboardStore& dashboard_store); void registerRoutes(httplib::Server& svr); @@ -41,6 +43,7 @@ private: MapStore& map_store_; SiteStore& site_store_; SoundStore& sound_store_; + TransitionStore& transition_store_; DashboardStore& dashboard_store_; bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201); @@ -51,6 +54,7 @@ private: void registerIntegrationRoutes(httplib::Server& svr); void registerRobotRoutes(httplib::Server& svr); void registerMediaRoutes(httplib::Server& svr); + void registerTransitionRoutes(httplib::Server& svr); void registerDashboardRoutes(httplib::Server& svr); }; diff --git a/src/server/api_transition_routes.cpp b/src/server/api_transition_routes.cpp new file mode 100644 index 0000000..3bc696e --- /dev/null +++ b/src/server/api_transition_routes.cpp @@ -0,0 +1,86 @@ +#include "server/api_server.hpp" + +#include "auth/auth_service.hpp" +#include "util/http_util.hpp" + +namespace lm { + +void ApiServer::registerTransitionRoutes(httplib::Server& svr) +{ + svr.Get("/api/transitions", [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({{"transitions", transition_store_.list(site_id)}}).dump(); + }); + + svr.Get(R"(/api/transitions/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + const auto tr = transition_store_.find(id); + if (!tr) + return HttpUtil::jsonError(res, 404, "transition not found"); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = tr->dump(); + }); + + svr.Post("/api/transitions", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + if (const AuthSession* session = AuthService::activeSession()) + { + if (!body.contains("created_by") || !body["created_by"].is_string() || + body["created_by"].get().empty()) + { + body["created_by"] = session->group_name.empty() ? session->username : session->group_name; + } + } + std::string err; + const auto created = transition_store_.create(body, err); + if (!created) + return HttpUtil::jsonError(res, 400, err); + res.status = 201; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = created->dump(); + }); + + svr.Put(R"(/api/transitions/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + std::string err; + if (!transition_store_.update(id, body, err)) + return HttpUtil::jsonError(res, 404, err); + const auto updated = transition_store_.find(id); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = updated ? updated->dump() : nlohmann::json::object().dump(); + }); + + svr.Delete(R"(/api/transitions/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + std::string err; + if (!transition_store_.remove(id, err)) + return HttpUtil::jsonError(res, 404, err); + res.status = 204; + }); +} + +} // namespace lm + diff --git a/src/storage/database.cpp b/src/storage/database.cpp index 0843b38..3ff1b7f 100644 --- a/src/storage/database.cpp +++ b/src/storage/database.cpp @@ -78,6 +78,8 @@ CREATE TABLE IF NOT EXISTS sounds ( file_name TEXT, duration_ms INTEGER, enabled INTEGER NOT NULL DEFAULT 1, + volume INTEGER NOT NULL DEFAULT 100, + is_system INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL ); @@ -93,6 +95,19 @@ CREATE TABLE IF NOT EXISTS recordings ( FOREIGN KEY (map_id) REFERENCES maps(id) ON DELETE SET NULL ); +CREATE TABLE IF NOT EXISTS transitions ( + id TEXT PRIMARY KEY, + site_id TEXT NOT NULL, + from_map_id TEXT NOT NULL, + to_map_id TEXT NOT NULL, + start_position_id TEXT NOT NULL, + goal_position_id TEXT NOT NULL, + mission_id TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS dashboards ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -335,6 +350,51 @@ bool Database::applySchemaMigrations(std::string& err) setMeta("schema_version", "3"); } + ver = getMeta("schema_version").value_or("1"); + if (ver == "3") + { + if (!tableHasColumn(db_, "sounds", "volume")) + { + if (!execSql(db_, "ALTER TABLE sounds ADD COLUMN volume INTEGER NOT NULL DEFAULT 100", err)) + return false; + } + if (!tableHasColumn(db_, "sounds", "is_system")) + { + if (!execSql(db_, "ALTER TABLE sounds ADD COLUMN is_system INTEGER NOT NULL DEFAULT 0", err)) + return false; + } + setMeta("schema_version", "4"); + } + + ver = getMeta("schema_version").value_or("1"); + if (ver == "4") + { + if (!execSql(db_, + "CREATE TABLE IF NOT EXISTS transitions (" + "id TEXT PRIMARY KEY, " + "site_id TEXT NOT NULL, " + "from_map_id TEXT NOT NULL, " + "to_map_id TEXT NOT NULL, " + "start_position_id TEXT NOT NULL, " + "goal_position_id TEXT NOT NULL, " + "mission_id TEXT NOT NULL, " + "created_by TEXT NOT NULL DEFAULT '', " + "created_at TEXT NOT NULL, " + "updated_at TEXT NOT NULL" + ")", + err)) + return false; + setMeta("schema_version", "5"); + } + + ver = getMeta("schema_version").value_or("1"); + if (ver == "5") + { + if (!execSql(db_, "ALTER TABLE transitions ADD COLUMN created_by TEXT NOT NULL DEFAULT ''", err)) + return false; + setMeta("schema_version", "6"); + } + return true; } diff --git a/src/storage/sound_store.cpp b/src/storage/sound_store.cpp index 779fdb6..830639f 100644 --- a/src/storage/sound_store.cpp +++ b/src/storage/sound_store.cpp @@ -5,12 +5,21 @@ #include "util/id_util.hpp" #include "util/string_util.hpp" +#include "util/string_util.hpp" + #include +#include +#include +#include namespace lm { namespace { +constexpr const char* kSoundSelect = + "SELECT id, name, description, file_name, duration_ms, enabled, volume, is_system, created_at, updated_at " + "FROM sounds"; + nlohmann::json rowToJson(sqlite3_stmt* stmt) { auto textOrNull = [&](int col) -> nlohmann::json { @@ -26,8 +35,44 @@ nlohmann::json rowToJson(sqlite3_stmt* stmt) ? nlohmann::json(nullptr) : nlohmann::json(sqlite3_column_int(stmt, 4))}, {"enabled", sqlite3_column_int(stmt, 5) != 0}, - {"created_at", textOrNull(6)}, - {"updated_at", textOrNull(7)}}; + {"volume", sqlite3_column_int(stmt, 6)}, + {"is_system", sqlite3_column_int(stmt, 7) != 0}, + {"created_at", textOrNull(8)}, + {"updated_at", textOrNull(9)}}; +} + +int clampVolume(int v) +{ + if (v < 0) + return 0; + if (v > 100) + return 100; + return v; +} + +bool isReservedSystemName(const std::string& name) +{ + const std::string lower = StringUtil::toLower(StringUtil::trimCopy(name)); + return lower == "beep" || lower == "horn" || lower == "chime"; +} + +bool findNameConflict(sqlite3* db, const std::string& name, const std::string& except_id) +{ + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db, + except_id.empty() + ? "SELECT id FROM sounds WHERE lower(name) = lower(?1) LIMIT 1" + : "SELECT id FROM sounds WHERE lower(name) = lower(?1) AND id != ?2 LIMIT 1", + -1, + &stmt, + nullptr) != SQLITE_OK) + return false; + sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT); + if (!except_id.empty()) + sqlite3_bind_text(stmt, 2, except_id.c_str(), -1, SQLITE_TRANSIENT); + const bool conflict = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return conflict; } } // namespace @@ -38,13 +83,9 @@ nlohmann::json SoundStore::list() const { std::lock_guard lock(mu_); nlohmann::json sounds = nlohmann::json::array(); + const std::string query = std::string(kSoundSelect) + " ORDER BY is_system DESC, name"; sqlite3_stmt* stmt = nullptr; - if (sqlite3_prepare_v2(db_.handle(), - "SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at " - "FROM sounds ORDER BY name", - -1, - &stmt, - nullptr) != SQLITE_OK) + if (sqlite3_prepare_v2(db_.handle(), query.c_str(), -1, &stmt, nullptr) != SQLITE_OK) return sounds; while (sqlite3_step(stmt) == SQLITE_ROW) sounds.push_back(rowToJson(stmt)); @@ -55,13 +96,9 @@ nlohmann::json SoundStore::list() const std::optional SoundStore::find(const std::string& id) const { std::lock_guard lock(mu_); + const std::string query = std::string(kSoundSelect) + " WHERE id = ?1"; sqlite3_stmt* stmt = nullptr; - if (sqlite3_prepare_v2(db_.handle(), - "SELECT id, name, description, file_name, duration_ms, enabled, created_at, updated_at " - "FROM sounds WHERE id = ?1", - -1, - &stmt, - nullptr) != SQLITE_OK) + if (sqlite3_prepare_v2(db_.handle(), query.c_str(), -1, &stmt, nullptr) != SQLITE_OK) return std::nullopt; sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT); std::optional out; @@ -84,17 +121,31 @@ std::optional SoundStore::create(const nlohmann::json& payload, err = "name is required"; return std::nullopt; } + if (isReservedSystemName(name)) + { + err = "name reserved for built-in system sounds"; + return std::nullopt; + } + + std::lock_guard lock(mu_); + if (findNameConflict(db_.handle(), name, "")) + { + err = "sound name already exists"; + return std::nullopt; + } const std::string id = payload.value("id", IdUtil::newId()); const std::string now = IdUtil::nowIso8601(); const std::string description = payload.value("description", ""); const bool enabled = payload.value("enabled", true); + const int volume = clampVolume(payload.value("volume", 100)); + const bool is_system = payload.value("is_system", false); - std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), - "INSERT INTO sounds(id, name, description, file_name, duration_ms, enabled, created_at, updated_at) " - "VALUES(?1,?2,?3,NULL,NULL,?4,?5,?6)", + "INSERT INTO sounds(id, name, description, file_name, duration_ms, enabled, volume, " + "is_system, created_at, updated_at) " + "VALUES(?1,?2,?3,NULL,NULL,?4,?5,?6,?7,?8)", -1, &stmt, nullptr) != SQLITE_OK) @@ -106,8 +157,10 @@ std::optional SoundStore::create(const nlohmann::json& payload, sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_text(stmt, 3, description.c_str(), -1, SQLITE_TRANSIENT); sqlite3_bind_int(stmt, 4, enabled ? 1 : 0); - sqlite3_bind_text(stmt, 5, now.c_str(), -1, SQLITE_TRANSIENT); - sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(stmt, 5, volume); + sqlite3_bind_int(stmt, 6, is_system ? 1 : 0); + sqlite3_bind_text(stmt, 7, now.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT); if (sqlite3_step(stmt) != SQLITE_DONE) { @@ -123,6 +176,8 @@ std::optional SoundStore::create(const nlohmann::json& payload, {"file_name", nullptr}, {"duration_ms", nullptr}, {"enabled", enabled}, + {"volume", volume}, + {"is_system", is_system}, {"created_at", now}, {"updated_at", now}}; } @@ -136,18 +191,42 @@ bool SoundStore::update(const std::string& id, const nlohmann::json& payload, st return false; } + const bool is_system = existing->value("is_system", false); nlohmann::json merged = *existing; - for (const char* key : {"name", "description", "enabled", "duration_ms"}) + for (const char* key : {"description", "enabled", "duration_ms", "volume"}) { if (payload.contains(key)) merged[key] = payload[key]; } + if (!is_system && payload.contains("name")) + { + const std::string new_name = StringUtil::trimCopy(payload["name"].get()); + if (new_name.empty()) + { + err = "name is required"; + return false; + } + if (isReservedSystemName(new_name)) + { + err = "name reserved for built-in system sounds"; + return false; + } + if (findNameConflict(db_.handle(), new_name, id)) + { + err = "sound name already exists"; + return false; + } + merged["name"] = new_name; + } + if (merged.contains("volume")) + merged["volume"] = clampVolume(merged["volume"].get()); const std::string now = IdUtil::nowIso8601(); std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; if (sqlite3_prepare_v2(db_.handle(), - "UPDATE sounds SET name=?2, description=?3, enabled=?4, duration_ms=?5, updated_at=?6 WHERE id=?1", + "UPDATE sounds SET name=?2, description=?3, enabled=?4, duration_ms=?5, volume=?6, " + "updated_at=?7 WHERE id=?1", -1, &stmt, nullptr) != SQLITE_OK) @@ -163,7 +242,8 @@ bool SoundStore::update(const std::string& id, const nlohmann::json& payload, st sqlite3_bind_int(stmt, 5, merged["duration_ms"].get()); else sqlite3_bind_null(stmt, 5); - sqlite3_bind_text(stmt, 6, now.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int(stmt, 6, clampVolume(merged.value("volume", 100))); + sqlite3_bind_text(stmt, 7, now.c_str(), -1, SQLITE_TRANSIENT); const bool ok = sqlite3_step(stmt) == SQLITE_DONE; if (!ok) @@ -180,6 +260,11 @@ bool SoundStore::remove(const std::string& id, std::string& err) err = "sound not found"; return false; } + if (existing->value("is_system", false)) + { + err = "system sounds cannot be deleted"; + return false; + } std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; @@ -217,11 +302,17 @@ bool SoundStore::saveFile(const std::string& id, const std::string& bytes, std::string& err) { - if (!find(id)) + auto existing = find(id); + if (!existing) { err = "sound not found"; return false; } + if (existing->value("is_system", false)) + { + err = "system sounds cannot be replaced"; + return false; + } std::error_code ec; std::filesystem::create_directories(db_.soundsDir(), ec); @@ -254,4 +345,57 @@ bool SoundStore::saveFile(const std::string& id, return ok; } +bool SoundStore::ensureSystemDefaults(std::string& err) +{ + { + std::vector dup_ids; + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "SELECT id FROM sounds WHERE is_system = 0 AND lower(name) IN ('beep', 'horn', 'chime')", + -1, + &stmt, + nullptr) == SQLITE_OK) + { + while (sqlite3_step(stmt) == SQLITE_ROW) + { + if (sqlite3_column_type(stmt, 0) != SQLITE_NULL) + dup_ids.push_back(reinterpret_cast(sqlite3_column_text(stmt, 0))); + } + sqlite3_finalize(stmt); + } + for (const auto& dup_id : dup_ids) + { + sqlite3_stmt* del = nullptr; + if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM sounds WHERE id = ?1", -1, &del, nullptr) == SQLITE_OK) + { + sqlite3_bind_text(del, 1, dup_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_step(del); + sqlite3_finalize(del); + } + } + } + + struct DefaultSound + { + const char* id; + const char* name; + }; + static const DefaultSound kDefaults[] = { + {"sys_beep", "Beep"}, + {"sys_horn", "Horn"}, + {"sys_chime", "Chime"}, + }; + + for (const auto& d : kDefaults) + { + if (find(d.id)) + continue; + nlohmann::json payload = {{"id", d.id}, {"name", d.name}, {"is_system", true}, {"volume", 100}}; + if (!create(payload, err)) + return false; + } + return true; +} + } // namespace lm diff --git a/src/storage/sound_store.hpp b/src/storage/sound_store.hpp index b613186..06c0665 100644 --- a/src/storage/sound_store.hpp +++ b/src/storage/sound_store.hpp @@ -24,6 +24,7 @@ public: std::optional filePath(const std::string& id) const; bool saveFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err); + bool ensureSystemDefaults(std::string& err); private: Database& db_; diff --git a/src/storage/transition_store.cpp b/src/storage/transition_store.cpp new file mode 100644 index 0000000..4c4245b --- /dev/null +++ b/src/storage/transition_store.cpp @@ -0,0 +1,258 @@ +#include "storage/transition_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) : ""; + }; + return {{"id", text(0)}, + {"site_id", text(1)}, + {"from_map_id", text(2)}, + {"to_map_id", text(3)}, + {"start_position_id", text(4)}, + {"goal_position_id", text(5)}, + {"mission_id", text(6)}, + {"created_by", text(7)}, + {"created_at", text(8)}, + {"updated_at", text(9)}}; +} + +} // namespace + +TransitionStore::TransitionStore(Database& db) : db_(db) {} + +nlohmann::json TransitionStore::list(const std::string& site_id) const +{ + std::lock_guard lock(mu_); + nlohmann::json out = nlohmann::json::array(); + std::string sql = + "SELECT id, site_id, from_map_id, to_map_id, start_position_id, goal_position_id, mission_id, created_by, created_at, updated_at " + "FROM transitions"; + if (!site_id.empty()) + sql += " WHERE site_id = ?1"; + sql += " ORDER BY site_id, from_map_id, to_map_id, created_at"; + + 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 TransitionStore::findBetween(const std::string& from_map_id, + const std::string& to_map_id) const +{ + if (from_map_id.empty() || to_map_id.empty() || from_map_id == to_map_id) + return std::nullopt; + + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "SELECT id, site_id, from_map_id, to_map_id, start_position_id, goal_position_id, mission_id, " + "created_by, created_at, updated_at FROM transitions WHERE from_map_id = ?1 AND to_map_id = ?2 LIMIT 1", + -1, + &stmt, + nullptr) != SQLITE_OK) + return std::nullopt; + sqlite3_bind_text(stmt, 1, from_map_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, to_map_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 TransitionStore::find(const std::string& id) const +{ + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "SELECT id, site_id, from_map_id, to_map_id, start_position_id, goal_position_id, mission_id, " + "created_by, created_at, updated_at FROM transitions WHERE id = ?1", + -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 TransitionStore::create(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 from_map_id = StringUtil::trimCopy(payload.value("from_map_id", "")); + const std::string to_map_id = StringUtil::trimCopy(payload.value("to_map_id", "")); + const std::string start_position_id = StringUtil::trimCopy(payload.value("start_position_id", "")); + const std::string goal_position_id = StringUtil::trimCopy(payload.value("goal_position_id", "")); + const std::string mission_id = StringUtil::trimCopy(payload.value("mission_id", "")); + const std::string created_by = StringUtil::trimCopy(payload.value("created_by", "")); + if (site_id.empty() || from_map_id.empty() || to_map_id.empty() || start_position_id.empty() || goal_position_id.empty() || + mission_id.empty()) + { + err = "missing required fields"; + return std::nullopt; + } + if (from_map_id == to_map_id) + { + err = "from_map_id and to_map_id must differ"; + return std::nullopt; + } + + const std::string id = payload.value("id", IdUtil::newId()); + const std::string now = IdUtil::nowIso8601(); + + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "INSERT INTO transitions(id, site_id, from_map_id, to_map_id, start_position_id, goal_position_id, " + "mission_id, created_by, created_at, updated_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)", + -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, from_map_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, to_map_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 5, start_position_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 6, goal_position_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 7, mission_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 8, created_by.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 9, now.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 10, now.c_str(), -1, SQLITE_TRANSIENT); + if (sqlite3_step(stmt) != SQLITE_DONE) + { + err = sqlite3_errmsg(db_.handle()); + sqlite3_finalize(stmt); + return std::nullopt; + } + sqlite3_finalize(stmt); + return nlohmann::json{{"id", id}, + {"site_id", site_id}, + {"from_map_id", from_map_id}, + {"to_map_id", to_map_id}, + {"start_position_id", start_position_id}, + {"goal_position_id", goal_position_id}, + {"mission_id", mission_id}, + {"created_by", created_by}, + {"created_at", now}, + {"updated_at", now}}; +} + +bool TransitionStore::update(const std::string& id, const nlohmann::json& payload, std::string& err) +{ + auto existing = find(id); + if (!existing) + { + err = "transition not found"; + return false; + } + + nlohmann::json merged = *existing; + for (const char* key : + {"site_id", "from_map_id", "to_map_id", "start_position_id", "goal_position_id", "mission_id"}) + { + if (payload.contains(key)) + merged[key] = payload[key]; + } + + const std::string site_id = StringUtil::trimCopy(merged.value("site_id", "")); + const std::string from_map_id = StringUtil::trimCopy(merged.value("from_map_id", "")); + const std::string to_map_id = StringUtil::trimCopy(merged.value("to_map_id", "")); + const std::string start_position_id = StringUtil::trimCopy(merged.value("start_position_id", "")); + const std::string goal_position_id = StringUtil::trimCopy(merged.value("goal_position_id", "")); + const std::string mission_id = StringUtil::trimCopy(merged.value("mission_id", "")); + if (site_id.empty() || from_map_id.empty() || to_map_id.empty() || start_position_id.empty() || goal_position_id.empty() || + mission_id.empty()) + { + err = "missing required fields"; + return false; + } + if (from_map_id == to_map_id) + { + err = "from_map_id and to_map_id must differ"; + return false; + } + + const std::string now = IdUtil::nowIso8601(); + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "UPDATE transitions SET site_id=?2, from_map_id=?3, to_map_id=?4, start_position_id=?5, " + "goal_position_id=?6, mission_id=?7, updated_at=?8 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_bind_text(stmt, 2, site_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 3, from_map_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, to_map_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 5, start_position_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 6, goal_position_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 7, mission_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 8, now.c_str(), -1, SQLITE_TRANSIENT); + const bool ok = sqlite3_step(stmt) == SQLITE_DONE; + if (!ok) + err = sqlite3_errmsg(db_.handle()); + sqlite3_finalize(stmt); + return ok; +} + +bool TransitionStore::remove(const std::string& id, std::string& err) +{ + if (!find(id)) + { + err = "transition not found"; + return false; + } + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM transitions 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); + const bool ok = sqlite3_step(stmt) == SQLITE_DONE; + if (!ok) + err = sqlite3_errmsg(db_.handle()); + sqlite3_finalize(stmt); + return ok; +} + +} // namespace lm + diff --git a/src/storage/transition_store.hpp b/src/storage/transition_store.hpp new file mode 100644 index 0000000..a75790f --- /dev/null +++ b/src/storage/transition_store.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include +#include + +namespace lm { + +class Database; + +class TransitionStore +{ +public: + explicit TransitionStore(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& from_map_id, + const std::string& to_map_id) const; + std::optional create(const nlohmann::json& payload, std::string& err); + bool update(const std::string& id, const nlohmann::json& payload, std::string& err); + bool remove(const std::string& id, std::string& err); + +private: + Database& db_; + mutable std::mutex mu_; +}; + +} // namespace lm + diff --git a/www/app.js b/www/app.js index 64cf8fc..85281d2 100644 --- a/www/app.js +++ b/www/app.js @@ -11,6 +11,7 @@ const pageMapsEl = el("pageMaps"); const pageMissionsEl = el("pageMissions"); const pageIntegrationsEl = el("pageIntegrations"); const pageSoundsEl = el("pageSounds"); +const pageTransitionsEl = el("pageTransitions"); const pageMonitoringEl = el("pageMonitoring"); const pageHelpEl = el("pageHelp"); const contentEl = document.querySelector(".content"); @@ -125,7 +126,7 @@ const state = { }; function setActivePage(page) { - const valid = ["dashboard", "config", "maps", "missions", "sounds", "integrations", "monitoring", "help"]; + const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "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)); @@ -137,6 +138,7 @@ function setActivePage(page) { if (pageMapsEl) pageMapsEl.hidden = p !== "maps"; if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions"; if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds"; + if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions"; if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations"; if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring"; if (pageHelpEl) pageHelpEl.hidden = p !== "help"; @@ -148,6 +150,7 @@ function setActivePage(page) { contentEl.classList.toggle("content--maps", p === "maps"); contentEl.classList.toggle("content--missions", p === "missions"); contentEl.classList.toggle("content--sounds", p === "sounds"); + contentEl.classList.toggle("content--transitions", p === "transitions"); contentEl.classList.toggle("content--integrations", p === "integrations"); contentEl.classList.toggle("content--monitoring", p === "monitoring"); contentEl.classList.toggle("content--help", p === "help"); @@ -157,6 +160,8 @@ function setActivePage(page) { if (p === "maps" && window.MapsApp) window.MapsApp.onPageShow(); if (p === "sounds" && window.SoundsApp) window.SoundsApp.onPageShow(); else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide(); + if (p === "transitions" && window.TransitionsApp) window.TransitionsApp.onPageShow(); + else if (window.TransitionsApp?.onPageHide) window.TransitionsApp.onPageHide(); if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow(); else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide(); if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow(); diff --git a/www/auth.js b/www/auth.js index 07142d2..bd7b02a 100644 --- a/www/auth.js +++ b/www/auth.js @@ -149,7 +149,8 @@ dashboard: "dashboard", maps: "maps", missions: "missions", - sounds: "integrations", + sounds: "sounds", + transitions: "maps", integrations: "integrations", }; const resource = map[page]; @@ -168,6 +169,7 @@ document.body.classList.toggle("auth-readonly-config", !canWrite("config")); document.body.classList.toggle("auth-readonly-maps", !canWrite("maps")); document.body.classList.toggle("auth-readonly-missions", !canWrite("missions")); + document.body.classList.toggle("auth-readonly-sounds", !canWrite("sounds") && !canWrite("integrations")); document.body.classList.toggle("auth-readonly-integrations", !canWrite("integrations")); } diff --git a/www/i18n.js b/www/i18n.js index 1593bb5..1cbb679 100644 --- a/www/i18n.js +++ b/www/i18n.js @@ -73,6 +73,7 @@ "nav.missions": "Missions", "nav.maps": "Maps", "nav.sounds": "Sounds", + "nav.transitions": "Transitions", "nav.build-robot": "Build Robot", "nav.monitoring-log": "System log", "nav.integrations": "Tích hợp", @@ -486,22 +487,73 @@ "maps.menu.save": "Lưu map", "sounds.title": "Sounds", - "sounds.subtitle": "Setup → Sounds — upload và quản lý âm thanh robot cho sound zones.", + "sounds.subtitle": "Tạo và chỉnh sửa sounds.", + "sounds.helpTitle": "Trợ giúp Sounds", + "sounds.helpBody": "Upload âm thanh cho sound zones trên map. System sounds (Beep, Horn, Chime) không thể xóa. Dùng Listen hoặc Play on robot để kiểm tra volume.", "sounds.create": "Tạo sound", + "sounds.clearFilters": "Xóa bộ lọc", + "sounds.filterLabel": "Lọc:", + "sounds.filterPlaceholder": "Lọc theo tên...", + "sounds.itemsFound": "{n} mục", + "sounds.pageOf": "Trang {page} / {total}", + "sounds.empty": "Chưa có sound.", + "sounds.emptyFilter": "Không có sound khớp bộ lọc.", "sounds.createTitle": "Tạo sound", "sounds.editTitle": "Sửa sound", - "sounds.empty": "Chưa có sound. Tạo mới để dùng trong sound zones.", "sounds.name": "Tên", + "sounds.note": "Ghi chú", "sounds.description": "Mô tả", "sounds.enabled": "Bật", + "sounds.volume": "Volume (0–100)", + "sounds.volumeHint": "100% ≈ 80 dB. Kiểm tra volume bằng Play on robot.", + "sounds.volumeShort": "{volume}%", "sounds.file": "File âm thanh", "sounds.noFile": "Chưa có file", + "sounds.systemNoFile": "Âm thanh hệ thống (phát trên robot)", + "sounds.systemBadge": "System sound", + "sounds.systemShort": "System", "sounds.upload": "Upload file…", + "sounds.listen": "Listen", + "sounds.playOnRobot": "Play on robot", "sounds.play": "Phát", "sounds.playFailed": "Không phát được file.", "sounds.fileMeta": "{name} · {duration}", + "sounds.saveChanges": "Lưu thay đổi", "sounds.nameRequired": "Nhập tên sound.", + "sounds.nameDuplicate": "Đã có sound cùng tên. Hãy Edit bản ghi hiện có.", + "sounds.reservedName": "Beep, Horn, Chime là system sounds — dùng Edit trên dòng System.", "sounds.deleteConfirm": "Xóa sound này?", + "sounds.deleteConfirmTitle": "Xóa sound?", + "sounds.deleteConfirmText": "Xóa \"{name}\"? Sound zones đang dùng có thể bị ảnh hưởng.", + + "transitions.title": "Transitions", + "transitions.subtitle": "Tạo và chỉnh sửa transitions.", + "transitions.helpTitle": "Trợ giúp Transitions", + "transitions.helpBody": "Transition nối hai map trong cùng site. Start position trên map nguồn, Goal position trên map đích (cùng vị trí vật lý). Mission phải chứa các bước switch map.", + "transitions.create": "Tạo transition", + "transitions.clearFilters": "Xóa bộ lọc", + "transitions.filterLabel": "Lọc:", + "transitions.filterPlaceholder": "Lọc theo position hoặc mission...", + "transitions.itemsFound": "{n} mục", + "transitions.pageOf": "Trang {page} / {total}", + "transitions.colStart": "Start", + "transitions.colGoal": "Goal", + "transitions.colMission": "Mission", + "transitions.colCreatedBy": "Created by", + "transitions.colFunctions": "Functions", + "transitions.empty": "Chưa có transition.", + "transitions.emptyFilter": "Không có transition khớp bộ lọc.", + "transitions.createTitle": "Tạo transition", + "transitions.editTitle": "Sửa transition", + "transitions.site": "Site", + "transitions.fromMap": "From map", + "transitions.toMap": "To map", + "transitions.startPosition": "Start position", + "transitions.goalPosition": "Goal position", + "transitions.mission": "Mission", + "transitions.deleteTitle": "Xóa transition?", + "transitions.deleteConfirmText": "Xóa transition {from} → {to}?", + "transitions.error.missing": "Thiếu trường bắt buộc.", "missions.title": "Missions", "missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.", @@ -571,6 +623,7 @@ "missions.action.drop_cart": "Drop cart", "missions.action.user_log": "User log", "missions.action.play_sound": "Play sound", + "missions.action.switch_map": "Switch map", "missions.error.nameRequired": "Tên mission không được trống.", "missions.error.nameDuplicate": "Tên mission đã tồn tại.", "missions.error.nameEmpty": "Tên không được trống.", @@ -691,6 +744,7 @@ "nav.missions": "Missions", "nav.maps": "Maps", "nav.sounds": "Sounds", + "nav.transitions": "Transitions", "nav.build-robot": "Build Robot", "nav.monitoring-log": "System log", "nav.integrations": "Integrations", @@ -1104,22 +1158,73 @@ "maps.menu.save": "Save map", "sounds.title": "Sounds", - "sounds.subtitle": "Setup → Sounds — upload and manage robot sounds for sound zones.", + "sounds.subtitle": "Create and edit sounds.", + "sounds.helpTitle": "Sounds help", + "sounds.helpBody": "Upload audio for sound zones on maps. System sounds (Beep, Horn, Chime) cannot be deleted. Use Listen or Play on robot to verify volume.", "sounds.create": "Create sound", + "sounds.clearFilters": "Clear filters", + "sounds.filterLabel": "Filter:", + "sounds.filterPlaceholder": "Filter by name...", + "sounds.itemsFound": "{n} item(s) found", + "sounds.pageOf": "Page {page} of {total}", + "sounds.empty": "No sounds yet.", + "sounds.emptyFilter": "No sounds match the filter.", "sounds.createTitle": "Create sound", "sounds.editTitle": "Edit sound", - "sounds.empty": "No sounds yet. Create one to use in sound zones.", "sounds.name": "Name", + "sounds.note": "Note", "sounds.description": "Description", "sounds.enabled": "Enabled", + "sounds.volume": "Volume (0–100)", + "sounds.volumeHint": "100% is approximately 80 dB. Verify volume by playing on the robot.", + "sounds.volumeShort": "{volume}%", "sounds.file": "Audio file", "sounds.noFile": "No file uploaded", + "sounds.systemNoFile": "Built-in system sound (plays on robot)", + "sounds.systemBadge": "System sound", + "sounds.systemShort": "System", "sounds.upload": "Upload file…", + "sounds.listen": "Listen", + "sounds.playOnRobot": "Play on robot", "sounds.play": "Play", "sounds.playFailed": "Could not play file.", "sounds.fileMeta": "{name} · {duration}", + "sounds.saveChanges": "Save changes", "sounds.nameRequired": "Enter a sound name.", + "sounds.nameDuplicate": "A sound with this name already exists. Edit the existing entry instead.", + "sounds.reservedName": "Beep, Horn, and Chime are built-in system sounds — use Edit on the System row.", "sounds.deleteConfirm": "Delete this sound?", + "sounds.deleteConfirmTitle": "Delete sound?", + "sounds.deleteConfirmText": "Delete \"{name}\"? Sound zones using it may be affected.", + + "transitions.title": "Transitions", + "transitions.subtitle": "Create and edit transitions.", + "transitions.helpTitle": "Transitions help", + "transitions.helpBody": "A transition links two maps in the same site. Start position is on the source map; Goal position is on the destination map (same physical location). The mission should include switch map actions.", + "transitions.create": "Create transition", + "transitions.clearFilters": "Clear filters", + "transitions.filterLabel": "Filter:", + "transitions.filterPlaceholder": "Filter by position or mission...", + "transitions.itemsFound": "{n} item(s) found", + "transitions.pageOf": "Page {page} of {total}", + "transitions.colStart": "Start", + "transitions.colGoal": "Goal", + "transitions.colMission": "Mission", + "transitions.colCreatedBy": "Created by", + "transitions.colFunctions": "Functions", + "transitions.empty": "No transitions yet.", + "transitions.emptyFilter": "No transitions match the filter.", + "transitions.createTitle": "Create transition", + "transitions.editTitle": "Edit transition", + "transitions.site": "Site", + "transitions.fromMap": "From map", + "transitions.toMap": "To map", + "transitions.startPosition": "Start position", + "transitions.goalPosition": "Goal position", + "transitions.mission": "Mission", + "transitions.deleteTitle": "Delete transition?", + "transitions.deleteConfirmText": "Delete transition {from} → {to}?", + "transitions.error.missing": "Missing required fields.", "missions.title": "Missions", "missions.subtitle": "Setup → Missions — robot task list.", @@ -1189,6 +1294,7 @@ "missions.action.drop_cart": "Drop cart", "missions.action.user_log": "User log", "missions.action.play_sound": "Play sound", + "missions.action.switch_map": "Switch map", "missions.error.nameRequired": "Mission name cannot be empty.", "missions.error.nameDuplicate": "Mission name already exists.", "missions.error.nameEmpty": "Name cannot be empty.", diff --git a/www/index.html b/www/index.html index 69e35a8..606f44e 100644 --- a/www/index.html +++ b/www/index.html @@ -864,53 +864,209 @@ + +