310 lines
14 KiB
Kotlin
310 lines
14 KiB
Kotlin
package dev.asdf00.visionfive.hc.server
|
|
|
|
import com.sun.net.httpserver.HttpExchange
|
|
|
|
import dev.asdf00.visionfive.hc.GlobalDataStore
|
|
|
|
import org.json.JSONArray
|
|
import org.json.JSONException
|
|
import org.json.JSONObject
|
|
|
|
import java.math.BigDecimal
|
|
import java.net.URI
|
|
import java.net.http.HttpClient
|
|
import java.net.http.HttpRequest
|
|
import java.net.http.HttpResponse
|
|
import java.nio.charset.StandardCharsets
|
|
import java.time.Duration
|
|
import java.util.*
|
|
|
|
fun main() {
|
|
// out-facing web page
|
|
val server = buildWebServer(8030) {
|
|
// main landing page
|
|
endpoint("") { exchange ->
|
|
exchange.responseHeaders.add("Cache-Control", "no-store")
|
|
val token = getAuthToken(exchange)
|
|
LoginBehavior.isLoggedIn(token)?.let { uname ->
|
|
// logged in
|
|
exchange.sendReply(
|
|
//@formatter:off
|
|
200, ContentType.HTML, """
|
|
<html>
|
|
<head>
|
|
<title>VFHeatControl Landing Page</title>
|
|
</head>
|
|
<body>
|
|
<h1>Welcome to HeatControl</h1>
|
|
<p>You are logged in as ${uname}. To log out, click <a href="/login/out">here</a>.</p>
|
|
<p>
|
|
The current recorded temperature is currently ${String.format("%.1f", GlobalDataStore.temperature)} C<br />
|
|
Last updated ${GlobalDataStore.tempUpdated}<br />
|
|
Current forecast for today: ${if (GlobalDataStore.isSunny) "sunny" else "cloudy"}
|
|
</p><p />
|
|
<h3>PID values</h3>
|
|
<p>
|
|
<table>
|
|
<tr><td>Temp:</td><td><input id="temp_val" type="number" /></td></tr>
|
|
<tr><td>P:</td><td><input id="p_val" type="number" /></td></tr>
|
|
<tr><td>I:</td><td><input id="i_val" type="number" /></td></tr>
|
|
<tr><td>D:</td><td><input id="d_val" type="number" /></td></tr>
|
|
</table>
|
|
<button onclick="onSubmit()">submit</button> <button onclick="onFetch()">fetch</button>
|
|
</p>
|
|
<script>
|
|
function onSubmit() {
|
|
fetch("/pid", {method: "POST", body: JSON.stringify({
|
|
temp: document.getElementById("temp_val").value,
|
|
p: document.getElementById("p_val").value,
|
|
i: document.getElementById("i_val").value,
|
|
d: document.getElementById("d_val").value
|
|
}),
|
|
headers: { "Content-type": "application/json; charset=UTF-8"}
|
|
})
|
|
}
|
|
function onFetch() {
|
|
fetch("/pid", {method: "GET"}).then(response => response.json()).then(data => {
|
|
document.getElementById("temp_val").value = data.temp
|
|
document.getElementById("p_val").value = data.p
|
|
document.getElementById("i_val").value = data.i
|
|
document.getElementById("d_val").value = data.d
|
|
})
|
|
}
|
|
onFetch()
|
|
</script>
|
|
</body>
|
|
</html>
|
|
""".trimIndent().uft8()
|
|
//@formatter:on
|
|
)
|
|
} ?: run {
|
|
// logged out
|
|
exchange.sendReply(
|
|
200, ContentType.HTML, """
|
|
<html>
|
|
<head>
|
|
<title>VFHeatControl Landing Page</title>
|
|
</head>
|
|
<body>
|
|
<h1>Welcome to HeatControl</h1>
|
|
<p>Please log in to view the current temperature</p>
|
|
<br />
|
|
<p>
|
|
Login Form
|
|
<br />
|
|
<form action="/login/in" method="post">
|
|
<label for="uname">user name:</label><br />
|
|
<input type="text" id="uname" name="uname"><br />
|
|
<label for="pwd">password:</label><br />
|
|
<input type="password" id="pwd" name="pwd"><br />
|
|
<input type="submit" value="Submit">
|
|
</form>
|
|
</p>
|
|
</body>
|
|
</html>
|
|
""".trimIndent().uft8()
|
|
)
|
|
}
|
|
}
|
|
|
|
// login logic
|
|
path("login") {
|
|
endpoint("in") handler@{ exchange ->
|
|
if (exchange.requestMethod != "POST") {
|
|
exchange.sendReply(400, ContentType.PLAIN, "wrong request method".uft8())
|
|
return@handler
|
|
}
|
|
val (userName, pwd) = exchange.requestBody.readAllBytes().utf8().split("&").map {
|
|
it.split("=").let {
|
|
if (it.size == 2) it[1] else ""
|
|
}
|
|
}.let {
|
|
if (it.size == 2) Pair(it[0], it[1]) else Pair("", "")
|
|
}
|
|
LoginBehavior.login(userName, pwd)?.let {
|
|
exchange.responseHeaders.add("Set-Cookie", "auth=${it}; Path=/; HttpOnly; SameSite=Strict")
|
|
exchange.sendReply(
|
|
200,
|
|
ContentType.HTML,
|
|
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
|
|
)
|
|
} ?: run {
|
|
exchange.sendReply(
|
|
400,
|
|
ContentType.HTML,
|
|
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
|
|
)
|
|
}
|
|
}
|
|
|
|
endpoint("out") { exchange ->
|
|
val token = getAuthToken(exchange)
|
|
if (LoginBehavior.logout(token)) {
|
|
exchange.responseHeaders.add("Set-Cookie", "auth=; Path=/")
|
|
exchange.sendReply(
|
|
200,
|
|
ContentType.HTML,
|
|
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
|
|
)
|
|
} else {
|
|
exchange.sendReply(
|
|
400,
|
|
ContentType.HTML,
|
|
"<meta http-equiv=\"refresh\" content=\"0; url=/\">".uft8()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// pid control
|
|
endpoint("pid") handler@{ exchange ->
|
|
if (LoginBehavior.isLoggedIn(getAuthToken(exchange)) == null) {
|
|
// not logged in
|
|
exchange.replyCat(401)
|
|
return@handler
|
|
}
|
|
if (exchange.requestMethod == "POST") {
|
|
// new values
|
|
try {
|
|
val data = JSONObject(exchange.requestBody.readAllBytes().utf8()).toMap()
|
|
if (!data.containsKey("temp") || !data.containsKey("p") || !data.containsKey("i") || !data.containsKey("d")) {
|
|
exchange.replyCat(400)
|
|
} else {
|
|
val temp = data["temp"].toString().toDouble()
|
|
val p = data["p"].toString().toDouble()
|
|
val i = data["i"].toString().toDouble()
|
|
val d = data["d"].toString().toDouble()
|
|
GlobalDataStore.targetTemp = temp
|
|
GlobalDataStore.pval = p
|
|
GlobalDataStore.ival = i
|
|
GlobalDataStore.dval = d
|
|
exchange.replyCat(200)
|
|
}
|
|
} catch (e: Exception) {
|
|
exchange.replyCat(400)
|
|
}
|
|
} else if (exchange.requestMethod == "GET") {
|
|
// get current values
|
|
exchange.sendReply(
|
|
200,
|
|
ContentType.JSON,
|
|
"{\"temp\": ${GlobalDataStore.targetTemp}, \"p\": ${GlobalDataStore.pval}, \"i\": ${GlobalDataStore.ival}, \"d\": ${GlobalDataStore.dval}}".uft8()
|
|
)
|
|
} else {
|
|
exchange.replyCat(400)
|
|
}
|
|
}
|
|
}
|
|
|
|
val tempServer = buildWebServer(8088) {
|
|
// data collection
|
|
endpoint("temp") handler@{ exchange ->
|
|
exchange.requestURI.query.split("&").forEach {
|
|
if (it.startsWith("t=")) {
|
|
it.substring(2).toDoubleOrNull()?.let {
|
|
GlobalDataStore.temperature = it
|
|
GlobalDataStore.tempUpdated = Date()
|
|
exchange.replyCat(200)
|
|
} ?: run {
|
|
exchange.replyCat(400)
|
|
}
|
|
return@handler
|
|
}
|
|
}
|
|
exchange.replyCat(400)
|
|
}
|
|
}
|
|
|
|
val forecastQuery = Thread {
|
|
while (!Thread.interrupted()) {
|
|
var success = false
|
|
val request = HttpRequest.newBuilder(URI.create(GlobalDataStore.API)).GET().build()
|
|
HttpClient.newHttpClient()
|
|
.sendAsync(request, HttpResponse.BodyHandlers.ofString())
|
|
.thenApply { response ->
|
|
if (response.statusCode() == 200 && response.headers().firstValue("Content-Type")
|
|
.map { it.contains(ContentType.JSON.text) }.orElse(false)
|
|
) {
|
|
try {
|
|
val data = JSONObject(response.body())
|
|
var maxTemp = Double.NEGATIVE_INFINITY
|
|
var maxTempTime = -1
|
|
((data["hourly"] as? JSONObject)?.get("temperature_2m") as? JSONArray)?.forEachIndexed { h, t ->
|
|
(t as? BigDecimal)?.let {
|
|
val temp = it.toDouble()
|
|
if (maxTemp < temp) {
|
|
maxTemp = temp
|
|
maxTempTime = h
|
|
}
|
|
}
|
|
}
|
|
if (maxTempTime > 0) {
|
|
val daylight = ((data["daily"] as? JSONObject)?.get("daylight_duration") as? JSONArray)?.let {
|
|
if (it.length() == 1)
|
|
(it[0] as? BigDecimal)?.let { it.toDouble() }
|
|
else
|
|
null
|
|
} ?: 0.0
|
|
val sun = ((data["daily"] as? JSONObject)?.get("sunshine_duration") as? JSONArray)?.let {
|
|
if (it.length() == 1)
|
|
(it[0] as? BigDecimal)?.let { it.toDouble() }
|
|
else
|
|
null
|
|
} ?: 0.0
|
|
|
|
GlobalDataStore.resetTime = Calendar.getInstance().also {
|
|
it.set(Calendar.HOUR_OF_DAY, maxTempTime)
|
|
it.set(Calendar.MINUTE, 30)
|
|
it.set(Calendar.SECOND, 0)
|
|
}.time
|
|
GlobalDataStore.isSunny = sun > daylight / 2
|
|
success = true
|
|
} else {
|
|
println("error reading weather API response")
|
|
}
|
|
} catch (e: JSONException) {
|
|
println("error parsing weather API response")
|
|
}
|
|
} else {
|
|
println("error querying weather API response")
|
|
}
|
|
}
|
|
.join()
|
|
|
|
if (success) {
|
|
Thread.sleep(Duration.ofHours(12))
|
|
} else {
|
|
Thread.sleep(Duration.ofMinutes(30))
|
|
}
|
|
}
|
|
}
|
|
|
|
forecastQuery.start()
|
|
server.start()
|
|
tempServer.start()
|
|
println("HttpServer is running, press CTRL+C to exit")
|
|
while (!Thread.interrupted()) {
|
|
println()
|
|
print("Username to add: ")
|
|
val userName = readln()
|
|
print("Password for '$userName': ")
|
|
val pwd = readln()
|
|
LoginBehavior.Users[userName] = pwd
|
|
}
|
|
forecastQuery.interrupt()
|
|
}
|
|
|
|
fun String.uft8() = toByteArray(StandardCharsets.UTF_8)
|
|
|
|
fun ByteArray.utf8() = String(this, StandardCharsets.UTF_8)
|
|
|
|
private fun getAuthToken(exchange: HttpExchange) =
|
|
exchange.requestHeaders["Cookie"]
|
|
?.let { if (it.size > 0) it[0] else null }
|
|
?.let { it.split("; ") }
|
|
?.filter { it.startsWith("auth=") }
|
|
?.firstOrNull()
|
|
?.let { it.split("=").let { if (it.size == 2) it[1] else "" } }
|
|
?: ""
|
|
|