diff --git a/src/server/api_media_routes.cpp b/src/server/api_media_routes.cpp index 2bdd740..27f314b 100644 --- a/src/server/api_media_routes.cpp +++ b/src/server/api_media_routes.cpp @@ -138,6 +138,44 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr) res.body = FileUtil::readBinary(*path); }); + svr.Get(R"(/api/maps/([^/]+)/image/base$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + const auto path = map_store_.baseImagePath(id); + if (!path) + return HttpUtil::jsonError(res, 404, "map base image not found"); + res.set_header("Content-Type", HttpUtil::contentTypeForPath(*path)); + res.body = FileUtil::readBinary(*path); + }); + + svr.Post(R"(/api/maps/([^/]+)/image/composite$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + if (!req.form.has_file("file")) + return HttpUtil::jsonError(res, 400, "file is required"); + const auto& file = req.form.get_file("file"); + std::string err; + if (!map_store_.saveCompositeImageFile(id, file.content, err)) + return HttpUtil::jsonError(res, 400, err); + const auto updated = map_store_.find(id); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = updated ? updated->dump() : nlohmann::json::object().dump(); + }); + + svr.Post(R"(/api/maps/([^/]+)/image/base$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + if (!req.form.has_file("file")) + return HttpUtil::jsonError(res, 400, "file is required"); + const auto& file = req.form.get_file("file"); + std::string err; + if (!map_store_.saveBaseImageFile(id, file.content, err)) + return HttpUtil::jsonError(res, 400, err); + const auto updated = map_store_.find(id); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = updated ? updated->dump() : nlohmann::json::object().dump(); + }); + svr.Post(R"(/api/maps/([^/]+)/image$)", [this](const httplib::Request& req, httplib::Response& res) { HttpUtil::addCors(res); const std::string id = req.matches[1]; diff --git a/src/storage/map_store.cpp b/src/storage/map_store.cpp index d8e062b..8ff5caf 100644 --- a/src/storage/map_store.cpp +++ b/src/storage/map_store.cpp @@ -11,6 +11,8 @@ namespace lm { namespace { +constexpr const char* kBaseImageName = "map_base.png"; + constexpr const char* kMapSelect = "SELECT id, name, description, site_id, created_by, width, height, resolution, " "origin_x, origin_y, origin_yaw, image_file, yaml_file, zones_json, created_at, updated_at " @@ -220,7 +222,14 @@ bool MapStore::update(const std::string& id, const nlohmann::json& payload, std: merged[key] = payload[key]; } if (payload.contains("zones")) + { + if (!payload["zones"].is_array()) + { + err = "zones must be an array"; + return false; + } merged["zones"] = payload["zones"]; + } const std::string now = IdUtil::nowIso8601(); const std::string zones_str = merged.value("zones", nlohmann::json::array()).dump(); @@ -307,6 +316,14 @@ std::optional MapStore::imagePath(const std::string& id) return path; } +std::optional MapStore::baseImagePath(const std::string& id) const +{ + const auto base = mapDir(id) / kBaseImageName; + if (std::filesystem::exists(base)) + return base; + return imagePath(id); +} + std::optional MapStore::yamlPath(const std::string& id) const { const auto map = find(id); @@ -338,6 +355,13 @@ bool MapStore::saveImageFile(const std::string& id, return false; } + const auto base_path = mapDir(id) / kBaseImageName; + if (!FileUtil::writeBinaryAtomic(base_path, bytes)) + { + err = "failed to write base image file"; + return false; + } + const std::string now = IdUtil::nowIso8601(); std::lock_guard lock(mu_); sqlite3_stmt* stmt = nullptr; @@ -360,6 +384,99 @@ bool MapStore::saveImageFile(const std::string& id, return ok; } +bool MapStore::saveCompositeImageFile(const std::string& id, const std::string& bytes, std::string& err) +{ + const auto map = find(id); + if (!map) + { + err = "map not found"; + return false; + } + const std::string filename = map->value("image_file", "map.png"); + if (filename.empty()) + { + err = "map has no image file"; + return false; + } + + std::error_code ec; + std::filesystem::create_directories(mapDir(id), ec); + const auto path = mapDir(id) / filename; + if (!FileUtil::writeBinaryAtomic(path, bytes)) + { + err = "failed to write composite image file"; + return false; + } + + const auto base_path = mapDir(id) / kBaseImageName; + if (!std::filesystem::exists(base_path)) + { + if (!FileUtil::writeBinaryAtomic(base_path, bytes)) + { + err = "failed to initialize base image file"; + return false; + } + } + + const std::string now = IdUtil::nowIso8601(); + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "UPDATE maps SET updated_at = ?2 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, 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 MapStore::saveBaseImageFile(const std::string& id, const std::string& bytes, std::string& err) +{ + if (!find(id)) + { + err = "map not found"; + return false; + } + + std::error_code ec; + std::filesystem::create_directories(mapDir(id), ec); + const auto path = mapDir(id) / kBaseImageName; + if (!FileUtil::writeBinaryAtomic(path, bytes)) + { + err = "failed to write base image file"; + return false; + } + + const std::string now = IdUtil::nowIso8601(); + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "UPDATE maps SET updated_at = ?2 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, 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 MapStore::saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err) { if (!find(id)) diff --git a/src/storage/map_store.hpp b/src/storage/map_store.hpp index 0a61657..8088ca4 100644 --- a/src/storage/map_store.hpp +++ b/src/storage/map_store.hpp @@ -24,8 +24,14 @@ public: std::filesystem::path mapDir(const std::string& id) const; std::optional imagePath(const std::string& id) const; + /** Scan/original floor plan (map_base.png); falls back to composite image if missing. */ + std::optional baseImagePath(const std::string& id) const; std::optional yamlPath(const std::string& id) const; bool saveImageFile(const std::string& id, const std::string& filename, const std::string& bytes, std::string& err); + /** Save flattened composite (map.png) without touching map_base.png. */ + bool saveCompositeImageFile(const std::string& id, const std::string& bytes, std::string& err); + /** Save base scan layer (map_base.png) only. */ + bool saveBaseImageFile(const std::string& id, const std::string& bytes, std::string& err); bool saveYamlFile(const std::string& id, const std::string& yaml_text, std::string& err); private: diff --git a/www/app.js b/www/app.js index 133346a..64cf8fc 100644 --- a/www/app.js +++ b/www/app.js @@ -10,6 +10,7 @@ const pageConfigEl = el("pageConfig"); const pageMapsEl = el("pageMaps"); const pageMissionsEl = el("pageMissions"); const pageIntegrationsEl = el("pageIntegrations"); +const pageSoundsEl = el("pageSounds"); const pageMonitoringEl = el("pageMonitoring"); const pageHelpEl = el("pageHelp"); const contentEl = document.querySelector(".content"); @@ -124,7 +125,7 @@ const state = { }; function setActivePage(page) { - const valid = ["dashboard", "config", "maps", "missions", "integrations", "monitoring", "help"]; + const valid = ["dashboard", "config", "maps", "missions", "sounds", "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)); @@ -135,6 +136,7 @@ function setActivePage(page) { if (pageConfigEl) pageConfigEl.hidden = p !== "config"; if (pageMapsEl) pageMapsEl.hidden = p !== "maps"; if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions"; + if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds"; if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations"; if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring"; if (pageHelpEl) pageHelpEl.hidden = p !== "help"; @@ -145,6 +147,7 @@ function setActivePage(page) { contentEl.classList.toggle("content--config", p === "config"); 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--integrations", p === "integrations"); contentEl.classList.toggle("content--monitoring", p === "monitoring"); contentEl.classList.toggle("content--help", p === "help"); @@ -152,6 +155,8 @@ function setActivePage(page) { if (p === "missions" && window.MissionsApp) window.MissionsApp.onPageShow(); else if (window.MissionsApp?.onPageHide) window.MissionsApp.onPageHide(); 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 === "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 7b14e97..07142d2 100644 --- a/www/auth.js +++ b/www/auth.js @@ -149,6 +149,7 @@ dashboard: "dashboard", maps: "maps", missions: "missions", + sounds: "integrations", integrations: "integrations", }; const resource = map[page]; diff --git a/www/i18n.js b/www/i18n.js index 0b1883b..fd454a8 100644 --- a/www/i18n.js +++ b/www/i18n.js @@ -72,6 +72,7 @@ "nav.dashboardsList": "Dashboards", "nav.missions": "Missions", "nav.maps": "Maps", + "nav.sounds": "Sounds", "nav.build-robot": "Build Robot", "nav.monitoring-log": "System log", "nav.integrations": "Tích hợp", @@ -403,8 +404,94 @@ "maps.editor.statusWorldIdle": "— m", "maps.editor.statusWorld": "X {x}, Y {y} m", "maps.editor.objectTypesNone": "Chưa chọn object-type", + "maps.editor.objectType.wall": "Walls", + "maps.editor.objectType.floor": "Floors", + "maps.editor.objectType.position": "Positions", + "maps.editor.objectType.forbidden": "Forbidden zones", + "maps.editor.objectType.preferred": "Preferred zones", + "maps.editor.objectType.unpreferred": "Unpreferred zones", + "maps.editor.objectType.speed": "Speed zones", + "maps.editor.objectType.sound": "Sound zones", + "maps.editor.objectType.directional": "Directional zones", + "maps.editor.objectType.directionalLine": "Directional lines", + "maps.editor.objectType.planner": "Planner zones", + "maps.editor.objectType.io": "I/O zones", + "maps.editor.filter.walls": "Walls", + "maps.editor.filter.floors": "Floors", + "maps.editor.filter.positions": "Positions", + "maps.editor.filter.forbidden": "Forbidden", + "maps.editor.filter.preferred": "Preferred", + "maps.editor.filter.unpreferred": "Unpreferred", + "maps.editor.filter.speed": "Speed", + "maps.editor.filter.sound": "Sound", + "maps.editor.tool.draw": "Vẽ — line hoặc polygon (walls, zones…)", + "maps.editor.tool.select": "Chọn object", + "maps.editor.tool.erase": "Tẩy object", + "maps.editor.tool.eraser": "Tẩy pixel — xóa noise trên floor plan (Walls/Floors layer)", + "maps.editor.tool.eraseShape": "Xóa line/shape đã vẽ", + "maps.editor.tool.eraseSelection": "Xóa vùng chọn — tẩy pixel trong khung", + "maps.editor.tool.confirmDraw": "Xác nhận hình vẽ", + "maps.editor.drawNeedMorePoints": "Cần thêm điểm trước khi xác nhận (wall ≥ 2, polygon ≥ 3).", + "maps.editor.position.title": "Position", + "maps.editor.position.name": "Tên", + "maps.editor.position.x": "X (m)", + "maps.editor.position.y": "Y (m)", + "maps.editor.position.yaw": "Hướng (°)", + "maps.editor.position.hint": "Click map và kéo để đặt hướng, rồi xác nhận.", + "maps.editor.position.invalid": "Nhập X, Y và hướng hợp lệ.", + "maps.editor.speed.title": "Speed zone", + "maps.editor.speed.limit": "Giới hạn tốc độ (m/s)", + "maps.editor.speed.hint": "Robot giảm tốc trong vùng này (0.1–1.5 m/s).", + "maps.editor.speed.invalid": "Nhập tốc độ hợp lệ (0.1–1.5 m/s).", + "maps.editor.sound.title": "Sound zone", + "maps.editor.sound.select": "Sound", + "maps.editor.sound.noSound": "— Chọn sound —", + "maps.editor.sound.manage": "Quản lý sounds tại Setup → Sounds", + "maps.editor.sound.invalid": "Chọn một sound.", + "maps.editor.directional.title": "Directional zone", + "maps.editor.directional.direction": "Hướng", + "maps.editor.directional.degOption": "{deg}°", + "maps.editor.directional.shapeHint": "Robot không được di chuyển ngược hướng mũi tên (bước 45°).", + "maps.editor.directional.lineWidth": "Độ rộng line (px)", + "maps.editor.directional.reversed": "Đảo hướng", + "maps.editor.directional.lineHint": "Hướng theo line từ điểm đầu đến điểm cuối.", + "maps.editor.directional.lineWidthInvalid": "Nhập độ rộng line hợp lệ (≥ 2 px).", + "maps.editor.planner.title": "Planner zone", + "maps.editor.planner.noLocalization": "No localization (chỉ encoder)", + "maps.editor.planner.lookAhead": "Look-ahead (thu hẹp field of view)", + "maps.editor.planner.ignoreObstacles": "Ignore obstacles", + "maps.editor.planner.pathDeviation": "Path deviation (m)", + "maps.editor.planner.pathTimeout": "Path timeout (s)", + "maps.editor.io.title": "I/O zone", + "maps.editor.io.module": "I/O module", + "maps.editor.io.plcRegister": "PLC register", + "maps.editor.io.plcValue": "Giá trị", + "maps.editor.io.plcMode": "PLC mode", + "maps.editor.io.plcModeSet": "Set", + "maps.editor.io.plcModeAdd": "Add", + "maps.editor.io.plcModeSubtract": "Subtract", + "maps.editor.io.hint": "Robot kích hoạt I/O khi vào vùng này.", + "maps.editor.io.moduleRequired": "Nhập tên I/O module.", "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.create": "Tạo sound", + "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.description": "Mô tả", + "sounds.enabled": "Bật", + "sounds.file": "File âm thanh", + "sounds.noFile": "Chưa có file", + "sounds.upload": "Upload file…", + "sounds.play": "Phát", + "sounds.playFailed": "Không phát được file.", + "sounds.fileMeta": "{name} · {duration}", + "sounds.nameRequired": "Nhập tên sound.", + "sounds.deleteConfirm": "Xóa sound này?", + "missions.title": "Missions", "missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.", "missions.create": "Tạo mission", @@ -592,6 +679,7 @@ "nav.dashboardsList": "Dashboards", "nav.missions": "Missions", "nav.maps": "Maps", + "nav.sounds": "Sounds", "nav.build-robot": "Build Robot", "nav.monitoring-log": "System log", "nav.integrations": "Integrations", @@ -923,8 +1011,94 @@ "maps.editor.statusWorldIdle": "— m", "maps.editor.statusWorld": "X {x}, Y {y} m", "maps.editor.objectTypesNone": "No object-type selected", + "maps.editor.objectType.wall": "Walls", + "maps.editor.objectType.floor": "Floors", + "maps.editor.objectType.position": "Positions", + "maps.editor.objectType.forbidden": "Forbidden zones", + "maps.editor.objectType.preferred": "Preferred zones", + "maps.editor.objectType.unpreferred": "Unpreferred zones", + "maps.editor.objectType.speed": "Speed zones", + "maps.editor.objectType.sound": "Sound zones", + "maps.editor.objectType.directional": "Directional zones", + "maps.editor.objectType.directionalLine": "Directional lines", + "maps.editor.objectType.planner": "Planner zones", + "maps.editor.objectType.io": "I/O zones", + "maps.editor.filter.walls": "Walls", + "maps.editor.filter.floors": "Floors", + "maps.editor.filter.positions": "Positions", + "maps.editor.filter.forbidden": "Forbidden", + "maps.editor.filter.preferred": "Preferred", + "maps.editor.filter.unpreferred": "Unpreferred", + "maps.editor.filter.speed": "Speed", + "maps.editor.filter.sound": "Sound", + "maps.editor.tool.draw": "Draw — line or polygon (walls, zones…)", + "maps.editor.tool.select": "Select object", + "maps.editor.tool.erase": "Eraser", + "maps.editor.tool.eraser": "Pixel eraser — remove noise on floor plan (Walls/Floors layer)", + "maps.editor.tool.eraseShape": "Erase shape or line", + "maps.editor.tool.eraseSelection": "Erase by selection — clear pixels in rectangle", + "maps.editor.tool.confirmDraw": "Confirm shape", + "maps.editor.drawNeedMorePoints": "Add more points before confirming (wall ≥ 2, polygon ≥ 3).", + "maps.editor.position.title": "Position", + "maps.editor.position.name": "Name", + "maps.editor.position.x": "X (m)", + "maps.editor.position.y": "Y (m)", + "maps.editor.position.yaw": "Orientation (°)", + "maps.editor.position.hint": "Click map and drag to set orientation, then confirm.", + "maps.editor.position.invalid": "Enter valid X, Y and orientation.", + "maps.editor.speed.title": "Speed zone", + "maps.editor.speed.limit": "Speed limit (m/s)", + "maps.editor.speed.hint": "Robot slows to this speed inside the zone (0.1–1.5 m/s).", + "maps.editor.speed.invalid": "Enter a valid speed (0.1–1.5 m/s).", + "maps.editor.sound.title": "Sound zone", + "maps.editor.sound.select": "Sound", + "maps.editor.sound.noSound": "— Select sound —", + "maps.editor.sound.manage": "Manage sounds in Setup → Sounds", + "maps.editor.sound.invalid": "Select a sound.", + "maps.editor.directional.title": "Directional zone", + "maps.editor.directional.direction": "Direction", + "maps.editor.directional.degOption": "{deg}°", + "maps.editor.directional.shapeHint": "Robot cannot move opposite to the arrow (45° steps).", + "maps.editor.directional.lineWidth": "Line width (px)", + "maps.editor.directional.reversed": "Reverse direction", + "maps.editor.directional.lineHint": "Direction follows the line from first to last point.", + "maps.editor.directional.lineWidthInvalid": "Enter a valid line width (≥ 2 px).", + "maps.editor.planner.title": "Planner zone", + "maps.editor.planner.noLocalization": "No localization (encoders only)", + "maps.editor.planner.lookAhead": "Look-ahead (narrow field of view)", + "maps.editor.planner.ignoreObstacles": "Ignore obstacles", + "maps.editor.planner.pathDeviation": "Path deviation (m)", + "maps.editor.planner.pathTimeout": "Path timeout (s)", + "maps.editor.io.title": "I/O zone", + "maps.editor.io.module": "I/O module", + "maps.editor.io.plcRegister": "PLC register", + "maps.editor.io.plcValue": "Value", + "maps.editor.io.plcMode": "PLC mode", + "maps.editor.io.plcModeSet": "Set", + "maps.editor.io.plcModeAdd": "Add", + "maps.editor.io.plcModeSubtract": "Subtract", + "maps.editor.io.hint": "Robot activates I/O when entering the zone.", + "maps.editor.io.moduleRequired": "Enter an I/O module name.", "maps.menu.save": "Save map", + "sounds.title": "Sounds", + "sounds.subtitle": "Setup → Sounds — upload and manage robot sounds for sound zones.", + "sounds.create": "Create sound", + "sounds.createTitle": "Create sound", + "sounds.editTitle": "Edit sound", + "sounds.empty": "No sounds yet. Create one to use in sound zones.", + "sounds.name": "Name", + "sounds.description": "Description", + "sounds.enabled": "Enabled", + "sounds.file": "Audio file", + "sounds.noFile": "No file uploaded", + "sounds.upload": "Upload file…", + "sounds.play": "Play", + "sounds.playFailed": "Could not play file.", + "sounds.fileMeta": "{name} · {duration}", + "sounds.nameRequired": "Enter a sound name.", + "sounds.deleteConfirm": "Delete this sound?", + "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 fd54c09..8c5cdb6 100644 --- a/www/index.html +++ b/www/index.html @@ -863,6 +863,56 @@ + +