diff --git a/CMakeLists.txt b/CMakeLists.txt index 10fda76..453dad0 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -45,6 +45,10 @@ add_executable(lidar_manager_web src/storage/site_store.cpp src/storage/sound_store.cpp src/storage/transition_store.cpp + src/storage/io_module_store.cpp + src/io/io_module_service.cpp + src/io/io_zone_runtime.cpp + src/io/io_module_usage.cpp src/storage/dashboard_store.cpp src/storage/state_repository.cpp src/validation/sensor_validator.cpp @@ -61,6 +65,7 @@ add_executable(lidar_manager_web src/server/api_robot_routes.cpp src/server/api_media_routes.cpp src/server/api_transition_routes.cpp + src/server/api_io_module_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 53cb4ab..93ec463 100644 --- a/src/app/lidar_manager_app.cpp +++ b/src/app/lidar_manager_app.cpp @@ -1,6 +1,8 @@ #include "app/lidar_manager_app.hpp" #include "auth/auth_service.hpp" +#include "io/io_module_service.hpp" +#include "io/io_zone_runtime.hpp" #include "mission/mission_enqueue.hpp" #include "mission/mission_queue.hpp" #include "mission/mission_scheduler.hpp" @@ -14,6 +16,7 @@ #include "storage/map_store.hpp" #include "storage/site_store.hpp" #include "storage/sound_store.hpp" +#include "storage/io_module_store.hpp" #include "storage/transition_store.hpp" #include "storage/state_repository.hpp" @@ -64,8 +67,11 @@ int LidarManagerApp::run() MapStore map_store(database); TransitionStore transition_store(database); + IoModuleStore io_module_store(database); + IoModuleService io_module_service(io_module_store); + IoZoneRuntime io_zone_runtime; MissionStore mission_store(database); - MissionQueue mission_queue(database, map_store, transition_store, mission_store); + MissionQueue mission_queue(database, map_store, transition_store, mission_store, io_module_service, io_zone_runtime); RobotRuntime robot_runtime(database, mission_queue); SiteStore site_store(database); site_store.ensureDefaultSiteId(); @@ -104,6 +110,8 @@ int LidarManagerApp::run() site_store, sound_store, transition_store, + io_module_store, + io_module_service, dashboard_store); api.registerRoutes(svr); auth.registerRoutes(svr); diff --git a/src/auth/auth_service.cpp b/src/auth/auth_service.cpp index 9e2570d..bcd183d 100644 --- a/src/auth/auth_service.cpp +++ b/src/auth/auth_service.cpp @@ -231,6 +231,8 @@ std::optional AuthService::resourceForApiPath(const std::string& pa return "dashboard"; if (path.rfind("/api/sounds", 0) == 0) return "sounds"; + if (path.rfind("/api/io_modules", 0) == 0) + return "integrations"; if (path == "/api/robot/active_map") return "maps"; if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 || @@ -326,6 +328,20 @@ bool AuthService::groupAllowsPinUnlocked(const std::string& group_id) const return group && group->value("allow_pin", false); } +bool AuthService::pinInUseUnlocked(const std::string& pin, const std::string& except_user_id) const +{ + if (pin.size() != 4) + return false; + for (const auto& user : data_["users"]) + { + if (user.value("id", "") == except_user_id) + continue; + if (verifyPinUnlocked(user, pin)) + return true; + } + return false; +} + std::optional AuthService::buildSessionUnlocked(const nlohmann::json& user) { const auto* group = findGroupByIdUnlocked(user.value("group_id", "")); @@ -347,9 +363,11 @@ nlohmann::json AuthService::userPublicView(const nlohmann::json& user, const nlo return {{"id", user.value("id", "")}, {"username", user.value("username", "")}, {"display_name", user.value("display_name", "")}, + {"email", user.value("email", "")}, {"group_id", user.value("group_id", "")}, {"group_name", group.value("name", "")}, {"permissions", group.value("permissions", nlohmann::json::object())}, + {"enabled", user.value("enabled", true)}, {"has_pin", !user.value("pin_hash", nlohmann::json()).is_null()}}; } @@ -525,20 +543,215 @@ std::optional AuthService::changeProfile(const std::string& toke return std::nullopt; } +bool AuthService::isBuiltinGroupId(const std::string& id) +{ + return id == "group_distributors" || id == "group_administrators" || id == "group_users"; +} + +nlohmann::json AuthService::normalizePermissions(const nlohmann::json& perms) +{ + static const char* kResources[] = { + "dashboard", "config", "maps", "missions", "sounds", "integrations", "users"}; + nlohmann::json out = nlohmann::json::object(); + for (const char* key : kResources) + { + std::string level = "none"; + if (perms.is_object() && perms.contains(key) && perms[key].is_string()) + level = perms[key].get(); + if (level != "none" && level != "read" && level != "write") + level = "none"; + out[key] = level; + } + return out; +} + +nlohmann::json AuthService::groupPublicView(const nlohmann::json& group, size_t user_count) +{ + return {{"id", group.value("id", "")}, + {"name", group.value("name", "")}, + {"allow_pin", group.value("allow_pin", false)}, + {"permissions", normalizePermissions(group.value("permissions", nlohmann::json::object()))}, + {"user_count", user_count}, + {"builtin", isBuiltinGroupId(group.value("id", ""))}}; +} + +bool AuthService::canManageGroupUnlocked(const std::string& editor_group_id, + const std::string& target_group_id) const +{ + if (editor_group_id == "group_distributors") + return true; + return target_group_id != "group_distributors" && target_group_id != "group_administrators"; +} + +size_t AuthService::countUsersInGroupUnlocked(const std::string& group_id) const +{ + size_t count = 0; + if (!data_.contains("users") || !data_["users"].is_array()) + return 0; + for (const auto& u : data_["users"]) + { + if (u.value("group_id", "") == group_id) + ++count; + } + return count; +} + +const nlohmann::json* AuthService::findGroupByNameUnlocked(const std::string& name, + const std::string& except_id) const +{ + const std::string needle = StringUtil::toLower(StringUtil::trimCopy(name)); + if (needle.empty() || !data_.contains("groups") || !data_["groups"].is_array()) + return nullptr; + for (const auto& g : data_["groups"]) + { + if (g.value("id", "") == except_id) + continue; + if (StringUtil::toLower(g.value("name", "")) == needle) + return &g; + } + return nullptr; +} + nlohmann::json AuthService::listGroups() const { std::lock_guard lock(mu_); nlohmann::json out = nlohmann::json::array(); for (const auto& g : data_["groups"]) { - out.push_back({{"id", g.value("id", "")}, - {"name", g.value("name", "")}, - {"allow_pin", g.value("allow_pin", false)}, - {"permissions", g.value("permissions", nlohmann::json::object())}}); + const std::string id = g.value("id", ""); + out.push_back(groupPublicView(g, countUsersInGroupUnlocked(id))); } return out; } +std::optional AuthService::createGroup(const nlohmann::json& payload, std::string& err) +{ + const std::string name = StringUtil::trimCopy(payload.value("name", "")); + if (name.empty()) + { + err = "name required"; + return std::nullopt; + } + + const AuthSession* session = activeSession(); + if (!session) + { + err = "authentication required"; + return std::nullopt; + } + + std::lock_guard lock(mu_); + if (!canManageGroupUnlocked(session->group_id, "")) + { + err = "insufficient permissions to create user groups"; + return std::nullopt; + } + if (findGroupByNameUnlocked(name)) + { + err = "group name already exists"; + return std::nullopt; + } + + nlohmann::json group = {{"id", "group_" + IdUtil::newId()}, + {"name", name}, + {"allow_pin", payload.value("allow_pin", false)}, + {"permissions", normalizePermissions(payload.value("permissions", nlohmann::json::object()))}}; + data_["groups"].push_back(group); + saveUnlocked(); + return groupPublicView(group, 0); +} + +std::optional AuthService::updateGroup(const std::string& id, + const nlohmann::json& payload, + std::string& err) +{ + const AuthSession* session = activeSession(); + if (!session) + { + err = "authentication required"; + return std::nullopt; + } + + std::lock_guard lock(mu_); + if (!canManageGroupUnlocked(session->group_id, id)) + { + err = "insufficient permissions to edit this user group"; + return std::nullopt; + } + + for (auto& group : data_["groups"]) + { + if (group.value("id", "") != id) + continue; + + if (payload.contains("name") && payload["name"].is_string()) + { + const std::string name = StringUtil::trimCopy(payload["name"].get()); + if (name.empty()) + { + err = "name cannot be empty"; + return std::nullopt; + } + if (findGroupByNameUnlocked(name, id)) + { + err = "group name already exists"; + return std::nullopt; + } + group["name"] = name; + } + if (payload.contains("allow_pin") && payload["allow_pin"].is_boolean()) + group["allow_pin"] = payload["allow_pin"].get(); + if (payload.contains("permissions")) + group["permissions"] = normalizePermissions(payload["permissions"]); + + saveUnlocked(); + return groupPublicView(group, countUsersInGroupUnlocked(id)); + } + + err = "group not found"; + return std::nullopt; +} + +bool AuthService::deleteGroup(const std::string& id, std::string& err) +{ + const AuthSession* session = activeSession(); + if (!session) + { + err = "authentication required"; + return false; + } + + std::lock_guard lock(mu_); + if (!canManageGroupUnlocked(session->group_id, id)) + { + err = "insufficient permissions to delete this user group"; + return false; + } + if (isBuiltinGroupId(id)) + { + err = "cannot delete built-in user group"; + return false; + } + if (countUsersInGroupUnlocked(id) > 0) + { + err = "group still has users"; + return false; + } + + auto& groups = data_["groups"]; + const auto it = std::remove_if(groups.begin(), groups.end(), [&](const nlohmann::json& g) { + return g.value("id", "") == id; + }); + if (it == groups.end()) + { + err = "group not found"; + return false; + } + groups.erase(it, groups.end()); + saveUnlocked(); + return true; +} + nlohmann::json AuthService::listUsers() const { std::lock_guard lock(mu_); @@ -576,6 +789,8 @@ std::optional AuthService::createUser(const nlohmann::json& payl const std::string id = "user_" + IdUtil::newId(); auto user = makeUser(id, username, password, group_id, payload.value("display_name", username)); + if (payload.contains("email") && payload["email"].is_string()) + user["email"] = StringUtil::trimCopy(payload["email"].get()); if (payload.contains("pin") && !payload["pin"].is_null()) { const std::string pin = payload.value("pin", ""); @@ -589,6 +804,11 @@ std::optional AuthService::createUser(const nlohmann::json& payl err = "group does not allow pin"; return std::nullopt; } + if (pinInUseUnlocked(pin)) + { + err = "pin already in use"; + return std::nullopt; + } const std::string pin_salt = CryptoUtil::randomToken(16); user["pin_salt"] = pin_salt; user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin); @@ -612,6 +832,29 @@ std::optional AuthService::updateUser(const std::string& id, if (payload.contains("display_name")) user["display_name"] = payload["display_name"]; + if (payload.contains("email")) + { + if (payload["email"].is_null()) + user["email"] = ""; + else if (payload["email"].is_string()) + user["email"] = StringUtil::trimCopy(payload["email"].get()); + } + if (payload.contains("username") && payload["username"].is_string()) + { + const std::string username = StringUtil::trimCopy(payload["username"].get()); + if (username.empty()) + { + err = "username cannot be empty"; + return std::nullopt; + } + const auto* existing = findUserByUsernameUnlocked(username); + if (existing && existing->value("id", "") != id) + { + err = "username already exists"; + return std::nullopt; + } + user["username"] = username; + } if (payload.contains("enabled")) user["enabled"] = payload["enabled"]; if (payload.contains("group_id")) @@ -651,6 +894,11 @@ std::optional AuthService::updateUser(const std::string& id, err = "group does not allow pin"; return std::nullopt; } + if (pinInUseUnlocked(pin, id)) + { + err = "pin already in use"; + return std::nullopt; + } const std::string pin_salt = CryptoUtil::randomToken(16); user["pin_salt"] = pin_salt; user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin); @@ -854,6 +1102,62 @@ void AuthService::registerRoutes(httplib::Server& svr) HttpUtil::addCors(res); }); + svr.Post("/api/user_groups", [this](const httplib::Request& req, httplib::Response& res) { + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + HttpUtil::jsonError(res, 400, "invalid json"); + return; + } + std::string err; + const auto group = createGroup(body, err); + if (!group) + { + HttpUtil::jsonError(res, 400, err); + return; + } + res.status = 201; + res.set_content(group->dump(), "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + + svr.Put(R"(/api/user_groups/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) { + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + HttpUtil::jsonError(res, 400, "invalid json"); + return; + } + std::string err; + const auto group = updateGroup(req.matches[1].str(), body, err); + if (!group) + { + HttpUtil::jsonError(res, 400, err); + return; + } + res.set_content(group->dump(), "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + + svr.Delete(R"(/api/user_groups/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) { + std::string err; + if (!deleteGroup(req.matches[1].str(), err)) + { + HttpUtil::jsonError(res, 400, err); + return; + } + res.set_content(R"({"ok":true})", "application/json; charset=utf-8"); + HttpUtil::addCors(res); + }); + svr.Get("/api/users", [this](const httplib::Request&, httplib::Response& res) { nlohmann::json out = {{"users", listUsers()}}; res.set_content(out.dump(), "application/json; charset=utf-8"); @@ -906,8 +1210,17 @@ void AuthService::registerRoutes(httplib::Server& svr) }); svr.Delete(R"(/api/users/([^/]+))", [this](const httplib::Request& req, httplib::Response& res) { + const std::string id = req.matches[1].str(); + if (const AuthSession* session = activeSession()) + { + if (session->user_id == id) + { + HttpUtil::jsonError(res, 400, "cannot delete current user"); + return; + } + } std::string err; - if (!deleteUser(req.matches[1].str(), err)) + if (!deleteUser(id, err)) { HttpUtil::jsonError(res, 400, err); return; diff --git a/src/auth/auth_service.hpp b/src/auth/auth_service.hpp index 6982bad..873dfc3 100644 --- a/src/auth/auth_service.hpp +++ b/src/auth/auth_service.hpp @@ -48,6 +48,11 @@ public: std::string& err); nlohmann::json listGroups() const; + std::optional createGroup(const nlohmann::json& payload, std::string& err); + std::optional updateGroup(const std::string& id, + const nlohmann::json& payload, + std::string& err); + bool deleteGroup(const std::string& id, std::string& err); nlohmann::json listUsers() const; std::optional createUser(const nlohmann::json& payload, std::string& err); std::optional updateUser(const std::string& id, @@ -80,6 +85,13 @@ private: bool verifyPasswordUnlocked(const nlohmann::json& user, const std::string& password) const; bool verifyPinUnlocked(const nlohmann::json& user, const std::string& pin) const; bool groupAllowsPinUnlocked(const std::string& group_id) const; + bool pinInUseUnlocked(const std::string& pin, const std::string& except_user_id = "") const; + static bool isBuiltinGroupId(const std::string& id); + static nlohmann::json normalizePermissions(const nlohmann::json& perms); + static nlohmann::json groupPublicView(const nlohmann::json& group, size_t user_count); + bool canManageGroupUnlocked(const std::string& editor_group_id, const std::string& target_group_id) const; + size_t countUsersInGroupUnlocked(const std::string& group_id) const; + const nlohmann::json* findGroupByNameUnlocked(const std::string& name, const std::string& except_id = "") const; }; } // namespace lm diff --git a/src/io/io_module_service.cpp b/src/io/io_module_service.cpp new file mode 100644 index 0000000..565214c --- /dev/null +++ b/src/io/io_module_service.cpp @@ -0,0 +1,366 @@ +#include "io/io_module_service.hpp" + +#include "storage/io_module_store.hpp" +#include "util/string_util.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace lm { + +namespace { + +constexpr int kDefaultModbusPort = 502; +constexpr int kPortCount = 4; + +bool parseBool(const nlohmann::json& v, bool fallback) +{ + if (v.is_boolean()) + return v.get(); + if (v.is_string()) + { + const std::string s = StringUtil::toLower(v.get()); + return s == "on" || s == "true" || s == "1"; + } + if (v.is_number()) + return v.get() != 0; + return fallback; +} + +int parsePort(const nlohmann::json& params, const std::string& module_type) +{ + int port = 0; + if (params.contains("output") && params["output"].is_number_integer()) + port = params["output"].get(); + else if (params.contains("pin") && params["pin"].is_number_integer()) + port = params["pin"].get(); + else if (params.contains("input") && params["input"].is_number_integer()) + port = params["input"].get(); + if (!IoModuleService::validPortForType(module_type, port)) + port = IoModuleService::defaultPortForType(module_type); + return port; +} + +} // namespace + +IoModuleService::IoModuleService(IoModuleStore& store) : store_(store) {} + +bool IoModuleService::validPortForType(const std::string& module_type, int port) +{ + if (module_type == "wise") + return port >= 0 && port <= 3; + return port >= 1 && port <= 4; +} + +int IoModuleService::defaultPortForType(const std::string& module_type) +{ + return module_type == "wise" ? 0 : 1; +} + +bool IoModuleService::testConnection(const std::string& ip_address, int port, std::string& err) const +{ + const std::string ip = StringUtil::trimCopy(ip_address); + if (ip.empty()) + { + err = "ip_address is required"; + return false; + } + if (port <= 0) + port = kDefaultModbusPort; + + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + addrinfo* res = nullptr; + const std::string port_str = std::to_string(port); + if (getaddrinfo(ip.c_str(), port_str.c_str(), &hints, &res) != 0) + { + err = "cannot resolve host"; + return false; + } + + bool ok = false; + for (addrinfo* p = res; p; p = p->ai_next) + { + const int fd = socket(p->ai_family, p->ai_socktype, p->ai_protocol); + if (fd < 0) + continue; + const int flags = fcntl(fd, F_GETFL, 0); + if (flags >= 0) + fcntl(fd, F_SETFL, flags | O_NONBLOCK); + const int rc = ::connect(fd, p->ai_addr, p->ai_addrlen); + if (rc == 0) + { + ok = true; + close(fd); + break; + } + if (errno == EINPROGRESS) + { + fd_set wfds; + FD_ZERO(&wfds); + FD_SET(fd, &wfds); + timeval tv{}; + tv.tv_sec = 2; + tv.tv_usec = 0; + if (select(fd + 1, nullptr, &wfds, nullptr, &tv) > 0) + { + int so_error = 0; + socklen_t len = sizeof(so_error); + getsockopt(fd, SOL_SOCKET, SO_ERROR, &so_error, &len); + ok = (so_error == 0); + } + } + close(fd); + if (ok) + break; + } + freeaddrinfo(res); + if (!ok) + err = "connection failed"; + return ok; +} + +std::optional IoModuleService::resolveModule(const std::string& ref) const +{ + const std::string needle = StringUtil::trimCopy(ref); + if (needle.empty()) + return std::nullopt; + if (auto by_id = store_.find(needle)) + return by_id; + return store_.findByName(needle); +} + +bool IoModuleService::connect(const std::string& module_id, std::string& err) +{ + const auto mod = store_.find(module_id); + if (!mod) + { + err = "io module not found"; + return false; + } + std::string test_err; + if (!testConnection(mod->value("ip_address", ""), kDefaultModbusPort, test_err)) + { + err = "connection test failed: " + test_err; + return false; + } + + std::lock_guard lock(mu_); + connected_[module_id] = true; + ensureSimStateUnlocked(module_id, mod->value("module_type", "bluetooth")); + + std::string persist_err; + if (!store_.setConnected(module_id, true, persist_err)) + { + err = persist_err; + return false; + } + return true; +} + +bool IoModuleService::disconnect(const std::string& module_id, std::string& err) +{ + if (!store_.find(module_id)) + { + err = "io module not found"; + return false; + } + { + std::lock_guard lock(mu_); + connected_[module_id] = false; + } + return store_.setConnected(module_id, false, err); +} + +bool IoModuleService::isConnected(const std::string& module_id) const +{ + std::lock_guard lock(mu_); + const auto it = connected_.find(module_id); + if (it != connected_.end()) + return it->second; + const auto mod = store_.find(module_id); + return mod && mod->value("connected", false); +} + +void IoModuleService::ensureSimStateUnlocked(const std::string& module_id, const std::string& module_type) +{ + if (outputs_.find(module_id) == outputs_.end()) + outputs_[module_id] = std::vector(kPortCount, false); + if (inputs_.find(module_id) == inputs_.end()) + inputs_[module_id] = std::vector(kPortCount, false); + (void)module_type; +} + +bool IoModuleService::requireConnectedUnlocked(const std::string& module_id, std::string& err) const +{ + const auto it = connected_.find(module_id); + const bool connected = (it != connected_.end()) ? it->second : false; + if (!connected) + { + const auto mod = store_.find(module_id); + if (!mod || !mod->value("connected", false)) + { + err = "io module is not connected"; + return false; + } + } + return true; +} + +bool IoModuleService::setOutput(const std::string& module_id, int port, bool on, std::string& err) +{ + const auto mod = store_.find(module_id); + if (!mod) + { + err = "io module not found"; + return false; + } + const std::string type = mod->value("module_type", "bluetooth"); + if (!validPortForType(type, port)) + { + err = "invalid output port for module type"; + return false; + } + { + std::lock_guard lock(mu_); + if (!requireConnectedUnlocked(module_id, err)) + return false; + ensureSimStateUnlocked(module_id, type); + const int idx = std::clamp(port, 0, kPortCount - 1); + outputs_[module_id][static_cast(idx)] = on; + inputs_[module_id][static_cast(idx)] = on; + } + return true; +} + +bool IoModuleService::setOutputTimed(const std::string& module_id, + int port, + bool on, + int timeout_ms, + std::string& err) +{ + if (!setOutput(module_id, port, on, err)) + return false; + if (on && timeout_ms > 0) + { + std::thread([this, module_id, port, timeout_ms]() { + std::this_thread::sleep_for(std::chrono::milliseconds(timeout_ms)); + std::string ignore; + setOutput(module_id, port, false, ignore); + }).detach(); + } + return true; +} + +bool IoModuleService::getInput(const std::string& module_id, int port, bool& value, std::string& err) const +{ + const auto mod = store_.find(module_id); + if (!mod) + { + err = "io module not found"; + return false; + } + const std::string type = mod->value("module_type", "bluetooth"); + if (!validPortForType(type, port)) + { + err = "invalid input port for module type"; + return false; + } + std::lock_guard lock(mu_); + if (!requireConnectedUnlocked(module_id, err)) + return false; + const auto it = inputs_.find(module_id); + if (it == inputs_.end()) + { + value = false; + return true; + } + const int idx = std::clamp(port, 0, kPortCount - 1); + value = it->second[static_cast(idx)]; + return true; +} + +bool IoModuleService::waitInput(const std::string& module_id, + int port, + bool expected, + int timeout_ms, + std::string& err) +{ + const int step = 100; + for (int elapsed = 0; elapsed <= timeout_ms; elapsed += step) + { + bool value = false; + if (!getInput(module_id, port, value, err)) + return false; + if (value == expected) + return true; + std::this_thread::sleep_for(std::chrono::milliseconds(step)); + } + err = "wait for input timed out"; + return false; +} + +bool IoModuleService::applyZoneSettings(const nlohmann::json& zone, std::string& err) +{ + const std::string module_ref = zone.value("io_module", ""); + if (module_ref.empty()) + { + err = "io_module is required"; + return false; + } + const auto mod = resolveModule(module_ref); + if (!mod) + { + err = "unknown io module: " + module_ref; + return false; + } + const std::string module_id = mod->value("id", ""); + if (!isConnected(module_id)) + { + if (!connect(module_id, err)) + return false; + } + + nlohmann::json params = zone; + const std::string type = mod->value("module_type", "bluetooth"); + const int port = parsePort(params, type); + const bool on = true; + if (!setOutput(module_id, port, on, err)) + return false; + + if (zone.contains("plc_register") && zone["plc_register"].is_number()) + { + // PLC register side-effect is logged only in simulated runtime for now. + } + return true; +} + +nlohmann::json IoModuleService::runtimeSnapshot(const std::string& module_id) const +{ + nlohmann::json out = {{"connected", isConnected(module_id)}, {"outputs", nlohmann::json::array()}, {"inputs", nlohmann::json::array()}}; + std::lock_guard lock(mu_); + const auto oit = outputs_.find(module_id); + if (oit != outputs_.end()) + { + for (bool v : oit->second) + out["outputs"].push_back(v); + } + const auto iit = inputs_.find(module_id); + if (iit != inputs_.end()) + { + for (bool v : iit->second) + out["inputs"].push_back(v); + } + return out; +} + +} // namespace lm diff --git a/src/io/io_module_service.hpp b/src/io/io_module_service.hpp new file mode 100644 index 0000000..84a8789 --- /dev/null +++ b/src/io/io_module_service.hpp @@ -0,0 +1,54 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace lm { + +class IoModuleStore; + +class IoModuleService +{ +public: + explicit IoModuleService(IoModuleStore& store); + + bool testConnection(const std::string& ip_address, int port, std::string& err) const; + std::optional resolveModule(const std::string& ref) const; + + bool connect(const std::string& module_id, std::string& err); + bool disconnect(const std::string& module_id, std::string& err); + bool isConnected(const std::string& module_id) const; + + bool setOutput(const std::string& module_id, int port, bool on, std::string& err); + bool setOutputTimed(const std::string& module_id, + int port, + bool on, + int timeout_ms, + std::string& err); + bool getInput(const std::string& module_id, int port, bool& value, std::string& err) const; + bool waitInput(const std::string& module_id, int port, bool expected, int timeout_ms, std::string& err); + + bool applyZoneSettings(const nlohmann::json& zone, std::string& err); + + nlohmann::json runtimeSnapshot(const std::string& module_id) const; + + static bool validPortForType(const std::string& module_type, int port); + static int defaultPortForType(const std::string& module_type); + +private: + IoModuleStore& store_; + mutable std::mutex mu_; + std::unordered_map connected_; + std::unordered_map> outputs_; + std::unordered_map> inputs_; + + void ensureSimStateUnlocked(const std::string& module_id, const std::string& module_type); + bool requireConnectedUnlocked(const std::string& module_id, std::string& err) const; +}; + +} // namespace lm diff --git a/src/io/io_module_usage.cpp b/src/io/io_module_usage.cpp new file mode 100644 index 0000000..aaf6fbf --- /dev/null +++ b/src/io/io_module_usage.cpp @@ -0,0 +1,90 @@ +#include "io/io_module_usage.hpp" + +#include "mission/mission_store.hpp" +#include "storage/map_store.hpp" +#include "util/string_util.hpp" + +namespace lm { + +namespace { + +bool moduleRefMatches(const std::string& ref, const std::string& module_id, const std::string& module_name) +{ + const std::string needle = StringUtil::trimCopy(ref); + if (needle.empty()) + return false; + return needle == module_id || StringUtil::toLower(needle) == StringUtil::toLower(module_name); +} + +void scanActions(const nlohmann::json& actions, + const std::string& module_id, + const std::string& module_name, + nlohmann::json& mission_hits) +{ + if (!actions.is_array()) + return; + for (const auto& action : actions) + { + if (!action.is_object()) + continue; + const auto& params = action.contains("params") && action["params"].is_object() ? action["params"] + : nlohmann::json::object(); + const std::string module_ref = params.value("module", params.value("module_id", "")); + if (moduleRefMatches(module_ref, module_id, module_name)) + { + mission_hits.push_back({{"mission_action", action.value("type", "")}, + {"label", action.value("label", "")}}); + } + if (action.contains("children")) + scanActions(action["children"], module_id, module_name, mission_hits); + if (action.contains("else_children")) + scanActions(action["else_children"], module_id, module_name, mission_hits); + } +} + +} // namespace + +nlohmann::json findIoModuleUsages(const std::string& module_id, + const std::string& module_name, + MapStore& maps, + MissionStore& missions) +{ + nlohmann::json zones = nlohmann::json::array(); + nlohmann::json mission_hits = nlohmann::json::array(); + + for (const auto& map : maps.list()) + { + if (!map.is_object()) + continue; + const auto& zlist = map.value("zones", nlohmann::json::array()); + if (!zlist.is_array()) + continue; + for (const auto& z : zlist) + { + if (!z.is_object() || z.value("type", "") != "io") + continue; + if (moduleRefMatches(z.value("io_module", ""), module_id, module_name)) + { + zones.push_back({{"map_id", map.value("id", "")}, + {"map_name", map.value("name", "")}, + {"zone_id", z.value("id", "")}}); + } + } + } + + for (const auto& mission : missions.listMissions()) + { + if (!mission.is_object()) + continue; + const auto before = mission_hits.size(); + scanActions(mission.value("actions", nlohmann::json::array()), module_id, module_name, mission_hits); + if (mission_hits.size() > before) + { + mission_hits.push_back({{"mission_id", mission.value("id", "")}, {"mission_name", mission.value("name", "")}}); + } + } + + return {{"zones", zones}, {"missions", mission_hits}, {"in_use", !zones.empty() || !mission_hits.empty()}}; +} + +} // namespace lm diff --git a/src/io/io_module_usage.hpp b/src/io/io_module_usage.hpp new file mode 100644 index 0000000..8bbc0c4 --- /dev/null +++ b/src/io/io_module_usage.hpp @@ -0,0 +1,18 @@ +#pragma once + +#include + +#include + +namespace lm { + +class IoModuleStore; +class MapStore; +class MissionStore; + +nlohmann::json findIoModuleUsages(const std::string& module_id, + const std::string& module_name, + MapStore& maps, + MissionStore& missions); + +} // namespace lm diff --git a/src/io/io_zone_runtime.cpp b/src/io/io_zone_runtime.cpp new file mode 100644 index 0000000..b09bdd4 --- /dev/null +++ b/src/io/io_zone_runtime.cpp @@ -0,0 +1,120 @@ +#include "io/io_zone_runtime.hpp" + +#include "io/io_module_service.hpp" +#include "storage/database.hpp" +#include "storage/map_store.hpp" +#include "util/id_util.hpp" + +namespace lm { + +namespace { + +void updatePose(Database& db, double x, double y, double yaw) +{ + nlohmann::json rt; + if (!db.getDocument("robot_runtime", rt) || !rt.is_object()) + rt = nlohmann::json::object(); + rt["pose"] = {{"x", x}, {"y", y}, {"yaw", yaw}}; + rt["updated_at"] = IdUtil::nowIso8601(); + db.setDocument("robot_runtime", rt); +} + +} // namespace + +bool IoZoneRuntime::pointInPolygon(double x, double y, 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 > y) != (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) + 1e-12) + xi); + if (intersect) + inside = !inside; + j = i; + } + return inside; +} + +bool IoZoneRuntime::zoneContainsPoint(const nlohmann::json& zone, double x, double y) +{ + const auto& points = zone.value("points", nlohmann::json::array()); + return pointInPolygon(x, y, points); +} + +std::optional> IoZoneRuntime::positionCoords(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 std::pair{z.value("x", 0.0), z.value("y", 0.0)}; + } + return std::nullopt; +} + +void IoZoneRuntime::onRobotAtPosition(Database& db, + MapStore& maps, + IoModuleService& io, + const std::string& map_id, + const std::string& position_id, + nlohmann::json& log) +{ + const auto map = maps.find(map_id); + if (!map) + return; + const auto coords = positionCoords(*map, position_id); + if (!coords) + return; + + const double x = coords->first; + const double y = coords->second; + updatePose(db, x, y, 0.0); + + const auto& zones = map->value("zones", nlohmann::json::array()); + if (!zones.is_array()) + return; + + for (const auto& zone : zones) + { + if (!zone.is_object() || zone.value("type", "") != "io") + continue; + if (!zoneContainsPoint(zone, x, y)) + continue; + + const std::string zone_id = zone.value("id", ""); + const std::string key = map_id + ":" + zone_id; + if (key == last_zone_key_) + continue; + last_zone_key_ = key; + + std::string err; + if (!io.applyZoneSettings(zone, err)) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "error"}, + {"message", "I/O zone failed: " + err}}); + continue; + } + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "I/O zone activated: " + zone.value("io_module", "")}}); + } +} + +} // namespace lm diff --git a/src/io/io_zone_runtime.hpp b/src/io/io_zone_runtime.hpp new file mode 100644 index 0000000..3efefa1 --- /dev/null +++ b/src/io/io_zone_runtime.hpp @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include + +namespace lm { + +class Database; +class IoModuleService; +class MapStore; + +class IoZoneRuntime +{ +public: + void onRobotAtPosition(Database& db, + MapStore& maps, + IoModuleService& io, + const std::string& map_id, + const std::string& position_id, + nlohmann::json& log); + +private: + std::string last_zone_key_; + + static bool pointInPolygon(double x, double y, const nlohmann::json& points); + static bool zoneContainsPoint(const nlohmann::json& zone, double x, double y); + static std::optional> positionCoords(const nlohmann::json& map, + const std::string& position_id); +}; + +} // namespace lm diff --git a/src/mission/mission_queue.cpp b/src/mission/mission_queue.cpp index 0e8b599..eaa7909 100644 --- a/src/mission/mission_queue.cpp +++ b/src/mission/mission_queue.cpp @@ -1,13 +1,17 @@ #include "mission/mission_queue.hpp" +#include "io/io_module_service.hpp" +#include "io/io_zone_runtime.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 "util/string_util.hpp" #include +#include #include #include #include @@ -42,10 +46,61 @@ double paramNumber(const nlohmann::json& params, const std::string& key, double return fallback; } +bool paramBool(const nlohmann::json& params, const std::string& key, bool fallback) +{ + if (params.contains(key)) + { + if (params[key].is_boolean()) + return params[key].get(); + if (params[key].is_number()) + return params[key].get() != 0; + if (params[key].is_string()) + { + const std::string s = StringUtil::toLower(params[key].get()); + return s == "on" || s == "true" || s == "1"; + } + } + return fallback; +} + +int ioPortFromParams(const nlohmann::json& params, const std::string& module_type) +{ + int port = 0; + if (params.contains("output") && params["output"].is_number_integer()) + port = params["output"].get(); + else if (params.contains("input") && params["input"].is_number_integer()) + port = params["input"].get(); + else if (params.contains("pin") && params["pin"].is_number_integer()) + port = params["pin"].get(); + if (!IoModuleService::validPortForType(module_type, port)) + port = IoModuleService::defaultPortForType(module_type); + return port; +} + +std::string moduleRefFromParams(const nlohmann::json& params) +{ + if (params.contains("module") && params["module"].is_string()) + return params["module"].get(); + if (params.contains("module_id") && params["module_id"].is_string()) + return params["module_id"].get(); + return ""; +} + } // namespace -MissionQueue::MissionQueue(Database& db, MapStore& maps, TransitionStore& transitions, MissionStore& missions) - : db_(db), maps_(maps), transitions_(transitions), missions_(missions), position_resolver_(maps) +MissionQueue::MissionQueue(Database& db, + MapStore& maps, + TransitionStore& transitions, + MissionStore& missions, + IoModuleService& io_modules, + IoZoneRuntime& io_zones) + : db_(db), + maps_(maps), + transitions_(transitions), + missions_(missions), + io_modules_(io_modules), + io_zones_(io_zones), + position_resolver_(maps) { load(); ensureRunnerDefaults(); @@ -676,6 +731,24 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j 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); continue; } } @@ -737,6 +810,228 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j continue; } + if (type == "set_digital_output" || type == "set_output") + { + const std::string module_ref = moduleRefFromParams(params); + const auto mod = io_modules_.resolveModule(module_ref); + if (!mod) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "error"}, + {"message", "Unknown I/O module: " + module_ref}}); + throw std::runtime_error("unknown io module"); + } + const std::string module_id = mod->value("id", ""); + const std::string module_type = mod->value("module_type", "bluetooth"); + const int port = ioPortFromParams(params, module_type); + const bool on = paramBool(params, "value", true); + const std::string operation = params.value("operation", "set"); + const int timeout_ms = static_cast(paramNumber(params, "timeout_ms", paramNumber(params, "timeout_s", 0) * 1000)); + std::string err; + bool ok = false; + if (operation == "timed" && on && timeout_ms > 0) + ok = io_modules_.setOutputTimed(module_id, port, on, timeout_ms, err); + else + ok = io_modules_.setOutput(module_id, port, on, err); + if (!ok) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Set output failed: " + err}}); + throw std::runtime_error("set output failed"); + } + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", mod->value("name", module_ref) + " output " + std::to_string(port) + " → " + + (on ? "ON" : "OFF")}}); + continue; + } + + if (type == "wait_digital_input" || type == "wait_for_input") + { + const std::string module_ref = moduleRefFromParams(params); + const auto mod = io_modules_.resolveModule(module_ref); + if (!mod) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "error"}, + {"message", "Unknown I/O module: " + module_ref}}); + throw std::runtime_error("unknown io module"); + } + const std::string module_id = mod->value("id", ""); + const std::string module_type = mod->value("module_type", "bluetooth"); + const int port = ioPortFromParams(params, module_type); + const bool expected = paramBool(params, "expected", true); + const int timeout_ms = static_cast(paramNumber(params, "timeout_ms", paramNumber(params, "timeout_s", 30) * 1000)); + std::string err; + if (!io_modules_.waitInput(module_id, port, expected, timeout_ms, err)) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Wait input failed: " + err}}); + throw std::runtime_error("wait input failed"); + } + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", mod->value("name", module_ref) + " input " + std::to_string(port) + " = " + + (expected ? "ON" : "OFF")}}); + continue; + } + + if (type == "connect_bluetooth" || type == "connect_io") + { + const std::string module_ref = moduleRefFromParams(params); + const auto mod = io_modules_.resolveModule(module_ref); + if (!mod) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "error"}, + {"message", "Unknown I/O module: " + module_ref}}); + throw std::runtime_error("unknown io module"); + } + const std::string module_id = mod->value("id", ""); + std::string err; + if (!io_modules_.connect(module_id, err)) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Connect failed: " + err}}); + throw std::runtime_error("connect failed"); + } + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "Connected " + mod->value("name", module_ref)}}); + continue; + } + + if (type == "disconnect_bluetooth" || type == "disconnect_io") + { + const std::string module_ref = moduleRefFromParams(params); + const auto mod = io_modules_.resolveModule(module_ref); + if (!mod) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "error"}, + {"message", "Unknown I/O module: " + module_ref}}); + throw std::runtime_error("unknown io module"); + } + const std::string module_id = mod->value("id", ""); + std::string err; + if (!io_modules_.disconnect(module_id, err)) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "Disconnect failed: " + err}}); + throw std::runtime_error("disconnect failed"); + } + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "Disconnected " + mod->value("name", module_ref)}}); + continue; + } + + if (type == "if") + { + const std::string condition = params.value("condition", ""); + bool branch = false; + if (condition == "io_input") + { + const std::string module_ref = moduleRefFromParams(params); + const auto mod = io_modules_.resolveModule(module_ref); + if (!mod) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "error"}, + {"message", "Unknown I/O module: " + module_ref}}); + throw std::runtime_error("unknown io module"); + } + const std::string module_id = mod->value("id", ""); + const std::string module_type = mod->value("module_type", "bluetooth"); + const int port = ioPortFromParams(params, module_type); + const bool expected = paramBool(params, "expected", true); + bool value = false; + std::string err; + if (!io_modules_.getInput(module_id, port, value, err)) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "If input read failed: " + err}}); + throw std::runtime_error("if input read failed"); + } + branch = (value == expected); + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "If I/O input " + std::to_string(port) + " → " + (branch ? "true" : "false")}}); + } + else + { + branch = true; + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "If " + condition + " (simulated true)"}}); + } + const auto& branch_actions = + branch + ? (action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array()) + : (action.contains("else_children") && action["else_children"].is_array() + ? action["else_children"] + : nlohmann::json::array()); + if (!branch_actions.empty()) + { + const LoopControl ctrl = executeActionsUnlocked(branch_actions, parameters, log, loop_depth + 1, allow_auto_transition); + if (ctrl == LoopControl::Break) + return LoopControl::Break; + if (ctrl == LoopControl::Continue) + continue; + } + if (cancel_) + throw MissionCancelled(); + continue; + } + + if (type == "while") + { + const std::string condition = params.value("condition", "io_input"); + const auto& children = + action.contains("children") && action["children"].is_array() ? action["children"] : nlohmann::json::array(); + const int max_iter = static_cast(paramNumber(params, "max_iterations", 1000)); + for (int i = 0; i < max_iter && !stop_ && !cancel_; ++i) + { + bool continue_loop = false; + if (condition == "io_input") + { + const std::string module_ref = moduleRefFromParams(params); + const auto mod = io_modules_.resolveModule(module_ref); + if (!mod) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "error"}, + {"message", "Unknown I/O module: " + module_ref}}); + throw std::runtime_error("unknown io module"); + } + const std::string module_id = mod->value("id", ""); + const std::string module_type = mod->value("module_type", "bluetooth"); + const int port = ioPortFromParams(params, module_type); + const bool expected = paramBool(params, "expected", true); + bool value = false; + std::string err; + if (!io_modules_.getInput(module_id, port, value, err)) + { + log.push_back({{"ts", IdUtil::nowIso8601()}, {"level", "error"}, {"message", "While input read failed: " + err}}); + throw std::runtime_error("while input read failed"); + } + continue_loop = (value == expected); + } + else + { + continue_loop = false; + } + if (!continue_loop) + break; + log.push_back({{"ts", IdUtil::nowIso8601()}, + {"level", "info"}, + {"message", "While iteration " + std::to_string(i + 1)}}); + const LoopControl ctrl = executeActionsUnlocked(children, parameters, log, loop_depth + 1, allow_auto_transition); + if (ctrl == LoopControl::Break) + break; + if (ctrl == LoopControl::Continue) + continue; + } + if (cancel_) + throw MissionCancelled(); + continue; + } + log.push_back( {{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}}); sleepMs(400); diff --git a/src/mission/mission_queue.hpp b/src/mission/mission_queue.hpp index e88ffb7..3a9f2d6 100644 --- a/src/mission/mission_queue.hpp +++ b/src/mission/mission_queue.hpp @@ -13,6 +13,8 @@ namespace lm { class Database; +class IoModuleService; +class IoZoneRuntime; class MapStore; class MissionStore; class TransitionStore; @@ -20,7 +22,12 @@ class TransitionStore; class MissionQueue { public: - MissionQueue(Database& db, MapStore& maps, TransitionStore& transitions, MissionStore& missions); + MissionQueue(Database& db, + MapStore& maps, + TransitionStore& transitions, + MissionStore& missions, + IoModuleService& io_modules, + IoZoneRuntime& io_zones); ~MissionQueue(); MissionQueue(const MissionQueue&) = delete; @@ -44,6 +51,8 @@ private: MapStore& maps_; TransitionStore& transitions_; MissionStore& missions_; + IoModuleService& io_modules_; + IoZoneRuntime& io_zones_; PositionResolver position_resolver_; mutable std::recursive_mutex mu_; nlohmann::json queue_; diff --git a/src/server/api_io_module_routes.cpp b/src/server/api_io_module_routes.cpp new file mode 100644 index 0000000..a210b36 --- /dev/null +++ b/src/server/api_io_module_routes.cpp @@ -0,0 +1,204 @@ +#include "server/api_server.hpp" + +#include "auth/auth_service.hpp" +#include "io/io_module_service.hpp" +#include "io/io_module_usage.hpp" +#include "util/http_util.hpp" +#include "util/string_util.hpp" + +namespace lm { + +void ApiServer::registerIoModuleRoutes(httplib::Server& svr) +{ + svr.Get("/api/io_modules", [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({{"io_modules", io_module_store_.list(site_id)}}).dump(); + }); + + svr.Post("/api/io_modules/test", [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"); + } + const std::string ip = body.value("ip_address", ""); + int port = body.value("port", 502); + if (body.contains("port") && body["port"].is_number()) + port = body["port"].get(); + std::string err; + const bool ok = io_module_service_.testConnection(ip, port, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + if (!ok) + return HttpUtil::jsonError(res, 400, err); + res.body = nlohmann::json({{"ok", true}}).dump(); + }); + + svr.Get(R"(/api/io_modules/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + const auto mod = io_module_store_.find(id); + if (!mod) + return HttpUtil::jsonError(res, 404, "io module not found"); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = mod->dump(); + }); + + svr.Get(R"(/api/io_modules/([^/]+)/runtime$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + if (!io_module_store_.find(id)) + return HttpUtil::jsonError(res, 404, "io module not found"); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = io_module_service_.runtimeSnapshot(id).dump(); + }); + + svr.Post(R"(/api/io_modules/([^/]+)/output$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + const auto mod = io_module_store_.find(id); + if (!mod) + return HttpUtil::jsonError(res, 404, "io module not found"); + nlohmann::json body; + try + { + body = nlohmann::json::parse(req.body); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + const std::string module_type = mod->value("module_type", "bluetooth"); + int port = body.value("port", body.value("output", IoModuleService::defaultPortForType(module_type))); + if (body.contains("output") && body["output"].is_number()) + port = body["output"].get(); + else if (body.contains("port") && body["port"].is_number()) + port = body["port"].get(); + bool on = true; + if (body.contains("value")) + { + if (body["value"].is_boolean()) + on = body["value"].get(); + else if (body["value"].is_number()) + on = body["value"].get() != 0; + } + const int timeout_ms = body.value("timeout_ms", 0); + std::string err; + bool ok = false; + if (timeout_ms > 0 && on) + ok = io_module_service_.setOutputTimed(id, port, on, timeout_ms, err); + else + ok = io_module_service_.setOutput(id, port, on, err); + if (!ok) + return HttpUtil::jsonError(res, 400, err); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = io_module_service_.runtimeSnapshot(id).dump(); + }); + + svr.Post("/api/io_modules", [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; + } + if (!body.contains("created_by_group") || !body["created_by_group"].is_string() || + body["created_by_group"].get().empty()) + body["created_by_group"] = session->group_id; + } + std::string err; + const auto created = io_module_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/io_modules/([^/]+)$)", [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 (!io_module_store_.update(id, body, err)) + return HttpUtil::jsonError(res, 400, err); + const auto updated = io_module_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/io_modules/([^/]+)$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + const auto existing = io_module_store_.find(id); + if (!existing) + return HttpUtil::jsonError(res, 404, "io module not found"); + if (const AuthSession* session = AuthService::activeSession()) + { + if (!AuthService::canDeleteMap(*existing, *session)) + return HttpUtil::jsonError(res, 403, "cannot delete io module from another user group"); + } + const auto usages = + findIoModuleUsages(id, existing->value("name", ""), map_store_, mission_store_); + if (usages.value("in_use", false)) + { + res.status = 409; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = nlohmann::json({{"error", "io module is in use"}, {"usages", usages}}).dump(); + return; + } + std::string err; + if (!io_module_store_.remove(id, err)) + return HttpUtil::jsonError(res, 400, err); + res.status = 204; + }); + + svr.Post(R"(/api/io_modules/([^/]+)/connect$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + std::string err; + if (!io_module_service_.connect(id, err)) + return HttpUtil::jsonError(res, 400, err); + const auto updated = io_module_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/io_modules/([^/]+)/disconnect$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string id = req.matches[1]; + std::string err; + if (!io_module_service_.disconnect(id, err)) + return HttpUtil::jsonError(res, 400, err); + const auto updated = io_module_store_.find(id); + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = updated ? updated->dump() : nlohmann::json::object().dump(); + }); +} + +} // namespace lm diff --git a/src/server/api_media_routes.cpp b/src/server/api_media_routes.cpp index 1a5aa9a..ee0ec58 100644 --- a/src/server/api_media_routes.cpp +++ b/src/server/api_media_routes.cpp @@ -3,6 +3,10 @@ #include "auth/auth_service.hpp" #include "util/file_util.hpp" #include "util/http_util.hpp" +#include "util/id_util.hpp" +#include "util/string_util.hpp" + +#include namespace lm { @@ -63,6 +67,126 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr) res.status = 204; }); + svr.Get(R"(/api/sites/([^/]+)/export$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string site_id = req.matches[1]; + const auto site = site_store_.find(site_id); + if (!site) + return HttpUtil::jsonError(res, 404, "site not found"); + + nlohmann::json maps = nlohmann::json::array(); + for (const auto& map : map_store_.list()) + { + if (map.value("site_id", "") == site_id) + maps.push_back(map); + } + + const nlohmann::json bundle = {{"format", "rbs-site-bundle"}, + {"version", 1}, + {"site", *site}, + {"maps", maps}, + {"transitions", transition_store_.list(site_id)}, + {"io_modules", io_module_store_.list(site_id)}, + {"sounds", sound_store_.list()}}; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = bundle.dump(2); + }); + + svr.Post(R"(/api/sites/([^/]+)/import$)", [this](const httplib::Request& req, httplib::Response& res) { + HttpUtil::addCors(res); + const std::string site_id = req.matches[1]; + if (!site_store_.find(site_id)) + return HttpUtil::jsonError(res, 404, "site not found"); + + nlohmann::json bundle; + try + { + bundle = nlohmann::json::parse(req.body); + } + catch (...) + { + return HttpUtil::jsonError(res, 400, "invalid JSON"); + } + if (bundle.value("format", "") != "rbs-site-bundle") + return HttpUtil::jsonError(res, 400, "unsupported bundle format"); + + nlohmann::json imported = {{"maps", nlohmann::json::array()}, + {"io_modules", nlohmann::json::array()}, + {"transitions", nlohmann::json::array()}}; + std::unordered_map map_id_remap; + + if (bundle.contains("maps") && bundle["maps"].is_array()) + { + for (const auto& map : bundle["maps"]) + { + if (!map.is_object()) + continue; + nlohmann::json payload = map; + const std::string old_id = payload.value("id", ""); + payload["id"] = IdUtil::newId(); + payload["site_id"] = site_id; + if (!old_id.empty()) + map_id_remap[old_id] = payload["id"].get(); + std::string err; + const auto created = map_store_.create(payload, err); + if (!created) + return HttpUtil::jsonError(res, 400, "map import failed: " + err); + imported["maps"].push_back(*created); + } + } + + if (bundle.contains("io_modules") && bundle["io_modules"].is_array()) + { + for (const auto& mod : bundle["io_modules"]) + { + if (!mod.is_object()) + continue; + nlohmann::json payload = mod; + payload.erase("id"); + payload["site_id"] = site_id; + if (const AuthSession* session = AuthService::activeSession()) + { + if (!payload.contains("created_by") || payload["created_by"].get().empty()) + payload["created_by"] = session->group_name.empty() ? session->username : session->group_name; + if (!payload.contains("created_by_group") || payload["created_by_group"].get().empty()) + payload["created_by_group"] = session->group_id; + } + std::string err; + const auto created = io_module_store_.create(payload, err); + if (!created) + return HttpUtil::jsonError(res, 400, "io module import failed: " + err); + imported["io_modules"].push_back(*created); + } + } + + if (bundle.contains("transitions") && bundle["transitions"].is_array()) + { + for (const auto& tr : bundle["transitions"]) + { + if (!tr.is_object()) + continue; + nlohmann::json payload = tr; + payload.erase("id"); + payload["site_id"] = site_id; + const std::string from_old = payload.value("from_map_id", ""); + const std::string to_old = payload.value("to_map_id", ""); + if (map_id_remap.count(from_old)) + payload["from_map_id"] = map_id_remap[from_old]; + if (map_id_remap.count(to_old)) + payload["to_map_id"] = map_id_remap[to_old]; + std::string err; + const auto created = transition_store_.create(payload, err); + if (!created) + return HttpUtil::jsonError(res, 400, "transition import failed: " + err); + imported["transitions"].push_back(*created); + } + } + + res.status = 201; + res.set_header("Content-Type", "application/json; charset=utf-8"); + res.body = imported.dump(2); + }); + svr.Get("/api/maps", [this](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 e1097c9..a59a0f9 100644 --- a/src/server/api_server.cpp +++ b/src/server/api_server.cpp @@ -20,6 +20,8 @@ ApiServer::ApiServer(StateRepository& repo, SiteStore& site_store, SoundStore& sound_store, TransitionStore& transition_store, + IoModuleStore& io_module_store, + IoModuleService& io_module_service, DashboardStore& dashboard_store) : repo_(repo), mission_queue_(mission_queue), @@ -31,6 +33,8 @@ ApiServer::ApiServer(StateRepository& repo, site_store_(site_store), sound_store_(sound_store), transition_store_(transition_store), + io_module_store_(io_module_store), + io_module_service_(io_module_service), dashboard_store_(dashboard_store) { } @@ -553,6 +557,7 @@ void ApiServer::registerRoutes(httplib::Server& svr) registerRobotRoutes(svr); registerMediaRoutes(svr); registerTransitionRoutes(svr); + registerIoModuleRoutes(svr); registerDashboardRoutes(svr); } diff --git a/src/server/api_server.hpp b/src/server/api_server.hpp index ad609bd..07a08c2 100644 --- a/src/server/api_server.hpp +++ b/src/server/api_server.hpp @@ -11,6 +11,8 @@ #include "storage/map_store.hpp" #include "storage/site_store.hpp" #include "storage/sound_store.hpp" +#include "storage/io_module_store.hpp" +#include "io/io_module_service.hpp" #include "storage/transition_store.hpp" #include "storage/state_repository.hpp" @@ -29,6 +31,8 @@ public: SiteStore& site_store, SoundStore& sound_store, TransitionStore& transition_store, + IoModuleStore& io_module_store, + IoModuleService& io_module_service, DashboardStore& dashboard_store); void registerRoutes(httplib::Server& svr); @@ -44,6 +48,8 @@ private: SiteStore& site_store_; SoundStore& sound_store_; TransitionStore& transition_store_; + IoModuleStore& io_module_store_; + IoModuleService& io_module_service_; DashboardStore& dashboard_store_; bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201); @@ -55,6 +61,7 @@ private: void registerRobotRoutes(httplib::Server& svr); void registerMediaRoutes(httplib::Server& svr); void registerTransitionRoutes(httplib::Server& svr); + void registerIoModuleRoutes(httplib::Server& svr); void registerDashboardRoutes(httplib::Server& svr); }; diff --git a/src/storage/database.cpp b/src/storage/database.cpp index 3ff1b7f..2b4242c 100644 --- a/src/storage/database.cpp +++ b/src/storage/database.cpp @@ -108,6 +108,20 @@ CREATE TABLE IF NOT EXISTS transitions ( updated_at TEXT NOT NULL ); +CREATE TABLE IF NOT EXISTS io_modules ( + id TEXT PRIMARY KEY, + site_id TEXT NOT NULL, + name TEXT NOT NULL, + module_type TEXT NOT NULL, + ip_address TEXT NOT NULL, + created_by TEXT NOT NULL DEFAULT '', + created_by_group TEXT NOT NULL DEFAULT '', + connected INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE +); + CREATE TABLE IF NOT EXISTS dashboards ( id TEXT PRIMARY KEY, name TEXT NOT NULL, @@ -395,6 +409,28 @@ bool Database::applySchemaMigrations(std::string& err) setMeta("schema_version", "6"); } + ver = getMeta("schema_version").value_or("1"); + if (ver == "6") + { + if (!execSql(db_, + "CREATE TABLE IF NOT EXISTS io_modules (" + "id TEXT PRIMARY KEY, " + "site_id TEXT NOT NULL, " + "name TEXT NOT NULL, " + "module_type TEXT NOT NULL, " + "ip_address TEXT NOT NULL, " + "created_by TEXT NOT NULL DEFAULT '', " + "created_by_group TEXT NOT NULL DEFAULT '', " + "connected INTEGER NOT NULL DEFAULT 0, " + "created_at TEXT NOT NULL, " + "updated_at TEXT NOT NULL, " + "FOREIGN KEY (site_id) REFERENCES sites(id) ON DELETE CASCADE" + ")", + err)) + return false; + setMeta("schema_version", "7"); + } + return true; } diff --git a/src/storage/io_module_store.cpp b/src/storage/io_module_store.cpp new file mode 100644 index 0000000..a1a7c6a --- /dev/null +++ b/src/storage/io_module_store.cpp @@ -0,0 +1,324 @@ +#include "storage/io_module_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)}, + {"name", text(2)}, + {"module_type", text(3)}, + {"ip_address", text(4)}, + {"created_by", text(5)}, + {"created_by_group", text(6)}, + {"connected", sqlite3_column_int(stmt, 7) != 0}, + {"created_at", text(8)}, + {"updated_at", text(9)}}; +} + +constexpr const char* kSelect = + "SELECT id, site_id, name, module_type, ip_address, created_by, created_by_group, connected, created_at, updated_at " + "FROM io_modules"; + +bool validModuleType(const std::string& t) +{ + return t == "bluetooth" || t == "wise"; +} + +} // namespace + +IoModuleStore::IoModuleStore(Database& db) : db_(db) {} + +bool IoModuleStore::findNameConflictUnlocked(const std::string& site_id, + const std::string& name, + const std::string& except_id) const +{ + const std::string needle = StringUtil::toLower(StringUtil::trimCopy(name)); + if (needle.empty()) + return false; + + sqlite3_stmt* stmt = nullptr; + const char* sql = except_id.empty() + ? "SELECT id FROM io_modules WHERE site_id = ?1 AND lower(name) = lower(?2) LIMIT 1" + : "SELECT id FROM io_modules WHERE site_id = ?1 AND lower(name) = lower(?2) AND id != ?3 LIMIT 1"; + if (sqlite3_prepare_v2(db_.handle(), sql, -1, &stmt, nullptr) != SQLITE_OK) + return false; + sqlite3_bind_text(stmt, 1, site_id.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 2, name.c_str(), -1, SQLITE_TRANSIENT); + if (!except_id.empty()) + sqlite3_bind_text(stmt, 3, except_id.c_str(), -1, SQLITE_TRANSIENT); + const bool conflict = sqlite3_step(stmt) == SQLITE_ROW; + sqlite3_finalize(stmt); + return conflict; +} + +nlohmann::json IoModuleStore::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 name"; + + 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 IoModuleStore::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 IoModuleStore::findByName(const std::string& name) const +{ + const std::string needle = StringUtil::toLower(StringUtil::trimCopy(name)); + if (needle.empty()) + return std::nullopt; + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "SELECT id, site_id, name, module_type, ip_address, created_by, created_by_group, connected, " + "created_at, updated_at FROM io_modules WHERE lower(name) = lower(?1) LIMIT 1", + -1, + &stmt, + nullptr) != SQLITE_OK) + return std::nullopt; + sqlite3_bind_text(stmt, 1, name.c_str(), -1, SQLITE_TRANSIENT); + std::optional out; + if (sqlite3_step(stmt) == SQLITE_ROW) + out = rowToJson(stmt); + sqlite3_finalize(stmt); + return out; +} + +std::optional IoModuleStore::resolveRef(const std::string& ref) const +{ + if (auto by_id = find(ref)) + return by_id; + return findByName(ref); +} + +std::optional IoModuleStore::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 name = StringUtil::trimCopy(payload.value("name", "")); + std::string module_type = StringUtil::toLower(StringUtil::trimCopy(payload.value("module_type", ""))); + const std::string ip_address = StringUtil::trimCopy(payload.value("ip_address", "")); + const std::string created_by = StringUtil::trimCopy(payload.value("created_by", "")); + const std::string created_by_group = StringUtil::trimCopy(payload.value("created_by_group", "")); + + if (site_id.empty() || name.empty() || module_type.empty() || ip_address.empty()) + { + err = "missing required fields"; + return std::nullopt; + } + if (!validModuleType(module_type)) + { + err = "module_type must be bluetooth or wise"; + return std::nullopt; + } + + std::lock_guard lock(mu_); + if (findNameConflictUnlocked(site_id, name, "")) + { + err = "io module name already exists for this site"; + return std::nullopt; + } + + const std::string id = payload.value("id", IdUtil::newId()); + const std::string now = IdUtil::nowIso8601(); + + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "INSERT INTO io_modules(id, site_id, name, module_type, ip_address, created_by, " + "created_by_group, connected, created_at, updated_at) " + "VALUES(?1,?2,?3,?4,?5,?6,?7,0,?8,?9)", + -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, name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, module_type.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 5, ip_address.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 6, created_by.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 7, created_by_group.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); + 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}, + {"name", name}, + {"module_type", module_type}, + {"ip_address", ip_address}, + {"created_by", created_by}, + {"created_by_group", created_by_group}, + {"connected", false}, + {"created_at", now}, + {"updated_at", now}}; +} + +bool IoModuleStore::update(const std::string& id, const nlohmann::json& payload, std::string& err) +{ + auto existing = find(id); + if (!existing) + { + err = "io module not found"; + return false; + } + + nlohmann::json merged = *existing; + for (const char* key : {"site_id", "name", "module_type", "ip_address"}) + { + if (payload.contains(key)) + merged[key] = payload[key]; + } + + const std::string site_id = StringUtil::trimCopy(merged.value("site_id", "")); + const std::string name = StringUtil::trimCopy(merged.value("name", "")); + std::string module_type = StringUtil::toLower(StringUtil::trimCopy(merged.value("module_type", ""))); + const std::string ip_address = StringUtil::trimCopy(merged.value("ip_address", "")); + + if (site_id.empty() || name.empty() || module_type.empty() || ip_address.empty()) + { + err = "missing required fields"; + return false; + } + if (!validModuleType(module_type)) + { + err = "module_type must be bluetooth or wise"; + return false; + } + + std::lock_guard lock(mu_); + if (findNameConflictUnlocked(site_id, name, id)) + { + err = "io module name already exists for this site"; + return false; + } + + const std::string now = IdUtil::nowIso8601(); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "UPDATE io_modules SET site_id=?2, name=?3, module_type=?4, ip_address=?5, updated_at=?6 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, name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 4, module_type.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 5, ip_address.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(stmt, 6, 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 IoModuleStore::remove(const std::string& id, std::string& err) +{ + if (!find(id)) + { + err = "io module not found"; + return false; + } + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), "DELETE FROM io_modules 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; +} + +bool IoModuleStore::setConnected(const std::string& id, bool connected, std::string& err) +{ + if (!find(id)) + { + err = "io module not found"; + return false; + } + const std::string now = IdUtil::nowIso8601(); + std::lock_guard lock(mu_); + sqlite3_stmt* stmt = nullptr; + if (sqlite3_prepare_v2(db_.handle(), + "UPDATE io_modules SET connected=?2, updated_at=?3 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_int(stmt, 2, connected ? 1 : 0); + sqlite3_bind_text(stmt, 3, 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; +} + +} // namespace lm diff --git a/src/storage/io_module_store.hpp b/src/storage/io_module_store.hpp new file mode 100644 index 0000000..6599f01 --- /dev/null +++ b/src/storage/io_module_store.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include +#include +#include + +namespace lm { + +class Database; + +class IoModuleStore +{ +public: + explicit IoModuleStore(Database& db); + + nlohmann::json list(const std::string& site_id = "") const; + std::optional find(const std::string& id) const; + std::optional findByName(const std::string& name) const; + std::optional resolveRef(const std::string& ref) 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); + bool setConnected(const std::string& id, bool connected, std::string& err); + +private: + Database& db_; + mutable std::mutex mu_; + + bool findNameConflictUnlocked(const std::string& site_id, + const std::string& name, + const std::string& except_id) const; +}; + +} // namespace lm diff --git a/www/app.js b/www/app.js index 85281d2..16f3f43 100644 --- a/www/app.js +++ b/www/app.js @@ -12,6 +12,9 @@ const pageMissionsEl = el("pageMissions"); const pageIntegrationsEl = el("pageIntegrations"); const pageSoundsEl = el("pageSounds"); const pageTransitionsEl = el("pageTransitions"); +const pageUsersEl = el("pageUsers"); +const pageUserGroupsEl = el("pageUserGroups"); +const pageIoModulesEl = el("pageIoModules"); const pageMonitoringEl = el("pageMonitoring"); const pageHelpEl = el("pageHelp"); const contentEl = document.querySelector(".content"); @@ -126,7 +129,7 @@ const state = { }; function setActivePage(page) { - const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "integrations", "monitoring", "help"]; + const valid = ["dashboard", "config", "maps", "missions", "sounds", "transitions", "user-groups", "io-modules", "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)); @@ -139,6 +142,9 @@ function setActivePage(page) { if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions"; if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds"; if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions"; + if (pageUserGroupsEl) pageUserGroupsEl.hidden = p !== "user-groups"; + if (pageIoModulesEl) pageIoModulesEl.hidden = p !== "io-modules"; + if (pageUsersEl) pageUsersEl.hidden = p !== "users"; if (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations"; if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring"; if (pageHelpEl) pageHelpEl.hidden = p !== "help"; @@ -151,6 +157,9 @@ function setActivePage(page) { 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--user-groups", p === "user-groups"); + contentEl.classList.toggle("content--io-modules", p === "io-modules"); + contentEl.classList.toggle("content--users", p === "users"); contentEl.classList.toggle("content--integrations", p === "integrations"); contentEl.classList.toggle("content--monitoring", p === "monitoring"); contentEl.classList.toggle("content--help", p === "help"); @@ -162,6 +171,12 @@ function setActivePage(page) { 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 === "user-groups" && window.UserGroupsApp) window.UserGroupsApp.onPageShow(); + 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 === "users" && window.UsersApp) window.UsersApp.onPageShow(); + else if (window.UsersApp?.onPageHide) window.UsersApp.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 bd7b02a..b9f2632 100644 --- a/www/auth.js +++ b/www/auth.js @@ -151,6 +151,9 @@ missions: "missions", sounds: "sounds", transitions: "maps", + "user-groups": "users", + "io-modules": "integrations", + users: "users", integrations: "integrations", }; const resource = map[page]; @@ -171,6 +174,9 @@ 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")); + 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")); } function updateUserMenu() { diff --git a/www/dashboard.js b/www/dashboard.js index 6ed8dbb..8fceb09 100644 --- a/www/dashboard.js +++ b/www/dashboard.js @@ -56,6 +56,7 @@ userGroups: [], maps: [], mapsLoaded: false, + ioModules: [], robotPose: null, robotPoseAt: 0, mapPollTimer: null, @@ -277,6 +278,167 @@ }); } + async function ensureIoModulesLoaded() { + if (store.ioModules.length) return store.ioModules; + try { + if (window.IoModulesCatalog?.refresh) { + store.ioModules = await window.IoModulesCatalog.refresh(); + } else { + const res = await fetch("/api/io_modules", { credentials: "include" }); + if (res.ok) { + const data = await res.json(); + store.ioModules = Array.isArray(data.io_modules) ? data.io_modules : []; + } + } + } catch { + store.ioModules = []; + } + return store.ioModules; + } + + function ioModuleOptions(selectedId) { + const list = store.ioModules || []; + if (!list.length) return ``; + return list + .map( + (m) => + `` + ) + .join(""); + } + + function ioModuleById(id) { + return (store.ioModules || []).find((m) => m.id === id) || null; + } + + async function setIoModuleConnected(id, connect) { + const path = connect ? "connect" : "disconnect"; + const res = await fetch(`/api/io_modules/${encodeURIComponent(id)}/${path}`, { + method: "POST", + credentials: "include", + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + const data = await res.json(); + const idx = store.ioModules.findIndex((m) => m.id === id); + if (idx >= 0) store.ioModules[idx] = data; + return data; + } + + function renderIoConnectWidget(widget, bodyEl) { + if (!bodyEl) return; + const mod = ioModuleById(widget.io_module_id); + const connected = !!mod?.connected; + const label = widget.title || mod?.name || t("dashboard.widget.io_connect"); + bodyEl.innerHTML = ` + +

${escapeHtml(label)} · ${escapeHtml(mod ? (mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected")) : "—")}

`; + const btn = bodyEl.querySelector("[data-io-connect]"); + btn?.addEventListener("click", async () => { + if (!widget.io_module_id) return; + try { + await setIoModuleConnected(widget.io_module_id, !connected); + renderIoConnectWidget(widget, bodyEl); + } catch (e) { + alert(e.message); + } + }); + } + + function renderIoStatusWidget(widget, bodyEl) { + if (!bodyEl) return; + const mod = ioModuleById(widget.io_module_id); + const label = widget.title || mod?.name || t("dashboard.widget.io_status"); + bodyEl.innerHTML = ` +
+
${escapeHtml(label)}
+
${escapeHtml(mod?.module_type ? t(`ioModules.type.${mod.module_type}`) : "—")} · ${escapeHtml(mod?.ip_address || "—")}
+
${escapeHtml(mod ? (mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected")) : "—")}
+
`; + } + + async function fetchIoRuntime(moduleId) { + const res = await fetch(`/api/io_modules/${encodeURIComponent(moduleId)}/runtime`, { credentials: "include" }); + if (!res.ok) throw new Error(res.statusText); + return res.json(); + } + + async function setIoOutput(moduleId, port, value, timeoutMs = 0) { + const res = await fetch(`/api/io_modules/${encodeURIComponent(moduleId)}/output`, { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ output: port, value, timeout_ms: timeoutMs }), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(text || res.statusText); + } + return res.json(); + } + + function ioPortRange(mod) { + return mod?.module_type === "wise" ? [0, 1, 2, 3] : [1, 2, 3, 4]; + } + + function renderIoConfigurationWidget(widget, bodyEl) { + if (!bodyEl) return; + const mod = ioModuleById(widget.io_module_id); + const label = widget.title || mod?.name || t("dashboard.widget.io_configuration"); + if (!mod) { + bodyEl.innerHTML = `

${escapeHtml(t("dashboard.widget.configHint"))}

`; + return; + } + const ports = ioPortRange(mod); + bodyEl.innerHTML = ` +
+
${escapeHtml(label)}
+
${escapeHtml(mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected"))}
+
+
`; + const portsEl = bodyEl.querySelector("[data-io-config-ports]"); + ports.forEach((port) => { + const row = document.createElement("div"); + row.className = "dashboardIoConfigRow"; + row.innerHTML = ` + ${escapeHtml(t("dashboard.widget.ioPort", { port }))} + + `; + portsEl.appendChild(row); + }); + const refresh = async () => { + try { + const rt = await fetchIoRuntime(mod.id); + portsEl.querySelectorAll("[data-port]").forEach((btn) => { + const port = Number(btn.dataset.port); + const outputs = Array.isArray(rt.outputs) ? rt.outputs : []; + const idx = mod.module_type === "wise" ? port : port; + const on = !!outputs[idx]; + btn.classList.toggle("is-active", (btn.dataset.value === "on") === on); + }); + } catch { + /* ignore */ + } + }; + portsEl.addEventListener("click", async (evt) => { + const btn = evt.target.closest("[data-port]"); + if (!btn || !widget.io_module_id) return; + const port = Number(btn.dataset.port); + const on = btn.dataset.value === "on"; + try { + await setIoOutput(widget.io_module_id, port, on); + await refresh(); + } catch (e) { + alert(e.message); + } + }); + void refresh(); + } + function widgetTypeLabel(type) { return t(`dashboard.widget.${type}`) || type; } @@ -986,6 +1148,16 @@ `; + } else if (type === "io_connect" || type === "io_status" || type === "io_configuration") { + container.innerHTML = ` +
+ + +
+
+ + +
`; } } @@ -1209,6 +1381,15 @@ case "robot_summary": renderRobotSummaryWidget(widget, bodyEl); break; + case "io_connect": + renderIoConnectWidget(widget, bodyEl); + break; + case "io_status": + renderIoStatusWidget(widget, bodyEl); + break; + case "io_configuration": + renderIoConfigurationWidget(widget, bodyEl); + break; default: bodyEl.innerHTML = `

${t("dashboard.widget.unsupported")}

`; } @@ -1245,6 +1426,9 @@ if (widget.type === "mission_queue") refreshQueueWidget(bodyEl); if (widget.type === "pause_continue") renderPauseContinueWidget(widget, bodyEl); if (widget.type === "mission_action_log") renderMissionActionLogWidget(widget, bodyEl); + if (widget.type === "io_connect") renderIoConnectWidget(widget, bodyEl); + if (widget.type === "io_status") renderIoStatusWidget(widget, bodyEl); + if (widget.type === "io_configuration") renderIoConfigurationWidget(widget, bodyEl); }); if (hasMapWidget()) refreshMapWidgets(); } @@ -1260,6 +1444,8 @@ }; if (widget.type === "map" || widget.type === "map_locked") { void ensureMapsLoaded().then(open); + } else if (widget.type === "io_connect" || widget.type === "io_status" || widget.type === "io_configuration") { + void ensureIoModulesLoaded().then(open); } else { open(); } @@ -1484,6 +1670,7 @@ async function init() { await loadStoreFromBackend(); await loadUserGroups(); + await ensureIoModulesLoaded(); bindEvents(); setView("list"); } @@ -1493,6 +1680,7 @@ getNavItems, handleNav, onPageShow() { + void ensureIoModulesLoaded(); if (store.view === "designer") { syncDesignerEditMode(); renderDesignerChrome(); diff --git a/www/i18n.js b/www/i18n.js index 1cbb679..07a6b3a 100644 --- a/www/i18n.js +++ b/www/i18n.js @@ -74,6 +74,9 @@ "nav.maps": "Maps", "nav.sounds": "Sounds", "nav.transitions": "Transitions", + "nav.user-groups": "User groups", + "nav.io-modules": "I/O modules", + "nav.users": "Users", "nav.build-robot": "Build Robot", "nav.monitoring-log": "System log", "nav.integrations": "Tích hợp", @@ -194,6 +197,15 @@ "dashboard.widget.map_locked": "Locked map", "dashboard.widget.map": "Map", "dashboard.widget.robot_summary": "Robot summary", + "dashboard.widget.io_connect": "Connect I/O module", + "dashboard.widget.io_status": "I/O status", + "dashboard.widget.io_connectAction": "Connect", + "dashboard.widget.io_disconnect": "Disconnect", + "dashboard.widget.io_configuration": "I/O configuration", + "dashboard.widget.ioPort": "Port {port}", + "dashboard.widget.ioOn": "ON", + "dashboard.widget.ioOff": "OFF", + "dashboard.widget.field.ioModule": "I/O module", "dashboard.widget.field.map": "Map", "dashboard.widget.mapActive": "Active map (robot)", "dashboard.widget.mapHint": "Chọn map cố định hoặc để «Active map» dùng map đang gắn với robot.", @@ -304,6 +316,8 @@ "maps.activeHint": "Map đang hoạt động: {name}", "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.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", @@ -555,6 +569,129 @@ "transitions.deleteConfirmText": "Xóa transition {from} → {to}?", "transitions.error.missing": "Thiếu trường bắt buộc.", + "users.title": "Users", + "users.subtitle": "Tạo và chỉnh sửa users.", + "users.helpTitle": "Trợ giúp Users", + "users.helpBody": "Mỗi người cần user profile. Name hiển thị góc phải khi đăng nhập; Username dùng để login. Quyền theo User group (§4.6). PIN 4 số chỉ cho nhóm cho phép. User tự đổi password qua menu góc phải.", + "users.create": "Tạo user", + "users.clearFilters": "Xóa bộ lọc", + "users.filterLabel": "Lọc:", + "users.filterPlaceholder": "Lọc theo tên hoặc username...", + "users.itemsFound": "{n} mục", + "users.pageOf": "Trang {page} / {total}", + "users.colName": "Name", + "users.colUsername": "Username", + "users.colGroup": "User group", + "users.colEmail": "Email", + "users.colPin": "PIN", + "users.colFunctions": "Functions", + "users.empty": "Chưa có user.", + "users.emptyFilter": "Không có user khớp bộ lọc.", + "users.createTitle": "Tạo user", + "users.editTitle": "Sửa user", + "users.fieldName": "Name", + "users.fieldUsername": "Username", + "users.fieldPassword": "Password", + "users.fieldEmail": "Email address", + "users.fieldGroup": "User group", + "users.fieldEnabled": "Enabled", + "users.fieldPinEnable": "Cho phép đăng nhập PIN", + "users.fieldPin": "Mã PIN (4 số)", + "users.passwordEditHint": "User tự đổi password qua menu góc phải trên.", + "users.pinGroupHint": "PIN chỉ dùng được với user group cho phép PIN.", + "users.pinYes": "Có", + "users.pinNo": "Không", + "users.save": "Lưu", + "users.deleteTitle": "Xóa user?", + "users.deleteConfirmText": "Xóa user \"{name}\"?", + "users.deleteHint": "Chỉ xóa hồ sơ user. Map, mission và dữ liệu khác do user tạo vẫn giữ nguyên.", + "users.error.missing": "Thiếu trường bắt buộc.", + "users.error.passwordRequired": "Nhập password khi tạo user.", + "users.error.pinInvalid": "PIN phải đúng 4 chữ số.", + + "userGroups.title": "User groups", + "userGroups.subtitle": "Tạo và chỉnh sửa user groups.", + "userGroups.helpTitle": "Trợ giúp User groups", + "userGroups.helpBody": "Mỗi user thuộc một user group. Quyền (none/read/write) áp dụng cho từng module. Tạo group trước khi tạo user. PIN chỉ dùng khi bật Allow PIN sign-in.", + "userGroups.create": "Tạo user group", + "userGroups.clearFilters": "Xóa bộ lọc", + "userGroups.filterLabel": "Lọc:", + "userGroups.filterPlaceholder": "Lọc theo tên...", + "userGroups.itemsFound": "{n} mục", + "userGroups.pageOf": "Trang {page} / {total}", + "userGroups.colName": "Name", + "userGroups.colUsers": "Users", + "userGroups.colPin": "PIN sign-in", + "userGroups.colPermissions": "Permissions", + "userGroups.colFunctions": "Functions", + "userGroups.empty": "Chưa có user group.", + "userGroups.emptyFilter": "Không có user group khớp bộ lọc.", + "userGroups.createTitle": "Tạo user group", + "userGroups.editTitle": "Sửa user group", + "userGroups.fieldName": "Name", + "userGroups.fieldAllowPin": "Cho phép đăng nhập PIN", + "userGroups.permissionsTitle": "Permissions", + "userGroups.permissionsHint": "Gán quyền read hoặc write cho từng module. None ẩn module khỏi menu.", + "userGroups.permColModule": "Module", + "userGroups.permColAccess": "Access", + "userGroups.perm.dashboard": "Dashboards", + "userGroups.perm.config": "Build robot", + "userGroups.perm.maps": "Maps", + "userGroups.perm.missions": "Missions", + "userGroups.perm.sounds": "Sounds", + "userGroups.perm.integrations": "Integrations", + "userGroups.perm.users": "Users & User groups", + "userGroups.permLevel.none": "None", + "userGroups.permLevel.read": "Read", + "userGroups.permLevel.write": "Write", + "userGroups.permSummaryAllWrite": "Full write", + "userGroups.permSummaryNone": "No access", + "userGroups.permSummaryMixed": "{write} write, {read} read", + "userGroups.pinYes": "Có", + "userGroups.pinNo": "Không", + "userGroups.userCount": "{n} user(s)", + "userGroups.save": "Lưu", + "userGroups.deleteTitle": "Xóa user group?", + "userGroups.deleteConfirmText": "Xóa user group \"{name}\"?", + "userGroups.deleteHint": "Phải chuyển users sang group khác trước khi xóa.", + "userGroups.error.missing": "Thiếu trường bắt buộc.", + + "ioModules.title": "I/O modules", + "ioModules.subtitle": "Tạo và quản lý kết nối I/O.", + "ioModules.helpTitle": "Trợ giúp I/O modules", + "ioModules.helpBody": "I/O module (Bluetooth hoặc WISE) dùng để giao tiếp cửa, thang nâng, băng tải… Nhập IP và chọn loại module. Dùng trong mission (Set output / Wait for input) và I/O zones trên map.", + "ioModules.create": "Tạo I/O connection", + "ioModules.clearFilters": "Xóa bộ lọc", + "ioModules.filterLabel": "Lọc:", + "ioModules.filterPlaceholder": "Lọc theo tên hoặc IP...", + "ioModules.itemsFound": "{n} mục", + "ioModules.pageOf": "Trang {page} / {total}", + "ioModules.colName": "Name", + "ioModules.colType": "Type", + "ioModules.colIp": "IP address", + "ioModules.colStatus": "Status", + "ioModules.colFunctions": "Functions", + "ioModules.empty": "Chưa có I/O module.", + "ioModules.emptyFilter": "Không có I/O module khớp bộ lọc.", + "ioModules.createTitle": "Tạo I/O connection", + "ioModules.editTitle": "Sửa I/O connection", + "ioModules.fieldSite": "Site", + "ioModules.fieldName": "Name", + "ioModules.fieldType": "I/O module type", + "ioModules.fieldIp": "IP address", + "ioModules.type.bluetooth": "Bluetooth", + "ioModules.type.wise": "WISE", + "ioModules.typeHint.bluetooth": "Output ports 1–4.", + "ioModules.typeHint.wise": "Output ports 0–3.", + "ioModules.status.connected": "Connected", + "ioModules.status.disconnected": "Disconnected", + "ioModules.save": "Lưu", + "ioModules.deleteTitle": "Xóa I/O connection?", + "ioModules.deleteConfirmText": "Xóa I/O module \"{name}\"?", + "ioModules.error.missing": "Thiếu trường bắt buộc.", + "ioModules.testConnection": "Kiểm tra kết nối", + "ioModules.testOk": "Kết nối TCP thành công.", + "missions.title": "Missions", "missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.", "missions.create": "Tạo mission", @@ -618,6 +755,9 @@ "missions.action.pause": "Pause", "missions.action.set_digital_output": "Set digital output", "missions.action.wait_digital_input": "Wait for digital input", + "missions.action.connect_bluetooth": "Connect Bluetooth module", + "missions.action.disconnect_bluetooth": "Disconnect Bluetooth module", + "missions.action.while": "While", "missions.action.set_plc_register": "Set PLC register", "missions.action.pick_cart": "Pick cart", "missions.action.drop_cart": "Drop cart", @@ -745,6 +885,9 @@ "nav.maps": "Maps", "nav.sounds": "Sounds", "nav.transitions": "Transitions", + "nav.user-groups": "User groups", + "nav.io-modules": "I/O modules", + "nav.users": "Users", "nav.build-robot": "Build Robot", "nav.monitoring-log": "System log", "nav.integrations": "Integrations", @@ -865,6 +1008,15 @@ "dashboard.widget.map_locked": "Locked map", "dashboard.widget.map": "Map", "dashboard.widget.robot_summary": "Robot summary", + "dashboard.widget.io_connect": "Connect I/O module", + "dashboard.widget.io_status": "I/O status", + "dashboard.widget.io_connectAction": "Connect", + "dashboard.widget.io_disconnect": "Disconnect", + "dashboard.widget.io_configuration": "I/O configuration", + "dashboard.widget.ioPort": "Port {port}", + "dashboard.widget.ioOn": "ON", + "dashboard.widget.ioOff": "OFF", + "dashboard.widget.field.ioModule": "I/O module", "dashboard.widget.field.map": "Map", "dashboard.widget.mapActive": "Active map (robot)", "dashboard.widget.mapHint": "Pick a fixed map or leave «Active map» to follow the robot's current map.", @@ -975,6 +1127,8 @@ "maps.activeHint": "Active map: {name}", "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.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", @@ -1226,6 +1380,129 @@ "transitions.deleteConfirmText": "Delete transition {from} → {to}?", "transitions.error.missing": "Missing required fields.", + "users.title": "Users", + "users.subtitle": "Create and edit users.", + "users.helpTitle": "Users help", + "users.helpBody": "Everyone needs a user profile. Name is shown in the upper right when signed in; Username is used to log in. Permissions come from the user group (§4.6). PIN is only for groups that allow PIN sign-in. Users change their own password from the menu in the upper right.", + "users.create": "Create user", + "users.clearFilters": "Clear filters", + "users.filterLabel": "Filter:", + "users.filterPlaceholder": "Filter by name or username...", + "users.itemsFound": "{n} item(s) found", + "users.pageOf": "Page {page} of {total}", + "users.colName": "Name", + "users.colUsername": "Username", + "users.colGroup": "User group", + "users.colEmail": "Email", + "users.colPin": "PIN", + "users.colFunctions": "Functions", + "users.empty": "No users yet.", + "users.emptyFilter": "No users match the filter.", + "users.createTitle": "Create user", + "users.editTitle": "Edit user", + "users.fieldName": "Name", + "users.fieldUsername": "Username", + "users.fieldPassword": "Password", + "users.fieldEmail": "Email address", + "users.fieldGroup": "User group", + "users.fieldEnabled": "Enabled", + "users.fieldPinEnable": "Allow PIN sign-in", + "users.fieldPin": "PIN code (4 digits)", + "users.passwordEditHint": "Users change their own password from the menu in the upper right corner.", + "users.pinGroupHint": "PIN is only available for user groups that allow PIN sign-in.", + "users.pinYes": "Yes", + "users.pinNo": "No", + "users.save": "Save", + "users.deleteTitle": "Delete user?", + "users.deleteConfirmText": "Delete user \"{name}\"?", + "users.deleteHint": "Only the user profile is removed. Maps, missions and other data created by this user remain unchanged.", + "users.error.missing": "Missing required fields.", + "users.error.passwordRequired": "Enter a password when creating a user.", + "users.error.pinInvalid": "PIN must be exactly 4 digits.", + + "userGroups.title": "User groups", + "userGroups.subtitle": "Create and edit user groups.", + "userGroups.helpTitle": "User groups help", + "userGroups.helpBody": "Each user belongs to one user group. Permissions (none/read/write) apply per module. Create groups before users. PIN sign-in works only when Allow PIN sign-in is enabled.", + "userGroups.create": "Create user group", + "userGroups.clearFilters": "Clear filters", + "userGroups.filterLabel": "Filter:", + "userGroups.filterPlaceholder": "Filter by name...", + "userGroups.itemsFound": "{n} item(s) found", + "userGroups.pageOf": "Page {page} of {total}", + "userGroups.colName": "Name", + "userGroups.colUsers": "Users", + "userGroups.colPin": "PIN sign-in", + "userGroups.colPermissions": "Permissions", + "userGroups.colFunctions": "Functions", + "userGroups.empty": "No user groups yet.", + "userGroups.emptyFilter": "No user groups match the filter.", + "userGroups.createTitle": "Create user group", + "userGroups.editTitle": "Edit user group", + "userGroups.fieldName": "Name", + "userGroups.fieldAllowPin": "Allow PIN sign-in", + "userGroups.permissionsTitle": "Permissions", + "userGroups.permissionsHint": "Set read or write access per module. None hides the module from the menu.", + "userGroups.permColModule": "Module", + "userGroups.permColAccess": "Access", + "userGroups.perm.dashboard": "Dashboards", + "userGroups.perm.config": "Build robot", + "userGroups.perm.maps": "Maps", + "userGroups.perm.missions": "Missions", + "userGroups.perm.sounds": "Sounds", + "userGroups.perm.integrations": "Integrations", + "userGroups.perm.users": "Users & User groups", + "userGroups.permLevel.none": "None", + "userGroups.permLevel.read": "Read", + "userGroups.permLevel.write": "Write", + "userGroups.permSummaryAllWrite": "Full write", + "userGroups.permSummaryNone": "No access", + "userGroups.permSummaryMixed": "{write} write, {read} read", + "userGroups.pinYes": "Yes", + "userGroups.pinNo": "No", + "userGroups.userCount": "{n} user(s)", + "userGroups.save": "Save", + "userGroups.deleteTitle": "Delete user group?", + "userGroups.deleteConfirmText": "Delete user group \"{name}\"?", + "userGroups.deleteHint": "Users must be moved to another group before this group can be deleted.", + "userGroups.error.missing": "Missing required fields.", + + "ioModules.title": "I/O modules", + "ioModules.subtitle": "Create and manage I/O connections.", + "ioModules.helpTitle": "I/O modules help", + "ioModules.helpBody": "I/O modules (Bluetooth or WISE) communicate with doors, lifts, conveyors, etc. Enter the IP and module type. Use in missions (Set output / Wait for input) and I/O zones on maps.", + "ioModules.create": "Create I/O connection", + "ioModules.clearFilters": "Clear filters", + "ioModules.filterLabel": "Filter:", + "ioModules.filterPlaceholder": "Filter by name or IP...", + "ioModules.itemsFound": "{n} item(s) found", + "ioModules.pageOf": "Page {page} of {total}", + "ioModules.colName": "Name", + "ioModules.colType": "Type", + "ioModules.colIp": "IP address", + "ioModules.colStatus": "Status", + "ioModules.colFunctions": "Functions", + "ioModules.empty": "No I/O modules yet.", + "ioModules.emptyFilter": "No I/O modules match the filter.", + "ioModules.createTitle": "Create I/O connection", + "ioModules.editTitle": "Edit I/O connection", + "ioModules.fieldSite": "Site", + "ioModules.fieldName": "Name", + "ioModules.fieldType": "I/O module type", + "ioModules.fieldIp": "IP address", + "ioModules.type.bluetooth": "Bluetooth", + "ioModules.type.wise": "WISE", + "ioModules.typeHint.bluetooth": "Output ports 1–4.", + "ioModules.typeHint.wise": "Output ports 0–3.", + "ioModules.status.connected": "Connected", + "ioModules.status.disconnected": "Disconnected", + "ioModules.save": "Save", + "ioModules.deleteTitle": "Delete I/O connection?", + "ioModules.deleteConfirmText": "Delete I/O module \"{name}\"?", + "ioModules.error.missing": "Missing required fields.", + "ioModules.testConnection": "Test connection", + "ioModules.testOk": "TCP connection successful.", + "missions.title": "Missions", "missions.subtitle": "Setup → Missions — robot task list.", "missions.create": "Create mission", @@ -1289,6 +1566,9 @@ "missions.action.pause": "Pause", "missions.action.set_digital_output": "Set digital output", "missions.action.wait_digital_input": "Wait for digital input", + "missions.action.connect_bluetooth": "Connect Bluetooth module", + "missions.action.disconnect_bluetooth": "Disconnect Bluetooth module", + "missions.action.while": "While", "missions.action.set_plc_register": "Set PLC register", "missions.action.pick_cart": "Pick cart", "missions.action.drop_cart": "Drop cart", diff --git a/www/index.html b/www/index.html index 606f44e..5df2e46 100644 --- a/www/index.html +++ b/www/index.html @@ -358,7 +358,9 @@

Widget nhóm này sẽ có trong bản cập nhật sau.

+ + + + + +