Skip to content

Commit 706076f

Browse files
committed
Implement air quality.
1 parent b08f4c6 commit 706076f

File tree

7 files changed

+263
-10
lines changed

7 files changed

+263
-10
lines changed

software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/ConfKey.kt

+10
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,16 @@ object NetworkViewKey : ConfigSpec("network-info") {
5555
val pingAddress by required<String>(description = "IP address or domain used to check internet access on network view")
5656
}
5757

58+
data class AirStationKey(
59+
val id: Long,
60+
val name: String
61+
)
62+
63+
object AirQualityKey : ConfigSpec("air-quality") {
64+
val station1 by required<AirStationKey>()
65+
val station2 by required<AirStationKey>()
66+
}
67+
5868
object ConfKey : ConfigSpec("") {
5969
val i2cBus by required<Int>()
6070
val databaseFile by optional<Path>(Paths.get("data.db"))

software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/Database.kt

+9
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package eu.slomkowski.octoglow.octoglowd
22

33

44
import kotlinx.coroutines.*
5+
import kotlinx.datetime.toJavaInstant
56
import mu.KLogging
67
import org.jetbrains.exposed.sql.*
78
import org.jetbrains.exposed.sql.transactions.TransactionManager
@@ -193,6 +194,14 @@ class DatabaseLayer(
193194
return result
194195
}
195196

197+
fun getLastHistoricalValuesByHourAsync(
198+
currentTime: kotlinx.datetime.Instant,
199+
key: HistoricalValueType,
200+
numberOfPastHours: Int
201+
): Deferred<List<Double?>> =
202+
getLastHistoricalValuesByHourAsync(currentTime.toJavaInstant().atZone(WARSAW_ZONE_ID), key, numberOfPastHours)
203+
204+
@Deprecated("migrating to kotlinx date time")
196205
fun getLastHistoricalValuesByHourAsync(
197206
currentTime: ZonedDateTime,
198207
key: HistoricalValueType,

software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/DatabaseKeys.kt

+10
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,16 @@ data class Cryptocurrency(val symbol: String) : HistoricalValueType() {
3434
get() = "CRYPTOCURRENCY_$symbol".toUpperCase()
3535
}
3636

37+
data class AirQuality(val stationId: Long) : HistoricalValueType() {
38+
init {
39+
require(stationId > 0)
40+
}
41+
42+
override val databaseSymbol: String
43+
get() = "AIR_QUALITY_$stationId"
44+
}
45+
46+
3747
data class Stock(val symbol: String) : HistoricalValueType() {
3848

3949
companion object {

software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/Main.kt

+2
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ fun main() {
2424
addSpec(NbpKey)
2525
// addSpec(StocksKey)
2626
addSpec(SimpleMonitorKey)
27+
addSpec(AirQualityKey)
2728
}.from.yaml.file("config.yml")
2829

2930
val hardware = Hardware(config)
@@ -46,6 +47,7 @@ fun main() {
4647
NbpView(config, hardware),
4748
// StockView(config, database, hardware),
4849
SimpleMonitorView(config, database, hardware),
50+
AirQualityView(config, database, hardware),
4951
NetworkView(config, hardware)
5052
)
5153

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
package eu.slomkowski.octoglow.octoglowd.daemon.frontdisplay
2+
3+
import com.uchuhimo.konf.Config
4+
import eu.slomkowski.octoglow.octoglowd.*
5+
import eu.slomkowski.octoglow.octoglowd.hardware.Hardware
6+
import eu.slomkowski.octoglow.octoglowd.hardware.Slot
7+
import io.ktor.client.request.*
8+
import kotlinx.coroutines.async
9+
import kotlinx.coroutines.coroutineScope
10+
import kotlinx.coroutines.launch
11+
import kotlinx.datetime.Instant
12+
import kotlinx.datetime.toJavaInstant
13+
import kotlinx.datetime.toKotlinInstant
14+
import kotlinx.serialization.SerialName
15+
import kotlinx.serialization.Serializable
16+
import mu.KLogging
17+
import java.time.Duration
18+
import java.time.ZoneId
19+
import java.time.ZonedDateTime
20+
21+
class AirQualityView(
22+
private val config: Config,
23+
private val database: DatabaseLayer,
24+
hardware: Hardware
25+
) : FrontDisplayView(
26+
hardware,
27+
"Air quality from powietrze.gios.gov.pl",
28+
Duration.ofMinutes(10),
29+
Duration.ofSeconds(15),
30+
Duration.ofSeconds(13)
31+
) {
32+
33+
enum class AirQualityIndex(val text: String) {
34+
EXCELLENT("excellent"),
35+
GOOD("good"),
36+
FAIR("fair"),
37+
POOR("poor"),
38+
BAD("BAD!"),
39+
HAZARDOUS("HAZARDOUS!!")
40+
}
41+
42+
@Serializable
43+
data class StIndexLevel(
44+
@SerialName("id")
45+
val id: Int,
46+
47+
@SerialName("indexLevelName")
48+
val levelName: String
49+
) {
50+
val level: AirQualityIndex?
51+
get() = when (id) {
52+
-1 -> null
53+
else -> AirQualityIndex.values()[id]
54+
}
55+
}
56+
57+
@Serializable
58+
data class AirQualityDto(
59+
val id: Long,
60+
val stIndexStatus: Boolean,
61+
val stIndexCrParam: String,
62+
val stIndexLevel: StIndexLevel,
63+
64+
@Serializable(AirQualityInstantSerializer::class)
65+
val stCalcDate: Instant,
66+
67+
@Serializable(AirQualityInstantSerializer::class)
68+
val stSourceDataDate: Instant
69+
) {
70+
override fun toString(): String {
71+
return "{$id, ${stIndexLevel.level}, $stIndexCrParam}"
72+
}
73+
}
74+
75+
data class AirQualityReport(
76+
val name: String,
77+
val level: AirQualityIndex,
78+
val latest: Double,
79+
val historical: List<Double?>
80+
) {
81+
init {
82+
require(name.isNotBlank())
83+
//todo tests for name
84+
}
85+
}
86+
87+
data class CurrentReport(
88+
val station1: AirQualityReport?,
89+
val station2: AirQualityReport?
90+
) {
91+
val updateStatus: UpdateStatus
92+
get() = if (station1 == null && station2 == null) {
93+
UpdateStatus.FAILURE
94+
} else if (station1 != null && station2 != null) {
95+
UpdateStatus.FULL_SUCCESS
96+
} else {
97+
UpdateStatus.PARTIAL_SUCCESS
98+
}
99+
}
100+
101+
private var currentReport: CurrentReport? = null
102+
103+
companion object : KLogging() {
104+
105+
private const val HISTORIC_VALUES_LENGTH = 14
106+
107+
suspend fun retrieveAirQualityData(stationId: Long): AirQualityDto {
108+
require(stationId > 0)
109+
110+
logger.debug("Downloading currency rates for station {}.", stationId)
111+
val url = "http://api.gios.gov.pl/pjp-api/rest/aqindex/getIndex/$stationId"
112+
113+
val resp: AirQualityDto = httpClient.get(url)
114+
115+
check(resp.stIndexStatus) { "no air quality index for station ${resp.id}" }
116+
checkNotNull(resp.stIndexLevel.level) { "no air quality index for station ${resp.id}" }
117+
logger.debug("Air quality is {}.", resp)
118+
119+
return resp
120+
}
121+
}
122+
123+
private suspend fun createStationReport(now: Instant, station: AirStationKey) = try {
124+
val dto = retrieveAirQualityData(station.id)
125+
val dbKey = AirQuality(station.id)
126+
val value = dto.stIndexLevel.id.toDouble()
127+
val timestamp = dto.stSourceDataDate.toJavaInstant().atZone(ZoneId.systemDefault())
128+
database.insertHistoricalValueAsync(timestamp, dbKey, value)
129+
130+
val history =
131+
database.getLastHistoricalValuesByHourAsync(now, dbKey, HISTORIC_VALUES_LENGTH).await()
132+
133+
AirQualityReport(station.name, checkNotNull(dto.stIndexLevel.level), value, history)
134+
} catch (e: Exception) {
135+
logger.error("Failed to update air quality of station ${station.id}.", e)
136+
null
137+
}
138+
139+
override suspend fun poolInstantData(now: ZonedDateTime) = UpdateStatus.NO_NEW_DATA
140+
141+
override suspend fun poolStatusData(now: ZonedDateTime): UpdateStatus = coroutineScope {
142+
val instantNow = now.toInstant().toKotlinInstant()
143+
144+
val rep1 = async { createStationReport(instantNow, config[AirQualityKey.station1]) }
145+
val rep2 = async { createStationReport(instantNow, config[AirQualityKey.station2]) }
146+
147+
val newRep = CurrentReport(rep1.await(), rep2.await())
148+
149+
currentReport = newRep
150+
151+
newRep.updateStatus
152+
}
153+
154+
override suspend fun redrawDisplay(redrawStatic: Boolean, redrawStatus: Boolean, now: ZonedDateTime) =
155+
coroutineScope {
156+
157+
val fd = hardware.frontDisplay
158+
val rep = currentReport
159+
160+
launch {
161+
fd.setStaticText(0, "A")
162+
fd.setStaticText(20, "Q")
163+
}
164+
165+
fun drawForStation(offset: Int, slot: Slot, report: AirQualityReport?) {
166+
report?.let {
167+
launch { fd.setOneLineDiffChart(5 * (offset + 16), it.latest, it.historical, 1.0) }
168+
}
169+
170+
launch {
171+
fd.setScrollingText(
172+
slot,
173+
offset + 2,
174+
13,
175+
report?.let { "${it.name}: ${it.level.text}" } ?: "no air quality data"
176+
)
177+
fd.setStaticText(offset + 19, report?.level?.ordinal?.toString() ?: "-")
178+
}
179+
}
180+
181+
drawForStation(0, Slot.SLOT0, rep?.station1)
182+
drawForStation(20, Slot.SLOT1, rep?.station2)
183+
}
184+
185+
}

software/octoglowd/src/main/kotlin/eu/slomkowski/octoglow/octoglowd/serializers.kt

+24-10
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
package eu.slomkowski.octoglow.octoglowd
22

3-
import kotlinx.datetime.Instant
4-
import kotlinx.datetime.LocalDate
5-
import kotlinx.datetime.toKotlinInstant
3+
import kotlinx.datetime.*
64
import kotlinx.serialization.KSerializer
75
import kotlinx.serialization.descriptors.PrimitiveKind
86
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
97
import kotlinx.serialization.descriptors.SerialDescriptor
108
import kotlinx.serialization.encoding.Decoder
119
import kotlinx.serialization.encoding.Encoder
12-
import java.time.ZonedDateTime
1310
import java.time.format.DateTimeFormatter
1411

1512
object LocalDateSerializer : KSerializer<LocalDate> {
@@ -23,18 +20,35 @@ object LocalDateSerializer : KSerializer<LocalDate> {
2320
}
2421
}
2522

26-
object SimpleMonitorInstantSerializer : KSerializer<Instant> {
23+
abstract class AbstractInstantSerializer(pattern: String) : KSerializer<Instant> {
2724

28-
override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("SimpleMonitorInstant", PrimitiveKind.STRING)
25+
override val descriptor: SerialDescriptor =
26+
PrimitiveSerialDescriptor("InstantSerializer$pattern", PrimitiveKind.STRING)
2927

30-
private val datetimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ssxxx")
28+
private val datetimeFormatter = DateTimeFormatter.ofPattern(pattern)
3129

3230
override fun deserialize(decoder: Decoder): Instant =
33-
ZonedDateTime.parse(decoder.decodeString(), datetimeFormatter).toInstant().toKotlinInstant()
31+
java.time.ZonedDateTime.parse(decoder.decodeString(), datetimeFormatter).toInstant().toKotlinInstant()
3432

35-
override fun serialize(encoder: Encoder, value: Instant) {
36-
encoder.encodeString(value.toString()) // toString? format?
33+
override fun serialize(encoder: Encoder, value: Instant) = TODO()
34+
}
35+
36+
object SimpleMonitorInstantSerializer : AbstractInstantSerializer("yyyy-MM-dd HH:mm:ssxxx")
37+
38+
object AirQualityInstantSerializer : KSerializer<Instant> {
39+
40+
override val descriptor: SerialDescriptor =
41+
PrimitiveSerialDescriptor("AirQualityInstantSerializer", PrimitiveKind.STRING)
42+
43+
private val datetimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
44+
private val timeZone = WARSAW_ZONE_ID.toKotlinTimeZone()
45+
46+
override fun deserialize(decoder: Decoder): Instant {
47+
val ldt = java.time.LocalDateTime.parse(decoder.decodeString(), datetimeFormatter).toKotlinLocalDateTime()
48+
return ldt.toInstant(timeZone)
3749
}
50+
51+
override fun serialize(encoder: Encoder, value: Instant) = TODO()
3852
}
3953

4054
object InstantSerializer : KSerializer<Instant> {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package eu.slomkowski.octoglow.octoglowd.daemon.frontdisplay
2+
3+
import kotlinx.coroutines.runBlocking
4+
import org.junit.jupiter.api.Assertions.assertNotNull
5+
import org.junit.jupiter.api.Test
6+
import kotlin.test.assertFails
7+
8+
internal class AirQualityViewTest {
9+
10+
@Test
11+
fun testRetrieveAirQualityDataNoAirQuality() {
12+
assertFails("no air quality index for station") {
13+
runBlocking { AirQualityView.retrieveAirQualityData(943) }
14+
}
15+
}
16+
17+
@Test
18+
fun testRetrieveAirQualityData() {
19+
runBlocking { AirQualityView.retrieveAirQualityData(11477) }.let {
20+
assertNotNull(it.stIndexLevel.levelName)
21+
}
22+
}
23+
}

0 commit comments

Comments
 (0)