inital commit

This commit is contained in:
grey
2025-03-06 18:52:49 +01:00
commit 5820933da1
26 changed files with 738 additions and 0 deletions

47
index.html Normal file
View File

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Werma Signal Controler</title>
<style>
html {
font-family: Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Werma Light Control</h1>
This is a small web interface to control the lights and buzzer of a Werma signal tower using the <a href="https://www.werma.com/de/s_c4i595/USB-Anschlusselement_RM_5VDC_BK/64084000.html">640.840.00 connection module</a>. It assumes that: Ring 1 is green, Ring 2 is yellow, Ring 3 is red, Ring 4 is buzzer. Ring 5 is assumed to be unconnected
<section>
<h2>Buzzer</h2>
<p>
<button onclick="setBuzz(0)">Off</button>
<button onclick="setBuzz(1)">On</button><br>
<button onclick="setBuzz(2)">Pulse</button>
<button onclick="setBuzz(3)">Pulse (alternate)</button><br>
</p>
<hr>
<h2>Lights</h2>
<i>Auto applies changes</i>
Ring 1 - Red: <input type="num" id="light1" value="0" max="3"><br>
Ring 2 - Yellow: <input type="num" id="light2" value="0" max="3"><br>
Ring 3 - Green: <input type="num" id="light3" value="0" max="3"><br>
<hr>
<p>
0 - Off<br>
1 - On<br>
2 - Pulse<br>
3 - Pulse (alternate)
</p>
<hr>
<h2>Connection control</h2>
<button onclick="connectToLight()">Connect to device</button><br><br>
<button onclick="sendLightData()">Send data to device</button><br>
</section>
<!-- make three selector-->
</body>
<script src="script.js"></script>
</html>

341
nxtRender.js Normal file
View File

@ -0,0 +1,341 @@
let lampContainer = document.getElementById("leuchte");
let fillContainer = document.getElementById("fill");
let configInput = document.getElementById("config");
let configLoadError = document.getElementById("config_error");
let configOutput = document.getElementById("config_out");
let ctrlPane = document.getElementById("ctrlPane");
let serialErrorState = document.getElementById("serialState");
const warn = document.getElementById("configWarn");
// Inputs
let input_setup_top = document.getElementById("setup_top");
// Elements
const topElement = document.getElementById("module_top");
const config_ring1 = document.getElementById("ring1Confg");
const config_ring2 = document.getElementById("ring2Confg");
const config_ring3 = document.getElementById("ring3Confg");
const config_ring4 = document.getElementById("ring4Confg");
const config_ring5 = document.getElementById("ring5Confg");
const config_ring_list = [config_ring1, config_ring2, config_ring3, config_ring4, config_ring5];
// Serial port
let port;
const usbVendorId = 0x0403;
// URLs
const urls = {
rings: {
red: "res/images/licht_rot.jpg",
green: "res/images/licht_gruen.jpg",
blue: "res/images/licht_blau.jpg",
yellow: "res/images/licht_gelb.jpg",
white: "res/images/licht_weiss.jpg",
empty: "",
},
extras: {
top: {
normal: "res/images/top.jpg",
buzzer: "res/images/licht_summer.jpg"
}
},
selector: {
rings: {
red: "res/images/module_red.jpg",
green: "res/images/module_green.jpg",
blue: "res/images/module_blue.jpg",
yellow: "res/images/module_yellow.jpg",
white: "res/images/module_white.jpg",
empty: "res/images/no.png",
},
extras: {
top: {
normal: "res/images/no.png",
buzzer: "res/images/module_sounder.jpg"
}
}
},
ctrls: {
off: "res/images/buttons/Off.png",
on: "res/images/buttons/On.png",
alternate1: "res/images/buttons/Alternate1.png",
alternate2: "res/images/buttons/Alternate2.png"
}
}
// Attach event listeners
input_setup_top.addEventListener("change", (event) => {
myConfig.extras.top = event.target.value;
applyConfig();
});
let myConfig = {
rings: {
0: "green",
1: "yellow",
2: "red",
3: "empty",
4: "empty",
},
extras: {
top: "buzzer"
},
states: {
0: 0,
1: 0,
2: 0,
3: 0,
4: 0
}
}
function validateConfig() {
// Check if there is a "hole" (empty) in the rings
// We want to detect ELEMENT EMPTY ELEMENT
// If the stack is "openended", thats fine
// Iterate over keys
let wasEmpty = false;
for (let i = 0; i < Object.keys(myConfig.rings).length; i++) {
console.log("Checking ring ", i);
isNowEmpty = myConfig.rings[i] === "empty";
if (wasEmpty && !isNowEmpty) {
warn.innerText = "Invalid config: Empty ring in the middle of the stack!";
return false;
}
console.log("Was empty: ", wasEmpty, " is empty: ", isNowEmpty);
wasEmpty = myConfig.rings[i] === "empty";
}
let amountOfPopulatedRings = 0;
for (let i = 0; i < Object.keys(myConfig.rings).length; i++) {
if (myConfig.rings[i] !== "empty") {
amountOfPopulatedRings++;
}
}
if(amountOfPopulatedRings === 0) {
warn.innerText = "Invalid config: No rings selected!";
return false;
}
if(amountOfPopulatedRings == 5 && myConfig.extras.top === "buzzer") {
warn.innerText = "Invalid config: Buzzer selected with all rings!";
return false;
}
warn.innerText = "";
return true;
}
function applyConfig() {
validateConfig();
// Remove state from output
let configCopy = JSON.parse(JSON.stringify(myConfig));
delete configCopy.states;
configOutput.innerText = JSON.stringify(configCopy);
topElement.src = urls.extras.top[myConfig.extras.top];
// Update radio buttons
for (let i = config_ring_list.length-1; i >= 0; i--) {
let currElm = config_ring_list[i];
for (let j = currElm.children.length-1; j >= 0; j--) {
const child = currElm.children[j];
if (child.type === "radio") {
if (child.value === myConfig.rings[i]) {
child.checked = true;
}
}
}
}
// Populate lamp tower
// Iterate over rings keys
for (let i = Object.keys(myConfig.rings).length - 1; i >= 0; i--) {
const ring = myConfig.rings[i];
let ringElm = document.getElementById("ring" + (i + 1));
if (!ringElm) {
ringElm = document.createElement("img");
ringElm.id = "ring" + (i + 1);
fillContainer.appendChild(ringElm);
} else {
ringElm.src = urls.rings[ring];
}
if(myConfig.states[i] == 0) {
ringElm.classList.remove("is-lit");
ringElm.classList.remove("is-alternating-1");
ringElm.classList.remove("is-alternating-2");
} else if(myConfig.states[i] == 2) {
ringElm.classList.add("is-alternating-1");
ringElm.classList.remove("is-alternating-2");
ringElm.classList.remove("is-lit");
}else{
ringElm.classList.add("is-lit");
ringElm.classList.remove("is-alternating-1");
ringElm.classList.remove("is-alternating-2");
}
}
setTimeout(() => {
// Add divs for the controll elements in the same height as the rings
// But only show for the visible rings
for (let i = 0; i < Object.keys(myConfig.rings).length; i++) {
// Get top and bottom y position of the ring from html element
const ring = myConfig.rings[i];
let ringElm = document.getElementById("ring" + (i + 1));
if (ring === "empty") {
continue;
}
console.log("Ring ", i, " is ", ring);
// output coords
console.log("Ring ", i, " coords: ", ringElm.getBoundingClientRect());
console.log(ringElm)
let ctrlElm = document.getElementById("ctrl" + (i + 1));
if (!ctrlElm) {
ctrlElm = document.createElement("div");
ctrlElm.id = "ctrl" + (i + 1);
ctrlElm.classList.add("ctrl");
ctrlPane.appendChild(ctrlElm);
}
// Set position by top and bottom of the ring (ignore left and right)
ctrlElm.style.position = "absolute";
ctrlElm.style.top = ringElm.getBoundingClientRect().top + "px";
ctrlElm.style.bottom = ringElm.getBoundingClientRect().bottom + "px";
ctrlElm.style.width = "250px";
// Add buttons for the modes (off, on, alternate1, alternate2)
let actionList = ["off", "on", "alternate1", "alternate2"];
for (let j = 0; j < actionList.length; j++) {
let action = actionList[j];
let actionElm = document.getElementById("action" + (i + 1) + action);
if (!actionElm) {
actionElm = document.createElement("button");
actionElm.id = "action" + (i + 1) + action;
// actionElm.innerText = action;
actionElm.style.backgroundImage = `url(${urls.ctrls[action]})`;
actionElm.style.backgroundSize = "contain";
actionElm.style.backgroundRepeat = "no-repeat";
actionElm.classList.add("ctrl-action");
actionElm.addEventListener("click", () => {
writeAction(i, action);
});
ctrlElm.appendChild(actionElm);
}
}
// ctrlElm.style.left = "0px";
// ctrlElm.style.right = "0px";
}
}, 200);
}
function writeAction(ring, state){
actionToState = {
"off": 0,
"on": 1,
"alternate1": 2,
"alternate2": 3
}
myConfig.states[ring] = actionToState[state];
applyConfig();
serial_send();
}
function loadConfig() {
try {
let config = JSON.parse(configInput.value);
// Merge configs, as states is not part of the input
config.states = myConfig.states;
myConfig = config;
applyConfig();
} catch (error) {
console.error("Error loading config: ", error);
configLoadError.innerText = "Error loading config: " + error;
} finally {
configLoadError.innerText = "Config loaded!";
}
input_setup_top.value = myConfig.extras.top;
}
function serial_check_env() {
if (!navigator.serial) {
console.error("Serial not supported!");
serialErrorState.innerText = "Serial not supported in this browser! Try using something chromium based!";
return false;
}
return true;
}
async function serial_connect() {
if (!serial_check_env()) {
return;
}
navigator.serial.requestPort({ filters: [{ usbVendorId }] }).then((serialPort) => {
serialPort.open({ baudRate: 9600 });
port = serialPort;
serialErrorState.innerText = "Connected to serial port!";
}).catch((error) => {
console.error("Error connecting to serial port: ", error);
serialErrorState.innerText = "Error connecting to serial port: " + error;
});
}
async function serial_send() {
if(port && port.writable) {
let data = `WR${myConfig.states[0]}${myConfig.states[1]}${myConfig.states[2]}${myConfig.states[3]}${myConfig.states[4]}\r`;
const encoder = new TextEncoder();
const writer = port.writable.getWriter();
await writer.write(encoder.encode(data));
writer.releaseLock();
}
}
// // Populate config section
// // From urls->selector->rings
// // Show as radio buttons
for (let i = config_ring_list.length-1; i >= 0; i--) {
let currElm = config_ring_list[i];
for (const key in urls.selector.rings) {
if (urls.selector.rings.hasOwnProperty(key)) {
const url = urls.selector.rings[key];
let radio = document.createElement("input");
radio.type = "radio";
radio.name = "ring" + (i + 1);
radio.value = key;
radio.id = "ring" + (i + 1) + key;
radio.addEventListener("change", (event) => {
myConfig.rings[i] = event.target.value;
applyConfig();
});
currElm.appendChild(radio);
let img = document.createElement("img");
img.src = url;
img.addEventListener("click", () => {
radio.checked = true;
myConfig.rings[i] = key;
applyConfig();
}
);
currElm.appendChild(img);
}
}
}
applyConfig();
setTimeout(() => {
applyConfig();
}
, 200);

BIN
res/images/base_1.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
res/images/base_2.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 919 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 915 B

BIN
res/images/buttons/Off.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

BIN
res/images/buttons/On.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 887 B

BIN
res/images/licht_blau.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
res/images/licht_gelb.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
res/images/licht_gruen.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

BIN
res/images/licht_rot.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
res/images/licht_summer.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

BIN
res/images/licht_weiss.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
res/images/module_blue.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
res/images/module_green.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
res/images/module_red.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

BIN
res/images/module_white.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

BIN
res/images/no.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 973 B

BIN
res/images/top.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

72
res/style.css Normal file
View File

@ -0,0 +1,72 @@
html {
font-family: Arial, sans-serif;
}
.leftPreview {
width: 50%;
float: left;
padding: 10px;
}
#leuchte img {
padding: 0px;
margin: 0px;
vertical-align: middle;
box-sizing: border-box;
}
#leuchte br {
padding: 0px;
margin: 0px;
height: 0px;
}
.ringConf img {
width: 10%;
}
#ctrlPane {
margin-top: "24px" !important;
}
.ctrl {
background-color: transparent;
border: 0px solid black;
height: 82px;
/* Center items vertically */
display: flex;
align-items: center;
}
.ctrl-action {
background-color: transparent;
border: 0px solid black;
height: 40px;
width: 40px;
padding-left: 10px;
}
.ctrl-action:active {
background-color: #f0f0f0;
filter: brightness(50%);
}
.is-lit {
/* Make image brighter */
filter: brightness(120%);
}
/* Blinking anim (no fading) */
@keyframes blinker {
0% { filter: brightness(120%); }
50% { filter: brightness(100%); }
100% { filter: brightness(120%); }
}
.is-alternating-1 {
animation: blinker 1s linear infinite;
}

76
script.js Normal file
View File

@ -0,0 +1,76 @@
// Connect to serial port with 9600 baudrate
// everything in the browser
lightState = {
red: 0,
yellow: 0,
green: 0,
buzzer: 0,
};
let light1Inp = document.getElementById("light1");
let light2Inp = document.getElementById("light2");
let light3Inp = document.getElementById("light3");
light1Inp.addEventListener("change", (event) => {
lightState.red = event.target.value;
sendLightData();
});
light2Inp.addEventListener("change", (event) => {
lightState.yellow = event.target.value;
sendLightData();
});
light3Inp.addEventListener("change", (event) => {
lightState.green = event.target.value;
sendLightData();
});
async function connectToLight() {
// usbVendorId: 0x0403, usbProductId: 0x6015
const usbVendorId = 0x0403;
port = await navigator.serial.requestPort({ filters: [{ usbVendorId }] })
await port.open({ baudRate: 9600 /* pick your baud rate */ });
}
function setBuzz(value) {
lightState.buzzer = value;
sendLightData();
}
async function sendLightData() {
// WR12345<CR> 1: green, 2: yellow, 3: red, 4: buzzer, 5: empty (alsways 0)
let data = `WR${lightState.green}${lightState.yellow}${lightState.red}${lightState.buzzer}0\r`;
console.log("Sending data: ", data);
const encoder = new TextEncoder();
const writer = port.writable.getWriter();
await writer.write(encoder.encode(data));
writer.releaseLock();
readData();
}
async function readData() {
console.log("Reading data");
while (port.readable) {
const reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
// |reader| has been canceled.
break;
}
// Do something with |value|…
// Convert value to string
const decoder = new TextDecoder();
console.log(decoder.decode(value));
}
} catch (error) {
// Handle |error|…
} finally {
reader.releaseLock();
}
}
console.log("Done reading");
}

90
scriptV2.js Normal file
View File

@ -0,0 +1,90 @@
// Connect to serial port with 9600 baudrate
// everything in the browser
lightState = {
ring1: 0,
ring2: 0,
ring3: 0,
ring4: 0,
ring5: 0
};
let light1Inp = document.getElementById("light1");
let light2Inp = document.getElementById("light2");
let light3Inp = document.getElementById("light3");
let light4Inp = document.getElementById("light4");
let light5Inp = document.getElementById("light5");
light1Inp.addEventListener("change", (event) => {
lightState.ring1 = event.target.value;
sendLightData();
});
light2Inp.addEventListener("change", (event) => {
lightState.ring2 = event.target.value;
sendLightData();
});
light3Inp.addEventListener("change", (event) => {
lightState.ring3 = event.target.value;
sendLightData();
});
light4Inp.addEventListener("change", (event) => {
lightState.ring4 = event.target.value;
sendLightData();
});
light5Inp.addEventListener("change", (event) => {
lightState.ring5 = event.target.value;
sendLightData();
});
async function connectToLight() {
// usbVendorId: 0x0403, usbProductId: 0x6015
const usbVendorId = 0x0403;
port = await navigator.serial.requestPort({ filters: [{ usbVendorId }] })
await port.open({ baudRate: 9600 });
}
function setBuzz(value) {
lightState.buzzer = value;
sendLightData();
}
async function sendLightData() {
// WR12345<CR> 1: green, 2: yellow, 3: red, 4: buzzer, 5: empty (alsways 0)
let data = `WR${lightState.ring1}${lightState.ring2}${lightState.ring3}${lightState.ring4}${lightState.ring5}\r`;
console.log("Sending data: ", data);
const encoder = new TextEncoder();
const writer = port.writable.getWriter();
await writer.write(encoder.encode(data));
writer.releaseLock();
readData();
}
async function readData() {
console.log("Reading data");
while (port.readable) {
const reader = port.readable.getReader();
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
// |reader| has been canceled.
break;
}
// Do something with |value|…
// Convert value to string
const decoder = new TextDecoder();
console.log(decoder.decode(value));
}
} catch (error) {
// Handle |error|…
} finally {
reader.releaseLock();
}
}
console.log("Done reading");
}

112
v2.html Normal file
View File

@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Werma Signal Controller</title>
<link rel="stylesheet" href="res/style.css">
</head>
<body>
<h1>Werma Light Control</h1>
This is a small web interface which allows you to controll a Werma signal tower using the <a href="https://www.werma.com/USB-Anschlusselement-RM-5VDC-BK/64084000">640.840.00 connection module</a>.
You can configure the web interface to match your tower's configuration. The default configuration is: Ring 1 is green, Ring 2 is yellow, Ring 3 is red, Ring 4 is buzzer. Ring 5 is assumed to be unconnected.<br>
<i>All "product photos" are the property of Werma, I did not take them myself. If you are Werma and want me to take down the images, reach out to me via the contact mail in the legal notes on my main domain.</i>
<h5>Currently unsupported features</h5>
<ul>
<li>Buzzers (somewhat, you cannot currently controll them)</li>
<li>Persistant configuration ("startup settings")</li>
<li>Any bidirectional communication (getting SW version, etc.)</li>
</ul>
<!-- 1/4 of screen is a preview, rest is controll surface-->
<section style="display: flex; flex-direction: row;">
<div style="width: 25%; max-width: 290px;">
<h2>Preview</h2>
<!-- Images are placed above each other -->
<div id="leuchte">
<img src="res/images/top.jpg" id="module_top">
<div id="fill">
</div>
<img src="res/images/base_2.jpg" id="base_2">
<img src="res/images/base_1.jpg" id="base_1">
</div>
</div>
<div style="width: 25%; max-width: 290px;">
<h2>Control</h2>
<div id="ctrlPane">
</div>
</div>
<div style="width: 75%;">
<div id="serialState">
</div>
<section>
<h2>Serial</h2>
<!-- Serial connection -->
<button onclick="serial_connect()">Connect</button>
<button onclick="serial_send()">Force refresh</button>
</section>
<section>
<h2>Setup</h2>
<!-- Load old config -->
<details>
<summary>Load config from string</summary>
<textarea id="config" style="width: 100%; height: 100px;"></textarea>
<button onclick="loadConfig()">Load</button>
<div id="config_error">
</div>
</details>
<details>
<summary>Export current config</summary>
<textarea id="config_out" style="width: 100%; height: 100px;" readonly></textarea>
</details>
<pre id="configWarn">
</pre>
<hr>
<h3>Configure light</h3>
<!-- Select top -->
<label for="setup_top">Top:</label>
<select id="setup_top" name="setup_top">
<option value="buzzer">Buzzer</option>
<option value="normal">Normal</option>
</select>
<br>
<div class="ringConf">
<!-- All 5 rings (setup) -->
<h5>Ring 5</h5>
<div id="ring5Confg">
</div>
<h5>Ring 4</h5>
<div id="ring4Confg">
</div>
<h5>Ring 3</h5>
<div id="ring3Confg">
</div>
<h5>Ring 2</h5>
<div id="ring2Confg">
</div>
<h5>Ring 1</h5>
<div id="ring1Confg">
</div>
</div>
</section>
</div>
</section>
<script src="nxtRender.js"></script>
</body>
</html>