Use zigbee2mqtt scenes directly, persist last selected scene locally

This commit is contained in:
Reinout Meliesie 2024-08-01 00:36:05 +02:00
parent d129c407d3
commit ac8fa6f0c0
Signed by: zedfrigg
GPG key ID: 3AFCC06481308BC6
11 changed files with 163 additions and 182 deletions

View file

@ -1,3 +1,10 @@
import java.io.FileInputStream
import java.util.Properties
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
val keystoreProperties = Properties ()
keystoreProperties . load ( FileInputStream ( rootProject . file ("keystore.properties") ) )
plugins {
alias ( libs . plugins . android . application )
alias ( libs . plugins . kotlin . android )
@ -17,10 +24,23 @@ android {
versionName = "1.0"
}
signingConfigs {
create ("kernelmaft") {
keyAlias = "kernelmaft"
keyPassword = keystoreProperties ["keyPassword"] as String
storeFile = file ( keystoreProperties ["storeFile"] as String )
storePassword = keystoreProperties ["storePassword"] as String
}
}
buildTypes {
debug {
signingConfig = signingConfigs . getByName ("kernelmaft")
}
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = signingConfigs . getByName ("kernelmaft")
}
}
compileOptions {
@ -47,6 +67,7 @@ dependencies {
implementation ( libs . androidx . compose . ui )
implementation ( libs . androidx . compose . ui . graphics )
debugImplementation ( libs . androidx . compose . ui . tooling )
implementation ( libs . androidx . datastore . preferences )
implementation ( libs . androidx . lifecycle . runtime . ktx )
implementation ( libs . kotlinx . coroutines . android )
implementation ( libs . kotlinx . serialization . json )
@ -55,7 +76,7 @@ dependencies {
implementation ( libs . kmqtt . client )
}
tasks . withType ( org . jetbrains . kotlin . gradle . tasks . KotlinCompile :: class ) . all {
tasks . withType ( KotlinCompile :: class ) . all {
compilerOptions {
freeCompilerArgs . addAll ( "-opt-in=kotlin.ExperimentalUnsignedTypes" )
}

View file

@ -1,17 +1,19 @@
package com.kernelmaft.zanbur
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
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)
val groups : MutableState < List <Group> > = mutableStateOf ( emptyList () )
fun putDevice ( device : Device ) { devices . value += Pair ( device . name , device ) }
fun putScene ( scene : Scene ) { scenes . value += Pair ( scene . name , scene ) }
fun setCurrentScene ( groupId : Int , currentScene : Scene ) {
var groups by this . groups
groups = groups . map {
if ( it . id == groupId ) it . copy ( currentScene = currentScene )
else it
}
}
}

View file

@ -0,0 +1,27 @@
package com.kernelmaft.zanbur
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@Composable fun SceneSwitcher (
scenes : Collection <Scene> ,
currentScene : Scene ? ,
onSwitch : (Scene) -> Unit ,
) {
CenteredColumn {
for ( scene in scenes ) {
val colors =
if ( scene . id == currentScene ?. id ) ButtonDefaults . buttonColors ()
else ButtonDefaults . filledTonalButtonColors ()
Button ( { onSwitch (scene) } , Modifier . fillMaxWidth () , true , ButtonDefaults . shape , colors ) {
Text ( scene . name )
}
}
}
}

View file

@ -6,25 +6,13 @@ object Config {
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 ) ) ) ,
) ) ,
val groups = listOf (
Group ( 0 , "All lights" , listOf (
Scene ( 0 , "All off" ) ,
Scene ( 1 , "Bright, warm, purple accent" ) ,
Scene ( 2 , "Medium-bright, warm, purple accent" ) ,
Scene ( 3 , "Dim, warm, purple accent" ) ,
Scene ( 4 , "Medium-bright, cold, no accent" ) ,
) )
)
}

View file

@ -1,25 +1,25 @@
package com.kernelmaft.zanbur
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
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.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.foundation.layout.width
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.compose.ui.unit.dp
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.lifecycle.lifecycleScope
@ -27,27 +27,35 @@ 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
var groups by AppState . groups
groups = Config . groups
for ( device in Config . devices ) AppState . putDevice (device)
for ( scene in Config . scenes ) AppState . putScene (scene)
lifecycleScope . launch (IO) {
val prefs = applicationContext . dataStore . data . firstOrNull ()
if ( prefs != null ) {
val savedSceneName = prefs [ stringPreferencesKey ("scene") ]
if ( savedSceneName != null ) {
val savedScene = groups [0] . scenes . find { it . name == savedSceneName }
if ( savedScene != null ) {
AppState . setCurrentScene ( 0 , savedScene )
}
}
}
}
enableEdgeToEdge ()
setContent {
ZanburTheme {
Scaffold { scaffoldPadding ->
val wrapperModifier = Modifier
.padding(scaffoldPadding)
.fillMaxWidth()
.fillMaxHeight()
CenteredColumn ( Modifier . padding (scaffoldPadding) . fillMaxSize () ) {
Column ( wrapperModifier , Center , CenterHorizontally ) {
SceneSwitcher ( scenes . values . map { it . name } , currentScene ?. name ) {
val newScene = scenes . getValue (it)
currentScene = newScene
handleSceneChange (newScene)
CenteredColumn ( Modifier . width ( 300 . dp ) ) {
Text (groups [0] . name)
Spacer ( Modifier . height ( compactSpacing ) )
SceneSwitcher ( groups [0] . scenes , groups [0] . currentScene ) {
AppState . setCurrentScene ( 0 , it )
publishSceneChange ( groups [0] , it )
}
}
}
}
@ -56,57 +64,17 @@ class MainActivity : ComponentActivity () {
MqttClient . run (lifecycleScope)
}
}
@Composable private fun SceneSwitcher (
scenes : Collection <SceneName> ,
currentScene : SceneName ? ,
onSwitch : (SceneName) -> Unit ,
) = Column {
for ( scene in scenes ) {
val colors =
if ( scene == currentScene ) ButtonDefaults . buttonColors ()
else ButtonDefaults . filledTonalButtonColors ()
override fun onStop () {
super . onStop ()
Button ( { onSwitch (scene) } , Modifier , true , ButtonDefaults . shape , colors ) {
Text ( scene . value )
val groups by AppState . groups
val currentScene = groups [0] . currentScene
if ( currentScene != null ) lifecycleScope . launch (IO) {
applicationContext . dataStore . edit {
it [ stringPreferencesKey ("scene") ] = currentScene . name
}
}
}
}
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) ) )
// }
// }
//}

View file

@ -3,6 +3,8 @@ package com.kernelmaft.zanbur
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers.IO
import kotlinx.coroutines.launch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import MQTTClient
@ -24,31 +26,11 @@ object MqttClient {
val json = Json { ignoreUnknownKeys = true }
coroutineScope . launch (IO) {
client = MQTTClient (
MQTTVersion . MQTT5 ,
Config . MQTT_SERVER_HOST ,
Config . MQTT_SERVER_PORT ,
null ,
) {
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 ()
}
}

View file

@ -1,62 +1,14 @@
package com.kernelmaft.zanbur
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
typealias Real = Double
data class Group (
val id : Int ,
val name : String ,
val scenes : Collection <Scene> ,
val currentScene : Scene ? = null ,
)
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
val id : Int ,
val name : String ,
)

View file

@ -0,0 +1,9 @@
package com.kernelmaft.zanbur
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.preferencesDataStore
val Context. dataStore : DataStore<Preferences> by preferencesDataStore ("state")

View file

@ -1,12 +1,17 @@
package com.kernelmaft.zanbur
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Arrangement.Center
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
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.Alignment.Companion.CenterHorizontally
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
@ -24,3 +29,9 @@ val compactSpacing = 16 . dp
MaterialTheme ( colorScheme, shapes , typography , content )
}
@Composable fun CenteredColumn (
modifier : Modifier = Modifier ,
content : @Composable ColumnScope . () -> Unit ,
) =
Column ( modifier , Center , CenterHorizontally , content )

View file

@ -0,0 +1,19 @@
package com.kernelmaft.zanbur
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
fun publishSceneChange ( group : Group , newScene : Scene ) {
val topic = Config . MQTT_TOPIC + "/" + group . name + "/set"
val packet = Json . encodeToString ( SceneRecallPacket ( newScene . id ) )
. toByteArray ()
. asUByteArray ()
MqttClient.publish ( topic , packet )
}
@Serializable private data class SceneRecallPacket (
@SerialName ("scene_recall") val sceneRecall : Int ,
)

View file

@ -3,12 +3,13 @@
android-plugin = "8.5.1"
kotlin = "2.0.0"
# Runtime libraries
androidx-core-ktx = "1.13.1"
android-material = "1.12.0"
androidx-activity-compose = "1.9.1"
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"
androidx-core-ktx = "1.13.1"
androidx-datastore-preferences = "1.1.1"
androidx-lifecycle-runtime-ktx = "2.8.4"
kotlinx-coroutines-android = "1.8.1"
kotlinx-serialization-json = "1.7.1"
# Other libraries
@ -23,6 +24,7 @@ androidx-compose-material3 = { group = "androidx.compose.material3", name = "mat
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-datastore-preferences = { group = "androidx.datastore" , name = "datastore-preferences" , version.ref = "androidx-datastore-preferences" }
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" }