diff --git a/.gitignore b/.gitignore index d46a1c4b..0313cb2d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ fastlane/report.xml fastlane/README.md fastlane_config.txt + +# Kiro specs (local only) +.kiro/ diff --git a/README.md b/README.md index 54b75fee..d633fedd 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Currently Dicio answers questions about: - **media**: play, pause, previous, next song - **translation**: translate from/to any language with **Lingva** - _How do I say Football in German?_ - **wake word control**: turn on/off the wakeword - _Stop listening_ +- **home assistant**: query and control **Home Assistant** entities - _Turn living room light on_, _Turn kitchen radio to BBC Radio 2_ + - Note: Media source selection with number homophones (e.g., "too" → "2") is currently English-only - **notifications**: reads all notifications currently in the status bar - _What are my notifications?_ - **flashlight**: turn on/off the phone flashlight - _Turn on the flashlight_ diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3cf851e9..cfa125ff 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -198,6 +198,9 @@ dependencies { // Used by skills implementation(libs.exp4j) + + // YAML processing + implementation("org.yaml:snakeyaml:2.0") // Testing testImplementation(libs.kotest.runner.junit5) diff --git a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt index 5c7c7f88..15a5957f 100644 --- a/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt +++ b/app/src/main/kotlin/org/stypox/dicio/eval/SkillHandler.kt @@ -20,6 +20,7 @@ import org.stypox.dicio.settings.datastore.UserSettingsModule import org.stypox.dicio.skills.calculator.CalculatorInfo import org.stypox.dicio.skills.current_time.CurrentTimeInfo import org.stypox.dicio.skills.fallback.text.TextFallbackInfo +import org.stypox.dicio.skills.homeassistant.HomeAssistantInfo import org.stypox.dicio.skills.listening.ListeningInfo import org.stypox.dicio.skills.lyrics.LyricsInfo import org.stypox.dicio.skills.media.MediaInfo @@ -57,6 +58,7 @@ class SkillHandler @Inject constructor( JokeInfo, ListeningInfo(dataStore), TranslationInfo, + HomeAssistantInfo, NotifyInfo, FlashlightInfo, ) diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantApi.kt b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantApi.kt new file mode 100644 index 00000000..18cecd08 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantApi.kt @@ -0,0 +1,63 @@ +package org.stypox.dicio.skills.homeassistant + +import org.json.JSONArray +import org.json.JSONObject +import org.stypox.dicio.util.ConnectionUtils +import java.io.IOException +import java.net.HttpURLConnection +import java.net.URL + +object HomeAssistantApi { + @Throws(IOException::class) + suspend fun getAllStates(baseUrl: String, token: String): JSONArray { + val connection = URL("$baseUrl/api/states").openConnection() as HttpURLConnection + connection.setRequestProperty("Authorization", "Bearer $token") + connection.setRequestProperty("Content-Type", "application/json") + + val scanner = java.util.Scanner(connection.inputStream) + val response = scanner.useDelimiter("\\A").next() + scanner.close() + + return JSONArray(response) + } + + @Throws(IOException::class) + suspend fun getEntityState(baseUrl: String, token: String, entityId: String): JSONObject { + val connection = URL("$baseUrl/api/states/$entityId").openConnection() as HttpURLConnection + connection.setRequestProperty("Authorization", "Bearer $token") + connection.setRequestProperty("Content-Type", "application/json") + + val scanner = java.util.Scanner(connection.inputStream) + val response = scanner.useDelimiter("\\A").next() + scanner.close() + + return JSONObject(response) + } + + @Throws(IOException::class) + suspend fun callService( + baseUrl: String, + token: String, + domain: String, + service: String, + entityId: String, + extraParams: Map = emptyMap() + ): JSONArray { + val connection = URL("$baseUrl/api/services/$domain/$service").openConnection() as HttpURLConnection + connection.requestMethod = "POST" + connection.setRequestProperty("Authorization", "Bearer $token") + connection.setRequestProperty("Content-Type", "application/json") + connection.doOutput = true + + val body = JSONObject().put("entity_id", entityId) + extraParams.forEach { (key, value) -> body.put(key, value) } + + connection.outputStream.write(body.toString().toByteArray()) + + val scanner = java.util.Scanner(connection.inputStream) + val response = scanner.useDelimiter("\\A").next() + scanner.close() + + return JSONArray(response) + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantInfo.kt b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantInfo.kt new file mode 100644 index 00000000..17efc7e3 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantInfo.kt @@ -0,0 +1,497 @@ +package org.stypox.dicio.skills.homeassistant + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.FileDownload +import androidx.compose.material.icons.filled.FileUpload +import androidx.compose.material.icons.filled.Home +import androidx.compose.material3.Card +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.Skill +import org.dicio.skill.skill.SkillInfo +import org.stypox.dicio.R +import org.stypox.dicio.sentences.Sentences +import org.stypox.dicio.settings.ui.StringSetting + +object HomeAssistantInfo : SkillInfo("home_assistant") { + override fun name(context: Context) = + context.getString(R.string.skill_name_home_assistant) + + override fun sentenceExample(context: Context) = + context.getString(R.string.skill_sentence_example_home_assistant) + + @Composable + override fun icon() = + rememberVectorPainter(Icons.Default.Home) + + override fun isAvailable(ctx: SkillContext): Boolean { + return Sentences.HomeAssistant[ctx.sentencesLanguage] != null + } + + override fun build(ctx: SkillContext): Skill<*> { + return HomeAssistantSkill(HomeAssistantInfo, Sentences.HomeAssistant[ctx.sentencesLanguage]!!) + } + + internal val Context.homeAssistantDataStore by dataStore( + fileName = "skill_settings_home_assistant.pb", + serializer = SkillSettingsHomeAssistantSerializer, + corruptionHandler = ReplaceFileCorruptionHandler { + SkillSettingsHomeAssistantSerializer.defaultValue + } + ) + + override val renderSettings: @Composable () -> Unit get() = @Composable { + val context = LocalContext.current + val dataStore = context.homeAssistantDataStore + val data by dataStore.data.collectAsState(SkillSettingsHomeAssistantSerializer.defaultValue) + val scope = rememberCoroutineScope() + + Column { + StringSetting( + title = stringResource(R.string.pref_homeassistant_base_url), + ).Render( + value = data.baseUrl, + onValueChange = { baseUrl -> + android.util.Log.d("HomeAssistant", "Saving base URL: $baseUrl") + kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { + try { + dataStore.updateData { + android.util.Log.d("HomeAssistant", "DataStore update started") + it.toBuilder().setBaseUrl(baseUrl).build() + } + android.util.Log.d("HomeAssistant", "DataStore update completed") + } catch (e: Exception) { + android.util.Log.e("HomeAssistant", "Failed to save base URL", e) + } + } + } + ) + + StringSetting( + title = stringResource(R.string.pref_homeassistant_access_token), + ).Render( + value = data.accessToken, + onValueChange = { token -> + android.util.Log.d("HomeAssistant", "Saving access token (length: ${token.length})") + kotlinx.coroutines.GlobalScope.launch(Dispatchers.IO) { + try { + dataStore.updateData { + android.util.Log.d("HomeAssistant", "DataStore update started") + it.toBuilder().setAccessToken(token).build() + } + android.util.Log.d("HomeAssistant", "DataStore update completed") + } catch (e: Exception) { + android.util.Log.e("HomeAssistant", "Failed to save access token", e) + } + } + } + ) + + EntityMappingsEditor( + mappings = data.entityMappingsList, + baseUrl = data.baseUrl, + accessToken = data.accessToken, + onMappingsChange = { mappings -> + scope.launch { + dataStore.updateData { + it.toBuilder().clearEntityMappings().addAllEntityMappings(mappings).build() + } + } + }, + onExport = { baseUrl, accessToken, mappings -> + scope.launch { + try { + val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply { + addCategory(Intent.CATEGORY_OPENABLE) + type = "text/yaml" + putExtra(Intent.EXTRA_TITLE, "home_assistant_config.yaml") + } + // Note: In a real implementation, you'd need to handle the file creation + // through an activity result launcher + } catch (e: Exception) { + e.printStackTrace() + } + } + }, + onImport = { config -> + scope.launch { + dataStore.updateData { currentData -> + val newMappings = config.entityMappings.map { yamlMapping -> + EntityMapping.newBuilder() + .setFriendlyName(yamlMapping.friendlyName) + .setEntityId(yamlMapping.entityId) + .build() + } + + currentData.toBuilder() + .setBaseUrl(config.baseUrl) + .setAccessToken(config.accessToken) + .clearEntityMappings() + .addAllEntityMappings(newMappings) + .build() + } + } + } + ) + } + } +} + +@Composable +fun EntityMappingsEditor( + mappings: List, + baseUrl: String, + accessToken: String, + onMappingsChange: (List) -> Unit, + onExport: (String, String, List) -> Unit, + onImport: (HomeAssistantYamlUtils.YamlHomeAssistantConfig) -> Unit +) { + var showDialog by remember { mutableStateOf(false) } + var editIndex by remember { mutableStateOf(-1) } + val context = LocalContext.current + + val exportLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.CreateDocument("text/yaml") + ) { uri -> + uri?.let { + try { + context.contentResolver.openOutputStream(it)?.use { outputStream -> + HomeAssistantYamlUtils.exportToYaml(baseUrl, accessToken, mappings, outputStream) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + val importLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.OpenDocument() + ) { uri -> + uri?.let { + try { + context.contentResolver.openInputStream(it)?.use { inputStream -> + val config = HomeAssistantYamlUtils.importFromYaml(inputStream) + onImport(config) + } + } catch (e: Exception) { + e.printStackTrace() + } + } + } + + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = stringResource(R.string.pref_homeassistant_entity_mappings), + style = MaterialTheme.typography.titleMedium + ) + Row { + IconButton(onClick = { + importLauncher.launch(arrayOf("text/yaml", "text/plain", "*/*")) + }) { + Icon(Icons.Default.FileUpload, contentDescription = "Import YAML") + } + IconButton(onClick = { + exportLauncher.launch("home_assistant_config.yaml") + }) { + Icon(Icons.Default.FileDownload, contentDescription = "Export YAML") + } + IconButton(onClick = { + editIndex = -1 + showDialog = true + }) { + Icon(Icons.Default.Add, contentDescription = stringResource(R.string.pref_homeassistant_add_mapping)) + } + } + } + + mappings.forEachIndexed { index, mapping -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp) + .clickable { + editIndex = index + showDialog = true + } + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = mapping.friendlyName, + style = MaterialTheme.typography.bodyLarge + ) + Text( + text = mapping.entityId, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = { + onMappingsChange(mappings.filterIndexed { i, _ -> i != index }) + }) { + Icon(Icons.Default.Delete, contentDescription = "Delete") + } + } + } + } + } + + if (showDialog) { + EntityMappingDialog( + baseUrl = baseUrl, + accessToken = accessToken, + initialMapping = if (editIndex >= 0) mappings[editIndex] else null, + onDismiss = { showDialog = false }, + onSave = { friendlyName, entityId -> + val newMapping = EntityMapping.newBuilder() + .setFriendlyName(friendlyName) + .setEntityId(entityId) + .build() + + val newMappings = if (editIndex >= 0) { + mappings.toMutableList().apply { set(editIndex, newMapping) } + } else { + mappings + newMapping + } + onMappingsChange(newMappings) + showDialog = false + } + ) + } +} + +@Composable +fun EntityMappingDialog( + baseUrl: String, + accessToken: String, + initialMapping: EntityMapping?, + onDismiss: () -> Unit, + onSave: (String, String) -> Unit +) { + var friendlyName by remember { mutableStateOf(initialMapping?.friendlyName ?: "") } + var entityId by remember { mutableStateOf(initialMapping?.entityId ?: "") } + var showEntityPicker by remember { mutableStateOf(false) } + var entities by remember { mutableStateOf>>(emptyList()) } + var isLoading by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = if (initialMapping == null) + stringResource(R.string.pref_homeassistant_add_mapping) + else + "Edit Mapping", + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp) + ) + + TextField( + value = friendlyName, + onValueChange = { friendlyName = it }, + label = { Text(stringResource(R.string.pref_homeassistant_friendly_name)) }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + TextField( + value = entityId, + onValueChange = { entityId = it }, + label = { Text(stringResource(R.string.pref_homeassistant_entity_id)) }, + modifier = Modifier.weight(1f) + ) + TextButton( + onClick = { + if (baseUrl.isNotBlank() && accessToken.isNotBlank()) { + isLoading = true + scope.launch { + try { + val states = withContext(Dispatchers.IO) { + HomeAssistantApi.getAllStates(baseUrl, accessToken) + } + entities = (0 until states.length()).map { i -> + val entity = states.getJSONObject(i) + val id = entity.getString("entity_id") + val name = entity.getJSONObject("attributes") + .optString("friendly_name", id) + id to name + }.sortedBy { it.first } + showEntityPicker = true + } catch (e: Exception) { + e.printStackTrace() + } finally { + isLoading = false + } + } + } + }, + enabled = !isLoading && baseUrl.isNotBlank() && accessToken.isNotBlank() + ) { + Text("Pick") + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(android.R.string.cancel)) + } + TextButton( + onClick = { onSave(friendlyName, entityId) }, + enabled = friendlyName.isNotBlank() && entityId.isNotBlank() + ) { + Text(stringResource(android.R.string.ok)) + } + } + } + } + } + + if (showEntityPicker) { + EntityPickerDialog( + entities = entities, + onDismiss = { showEntityPicker = false }, + onSelect = { selectedId, selectedName -> + entityId = selectedId + if (friendlyName.isBlank()) { + friendlyName = selectedName + } + showEntityPicker = false + } + ) + } +} + +@Composable +fun EntityPickerDialog( + entities: List>, + onDismiss: () -> Unit, + onSelect: (String, String) -> Unit +) { + var searchQuery by remember { mutableStateOf("") } + val filteredEntities = remember(entities, searchQuery) { + if (searchQuery.isBlank()) { + entities + } else { + entities.filter { (id, name) -> + id.contains(searchQuery, ignoreCase = true) || + name.contains(searchQuery, ignoreCase = true) + } + } + } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.extraLarge + ) { + Column(modifier = Modifier.padding(16.dp)) { + Text( + text = "Select Entity", + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp) + ) + + TextField( + value = searchQuery, + onValueChange = { searchQuery = it }, + label = { Text("Search") }, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + singleLine = true + ) + + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f, false) + ) { + items(filteredEntities) { (id, name) -> + Card( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .clickable { onSelect(id, name) } + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text(text = name, style = MaterialTheme.typography.bodyLarge) + Text( + text = id, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } + } + + TextButton( + onClick = onDismiss, + modifier = Modifier.align(Alignment.End).padding(top = 8.dp) + ) { + Text(stringResource(android.R.string.cancel)) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantOutput.kt b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantOutput.kt new file mode 100644 index 00000000..de5cbd49 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantOutput.kt @@ -0,0 +1,278 @@ +package org.stypox.dicio.skills.homeassistant + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillOutput +import org.json.JSONObject +import org.stypox.dicio.R +import org.stypox.dicio.io.graphical.HeadlineSpeechSkillOutput +import org.stypox.dicio.util.getString + +sealed interface HomeAssistantOutput : SkillOutput { + data class GetStatusSuccess( + val entityId: String, + val friendlyName: String, + val state: String, + val attributes: JSONObject? + ) : HomeAssistantOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_entity_state, + friendlyName, + state + ) + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = friendlyName, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = ctx.getString(R.string.skill_homeassistant_entity_state, "", state), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + data class SetStateSuccess( + val entityId: String, + val friendlyName: String, + val action: String + ) : HomeAssistantOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_set_success, + friendlyName, + action + ) + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = friendlyName, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = ctx.getString(R.string.skill_homeassistant_set_success, "", action), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + data class EntityNotMapped( + val entityName: String + ) : HomeAssistantOutput, HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_entity_not_mapped, + entityName + ) + } + + data class EntityNotFound( + val entityId: String + ) : HomeAssistantOutput, HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_entity_not_found, + entityId + ) + } + + data class InvalidAction( + val action: String, + val entityType: String + ) : HomeAssistantOutput, HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_invalid_action, + action, + entityType + ) + } + + class ConnectionFailed : HomeAssistantOutput, HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_connection_failed + ) + } + + class AuthFailed : HomeAssistantOutput, HeadlineSpeechSkillOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_auth_failed + ) + } + + data class SelectSourceSuccess( + val entityId: String, + val friendlyName: String, + val sourceName: String + ) : HomeAssistantOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_select_source_success, + sourceName, + friendlyName + ) + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = friendlyName, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = ctx.getString( + R.string.skill_homeassistant_select_source_success, + sourceName, + friendlyName + ), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + data class NoSourceList( + val friendlyName: String + ) : HomeAssistantOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_no_source_list, + friendlyName + ) + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = friendlyName, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = ctx.getString( + R.string.skill_homeassistant_no_source_list, + "" + ).trim(), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + data class SourceNotFound( + val requestedSource: String, + val friendlyName: String + ) : HomeAssistantOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_source_not_found, + requestedSource, + friendlyName + ) + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = friendlyName, + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = ctx.getString(R.string.skill_homeassistant_source_not_found_short), + style = MaterialTheme.typography.bodyLarge, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth() + ) + Text( + text = "\"$requestedSource\"", + style = MaterialTheme.typography.bodyMedium, + textAlign = TextAlign.Center, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.fillMaxWidth() + ) + } + } + } + + class HelpResponse : HomeAssistantOutput { + override fun getSpeechOutput(ctx: SkillContext): String = ctx.getString( + R.string.skill_homeassistant_help_speech + ) + + @Composable + override fun GraphicalOutput(ctx: SkillContext) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + ) { + Text( + text = ctx.getString(R.string.skill_homeassistant_help_title), + style = MaterialTheme.typography.headlineMedium, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = ctx.getString(R.string.skill_homeassistant_help_content), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.fillMaxWidth() + ) + } + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantSkill.kt b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantSkill.kt new file mode 100644 index 00000000..0f88d7e4 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantSkill.kt @@ -0,0 +1,292 @@ +package org.stypox.dicio.skills.homeassistant + +import kotlinx.coroutines.flow.first +import org.dicio.skill.context.SkillContext +import org.dicio.skill.skill.SkillInfo +import org.dicio.skill.skill.SkillOutput +import org.dicio.skill.standard.StandardRecognizerData +import org.dicio.skill.standard.StandardRecognizerSkill +import org.stypox.dicio.sentences.Sentences.HomeAssistant +import org.stypox.dicio.skills.homeassistant.HomeAssistantInfo.homeAssistantDataStore +import java.io.FileNotFoundException + +class HomeAssistantSkill( + correspondingSkillInfo: SkillInfo, + data: StandardRecognizerData +) : StandardRecognizerSkill(correspondingSkillInfo, data) { + + override suspend fun generateOutput(ctx: SkillContext, inputData: HomeAssistant): SkillOutput { + android.util.Log.d("HomeAssistantSkill", "generateOutput called with inputData: $inputData") + val settings = ctx.android.homeAssistantDataStore.data.first() + + return try { + when (inputData) { + is HomeAssistant.GetHelp -> { + handleGetHelp() + } + is HomeAssistant.GetStatus -> { + val entityName = inputData.entityName ?: "" + val mapping = findBestMatch(entityName, settings.entityMappingsList) + ?: return HomeAssistantOutput.EntityNotMapped(entityName) + handleGetStatus(settings, mapping) + } + is HomeAssistant.GetPersonLocation -> { + val personName = inputData.personName?.trim() ?: "" + val mapping = findBestMatch(personName, settings.entityMappingsList) + ?: return HomeAssistantOutput.EntityNotMapped(personName) + handleGetStatus(settings, mapping) + } + is HomeAssistant.SetStateOn -> { + val entityName = inputData.entityName ?: "" + val mapping = findBestMatch(entityName, settings.entityMappingsList) + ?: return HomeAssistantOutput.EntityNotMapped(entityName) + handleSetState(settings, mapping, "on") + } + is HomeAssistant.SetStateOff -> { + val entityName = inputData.entityName ?: "" + val mapping = findBestMatch(entityName, settings.entityMappingsList) + ?: return HomeAssistantOutput.EntityNotMapped(entityName) + handleSetState(settings, mapping, "off") + } + is HomeAssistant.SetStateToggle -> { + val entityName = inputData.entityName ?: "" + val mapping = findBestMatch(entityName, settings.entityMappingsList) + ?: return HomeAssistantOutput.EntityNotMapped(entityName) + handleSetState(settings, mapping, "toggle") + } + is HomeAssistant.SelectSource -> { + val entityName = inputData.entityName ?: "" + val sourceName = inputData.sourceName ?: "" + val mapping = findBestMatch(entityName, settings.entityMappingsList) + ?: return HomeAssistantOutput.EntityNotMapped(entityName) + handleSelectSource(settings, mapping, sourceName) + } + } + } catch (e: FileNotFoundException) { + HomeAssistantOutput.EntityNotFound("unknown") + } catch (e: Exception) { + if (e.message?.contains("401") == true || e.message?.contains("403") == true) { + HomeAssistantOutput.AuthFailed() + } else { + HomeAssistantOutput.ConnectionFailed() + } + } + } + + private fun handleGetHelp(): SkillOutput { + return HomeAssistantOutput.HelpResponse() + } + + private suspend fun handleGetStatus( + settings: SkillSettingsHomeAssistant, + mapping: EntityMapping + ): SkillOutput { + val state = HomeAssistantApi.getEntityState( + settings.baseUrl, + settings.accessToken, + mapping.entityId + ) + + return HomeAssistantOutput.GetStatusSuccess( + entityId = mapping.entityId, + friendlyName = mapping.friendlyName, + state = state.getString("state"), + attributes = state.optJSONObject("attributes") + ) + } + + private suspend fun handleSetState( + settings: SkillSettingsHomeAssistant, + mapping: EntityMapping, + action: String + ): SkillOutput { + android.util.Log.d("HomeAssistantSkill", "handleSetState - action: '$action', entityId: '${mapping.entityId}'") + val domain = mapping.entityId.substringBefore(".") + android.util.Log.d("HomeAssistantSkill", "Domain: '$domain'") + val parsedAction = parseAction(action, domain) + if (parsedAction == null) { + android.util.Log.e("HomeAssistantSkill", "Failed to parse action: '$action'") + return HomeAssistantOutput.InvalidAction(action.ifEmpty { "" }, domain) + } + android.util.Log.d("HomeAssistantSkill", "Parsed action: service='${parsedAction.service}', spokenForm='${parsedAction.spokenForm}'") + + val service = when (domain) { + "cover" -> when (parsedAction.service) { + "turn_on" -> "open_cover" + "turn_off" -> "close_cover" + else -> parsedAction.service + } + "lock" -> when (parsedAction.service) { + "turn_on" -> "unlock" + "turn_off" -> "lock" + else -> parsedAction.service + } + else -> parsedAction.service + } + + HomeAssistantApi.callService( + settings.baseUrl, + settings.accessToken, + domain, + service, + mapping.entityId + ) + + return HomeAssistantOutput.SetStateSuccess( + entityId = mapping.entityId, + friendlyName = mapping.friendlyName, + action = parsedAction.spokenForm + ) + } + + private suspend fun handleSelectSource( + settings: SkillSettingsHomeAssistant, + mapping: EntityMapping, + requestedSource: String + ): SkillOutput { + // Get entity state to retrieve source_list + val state = HomeAssistantApi.getEntityState( + settings.baseUrl, + settings.accessToken, + mapping.entityId + ) + + // Extract source_list attribute + val attributes = state.optJSONObject("attributes") + val sourceListJson = attributes?.optJSONArray("source_list") + + if (sourceListJson == null || sourceListJson.length() == 0) { + return HomeAssistantOutput.NoSourceList(mapping.friendlyName) + } + + // Convert to list + val sourceList = (0 until sourceListJson.length()) + .map { sourceListJson.getString(it) } + + // Fuzzy match requested source + val matchedSource = findBestSourceMatch(requestedSource, sourceList) + ?: return HomeAssistantOutput.SourceNotFound( + requestedSource, + mapping.friendlyName + ) + + // Call select_source service + HomeAssistantApi.callService( + settings.baseUrl, + settings.accessToken, + "media_player", + "select_source", + mapping.entityId, + mapOf("source" to matchedSource) + ) + + return HomeAssistantOutput.SelectSourceSuccess( + entityId = mapping.entityId, + friendlyName = mapping.friendlyName, + sourceName = matchedSource + ) + } + + private fun generateNumberVariations(input: String): List { + // Map number words to their digit and homophone variations + val numberMappings = mapOf( + "one" to listOf("1", "won"), + "two" to listOf("2", "to", "too"), + "three" to listOf("3"), + "four" to listOf("4", "for", "fore"), + "five" to listOf("5"), + "six" to listOf("6"), + "seven" to listOf("7"), + "eight" to listOf("8", "ate"), + "nine" to listOf("9"), + "ten" to listOf("10") + ) + + // Build reverse map: homophone -> (number word, all variations) + val reverseMap = mutableMapOf>>() + for ((word, variations) in numberMappings) { + reverseMap[word] = word to (listOf(word) + variations) + for (variation in variations) { + reverseMap[variation] = word to (listOf(word) + variations) + } + } + + val variations = mutableListOf(input) + + // Find all number words or homophones in input + for ((trigger, pair) in reverseMap) { + val regex = Regex("\\b$trigger\\b", RegexOption.IGNORE_CASE) + if (regex.containsMatchIn(input)) { + val (_, allVariations) = pair + for (replacement in allVariations) { + if (replacement.lowercase() != trigger.lowercase()) { + variations.add(regex.replace(input, replacement)) + } + } + } + } + + return variations.distinct() + } + + private fun findBestSourceMatch(requested: String, available: List): String? { + val variations = generateNumberVariations(requested) + + // 1. Try exact match with each variation + for (variation in variations) { + val normalized = variation.lowercase().trim() + available.firstOrNull { it.lowercase() == normalized }?.let { return it } + } + + // 2. Fuzzy match with all variations (skip contains - too greedy for short words) + val allMatches = variations.flatMap { variation -> + val normalized = variation.lowercase().trim() + available.mapIndexed { index, source -> + Triple(source, calculateSimilarity(normalized, source.lowercase()), index) + } + } + + val scored = allMatches.filter { it.second >= 0.4 } + + // Prefer higher similarity, then shorter match, then earlier in list + return scored.maxWithOrNull( + compareBy({ it.second }, { -it.first.length }, { -it.third }) + )?.first + } + + private fun calculateSimilarity(s1: String, s2: String): Double { + val words1 = s1.split(Regex("\\s+")).toSet() + val words2 = s2.split(Regex("\\s+")).toSet() + val intersection = words1.intersect(words2).size + val union = words1.union(words2).size + return if (union > 0) intersection.toDouble() / union else 0.0 + } + + private fun findBestMatch(spokenName: String, mappings: List): EntityMapping? { + val normalized = spokenName.lowercase().replace(Regex("\\b(the|a|an)\\b"), "").trim() + + mappings.firstOrNull { it.friendlyName.lowercase() == normalized }?.let { return it } + + return mappings.firstOrNull { + it.friendlyName.lowercase().contains(normalized) || + normalized.contains(it.friendlyName.lowercase()) + } + } + + private data class ParsedAction(val service: String, val spokenForm: String) + + private fun parseAction(action: String, domain: String): ParsedAction? { + val normalized = action.lowercase().trim() + android.util.Log.d("HomeAssistantSkill", "parseAction - input: '$action', normalized: '$normalized'") + + return when { + normalized.contains("on") || normalized in listOf("open", "unlock", "enable") -> + ParsedAction("turn_on", "on") + normalized.contains("off") || normalized in listOf("close", "lock", "disable") -> + ParsedAction("turn_off", "off") + normalized.contains("toggle") -> + ParsedAction("toggle", "toggled") + else -> null + } + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantYamlUtils.kt b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantYamlUtils.kt new file mode 100644 index 00000000..2dccee36 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantYamlUtils.kt @@ -0,0 +1,65 @@ +package org.stypox.dicio.skills.homeassistant + +import org.yaml.snakeyaml.DumperOptions +import org.yaml.snakeyaml.Yaml +import java.io.InputStream +import java.io.OutputStream + +object HomeAssistantYamlUtils { + + data class YamlEntityMapping( + val friendlyName: String, + val entityId: String + ) + + data class YamlHomeAssistantConfig( + val baseUrl: String = "", + val accessToken: String = "", + val entityMappings: List = emptyList() + ) + + fun exportToYaml( + baseUrl: String, + accessToken: String, + mappings: List, + outputStream: OutputStream + ) { + val config = YamlHomeAssistantConfig( + baseUrl = baseUrl, + accessToken = accessToken, + entityMappings = mappings.map { + YamlEntityMapping(it.friendlyName, it.entityId) + } + ) + + val options = DumperOptions().apply { + defaultFlowStyle = DumperOptions.FlowStyle.BLOCK + isPrettyFlow = true + } + + val yaml = Yaml(options) + outputStream.writer().use { writer -> + yaml.dump(mapOf("homeAssistant" to config), writer) + } + } + + fun importFromYaml(inputStream: InputStream): YamlHomeAssistantConfig { + val yaml = Yaml() + val data = yaml.load>(inputStream) + val haConfig = data["homeAssistant"] as? Map ?: return YamlHomeAssistantConfig() + + val baseUrl = haConfig["baseUrl"] as? String ?: "" + val accessToken = haConfig["accessToken"] as? String ?: "" + val mappingsData = haConfig["entityMappings"] as? List> ?: emptyList() + + val mappings = mappingsData.mapNotNull { mapping -> + val friendlyName = mapping["friendlyName"] as? String + val entityId = mapping["entityId"] as? String + if (friendlyName != null && entityId != null) { + YamlEntityMapping(friendlyName, entityId) + } else null + } + + return YamlHomeAssistantConfig(baseUrl, accessToken, mappings) + } +} diff --git a/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/SkillSettingsHomeAssistantSerializer.kt b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/SkillSettingsHomeAssistantSerializer.kt new file mode 100644 index 00000000..b05b79da --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/SkillSettingsHomeAssistantSerializer.kt @@ -0,0 +1,23 @@ +package org.stypox.dicio.skills.homeassistant + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream + +object SkillSettingsHomeAssistantSerializer : Serializer { + override val defaultValue: SkillSettingsHomeAssistant = SkillSettingsHomeAssistant.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): SkillSettingsHomeAssistant { + try { + return SkillSettingsHomeAssistant.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto", exception) + } + } + + override suspend fun writeTo(t: SkillSettingsHomeAssistant, output: OutputStream) { + t.writeTo(output) + } +} diff --git a/app/src/main/proto/skill_settings_home_assistant.proto b/app/src/main/proto/skill_settings_home_assistant.proto new file mode 100644 index 00000000..20da545a --- /dev/null +++ b/app/src/main/proto/skill_settings_home_assistant.proto @@ -0,0 +1,15 @@ +syntax = "proto3"; + +option java_package = "org.stypox.dicio.skills.homeassistant"; +option java_multiple_files = true; + +message SkillSettingsHomeAssistant { + string base_url = 1; + string access_token = 2; + repeated EntityMapping entity_mappings = 3; +} + +message EntityMapping { + string friendly_name = 1; + string entity_id = 2; +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ea8db666..15385ef9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -248,6 +248,30 @@ Failed to copy to clipboard Auto DuckDuckGo did not provide results, asking for a Captcha to be solved + Home Assistant + Turn living room light on + %1$s is %2$s + %1$s turned %2$s + Cannot %1$s a %2$s + I don\'t have a mapping for %1$s + Entity %1$s not found in Home Assistant + Could not connect to Home Assistant + Home Assistant authentication failed + Playing %1$s on %2$s + %1$s does not have any available sources + Could not find source %1$s on %2$s + Could not find source + Home Assistant Commands + Here are the Home Assistant commands you can use + Status Queries:\n• "What is the living room light status?"\n• "Check the front door lock"\n• "Get status of bedroom temperature"\n\nPerson Location:\n• "Where is the person Dylan?"\n• "What is Dylan\'s location?"\n\nControl Commands:\n• "Turn living room light on"\n• "Switch bedroom fan off"\n• "Toggle kitchen light"\n\nSupported Entities:\n• Lights (light.*)\n• Switches (switch.*)\n• Covers (cover.*) - open/close\n• Locks (lock.*) - lock/unlock\n• Fans (fan.*)\n• Climate (climate.*)\n• Person tracking (person.*)\n\nSetup Required:\nConfigure Home Assistant URL and access token in Dicio settings before using these commands. + Home Assistant URL + Access Token + Entity Mappings + Add Mapping + Friendly Name + Entity ID + Export YAML + Import YAML The notification says %1$s. The %1$s notification says %2$s. , diff --git a/app/src/main/sentences/en/home_assistant.yml b/app/src/main/sentences/en/home_assistant.yml new file mode 100644 index 00000000..5910314b --- /dev/null +++ b/app/src/main/sentences/en/home_assistant.yml @@ -0,0 +1,26 @@ +get_status: + - (get|what is|whats|check) (the )?status (of|for) .entity_name. + - (get|what is|whats|check) (the )?.entity_name. + +get_person_location: + - (whats|what is) .person_name. location + - (where is|wheres) the person .person_name. + +set_state_on: + - (turn|switch) (the )?.entity_name. on + +set_state_off: + - (turn|switch) (the )?.entity_name. off + +set_state_toggle: + - (turn|switch) (the )?.entity_name. toggle + +select_source: + - (turn|switch|set|tune|change) (the )?.entity_name. to .source_name. + - (tune|set) (the )?.entity_name. on .source_name. + +get_help: + - home assistant help + - (help|how) (with|for) home assistant + - what can (i|you) do with home assistant + - home assistant commands diff --git a/app/src/main/sentences/skill_definitions.yml b/app/src/main/sentences/skill_definitions.yml index ed347e96..060ad0aa 100644 --- a/app/src/main/sentences/skill_definitions.yml +++ b/app/src/main/sentences/skill_definitions.yml @@ -134,3 +134,34 @@ skills: type: string - id: target type: string + + - id: home_assistant + specificity: high + sentences: + - id: get_status + captures: + - id: entity_name + type: string + - id: get_person_location + captures: + - id: person_name + type: string + - id: set_state_on + captures: + - id: entity_name + type: string + - id: set_state_off + captures: + - id: entity_name + type: string + - id: set_state_toggle + captures: + - id: entity_name + type: string + - id: select_source + captures: + - id: entity_name + type: string + - id: source_name + type: string + - id: get_help diff --git a/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/FuzzyMatchingTest.kt b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/FuzzyMatchingTest.kt new file mode 100644 index 00000000..1b69a4ce --- /dev/null +++ b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/FuzzyMatchingTest.kt @@ -0,0 +1,195 @@ +package org.stypox.dicio.skills.homeassistant + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.doubles.shouldBeGreaterThan +import io.kotest.matchers.doubles.shouldBeLessThan +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import org.stypox.dicio.sentences.Sentences + +class FuzzyMatchingTest : StringSpec({ + // Real source list from kitchen_radio_2 + val sources = listOf( + "Greatest Hits Radio Dorset", + "Magic 100% Christmas", + "BBC Radio Solent", + "Heart Dorset", + "chillout CROOZE", + "Virgin Radio", + "BBC Radio 4", + "BBC Radio 2" + ) + + // Helper to access private methods via reflection + val skill = HomeAssistantSkill(HomeAssistantInfo, Sentences.HomeAssistant["en"]!!) + val findBestSourceMatch = skill.javaClass.getDeclaredMethod( + "findBestSourceMatch", + String::class.java, + List::class.java + ).apply { isAccessible = true } + + val calculateSimilarity = skill.javaClass.getDeclaredMethod( + "calculateSimilarity", + String::class.java, + String::class.java + ).apply { isAccessible = true } + + fun findMatch(requested: String): String? { + return findBestSourceMatch.invoke(skill, requested, sources) as String? + } + + fun similarity(s1: String, s2: String): Double { + return calculateSimilarity.invoke(skill, s1, s2) as Double + } + + // Exact match tests + "exact match - case insensitive" { + findMatch("BBC Radio 2") shouldBe "BBC Radio 2" + findMatch("bbc radio 2") shouldBe "BBC Radio 2" + findMatch("BBC RADIO 2") shouldBe "BBC Radio 2" + } + + "exact match - Virgin Radio" { + findMatch("Virgin Radio") shouldBe "Virgin Radio" + findMatch("virgin radio") shouldBe "Virgin Radio" + } + + "exact match - Heart Dorset" { + findMatch("Heart Dorset") shouldBe "Heart Dorset" + } + + // Partial match tests + "partial match - Radio 2" { + findMatch("Radio 2") shouldBe "BBC Radio 2" + } + + "partial match - Radio 4" { + findMatch("Radio 4") shouldBe "BBC Radio 4" + } + + "partial match - Virgin" { + findMatch("Virgin") shouldBe "Virgin Radio" + } + + "partial match - Heart" { + findMatch("Heart") shouldBe "Heart Dorset" + } + + "partial match - Greatest Hits" { + findMatch("Greatest Hits") shouldBe "Greatest Hits Radio Dorset" + } + + "partial match - Magic Christmas" { + findMatch("Magic Christmas") shouldBe "Magic 100% Christmas" + } + + "partial match - Solent (fuzzy)" { + val result = findMatch("Solent") + // Single word has low similarity (1/3 = 0.33) - may not match + // This is acceptable behavior for very short queries + result shouldBe null // Below 0.4 threshold + } + + "partial match - CROOZE (fuzzy)" { + findMatch("CROOZE") shouldBe "chillout CROOZE" // Via fuzzy word match (1/2 = 0.5) + } + + // Fuzzy match tests + "fuzzy match - Greatest Hits Dorset" { + findMatch("Greatest Hits Dorset") shouldBe "Greatest Hits Radio Dorset" + } + + "fuzzy match - BBC Radio Solent" { + findMatch("BBC Radio Solent") shouldBe "BBC Radio Solent" + } + + // Ambiguous cases (fuzzy matching returns best word overlap) + "ambiguous - Radio (fuzzy)" { + val result = findMatch("Radio") + result shouldNotBe null // Should match via fuzzy (1 word overlap) + } + + "ambiguous - BBC (fuzzy)" { + val result = findMatch("BBC") + // Single word "BBC" vs "BBC Radio X" = 1/3 = 0.33, below threshold + // This is acceptable - very short queries are ambiguous + result shouldBe null + } + + "ambiguous - Dorset (fuzzy)" { + val result = findMatch("Dorset") + result shouldNotBe null // Should match via fuzzy (1 word overlap) + } + + // No match tests + "no match - Spotify" { + findMatch("Spotify") shouldBe null + } + + "no match - Netflix" { + findMatch("Netflix") shouldBe null + } + + "no match - Radio 1" { + findMatch("Radio 1") shouldBe null + } + + "no match - Classic FM" { + findMatch("Classic FM") shouldBe null + } + + // Edge case - empty string has no word overlap + "empty string returns null" { + val result = findMatch("") + result shouldBe null // No words = no fuzzy match + } + + // Homophone variation tests + "homophone - 'too' finds 'BBC Radio 2' not 'BBC Radio 4'" { + findMatch("BBC Radio too") shouldBe "BBC Radio 2" + } + + "homophone - 'to' finds 'BBC Radio 2'" { + findMatch("BBC Radio to") shouldBe "BBC Radio 2" + } + + "homophone - 'for' finds 'BBC Radio 4'" { + findMatch("BBC Radio for") shouldBe "BBC Radio 4" + } + + "homophone - 'fore' finds 'BBC Radio 4'" { + findMatch("BBC Radio fore") shouldBe "BBC Radio 4" + } + + "homophone - partial match with 'too'" { + findMatch("Radio too") shouldBe "BBC Radio 2" + } + + "homophone - case insensitive 'Too'" { + findMatch("BBC Radio Too") shouldBe "BBC Radio 2" + } + + // Similarity calculation tests + "similarity - identical strings" { + similarity("bbc radio 2", "bbc radio 2") shouldBe 1.0 + } + + "similarity - no common words" { + similarity("bbc radio 2", "spotify") shouldBe 0.0 + } + + "similarity - partial overlap" { + val sim = similarity("bbc radio 2", "bbc radio 4") + sim shouldBe 0.5 // 2/4 words match (bbc, radio) = 0.5 + } + + "similarity - subset" { + val sim = similarity("radio 2", "bbc radio 2") + sim shouldBeGreaterThan 0.6 + } + + "similarity - Magic Christmas vs Magic 100% Christmas" { + val sim = similarity("magic christmas", "magic 100% christmas") + sim shouldBeGreaterThan 0.5 // 2/3 words match + } +}) diff --git a/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantSkillTest.kt b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantSkillTest.kt new file mode 100644 index 00000000..dee20353 --- /dev/null +++ b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantSkillTest.kt @@ -0,0 +1,253 @@ +package org.stypox.dicio.skills.homeassistant + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.stypox.dicio.sentences.Sentences + +class HomeAssistantSkillTest : StringSpec({ + "parse 'turn outside lights off'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn outside lights off" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateOff + setState.entityName?.trim() shouldBe "outside lights" + } + + "parse 'turn the kitchen light on'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn the kitchen light on" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateOn + setState.entityName?.trim() shouldBe "kitchen light" + } + + "parse 'switch bedroom lamp off'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "switch bedroom lamp off" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateOff + setState.entityName?.trim() shouldBe "bedroom lamp" + } + + "parse 'get status of living room light'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "get status of living room light" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getStatus = inputData as Sentences.HomeAssistant.GetStatus + getStatus.entityName?.trim() shouldBe "living room light" + } + + "parse 'what is the status for garage door'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "what is the status for garage door" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getStatus = inputData as Sentences.HomeAssistant.GetStatus + getStatus.entityName?.trim() shouldBe "garage door" + } + + "parse 'check downstairs hallway lights'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "check downstairs hallway lights" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getStatus = inputData as Sentences.HomeAssistant.GetStatus + getStatus.entityName?.trim() shouldBe "downstairs hallway lights" + } + + "parse 'check the downstairs hallway lights'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "check the downstairs hallway lights" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getStatus = inputData as Sentences.HomeAssistant.GetStatus + getStatus.entityName?.trim() shouldBe "downstairs hallway lights" + } + + "parse 'whats the status of bedroom light'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "whats the status of bedroom light" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getStatus = inputData as Sentences.HomeAssistant.GetStatus + getStatus.entityName?.trim() shouldBe "bedroom light" + } + + "parse 'get front door'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "get front door" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getStatus = inputData as Sentences.HomeAssistant.GetStatus + getStatus.entityName?.trim() shouldBe "front door" + } + + "parse 'what is porch light'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "what is porch light" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getStatus = inputData as Sentences.HomeAssistant.GetStatus + getStatus.entityName?.trim() shouldBe "porch light" + } + + "parse 'switch the living room light on'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "switch the living room light on" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateOn + setState.entityName?.trim() shouldBe "living room light" + } + + "parse 'turn garage door off'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn garage door off" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateOff + setState.entityName?.trim() shouldBe "garage door" + } + + "parse 'switch the fan off'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "switch the fan off" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateOff + setState.entityName?.trim() shouldBe "fan" + } + + "parse 'turn office light toggle'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn office light toggle" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateToggle + setState.entityName?.trim() shouldBe "office light" + } + + "parse 'switch the basement lights toggle'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "switch the basement lights toggle" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateToggle + setState.entityName?.trim() shouldBe "basement lights" + } + + "parse 'where is the person Mark'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "where is the person Mark" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getLocation = inputData as Sentences.HomeAssistant.GetPersonLocation + getLocation.personName?.trim() shouldBe "Mark" + } + + "parse 'wheres the person Sarah'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "wheres the person Sarah" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getLocation = inputData as Sentences.HomeAssistant.GetPersonLocation + getLocation.personName?.trim() shouldBe "Sarah" + } + + "parse 'whats John location'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "whats John location" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getLocation = inputData as Sentences.HomeAssistant.GetPersonLocation + getLocation.personName?.trim() shouldBe "John" + } + + "parse 'what is Emily location'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "what is Emily location" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val getLocation = inputData as Sentences.HomeAssistant.GetPersonLocation + getLocation.personName?.trim() shouldBe "Emily" + } + + // Select Source sentence recognition tests + "parse 'turn kitchen radio to BBC Radio 2'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn kitchen radio to BBC Radio 2" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "kitchen radio" + selectSource.sourceName?.trim() shouldBe "BBC Radio 2" + } + + "parse 'set kitchen radio on Virgin Radio'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "set kitchen radio on Virgin Radio" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "kitchen radio" + selectSource.sourceName?.trim() shouldBe "Virgin Radio" + } + + "parse 'tune the bedroom speaker to Heart Dorset'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "tune the bedroom speaker to Heart Dorset" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "bedroom speaker" + selectSource.sourceName?.trim() shouldBe "Heart Dorset" + } + + "parse 'change living room tv to HDMI 1'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "change living room tv to HDMI 1" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "living room tv" + selectSource.sourceName?.trim() shouldBe "HDMI 1" + } + + "does not conflict with set_state_on" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn kitchen radio on" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateOn + setState.entityName?.trim() shouldBe "kitchen radio" + } +}) diff --git a/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/NumberVariationsTest.kt b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/NumberVariationsTest.kt new file mode 100644 index 00000000..59af1947 --- /dev/null +++ b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/NumberVariationsTest.kt @@ -0,0 +1,89 @@ +package org.stypox.dicio.skills.homeassistant + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.collections.shouldHaveSize +import io.kotest.matchers.ints.shouldBeGreaterThan +import io.kotest.matchers.shouldBe +import org.stypox.dicio.sentences.Sentences + +class NumberVariationsTest : StringSpec({ + + val skill = HomeAssistantSkill(HomeAssistantInfo, Sentences.HomeAssistant["en"]!!) + + // Use reflection to access private method + val generateNumberVariations = skill.javaClass.getDeclaredMethod( + "generateNumberVariations", + String::class.java + ).apply { isAccessible = true } + + fun generate(input: String): List { + @Suppress("UNCHECKED_CAST") + return generateNumberVariations.invoke(skill, input) as List + } + + "single number word - two" { + val result = generate("BBC Radio two") + result shouldContain "BBC Radio two" + result shouldContain "BBC Radio 2" + result shouldContain "BBC Radio to" + result shouldContain "BBC Radio too" + result shouldHaveSize 4 + } + + "single number word - four" { + val result = generate("BBC Radio four") + result shouldContain "BBC Radio four" + result shouldContain "BBC Radio 4" + result shouldContain "BBC Radio for" + result shouldContain "BBC Radio fore" + result shouldHaveSize 4 + } + + "single number word - eight" { + val result = generate("Radio eight") + result shouldContain "Radio eight" + result shouldContain "Radio 8" + result shouldContain "Radio ate" + result shouldHaveSize 3 + } + + "no number words" { + val result = generate("BBC Radio") + result shouldBe listOf("BBC Radio") + } + + "multiple numbers" { + val result = generate("one two") + result shouldContain "one two" + result shouldContain "1 two" + result shouldContain "won two" + result shouldContain "one 2" + result shouldContain "one to" + result shouldContain "one too" + result.size shouldBeGreaterThan 5 + } + + "case insensitive" { + val result = generate("BBC Radio Two") + result shouldContain "BBC Radio Two" + result shouldContain "BBC Radio 2" + result shouldContain "BBC Radio to" + result shouldContain "BBC Radio too" + } + + "number at start" { + val result = generate("two BBC Radio") + result shouldContain "2 BBC Radio" + result shouldContain "to BBC Radio" + result shouldContain "too BBC Radio" + } + + "all number words have variations" { + val numbers = listOf("one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten") + for (number in numbers) { + val result = generate("Radio $number") + result.size shouldBeGreaterThan 1 + } + } +}) diff --git a/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/SelectSourceIntegrationTest.kt b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/SelectSourceIntegrationTest.kt new file mode 100644 index 00000000..787c1676 --- /dev/null +++ b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/SelectSourceIntegrationTest.kt @@ -0,0 +1,167 @@ +package org.stypox.dicio.skills.homeassistant + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.stypox.dicio.sentences.Sentences + +/** + * Integration tests for SelectSource feature. + * Focuses on sentence recognition and pattern matching. + */ +class SelectSourceIntegrationTest : StringSpec({ + + "sentence recognition - turn to pattern" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn kitchen radio to BBC Radio 2" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "kitchen radio" + selectSource.sourceName?.trim() shouldBe "BBC Radio 2" + } + + "sentence recognition - set on pattern" { + val data = Sentences.HomeAssistant["en"]!! + val input = "set kitchen radio on Virgin Radio" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "kitchen radio" + selectSource.sourceName?.trim() shouldBe "Virgin Radio" + } + + "sentence recognition - tune to pattern" { + val data = Sentences.HomeAssistant["en"]!! + val input = "tune bedroom speaker to Heart Dorset" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "bedroom speaker" + selectSource.sourceName?.trim() shouldBe "Heart Dorset" + } + + "sentence recognition - change to pattern" { + val data = Sentences.HomeAssistant["en"]!! + val input = "change living room tv to HDMI 1" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "living room tv" + selectSource.sourceName?.trim() shouldBe "HDMI 1" + } + + "sentence recognition - switch to pattern" { + val data = Sentences.HomeAssistant["en"]!! + val input = "switch office stereo to Spotify" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "office stereo" + selectSource.sourceName?.trim() shouldBe "Spotify" + } + + "sentence recognition - with the article" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn the kitchen radio to BBC Radio 2" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "kitchen radio" + selectSource.sourceName?.trim() shouldBe "BBC Radio 2" + } + + "sentence recognition - tune on pattern" { + val data = Sentences.HomeAssistant["en"]!! + val input = "tune kitchen radio on BBC Radio 4" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "kitchen radio" + selectSource.sourceName?.trim() shouldBe "BBC Radio 4" + } + + "sentence recognition - set on pattern with the" { + val data = Sentences.HomeAssistant["en"]!! + val input = "set the bedroom speaker on Virgin Radio" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "bedroom speaker" + selectSource.sourceName?.trim() shouldBe "Virgin Radio" + } + + "sentence recognition - multi-word entity and source" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn living room smart speaker to Greatest Hits Radio Dorset" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "living room smart speaker" + selectSource.sourceName?.trim() shouldBe "Greatest Hits Radio Dorset" + } + + "sentence recognition - source with special characters" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn kitchen radio to Magic 100% Christmas" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "kitchen radio" + selectSource.sourceName?.trim() shouldBe "Magic 100% Christmas" + } + + "sentence recognition - homophone 'too' instead of '2'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn kitchen radio to BBC Radio too" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "kitchen radio" + selectSource.sourceName?.trim() shouldBe "BBC Radio too" + } + + "sentence recognition - homophone 'for' instead of '4'" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn kitchen radio to BBC Radio for" + val (score, inputData) = data.score(TestSkillContext(input), input) + + inputData.shouldBeInstanceOf() + val selectSource = inputData as Sentences.HomeAssistant.SelectSource + selectSource.entityName?.trim() shouldBe "kitchen radio" + selectSource.sourceName?.trim() shouldBe "BBC Radio for" + } + + "sentence recognition - does not conflict with set_state_on" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn kitchen radio on" + val (score, inputData) = data.score(TestSkillContext(input), input) + + // Should match set_state_on, not select_source + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateOn + setState.entityName?.trim() shouldBe "kitchen radio" + } + + "sentence recognition - does not conflict with set_state_off" { + val data = Sentences.HomeAssistant["en"]!! + val input = "turn kitchen radio off" + val (score, inputData) = data.score(TestSkillContext(input), input) + + // Should match set_state_off, not select_source + inputData.shouldBeInstanceOf() + val setState = inputData as Sentences.HomeAssistant.SetStateOff + setState.entityName?.trim() shouldBe "kitchen radio" + } +}) diff --git a/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/TestSkillContext.kt b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/TestSkillContext.kt new file mode 100644 index 00000000..16edd0ce --- /dev/null +++ b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/TestSkillContext.kt @@ -0,0 +1,19 @@ +package org.stypox.dicio.skills.homeassistant + +import android.content.Context +import org.dicio.numbers.ParserFormatter +import org.dicio.skill.context.SkillContext +import org.dicio.skill.context.SpeechOutputDevice +import org.dicio.skill.skill.SkillOutput +import org.dicio.skill.standard.util.MatchHelper +import java.util.Locale + +class TestSkillContext(input: String) : SkillContext { + override var standardMatchHelper: MatchHelper? = MatchHelper(null, input) + override val parserFormatter: ParserFormatter? = null + override val android: Context get() = throw NotImplementedError() + override val locale: Locale get() = throw NotImplementedError() + override val sentencesLanguage: String get() = throw NotImplementedError() + override val speechOutputDevice: SpeechOutputDevice get() = throw NotImplementedError() + override val previousOutput: SkillOutput get() = throw NotImplementedError() +} diff --git a/docs/skills/home-assistant.md b/docs/skills/home-assistant.md new file mode 100644 index 00000000..51bad246 --- /dev/null +++ b/docs/skills/home-assistant.md @@ -0,0 +1,258 @@ +# Dicio Setup Guide + +This guide provides detailed setup instructions for skills that require additional configuration. + +## Table of Contents + +- [Home Assistant Skill](#home-assistant-skill) + +--- + +## Home Assistant Skill + +The Home Assistant skill allows you to control and query your Home Assistant entities using voice commands. + +### Prerequisites + +- A running Home Assistant instance (local network or remote) +- Access to the Home Assistant web interface +- Home Assistant version 2021.1 or later (for REST API support) + +### Step 1: Get Your Long-Lived Access Token + +1. Open your Home Assistant web interface (e.g., `http://192.168.1.100:8123`) +2. Click on your **profile** icon in the bottom left corner +3. Scroll down to the **"Long-Lived Access Tokens"** section +4. Click **"Create Token"** +5. Give it a name (e.g., "Dicio Voice Assistant") +6. **Copy the token immediately** - you won't be able to see it again! +7. Store it securely + +### Step 2: Find Your Entity IDs + +You need to know the entity IDs of the devices you want to control: + +1. In Home Assistant, go to **Developer Tools** (wrench icon in sidebar) +2. Click on the **"States"** tab +3. Browse or search for your entities +4. Note down the **Entity ID** (e.g., `light.living_room`, `switch.coffee_maker`, `person.john`) + +Common entity ID patterns: +- Lights: `light.kitchen`, `light.bedroom_lamp` +- Switches: `switch.fan`, `switch.tv` +- Covers: `cover.garage_door`, `cover.blinds` +- Locks: `lock.front_door` +- Persons: `person.mark`, `person.sarah` + +### Step 3: Configure Dicio + +1. Open **Dicio** on your Android device +2. Go to **Settings** (gear icon) +3. Tap on **Skills** +4. Find and tap on **Home Assistant** +5. Enter your configuration: + - **Base URL**: Your Home Assistant URL (e.g., `http://192.168.1.100:8123`) + - Use `http://` for local network + - Use `https://` for remote access (recommended for security) + - **Access Token**: Paste the Long-Lived Access Token you created + +### Step 4: Add Entity Mappings + +Entity mappings connect the friendly names you say to the actual Home Assistant entity IDs. + +1. In the Home Assistant skill settings, tap **"Add Entity Mapping"** +2. For each device you want to control: + - **Friendly Name**: What you'll say (e.g., "living room light", "Mark") + - **Entity ID**: The Home Assistant entity ID (e.g., `light.living_room`, `person.mark`) + +#### Example Mappings: + +| Friendly Name | Entity ID | Example Command | +|--------------|-----------|-----------------| +| living room light | light.living_room | "Turn living room light on" | +| kitchen light | light.kitchen | "Turn the kitchen light off" | +| bedroom lamp | light.bedroom_lamp | "Switch bedroom lamp on" | +| garage door | cover.garage_door | "Get status of garage door" | +| front door | lock.front_door | "Check front door" | +| Mark | person.mark | "Where is the person Mark" | +| Sarah | person.sarah | "What is Sarah location" | + +### Step 5: Test Your Setup + +Try these example commands: + +**Controlling Lights:** +- "Turn living room light on" +- "Turn the kitchen light off" +- "Switch bedroom lamp on" + +**Checking Status:** +- "Get status of garage door" +- "Check front door" +- "What is living room light" + +**Person Location:** +- "Where is the person Mark" +- "What is Sarah location" + +### Supported Commands + +#### Turn Entities On/Off/Toggle + +**Patterns:** +- `turn [the] on/off/toggle` +- `switch [the] on/off/toggle` + +**Examples:** +- "Turn outside lights off" +- "Turn the kitchen light on" +- "Switch bedroom lamp off" +- "Turn office light toggle" + +#### Check Entity Status + +**Patterns:** +- `get status of ` +- `what is [the] status for ` +- `check [the] ` +- `get [the] ` + +**Examples:** +- "Get status of living room light" +- "What is the status for garage door" +- "Check downstairs hallway lights" +- "Get front door" + +#### Person Location + +**Patterns:** +- `where is the person ` +- `what is location` + +**Examples:** +- "Where is the person Mark" +- "What is Sarah location" + +### Supported Entity Types + +The skill works with any Home Assistant entity that supports the following services: + +- **Lights** (`light.*`): turn_on, turn_off, toggle +- **Switches** (`switch.*`): turn_on, turn_off, toggle +- **Covers** (`cover.*`): open_cover, close_cover, toggle +- **Locks** (`lock.*`): lock, unlock +- **Fans** (`fan.*`): turn_on, turn_off, toggle +- **Media Players** (`media_player.*`): turn_on, turn_off, toggle +- **Persons** (`person.*`): Get location/zone information +- **Any other entity**: Get status + +### Troubleshooting + +#### "Connection Failed" + +**Possible causes:** +- Home Assistant is not running +- Wrong Base URL +- Network connectivity issues +- Firewall blocking the connection + +**Solutions:** +1. Verify Home Assistant is accessible by opening the URL in a browser +2. Check that you're on the same network (for local URLs) +3. Try using the IP address instead of hostname +4. Ensure port 8123 is not blocked + +#### "Authentication Failed" + +**Possible causes:** +- Invalid or expired access token +- Token was copied incorrectly + +**Solutions:** +1. Create a new Long-Lived Access Token +2. Make sure you copied the entire token (no spaces or line breaks) +3. Delete and re-enter the token in Dicio settings + +#### "Entity Not Mapped" + +**Possible causes:** +- No entity mapping exists for the spoken name +- Spoken name doesn't match any friendly name + +**Solutions:** +1. Add an entity mapping for the device +2. Try using the exact friendly name you configured +3. Check for typos in your entity mappings + +#### "Entity Not Found" + +**Possible causes:** +- Entity ID doesn't exist in Home Assistant +- Entity was deleted or renamed + +**Solutions:** +1. Verify the entity ID exists in Home Assistant (Developer Tools > States) +2. Update the entity mapping with the correct entity ID +3. Check for typos in the entity ID + +#### "Invalid Action" + +**Possible causes:** +- The entity doesn't support the requested action +- Wrong domain for the action + +**Solutions:** +1. Check that the entity supports the action (e.g., locks don't support "toggle") +2. Use appropriate commands for the entity type +3. Verify the entity is working in Home Assistant + +### Security Considerations + +- **Use HTTPS**: When accessing Home Assistant remotely, always use HTTPS to encrypt your access token +- **Token Storage**: Dicio stores your access token securely on your device +- **Network Security**: For local access, ensure your home network is secure +- **Token Rotation**: Periodically create new access tokens and revoke old ones +- **Limited Scope**: Consider creating a separate Home Assistant user with limited permissions for Dicio + +### Advanced Configuration + +#### Using Home Assistant Cloud (Nabu Casa) + +If you use Home Assistant Cloud, you can use your remote URL: + +- **Base URL**: `https://your-instance.ui.nabu.casa` +- **Access Token**: Your Long-Lived Access Token + +#### Using DuckDNS or Other Dynamic DNS + +If you have a dynamic DNS setup: + +- **Base URL**: `https://your-domain.duckdns.org:8123` +- Ensure your router forwards port 8123 +- Use HTTPS with a valid SSL certificate + +#### Custom Port + +If Home Assistant runs on a custom port: + +- **Base URL**: `http://192.168.1.100:8124` (replace 8124 with your port) + +### Privacy + +The Home Assistant skill: +- Processes all commands **on-device** (speech recognition) +- Only sends API requests to **your Home Assistant instance** +- Does **not** send any data to third-party servers +- Stores your access token **locally** on your device + +### Getting Help + +If you encounter issues: + +1. Check the [Home Assistant REST API documentation](https://developers.home-assistant.io/docs/api/rest/) +2. Open an issue on [GitHub](https://github.com/Stypox/dicio-android/issues) +3. Join the [Matrix room](https://matrix.to/#/#dicio:matrix.org) for community support + +--- + +*Last updated: December 2024* diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt index 0542a894..f50b1a70 100644 --- a/fastlane/metadata/android/en-US/full_description.txt +++ b/fastlane/metadata/android/en-US/full_description.txt @@ -15,6 +15,7 @@ Dicio answers questions about:
  • media: play, pause, previous, next song - Next Song
  • translation: translate from/to any language with Lingva - How do I say Football in German?
  • wake word control: turn on/off the wakeword - Stop listening
  • +
  • home assistant: query and control Home Assistant entities - Turn living room light on
  • notifications: reads all notifications currently in the status bar - What are my notifications?
  • flashlight: turn on/off the phone's flashlight - Turn on the light