wip: added basic web throttle

see #178
Dieser Commit ist enthalten in:
Reinder Feenstra 2025-01-10 00:26:46 +01:00
Ursprung d91b30715d
Commit 0c5f71fc85
11 geänderte Dateien mit 1305 neuen und 5 gelöschten Zeilen

Datei anzeigen

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

Datei anzeigen

@ -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<std::byte, {size}> {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
''')

Datei anzeigen

@ -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 <resource/www/throttle.html.hpp>
#include <resource/www/css/throttle.css.hpp>
#include <resource/www/js/throttle.js.hpp>
#endif
#include <resource/www/css/normalize.css.hpp>
#include <resource/shared/gfx/appicon.ico.hpp>
#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<http::string_body>& request)
@ -131,6 +145,16 @@ http::message_generator textHtml(const http::request<http::string_body>& request
return text(request, contentTypeTextHtml, body);
}
http::message_generator textCss(const http::request<http::string_body>& request, std::string_view body)
{
return text(request, contentTypeTextCss, body);
}
http::message_generator textJavaScript(const http::request<http::string_body>& 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<http::string_bod
"</head>"
"<body>"
"<h1>Traintastic <small>v" TRAINTASTIC_VERSION_FULL "</small></h1>"
"<ul>"
"<li><a href=\"/throttle\">Web throttle</a></li>"
"</ul>"
"</body>"
"</html>");
}
@ -337,6 +364,37 @@ http::message_generator Server::handleHTTPRequest(http::request<http::string_bod
{
return binary(request, contentTypeImageXIcon, Resource::shared::gfx::appicon_ico);
}
if(request.target() == "/css/normalize.css")
{
return textCss(request, Resource::www::css::normalize_css);
}
if(request.target() == "/css/throttle.css")
{
#ifdef SERVE_FROM_FS
const auto css = readFile(www / "css" / "throttle.css");
return css ? textCss(request, *css) : notFound(request);
#else
return textCss(request, Resource::www::css::throttle_css);
#endif
}
if(request.target() == "/js/throttle.js")
{
#ifdef SERVE_FROM_FS
const auto js = readFile(www / "js" / "throttle.js");
return js ? textJavaScript(request, *js) : notFound(request);
#else
return textJavaScript(request, Resource::www::js::throttle_js);
#endif
}
if(request.target() == "/throttle")
{
#ifdef SERVE_FROM_FS
const auto html = readFile(www / "throttle.html");
return html ? textHtml(request, *html) : notFound(request);
#else
return textHtml(request, Resource::www::throttle_html);
#endif
}
if(target == "/version")
{
return textPlain(request, TRAINTASTIC_VERSION_FULL);

Datei anzeigen

@ -22,8 +22,15 @@
#include "webthrottleconnection.hpp"
#include "server.hpp"
#include "../traintastic/traintastic.hpp"
#include "../core/eventloop.hpp"
#include "../core/objectproperty.tpp"
#include "../hardware/throttle/webthrottle.hpp"
#include "../log/log.hpp"
#include "../train/train.hpp"
#include "../train/trainerror.hpp"
#include "../train/trainlist.hpp"
WebThrottleConnection::WebThrottleConnection(Server& server, std::shared_ptr<boost::beast::websocket::stream<boost::beast::tcp_stream>> ws)
: WebSocketConnection(server, std::move(ws), "webthrottle")
{
@ -35,6 +42,12 @@ WebThrottleConnection::WebThrottleConnection(Server& server, std::shared_ptr<boo
WebThrottleConnection::~WebThrottleConnection()
{
assert(isEventLoopThread());
for(auto& it : m_throttles)
{
it.second->destroy();
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<uint32_t>("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<Train>(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<AbstractUnitProperty*>(&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<WebThrottle>& WebThrottleConnection::getThrottle(uint32_t throttleId)
{
assert(isEventLoopThread());
static const std::shared_ptr<WebThrottle> 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;
}

Datei anzeigen

@ -37,6 +37,9 @@ class WebThrottleConnection : public WebSocketConnection
protected:
boost::beast::flat_buffer m_readBuffer;
std::queue<std::string> m_writeQueue;
std::map<uint32_t, std::shared_ptr<WebThrottle>> m_throttles;
std::map<uint32_t, boost::signals2::scoped_connection> m_throttleReleased;
std::map<uint32_t, boost::signals2::scoped_connection> 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<WebThrottle>& getThrottle(uint32_t throttleId);
public:
const std::string id;

Datei anzeigen

@ -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 <fstream>
std::optional<std::string> 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;
}

Datei anzeigen

@ -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 <filesystem>
#include <string>
#include <optional>
std::optional<std::string> readFile(const std::filesystem::path& filename);
#endif

349
server/www/css/normalize.css vendored Normale Datei
Datei anzeigen

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

192
server/www/css/throttle.css Normale Datei
Datei anzeigen

@ -0,0 +1,192 @@
/**
* This file is part of Traintastic,
* see <https://github.com/traintastic/traintastic>.
*
* 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;
}

403
server/www/js/throttle.js Normale Datei
Datei anzeigen

@ -0,0 +1,403 @@
/**
* This file is part of Traintastic,
* see <https://github.com/traintastic/traintastic>.
*
* 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));
};
}();

36
server/www/throttle.html Normale Datei
Datei anzeigen

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<title>Throttle | Traintastic</title>
<link rel="stylesheet" href="/css/normalize.css">
<link rel="stylesheet" href="/css/throttle.css">
</head>
<body onload="tm.init()">
<header>
<div id="open_settings" class="right">&#8942;</div>
<h1>Traintastic</h1>
</header>
<div id="body">
<div id="settings">
<div>
<label for="throttle_name">Name:</label>
<input type="text" id="throttle_name">
</div>
<div>
<label for="stop_train_on_release">Stop train on release:</label>
<input type="checkbox" id="stop_train_on_release" checked="checked">
</div>
<div>
<button id="close_settings">Close</button>
</div>
</div>
<div id="throttles" class="flex-row"></div>
</div>
<script src="/js/throttle.js"></script>
</body>
</html>