VisionFiveHeatControl/src/main/kotlin/dev/asdf00/visionfive/hc/server/Main.kt
2025-04-25 22:13:46 +02:00

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 "" } }
?: ""