diff --git a/README.md b/README.md index 29ed5a21..33c93db0 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/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/EntityMappingsEditor.kt b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/EntityMappingsEditor.kt new file mode 100644 index 00000000..adaf79f3 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/EntityMappingsEditor.kt @@ -0,0 +1,322 @@ +package org.stypox.dicio.skills.homeassistant + +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.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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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 kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.stypox.dicio.R + +/** + * Editor UI for managing Home Assistant entity mappings (friendly name <-> entity ID). + * Supports adding, editing, and deleting mappings. + */ +@Composable +fun EntityMappingsEditor( + mappings: List, + baseUrl: String, + accessToken: String, + onMappingsChange: (List) -> Unit, +) { + var showDialog by remember { mutableStateOf(false) } + var editIndex by remember { mutableStateOf(-1) } + + 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 + ) + 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 + } + ) + } +} + +/** + * Dialog for adding or editing a single entity mapping. + * Includes a "Pick" button that fetches entities from Home Assistant for selection. + */ +@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 + } + ) + } +} + +/** + * Dialog that lists all Home Assistant entities with search/filter, + * allowing the user to pick one for an entity mapping. + */ +@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/HomeAssistantApi.kt b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantApi.kt new file mode 100644 index 00000000..e2ff0417 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantApi.kt @@ -0,0 +1,66 @@ +package org.stypox.dicio.skills.homeassistant + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONArray +import org.json.JSONObject +import java.io.IOException + +object HomeAssistantApi { + private val client = OkHttpClient() + private val JSON_MEDIA_TYPE = "application/json; charset=utf-8".toMediaType() + + @Throws(IOException::class) + suspend fun getAllStates(baseUrl: String, token: String): JSONArray { + val body = executeRequest( + Request.Builder() + .url("$baseUrl/api/states") + .addHeader("Authorization", "Bearer $token") + .build() + ) + return JSONArray(body) + } + + @Throws(IOException::class) + suspend fun getEntityState(baseUrl: String, token: String, entityId: String): JSONObject { + val body = executeRequest( + Request.Builder() + .url("$baseUrl/api/states/$entityId") + .addHeader("Authorization", "Bearer $token") + .build() + ) + return JSONObject(body) + } + + @Throws(IOException::class) + suspend fun callService( + baseUrl: String, + token: String, + domain: String, + service: String, + entityId: String, + extraParams: Map = emptyMap() + ): JSONArray { + val jsonBody = JSONObject().put("entity_id", entityId) + extraParams.forEach { (key, value) -> jsonBody.put(key, value) } + + val body = executeRequest( + Request.Builder() + .url("$baseUrl/api/services/$domain/$service") + .addHeader("Authorization", "Bearer $token") + .post(jsonBody.toString().toRequestBody(JSON_MEDIA_TYPE)) + .build() + ) + return JSONArray(body) + } + + private fun executeRequest(request: Request): String { + val response = client.newCall(request).execute() + return response.use { + if (!it.isSuccessful) throw IOException("HTTP ${it.code}: ${it.message}") + it.body?.string() ?: throw IOException("Empty response body") + } + } +} 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..355291d5 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantInfo.kt @@ -0,0 +1,117 @@ +package org.stypox.dicio.skills.homeassistant + +import android.content.Context +import android.util.Log +import androidx.compose.foundation.layout.Column +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Home +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.dataStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +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 + +private const val TAG = "HomeAssistantInfo" + +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 -> + Log.d(TAG, "Saving base URL: $baseUrl") + GlobalScope.launch(Dispatchers.IO) { + try { + dataStore.updateData { + Log.d(TAG, "DataStore update started") + it.toBuilder().setBaseUrl(baseUrl).build() + } + Log.d(TAG, "DataStore update completed") + } catch (e: Exception) { + Log.e(TAG, "Failed to save base URL", e) + } + } + } + ) + + StringSetting( + title = stringResource(R.string.pref_homeassistant_access_token), + ).Render( + value = data.accessToken, + onValueChange = { token -> + Log.d(TAG, "Saving access token (length: ${token.length})") + GlobalScope.launch(Dispatchers.IO) { + try { + dataStore.updateData { + Log.d(TAG, "DataStore update started") + it.toBuilder().setAccessToken(token).build() + } + Log.d(TAG, "DataStore update completed") + } catch (e: Exception) { + Log.e(TAG, "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() + } + } + }, + ) + } + } +} 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..bca12115 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantOutput.kt @@ -0,0 +1,281 @@ +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 = ctx.getString( + R.string.skill_homeassistant_source_not_found_quoted, + 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..7aff89d8 --- /dev/null +++ b/app/src/main/kotlin/org/stypox/dicio/skills/homeassistant/HomeAssistantSkill.kt @@ -0,0 +1,276 @@ +package org.stypox.dicio.skills.homeassistant + +import kotlinx.coroutines.flow.first +import org.dicio.numbers.unit.Number +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 org.stypox.dicio.util.StringUtils +import java.io.FileNotFoundException + +class HomeAssistantSkill( + correspondingSkillInfo: SkillInfo, + data: StandardRecognizerData +) : StandardRecognizerSkill(correspondingSkillInfo, data) { + + override suspend fun generateOutput(ctx: SkillContext, inputData: HomeAssistant): SkillOutput { + 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) + // Parse the action at the call site, so handleSetState receives + // a validated ParsedAction rather than a raw string. + val domain = mapping.entityId.substringBefore(".") + val action = parseAction("on", domain) + ?: return HomeAssistantOutput.InvalidAction("on", domain) + handleSetState(settings, mapping, action) + } + is HomeAssistant.SetStateOff -> { + val entityName = inputData.entityName ?: "" + val mapping = findBestMatch(entityName, settings.entityMappingsList) + ?: return HomeAssistantOutput.EntityNotMapped(entityName) + val domain = mapping.entityId.substringBefore(".") + val action = parseAction("off", domain) + ?: return HomeAssistantOutput.InvalidAction("off", domain) + handleSetState(settings, mapping, action) + } + is HomeAssistant.SetStateToggle -> { + val entityName = inputData.entityName ?: "" + val mapping = findBestMatch(entityName, settings.entityMappingsList) + ?: return HomeAssistantOutput.EntityNotMapped(entityName) + val domain = mapping.entityId.substringBefore(".") + val action = parseAction("toggle", domain) + ?: return HomeAssistantOutput.InvalidAction("toggle", domain) + handleSetState(settings, mapping, action) + } + is HomeAssistant.SelectSource -> { + val entityName = inputData.entityName ?: "" + val sourceName = inputData.sourceName ?: "" + val mapping = findBestMatch(entityName, settings.entityMappingsList) + ?: return HomeAssistantOutput.EntityNotMapped(entityName) + handleSelectSource(ctx, settings, mapping, sourceName) + } + } + // Only catch exceptions we can meaningfully handle; let unrecognized + // exceptions propagate so Dicio's infrastructure shows the error to the user. + } catch (e: FileNotFoundException) { + HomeAssistantOutput.EntityNotFound("unknown") + } catch (e: Exception) { + if (e.message?.contains("401") == true || e.message?.contains("403") == true) { + HomeAssistantOutput.AuthFailed() + } else { + throw e + } + } + } + + 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") + ) + } + + /** + * Handles state changes (on/off/toggle) for an entity. + * Accepts a pre-validated [ParsedAction] so that action parsing happens at the call site, + * keeping this method focused on the HA API call and domain-specific service mapping. + */ + private suspend fun handleSetState( + settings: SkillSettingsHomeAssistant, + mapping: EntityMapping, + parsedAction: ParsedAction + ): SkillOutput { + val domain = mapping.entityId.substringBefore(".") + + // Map generic on/off services to domain-specific equivalents + // (e.g. cover uses open_cover/close_cover, lock uses lock/unlock) + 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( + ctx: SkillContext, + 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) } + + // Use dicio-numbers to convert spoken number words to digits (e.g. "two" -> "2"). + // Note: homophone variations (e.g. "too" -> "2") are not yet supported by dicio-numbers. + val normalizedSource = normalizeNumberWords(ctx, requestedSource) + + // Fuzzy match using StringUtils.customStringDistance (Levenshtein-based) + val matchedSource = findBestSourceMatch(normalizedSource, 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 + ) + } + + /** + * Uses dicio-numbers [ParserFormatter] to convert spoken number words to their digit form. + * For example, "BBC Radio two" becomes "BBC Radio 2". + * Falls back to the original input if no parser is available for the current locale. + */ + private fun normalizeNumberWords(ctx: SkillContext, input: String): String { + val pf = ctx.parserFormatter ?: return input + val parts = pf.extractNumber(input).parseMixedWithText() + return parts.joinToString("") { part -> + when (part) { + is Number -> part.integerValue().toString() + else -> part.toString() + } + }.trim() + } + + /** + * Finds the best matching source from [available] for the [requested] source name, + * using [StringUtils.customStringDistance] (Levenshtein-based with subsequence bonuses). + * Returns null if no source is close enough. + */ + private fun findBestSourceMatch(requested: String, available: List): String? { + // Try exact match first (case-insensitive) + val normalized = requested.lowercase().trim() + available.firstOrNull { it.lowercase() == normalized }?.let { return it } + + // Use StringUtils.customStringDistance — lower is better. + // The threshold scales with input length but is capped to avoid false positives + // on short inputs matching long source names. + val maxAcceptableDistance = (normalized.length / 3).coerceAtLeast(2) + return available + .map { it to StringUtils.customStringDistance(normalized, it) } + .filter { it.second <= maxAcceptableDistance } + .minByOrNull { it.second } + ?.first + } + + 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()) + } + } + + /** + * Represents a validated action parsed from user input. + * @property service the HA service name (e.g. "turn_on", "turn_off", "toggle") + * @property spokenForm the human-readable form shown in output (e.g. "on", "off", "toggled") + */ + private data class ParsedAction(val service: String, val spokenForm: String) + + private fun parseAction(action: String, domain: String): ParsedAction? { + val normalized = action.lowercase().trim() + + 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/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..6df41668 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -248,6 +248,29 @@ 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 + \"%1$s\" + 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 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..d9461281 --- /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..f95622c7 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: medium + 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..5cf9c581 --- /dev/null +++ b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/FuzzyMatchingTest.kt @@ -0,0 +1,74 @@ +package org.stypox.dicio.skills.homeassistant + +import io.kotest.core.spec.style.StringSpec +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" + ) + + // Access private findBestSourceMatch via reflection + val skill = HomeAssistantSkill(HomeAssistantInfo, Sentences.HomeAssistant["en"]!!) + val findBestSourceMatch = skill.javaClass.getDeclaredMethod( + "findBestSourceMatch", + String::class.java, + List::class.java + ).apply { isAccessible = true } + + fun findMatch(requested: String): String? { + return findBestSourceMatch.invoke(skill, requested, sources) as String? + } + + // 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" + } + + "exact match - Heart Dorset" { + findMatch("Heart Dorset") shouldBe "Heart Dorset" + } + + // Fuzzy match tests (Levenshtein-based) + "fuzzy match - BBC Radio Solent" { + findMatch("BBC Radio Solent") shouldBe "BBC Radio Solent" + } + + "fuzzy match - Greatest Hits Dorset" { + findMatch("Greatest Hits Dorset") shouldBe "Greatest Hits Radio Dorset" + } + + // No match tests + "no match - Spotify" { + findMatch("Spotify") shouldBe null + } + + "no match - Netflix" { + findMatch("Netflix") shouldBe null + } + + "no match - Classic FM" { + findMatch("Classic FM") shouldBe null + } + + // Edge cases + "empty string returns null" { + findMatch("") shouldBe null + } +}) 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..98f6afab --- /dev/null +++ b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/NumberVariationsTest.kt @@ -0,0 +1,61 @@ +package org.stypox.dicio.skills.homeassistant + +import io.kotest.core.spec.style.StringSpec +import io.kotest.matchers.shouldBe +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 org.stypox.dicio.sentences.Sentences +import java.util.Locale + +/** + * Tests for normalizeNumberWords which uses dicio-numbers [ParserFormatter] + * to convert spoken number words to digits (e.g. "two" -> "2"). + * + * Note: homophone variations (e.g. "too" -> "2") are not supported by dicio-numbers + * and would need to be handled at the dicio-numbers level in the future. + */ +class NumberVariationsTest : StringSpec({ + + val skill = HomeAssistantSkill(HomeAssistantInfo, Sentences.HomeAssistant["en"]!!) + + // Access private normalizeNumberWords via reflection + val normalizeNumberWords = skill.javaClass.getDeclaredMethod( + "normalizeNumberWords", + SkillContext::class.java, + String::class.java + ).apply { isAccessible = true } + + // Create a SkillContext with a real ParserFormatter for English + val ctx = object : SkillContext { + override val parserFormatter = ParserFormatter(Locale.ENGLISH) + override var standardMatchHelper: MatchHelper? = null + override val android get() = throw NotImplementedError() + override val locale get() = Locale.ENGLISH + override val sentencesLanguage get() = "en" + override val speechOutputDevice: SpeechOutputDevice get() = throw NotImplementedError() + override val previousOutput: SkillOutput get() = throw NotImplementedError() + } + + fun normalize(input: String): String { + return normalizeNumberWords.invoke(skill, ctx, input) as String + } + + "number word - two becomes 2" { + normalize("BBC Radio two") shouldBe "BBC Radio 2" + } + + "number word - four becomes 4" { + normalize("BBC Radio four") shouldBe "BBC Radio 4" + } + + "no number words - unchanged" { + normalize("BBC Radio") shouldBe "BBC Radio" + } + + "number word at start" { + normalize("two BBC Radio") shouldBe "2 BBC Radio" + } +}) 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..72309105 --- /dev/null +++ b/app/src/test/kotlin/org/stypox/dicio/skills/homeassistant/TestSkillContext.kt @@ -0,0 +1,24 @@ +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 + +/** + * SkillContext for sentence scoring tests that need a [MatchHelper] initialized with input text. + * Unlike [org.stypox.dicio.MockSkillContext] (which throws on all properties), this provides + * a working [standardMatchHelper] so that [StandardRecognizerData.score] can be called. + */ +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/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