diff --git a/.gitignore b/.gitignore
index b1706a0..8541e12 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,11 @@
/app/build/
+/app/release/
/.idea/
/gradle/wrapper/
/gradlew
/gradlew.bat
+/keystore.properties
+/.kotlin/
# Android Studio defaults
*.iml
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
new file mode 100644
index 0000000..7e1710b
--- /dev/null
+++ b/app/build.gradle.kts
@@ -0,0 +1,62 @@
+plugins {
+ alias ( libs . plugins . android . application )
+ alias ( libs . plugins . kotlin . android )
+ alias ( libs . plugins . kotlin . compose )
+ alias ( libs . plugins . kotlin . serialization )
+}
+
+android {
+ namespace = "com.kernelmaft.zanbur"
+ compileSdk = 34
+
+ defaultConfig {
+ applicationId = "com.kernelmaft.zanbur"
+ minSdk = 31
+ targetSdk = 34
+ versionCode = 1
+ versionName = "1.0"
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = true
+ isShrinkResources = true
+ }
+ }
+ compileOptions {
+ targetCompatibility = JavaVersion . VERSION_11
+ }
+ kotlinOptions {
+ jvmTarget = "11"
+ }
+ buildToolsVersion = "34.0.0"
+ buildFeatures {
+ compose = true
+ }
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.14"
+ }
+}
+
+dependencies {
+ // Runtime libraries
+ implementation ( libs . android . material )
+ implementation ( libs . androidx . activity . compose )
+ implementation ( libs . androidx . core . ktx )
+ implementation ( libs . androidx . compose . material3 )
+ implementation ( libs . androidx . compose . ui )
+ implementation ( libs . androidx . compose . ui . graphics )
+ debugImplementation ( libs . androidx . compose . ui . tooling )
+ implementation ( libs . androidx . lifecycle . runtime . ktx )
+ implementation ( libs . kotlinx . coroutines . android )
+ implementation ( libs . kotlinx . serialization . json )
+ // Other libraries
+ implementation ( libs . kmqtt . common )
+ implementation ( libs . kmqtt . client )
+}
+
+tasks . withType ( org . jetbrains . kotlin . gradle . tasks . KotlinCompile :: class ) . all {
+ compilerOptions {
+ freeCompilerArgs . addAll ( "-opt-in=kotlin.ExperimentalUnsignedTypes" )
+ }
+}
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..6d13121
--- /dev/null
+++ b/app/src/main/AndroidManifest.xml
@@ -0,0 +1,28 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/com/kernelmaft/zanbur/app-state.kt b/app/src/main/java/com/kernelmaft/zanbur/app-state.kt
new file mode 100644
index 0000000..8968415
--- /dev/null
+++ b/app/src/main/java/com/kernelmaft/zanbur/app-state.kt
@@ -0,0 +1,17 @@
+package com.kernelmaft.zanbur
+
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
+
+
+typealias MutableStateMap < Key , Value > = MutableState < Map < Key , Value > >
+
+object AppState {
+ val devices : MutableStateMap < DeviceName , Device > = mutableStateOf ( emptyMap () )
+ val deviceStates : MutableStateMap < Device , LampState > = mutableStateOf ( emptyMap () )
+ val scenes : MutableStateMap < SceneName , Scene > = mutableStateOf ( emptyMap () )
+ val currentScene : MutableState < Scene ? > = mutableStateOf (null)
+
+ fun putDevice ( device : Device ) { devices . value += Pair ( device . name , device ) }
+ fun putScene ( scene : Scene ) { scenes . value += Pair ( scene . name , scene ) }
+}
diff --git a/app/src/main/java/com/kernelmaft/zanbur/config.kt b/app/src/main/java/com/kernelmaft/zanbur/config.kt
new file mode 100644
index 0000000..8e15bca
--- /dev/null
+++ b/app/src/main/java/com/kernelmaft/zanbur/config.kt
@@ -0,0 +1,30 @@
+package com.kernelmaft.zanbur
+
+
+object Config {
+ const val MQTT_SERVER_HOST = "antoinette.kernelmaft.com"
+ const val MQTT_SERVER_PORT = 1883
+ const val MQTT_TOPIC = "zigbee2mqtt"
+
+ private val deskLamp = TemperatureLamp ( DeviceName ("Desk lamp") )
+ private val standingLamp = TemperatureLamp ( DeviceName ("Standing lamp") )
+ private val diningTableLamp = TemperatureLamp ( DeviceName ("Dining table lamp") )
+ private val ledStrip = RgbLamp ( DeviceName ("LED strip") )
+
+ val devices = listOf ( deskLamp , standingLamp , diningTableLamp , ledStrip )
+
+ val scenes = listOf (
+ Scene ( SceneName ("All off") , mapOf (
+ Pair ( deskLamp , TemperatureLampState ( Power . OFF , 254 , 250 ) ) ,
+ Pair ( standingLamp , TemperatureLampState ( Power . OFF , 254 , 250 ) ) ,
+ Pair ( diningTableLamp , TemperatureLampState ( Power . OFF , 254 , 250 ) ) ,
+ Pair ( ledStrip , RgbLampState ( Power . OFF , 254 , Cie1931Color ( 0.0 , 0.0 ) ) ) ,
+ ) ) ,
+ Scene ( SceneName ("Evening") , mapOf (
+ Pair ( deskLamp , TemperatureLampState ( Power . ON , 128 , 454 ) ) ,
+ Pair ( standingLamp , TemperatureLampState ( Power . ON , 192 , 454 ) ) ,
+ Pair ( diningTableLamp , TemperatureLampState ( Power . ON , 192 , 454 ) ) ,
+ Pair ( ledStrip , RgbLampState ( Power . ON , 128 , Cie1931Color ( 0.25 , 0.05 ) ) ) ,
+ ) ) ,
+ )
+}
diff --git a/app/src/main/java/com/kernelmaft/zanbur/main.kt b/app/src/main/java/com/kernelmaft/zanbur/main.kt
new file mode 100644
index 0000000..67ab86c
--- /dev/null
+++ b/app/src/main/java/com/kernelmaft/zanbur/main.kt
@@ -0,0 +1,112 @@
+package com.kernelmaft.zanbur
+
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.compose.foundation.layout.Arrangement.Center
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+
+
+class MainActivity : ComponentActivity () {
+ override fun onCreate ( savedInstanceState : Bundle ? ) {
+ super . onCreate (savedInstanceState)
+
+ val devices by AppState . devices
+ val scenes by AppState . scenes
+ var currentScene by AppState . currentScene
+
+ for ( device in Config . devices ) AppState . putDevice (device)
+ for ( scene in Config . scenes ) AppState . putScene (scene)
+
+ enableEdgeToEdge ()
+ setContent {
+ ZanburTheme {
+ Scaffold { scaffoldPadding ->
+ val wrapperModifier = Modifier
+ .padding(scaffoldPadding)
+ .fillMaxWidth()
+ .fillMaxHeight()
+
+ Column ( wrapperModifier , Center , CenterHorizontally ) {
+ SceneSwitcher ( scenes . values . map { it . name } , currentScene ?. name ) {
+ val newScene = scenes . getValue (it)
+ currentScene = newScene
+ handleSceneChange (newScene)
+ }
+ }
+ }
+ }
+ }
+
+ MqttClient . run (lifecycleScope)
+ }
+}
+
+@Composable private fun SceneSwitcher (
+ scenes : Collection ,
+ currentScene : SceneName ? ,
+ onSwitch : (SceneName) -> Unit ,
+) = Column {
+ for ( scene in scenes ) {
+ val colors =
+ if ( scene == currentScene ) ButtonDefaults . buttonColors ()
+ else ButtonDefaults . filledTonalButtonColors ()
+
+ Button ( { onSwitch (scene) } , Modifier , true , ButtonDefaults . shape , colors ) {
+ Text ( scene . value )
+ }
+ }
+}
+
+private fun handleSceneChange ( newScene : Scene ) {
+ for ( ( device , newState ) in newScene . states ) {
+ val jsonString = when (device) {
+ is TemperatureLamp -> Json . encodeToString ( newState as TemperatureLampState )
+ is RgbLamp -> Json . encodeToString ( newState as RgbLampState )
+ else -> throw Error ("Unknown type of device state")
+ }
+ MqttClient . publish (
+ Config . MQTT_TOPIC + "/" + device . name . value + "/set" ,
+ jsonString . toByteArray () . asUByteArray () ,
+ )
+ }
+}
+
+//private fun handlePowerChange ( deviceName : String , power : Power ) {
+// when ( val device = AppState . devices . value . getValue (deviceName) ) {
+//
+// is TemperatureLamp ->
+// if ( device . currentState != null && device . currentState . power != power ) {
+// MqttClient . publish (
+// "zigbee2mqtt/$deviceName/set/state" ,
+// power . toString () . toByteArray () . asUByteArray () ,
+// )
+// AppState . putDevice ( TemperatureLamp ( deviceName , device . currentState . copy (power) ) )
+// }
+//
+// is RgbLamp ->
+// if ( device . currentState != null && device . currentState . power != power ) {
+// MqttClient . publish (
+// "zigbee2mqtt/$deviceName/set/state" ,
+// power . toString () . toByteArray () . asUByteArray () ,
+// )
+// AppState . putDevice ( RgbLamp ( deviceName , device . currentState . copy (power) ) )
+// }
+// }
+//}
diff --git a/app/src/main/java/com/kernelmaft/zanbur/mqtt.kt b/app/src/main/java/com/kernelmaft/zanbur/mqtt.kt
new file mode 100644
index 0000000..8666dbc
--- /dev/null
+++ b/app/src/main/java/com/kernelmaft/zanbur/mqtt.kt
@@ -0,0 +1,63 @@
+package com.kernelmaft.zanbur
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.launch
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import MQTTClient
+import mqtt.MQTTVersion
+import mqtt.Subscription
+import mqtt.packets.Qos.AT_MOST_ONCE
+import mqtt.packets.mqtt.MQTTPublish
+
+
+typealias MqttPublishHandler = ( MQTTPublish , Json ) -> Unit
+
+object MqttClient {
+ private var client : MQTTClient ? = null
+ private var coroutineScope : CoroutineScope ? = null
+ private val publishHandlers : MutableList = mutableListOf ()
+
+ fun run ( coroutineScope : CoroutineScope ) {
+ this . coroutineScope = coroutineScope
+ val json = Json { ignoreUnknownKeys = true }
+
+ coroutineScope . launch (IO) {
+ client = MQTTClient (
+ MQTTVersion . MQTT5 ,
+ Config . MQTT_SERVER_HOST ,
+ Config . MQTT_SERVER_PORT ,
+ null ,
+ ) {
+ for ( handler in publishHandlers ) handler ( it , json )
+ }
+ client !! . subscribe ( listOf ( Subscription ( Config . MQTT_TOPIC + "/#" ) ) )
+
+ for ( ( name , device ) in AppState . devices . value ) when (device) {
+ is TemperatureLamp -> publish (
+ Config . MQTT_TOPIC + "/" + name + "/get" ,
+ Json . encodeToString ( TemperatureLampState ( Power . OFF , 0 , 250 ) )
+ . toByteArray ()
+ . asUByteArray () ,
+ )
+ is RgbLamp -> publish (
+ Config . MQTT_TOPIC + "/" + name + "/get" ,
+ Json . encodeToString ( RgbLampState ( Power . OFF , 0 , Cie1931Color ( 0.0 , 0.0 ) ) )
+ . toByteArray ()
+ . asUByteArray () ,
+ )
+ }
+
+ client !! . run ()
+ }
+ }
+
+ fun addPublishHandler ( handler : MqttPublishHandler ) = publishHandlers . add (handler)
+
+ fun publish ( topic : String , payload : UByteArray ) {
+ coroutineScope !! . launch (IO) {
+ client !! . publish ( false , AT_MOST_ONCE , topic , payload )
+ }
+ }
+}
diff --git a/app/src/main/java/com/kernelmaft/zanbur/ontology.kt b/app/src/main/java/com/kernelmaft/zanbur/ontology.kt
new file mode 100644
index 0000000..80a2bb5
--- /dev/null
+++ b/app/src/main/java/com/kernelmaft/zanbur/ontology.kt
@@ -0,0 +1,62 @@
+package com.kernelmaft.zanbur
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+
+
+typealias Real = Double
+
+
+data class Scene (
+ val name : SceneName ,
+ val states : Map < Device , LampState > ,
+)
+
+data class SceneName ( val value : String )
+
+
+abstract class Device ( val name : DeviceName )
+
+class TemperatureLamp ( name : DeviceName ) : Device (name)
+class RgbLamp ( name : DeviceName ) : Device (name)
+
+data class DeviceName ( val value : String )
+
+
+abstract class LampState {
+ abstract val power : Power
+ abstract val brightness : Int // Range 0 to 254
+}
+
+@Serializable data class TemperatureLampState (
+ @SerialName ("state") override val power : Power ,
+ @SerialName ("brightness") override val brightness : Int ,
+ @SerialName ("color_temp") val colorTemperature : Int , // Range 250 to 454
+) : LampState ()
+
+@Serializable data class RgbLampState (
+ @SerialName ("state") override val power : Power ,
+ @SerialName ("brightness") override val brightness : Int ,
+ @SerialName ("color") val color : Cie1931Color ,
+) : LampState ()
+
+@Serializable enum class Power {
+ ON , OFF ;
+
+ fun toBoolean () : Boolean = when (this) {
+ ON -> true
+ OFF -> false
+ }
+
+ companion object {
+ fun fromBoolean ( power : Boolean ) : Power = when (power) {
+ true -> ON
+ false -> OFF
+ }
+ }
+}
+
+@Serializable data class Cie1931Color (
+ val x : Real , // Range 0.0 to 1.0 (for shrimp anyway)
+ val y : Real , // Idem
+)
diff --git a/app/src/main/java/com/kernelmaft/zanbur/theme.kt b/app/src/main/java/com/kernelmaft/zanbur/theme.kt
new file mode 100644
index 0000000..352d3af
--- /dev/null
+++ b/app/src/main/java/com/kernelmaft/zanbur/theme.kt
@@ -0,0 +1,26 @@
+package com.kernelmaft.zanbur
+
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MaterialTheme.shapes
+import androidx.compose.material3.MaterialTheme.typography
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.unit.dp
+
+
+val compactSpacing = 16 . dp
+
+@Composable fun ZanburTheme ( content : @Composable () -> Unit ) {
+ val colorScheme = run {
+ val context = LocalContext . current
+ if ( isSystemInDarkTheme () )
+ dynamicDarkColorScheme (context)
+ else
+ dynamicLightColorScheme (context)
+ }
+
+ MaterialTheme ( colorScheme, shapes , typography , content )
+}
diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000..4e12b43
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000..c6aee64
--- /dev/null
+++ b/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher.xml b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
new file mode 100644
index 0000000..52ac069
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
new file mode 100644
index 0000000..52ac069
--- /dev/null
+++ b/app/src/main/res/mipmap-anydpi/ic_launcher_round.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000..542a2e4
--- /dev/null
+++ b/app/src/main/res/values/strings.xml
@@ -0,0 +1,4 @@
+
+ Zanbur
+ Zanbur
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
new file mode 100644
index 0000000..f273fc3
--- /dev/null
+++ b/app/src/main/res/values/themes.xml
@@ -0,0 +1,7 @@
+
+
+
diff --git a/build.gradle.kts b/build.gradle.kts
new file mode 100644
index 0000000..722a1ac
--- /dev/null
+++ b/build.gradle.kts
@@ -0,0 +1,2 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+plugins {}
diff --git a/gradle.properties b/gradle.properties
new file mode 100644
index 0000000..95e0e0e
--- /dev/null
+++ b/gradle.properties
@@ -0,0 +1,25 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -XX:+UseParallelGC
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. For more details, visit
+# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
+org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Kotlin code style for this project: "official" or "obsolete":
+kotlin.code.style=official
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
+
+org.gradle.configuration-cache=true
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..e9d56bf
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,37 @@
+[versions]
+# Gradle plugins
+android-plugin = "8.5.1"
+kotlin = "2.0.0"
+# Runtime libraries
+androidx-core-ktx = "1.13.1"
+android-material = "1.12.0"
+androidx-compose-material3 = "1.2.1"
+androidx-lifecycle-runtime-ktx = "2.8.3"
+androidx-activity-compose = "1.9.0"
+androidx-compose-ui = "1.6.8"
+kotlinx-coroutines-android = "1.8.1"
+kotlinx-serialization-json = "1.7.1"
+# Other libraries
+kmqtt = "0.4.8"
+
+[libraries]
+# Runtime libraries
+android-material = { group = "com.google.android.material", name = "material", version.ref = "android-material" }
+androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "androidx-activity-compose" }
+androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "androidx-core-ktx" }
+androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "androidx-compose-material3" }
+androidx-compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "androidx-compose-ui" }
+androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics", version.ref = "androidx-compose-ui" }
+androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "androidx-compose-ui" }
+androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "androidx-lifecycle-runtime-ktx" }
+kotlinx-coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-android", version.ref = "kotlinx-coroutines-android" }
+kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinx-serialization-json" }
+# Other libraries
+kmqtt-common = { group = "io.github.davidepianca98", name = "kmqtt-common", version.ref = "kmqtt" }
+kmqtt-client = { group = "io.github.davidepianca98", name = "kmqtt-client", version.ref = "kmqtt" }
+
+[plugins]
+android-application = { id = "com.android.application", version.ref = "android-plugin" }
+kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
diff --git a/settings.gradle.kts b/settings.gradle.kts
new file mode 100644
index 0000000..3c7e692
--- /dev/null
+++ b/settings.gradle.kts
@@ -0,0 +1,23 @@
+pluginManagement {
+ repositories {
+ google {
+ content {
+ includeGroupByRegex("com\\.android.*")
+ includeGroupByRegex("com\\.google.*")
+ includeGroupByRegex("androidx.*")
+ }
+ }
+ mavenCentral()
+ gradlePluginPortal()
+ }
+}
+dependencyResolutionManagement {
+ repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
+ repositories {
+ google()
+ mavenCentral()
+ }
+}
+
+rootProject.name = "Zanbur"
+include(":app")