From 0c5f71fc85bc45928454f956065ed1298cf71903 Mon Sep 17 00:00:00 2001 From: Reinder Feenstra Date: Fri, 10 Jan 2025 00:26:46 +0100 Subject: [PATCH] wip: added basic web throttle see #178 --- server/CMakeLists.txt | 12 +- server/cmake/generateresourceheader.py | 6 +- server/src/network/server.cpp | 58 +++ server/src/network/webthrottleconnection.cpp | 178 +++++++- server/src/network/webthrottleconnection.hpp | 5 + server/src/utils/readfile.cpp | 39 ++ server/src/utils/readfile.hpp | 32 ++ server/www/css/normalize.css | 349 ++++++++++++++++ server/www/css/throttle.css | 192 +++++++++ server/www/js/throttle.js | 403 +++++++++++++++++++ server/www/throttle.html | 36 ++ 11 files changed, 1305 insertions(+), 5 deletions(-) create mode 100644 server/src/utils/readfile.cpp create mode 100644 server/src/utils/readfile.hpp create mode 100644 server/www/css/normalize.css create mode 100644 server/www/css/throttle.css create mode 100644 server/www/js/throttle.js create mode 100644 server/www/throttle.html diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 32d56467..762a1db6 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -207,15 +207,23 @@ endif() include(cmake/add-resource.cmake) +add_resource(resource-www + FILES + www/css/normalize.css + www/css/throttle.css + www/js/throttle.js + www/throttle.html +) + add_resource(resource-shared BASE_DIR ../ FILES shared/gfx/appicon.ico ) -add_dependencies(traintastic-server resource-shared) +add_dependencies(traintastic-server resource-www resource-shared) if(BUILD_TESTING) - add_dependencies(traintastic-server-test resource-shared) + add_dependencies(traintastic-server-test resource-www resource-shared) endif() ### OPTIONS ### diff --git a/server/cmake/generateresourceheader.py b/server/cmake/generateresourceheader.py index 417af791..04a0ef89 100644 --- a/server/cmake/generateresourceheader.py +++ b/server/cmake/generateresourceheader.py @@ -37,7 +37,7 @@ guard = '_'.join(namespaces).upper() + '_' + re.sub(r'[\.]+','_', os.path.basena is_binary = input_file_ext not in ['html', 'css', 'js'] -with open(input_file, 'rb') as f: +with open(input_file, 'rb' if is_binary else 'r') as f: contents = f.read() os.makedirs(os.path.dirname(sys.argv[3]), exist_ok=True) @@ -64,6 +64,7 @@ constexpr std::array {variable}{{{{ }}}}; }} + #endif ''') @@ -79,8 +80,9 @@ else: # text namespace {'::'.join(namespaces)} {{ -constexpr std::string_view {variable} = R"({contents})"; +constexpr std::string_view {variable} = R"~#!({contents})~#!"; }} + #endif ''') diff --git a/server/src/network/server.cpp b/server/src/network/server.cpp index 61ff0b4c..f51a6f0c 100644 --- a/server/src/network/server.cpp +++ b/server/src/network/server.cpp @@ -32,6 +32,18 @@ #include "../log/log.hpp" #include "../log/logmessageexception.hpp" #include "../utils/setthreadname.hpp" + +//#define SERVE_FROM_FS // Development option, NOT for production! +#ifdef SERVE_FROM_FS + #include "../utils/readfile.hpp" + + static const auto www = std::filesystem::absolute(std::filesystem::path(__FILE__).parent_path() / ".." / ".." / "www"); +#else + #include + #include + #include +#endif +#include #include #define IS_SERVER_THREAD (std::this_thread::get_id() == m_thread.get_id()) @@ -45,6 +57,8 @@ namespace static constexpr std::string_view serverHeader{"Traintastic-server/" TRAINTASTIC_VERSION_FULL}; static constexpr std::string_view contentTypeTextPlain{"text/plain"}; static constexpr std::string_view contentTypeTextHtml{"text/html"}; +static constexpr std::string_view contentTypeTextCss{"text/css"}; +static constexpr std::string_view contentTypeTextJavaScript{"text/javascript"}; static constexpr std::string_view contentTypeImageXIcon{"image/x-icon"}; http::message_generator notFound(const http::request& request) @@ -131,6 +145,16 @@ http::message_generator textHtml(const http::request& request return text(request, contentTypeTextHtml, body); } +http::message_generator textCss(const http::request& request, std::string_view body) +{ + return text(request, contentTypeTextCss, body); +} + +http::message_generator textJavaScript(const http::request& request, std::string_view body) +{ + return text(request, contentTypeTextJavaScript, body); +} + } Server::Server(bool localhostOnly, uint16_t port, bool discoverable) @@ -330,6 +354,9 @@ http::message_generator Server::handleHTTPRequest(http::request" "" "

Traintastic v" TRAINTASTIC_VERSION_FULL "

" + "" "" ""); } @@ -337,6 +364,37 @@ http::message_generator Server::handleHTTPRequest(http::request> ws) : WebSocketConnection(server, std::move(ws), "webthrottle") { @@ -35,6 +42,12 @@ WebThrottleConnection::WebThrottleConnection(Server& server, std::shared_ptrdestroy(); + it.second.reset(); + } } void WebThrottleConnection::doRead() @@ -102,7 +115,138 @@ void WebThrottleConnection::doWrite() void WebThrottleConnection::processMessage(const nlohmann::json& message) { assert(isEventLoopThread()); - (void)message; + + const auto& world = Traintastic::instance->world.value(); + const auto action = message.value("action", ""); + const auto throttleId = message.value("throttle_id", 0); + + if(throttleId == 0) + { + if(action == "get_train_list") + { + auto response = nlohmann::json::object(); + response.emplace("event", "train_list"); + auto list = nlohmann::json::array(); + if(world) + { + for(const auto& train : *world->trains) + { + auto item = nlohmann::json::object(); + item.emplace("id", train->id.value()); + item.emplace("name", train->name.value()); + list.emplace_back(item); + } + } + response.emplace("list", list); + sendMessage(response); + } + } + else + { + const auto& throttle = getThrottle(throttleId); + + if(action == "acquire") + { + auto train = std::dynamic_pointer_cast(world->getObjectById(message.value("train_id", ""))); + if(train) + { + nlohmann::json object; + + const auto ec = throttle->acquire(train, message.value("steal", false)); + if(!ec) + { + m_trainPropertyChanged.emplace(throttleId, train->propertyChanged.connect( + [this, throttleId](BaseProperty& property) + { + const auto name = property.name(); + if(name == "direction" || name == "speed" || name == "throttle_speed") + { + auto event = nlohmann::json::object(); + event.emplace("event", name); + event.emplace("throttle_id", throttleId); + if(dynamic_cast(&property)) + { + event.update(property.toJSON()); + } + else + { + event.emplace("value", property.toJSON()); + } + sendMessage(event); + } + })); + + object = nlohmann::json::object(); + object.emplace("id", train->id.toJSON()); + object.emplace("name", train->name.toJSON()); + object.emplace("direction", train->direction.toJSON()); + object.emplace("speed", train->speed.toJSON()); + object.emplace("throttle_speed", train->throttleSpeed.toJSON()); + } + else // error + { + auto error = nlohmann::json::object(); + error.emplace("event", "message"); + error.emplace("throttle_id", throttleId); + error.emplace("type", "error"); + if(ec == TrainError::AlreadyAcquired) + { + error.emplace("tag", "already_acquired"); + } + error.emplace("text", ec.message()); + sendMessage(error); + } + auto response = nlohmann::json::object(); + response.emplace("event", "train"); + response.emplace("throttle_id", throttleId); + response.emplace("train", object); + sendMessage(response); + } + } + else if(action == "set_name") + { + throttle->name = message.value("value", ""); + } + else if(throttle->acquired()) + { + if(action == "estop") + { + throttle->emergencyStop(); + } + else if(action == "stop") + { + throttle->stop(); + } + else if(action == "faster") + { + throttle->faster(); + } + else if(action == "slower") + { + throttle->slower(); + } + else if(action == "reverse") + { + throttle->setDirection(Direction::Reverse); + } + else if(action == "forward") + { + throttle->setDirection(Direction::Forward); + } + else if(action == "release") + { + throttle->release(message.value("stop", true)); + + m_trainPropertyChanged.erase(throttleId); + + auto response = nlohmann::json::object(); + response.emplace("event", "train"); + response.emplace("throttle_id", throttleId); + response.emplace("train", nullptr); + sendMessage(response); + } + } + } } void WebThrottleConnection::sendMessage(const nlohmann::json& message) @@ -120,3 +264,35 @@ void WebThrottleConnection::sendMessage(const nlohmann::json& message) } }); } + +const std::shared_ptr& WebThrottleConnection::getThrottle(uint32_t throttleId) +{ + assert(isEventLoopThread()); + + static const std::shared_ptr noThrottle; + + if(auto it = m_throttles.find(throttleId); it != m_throttles.end()) + { + return it->second; + } + + if(const auto& world = Traintastic::instance->world.value()) + { + auto [it, inserted] = m_throttles.emplace(throttleId, WebThrottle::create(*world)); + if(inserted) /*[[likely]]*/ + { + m_throttleReleased.emplace(throttleId, it->second->released.connect( + [this, throttleId]() + { + auto response = nlohmann::json::object(); + response.emplace("event", "train"); + response.emplace("throttle_id", throttleId); + response.emplace("train", nullptr); + sendMessage(response); + })); + return it->second; + } + } + + return noThrottle; +} diff --git a/server/src/network/webthrottleconnection.hpp b/server/src/network/webthrottleconnection.hpp index d187f0b5..a16ca44f 100644 --- a/server/src/network/webthrottleconnection.hpp +++ b/server/src/network/webthrottleconnection.hpp @@ -37,6 +37,9 @@ class WebThrottleConnection : public WebSocketConnection protected: boost::beast::flat_buffer m_readBuffer; std::queue m_writeQueue; + std::map> m_throttles; + std::map m_throttleReleased; + std::map m_trainPropertyChanged; void doRead() final; void doWrite() final; @@ -44,6 +47,8 @@ protected: void processMessage(const nlohmann::json& message); void sendMessage(const nlohmann::json& message); + const std::shared_ptr& getThrottle(uint32_t throttleId); + public: const std::string id; diff --git a/server/src/utils/readfile.cpp b/server/src/utils/readfile.cpp new file mode 100644 index 00000000..9e1302ef --- /dev/null +++ b/server/src/utils/readfile.cpp @@ -0,0 +1,39 @@ +/** + * server/src/utils/readfile.cpp + * + * This file is part of the traintastic source code. + * + * Copyright (C) 2025 Reinder Feenstra + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +#include "readfile.hpp" +#include + +std::optional readFile(const std::filesystem::path& filename) +{ + std::ifstream file(filename, std::ios::in | std::ios::binary | std::ios::ate); + if(!file.is_open()) + { + return std::nullopt; + } + const size_t size = file.tellg(); + std::string contents; + contents.resize(size); + file.seekg(std::ios::beg); + file.read(contents.data(), size); + return contents; +} diff --git a/server/src/utils/readfile.hpp b/server/src/utils/readfile.hpp new file mode 100644 index 00000000..02690ad0 --- /dev/null +++ b/server/src/utils/readfile.hpp @@ -0,0 +1,32 @@ +/** + * server/src/utils/readfile.hpp + * + * This file is part of the traintastic source code. + * + * Copyright (C) 2025 Reinder Feenstra + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +#ifndef TRAINTASTIC_SERVER_UTILS_READFILE_HPP +#define TRAINTASTIC_SERVER_UTILS_READFILE_HPP + +#include +#include +#include + +std::optional readFile(const std::filesystem::path& filename); + +#endif diff --git a/server/www/css/normalize.css b/server/www/css/normalize.css new file mode 100644 index 00000000..192eb9ce --- /dev/null +++ b/server/www/css/normalize.css @@ -0,0 +1,349 @@ +/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ + +/* Document + ========================================================================== */ + +/** + * 1. Correct the line height in all browsers. + * 2. Prevent adjustments of font size after orientation changes in iOS. + */ + +html { + line-height: 1.15; /* 1 */ + -webkit-text-size-adjust: 100%; /* 2 */ +} + +/* Sections + ========================================================================== */ + +/** + * Remove the margin in all browsers. + */ + +body { + margin: 0; +} + +/** + * Render the `main` element consistently in IE. + */ + +main { + display: block; +} + +/** + * Correct the font size and margin on `h1` elements within `section` and + * `article` contexts in Chrome, Firefox, and Safari. + */ + +h1 { + font-size: 2em; + margin: 0.67em 0; +} + +/* Grouping content + ========================================================================== */ + +/** + * 1. Add the correct box sizing in Firefox. + * 2. Show the overflow in Edge and IE. + */ + +hr { + box-sizing: content-box; /* 1 */ + height: 0; /* 1 */ + overflow: visible; /* 2 */ +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +pre { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/* Text-level semantics + ========================================================================== */ + +/** + * Remove the gray background on active links in IE 10. + */ + +a { + background-color: transparent; +} + +/** + * 1. Remove the bottom border in Chrome 57- + * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. + */ + +abbr[title] { + border-bottom: none; /* 1 */ + text-decoration: underline; /* 2 */ + text-decoration: underline dotted; /* 2 */ +} + +/** + * Add the correct font weight in Chrome, Edge, and Safari. + */ + +b, +strong { + font-weight: bolder; +} + +/** + * 1. Correct the inheritance and scaling of font size in all browsers. + * 2. Correct the odd `em` font sizing in all browsers. + */ + +code, +kbd, +samp { + font-family: monospace, monospace; /* 1 */ + font-size: 1em; /* 2 */ +} + +/** + * Add the correct font size in all browsers. + */ + +small { + font-size: 80%; +} + +/** + * Prevent `sub` and `sup` elements from affecting the line height in + * all browsers. + */ + +sub, +sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.5em; +} + +/* Embedded content + ========================================================================== */ + +/** + * Remove the border on images inside links in IE 10. + */ + +img { + border-style: none; +} + +/* Forms + ========================================================================== */ + +/** + * 1. Change the font styles in all browsers. + * 2. Remove the margin in Firefox and Safari. + */ + +button, +input, +optgroup, +select, +textarea { + font-family: inherit; /* 1 */ + font-size: 100%; /* 1 */ + line-height: 1.15; /* 1 */ + margin: 0; /* 2 */ +} + +/** + * Show the overflow in IE. + * 1. Show the overflow in Edge. + */ + +button, +input { /* 1 */ + overflow: visible; +} + +/** + * Remove the inheritance of text transform in Edge, Firefox, and IE. + * 1. Remove the inheritance of text transform in Firefox. + */ + +button, +select { /* 1 */ + text-transform: none; +} + +/** + * Correct the inability to style clickable types in iOS and Safari. + */ + +button, +[type="button"], +[type="reset"], +[type="submit"] { + -webkit-appearance: button; +} + +/** + * Remove the inner border and padding in Firefox. + */ + +button::-moz-focus-inner, +[type="button"]::-moz-focus-inner, +[type="reset"]::-moz-focus-inner, +[type="submit"]::-moz-focus-inner { + border-style: none; + padding: 0; +} + +/** + * Restore the focus styles unset by the previous rule. + */ + +button:-moz-focusring, +[type="button"]:-moz-focusring, +[type="reset"]:-moz-focusring, +[type="submit"]:-moz-focusring { + outline: 1px dotted ButtonText; +} + +/** + * Correct the padding in Firefox. + */ + +fieldset { + padding: 0.35em 0.75em 0.625em; +} + +/** + * 1. Correct the text wrapping in Edge and IE. + * 2. Correct the color inheritance from `fieldset` elements in IE. + * 3. Remove the padding so developers are not caught out when they zero out + * `fieldset` elements in all browsers. + */ + +legend { + box-sizing: border-box; /* 1 */ + color: inherit; /* 2 */ + display: table; /* 1 */ + max-width: 100%; /* 1 */ + padding: 0; /* 3 */ + white-space: normal; /* 1 */ +} + +/** + * Add the correct vertical alignment in Chrome, Firefox, and Opera. + */ + +progress { + vertical-align: baseline; +} + +/** + * Remove the default vertical scrollbar in IE 10+. + */ + +textarea { + overflow: auto; +} + +/** + * 1. Add the correct box sizing in IE 10. + * 2. Remove the padding in IE 10. + */ + +[type="checkbox"], +[type="radio"] { + box-sizing: border-box; /* 1 */ + padding: 0; /* 2 */ +} + +/** + * Correct the cursor style of increment and decrement buttons in Chrome. + */ + +[type="number"]::-webkit-inner-spin-button, +[type="number"]::-webkit-outer-spin-button { + height: auto; +} + +/** + * 1. Correct the odd appearance in Chrome and Safari. + * 2. Correct the outline style in Safari. + */ + +[type="search"] { + -webkit-appearance: textfield; /* 1 */ + outline-offset: -2px; /* 2 */ +} + +/** + * Remove the inner padding in Chrome and Safari on macOS. + */ + +[type="search"]::-webkit-search-decoration { + -webkit-appearance: none; +} + +/** + * 1. Correct the inability to style clickable types in iOS and Safari. + * 2. Change font properties to `inherit` in Safari. + */ + +::-webkit-file-upload-button { + -webkit-appearance: button; /* 1 */ + font: inherit; /* 2 */ +} + +/* Interactive + ========================================================================== */ + +/* + * Add the correct display in Edge, IE 10+, and Firefox. + */ + +details { + display: block; +} + +/* + * Add the correct display in all browsers. + */ + +summary { + display: list-item; +} + +/* Misc + ========================================================================== */ + +/** + * Add the correct display in IE 10+. + */ + +template { + display: none; +} + +/** + * Add the correct display in IE 10. + */ + +[hidden] { + display: none; +} diff --git a/server/www/css/throttle.css b/server/www/css/throttle.css new file mode 100644 index 00000000..06ef6397 --- /dev/null +++ b/server/www/css/throttle.css @@ -0,0 +1,192 @@ +/** + * This file is part of Traintastic, + * see . + * + * Copyright (C) 2024-2025 Reinder Feenstra + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +.flex-column +{ + display: flex; + flex-direction: column; +} + +.flex-row +{ + display: flex; + flex-direction: row; +} + +.flex-resize +{ + display: flex; + flex: 1 1 auto; +} + +.flex-fixed +{ + display: flex; + flex: 0 0 auto; +} + +.hide +{ + display: none; +} + +.stretch +{ + width: 100%; +} + +.text-right +{ + text-align: right; +} + +.right +{ + float: right; +} + +.p1 +{ + padding: 0.5rem; +} + +.pr1 +{ + padding-right: 0.5rem; +} + +html +{ + font-family: sans-serif; +} + +body +{ + background-color: #121212; + color: white; + margin: 0; + padding: 0; + height: 100vh; + width: 100vw; +} + +header +{ + padding: 0.5rem; +} + +header h1 +{ + margin: 0; +} + +#open_settings +{ + font-size: 200%; + text-align: center; + min-width: 2.5rem; + cursor: pointer; +} + +#body +{ + position: relative; +} + +#settings +{ + z-index: 1; + position: absolute; + background-color: #121212; + width: 100%; + height: 100%; + padding: 0.5rem; +} + +#settings label +{ + display: block; +} + +button.control, +select.control +{ + background-color: #bb86fc; + border: none; + border-radius: 1.5rem; + cursor: pointer; + font-size: 2.5rem; + font-weight: bold; + margin: 0.25rem; + height: 5rem; +} + +button.control:disabled +{ + background-color: gray !important; + cursor: auto !important; +} + +button.control.active +{ + background-color: #03dac6; +} + +button.red +{ + background-color: #cf6679; +} + +button.square +{ + width: 5rem; +} + +label +{ + font-weight: bold; +} + +input[type=text] +{ + background-color: rgba(255, 255, 255, .07); + border: solid 2px #bb86fc; + border-radius: 1.5rem; + font-size: 150%; + color: white; + padding: 0 0.5rem; + margin: 0.5rem 0 1rem 0; +} + +div.message +{ + z-index: 2; + position: absolute; + bottom: 1rem; + border: none; + border-radius: 0.5rem; + width: 100%; +} + +div.message.error +{ + background-color: #cf6679; +} diff --git a/server/www/js/throttle.js b/server/www/js/throttle.js new file mode 100644 index 00000000..768255c5 --- /dev/null +++ b/server/www/js/throttle.js @@ -0,0 +1,403 @@ +/** + * This file is part of Traintastic, + * see . + * + * Copyright (C) 2024-2025 Reinder Feenstra + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +function Throttle(parent, id) +{ + this.id = id; + this.message = null; + this.messageTimeout = null; + + var createButton = function (action, innerText, className) + { + var e = document.createElement('button'); + e.innerText = innerText; + e.className = className; + e.setAttribute('throttle-id', id); + e.setAttribute('action', action); + e.onclick = function () + { + tm.send({ + 'throttle_id': parseInt(this.getAttribute('throttle-id')), + 'action': this.getAttribute('action'), + }); + }; + return e; + }; + + var createDiv = function (className, children) + { + var e = document.createElement('div'); + e.className = className; + children.forEach(function (c, _) + { + e.appendChild(c); + }); + return e; + }; + + var createTrainSelect = function (className) + { + var e = document.createElement('select'); + e.className = className; + e.setAttribute('throttle-id', id); + e.setAttribute('name', 'train_select'); + e.onchange = function () + { + if(this.value != '') + { + tm.send({ + 'throttle_id': parseInt(this.getAttribute('throttle-id')), + 'action': 'acquire', + 'train_id': this.value, + 'steal': false, + }); + } + else + { + tm.send({ + 'throttle_id': parseInt(this.getAttribute('throttle-id')), + 'action': 'release', + 'stop': localStorage.throttleStopOnRelease != 'false', + }); + } + }; + return e; + } + + var createSpan = function (id, text = '', className = '') + { + var e = document.createElement('span'); + e.id = id; + e.className = className; + e.innerText = text; + return e; + } + + var addRemoveClass = function (e, add, className) + { + if(add && !e.classList.contains(className)) + { + e.classList.add(className); + } + else if(!add) + { + e.classList.remove(className); + } + } + + var formatSpeed = function (value, unit) + { + if(unit == 'kmph') + { + return value.toFixed(0) + ' km/h'; + } + if(unit == 'mph') + { + return value.toFixed(0) + ' mph'; + } + if(unit == 'mps') + { + return value.toFixed(1) + ' m/s'; + } + return value + ' ' + unit; + } + + var throttle = createDiv('p1 stretch', [ + createTrainSelect('control stretch'), + createDiv('flex-row', [ + createSpan('', 'Speed:', 'text-right pr1'), + createSpan('throttle-' + id + '-actual-speed', '', 'stretch'), + createSpan('', 'Target:', 'stretch text-right pr1'), + createSpan('throttle-' + id + '-target-speed', '', 'stretch'), + /* not yet supported: + createSpan('', 'Limit:', 'stretch text-right pr1'), + createSpan('throttle-' + id + '-target-limit', '∞', 'stretch'), + */ + ]), + createDiv('flex-row', [ + createDiv('flex-resize', [ + // TODO: function buttons + ]), + createDiv('flex-resize', [ + createDiv('flex-column stretch', [ + createButton('faster', '+', 'control stretch'), + createButton('slower', '-', 'control stretch'), + ]) + ]) + ]), + createDiv('flex-row', [ + createDiv('flex-resize', [createButton('reverse', '<', 'control stretch')]), + createDiv('flex-resize', [createButton('stop', '0', 'control stretch')]), + createDiv('flex-resize', [createButton('forward', '>', 'control stretch')]), + ]), + createButton('estop', 'EStop', 'control stretch red'), + ]); + parent.appendChild(throttle); + + this.setTrainList = function (list) + { + var train_select = throttle.querySelector('select[name=train_select]'); + + train_select.innerHTML = ''; + train_select.appendChild(document.createElement('option')); + list.forEach(function (train, _) + { + var option = document.createElement('option'); + option.value = train['id']; + option.innerText = train['name']; + train_select.appendChild(option); + }); + }; + + this.showMessage = function (message) + { + var layout = []; + if(message['tag'] == 'already_acquired') + { + var e = document.createElement('button'); + e.innerText = 'Steal'; + e.className = 'right'; + e.setAttribute('throttle-id', id); + e.setAttribute('train-id', throttle.querySelector('select[name=train_select]').value); + e.onclick = function () + { + var throttleId = parseInt(this.getAttribute('throttle-id')); + tm.send({ + 'throttle_id': throttleId, + 'action': 'acquire', + 'train_id': this.getAttribute('train-id'), + 'steal': true, + }); + tm.throttles[throttleId].clearMessage(); + }; + layout.push(e) + } + text = document.createElement('p'); + text.innerText = message['text']; + layout.push(text); + this.clearMessage(); + this.message = createDiv('message ' + message['type'], layout); + throttle.appendChild(this.message); + this.messageTimeout = window.setTimeout(function (throttle) { throttle.clearMessage(); }, 5000, this); + } + + this.clearMessage = function () + { + if(this.message) + { + throttle.removeChild(this.message); + window.clearTimeout(this.messageTimeout); + this.message = null; + } + } + + this.setTrain = function (train) + { + var buttons = throttle.querySelectorAll('button.control'); + if(train) + { + this.setDirection(train.direction); + this.setSpeed(train.speed.value, train.speed.unit); + this.setThrottleSpeed(train.throttle_speed.value, train.throttle_speed.unit); + buttons.forEach(function (button) { button.disabled = false; }); + throttle.querySelector('select[name=train_select]').value = train.id; + } + else + { + buttons.forEach(function (button) { button.disabled = true; }); + throttle.querySelector('select[name=train_select]').value = ''; + } + } + + this.setDirection = function (direction) + { + addRemoveClass( + throttle.querySelector('button[action=forward]'), + direction == 'forward', + 'active'); + addRemoveClass( + throttle.querySelector('button[action=reverse]'), + direction == 'reverse', + 'active'); + } + + this.setSpeed = function (value, unit) + { + document.getElementById('throttle-' + this.id + '-actual-speed').innerText = formatSpeed(value, unit); + } + + this.setThrottleSpeed = function (value, unit) + { + document.getElementById('throttle-' + this.id + '-target-speed').innerText = formatSpeed(value, unit); + } + + this.setTrain(null); +}; + +var tm = new function () +{ + this.ws = null; + this.throttles = [] + + this.init = function () + { + if(localStorage.throttleName) + { + document.getElementById('throttle_name').value = localStorage.throttleName; + } + if(localStorage.throttleStopOnRelease) + { + document.getElementById('stop_train_on_release').value = localStorage.throttleStopOnRelease; + } + + document.getElementById('open_settings').onclick = function () + { + var settings = document.getElementById('settings'); + if(settings.classList.contains('hide')) + { + settings.classList.remove('hide'); + } + else + { + document.getElementById('close_settings').onclick(); + } + }; + + document.getElementById('close_settings').onclick = function () + { + localStorage.throttleName = document.getElementById('throttle_name').value; + localStorage.throttleStopOnRelease = document.getElementById('stop_train_on_release').value == 'on'; + document.getElementById('settings').classList.add('hide'); + if(tm.throttles.length == 0) + { + tm.add(); + } + }; + + if(localStorage.throttleName && localStorage.throttleStopOnRelease) + { + document.getElementById('settings').classList.add('hide'); + this.add(); + } + } + + this.add = function () + { + this.connect(); + var e = document.getElementById('throttles'); + var id = 1; + while(id in this.throttles) { id++; } + this.throttles[id] = new Throttle(e, id); + return this.throttles[id]; + } + + this.connect = function () + { + if(!this.ws) + { + this.ws = new WebSocket((window.location.protocol == 'https' ? 'wss' : 'ws') + '://' + window.location.host + window.location.pathname); + this.ws.onopen = function (ev) + { + tm.send({ 'action': 'get_train_list' }); + tm.throttles.forEach(function (throttle, _) + { + tm.send({ + 'throttle_id': throttle.id, + 'action': 'set_name', + 'value': localStorage.throttleName + ' #' + throttle.id, + }); + var trainId = localStorage['throttle' + throttle.id + 'TrainId']; + if(trainId) + { + tm.send({ + 'throttle_id': throttle.id, + 'action': 'acquire', + 'train_id': trainId, + 'steal': false, + }); + } + }); + }; + this.ws.onmessage = function (ev) + { + var msg = JSON.parse(ev.data); + console.log('RX', msg); + if(msg['event'] == 'train_list') + { + tm.throttles.forEach(function (throttle, _) + { + throttle.setTrainList(msg['list']); + }); + } + else if(msg['event'] == 'message') + { + var throttleId = msg['throttle_id']; + if(throttleId) + { + tm.throttles[throttleId].showMessage(msg); + } + } + else if(msg['event'] == 'train') + { + var throttleId = msg['throttle_id']; + var train = msg['train']; + var item = 'throttle' + throttleId + 'TrainId'; + if(train) + { + localStorage.setItem(item, train.id); + } + else + { + localStorage.removeItem(item); + } + tm.throttles[throttleId].setTrain(train); + } + else if(msg['event'] == 'direction') + { + tm.throttles[msg['throttle_id']].setDirection(msg['value']); + } + else if(msg['event'] == 'speed') + { + tm.throttles[msg['throttle_id']].setSpeed(msg['value'], msg['unit']); + } + else if(msg['event'] == 'throttle_speed') + { + tm.throttles[msg['throttle_id']].setThrottleSpeed(msg['value'], msg['unit']); + } + }; + this.ws.onclose = function () + { + setTimeout(function () { tm.connect(); }, 1000); + } + this.ws.onerror = function (err) + { + console.error('WebSocket error: ', err.message); + this.ws.close(); + }; + } + } + + this.send = function (msg) + { + console.log('TX', msg); + return this.ws.send(JSON.stringify(msg)); + }; +}(); diff --git a/server/www/throttle.html b/server/www/throttle.html new file mode 100644 index 00000000..d6509eb8 --- /dev/null +++ b/server/www/throttle.html @@ -0,0 +1,36 @@ + + + + + + + Throttle | Traintastic + + + + + +
+
+

Traintastic

+
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + + +