Compare commits

...

6 Commits

Author SHA1 Message Date
1ae7e7c3d4 bind device toggles with UI
only flow apllication -> webpage is supported now
2024-02-16 01:26:14 +03:00
b0154ac187 sync UI buttons 2024-01-27 20:35:39 +03:00
9f60c14bd9 change protocol to WebSocket 2024-01-19 02:58:17 +03:00
2177dd5b42 implement dynamic drawing of devices in config 2024-01-18 03:48:12 +03:00
e367bd62f1 implement devices config endpoint 2024-01-15 22:04:19 +03:00
650e789fc5 start working on webUI 2024-01-15 04:52:50 +03:00
13 changed files with 552 additions and 5 deletions

View File

@ -112,6 +112,7 @@ fun EventTarget.deviceStateIndicator(connection: DeviceDisplayFX<*>, state: Stri
fun Node.deviceStateToggle(connection: DeviceDisplayFX<*>, state: String, title: String = state) {
if (connection.device.stateNames.contains(state)) {
togglebutton(title) {
this.id = title
selectedProperty().addListener { _, oldValue, newValue ->
if (oldValue != newValue) {
connection.device.states[state] = newValue

View File

@ -9,7 +9,7 @@ import hep.dataforge.optional
import javafx.scene.Scene
import javafx.stage.Stage
import org.slf4j.LoggerFactory
import tornadofx.*
import tornadofx.App
import java.util.*
/**
@ -46,8 +46,10 @@ abstract class NumassControlApplication<in D : Device> : App() {
abstract fun getDeviceMeta(config: Meta): Meta
fun getDeviceConfig() : Meta = getConfig(this).optional.orElseGet { readResourceMeta("config/devices.xml") }
private fun setupDevice(): D {
val config = getConfig(this).optional.orElseGet { readResourceMeta("config/devices.xml") }
val config = getDeviceConfig()
val ctx = setupContext(config)
val deviceConfig = getDeviceMeta(config)

View File

@ -100,7 +100,6 @@ fun getConfig(app: Application): Meta? {
}
}
fun findDeviceMeta(config: Meta, criterion: (Meta) -> Boolean): Meta? {
return config.getMetaList("device").stream().filter(criterion).findFirst().nullable
}

View File

@ -14,7 +14,7 @@ import javafx.stage.Stage
/**
* @author Alexander Nozik
*/
class ReadVac : NumassControlApplication<VacCollectorDevice>() {
open class ReadVac : NumassControlApplication<VacCollectorDevice>() {
override val deviceFactory = VacDeviceFactory()

View File

@ -84,6 +84,7 @@ class VacCollectorDisplay : DeviceDisplayFX<VacCollectorDevice>() {
}
separator(Orientation.VERTICAL)
togglebutton("Log") {
this.id = "Log"
isSelected = false
LogFragment().apply {
addLogHandler(device.logger)

View File

@ -121,6 +121,7 @@ open class VacDisplay : DeviceDisplayFX<Sensor>() {
value = "---"
}
}
id = device.name
}
}
}
@ -161,6 +162,7 @@ open class VacDisplay : DeviceDisplayFX<Sensor>() {
minHeight = 30.0
vgrow = Priority.ALWAYS
switch("Power") {
this.id = "power"
alignment = Pos.CENTER
booleanStateProperty("power").bindBidirectional(selectedProperty())
}

42
numass-web-control/.gitignore vendored Normal file
View File

@ -0,0 +1,42 @@
.gradle
build/
!gradle/wrapper/gradle-wrapper.jar
!**/src/main/**/build/
!**/src/test/**/build/
### IntelliJ IDEA ###
.idea/modules.xml
.idea/jarRepositories.xml
.idea/compiler.xml
.idea/libraries/
*.iws
*.iml
*.ipr
out/
!**/src/main/**/out/
!**/src/test/**/out/
### Eclipse ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
bin/
!**/src/main/**/bin/
!**/src/test/**/bin/
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
### VS Code ###
.vscode/
### Mac OS ###
.DS_Store

View File

@ -0,0 +1,34 @@
plugins {
application
id("org.openjfx.javafxplugin") version "0.1.0"
}
group = "inr.numass"
repositories {
mavenCentral()
}
javafx {
modules("javafx.controls", "javafx.web")
version = "16"
}
dependencies {
testImplementation(platform("org.junit:junit-bom:5.9.1"))
testImplementation("org.junit.jupiter:junit-jupiter")
implementation("io.ktor:ktor-server-core:2.3.7")
implementation("io.ktor:ktor-server-netty:2.3.7")
implementation("io.ktor:ktor-server-websockets:2.3.7")
api(project(":numass-control:vac"))
api(project(":dataforge-core:dataforge-json"))
}
tasks.test {
useJUnitPlatform()
}
application {
mainClass.set("inr.numass.webcontrol.ServerKt")
}

View File

@ -0,0 +1,152 @@
package inr.numass.webcontrol
import ch.qos.logback.classic.spi.ILoggingEvent
import ch.qos.logback.core.AppenderBase
import hep.dataforge.get
import hep.dataforge.io.JSONMetaWriter
import inr.numass.control.readvac.ReadVac
import inr.numass.control.readvac.VacCollectorDevice
import io.ktor.http.*
import io.ktor.server.application.*
import io.ktor.server.engine.*
import io.ktor.server.http.content.*
import io.ktor.server.netty.*
import io.ktor.server.response.*
import io.ktor.server.routing.*
import io.ktor.server.websocket.*
import io.ktor.websocket.*
import javafx.application.Application
import javafx.scene.control.ToggleButton
import javafx.stage.Stage
import kotlinx.coroutines.runBlocking
import org.controlsfx.control.ToggleSwitch
import java.io.ByteArrayOutputStream
import java.nio.file.Paths
import java.util.*
import java.util.concurrent.atomic.AtomicInteger
class Connection(val session: DefaultWebSocketServerSession) {
companion object {
val lastId = AtomicInteger(0)
}
val name = "user${lastId.getAndIncrement()}"
}
class ReadVacSvr : ReadVac() {
var server : NettyApplicationEngine? = null
private var device : VacCollectorDevice? = null
override fun setupStage(stage: Stage, device: VacCollectorDevice) {
super.setupStage(stage, device)
this.device = device
}
override fun start(stage: Stage) {
super.start(stage)
val measureButton = stage.scene.lookup("#Measure") as ToggleButton
val storeButton = stage.scene.lookup("#Store") as ToggleButton
val sensors = this@ReadVacSvr.getDeviceMeta(this@ReadVacSvr.getDeviceConfig()).getMetaList("sensor").map { it["name"] }
this.server = embeddedServer(Netty, port = 8000) {
install(WebSockets)
routing {
staticFiles("/", Paths.get(this.javaClass.classLoader.getResource("index.html").toURI()).toFile().parentFile)
val connections = Collections.synchronizedSet<Connection?>(LinkedHashSet())
for (sensor in sensors) {
(stage.scene.lookup("#${sensor}") as ToggleSwitch).selectedProperty().addListener{ _, oldValue, newValue ->
if (oldValue != newValue) {
connections.forEach {
runBlocking {
it.session.send("{ \"sensor\": {\"${sensor}\": ${newValue}}}")
}
}
}
}
}
measureButton.selectedProperty().addListener { _, oldValue, newValue ->
if (oldValue != newValue) {
connections.forEach {
runBlocking {
it.session.send("{ \"measurement\": {\"value\": ${newValue}}}")
}
}
}
}
storeButton.selectedProperty().addListener { _, oldValue, newValue ->
if (oldValue != newValue) {
connections.forEach {
runBlocking {
it.session.send("{ \"storing\": {\"value\": ${newValue}}}")
}
}
}
}
(device!!.logger as ch.qos.logback.classic.Logger).addAppender(
object : AppenderBase<ILoggingEvent>() {
override fun append(eventObject: ILoggingEvent) {
synchronized(this) {
connections.forEach {
runBlocking {
it.session.send("{ \"log\": {\"value\": \"${eventObject}\"}}") // FIXME: '"' in eventObject
}
}
}
}
}.apply {
name = "serverLogger"
start()
}
)
webSocket("/echo") {
val thisConnection = Connection(this)
connections += thisConnection
try {
// Sending device list and initial UI state
val stream = ByteArrayOutputStream()
JSONMetaWriter.write(stream, this@ReadVacSvr.getDeviceMeta(this@ReadVacSvr.getDeviceConfig()))
send("{ \"devices\": $stream, \"storing\": {\"value\": ${storeButton.selectedProperty().get()}}, \"measurement\": {\"value\": ${measureButton.selectedProperty().get()}} }")
for (sensor in sensors) { send("{ \"sensor\": {\"${sensor}\": ${(stage.scene.lookup("#${sensor}") as ToggleSwitch).selectedProperty().get()}}}") }
for (frame in incoming) {
frame as? Frame.Text ?: continue
when (frame.readText()) {
"measurePressed" -> {
measureButton.fire()
}
"storePressed" -> {
storeButton.fire()
}
"logPressed" -> {
//logButton.fire()
}
"bye" -> close(CloseReason(CloseReason.Codes.NORMAL, "Client said BYE"))
}
}
} catch (e: Exception) {
println(e.localizedMessage)
} finally {
println("Removing $thisConnection")
connections -= thisConnection
}
}
get ("/api") {
call.respondText(call.parameters.toString())
}
get ("/api/devices") {
call.respondOutputStream(ContentType.Application.Json, HttpStatusCode.OK, null) {
JSONMetaWriter.write(this, this@ReadVacSvr.getDeviceMeta(this@ReadVacSvr.getDeviceConfig()))
}
}
}
}
this.server!!.start(wait = false)
}
override fun stop() {
super.stop()
this.server?.stop()
}
}
fun main() {
Application.launch(ReadVacSvr::class.java)
}

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Nu-mass vacuum measurements</title>
<link rel="stylesheet" href="styles.css">
<script src="https://cdn.plot.ly/plotly-2.27.0.min.js" charset="utf-8"></script>
</head>
<body>
<div id="logBar"></div>
<div class="toolbar">
<button id="measure" type="button" class="myButton toolbarButton">Measure</button>
<button id="store" type="button" class="myButton toolbarButton">Store</button>
<button id="log" type="button" class="logButton toolbarButton">Log</button>
</div>
<div id="lower">
<div id="plot">
</div>
<div id="sidebar">
<!--
<div class="card">
<div class="upperSection">
<input class="switch" type="checkbox">
</div>
<div class="middleSection">
<span>Undefined</span>
<span>mbar</span>
</div>
<hr>
<div class="powerSection">
<span>Power</span>
<input class="switch" type="checkbox">
</div>
</div>
-->
</div>
</div>
<script src="/script.js"></script>
</body>
</html>

View File

@ -0,0 +1,129 @@
// Creates device card
function drawCard(device_name = "Default", col = "red", measure = "---", power = false) {
let card = document.createElement("div")
card.classList = "card"
// Create upper section of a card
let upperSection = document.createElement("div")
upperSection.classList = "upperSection"
let name = document.createElement("span")
name.style.fontWeight = "bolder"
let p_toggle = document.createElement("input")
p_toggle.id = device_name
p_toggle.type= "checkbox"
p_toggle.classList = "switch"
name.textContent = device_name
upperSection.appendChild(name)
upperSection.appendChild(p_toggle)
card.appendChild(upperSection)
card.appendChild(document.createElement("hr"))
// Create middle section of a card
let middleSection = document.createElement("div")
middleSection.classList = "middleSection"
let measurement = document.createElement("span")
measurement.classList = "measureValue"
measurement.textContent = measure
measurement.style.color = col
let mbar = document.createElement("span")
mbar.textContent = "mbar"
mbar.style.fontSize = "2.5em"
mbar.style.float = "right"
middleSection.appendChild(measurement)
middleSection.appendChild(mbar)
card.appendChild(middleSection)
card.appendChild(document.createElement("hr"))
if (power) {
let powerSection = document.createElement("div")
let power = document.createElement("span")
power.textContent = "Power"
let power_toggle = document.createElement("input")
power_toggle.type= "checkbox"
power_toggle.classList = "switch"
powerSection.appendChild(power)
powerSection.appendChild(power_toggle)
card.appendChild(powerSection)
}
document.querySelector("#sidebar").appendChild(card)
}
var devices = []
const nuWebSocket = new WebSocket("ws://" + window.location.host + "/echo")
nuWebSocket.onmessage = (event) => {
console.log(event.data)
const msg = JSON.parse(event.data)
if (msg.devices) {
msg.devices.sensor.forEach((device) => {
devices.push(device.name)
drawCard(device.name, device.color, "---", (device.sensorType == "mks") )
})
}
if (msg.measurement) {
if (msg.measurement.value) {
document.querySelector("#measure").classList.add("pressedButton")
}
else document.querySelector("#measure").classList.remove("pressedButton")
}
if (msg.storing) {
if (msg.storing.value) {
document.querySelector("#store").classList.add("pressedButton")
}
else document.querySelector("#store").classList.remove("pressedButton")
}
if (msg.log) {
if (msg.log.value) {
document.querySelector("#logBar").innerHTML += msg.log.value + "<br>"
document.querySelector("#logBar").scrollTo(0, document.querySelector("#logBar").scrollHeight)
}
}
if (msg.sensor) {
for (sensor in msg.sensor) {
let sensorButton = document.querySelector("#" + sensor)
if (sensorButton) {
sensorButton.checked = msg.sensor[sensor]
}
}
}
}
/*
fetch("/api/devices")
.then((response) => response.json())
.then((json) => {
console.log(json)
json.sensor.forEach((device) => {
devices.push(device.name)
drawCard(device.name, device.color, "---", (device.sensorType == "mks") )
})
})
*/
var data = []
var layout = {
xaxis: {
title: 'timestamp'
},
yaxis: {
title: 'pressure (mbar)'
}
};
Plotly.newPlot('plot', data, layout, { responsive: true });
document.querySelector("#measure").addEventListener("click", (e) => {
nuWebSocket.send("measurePressed")
console.log("measurePressed")
})
document.querySelector("#store").addEventListener("click", (e) => {
nuWebSocket.send("storePressed")
console.log("storePressed")
})
document.querySelector("#log").addEventListener("click", (e) => {
console.log("logPressed")
document.querySelector("#logBar").hidden = !document.querySelector("#logBar").hidden
document.querySelector("#logBar").scrollTo(0, document.querySelector("#logBar").scrollHeight)
})

View File

@ -0,0 +1,144 @@
.myButton {
color: white;
background-color: #ad0022;
border: 1px black solid;
padding: 0.2em 1ch;
text-transform: uppercase;
line-height: 1em;
}
.myButton:hover {
background-color: #7b0d23;
transition: 0.5s;
}
.pressedButton {
background-color: #008a03;
transition: 0.5s;
}
.testButton {
color: magenta;
background: cyan;
}
#log {
color: magenta;
background: cyan;
float: right;
margin-left: auto;
}
#plot {
width: 80%;
padding-right: 0.5em;
}
body {
height: 100vh;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
background: #f6e9ff;
font-family: Sans-Serif;
}
#lower {
flex: 4;
display: flex;
}
#logBar {
position: absolute;
width: 100%;
background: grey;
border: 1px black solid;
bottom: 0;
z-index: 10;
box-sizing: border-box;
padding: 0.5em;
max-height: 20%;
overflow-y: scroll;
}
.upperSection {
background: lightblue;
text-align: center;
}
#sidebar {
flex: 1;
overflow-y: auto;
max-height: calc(100vh - 3em);
overflow-x: hidden;
}
.card {
border: 1px black solid;
padding: .25em;
padding-bottom: 1em;
background: #efefef;
}
input.switch {
position: relative;
float: right;
left: -1em;
}
input.switch:before {
content: ' ';
display: block;
position: absolute;
width: 3em;
height: 1em;
background: grey;
left: -1em;
border-radius: 0.5em;
}
input.switch:after {
content: ' ';
display: block;
position: absolute;
width: 1em;
height: 1em;
background: #fff;
left: -1em;
border-radius: 0.5em;
transition: .25s ease;
}
input.switch:checked:after {
left: 1em;
background: #fff;
}
input.switch:checked:before {
background: #bada55;
}
.toolbarButton {
border-radius: 1em;
font-size: 1.25rem;
margin-right: 0.5em;
}
.toolbar {
padding: 0.5em;
height: 3em;
display: flex;
align-items: center;
justify-content: flex-start;
}
.vl {
border-left: 1px solid grey;
height: 100%;
padding-right: 0.8em;
}
.measureValue {
font-size: 2.5em;
}
.middleSection {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1ch;
}

View File

@ -53,4 +53,4 @@ include("numass-core:numass-data-proto")
include("numass-core:numass-signal-processing")
include(":numass-viewer")
include("numass-web-control")