update User, User group, IO modules
Some checks failed
Test / test (push) Has been cancelled

This commit is contained in:
2026-06-23 11:06:26 +07:00
parent 523f98b74c
commit 50a2587cef
32 changed files with 4728 additions and 52 deletions

View File

@@ -45,6 +45,10 @@ add_executable(lidar_manager_web
src/storage/site_store.cpp src/storage/site_store.cpp
src/storage/sound_store.cpp src/storage/sound_store.cpp
src/storage/transition_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/dashboard_store.cpp
src/storage/state_repository.cpp src/storage/state_repository.cpp
src/validation/sensor_validator.cpp src/validation/sensor_validator.cpp
@@ -61,6 +65,7 @@ add_executable(lidar_manager_web
src/server/api_robot_routes.cpp src/server/api_robot_routes.cpp
src/server/api_media_routes.cpp src/server/api_media_routes.cpp
src/server/api_transition_routes.cpp src/server/api_transition_routes.cpp
src/server/api_io_module_routes.cpp
src/server/api_dashboard_routes.cpp src/server/api_dashboard_routes.cpp
) )

View File

@@ -1,6 +1,8 @@
#include "app/lidar_manager_app.hpp" #include "app/lidar_manager_app.hpp"
#include "auth/auth_service.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_enqueue.hpp"
#include "mission/mission_queue.hpp" #include "mission/mission_queue.hpp"
#include "mission/mission_scheduler.hpp" #include "mission/mission_scheduler.hpp"
@@ -14,6 +16,7 @@
#include "storage/map_store.hpp" #include "storage/map_store.hpp"
#include "storage/site_store.hpp" #include "storage/site_store.hpp"
#include "storage/sound_store.hpp" #include "storage/sound_store.hpp"
#include "storage/io_module_store.hpp"
#include "storage/transition_store.hpp" #include "storage/transition_store.hpp"
#include "storage/state_repository.hpp" #include "storage/state_repository.hpp"
@@ -64,8 +67,11 @@ int LidarManagerApp::run()
MapStore map_store(database); MapStore map_store(database);
TransitionStore transition_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); 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); RobotRuntime robot_runtime(database, mission_queue);
SiteStore site_store(database); SiteStore site_store(database);
site_store.ensureDefaultSiteId(); site_store.ensureDefaultSiteId();
@@ -104,6 +110,8 @@ int LidarManagerApp::run()
site_store, site_store,
sound_store, sound_store,
transition_store, transition_store,
io_module_store,
io_module_service,
dashboard_store); dashboard_store);
api.registerRoutes(svr); api.registerRoutes(svr);
auth.registerRoutes(svr); auth.registerRoutes(svr);

View File

@@ -231,6 +231,8 @@ std::optional<std::string> AuthService::resourceForApiPath(const std::string& pa
return "dashboard"; return "dashboard";
if (path.rfind("/api/sounds", 0) == 0) if (path.rfind("/api/sounds", 0) == 0)
return "sounds"; return "sounds";
if (path.rfind("/api/io_modules", 0) == 0)
return "integrations";
if (path == "/api/robot/active_map") if (path == "/api/robot/active_map")
return "maps"; return "maps";
if (path.rfind("/api/maps", 0) == 0 || path.rfind("/api/sites", 0) == 0 || 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); 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<AuthSession> AuthService::buildSessionUnlocked(const nlohmann::json& user) std::optional<AuthSession> AuthService::buildSessionUnlocked(const nlohmann::json& user)
{ {
const auto* group = findGroupByIdUnlocked(user.value("group_id", "")); 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", "")}, return {{"id", user.value("id", "")},
{"username", user.value("username", "")}, {"username", user.value("username", "")},
{"display_name", user.value("display_name", "")}, {"display_name", user.value("display_name", "")},
{"email", user.value("email", "")},
{"group_id", user.value("group_id", "")}, {"group_id", user.value("group_id", "")},
{"group_name", group.value("name", "")}, {"group_name", group.value("name", "")},
{"permissions", group.value("permissions", nlohmann::json::object())}, {"permissions", group.value("permissions", nlohmann::json::object())},
{"enabled", user.value("enabled", true)},
{"has_pin", !user.value("pin_hash", nlohmann::json()).is_null()}}; {"has_pin", !user.value("pin_hash", nlohmann::json()).is_null()}};
} }
@@ -525,20 +543,215 @@ std::optional<nlohmann::json> AuthService::changeProfile(const std::string& toke
return std::nullopt; 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<std::string>();
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 nlohmann::json AuthService::listGroups() const
{ {
std::lock_guard<std::mutex> lock(mu_); std::lock_guard<std::mutex> lock(mu_);
nlohmann::json out = nlohmann::json::array(); nlohmann::json out = nlohmann::json::array();
for (const auto& g : data_["groups"]) for (const auto& g : data_["groups"])
{ {
out.push_back({{"id", g.value("id", "")}, const std::string id = g.value("id", "");
{"name", g.value("name", "")}, out.push_back(groupPublicView(g, countUsersInGroupUnlocked(id)));
{"allow_pin", g.value("allow_pin", false)},
{"permissions", g.value("permissions", nlohmann::json::object())}});
} }
return out; return out;
} }
std::optional<nlohmann::json> 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<std::mutex> 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<nlohmann::json> 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<std::mutex> 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<std::string>());
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<bool>();
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<std::mutex> 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 nlohmann::json AuthService::listUsers() const
{ {
std::lock_guard<std::mutex> lock(mu_); std::lock_guard<std::mutex> lock(mu_);
@@ -576,6 +789,8 @@ std::optional<nlohmann::json> AuthService::createUser(const nlohmann::json& payl
const std::string id = "user_" + IdUtil::newId(); const std::string id = "user_" + IdUtil::newId();
auto user = makeUser(id, username, password, group_id, payload.value("display_name", username)); 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<std::string>());
if (payload.contains("pin") && !payload["pin"].is_null()) if (payload.contains("pin") && !payload["pin"].is_null())
{ {
const std::string pin = payload.value("pin", ""); const std::string pin = payload.value("pin", "");
@@ -589,6 +804,11 @@ std::optional<nlohmann::json> AuthService::createUser(const nlohmann::json& payl
err = "group does not allow pin"; err = "group does not allow pin";
return std::nullopt; return std::nullopt;
} }
if (pinInUseUnlocked(pin))
{
err = "pin already in use";
return std::nullopt;
}
const std::string pin_salt = CryptoUtil::randomToken(16); const std::string pin_salt = CryptoUtil::randomToken(16);
user["pin_salt"] = pin_salt; user["pin_salt"] = pin_salt;
user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin); user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin);
@@ -612,6 +832,29 @@ std::optional<nlohmann::json> AuthService::updateUser(const std::string& id,
if (payload.contains("display_name")) if (payload.contains("display_name"))
user["display_name"] = payload["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<std::string>());
}
if (payload.contains("username") && payload["username"].is_string())
{
const std::string username = StringUtil::trimCopy(payload["username"].get<std::string>());
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")) if (payload.contains("enabled"))
user["enabled"] = payload["enabled"]; user["enabled"] = payload["enabled"];
if (payload.contains("group_id")) if (payload.contains("group_id"))
@@ -651,6 +894,11 @@ std::optional<nlohmann::json> AuthService::updateUser(const std::string& id,
err = "group does not allow pin"; err = "group does not allow pin";
return std::nullopt; return std::nullopt;
} }
if (pinInUseUnlocked(pin, id))
{
err = "pin already in use";
return std::nullopt;
}
const std::string pin_salt = CryptoUtil::randomToken(16); const std::string pin_salt = CryptoUtil::randomToken(16);
user["pin_salt"] = pin_salt; user["pin_salt"] = pin_salt;
user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin); user["pin_hash"] = CryptoUtil::hashPin(pin_salt, pin);
@@ -854,6 +1102,62 @@ void AuthService::registerRoutes(httplib::Server& svr)
HttpUtil::addCors(res); 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) { svr.Get("/api/users", [this](const httplib::Request&, httplib::Response& res) {
nlohmann::json out = {{"users", listUsers()}}; nlohmann::json out = {{"users", listUsers()}};
res.set_content(out.dump(), "application/json; charset=utf-8"); 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) { 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; std::string err;
if (!deleteUser(req.matches[1].str(), err)) if (!deleteUser(id, err))
{ {
HttpUtil::jsonError(res, 400, err); HttpUtil::jsonError(res, 400, err);
return; return;

View File

@@ -48,6 +48,11 @@ public:
std::string& err); std::string& err);
nlohmann::json listGroups() const; nlohmann::json listGroups() const;
std::optional<nlohmann::json> createGroup(const nlohmann::json& payload, std::string& err);
std::optional<nlohmann::json> 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; nlohmann::json listUsers() const;
std::optional<nlohmann::json> createUser(const nlohmann::json& payload, std::string& err); std::optional<nlohmann::json> createUser(const nlohmann::json& payload, std::string& err);
std::optional<nlohmann::json> updateUser(const std::string& id, std::optional<nlohmann::json> updateUser(const std::string& id,
@@ -80,6 +85,13 @@ private:
bool verifyPasswordUnlocked(const nlohmann::json& user, const std::string& password) const; bool verifyPasswordUnlocked(const nlohmann::json& user, const std::string& password) const;
bool verifyPinUnlocked(const nlohmann::json& user, const std::string& pin) const; bool verifyPinUnlocked(const nlohmann::json& user, const std::string& pin) const;
bool groupAllowsPinUnlocked(const std::string& group_id) 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 } // namespace lm

View File

@@ -0,0 +1,366 @@
#include "io/io_module_service.hpp"
#include "storage/io_module_store.hpp"
#include "util/string_util.hpp"
#include <algorithm>
#include <arpa/inet.h>
#include <cerrno>
#include <chrono>
#include <cstring>
#include <fcntl.h>
#include <netdb.h>
#include <sys/socket.h>
#include <thread>
#include <unistd.h>
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<bool>();
if (v.is_string())
{
const std::string s = StringUtil::toLower(v.get<std::string>());
return s == "on" || s == "true" || s == "1";
}
if (v.is_number())
return v.get<int>() != 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<int>();
else if (params.contains("pin") && params["pin"].is_number_integer())
port = params["pin"].get<int>();
else if (params.contains("input") && params["input"].is_number_integer())
port = params["input"].get<int>();
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<nlohmann::json> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<bool>(kPortCount, false);
if (inputs_.find(module_id) == inputs_.end())
inputs_[module_id] = std::vector<bool>(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<std::mutex> 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<size_t>(idx)] = on;
inputs_[module_id][static_cast<size_t>(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<std::mutex> 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<size_t>(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<std::mutex> 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

View File

@@ -0,0 +1,54 @@
#pragma once
#include <nlohmann/json.hpp>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
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<nlohmann::json> 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<std::string, bool> connected_;
std::unordered_map<std::string, std::vector<bool>> outputs_;
std::unordered_map<std::string, std::vector<bool>> 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

View File

@@ -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

View File

@@ -0,0 +1,18 @@
#pragma once
#include <nlohmann/json.hpp>
#include <string>
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

120
src/io/io_zone_runtime.cpp Normal file
View File

@@ -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<std::pair<double, double>> 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<double, double>{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

View File

@@ -0,0 +1,32 @@
#pragma once
#include <nlohmann/json.hpp>
#include <string>
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<std::pair<double, double>> positionCoords(const nlohmann::json& map,
const std::string& position_id);
};
} // namespace lm

View File

@@ -1,13 +1,17 @@
#include "mission/mission_queue.hpp" #include "mission/mission_queue.hpp"
#include "io/io_module_service.hpp"
#include "io/io_zone_runtime.hpp"
#include "mission/mission_store.hpp" #include "mission/mission_store.hpp"
#include "mission/position_resolver.hpp" #include "mission/position_resolver.hpp"
#include "storage/database.hpp" #include "storage/database.hpp"
#include "storage/map_store.hpp" #include "storage/map_store.hpp"
#include "storage/transition_store.hpp" #include "storage/transition_store.hpp"
#include "util/id_util.hpp" #include "util/id_util.hpp"
#include "util/string_util.hpp"
#include <chrono> #include <chrono>
#include <cmath>
#include <stdexcept> #include <stdexcept>
#include <thread> #include <thread>
#include <unordered_set> #include <unordered_set>
@@ -42,10 +46,61 @@ double paramNumber(const nlohmann::json& params, const std::string& key, double
return fallback; 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<bool>();
if (params[key].is_number())
return params[key].get<int>() != 0;
if (params[key].is_string())
{
const std::string s = StringUtil::toLower(params[key].get<std::string>());
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<int>();
else if (params.contains("input") && params["input"].is_number_integer())
port = params["input"].get<int>();
else if (params.contains("pin") && params["pin"].is_number_integer())
port = params["pin"].get<int>();
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<std::string>();
if (params.contains("module_id") && params["module_id"].is_string())
return params["module_id"].get<std::string>();
return "";
}
} // namespace } // namespace
MissionQueue::MissionQueue(Database& db, MapStore& maps, TransitionStore& transitions, MissionStore& missions) MissionQueue::MissionQueue(Database& db,
: db_(db), maps_(maps), transitions_(transitions), missions_(missions), position_resolver_(maps) 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(); load();
ensureRunnerDefaults(); ensureRunnerDefaults();
@@ -676,6 +731,24 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j
sleepMs(1200); sleepMs(1200);
if (cancel_) if (cancel_)
throw MissionCancelled(); 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; continue;
} }
} }
@@ -737,6 +810,228 @@ MissionQueue::LoopControl MissionQueue::executeActionsUnlocked(const nlohmann::j
continue; 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<int>(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<int>(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<int>(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( log.push_back(
{{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}}); {{"ts", IdUtil::nowIso8601()}, {"level", "info"}, {"message", label + " (" + type + ") simulated"}});
sleepMs(400); sleepMs(400);

View File

@@ -13,6 +13,8 @@
namespace lm { namespace lm {
class Database; class Database;
class IoModuleService;
class IoZoneRuntime;
class MapStore; class MapStore;
class MissionStore; class MissionStore;
class TransitionStore; class TransitionStore;
@@ -20,7 +22,12 @@ class TransitionStore;
class MissionQueue class MissionQueue
{ {
public: 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();
MissionQueue(const MissionQueue&) = delete; MissionQueue(const MissionQueue&) = delete;
@@ -44,6 +51,8 @@ private:
MapStore& maps_; MapStore& maps_;
TransitionStore& transitions_; TransitionStore& transitions_;
MissionStore& missions_; MissionStore& missions_;
IoModuleService& io_modules_;
IoZoneRuntime& io_zones_;
PositionResolver position_resolver_; PositionResolver position_resolver_;
mutable std::recursive_mutex mu_; mutable std::recursive_mutex mu_;
nlohmann::json queue_; nlohmann::json queue_;

View File

@@ -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<int>();
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<int>();
else if (body.contains("port") && body["port"].is_number())
port = body["port"].get<int>();
bool on = true;
if (body.contains("value"))
{
if (body["value"].is_boolean())
on = body["value"].get<bool>();
else if (body["value"].is_number())
on = body["value"].get<int>() != 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<std::string>().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<std::string>().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

View File

@@ -3,6 +3,10 @@
#include "auth/auth_service.hpp" #include "auth/auth_service.hpp"
#include "util/file_util.hpp" #include "util/file_util.hpp"
#include "util/http_util.hpp" #include "util/http_util.hpp"
#include "util/id_util.hpp"
#include "util/string_util.hpp"
#include <unordered_map>
namespace lm { namespace lm {
@@ -63,6 +67,126 @@ void ApiServer::registerMediaRoutes(httplib::Server& svr)
res.status = 204; 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<std::string, std::string> 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>();
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<std::string>().empty())
payload["created_by"] = session->group_name.empty() ? session->username : session->group_name;
if (!payload.contains("created_by_group") || payload["created_by_group"].get<std::string>().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) { svr.Get("/api/maps", [this](const httplib::Request&, httplib::Response& res) {
HttpUtil::addCors(res); HttpUtil::addCors(res);
res.set_header("Content-Type", "application/json; charset=utf-8"); res.set_header("Content-Type", "application/json; charset=utf-8");

View File

@@ -20,6 +20,8 @@ ApiServer::ApiServer(StateRepository& repo,
SiteStore& site_store, SiteStore& site_store,
SoundStore& sound_store, SoundStore& sound_store,
TransitionStore& transition_store, TransitionStore& transition_store,
IoModuleStore& io_module_store,
IoModuleService& io_module_service,
DashboardStore& dashboard_store) DashboardStore& dashboard_store)
: repo_(repo), : repo_(repo),
mission_queue_(mission_queue), mission_queue_(mission_queue),
@@ -31,6 +33,8 @@ ApiServer::ApiServer(StateRepository& repo,
site_store_(site_store), site_store_(site_store),
sound_store_(sound_store), sound_store_(sound_store),
transition_store_(transition_store), transition_store_(transition_store),
io_module_store_(io_module_store),
io_module_service_(io_module_service),
dashboard_store_(dashboard_store) dashboard_store_(dashboard_store)
{ {
} }
@@ -553,6 +557,7 @@ void ApiServer::registerRoutes(httplib::Server& svr)
registerRobotRoutes(svr); registerRobotRoutes(svr);
registerMediaRoutes(svr); registerMediaRoutes(svr);
registerTransitionRoutes(svr); registerTransitionRoutes(svr);
registerIoModuleRoutes(svr);
registerDashboardRoutes(svr); registerDashboardRoutes(svr);
} }

View File

@@ -11,6 +11,8 @@
#include "storage/map_store.hpp" #include "storage/map_store.hpp"
#include "storage/site_store.hpp" #include "storage/site_store.hpp"
#include "storage/sound_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/transition_store.hpp"
#include "storage/state_repository.hpp" #include "storage/state_repository.hpp"
@@ -29,6 +31,8 @@ public:
SiteStore& site_store, SiteStore& site_store,
SoundStore& sound_store, SoundStore& sound_store,
TransitionStore& transition_store, TransitionStore& transition_store,
IoModuleStore& io_module_store,
IoModuleService& io_module_service,
DashboardStore& dashboard_store); DashboardStore& dashboard_store);
void registerRoutes(httplib::Server& svr); void registerRoutes(httplib::Server& svr);
@@ -44,6 +48,8 @@ private:
SiteStore& site_store_; SiteStore& site_store_;
SoundStore& sound_store_; SoundStore& sound_store_;
TransitionStore& transition_store_; TransitionStore& transition_store_;
IoModuleStore& io_module_store_;
IoModuleService& io_module_service_;
DashboardStore& dashboard_store_; DashboardStore& dashboard_store_;
bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201); bool enqueueRequest(const nlohmann::json& request, httplib::Response& res, int status_code = 201);
@@ -55,6 +61,7 @@ private:
void registerRobotRoutes(httplib::Server& svr); void registerRobotRoutes(httplib::Server& svr);
void registerMediaRoutes(httplib::Server& svr); void registerMediaRoutes(httplib::Server& svr);
void registerTransitionRoutes(httplib::Server& svr); void registerTransitionRoutes(httplib::Server& svr);
void registerIoModuleRoutes(httplib::Server& svr);
void registerDashboardRoutes(httplib::Server& svr); void registerDashboardRoutes(httplib::Server& svr);
}; };

View File

@@ -108,6 +108,20 @@ CREATE TABLE IF NOT EXISTS transitions (
updated_at TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS dashboards (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
name TEXT NOT NULL, name TEXT NOT NULL,
@@ -395,6 +409,28 @@ bool Database::applySchemaMigrations(std::string& err)
setMeta("schema_version", "6"); 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; return true;
} }

View File

@@ -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 <sqlite3.h>
namespace lm {
namespace {
nlohmann::json rowToJson(sqlite3_stmt* stmt)
{
auto text = [&](int col) -> std::string {
if (sqlite3_column_type(stmt, col) == SQLITE_NULL)
return "";
const char* v = reinterpret_cast<const char*>(sqlite3_column_text(stmt, col));
return v ? std::string(v) : "";
};
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<std::mutex> lock(mu_);
nlohmann::json out = nlohmann::json::array();
std::string sql = kSelect;
if (!site_id.empty())
sql += " WHERE site_id = ?1";
sql += " ORDER BY 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<nlohmann::json> IoModuleStore::find(const std::string& id) const
{
std::lock_guard<std::mutex> lock(mu_);
const std::string sql = std::string(kSelect) + " WHERE id = ?1";
sqlite3_stmt* stmt = nullptr;
if (sqlite3_prepare_v2(db_.handle(), sql.c_str(), -1, &stmt, nullptr) != SQLITE_OK)
return std::nullopt;
sqlite3_bind_text(stmt, 1, id.c_str(), -1, SQLITE_TRANSIENT);
std::optional<nlohmann::json> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
out = rowToJson(stmt);
sqlite3_finalize(stmt);
return out;
}
std::optional<nlohmann::json> 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<std::mutex> 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<nlohmann::json> out;
if (sqlite3_step(stmt) == SQLITE_ROW)
out = rowToJson(stmt);
sqlite3_finalize(stmt);
return out;
}
std::optional<nlohmann::json> IoModuleStore::resolveRef(const std::string& ref) const
{
if (auto by_id = find(ref))
return by_id;
return findByName(ref);
}
std::optional<nlohmann::json> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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

View File

@@ -0,0 +1,36 @@
#pragma once
#include <nlohmann/json.hpp>
#include <mutex>
#include <optional>
#include <string>
namespace lm {
class Database;
class IoModuleStore
{
public:
explicit IoModuleStore(Database& db);
nlohmann::json list(const std::string& site_id = "") const;
std::optional<nlohmann::json> find(const std::string& id) const;
std::optional<nlohmann::json> findByName(const std::string& name) const;
std::optional<nlohmann::json> resolveRef(const std::string& ref) const;
std::optional<nlohmann::json> 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

View File

@@ -12,6 +12,9 @@ const pageMissionsEl = el("pageMissions");
const pageIntegrationsEl = el("pageIntegrations"); const pageIntegrationsEl = el("pageIntegrations");
const pageSoundsEl = el("pageSounds"); const pageSoundsEl = el("pageSounds");
const pageTransitionsEl = el("pageTransitions"); const pageTransitionsEl = el("pageTransitions");
const pageUsersEl = el("pageUsers");
const pageUserGroupsEl = el("pageUserGroups");
const pageIoModulesEl = el("pageIoModules");
const pageMonitoringEl = el("pageMonitoring"); const pageMonitoringEl = el("pageMonitoring");
const pageHelpEl = el("pageHelp"); const pageHelpEl = el("pageHelp");
const contentEl = document.querySelector(".content"); const contentEl = document.querySelector(".content");
@@ -126,7 +129,7 @@ const state = {
}; };
function setActivePage(page) { 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"; let p = valid.includes(page) ? page : "missions";
if (window.AuthApp && !window.AuthApp.canAccessPage(p)) { if (window.AuthApp && !window.AuthApp.canAccessPage(p)) {
const fallback = valid.find((v) => window.AuthApp.canAccessPage(v)); const fallback = valid.find((v) => window.AuthApp.canAccessPage(v));
@@ -139,6 +142,9 @@ function setActivePage(page) {
if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions"; if (pageMissionsEl) pageMissionsEl.hidden = p !== "missions";
if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds"; if (pageSoundsEl) pageSoundsEl.hidden = p !== "sounds";
if (pageTransitionsEl) pageTransitionsEl.hidden = p !== "transitions"; 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 (pageIntegrationsEl) pageIntegrationsEl.hidden = p !== "integrations";
if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring"; if (pageMonitoringEl) pageMonitoringEl.hidden = p !== "monitoring";
if (pageHelpEl) pageHelpEl.hidden = p !== "help"; if (pageHelpEl) pageHelpEl.hidden = p !== "help";
@@ -151,6 +157,9 @@ function setActivePage(page) {
contentEl.classList.toggle("content--missions", p === "missions"); contentEl.classList.toggle("content--missions", p === "missions");
contentEl.classList.toggle("content--sounds", p === "sounds"); contentEl.classList.toggle("content--sounds", p === "sounds");
contentEl.classList.toggle("content--transitions", p === "transitions"); 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--integrations", p === "integrations");
contentEl.classList.toggle("content--monitoring", p === "monitoring"); contentEl.classList.toggle("content--monitoring", p === "monitoring");
contentEl.classList.toggle("content--help", p === "help"); contentEl.classList.toggle("content--help", p === "help");
@@ -162,6 +171,12 @@ function setActivePage(page) {
else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide(); else if (window.SoundsApp?.onPageHide) window.SoundsApp.onPageHide();
if (p === "transitions" && window.TransitionsApp) window.TransitionsApp.onPageShow(); if (p === "transitions" && window.TransitionsApp) window.TransitionsApp.onPageShow();
else if (window.TransitionsApp?.onPageHide) window.TransitionsApp.onPageHide(); 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(); if (p === "dashboard" && window.DashboardApp) window.DashboardApp.onPageShow();
else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide(); else if (window.DashboardApp?.onPageHide) window.DashboardApp.onPageHide();
if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow(); if (p === "integrations" && window.IntegrationsApp) window.IntegrationsApp.onPageShow();

View File

@@ -151,6 +151,9 @@
missions: "missions", missions: "missions",
sounds: "sounds", sounds: "sounds",
transitions: "maps", transitions: "maps",
"user-groups": "users",
"io-modules": "integrations",
users: "users",
integrations: "integrations", integrations: "integrations",
}; };
const resource = map[page]; const resource = map[page];
@@ -171,6 +174,9 @@
document.body.classList.toggle("auth-readonly-missions", !canWrite("missions")); 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-sounds", !canWrite("sounds") && !canWrite("integrations"));
document.body.classList.toggle("auth-readonly-integrations", !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() { function updateUserMenu() {

View File

@@ -56,6 +56,7 @@
userGroups: [], userGroups: [],
maps: [], maps: [],
mapsLoaded: false, mapsLoaded: false,
ioModules: [],
robotPose: null, robotPose: null,
robotPoseAt: 0, robotPoseAt: 0,
mapPollTimer: null, 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 `<option value="">${escapeHtml(t("ioModules.empty"))}</option>`;
return list
.map(
(m) =>
`<option value="${escapeHtml(m.id)}" ${m.id === selectedId ? "selected" : ""}>${escapeHtml(m.name || m.id)}</option>`
)
.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 = `
<button type="button" class="dashboardMirMissionBtn dashboardIoConnectBtn" data-io-connect="${escapeHtml(widget.io_module_id || "")}">
${escapeHtml(connected ? t("dashboard.widget.io_disconnect") : t("dashboard.widget.io_connectAction"))}
</button>
<p class="mutedNote dashboardIoWidgetMeta">${escapeHtml(label)} · ${escapeHtml(mod ? (mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected")) : "—")}</p>`;
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 = `
<div class="dashboardIoStatusCard">
<div class="dashboardIoStatusName">${escapeHtml(label)}</div>
<div class="dashboardIoStatusLine">${escapeHtml(mod?.module_type ? t(`ioModules.type.${mod.module_type}`) : "—")} · ${escapeHtml(mod?.ip_address || "—")}</div>
<div class="dashboardIoStatusBadge ${mod?.connected ? "is-on" : "is-off"}">${escapeHtml(mod ? (mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected")) : "—")}</div>
</div>`;
}
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 = `<p class="mutedNote">${escapeHtml(t("dashboard.widget.configHint"))}</p>`;
return;
}
const ports = ioPortRange(mod);
bodyEl.innerHTML = `
<div class="dashboardIoConfigHead">
<div class="dashboardIoStatusName">${escapeHtml(label)}</div>
<div class="dashboardIoStatusBadge ${mod.connected ? "is-on" : "is-off"}">${escapeHtml(mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected"))}</div>
</div>
<div class="dashboardIoConfigPorts" data-io-config-ports></div>`;
const portsEl = bodyEl.querySelector("[data-io-config-ports]");
ports.forEach((port) => {
const row = document.createElement("div");
row.className = "dashboardIoConfigRow";
row.innerHTML = `
<span class="dashboardIoConfigPortLabel">${escapeHtml(t("dashboard.widget.ioPort", { port }))}</span>
<button type="button" class="dashboardIoConfigToggle" data-port="${port}" data-value="off">${escapeHtml(t("dashboard.widget.ioOff"))}</button>
<button type="button" class="dashboardIoConfigToggle is-primary" data-port="${port}" data-value="on">${escapeHtml(t("dashboard.widget.ioOn"))}</button>`;
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) { function widgetTypeLabel(type) {
return t(`dashboard.widget.${type}`) || type; return t(`dashboard.widget.${type}`) || type;
} }
@@ -986,6 +1148,16 @@
<label>${t("dashboard.widget.field.title")}</label> <label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || t("dashboard.widget.robot_summary"))}" /> <input data-field="title" type="text" value="${escapeHtml(widget.title || t("dashboard.widget.robot_summary"))}" />
</div>`; </div>`;
} else if (type === "io_connect" || type === "io_status" || type === "io_configuration") {
container.innerHTML = `
<div class="row rowWide">
<label>${t("dashboard.widget.field.ioModule")}</label>
<select data-field="io_module_id">${ioModuleOptions(widget.io_module_id || "")}</select>
</div>
<div class="row rowWide">
<label>${t("dashboard.widget.field.title")}</label>
<input data-field="title" type="text" value="${escapeHtml(widget.title || "")}" placeholder="${escapeHtml(widgetTypeLabel(type))}" />
</div>`;
} }
} }
@@ -1209,6 +1381,15 @@
case "robot_summary": case "robot_summary":
renderRobotSummaryWidget(widget, bodyEl); renderRobotSummaryWidget(widget, bodyEl);
break; break;
case "io_connect":
renderIoConnectWidget(widget, bodyEl);
break;
case "io_status":
renderIoStatusWidget(widget, bodyEl);
break;
case "io_configuration":
renderIoConfigurationWidget(widget, bodyEl);
break;
default: default:
bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.unsupported")}</p>`; bodyEl.innerHTML = `<p class="mutedNote">${t("dashboard.widget.unsupported")}</p>`;
} }
@@ -1245,6 +1426,9 @@
if (widget.type === "mission_queue") refreshQueueWidget(bodyEl); if (widget.type === "mission_queue") refreshQueueWidget(bodyEl);
if (widget.type === "pause_continue") renderPauseContinueWidget(widget, bodyEl); if (widget.type === "pause_continue") renderPauseContinueWidget(widget, bodyEl);
if (widget.type === "mission_action_log") renderMissionActionLogWidget(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(); if (hasMapWidget()) refreshMapWidgets();
} }
@@ -1260,6 +1444,8 @@
}; };
if (widget.type === "map" || widget.type === "map_locked") { if (widget.type === "map" || widget.type === "map_locked") {
void ensureMapsLoaded().then(open); void ensureMapsLoaded().then(open);
} else if (widget.type === "io_connect" || widget.type === "io_status" || widget.type === "io_configuration") {
void ensureIoModulesLoaded().then(open);
} else { } else {
open(); open();
} }
@@ -1484,6 +1670,7 @@
async function init() { async function init() {
await loadStoreFromBackend(); await loadStoreFromBackend();
await loadUserGroups(); await loadUserGroups();
await ensureIoModulesLoaded();
bindEvents(); bindEvents();
setView("list"); setView("list");
} }
@@ -1493,6 +1680,7 @@
getNavItems, getNavItems,
handleNav, handleNav,
onPageShow() { onPageShow() {
void ensureIoModulesLoaded();
if (store.view === "designer") { if (store.view === "designer") {
syncDesignerEditMode(); syncDesignerEditMode();
renderDesignerChrome(); renderDesignerChrome();

View File

@@ -74,6 +74,9 @@
"nav.maps": "Maps", "nav.maps": "Maps",
"nav.sounds": "Sounds", "nav.sounds": "Sounds",
"nav.transitions": "Transitions", "nav.transitions": "Transitions",
"nav.user-groups": "User groups",
"nav.io-modules": "I/O modules",
"nav.users": "Users",
"nav.build-robot": "Build Robot", "nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log", "nav.monitoring-log": "System log",
"nav.integrations": "Tích hợp", "nav.integrations": "Tích hợp",
@@ -194,6 +197,15 @@
"dashboard.widget.map_locked": "Locked map", "dashboard.widget.map_locked": "Locked map",
"dashboard.widget.map": "Map", "dashboard.widget.map": "Map",
"dashboard.widget.robot_summary": "Robot summary", "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.field.map": "Map",
"dashboard.widget.mapActive": "Active map (robot)", "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.", "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.activeHint": "Map đang hoạt động: {name}",
"maps.view": "Xem", "maps.view": "Xem",
"maps.importComingSoon": "Import site sẽ có trong phiên bản sau.", "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.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.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", "maps.createDialog.title": "Tạo map",
@@ -555,6 +569,129 @@
"transitions.deleteConfirmText": "Xóa transition {from} → {to}?", "transitions.deleteConfirmText": "Xóa transition {from} → {to}?",
"transitions.error.missing": "Thiếu trường bắt buộc.", "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 14.",
"ioModules.typeHint.wise": "Output ports 03.",
"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.title": "Missions",
"missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.", "missions.subtitle": "Setup → Missions — danh sách nhiệm vụ robot.",
"missions.create": "Tạo mission", "missions.create": "Tạo mission",
@@ -618,6 +755,9 @@
"missions.action.pause": "Pause", "missions.action.pause": "Pause",
"missions.action.set_digital_output": "Set digital output", "missions.action.set_digital_output": "Set digital output",
"missions.action.wait_digital_input": "Wait for digital input", "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.set_plc_register": "Set PLC register",
"missions.action.pick_cart": "Pick cart", "missions.action.pick_cart": "Pick cart",
"missions.action.drop_cart": "Drop cart", "missions.action.drop_cart": "Drop cart",
@@ -745,6 +885,9 @@
"nav.maps": "Maps", "nav.maps": "Maps",
"nav.sounds": "Sounds", "nav.sounds": "Sounds",
"nav.transitions": "Transitions", "nav.transitions": "Transitions",
"nav.user-groups": "User groups",
"nav.io-modules": "I/O modules",
"nav.users": "Users",
"nav.build-robot": "Build Robot", "nav.build-robot": "Build Robot",
"nav.monitoring-log": "System log", "nav.monitoring-log": "System log",
"nav.integrations": "Integrations", "nav.integrations": "Integrations",
@@ -865,6 +1008,15 @@
"dashboard.widget.map_locked": "Locked map", "dashboard.widget.map_locked": "Locked map",
"dashboard.widget.map": "Map", "dashboard.widget.map": "Map",
"dashboard.widget.robot_summary": "Robot summary", "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.field.map": "Map",
"dashboard.widget.mapActive": "Active map (robot)", "dashboard.widget.mapActive": "Active map (robot)",
"dashboard.widget.mapHint": "Pick a fixed map or leave «Active map» to follow the robot's current map.", "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.activeHint": "Active map: {name}",
"maps.view": "View", "maps.view": "View",
"maps.importComingSoon": "Import site will be available in a future release.", "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.helpTitle": "Maps help",
"maps.helpText": "Create a new map, upload a PNG via the ⋮ menu, then activate the map for the robot.", "maps.helpText": "Create a new map, upload a PNG via the ⋮ menu, then activate the map for the robot.",
"maps.createDialog.title": "Create map", "maps.createDialog.title": "Create map",
@@ -1226,6 +1380,129 @@
"transitions.deleteConfirmText": "Delete transition {from} → {to}?", "transitions.deleteConfirmText": "Delete transition {from} → {to}?",
"transitions.error.missing": "Missing required fields.", "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 14.",
"ioModules.typeHint.wise": "Output ports 03.",
"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.title": "Missions",
"missions.subtitle": "Setup → Missions — robot task list.", "missions.subtitle": "Setup → Missions — robot task list.",
"missions.create": "Create mission", "missions.create": "Create mission",
@@ -1289,6 +1566,9 @@
"missions.action.pause": "Pause", "missions.action.pause": "Pause",
"missions.action.set_digital_output": "Set digital output", "missions.action.set_digital_output": "Set digital output",
"missions.action.wait_digital_input": "Wait for digital input", "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.set_plc_register": "Set PLC register",
"missions.action.pick_cart": "Pick cart", "missions.action.pick_cart": "Pick cart",
"missions.action.drop_cart": "Drop cart", "missions.action.drop_cart": "Drop cart",

View File

@@ -358,7 +358,9 @@
<p class="dashboardMirWidgetPanelNote" data-i18n="dashboard.menu.comingSoon">Widget nhóm này sẽ có trong bản cập nhật sau.</p> <p class="dashboardMirWidgetPanelNote" data-i18n="dashboard.menu.comingSoon">Widget nhóm này sẽ có trong bản cập nhật sau.</p>
</div> </div>
<div class="dashboardMirWidgetPanel" data-panel="io" hidden> <div class="dashboardMirWidgetPanel" data-panel="io" hidden>
<p class="dashboardMirWidgetPanelNote" data-i18n="dashboard.menu.comingSoon">I/O widgets — sắp có.</p> <button type="button" class="dashboardMirWidgetPick" data-add-widget="io_connect" data-i18n="dashboard.widget.io_connect">Connect I/O module</button>
<button type="button" class="dashboardMirWidgetPick" data-add-widget="io_status" data-i18n="dashboard.widget.io_status">I/O status</button>
<button type="button" class="dashboardMirWidgetPick" data-add-widget="io_configuration" data-i18n="dashboard.widget.io_configuration">I/O configuration</button>
</div> </div>
<div class="dashboardMirWidgetPanel" data-panel="misc" hidden> <div class="dashboardMirWidgetPanel" data-panel="misc" hidden>
<button type="button" class="dashboardMirWidgetPick" data-add-widget="robot_summary" data-i18n="dashboard.widget.robot_summary">Robot summary</button> <button type="button" class="dashboardMirWidgetPick" data-add-widget="robot_summary" data-i18n="dashboard.widget.robot_summary">Robot summary</button>
@@ -1069,6 +1071,323 @@
</dialog> </dialog>
</div> </div>
<div class="page" id="pageUserGroups" data-page-content="user-groups" hidden>
<div id="userGroupsListView" class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="userGroups.title">User groups</h1>
<p class="mapsMirSubtitle">
<span data-i18n="userGroups.subtitle">Create and edit user groups.</span>
<button type="button" class="mapsMirHelpBtn" id="userGroupsHelpBtn" data-i18n-title="userGroups.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="userGroupCreateBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="userGroups.create">Create user group</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userGroupsClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="userGroups.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="userGroupsFilterInput" data-i18n="userGroups.filterLabel">Filter:</label>
<input type="search" id="userGroupsFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="userGroups.filterPlaceholder" placeholder="Filter by name..." autocomplete="off" />
<span id="userGroupsFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="userGroupsPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="userGroupsPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="userGroupsPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="userGroupsPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="userGroupsPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--userGroups" id="userGroupsTable">
<thead>
<tr>
<th class="userGroupsMirThIcon" aria-hidden="true"></th>
<th data-i18n="userGroups.colName">Name</th>
<th data-i18n="userGroups.colUsers">Users</th>
<th data-i18n="userGroups.colPin">PIN sign-in</th>
<th data-i18n="userGroups.colPermissions">Permissions</th>
<th class="mapsMirThFunctions" data-i18n="userGroups.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="userGroupList"></tbody>
</table>
<div id="userGroupListEmpty" class="mapsMirEmpty" hidden data-i18n="userGroups.empty">No user groups yet.</div>
</div>
</div>
<dialog id="userGroupEditDialog" class="mapsMirDialog mapsMirDialog--wide">
<form id="userGroupEditForm" method="dialog">
<h2 class="mapsMirDialogTitle" id="userGroupEditTitle" data-i18n="userGroups.createTitle">Create user group</h2>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="userGroups.fieldName">Name</span>
<input type="text" id="userGroupEditName" required />
</label>
<label class="mapsMirField mapsMirField--checkbox">
<input type="checkbox" id="userGroupEditAllowPin" />
<span data-i18n="userGroups.fieldAllowPin">Allow PIN sign-in</span>
</label>
<div class="userGroupsPermSection">
<h3 class="userGroupsPermTitle" data-i18n="userGroups.permissionsTitle">Permissions</h3>
<p class="mapsMirFieldHint" data-i18n="userGroups.permissionsHint">Set read or write access per module. None hides the module from the menu.</p>
<table class="userGroupsPermTable">
<thead>
<tr>
<th data-i18n="userGroups.permColModule">Module</th>
<th data-i18n="userGroups.permColAccess">Access</th>
</tr>
</thead>
<tbody id="userGroupEditPermsBody"></tbody>
</table>
</div>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userGroupEditCancelBtn" data-i18n="common.cancel">Cancel</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userGroupEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
<button type="submit" class="mapsMirBtn mapsMirBtn--green" id="userGroupEditSaveBtn" data-i18n="userGroups.save">Save</button>
</div>
</form>
</dialog>
<dialog id="userGroupDeleteConfirmDialog" class="mapsMirDialog">
<div class="mapsMirDialogPanel">
<h2 class="mapsMirDialogTitle" data-i18n="userGroups.deleteTitle">Delete user group?</h2>
<p id="userGroupDeleteConfirmText" class="mapsMirDialogText"></p>
<p class="mapsMirDialogHint" data-i18n="userGroups.deleteHint">Users must be moved to another group before this group can be deleted.</p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userGroupDeleteCancelBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userGroupDeleteYesBtn" data-i18n="common.yes">Yes</button>
</div>
</div>
</dialog>
</div>
<div class="page" id="pageIoModules" data-page-content="io-modules" hidden>
<div id="ioModulesListView" class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="ioModules.title">I/O modules</h1>
<p class="mapsMirSubtitle">
<span data-i18n="ioModules.subtitle">Create and manage I/O connections.</span>
<button type="button" class="mapsMirHelpBtn" id="ioModulesHelpBtn" data-i18n-title="ioModules.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="ioModuleCreateBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="ioModules.create">Create I/O connection</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModulesClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="ioModules.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="ioModulesFilterInput" data-i18n="ioModules.filterLabel">Filter:</label>
<input type="search" id="ioModulesFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="ioModules.filterPlaceholder" placeholder="Filter by name or IP..." autocomplete="off" />
<span id="ioModulesFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="ioModulesPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="ioModulesPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="ioModulesPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="ioModulesPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="ioModulesPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--ioModules" id="ioModulesTable">
<thead>
<tr>
<th class="ioModulesMirThIcon" aria-hidden="true"></th>
<th data-i18n="ioModules.colName">Name</th>
<th data-i18n="ioModules.colType">Type</th>
<th data-i18n="ioModules.colIp">IP address</th>
<th data-i18n="ioModules.colStatus">Status</th>
<th class="mapsMirThFunctions" data-i18n="ioModules.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="ioModuleList"></tbody>
</table>
<div id="ioModuleListEmpty" class="mapsMirEmpty" hidden data-i18n="ioModules.empty">No I/O modules yet.</div>
</div>
</div>
<dialog id="ioModuleEditDialog" class="mapsMirDialog">
<form id="ioModuleEditForm" method="dialog">
<h2 class="mapsMirDialogTitle" id="ioModuleEditTitle" data-i18n="ioModules.createTitle">Create I/O connection</h2>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldSite">Site</span>
<select id="ioModuleEditSite" required></select>
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldName">Name</span>
<input type="text" id="ioModuleEditName" required autocomplete="off" />
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldType">I/O module type</span>
<select id="ioModuleEditType" required>
<option value="bluetooth" data-i18n="ioModules.type.bluetooth">Bluetooth</option>
<option value="wise" data-i18n="ioModules.type.wise">WISE</option>
</select>
</label>
<p id="ioModuleEditTypeHint" class="mapsMirFieldHint" data-i18n="ioModules.typeHint.bluetooth">Output ports 14.</p>
<div class="mapsMirFieldRow mapsMirFieldRow--actions">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModuleTestBtn" data-i18n="ioModules.testConnection">Test connection</button>
</div>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="ioModules.fieldIp">IP address</span>
<input type="text" id="ioModuleEditIp" required autocomplete="off" inputmode="decimal" />
</label>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModuleEditCancelBtn" data-i18n="common.cancel">Cancel</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="ioModuleEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
<button type="submit" class="mapsMirBtn mapsMirBtn--green" id="ioModuleEditSaveBtn" data-i18n="ioModules.save">Create</button>
</div>
</form>
</dialog>
<dialog id="ioModuleDeleteConfirmDialog" class="mapsMirDialog">
<div class="mapsMirDialogPanel">
<h2 class="mapsMirDialogTitle" data-i18n="ioModules.deleteTitle">Delete I/O connection?</h2>
<p id="ioModuleDeleteConfirmText" class="mapsMirDialogText"></p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="ioModuleDeleteCancelBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="ioModuleDeleteYesBtn" data-i18n="common.yes">Yes</button>
</div>
</div>
</dialog>
</div>
<div class="page" id="pageUsers" data-page-content="users" hidden>
<div id="usersListView" class="mapsMirPage">
<header class="mapsMirHeader">
<div class="mapsMirHeaderText">
<h1 class="mapsMirTitle" data-i18n="users.title">Users</h1>
<p class="mapsMirSubtitle">
<span data-i18n="users.subtitle">Create and edit users.</span>
<button type="button" class="mapsMirHelpBtn" id="usersHelpBtn" data-i18n-title="users.helpTitle" aria-label="Help">
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="7" fill="none" stroke="currentColor" stroke-width="1.5"/><text x="8" y="11.5" text-anchor="middle" font-size="10" font-weight="700" fill="currentColor">?</text></svg>
</button>
</p>
</div>
<div class="mapsMirHeaderActions">
<button type="button" class="mapsMirBtn mapsMirBtn--green" id="userCreateBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
<span data-i18n="users.create">Create user</span>
</button>
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="usersClearFiltersBtn">
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><circle cx="7" cy="7" r="5.5" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M4.5 4.5l5 5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>
<span data-i18n="users.clearFilters">Clear filters</span>
</button>
</div>
</header>
<div class="mapsMirFilterBar">
<label class="mapsMirFilterLabel" for="usersFilterInput" data-i18n="users.filterLabel">Filter:</label>
<input type="search" id="usersFilterInput" class="mapsMirFilterInput" data-i18n-placeholder="users.filterPlaceholder" placeholder="Filter by name or username..." autocomplete="off" />
<span id="usersFilterCount" class="mapsMirFilterCount">0 item(s) found</span>
<div class="mapsMirPager">
<button type="button" class="mapsMirPageBtn" id="usersPageFirst" aria-label="First page">&laquo;</button>
<button type="button" class="mapsMirPageBtn" id="usersPagePrev" aria-label="Previous page">&lsaquo;</button>
<span id="usersPageLabel" class="mapsMirPageLabel">Page 1 of 1</span>
<button type="button" class="mapsMirPageBtn" id="usersPageNext" aria-label="Next page">&rsaquo;</button>
<button type="button" class="mapsMirPageBtn" id="usersPageLast" aria-label="Last page">&raquo;</button>
</div>
</div>
<div class="mapsMirTableWrap">
<table class="mapsMirTable mapsMirTable--users" id="usersTable">
<thead>
<tr>
<th class="usersMirThIcon" aria-hidden="true"></th>
<th data-i18n="users.colName">Name</th>
<th data-i18n="users.colUsername">Username</th>
<th data-i18n="users.colGroup">User group</th>
<th data-i18n="users.colEmail">Email</th>
<th data-i18n="users.colPin">PIN</th>
<th class="mapsMirThFunctions" data-i18n="users.colFunctions">Functions</th>
</tr>
</thead>
<tbody id="userList"></tbody>
</table>
<div id="userListEmpty" class="mapsMirEmpty" hidden data-i18n="users.empty">No users yet.</div>
</div>
</div>
<dialog id="userEditDialog" class="mapsMirDialog">
<form id="userEditForm" method="dialog">
<h2 class="mapsMirDialogTitle" id="userEditTitle" data-i18n="users.createTitle">Create user</h2>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="users.fieldName">Name</span>
<input type="text" id="userEditDisplayName" autocomplete="name" required />
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="users.fieldUsername">Username</span>
<input type="text" id="userEditUsername" autocomplete="username" required />
</label>
<label class="mapsMirField" id="userEditPasswordField">
<span class="mapsMirFieldLabel" data-i18n="users.fieldPassword">Password</span>
<input type="password" id="userEditPassword" autocomplete="new-password" minlength="4" />
</label>
<p id="userEditPasswordHint" class="mapsMirFieldHint" hidden data-i18n="users.passwordEditHint">Users change their own password from the menu in the upper right corner.</p>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="users.fieldEmail">Email address</span>
<input type="email" id="userEditEmail" autocomplete="email" />
</label>
<label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="users.fieldGroup">User group</span>
<select id="userEditGroup" required></select>
</label>
<label class="mapsMirField mapsMirField--checkbox">
<input type="checkbox" id="userEditEnabled" checked />
<span data-i18n="users.fieldEnabled">Enabled</span>
</label>
<div id="userEditPinSection" class="mapsMirField">
<label class="mapsMirField mapsMirField--checkbox">
<input type="checkbox" id="userEditPinEnabled" />
<span data-i18n="users.fieldPinEnable">Allow PIN sign-in</span>
</label>
<label class="mapsMirField" id="userEditPinField" hidden>
<span class="mapsMirFieldLabel" data-i18n="users.fieldPin">PIN code (4 digits)</span>
<input type="text" id="userEditPin" inputmode="numeric" pattern="[0-9]{4}" maxlength="4" autocomplete="off" />
</label>
<span id="userEditPinHint" class="mapsMirFieldHint" hidden data-i18n="users.pinGroupHint">PIN is only available for user groups that allow PIN sign-in.</span>
</div>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userEditCancelBtn" data-i18n="common.cancel">Cancel</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userEditDeleteBtn" hidden data-i18n="common.delete">Delete</button>
<button type="submit" class="mapsMirBtn mapsMirBtn--green" id="userEditSaveBtn" data-i18n="users.save">Save</button>
</div>
</form>
</dialog>
<dialog id="userDeleteConfirmDialog" class="mapsMirDialog">
<div class="mapsMirDialogPanel">
<h2 class="mapsMirDialogTitle" data-i18n="users.deleteTitle">Delete user?</h2>
<p id="userDeleteConfirmText" class="mapsMirDialogText"></p>
<p class="mapsMirDialogHint" data-i18n="users.deleteHint">Only the user profile is removed. Maps, missions and other data created by this user remain unchanged.</p>
<div class="mapsMirDialogFooter">
<button type="button" class="mapsMirBtn mapsMirBtn--outline" id="userDeleteCancelBtn" data-i18n="common.no">No</button>
<button type="button" class="mapsMirBtn mapsMirBtn--danger" id="userDeleteYesBtn" data-i18n="common.yes">Yes</button>
</div>
</div>
</dialog>
</div>
<div class="page" id="pageMaps" data-page-content="maps" hidden> <div class="page" id="pageMaps" data-page-content="maps" hidden>
<div id="mapsListView" class="mapsMirPage"> <div id="mapsListView" class="mapsMirPage">
<header class="mapsMirHeader"> <header class="mapsMirHeader">
@@ -1666,10 +1985,7 @@
<label class="mapsMirField"> <label class="mapsMirField">
<span class="mapsMirFieldLabel" data-i18n="maps.editor.io.module">I/O module</span> <span class="mapsMirFieldLabel" data-i18n="maps.editor.io.module">I/O module</span>
<input type="text" id="mapIoModule" list="mapIoModuleList" autocomplete="off" required /> <input type="text" id="mapIoModule" list="mapIoModuleList" autocomplete="off" required />
<datalist id="mapIoModuleList"> <datalist id="mapIoModuleList"></datalist>
<option value="GPIO module 1"></option>
<option value="PLC I/O 1"></option>
</datalist>
</label> </label>
<div class="mapsMirFieldRow"> <div class="mapsMirFieldRow">
<label class="mapsMirField"> <label class="mapsMirField">
@@ -2238,6 +2554,9 @@ GET /api/v2.0.0/status</pre>
<script src="/maps.js"></script> <script src="/maps.js"></script>
<script src="/sounds.js"></script> <script src="/sounds.js"></script>
<script src="/transitions.js"></script> <script src="/transitions.js"></script>
<script src="/users.js"></script>
<script src="/user-groups.js"></script>
<script src="/io-modules.js"></script>
<script src="/map-editor.js"></script> <script src="/map-editor.js"></script>
<script src="/topbar.js"></script> <script src="/topbar.js"></script>
<script src="/dashboard.js"></script> <script src="/dashboard.js"></script>

404
www/io-modules.js Normal file
View File

@@ -0,0 +1,404 @@
(() => {
const PAGE_SIZE = 10;
const ICONS = {
io: `<svg class="ioModulesMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><rect x="3" y="6" width="14" height="8" rx="1.5" fill="none" stroke="currentColor" stroke-width="1.4"/><circle cx="7" cy="10" r="1.2" fill="currentColor"/><circle cx="13" cy="10" r="1.2" fill="currentColor"/><path d="M10 3v3M10 14v3" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("ioModuleList");
const emptyEl = el("ioModuleListEmpty");
const tableEl = el("ioModulesTable");
const filterInputEl = el("ioModulesFilterInput");
const filterCountEl = el("ioModulesFilterCount");
const pageLabelEl = el("ioModulesPageLabel");
const dialogEl = el("ioModuleEditDialog");
const formEl = el("ioModuleEditForm");
const titleEl = el("ioModuleEditTitle");
const deleteBtnEl = el("ioModuleEditDeleteBtn");
const typeHintEl = el("ioModuleEditTypeHint");
const deleteConfirmDialogEl = el("ioModuleDeleteConfirmDialog");
const deleteConfirmTextEl = el("ioModuleDeleteConfirmText");
const fields = {
site: el("ioModuleEditSite"),
name: el("ioModuleEditName"),
type: el("ioModuleEditType"),
ip: el("ioModuleEditIp"),
};
const store = {
modules: [],
sites: [],
editingId: null,
pendingDeleteId: null,
filter: "",
page: 1,
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("integrations");
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
function defaultSiteId() {
return store.sites[0]?.id || "site_configuration";
}
function typeLabel(type) {
if (type === "bluetooth") return t("ioModules.type.bluetooth");
if (type === "wise") return t("ioModules.type.wise");
return type || "—";
}
function statusLabel(mod) {
return mod.connected ? t("ioModules.status.connected") : t("ioModules.status.disconnected");
}
function updateTypeHint() {
if (!typeHintEl || !fields.type) return;
const type = fields.type.value;
typeHintEl.textContent =
type === "wise" ? t("ioModules.typeHint.wise") : t("ioModules.typeHint.bluetooth");
}
async function refreshAll() {
const [sitesData, modulesData] = await Promise.all([
apiJson("/api/sites"),
apiJson("/api/io_modules"),
]);
store.sites = Array.isArray(sitesData.sites) ? sitesData.sites : [];
store.modules = Array.isArray(modulesData.io_modules) ? modulesData.io_modules : [];
return store.modules;
}
function moduleById(id) {
return store.modules.find((m) => m.id === id) || null;
}
function filteredModules() {
const q = store.filter.trim().toLowerCase();
let items = [...store.modules].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
if (q) {
items = items.filter((m) => {
const name = (m.name || "").toLowerCase();
const ip = (m.ip_address || "").toLowerCase();
const type = (m.module_type || "").toLowerCase();
return name.includes(q) || ip.includes(q) || type.includes(q);
});
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedItems(items) {
const totalPages = pageCount(items.length);
if (store.page > totalPages) store.page = totalPages;
if (store.page < 1) store.page = 1;
const start = (store.page - 1) * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}
function updatePagerUi(totalItems) {
const totalPages = pageCount(totalItems);
if (filterCountEl) filterCountEl.textContent = t("ioModules.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("ioModules.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("ioModulesPageFirst")?.toggleAttribute("disabled", atStart);
el("ioModulesPagePrev")?.toggleAttribute("disabled", atStart);
el("ioModulesPageNext")?.toggleAttribute("disabled", atEnd);
el("ioModulesPageLast")?.toggleAttribute("disabled", atEnd);
}
function fillSiteSelect(selectedId) {
if (!fields.site) return;
fields.site.innerHTML = "";
store.sites.forEach((site) => {
const opt = document.createElement("option");
opt.value = site.id;
opt.textContent = site.name || site.id;
if (site.id === selectedId) opt.selected = true;
fields.site.appendChild(opt);
});
if (!fields.site.value && store.sites[0]) fields.site.value = store.sites[0].id;
}
function renderList() {
if (!listEl) return;
const items = filteredModules();
const pageItems = pagedItems(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (tableEl) tableEl.hidden = showEmpty;
if (emptyEl) {
emptyEl.hidden = !showEmpty;
emptyEl.textContent = store.filter.trim() ? t("ioModules.emptyFilter") : t("ioModules.empty");
}
pageItems.forEach((mod) => {
const tr = document.createElement("tr");
tr.className = "mapsMirRow ioModulesMirRow";
tr.dataset.id = mod.id;
const actions = canWrite()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn ioModuleEditBtn" data-edit="${escapeHtml(mod.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger ioModuleDeleteBtn" data-delete="${escapeHtml(mod.id)}" title="${escapeHtml(t("common.delete"))}">${ICONS.delete}</button>
</div>`
: "";
const statusClass = mod.connected ? "ioModulesMirStatus--on" : "ioModulesMirStatus--off";
tr.innerHTML = `
<td class="ioModulesMirCellIcon">${ICONS.io}</td>
<td class="ioModulesMirCellName">
<button type="button" class="mapsMirNameLink ioModulesMirNameLink" data-edit="${escapeHtml(mod.id)}">${escapeHtml(mod.name || "—")}</button>
</td>
<td>${escapeHtml(typeLabel(mod.module_type))}</td>
<td>${escapeHtml(mod.ip_address || "—")}</td>
<td><span class="ioModulesMirStatus ${statusClass}">${escapeHtml(statusLabel(mod))}</span></td>
<td class="mapsMirCellFunctions">${actions}</td>
`;
listEl.appendChild(tr);
});
}
function openCreateDialog() {
store.editingId = null;
if (titleEl) titleEl.textContent = t("ioModules.createTitle");
fillSiteSelect(defaultSiteId());
if (fields.name) fields.name.value = "";
if (fields.type) fields.type.value = "bluetooth";
if (fields.ip) fields.ip.value = "";
updateTypeHint();
deleteBtnEl?.toggleAttribute("hidden", true);
dialogEl?.showModal();
}
function openEditDialog(id) {
const mod = moduleById(id);
if (!mod) return;
store.editingId = id;
if (titleEl) titleEl.textContent = t("ioModules.editTitle");
fillSiteSelect(mod.site_id || defaultSiteId());
if (fields.name) fields.name.value = mod.name || "";
if (fields.type) fields.type.value = mod.module_type || "bluetooth";
if (fields.ip) fields.ip.value = mod.ip_address || "";
updateTypeHint();
deleteBtnEl?.toggleAttribute("hidden", false);
dialogEl?.showModal();
}
async function saveModule(evt) {
evt?.preventDefault();
const payload = {
site_id: fields.site?.value || defaultSiteId(),
name: (fields.name?.value || "").trim(),
module_type: fields.type?.value || "bluetooth",
ip_address: (fields.ip?.value || "").trim(),
};
if (!payload.name || !payload.ip_address) {
alert(t("ioModules.error.missing"));
return;
}
try {
if (store.editingId) {
await apiJson(`/api/io_modules/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await apiJson("/api/io_modules", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
dialogEl?.close();
await refreshAll();
renderList();
window.dispatchEvent(new CustomEvent("lm:io-modules-changed"));
} catch (e) {
alert(e.message);
}
}
function openDeleteConfirm(id) {
const mod = moduleById(id);
if (!mod) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("ioModules.deleteConfirmText", { name: mod.name || "" });
}
deleteConfirmDialogEl?.showModal();
}
async function confirmDelete() {
const id = store.pendingDeleteId;
if (!id) return;
try {
const res = await fetch(`/api/io_modules/${encodeURIComponent(id)}`, { method: "DELETE", credentials: "include" });
if (!res.ok) {
let msg = res.statusText;
try {
const err = await res.json();
if (err.error) msg = err.error;
if (err.usages) msg += "\n" + JSON.stringify(err.usages, null, 2);
} catch {
/* ignore */
}
throw new Error(msg);
}
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
dialogEl?.close();
await refreshAll();
renderList();
window.dispatchEvent(new CustomEvent("lm:io-modules-changed"));
} catch (e) {
alert(e.message);
}
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
el("ioModuleCreateBtn")?.addEventListener("click", () => {
if (canWrite()) openCreateDialog();
});
formEl?.addEventListener("submit", saveModule);
el("ioModuleEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
deleteBtnEl?.addEventListener("click", () => {
if (store.editingId) openDeleteConfirm(store.editingId);
});
fields.type?.addEventListener("change", updateTypeHint);
el("ioModuleTestBtn")?.addEventListener("click", async () => {
const ip = (fields.ip?.value || "").trim();
if (!ip) {
alert(t("ioModules.error.missing"));
return;
}
try {
await apiJson("/api/io_modules/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ip_address: ip, port: 502 }),
});
alert(t("ioModules.testOk"));
} catch (e) {
alert(e.message);
}
});
listEl?.addEventListener("click", (evt) => {
const editBtn = evt.target.closest("[data-edit]");
const deleteBtn = evt.target.closest("[data-delete]");
if (editBtn?.dataset.edit && canWrite()) openEditDialog(editBtn.dataset.edit);
else if (deleteBtn?.dataset.delete && canWrite()) openDeleteConfirm(deleteBtn.dataset.delete);
});
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("ioModuleDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("ioModuleDeleteYesBtn")?.addEventListener("click", confirmDelete);
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("ioModulesClearFiltersBtn")?.addEventListener("click", clearFilters);
el("ioModulesPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("ioModulesPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("ioModulesPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("ioModulesPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredModules().length);
renderList();
});
el("ioModulesHelpBtn")?.addEventListener("click", () => alert(t("ioModules.helpBody")));
window.addEventListener("lm:locale-change", () => renderList());
}
async function onPageShow() {
if (!window.AuthApp?.canAccessPage?.("io-modules")) return;
document.body.classList.toggle("auth-readonly-io-modules", !canWrite());
try {
await refreshAll();
renderList();
} catch (e) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
if (tableEl) tableEl.hidden = true;
}
}
function onPageHide() {
dialogEl?.close();
deleteConfirmDialogEl?.close();
}
bindEvents();
window.IoModulesCatalog = {
getModules: () => [...store.modules],
getNames: () => store.modules.map((m) => m.name).filter(Boolean),
refresh: refreshAll,
};
window.IoModulesApp = { onPageShow, onPageHide, refreshAll, getModules: () => [...store.modules] };
})();

View File

@@ -146,6 +146,28 @@
plcMode: el("mapIoPlcMode"), plcMode: el("mapIoPlcMode"),
}; };
async function refreshMapIoModuleList() {
const listEl = document.getElementById("mapIoModuleList");
if (!listEl) return;
let modules = [];
try {
if (window.IoModulesCatalog?.refresh) {
modules = await window.IoModulesCatalog.refresh();
} else {
const res = await fetch("/api/io_modules", { credentials: "include" });
if (res.ok) {
const data = await res.json();
modules = Array.isArray(data.io_modules) ? data.io_modules : [];
}
}
} catch {
modules = [];
}
listEl.innerHTML = modules
.map((m) => `<option value="${String(m.name || "").replace(/"/g, "&quot;")}"></option>`)
.join("");
}
/** Off-screen base scan layer (map_base.png — eraser edits this). */ /** Off-screen base scan layer (map_base.png — eraser edits this). */
let baseCanvasEl = null; let baseCanvasEl = null;
function getBaseCanvas() { function getBaseCanvas() {
@@ -1177,6 +1199,7 @@
} }
if (zone.type === obj.TYPES.io) { if (zone.type === obj.TYPES.io) {
const s = adv.normalizeIoSettings(zone); const s = adv.normalizeIoSettings(zone);
void refreshMapIoModuleList().then(() => {
if (ioFields.module) ioFields.module.value = s.io_module || ""; if (ioFields.module) ioFields.module.value = s.io_module || "";
if (ioFields.plcRegister) { if (ioFields.plcRegister) {
ioFields.plcRegister.value = s.plc_register == null ? "" : String(s.plc_register); ioFields.plcRegister.value = s.plc_register == null ? "" : String(s.plc_register);
@@ -1184,6 +1207,7 @@
if (ioFields.plcValue) ioFields.plcValue.value = s.plc_value == null ? "" : String(s.plc_value); if (ioFields.plcValue) ioFields.plcValue.value = s.plc_value == null ? "" : String(s.plc_value);
if (ioFields.plcMode) ioFields.plcMode.value = s.plc_mode || "set"; if (ioFields.plcMode) ioFields.plcMode.value = s.plc_mode || "set";
ioDialogEl?.showModal(); ioDialogEl?.showModal();
});
} }
} }

View File

@@ -530,12 +530,45 @@
renderList(); renderList();
} }
async function importSiteBundle() {
const site_id = createSiteSelectEl?.value || store.sites[0]?.id;
if (!site_id) {
alert(t("maps.importNoSite"));
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = ".json,application/json";
input.addEventListener("change", async () => {
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
const result = await api(`/api/sites/${encodeURIComponent(site_id)}/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: text,
});
await loadMaps();
const counts = [
Array.isArray(result.maps) ? result.maps.length : 0,
Array.isArray(result.io_modules) ? result.io_modules.length : 0,
Array.isArray(result.transitions) ? result.transitions.length : 0,
];
alert(t("maps.importSuccess", { maps: counts[0], io: counts[1], transitions: counts[2] }));
} catch (e) {
alert(e.message);
}
});
input.click();
}
function bindEvents() { function bindEvents() {
el("mapsCreateOpenBtn")?.addEventListener("click", openCreatePage); el("mapsCreateOpenBtn")?.addEventListener("click", openCreatePage);
el("mapsCreateGoBackBtn")?.addEventListener("click", showList); el("mapsCreateGoBackBtn")?.addEventListener("click", showList);
el("mapsCreateCancelBtn")?.addEventListener("click", showList); el("mapsCreateCancelBtn")?.addEventListener("click", showList);
el("mapsCreateHelpBtn")?.addEventListener("click", () => alert(t("maps.createPage.helpText"))); el("mapsCreateHelpBtn")?.addEventListener("click", () => alert(t("maps.createPage.helpText")));
el("mapsImportSiteBtn")?.addEventListener("click", () => alert(t("maps.importComingSoon"))); el("mapsImportSiteBtn")?.addEventListener("click", () => importSiteBundle().catch((e) => alert(e.message)));
el("mapsClearFiltersBtn")?.addEventListener("click", clearFilters); el("mapsClearFiltersBtn")?.addEventListener("click", clearFilters);
el("mapsHelpBtn")?.addEventListener("click", () => alert(t("maps.helpText"))); el("mapsHelpBtn")?.addEventListener("click", () => alert(t("maps.helpText")));

View File

@@ -12,6 +12,7 @@
], ],
Logic: [ Logic: [
{ type: "if", label: "If" }, { type: "if", label: "If" },
{ type: "while", label: "While", isLoop: true },
{ type: "loop", label: "Loop", isLoop: true }, { type: "loop", label: "Loop", isLoop: true },
{ type: "break", label: "Break" }, { type: "break", label: "Break" },
{ type: "continue", label: "Continue" }, { type: "continue", label: "Continue" },
@@ -20,6 +21,8 @@
"I/O": [ "I/O": [
{ type: "set_digital_output", label: "Set digital output" }, { type: "set_digital_output", label: "Set digital output" },
{ type: "wait_digital_input", label: "Wait for digital input" }, { type: "wait_digital_input", label: "Wait for digital input" },
{ type: "connect_bluetooth", label: "Connect Bluetooth module" },
{ type: "disconnect_bluetooth", label: "Disconnect Bluetooth module" },
{ type: "set_plc_register", label: "Set PLC register" }, { type: "set_plc_register", label: "Set PLC register" },
], ],
Cart: [ Cart: [
@@ -37,6 +40,42 @@
const SAMPLE_POSITIONS = ["Charging station", "Warehouse", "Production line 1", "Dock A"]; const SAMPLE_POSITIONS = ["Charging station", "Warehouse", "Production line 1", "Dock A"];
const SAMPLE_MARKERS = ["Marker 1", "Marker 2", "Home"]; const SAMPLE_MARKERS = ["Marker 1", "Marker 2", "Home"];
const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"]; const SAMPLE_IO_MODULES = ["GPIO module 1", "PLC I/O 1"];
let ioModuleCatalog = [];
function ioModuleNames() {
const names = ioModuleCatalog.map((m) => m.name).filter(Boolean);
return names.length ? names : SAMPLE_IO_MODULES;
}
function ioModuleByName(name) {
return ioModuleCatalog.find((m) => m.name === name || m.id === name) || null;
}
function ioOutputPorts(moduleName) {
const mod = ioModuleByName(moduleName);
return mod?.module_type === "wise" ? [0, 1, 2, 3] : [1, 2, 3, 4];
}
function ioInputPorts(moduleName) {
return ioOutputPorts(moduleName);
}
async function loadIoModuleCatalog() {
try {
if (window.IoModulesCatalog?.refresh) {
ioModuleCatalog = await window.IoModulesCatalog.refresh();
} else {
const res = await fetch("/api/io_modules", { credentials: "include" });
if (res.ok) {
const data = await res.json();
ioModuleCatalog = Array.isArray(data.io_modules) ? data.io_modules : [];
}
}
} catch {
ioModuleCatalog = [];
}
return ioModuleCatalog;
}
const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"]; const SAMPLE_CARTS = ["Any valid cart", "Cart A", "Cart B"];
let missionPositionCatalog = []; let missionPositionCatalog = [];
@@ -170,13 +209,18 @@
case "switch_map": case "switch_map":
return { map_id: "", entry_position_id: "" }; return { map_id: "", entry_position_id: "" };
case "if": case "if":
return { condition: "position_free", position: positionIds()[0] || SAMPLE_POSITIONS[0] }; return { condition: "io_input", module: ioModuleNames()[0] || "", input: 1, expected: true };
case "while":
return { condition: "io_input", module: ioModuleNames()[0] || "", input: 1, expected: true };
case "loop": case "loop":
return { count: 1, mode: "count" }; return { count: 1, mode: "count" };
case "set_digital_output": case "set_digital_output":
return { module: SAMPLE_IO_MODULES[0], pin: 1, value: true }; return { module: ioModuleNames()[0] || "", output: 1, value: true, operation: "set", timeout_ms: 0 };
case "wait_digital_input": case "wait_digital_input":
return { module: SAMPLE_IO_MODULES[0], pin: 1, expected: true, timeout_s: 30 }; return { module: ioModuleNames()[0] || "", input: 1, expected: true, timeout_s: 30 };
case "connect_bluetooth":
case "disconnect_bluetooth":
return { module: ioModuleNames()[0] || "" };
case "set_plc_register": case "set_plc_register":
return { register: 1, action: "set", value: 0 }; return { register: 1, action: "set", value: 0 };
case "pick_cart": case "pick_cart":
@@ -344,12 +388,28 @@
return `Speed: ${p.speed}`; return `Speed: ${p.speed}`;
case "loop": case "loop":
return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`; return p.mode === "endless" ? "Lặp vô hạn" : `Lặp ${p.count} lần • ${action.children?.length || 0} bước`;
case "if": case "if": {
if (p.condition === "io_input") {
const port = p.input != null ? p.input : p.pin;
return `If I/O ${p.module} in ${port} = ${p.expected ? "ON" : "OFF"}`;
}
return `If ${p.condition} @ ${positionLabel(p.position)}`; return `If ${p.condition} @ ${positionLabel(p.position)}`;
case "set_digital_output": }
return `${p.module} pin ${p.pin}${p.value ? "ON" : "OFF"}`; case "while":
case "wait_digital_input": return `While I/O ${p.module} in ${p.input != null ? p.input : p.pin} = ${p.expected ? "ON" : "OFF"}${action.children?.length || 0} bước`;
return `${p.module} pin ${p.pin} = ${p.expected ? "ON" : "OFF"}`; case "set_digital_output": {
const port = p.output != null ? p.output : p.pin;
const op = p.operation === "timed" ? ` (${p.timeout_ms || 0}ms)` : "";
return `${p.module} out ${port}${p.value ? "ON" : "OFF"}${op}`;
}
case "wait_digital_input": {
const port = p.input != null ? p.input : p.pin;
return `${p.module} in ${port} = ${p.expected ? "ON" : "OFF"} (${p.timeout_s}s)`;
}
case "connect_bluetooth":
return `Connect ${p.module || "?"}`;
case "disconnect_bluetooth":
return `Disconnect ${p.module || "?"}`;
case "set_plc_register": case "set_plc_register":
return `Reg ${p.register}: ${p.action} ${p.value}`; return `Reg ${p.register}: ${p.action} ${p.value}`;
case "pick_cart": case "pick_cart":
@@ -374,10 +434,15 @@
function normalizeActionTree(actions) { function normalizeActionTree(actions) {
if (!Array.isArray(actions)) return; if (!Array.isArray(actions)) return;
actions.forEach((action) => { actions.forEach((action) => {
if (action.type === "loop" && !Array.isArray(action.children)) { if ((action.type === "loop" || action.type === "while") && !Array.isArray(action.children)) {
action.children = []; action.children = [];
} }
if (action.type === "if") {
if (!Array.isArray(action.children)) action.children = [];
if (!Array.isArray(action.else_children)) action.else_children = [];
}
if (Array.isArray(action.children)) normalizeActionTree(action.children); if (Array.isArray(action.children)) normalizeActionTree(action.children);
if (Array.isArray(action.else_children)) normalizeActionTree(action.else_children);
}); });
} }
@@ -387,9 +452,24 @@
if (path === "root") return draft.actions; if (path === "root") return draft.actions;
const parts = path.split(".").filter((p) => p !== "root"); const parts = path.split(".").filter((p) => p !== "root");
let list = draft.actions; let list = draft.actions;
for (const part of parts) { let i = 0;
while (i < parts.length) {
const part = parts[i];
if (part === "else") return null;
const node = list.find((a) => a.id === part); const node = list.find((a) => a.id === part);
if (!node || !Array.isArray(node.children)) return null; if (!node) return null;
i += 1;
if (i < parts.length && parts[i] === "else") {
if (!Array.isArray(node.else_children)) return null;
list = node.else_children;
i += 1;
continue;
}
if (i >= parts.length) {
if (!Array.isArray(node.children)) return null;
return node.children;
}
if (!Array.isArray(node.children)) return null;
list = node.children; list = node.children;
} }
return list; return list;
@@ -402,7 +482,11 @@
if (action.id === actionId) return { action, list, index: i, path, parent }; if (action.id === actionId) return { action, list, index: i, path, parent };
if (Array.isArray(action.children)) { if (Array.isArray(action.children)) {
const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action); const hit = findActionWithParent(actionId, action.children, `${path}.${action.id}`, action);
if (hit) return { ...hit, label: t(`missions.action.${type}`) || hit.label }; if (hit) return hit;
}
if (Array.isArray(action.else_children)) {
const hit = findActionWithParent(actionId, action.else_children, `${path}.${action.id}.else`, action);
if (hit) return hit;
} }
} }
return null; return null;
@@ -492,6 +576,7 @@
actions.forEach((action) => { actions.forEach((action) => {
visit(action, depth); visit(action, depth);
if (Array.isArray(action.children)) walkActions(action.children, visit, depth + 1); if (Array.isArray(action.children)) walkActions(action.children, visit, depth + 1);
if (Array.isArray(action.else_children)) walkActions(action.else_children, visit, depth + 1);
}); });
} }
@@ -504,6 +589,9 @@
if (Array.isArray(copy.children)) { if (Array.isArray(copy.children)) {
copy.children = copy.children.map((child) => resolveActionSnapshot(child, depth)); copy.children = copy.children.map((child) => resolveActionSnapshot(child, depth));
} }
if (Array.isArray(copy.else_children)) {
copy.else_children = copy.else_children.map((child) => resolveActionSnapshot(child, depth));
}
return copy; return copy;
} }
@@ -949,8 +1037,8 @@
row.dataset.index = String(index); row.dataset.index = String(index);
const iconClass = const iconClass =
action.kind === "mission" ? "kind-mission" : action.type === "loop" ? "kind-loop" : ""; action.kind === "mission" ? "kind-mission" : action.type === "loop" || action.type === "while" ? "kind-loop" : action.type === "if" ? "kind-if" : "";
const iconChar = action.kind === "mission" ? "◎" : action.type === "loop" ? "↻" : "▶"; const iconChar = action.kind === "mission" ? "◎" : action.type === "loop" || action.type === "while" ? "↻" : action.type === "if" ? "?" : "▶";
row.innerHTML = ` row.innerHTML = `
<div class="missionDragHandle" draggable="true" title="Kéo để sắp xếp" aria-label="Kéo để sắp xếp">↕</div> <div class="missionDragHandle" draggable="true" title="Kéo để sắp xếp" aria-label="Kéo để sắp xếp">↕</div>
@@ -968,17 +1056,17 @@
</div> </div>
</div>`; </div>`;
if (action.type === "loop" && Array.isArray(action.children)) { if ((action.type === "loop" || action.type === "while") && Array.isArray(action.children)) {
const loop = document.createElement("div"); const loop = document.createElement("div");
loop.className = "missionLoopBlock"; loop.className = "missionLoopBlock";
loop.innerHTML = `<div class="missionLoopLabel">Loop body — kéo action vào đây</div>`; loop.innerHTML = `<div class="missionLoopLabel">${action.type === "while" ? "While body" : "Loop body"} — kéo action vào đây</div>`;
const drop = document.createElement("div"); const drop = document.createElement("div");
drop.className = "missionLoopDrop"; drop.className = "missionLoopDrop";
drop.dataset.loopPath = `${listPath}.${action.id}`; drop.dataset.loopPath = `${listPath}.${action.id}`;
if (!action.children.length) { if (!action.children.length) {
const empty = document.createElement("div"); const empty = document.createElement("div");
empty.className = "missionLoopEmpty"; empty.className = "missionLoopEmpty";
empty.textContent = "Kéo action hoặc mission vào loop"; empty.textContent = "Kéo action hoặc mission vào đây";
drop.appendChild(empty); drop.appendChild(empty);
} else { } else {
renderActionRows(action.children, `${listPath}.${action.id}`, drop); renderActionRows(action.children, `${listPath}.${action.id}`, drop);
@@ -987,6 +1075,31 @@
row.appendChild(loop); row.appendChild(loop);
} }
if (action.type === "if" && Array.isArray(action.children)) {
const makeBranch = (label, branchPath, children) => {
const block = document.createElement("div");
block.className = "missionLoopBlock missionIfBlock";
block.innerHTML = `<div class="missionLoopLabel">${label}</div>`;
const drop = document.createElement("div");
drop.className = "missionLoopDrop";
drop.dataset.loopPath = branchPath;
if (!children.length) {
const empty = document.createElement("div");
empty.className = "missionLoopEmpty";
empty.textContent = "Kéo action vào nhánh này";
drop.appendChild(empty);
} else {
renderActionRows(children, branchPath, drop);
}
block.appendChild(drop);
return block;
};
row.appendChild(makeBranch("Then", `${listPath}.${action.id}`, action.children));
row.appendChild(
makeBranch("Else", `${listPath}.${action.id}.else`, Array.isArray(action.else_children) ? action.else_children : [])
);
}
row.querySelector("[data-config]").addEventListener("click", (evt) => { row.querySelector("[data-config]").addEventListener("click", (evt) => {
evt.stopPropagation(); evt.stopPropagation();
openActionConfig(action.id); openActionConfig(action.id);
@@ -1282,13 +1395,35 @@
addField("Số lần lặp", textInput("count", p.count, "number")); addField("Số lần lặp", textInput("count", p.count, "number"));
break; break;
case "if": case "if":
addField("Điều kiện", selectInput("condition", p.condition, ["position_free", "position_occupied", "register_equals"])); addField("Điều kiện", selectInput("condition", p.condition, ["io_input", "position_free", "position_occupied", "register_equals"]));
if (p.condition === "io_input") {
addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
addField("Kỳ vọng", chk);
}
} else {
addField("Position", selectInputLabeled("position", p.position, positionSelectOptions())); addField("Position", selectInputLabeled("position", p.position, positionSelectOptions()));
addVariableToggle("position", "Position"); addVariableToggle("position", "Position");
}
break;
case "while":
addField("Điều kiện", selectInput("condition", p.condition || "io_input", ["io_input"]));
addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
{
const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="expected" ${p.expected ? "checked" : ""} /> ${t("missions.action.waitOnLevel")}`;
addField("Kỳ vọng", chk);
}
break; break;
case "set_digital_output": case "set_digital_output":
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES)); addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Pin", textInput("pin", p.pin, "number")); addField("Output", selectInput("output", String(p.output != null ? p.output : p.pin || 1), ioOutputPorts(p.module).map(String)));
addField("Operation", selectInput("operation", p.operation || "set", ["set", "timed"]));
addField("Timeout (ms)", textInput("timeout_ms", p.timeout_ms != null ? p.timeout_ms : 0, "number"));
{ {
const chk = document.createElement("label"); const chk = document.createElement("label");
chk.innerHTML = `<input type="checkbox" data-param="value" ${p.value ? "checked" : ""} /> Bật (ON)`; chk.innerHTML = `<input type="checkbox" data-param="value" ${p.value ? "checked" : ""} /> Bật (ON)`;
@@ -1296,8 +1431,8 @@
} }
break; break;
case "wait_digital_input": case "wait_digital_input":
addField("Module", selectInput("module", p.module, SAMPLE_IO_MODULES)); addField("Module", selectInput("module", p.module, ioModuleNames()));
addField("Pin", textInput("pin", p.pin, "number")); addField("Input", selectInput("input", String(p.input != null ? p.input : p.pin || 1), ioInputPorts(p.module).map(String)));
addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number")); addField("Timeout (s)", textInput("timeout_s", p.timeout_s, "number"));
{ {
const chk = document.createElement("label"); const chk = document.createElement("label");
@@ -1305,6 +1440,10 @@
addField("Kỳ vọng", chk); addField("Kỳ vọng", chk);
} }
break; break;
case "connect_bluetooth":
case "disconnect_bluetooth":
addField("Module", selectInput("module", p.module, ioModuleNames()));
break;
case "set_plc_register": case "set_plc_register":
addField("Register", textInput("register", p.register, "number")); addField("Register", textInput("register", p.register, "number"));
addField("Hành động", selectInput("action", p.action, ["set", "add", "subtract"])); addField("Hành động", selectInput("action", p.action, ["set", "add", "subtract"]));
@@ -1364,6 +1503,8 @@
else params[key] = node.value; else params[key] = node.value;
}); });
hit.action.params = params; hit.action.params = params;
if (params.input != null && params.input !== "") params.input = Number(params.input);
if (params.output != null && params.output !== "") params.output = Number(params.output);
setDirty(true); setDirty(true);
renderMissionEditor(); renderMissionEditor();
} }
@@ -1464,6 +1605,7 @@
/* ignore */ /* ignore */
} }
} }
await loadIoModuleCatalog();
try { try {
const maps = await fetch("/api/maps", { credentials: "include" }); const maps = await fetch("/api/maps", { credentials: "include" });
if (maps.ok) { if (maps.ok) {
@@ -1503,11 +1645,13 @@
startQueuePoll, startQueuePoll,
stopQueuePoll, stopQueuePoll,
onPageShow() { onPageShow() {
void loadIoModuleCatalog().then(() => {
if (!missionEditorViewEl?.hidden) renderMissionEditor(); if (!missionEditorViewEl?.hidden) renderMissionEditor();
else { else {
renderMissionList(); renderMissionList();
startQueuePoll(); startQueuePoll();
} }
});
}, },
onPageHide() { onPageHide() {
stopQueuePoll(); stopQueuePoll();
@@ -1527,6 +1671,12 @@
} }
window.addEventListener("lm:locale-change", onLocaleChange); window.addEventListener("lm:locale-change", onLocaleChange);
window.addEventListener("lm:io-modules-changed", () => {
void loadIoModuleCatalog().then(() => {
if (!missionEditorViewEl?.hidden) renderMissionEditor();
});
});
if (window.AuthApp?.isReady()) boot(); if (window.AuthApp?.isReady()) boot();
else window.addEventListener("lm:auth-ready", boot, { once: true }); else window.addEventListener("lm:auth-ready", boot, { once: true });
window.addEventListener("lm:auth-logout", stopQueuePollForce); window.addEventListener("lm:auth-logout", stopQueuePollForce);

View File

@@ -17,6 +17,9 @@
{ section: "maps", page: "maps" }, { section: "maps", page: "maps" },
{ section: "sounds", page: "sounds" }, { section: "sounds", page: "sounds" },
{ section: "transitions", page: "transitions" }, { section: "transitions", page: "transitions" },
{ section: "user-groups", page: "user-groups" },
{ section: "io-modules", page: "io-modules" },
{ section: "users", page: "users" },
{ section: "build-robot", page: "config" }, { section: "build-robot", page: "config" },
], ],
}, },
@@ -38,6 +41,9 @@
missions: { module: "setup", section: "missions" }, missions: { module: "setup", section: "missions" },
sounds: { module: "setup", section: "sounds" }, sounds: { module: "setup", section: "sounds" },
transitions: { module: "setup", section: "transitions" }, transitions: { module: "setup", section: "transitions" },
"user-groups": { module: "setup", section: "user-groups" },
"io-modules": { module: "setup", section: "io-modules" },
users: { module: "setup", section: "users" },
integrations: { module: "system", section: "integrations" }, integrations: { module: "system", section: "integrations" },
monitoring: { module: "monitoring", section: "monitoring-log" }, monitoring: { module: "monitoring", section: "monitoring-log" },
help: { module: "help", section: "help-api" }, help: { module: "help", section: "help-api" },

View File

@@ -1103,6 +1103,57 @@ canvas {
height: 100%; height: 100%;
} }
.content.content--users {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
align-content: stretch;
max-width: none;
width: 100%;
padding: 0;
overflow: hidden;
background: #fff;
}
.content.content--users > #pageUsers {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
}
.content.content--user-groups {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
align-content: stretch;
max-width: none;
width: 100%;
padding: 0;
overflow: hidden;
background: #fff;
}
.content.content--user-groups > #pageUserGroups {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
}
.content.content--io-modules {
grid-template-columns: minmax(0, 1fr);
align-items: stretch;
align-content: stretch;
max-width: none;
width: 100%;
padding: 0;
overflow: hidden;
background: #fff;
}
.content.content--io-modules > #pageIoModules {
width: 100%;
min-width: 0;
min-height: 0;
height: 100%;
}
.missionsPage { min-width: 0; width: 100%; } .missionsPage { min-width: 0; width: 100%; }
.soundVolumeRow { .soundVolumeRow {
@@ -3141,6 +3192,45 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
min-height: 0; min-height: 0;
} }
#pageUsers {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#usersListView {
flex: 1;
min-height: 0;
}
#pageUserGroups {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#userGroupsListView {
flex: 1;
min-height: 0;
}
#pageIoModules {
position: relative;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
#ioModulesListView {
flex: 1;
min-height: 0;
}
#mapsListView[hidden], #mapsListView[hidden],
#mapsCreateView[hidden], #mapsCreateView[hidden],
#mapEditorView[hidden] { #mapEditorView[hidden] {
@@ -3590,6 +3680,261 @@ body.auth-readonly-integrations .integrationToolbar .btn.primary { pointer-event
.mapsMirTable--transitions thead th:nth-child(4) { width: 22%; } .mapsMirTable--transitions thead th:nth-child(4) { width: 22%; }
.mapsMirTable--transitions thead th:nth-child(5) { width: 16%; } .mapsMirTable--transitions thead th:nth-child(5) { width: 16%; }
.mapsMirTable--users thead th.usersMirThIcon { width: 52px; }
.mapsMirTable--users thead th:nth-child(2) { width: 18%; }
.mapsMirTable--users thead th:nth-child(3) { width: 14%; }
.mapsMirTable--users thead th:nth-child(4) { width: 16%; }
.mapsMirTable--users thead th:nth-child(5) { width: 22%; }
.mapsMirTable--users thead th:nth-child(6) { width: 72px; }
.usersMirIcon {
color: var(--mir-green, #5cb85c);
display: block;
}
.usersMirCellIcon {
width: 52px;
text-align: center;
padding-left: 12px !important;
padding-right: 8px !important;
}
.usersMirNameLink {
font-weight: 600;
}
.usersMirDisabledTag {
font-size: 12px;
font-weight: 600;
color: #a94442;
margin-left: 6px;
}
body.auth-readonly-users #userCreateBtn { display: none !important; }
body.auth-readonly-users .usersMirRow .userEditBtn,
body.auth-readonly-users .usersMirRow .userDeleteBtn { pointer-events: none; opacity: 0.55; }
.mapsMirTable--userGroups thead th.userGroupsMirThIcon { width: 52px; }
.mapsMirTable--userGroups thead th:nth-child(2) { width: 20%; }
.mapsMirTable--userGroups thead th:nth-child(3) { width: 12%; }
.mapsMirTable--userGroups thead th:nth-child(4) { width: 12%; }
.mapsMirTable--userGroups thead th:nth-child(5) { width: auto; }
.userGroupsMirIcon {
color: var(--mir-green, #5cb85c);
display: block;
}
.userGroupsMirCellIcon {
width: 52px;
text-align: center;
padding-left: 12px !important;
padding-right: 8px !important;
}
.userGroupsMirNameLink { font-weight: 600; }
.userGroupsMirCellPerms {
font-size: 13px;
color: #555;
}
body.auth-readonly-user-groups #userGroupCreateBtn { display: none !important; }
body.auth-readonly-user-groups .userGroupsMirRow .userGroupEditBtn,
body.auth-readonly-user-groups .userGroupsMirRow .userGroupDeleteBtn { pointer-events: none; opacity: 0.55; }
.mapsMirTable--ioModules thead th.ioModulesMirThIcon { width: 52px; }
.mapsMirTable--ioModules thead th:nth-child(2) { width: 20%; }
.mapsMirTable--ioModules thead th:nth-child(3) { width: 14%; }
.mapsMirTable--ioModules thead th:nth-child(4) { width: 18%; }
.mapsMirTable--ioModules thead th:nth-child(5) { width: 14%; }
.ioModulesMirIcon {
color: var(--mir-green, #5cb85c);
display: block;
}
.ioModulesMirCellIcon {
width: 52px;
text-align: center;
padding-left: 12px !important;
padding-right: 8px !important;
}
.ioModulesMirNameLink { font-weight: 600; }
.ioModulesMirStatus {
font-size: 12px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
}
.ioModulesMirStatus--on {
color: #2d6a2d;
background: #e8f5e8;
}
.ioModulesMirStatus--off {
color: #666;
background: #f0f0f0;
}
body.auth-readonly-io-modules #ioModuleCreateBtn { display: none !important; }
body.auth-readonly-io-modules .ioModulesMirRow .ioModuleEditBtn,
body.auth-readonly-io-modules .ioModulesMirRow .ioModuleDeleteBtn { pointer-events: none; opacity: 0.55; }
.dashboardIoConnectBtn { width: 100%; }
.dashboardIoWidgetMeta {
margin: 8px 0 0;
font-size: 12px;
}
.dashboardIoStatusCard {
display: flex;
flex-direction: column;
gap: 4px;
padding: 4px 0;
}
.dashboardIoStatusName {
font-weight: 700;
font-size: 15px;
}
.dashboardIoStatusLine {
font-size: 12px;
color: #666;
}
.dashboardIoStatusBadge {
display: inline-block;
margin-top: 4px;
font-size: 12px;
font-weight: 600;
padding: 3px 10px;
border-radius: 10px;
align-self: flex-start;
}
.dashboardIoStatusBadge.is-on {
color: #2d6a2d;
background: #e8f5e8;
}
.dashboardIoStatusBadge.is-off {
color: #666;
background: #f0f0f0;
}
.dashboardIoConfigHead {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.dashboardIoConfigPorts {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.dashboardIoConfigRow {
display: grid;
grid-template-columns: 1fr auto auto;
gap: 0.35rem;
align-items: center;
}
.dashboardIoConfigPortLabel {
font-size: 0.85rem;
font-weight: 600;
}
.dashboardIoConfigToggle {
padding: 0.25rem 0.55rem;
border-radius: 6px;
border: 1px solid var(--border, #d1d5db);
background: #fff;
font-size: 0.8rem;
cursor: pointer;
}
.dashboardIoConfigToggle.is-primary {
border-color: #16a34a;
color: #15803d;
}
.dashboardIoConfigToggle.is-active {
background: #16a34a;
color: #fff;
border-color: #16a34a;
}
.missionIfBlock {
margin-top: 0.35rem;
}
.mapsMirDialog--wide {
width: min(520px, calc(100vw - 32px));
max-width: none;
}
.userGroupsPermSection {
margin-top: 8px;
}
.userGroupsPermTitle {
margin: 0 0 4px;
font-size: 14px;
font-weight: 700;
color: #333;
}
.userGroupsPermTable {
width: 100%;
border-collapse: collapse;
margin-top: 8px;
font-size: 13px;
}
.userGroupsPermTable th,
.userGroupsPermTable td {
padding: 8px 10px;
border-top: 1px solid #eee;
text-align: left;
vertical-align: middle;
}
.userGroupsPermTable thead th {
font-weight: 700;
color: #444;
background: #fafafa;
border-top: none;
}
.userGroupsPermTable th[scope="row"] {
font-weight: 600;
color: #333;
width: 55%;
}
.userGroupsPermSelect {
width: 100%;
min-width: 120px;
padding: 6px 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 13px;
background: #fff;
}
.transMirIcon { .transMirIcon {
color: var(--mir-green, #5cb85c); color: var(--mir-green, #5cb85c);
display: block; display: block;

407
www/user-groups.js Normal file
View File

@@ -0,0 +1,407 @@
(() => {
const PAGE_SIZE = 10;
const PERM_RESOURCES = [
{ key: "dashboard", labelKey: "userGroups.perm.dashboard" },
{ key: "config", labelKey: "userGroups.perm.config" },
{ key: "maps", labelKey: "userGroups.perm.maps" },
{ key: "missions", labelKey: "userGroups.perm.missions" },
{ key: "sounds", labelKey: "userGroups.perm.sounds" },
{ key: "integrations", labelKey: "userGroups.perm.integrations" },
{ key: "users", labelKey: "userGroups.perm.users" },
];
const ICONS = {
group: `<svg class="userGroupsMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="7" cy="7" r="2.8" fill="none" stroke="currentColor" stroke-width="1.3"/><circle cx="13" cy="7" r="2.8" fill="none" stroke="currentColor" stroke-width="1.3"/><path d="M3.5 16c0-2.5 1.6-4.5 3.5-4.5s3.5 2 3.5 4.5M10 16c0-2.5 1.6-4.5 3.5-4.5s3.5 2 3.5 4.5" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("userGroupList");
const emptyEl = el("userGroupListEmpty");
const tableEl = el("userGroupsTable");
const filterInputEl = el("userGroupsFilterInput");
const filterCountEl = el("userGroupsFilterCount");
const pageLabelEl = el("userGroupsPageLabel");
const dialogEl = el("userGroupEditDialog");
const formEl = el("userGroupEditForm");
const titleEl = el("userGroupEditTitle");
const deleteBtnEl = el("userGroupEditDeleteBtn");
const permsBodyEl = el("userGroupEditPermsBody");
const deleteConfirmDialogEl = el("userGroupDeleteConfirmDialog");
const deleteConfirmTextEl = el("userGroupDeleteConfirmText");
const fields = {
name: el("userGroupEditName"),
allowPin: el("userGroupEditAllowPin"),
};
const store = {
groups: [],
editingId: null,
pendingDeleteId: null,
filter: "",
page: 1,
permSelects: {},
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("users");
}
function isDistributor() {
return window.AuthApp?.getUser?.()?.group_id === "group_distributors";
}
function canManageGroup(group) {
if (!group) return canWrite();
if (isDistributor()) return canWrite();
return canWrite() && group.id !== "group_distributors" && group.id !== "group_administrators";
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
async function refreshAll() {
const data = await apiJson("/api/user_groups");
store.groups = Array.isArray(data.groups) ? data.groups : [];
}
function groupById(id) {
return store.groups.find((g) => g.id === id) || null;
}
function filteredGroups() {
const q = store.filter.trim().toLowerCase();
let items = [...store.groups].sort((a, b) => (a.name || "").localeCompare(b.name || ""));
if (q) {
items = items.filter((g) => (g.name || "").toLowerCase().includes(q));
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedItems(items) {
const totalPages = pageCount(items.length);
if (store.page > totalPages) store.page = totalPages;
if (store.page < 1) store.page = 1;
const start = (store.page - 1) * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}
function updatePagerUi(totalItems) {
const totalPages = pageCount(totalItems);
if (filterCountEl) filterCountEl.textContent = t("userGroups.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("userGroups.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("userGroupsPageFirst")?.toggleAttribute("disabled", atStart);
el("userGroupsPagePrev")?.toggleAttribute("disabled", atStart);
el("userGroupsPageNext")?.toggleAttribute("disabled", atEnd);
el("userGroupsPageLast")?.toggleAttribute("disabled", atEnd);
}
function permSummary(group) {
const perms = group.permissions || {};
const writeCount = PERM_RESOURCES.filter((r) => perms[r.key] === "write").length;
const readCount = PERM_RESOURCES.filter((r) => perms[r.key] === "read").length;
if (writeCount === PERM_RESOURCES.length) return t("userGroups.permSummaryAllWrite");
if (writeCount === 0 && readCount === 0) return t("userGroups.permSummaryNone");
return t("userGroups.permSummaryMixed", { write: writeCount, read: readCount });
}
function renderList() {
if (!listEl) return;
const items = filteredGroups();
const pageItems = pagedItems(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (tableEl) tableEl.hidden = showEmpty;
if (emptyEl) {
emptyEl.hidden = !showEmpty;
emptyEl.textContent = store.filter.trim() ? t("userGroups.emptyFilter") : t("userGroups.empty");
}
pageItems.forEach((group) => {
const tr = document.createElement("tr");
tr.className = "mapsMirRow userGroupsMirRow";
tr.dataset.id = group.id;
const manageable = canManageGroup(group);
const canDelete = manageable && !group.builtin && (group.user_count || 0) === 0;
const actions = canWrite()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn userGroupEditBtn" data-edit="${escapeHtml(group.id)}" title="${escapeHtml(t("common.edit"))}" ${manageable ? "" : "disabled"}>${ICONS.edit}</button>
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger userGroupDeleteBtn" data-delete="${escapeHtml(group.id)}" title="${escapeHtml(t("common.delete"))}" ${canDelete ? "" : "disabled"}>${ICONS.delete}</button>
</div>`
: "";
const pinLabel = group.allow_pin ? t("userGroups.pinYes") : t("userGroups.pinNo");
const usersLabel = t("userGroups.userCount", { n: group.user_count || 0 });
tr.innerHTML = `
<td class="userGroupsMirCellIcon">${ICONS.group}</td>
<td class="userGroupsMirCellName">
<button type="button" class="mapsMirNameLink userGroupsMirNameLink" data-edit="${escapeHtml(group.id)}" ${manageable ? "" : "disabled"}>${escapeHtml(group.name || "—")}</button>
</td>
<td>${escapeHtml(usersLabel)}</td>
<td>${escapeHtml(pinLabel)}</td>
<td class="userGroupsMirCellPerms">${escapeHtml(permSummary(group))}</td>
<td class="mapsMirCellFunctions">${actions}</td>
`;
listEl.appendChild(tr);
});
}
function buildPermRows(permissions = {}) {
if (!permsBodyEl) return;
permsBodyEl.innerHTML = "";
store.permSelects = {};
PERM_RESOURCES.forEach((res) => {
const tr = document.createElement("tr");
const select = document.createElement("select");
select.className = "userGroupsPermSelect";
select.dataset.resource = res.key;
["none", "read", "write"].forEach((level) => {
const opt = document.createElement("option");
opt.value = level;
opt.textContent = t(`userGroups.permLevel.${level}`);
select.appendChild(opt);
});
select.value = permissions[res.key] || "none";
store.permSelects[res.key] = select;
tr.innerHTML = `<th scope="row">${escapeHtml(t(res.labelKey))}</th>`;
const td = document.createElement("td");
td.appendChild(select);
tr.appendChild(td);
permsBodyEl.appendChild(tr);
});
}
function readPermissionsFromForm() {
const perms = {};
PERM_RESOURCES.forEach((res) => {
const select = store.permSelects[res.key];
perms[res.key] = select?.value || "none";
});
return perms;
}
function openCreateDialog() {
store.editingId = null;
if (titleEl) titleEl.textContent = t("userGroups.createTitle");
if (fields.name) {
fields.name.value = "";
fields.name.disabled = false;
}
if (fields.allowPin) fields.allowPin.checked = false;
buildPermRows(defaultPermissionsForNewGroup());
deleteBtnEl?.toggleAttribute("hidden", true);
dialogEl?.showModal();
}
function defaultPermissionsForNewGroup() {
return {
dashboard: "write",
config: "none",
maps: "none",
missions: "read",
sounds: "read",
integrations: "read",
users: "none",
};
}
function openEditDialog(id) {
const group = groupById(id);
if (!group || !canManageGroup(group)) return;
store.editingId = id;
if (titleEl) titleEl.textContent = t("userGroups.editTitle");
if (fields.name) fields.name.value = group.name || "";
if (fields.allowPin) fields.allowPin.checked = !!group.allow_pin;
buildPermRows(group.permissions || {});
const canDelete = !group.builtin && (group.user_count || 0) === 0;
deleteBtnEl?.toggleAttribute("hidden", !canDelete);
dialogEl?.showModal();
}
async function saveGroup(evt) {
evt?.preventDefault();
const name = (fields.name?.value || "").trim();
if (!name) {
alert(t("userGroups.error.missing"));
return;
}
const payload = {
name,
allow_pin: !!fields.allowPin?.checked,
permissions: readPermissionsFromForm(),
};
try {
if (store.editingId) {
await apiJson(`/api/user_groups/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
} else {
await apiJson("/api/user_groups", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
dialogEl?.close();
await refreshAll();
renderList();
} catch (e) {
alert(e.message);
}
}
function openDeleteConfirm(id) {
const group = groupById(id);
if (!group || group.builtin || (group.user_count || 0) > 0) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("userGroups.deleteConfirmText", { name: group.name || "" });
}
deleteConfirmDialogEl?.showModal();
}
function openDeleteConfirmFromDialog() {
if (store.editingId) openDeleteConfirm(store.editingId);
}
async function confirmDelete() {
const id = store.pendingDeleteId;
if (!id) return;
try {
await apiJson(`/api/user_groups/${encodeURIComponent(id)}`, { method: "DELETE" });
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
dialogEl?.close();
await refreshAll();
renderList();
} catch (e) {
alert(e.message);
}
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
el("userGroupCreateBtn")?.addEventListener("click", () => {
if (canWrite()) openCreateDialog();
});
formEl?.addEventListener("submit", saveGroup);
el("userGroupEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog);
listEl?.addEventListener("click", (evt) => {
const editBtn = evt.target.closest("[data-edit]");
const deleteBtn = evt.target.closest("[data-delete]");
if (editBtn?.dataset.edit) openEditDialog(editBtn.dataset.edit);
else if (deleteBtn?.dataset.delete) openDeleteConfirm(deleteBtn.dataset.delete);
});
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("userGroupDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("userGroupDeleteYesBtn")?.addEventListener("click", confirmDelete);
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("userGroupsClearFiltersBtn")?.addEventListener("click", clearFilters);
el("userGroupsPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("userGroupsPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("userGroupsPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("userGroupsPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredGroups().length);
renderList();
});
el("userGroupsHelpBtn")?.addEventListener("click", () => alert(t("userGroups.helpBody")));
window.addEventListener("lm:locale-change", () => {
if (store.editingId || dialogEl?.open) {
const perms = readPermissionsFromForm();
buildPermRows(perms);
}
renderList();
});
}
async function onPageShow() {
if (!window.AuthApp?.canAccessPage?.("user-groups")) return;
document.body.classList.toggle("auth-readonly-user-groups", !canWrite());
try {
await refreshAll();
renderList();
} catch (e) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
if (tableEl) tableEl.hidden = true;
}
}
function onPageHide() {
dialogEl?.close();
deleteConfirmDialogEl?.close();
}
bindEvents();
window.UserGroupsApp = { onPageShow, onPageHide };
})();

441
www/users.js Normal file
View File

@@ -0,0 +1,441 @@
(() => {
const PAGE_SIZE = 10;
const ICONS = {
user: `<svg class="usersMirIcon" width="20" height="20" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="7" r="3.5" fill="none" stroke="currentColor" stroke-width="1.4"/><path d="M4 17c0-3.3 2.7-6 6-6s6 2.7 6 6" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>`,
edit: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M9.5 2.5l2 2L5 11H3v-2L9.5 2.5z" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linejoin="round"/></svg>`,
delete: `<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden="true"><path d="M4 4l6 6M10 4l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>`,
};
const el = (id) => document.getElementById(id);
const t = (key, vars) => window.I18n?.t(key, vars) ?? key;
const listEl = el("userList");
const emptyEl = el("userListEmpty");
const tableEl = el("usersTable");
const createBtnEl = el("userCreateBtn");
const filterInputEl = el("usersFilterInput");
const filterCountEl = el("usersFilterCount");
const pageLabelEl = el("usersPageLabel");
const dialogEl = el("userEditDialog");
const formEl = el("userEditForm");
const titleEl = el("userEditTitle");
const deleteBtnEl = el("userEditDeleteBtn");
const passwordFieldEl = el("userEditPasswordField");
const passwordHintEl = el("userEditPasswordHint");
const pinSectionEl = el("userEditPinSection");
const pinFieldEl = el("userEditPinField");
const pinHintEl = el("userEditPinHint");
const deleteConfirmDialogEl = el("userDeleteConfirmDialog");
const deleteConfirmTextEl = el("userDeleteConfirmText");
const fields = {
displayName: el("userEditDisplayName"),
username: el("userEditUsername"),
password: el("userEditPassword"),
email: el("userEditEmail"),
group: el("userEditGroup"),
enabled: el("userEditEnabled"),
pinEnabled: el("userEditPinEnabled"),
pin: el("userEditPin"),
};
const store = {
users: [],
groups: [],
editingId: null,
pendingDeleteId: null,
filter: "",
page: 1,
};
function canWrite() {
if (!window.AuthApp?.canWrite) return true;
return window.AuthApp.canWrite("users");
}
function currentUserId() {
return window.AuthApp?.getUser?.()?.id || "";
}
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
async function apiJson(url, opts = {}) {
const res = await fetch(url, { credentials: "include", ...opts });
const text = await res.text();
let data = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = null;
}
if (!res.ok) throw new Error((data && data.error) || text || res.statusText);
return data;
}
async function refreshAll() {
const [usersData, groupsData] = await Promise.all([
apiJson("/api/users"),
apiJson("/api/user_groups"),
]);
store.users = Array.isArray(usersData.users) ? usersData.users : [];
store.groups = Array.isArray(groupsData.groups) ? groupsData.groups : [];
}
function groupById(id) {
return store.groups.find((g) => g.id === id) || null;
}
function groupAllowsPin(groupId) {
return !!groupById(groupId)?.allow_pin;
}
function filteredUsers() {
const q = store.filter.trim().toLowerCase();
let items = [...store.users].sort((a, b) =>
(a.display_name || a.username || "").localeCompare(b.display_name || b.username || ""),
);
if (q) {
items = items.filter((u) => {
const name = (u.display_name || "").toLowerCase();
const username = (u.username || "").toLowerCase();
const email = (u.email || "").toLowerCase();
const group = (u.group_name || "").toLowerCase();
return name.includes(q) || username.includes(q) || email.includes(q) || group.includes(q);
});
}
return items;
}
function pageCount(total) {
return Math.max(1, Math.ceil(total / PAGE_SIZE));
}
function pagedItems(items) {
const totalPages = pageCount(items.length);
if (store.page > totalPages) store.page = totalPages;
if (store.page < 1) store.page = 1;
const start = (store.page - 1) * PAGE_SIZE;
return items.slice(start, start + PAGE_SIZE);
}
function updatePagerUi(totalItems) {
const totalPages = pageCount(totalItems);
if (filterCountEl) filterCountEl.textContent = t("users.itemsFound", { n: totalItems });
if (pageLabelEl) pageLabelEl.textContent = t("users.pageOf", { page: store.page, total: totalPages });
const atStart = store.page <= 1;
const atEnd = store.page >= totalPages;
el("usersPageFirst")?.toggleAttribute("disabled", atStart);
el("usersPagePrev")?.toggleAttribute("disabled", atStart);
el("usersPageNext")?.toggleAttribute("disabled", atEnd);
el("usersPageLast")?.toggleAttribute("disabled", atEnd);
}
function pinLabel(user) {
if (!user.has_pin) return t("users.pinNo");
return t("users.pinYes");
}
function renderList() {
if (!listEl) return;
const items = filteredUsers();
const pageItems = pagedItems(items);
updatePagerUi(items.length);
listEl.innerHTML = "";
const showEmpty = items.length === 0;
if (tableEl) tableEl.hidden = showEmpty;
if (emptyEl) {
emptyEl.hidden = !showEmpty;
emptyEl.textContent = store.filter.trim() ? t("users.emptyFilter") : t("users.empty");
}
pageItems.forEach((user) => {
const tr = document.createElement("tr");
tr.className = "mapsMirRow usersMirRow";
tr.dataset.id = user.id;
const isSelf = user.id === currentUserId();
const actions = canWrite()
? `<div class="mapsMirRowActions">
<button type="button" class="mapsMirIconBtn" data-edit="${escapeHtml(user.id)}" title="${escapeHtml(t("common.edit"))}">${ICONS.edit}</button>
<button type="button" class="mapsMirIconBtn mapsMirIconBtn--danger" data-delete="${escapeHtml(user.id)}" title="${escapeHtml(t("common.delete"))}" ${isSelf ? "disabled" : ""}>${ICONS.delete}</button>
</div>`
: "";
const disabledTag = user.enabled === false ? ` <span class="usersMirDisabledTag">${escapeHtml(t("common.disabled"))}</span>` : "";
tr.innerHTML = `
<td class="usersMirCellIcon">${ICONS.user}</td>
<td class="usersMirCellName">
<button type="button" class="mapsMirNameLink usersMirNameLink" data-edit="${escapeHtml(user.id)}">${escapeHtml(user.display_name || user.username)}</button>${disabledTag}
</td>
<td>${escapeHtml(user.username || "—")}</td>
<td>${escapeHtml(user.group_name || user.group_id || "—")}</td>
<td>${escapeHtml(user.email || "—")}</td>
<td>${escapeHtml(pinLabel(user))}</td>
<td class="mapsMirCellActions">${actions}</td>`;
tr.querySelectorAll("[data-edit]").forEach((btn) => {
btn.addEventListener("click", () => openDialog(btn.dataset.edit));
});
tr.querySelector("[data-delete]")?.addEventListener("click", () => openDeleteConfirm(user.id));
tr.addEventListener("dblclick", () => {
if (canWrite()) openDialog(user.id);
});
listEl.appendChild(tr);
});
if (createBtnEl) createBtnEl.hidden = !canWrite();
}
function fillGroupSelect(value) {
if (!fields.group) return;
fields.group.innerHTML = "";
store.groups.forEach((g) => {
const o = document.createElement("option");
o.value = g.id;
o.textContent = g.name || g.id;
if (g.id === value) o.selected = true;
fields.group.appendChild(o);
});
}
function syncPinUi() {
const groupId = fields.group?.value || "";
const allowPin = groupAllowsPin(groupId);
if (pinSectionEl) pinSectionEl.hidden = !allowPin;
if (pinHintEl) pinHintEl.hidden = allowPin;
if (!allowPin) {
if (fields.pinEnabled) fields.pinEnabled.checked = false;
if (pinFieldEl) pinFieldEl.hidden = true;
if (fields.pin) fields.pin.value = "";
return;
}
const pinOn = fields.pinEnabled?.checked;
if (pinFieldEl) pinFieldEl.hidden = !pinOn;
if (!pinOn && fields.pin) fields.pin.value = "";
}
function openDialog(id = null) {
store.editingId = id;
const existing = id ? store.users.find((x) => x.id === id) : null;
const isEdit = !!existing;
if (titleEl) titleEl.textContent = isEdit ? t("users.editTitle") : t("users.createTitle");
if (fields.displayName) fields.displayName.value = existing?.display_name || "";
if (fields.username) fields.username.value = existing?.username || "";
if (fields.email) fields.email.value = existing?.email || "";
if (fields.enabled) fields.enabled.checked = existing?.enabled !== false;
if (fields.password) fields.password.value = "";
fillGroupSelect(existing?.group_id || store.groups[0]?.id || "");
if (fields.pinEnabled) fields.pinEnabled.checked = !!existing?.has_pin;
if (fields.pin) fields.pin.value = "";
if (passwordFieldEl) passwordFieldEl.hidden = isEdit;
if (passwordHintEl) passwordHintEl.hidden = !isEdit;
if (fields.password) fields.password.required = !isEdit;
const ro = !canWrite();
Object.values(fields).forEach((node) => {
if (!node) return;
node.disabled = ro;
});
if (deleteBtnEl) {
const isSelf = existing?.id === currentUserId();
deleteBtnEl.hidden = !isEdit || ro || isSelf;
}
syncPinUi();
dialogEl?.showModal();
}
function readPayload(isCreate) {
const payload = {
display_name: fields.displayName?.value.trim() || "",
username: fields.username?.value.trim() || "",
email: fields.email?.value.trim() || "",
group_id: fields.group?.value || "",
enabled: fields.enabled?.checked !== false,
};
if (isCreate) payload.password = fields.password?.value || "";
if (fields.pinEnabled?.checked && groupAllowsPin(payload.group_id)) {
const pin = (fields.pin?.value || "").trim();
if (pin) {
payload.pin = pin;
} else if (!store.editingId) {
payload.pin = null;
}
} else {
payload.pin = null;
}
return payload;
}
async function saveDialog() {
if (!canWrite()) return;
const isCreate = !store.editingId;
const payload = readPayload(isCreate);
if (!payload.display_name || !payload.username || !payload.group_id) {
alert(t("users.error.missing"));
return;
}
if (isCreate && !payload.password) {
alert(t("users.error.passwordRequired"));
return;
}
if (fields.pinEnabled?.checked && groupAllowsPin(payload.group_id)) {
const pin = (fields.pin?.value || "").trim();
const needPin = isCreate || !store.users.find((u) => u.id === store.editingId)?.has_pin;
if (needPin && pin.length !== 4) {
alert(t("users.error.pinInvalid"));
return;
}
}
try {
if (store.editingId) {
const updatePayload = { ...payload };
delete updatePayload.password;
await apiJson(`/api/users/${encodeURIComponent(store.editingId)}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatePayload),
});
} else {
await apiJson("/api/users", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
}
await refreshAll();
renderList();
dialogEl?.close();
} catch (e) {
alert(e.message);
}
}
function openDeleteConfirm(id) {
const user = store.users.find((x) => x.id === id);
if (!user || !canWrite() || user.id === currentUserId()) return;
store.pendingDeleteId = id;
if (deleteConfirmTextEl) {
deleteConfirmTextEl.textContent = t("users.deleteConfirmText", {
name: user.display_name || user.username,
});
}
deleteConfirmDialogEl?.showModal();
}
function openDeleteConfirmFromDialog() {
const id = store.editingId;
if (!id) return;
openDeleteConfirm(id);
}
async function confirmDelete() {
const id = store.pendingDeleteId || store.editingId;
if (!id || !canWrite()) return;
try {
await apiJson(`/api/users/${encodeURIComponent(id)}`, { method: "DELETE" });
deleteConfirmDialogEl?.close();
dialogEl?.close();
store.editingId = null;
store.pendingDeleteId = null;
await refreshAll();
renderList();
} catch (e) {
alert(e.message);
}
}
function clearFilters() {
store.filter = "";
store.page = 1;
if (filterInputEl) filterInputEl.value = "";
renderList();
}
function bindEvents() {
createBtnEl?.addEventListener("click", () => openDialog(null));
formEl?.addEventListener("submit", (evt) => {
evt.preventDefault();
saveDialog();
});
el("userEditCancelBtn")?.addEventListener("click", () => dialogEl?.close());
dialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
dialogEl?.close();
});
fields.group?.addEventListener("change", syncPinUi);
fields.pinEnabled?.addEventListener("change", syncPinUi);
deleteBtnEl?.addEventListener("click", openDeleteConfirmFromDialog);
el("userDeleteCancelBtn")?.addEventListener("click", () => {
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
el("userDeleteYesBtn")?.addEventListener("click", confirmDelete);
deleteConfirmDialogEl?.addEventListener("cancel", (evt) => {
evt.preventDefault();
store.pendingDeleteId = null;
deleteConfirmDialogEl?.close();
});
filterInputEl?.addEventListener("input", () => {
store.filter = filterInputEl.value;
store.page = 1;
renderList();
});
el("usersClearFiltersBtn")?.addEventListener("click", clearFilters);
el("usersPageFirst")?.addEventListener("click", () => {
store.page = 1;
renderList();
});
el("usersPagePrev")?.addEventListener("click", () => {
store.page = Math.max(1, store.page - 1);
renderList();
});
el("usersPageNext")?.addEventListener("click", () => {
store.page += 1;
renderList();
});
el("usersPageLast")?.addEventListener("click", () => {
store.page = pageCount(filteredUsers().length);
renderList();
});
el("usersHelpBtn")?.addEventListener("click", () => alert(t("users.helpBody")));
window.addEventListener("lm:locale-change", () => renderList());
}
async function onPageShow() {
if (!window.AuthApp?.canAccessPage?.("users")) return;
document.body.classList.toggle("auth-readonly-users", !canWrite());
try {
await refreshAll();
renderList();
} catch (e) {
if (emptyEl) {
emptyEl.hidden = false;
emptyEl.textContent = e.message;
}
if (tableEl) tableEl.hidden = true;
}
}
function onPageHide() {
dialogEl?.close();
deleteConfirmDialogEl?.close();
}
bindEvents();
window.UsersApp = { onPageShow, onPageHide };
})();