diff --git a/app/src/androidTest/kotlin/com/loosecannon/servicetag/seasonsync/SeasonSyncScreensTest.kt b/app/src/androidTest/kotlin/com/loosecannon/servicetag/seasonsync/SeasonSyncScreensTest.kt index f0ee3c92..59d16bfc 100644 --- a/app/src/androidTest/kotlin/com/loosecannon/servicetag/seasonsync/SeasonSyncScreensTest.kt +++ b/app/src/androidTest/kotlin/com/loosecannon/servicetag/seasonsync/SeasonSyncScreensTest.kt @@ -81,6 +81,12 @@ import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_FOLLOWS_HA import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_FORCE_IN import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_FORCE_OUT import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_LINK +import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_SCOPE_LINE +import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_REFRESH +import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_LINK_MANUAL +import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_ENTER_MANUALLY +import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_CHOOSE_ENTITY +import com.loosecannon.servicetag.ui.asset.CANCEL_BUTTON import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_LINK_CALENDAR import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_LINK_YEAR_ROUND import com.loosecannon.servicetag.ui.asset.SEASON_SYNC_NOT_CHECKED_IN_TIME @@ -356,8 +362,9 @@ class SeasonSyncScreensTest { // --- The setup sheet ------------------------------------------------------------------------------------------ /** - * Pins: the sheet opened by Link draws the mode's sentence (P16-44) above an enabled Save before anything is - * written, and a strands refusal draws S55 inside the sheet, which stays open. Targets: P16-44, Save, S55. + * Pins: the sheet opened by Link draws the mode's sentence (P16-44) above Save before anything is written — Save + * held until an entity is given (#105 row 16), then enabled — and a strands refusal draws S55 inside the sheet, + * which stays open. Targets: P16-44, Save, S55. */ @Test fun theSetupSheetShowsItsSentenceBeforeSaveAndKeepsS55Open() { connect() @@ -369,12 +376,12 @@ class SeasonSyncScreensTest { rule.awaitText(SEASON_SYNC_LINK_CALENDAR) rule.onNodeWithText(SEASON_SYNC_LINK_CALENDAR).performScrollTo().assertIsDisplayed() - rule.onNodeWithText(SAVE_LABEL).performScrollTo().assertIsEnabled() + rule.onNodeWithText(SAVE_LABEL).performScrollTo().assertIsNotEnabled() assertTrue("the sentence comes before Save", top(SEASON_SYNC_LINK_CALENDAR) < top(SAVE_LABEL)) assertNull("nothing is written before Save", binding(heater)) typeEntityId() - rule.onNodeWithText(SAVE_LABEL).performScrollTo().performClick() + rule.onNodeWithText(SAVE_LABEL).performScrollTo().assertIsEnabled().performClick() val strands = seasonStrands(listOf(PRE_SEASON_TITLE)) rule.awaitText(strands) @@ -385,6 +392,58 @@ class SeasonSyncScreensTest { assertNull("a refusal writes nothing", binding(heater)) } + /** + * #105 row 18 (B3): the setup sheet opens on the Choose entity row — no typed field — and Enter entity ID manually + * brings the field back, after which typing and Save link exactly as before. + */ + @Test fun theSetupSheetOffersChooseEntityAndManualEntryBringsTheFieldBack() { + connect() + val heater = heater(MANUAL_OUT) + detail(heater, sheet = SeasonSyncSheetPurpose.LINK) + rule.awaitText(SEASON_SYNC_LINK) + rule.onNodeWithText(SEASON_SYNC_LINK).performScrollTo().performClick() + + rule.awaitText(SEASON_SYNC_CHOOSE_ENTITY) + rule.onAllNodes(hasSetTextAction() and hasText(SEASON_SYNC_ENTITY_ID)).assertCountEquals(0) + rule.onNodeWithText(SEASON_SYNC_ENTER_MANUALLY).performScrollTo().performClick() + rule.onNode(hasSetTextAction() and hasText(SEASON_SYNC_ENTITY_ID)).assertExists() + + typeEntityId() + rule.onNodeWithText(SAVE_LABEL).performScrollTo().performClick() + + rule.waitUntil(TIMEOUT_MS) { binding(heater) != null } + assertEquals(ENTITY_ID, binding(heater)!!.entityId) + } + + /** + * #105 row 19 (B3): Choose entity opens the browser in the sheet's place — its title, its scope line, Refresh and + * the manual path — and Cancel returns to the form with nothing chosen and nothing written. The rows, the + * sentences and the pick need a Home Assistant to answer, which the emulator has none of: those are the JVM's + * (`LinkSeasonSyncViewModelTest`); the read this tap starts is cancelled when the browser closes. + */ + @Test fun chooseEntityOpensTheBrowserAndCancelReturnsToTheForm() { + connect() + val heater = heater(MANUAL_OUT) + detail(heater, sheet = SeasonSyncSheetPurpose.LINK) + rule.awaitText(SEASON_SYNC_LINK) + rule.onNodeWithText(SEASON_SYNC_LINK).performScrollTo().performClick() + rule.awaitText(SEASON_SYNC_CHOOSE_ENTITY) + rule.awaitText(SEASON_SYNC_LINK_MANUAL) + + rule.onNodeWithText(SEASON_SYNC_CHOOSE_ENTITY).performScrollTo().performClick() + + rule.awaitText(SEASON_SYNC_SCOPE_LINE) + rule.onNodeWithText(SEASON_SYNC_REFRESH).assertExists() + rule.onNodeWithText(SEASON_SYNC_ENTER_MANUALLY).assertExists() + rule.onAllNodesWithText(SEASON_SYNC_LINK_MANUAL).assertCountEquals(0) + + rule.onNodeWithText(CANCEL_BUTTON).performScrollTo().performClick() + + rule.awaitText(SEASON_SYNC_LINK_MANUAL) + rule.onNodeWithText(SEASON_SYNC_CHOOSE_ENTITY).assertExists() + assertNull("nothing chosen, nothing written", binding(heater)) + } + /** * Pins: after a YEAR_ROUND link #78's dialog takes the sheet's place, and its "Review maintenance schedules" * scrolls this page's own scroll to the maintenance sections. Targets: P78-1b, P78-2, "Weekly check". @@ -601,9 +660,17 @@ class SeasonSyncScreensTest { assertTrue("the fixture's binding was not rewritten", written) } - /** Types the fictional entity id into the sheet's field and waits for the field to hold it. */ + /** + * Types the fictional entity id into the sheet's field and waits for the field to hold it, first opening the field + * with Enter entity ID manually (P105-3) when the sheet shows the Choose entity row instead (#105). + */ private fun typeEntityId() { - val field = rule.onNode(hasSetTextAction() and hasText(SEASON_SYNC_ENTITY_ID)) + val shown = hasSetTextAction() and hasText(SEASON_SYNC_ENTITY_ID) + if (rule.onAllNodes(shown).fetchSemanticsNodes().isEmpty()) { + rule.onNodeWithText(SEASON_SYNC_ENTER_MANUALLY).performScrollTo().performClick() + rule.waitUntil(TIMEOUT_MS) { rule.onAllNodes(shown).fetchSemanticsNodes().isNotEmpty() } + } + val field = rule.onNode(shown) field.performScrollTo().performClick() field.performTextInput(ENTITY_ID) rule.waitUntil(TIMEOUT_MS) { inputOf(field) == ENTITY_ID } diff --git a/app/src/main/kotlin/com/loosecannon/servicetag/di/AppGraph.kt b/app/src/main/kotlin/com/loosecannon/servicetag/di/AppGraph.kt index 1455ba71..f52b5fff 100644 --- a/app/src/main/kotlin/com/loosecannon/servicetag/di/AppGraph.kt +++ b/app/src/main/kotlin/com/loosecannon/servicetag/di/AppGraph.kt @@ -75,6 +75,8 @@ import com.loosecannon.servicetag.core.reminders.BuildReminderSubjects import com.loosecannon.servicetag.core.seasonsync.EditSeasonSyncEntity import com.loosecannon.servicetag.core.seasonsync.ForgetHaConnection import com.loosecannon.servicetag.core.seasonsync.HaConnectionRepository +import com.loosecannon.servicetag.core.seasonsync.SyncErrorKind +import com.loosecannon.servicetag.core.seasonsync.HaListOutcome import com.loosecannon.servicetag.core.seasonsync.LinkSeasonSync import com.loosecannon.servicetag.core.seasonsync.RecordSeasonSyncResult import com.loosecannon.servicetag.core.seasonsync.ResumeSeasonSync @@ -699,6 +701,24 @@ class AppGraph(private val context: Context) { resolver = InetHostResolver(networkPermissionGranted), ) + /** + * #105: the setup sheet's foreground entity list — the stored connection and its token, then one `GET /api/states` + * through [haStateClient] under every #16 rule. Null when no connection is stored (the sheet draws P16-10); + * `NEEDS_TOKEN` when the token is gone (P16-11); otherwise the client's own outcome. Nothing reads this in the + * background: only Choose entity and Refresh do. + */ + val listHaEntities: suspend () -> HaListOutcome? = { + val connection = haConnections.get() + if (connection == null) { + null + } else { + when (val token = secretStore.get(connection.id)) { + null -> HaListOutcome.Failed(SyncErrorKind.NEEDS_TOKEN, null) + else -> haStateClient.listStates(connection, token) + } + } + } + /** A cache file the camera can write into through the FileProvider (spec §9.3). */ fun cameraCaptureUri(): Uri { val file = File(File(context.applicationContext.cacheDir, "camera"), "${ids.newId()}.jpg") diff --git a/app/src/main/kotlin/com/loosecannon/servicetag/seasonsync/HomeAssistantStateClient.kt b/app/src/main/kotlin/com/loosecannon/servicetag/seasonsync/HomeAssistantStateClient.kt index 5ad45447..7aa85744 100644 --- a/app/src/main/kotlin/com/loosecannon/servicetag/seasonsync/HomeAssistantStateClient.kt +++ b/app/src/main/kotlin/com/loosecannon/servicetag/seasonsync/HomeAssistantStateClient.kt @@ -9,6 +9,7 @@ import com.loosecannon.servicetag.core.seasonsync.HaEndpoint import com.loosecannon.servicetag.core.seasonsync.HaEndpointPolicy import com.loosecannon.servicetag.core.seasonsync.HaHostKind import com.loosecannon.servicetag.core.seasonsync.HaHttpAnswer +import com.loosecannon.servicetag.core.seasonsync.HaListOutcome import com.loosecannon.servicetag.core.seasonsync.HaReadOutcome import com.loosecannon.servicetag.core.seasonsync.HaScheme import com.loosecannon.servicetag.core.seasonsync.HaStateReader @@ -19,6 +20,7 @@ import com.loosecannon.servicetag.core.seasonsync.eligibleNow import com.loosecannon.servicetag.core.seasonsync.isJsonMediaType import com.loosecannon.servicetag.core.seasonsync.isPrivateLanAddress import com.loosecannon.servicetag.core.seasonsync.mapHaAnswer +import com.loosecannon.servicetag.core.seasonsync.mapHaStatesAnswer import com.loosecannon.servicetag.core.seasonsync.parseObject import com.loosecannon.servicetag.fetch.FailurePhase import com.loosecannon.servicetag.fetch.transportFailureOf @@ -106,19 +108,37 @@ class HomeAssistantStateClient( is Exchange.Failed -> ConnectionTestOutcome.Failed(exchange.outcome.kind, exchange.outcome.detail) } + /** + * #105 (B2; owner ruling Q2): the setup sheet's one foreground `GET /api/states`, under exactly the rules + * above — the same gate, headers, timeouts and failure map — with its own body cap, [LIST_MAX_BODY_BYTES], because + * a whole installation's states run to megabytes where one entity's run to bytes. The poll's cap is untouched. + * Called only when the owner taps Choose entity or Refresh; never by the runner or the worker. The answer is mapped + * on [io] too: up to 8 MiB of JSON is not parsed on the caller's thread, which is the sheet's, the main one. + */ + suspend fun listStates(connection: HaConnection, token: Secret): HaListOutcome = + when (val exchange = exchange(connection, STATES_LIST_PATH, token, LIST_MAX_BODY_BYTES)) { + is Exchange.Answered -> withContext(io) { mapHaStatesAnswer(exchange.answer) } + is Exchange.Failed -> HaListOutcome.Failed(exchange.outcome.kind, exchange.outcome.detail) + } + private sealed interface Exchange { class Answered(val answer: HaHttpAnswer) : Exchange class Failed(val outcome: HaReadOutcome.NoDecision) : Exchange } - private suspend fun exchange(connection: HaConnection, path: String, token: Secret): Exchange { + private suspend fun exchange( + connection: HaConnection, + path: String, + token: Secret, + maxBodyBytes: Int = MAX_BODY_BYTES, + ): Exchange { val endpoint = when (val check = HaEndpointPolicy.classify(connection.baseUrl)) { is EndpointCheck.Allowed -> check.endpoint is EndpointCheck.Refused -> return failed(SyncErrorKind.ENDPOINT_REFUSED) } if (!networkPermissionGranted()) return failed(SyncErrorKind.DENIED) - return withTimeoutOrNull(CALL_MILLIS) { checkedExchange(connection, endpoint, path, token) } + return withTimeoutOrNull(CALL_MILLIS) { checkedExchange(connection, endpoint, path, token, maxBodyBytes) } ?: failed(SyncErrorKind.TIMED_OUT) } @@ -127,6 +147,7 @@ class HomeAssistantStateClient( endpoint: HaEndpoint, path: String, token: Secret, + maxBodyBytes: Int, ): Exchange { val homeOnly = when (endpoint.scheme) { HaScheme.HTTP -> true @@ -152,7 +173,7 @@ class HomeAssistantStateClient( return failed(SyncErrorKind.ENDPOINT_REFUSED) } if (!sameAsciiHost(url.host.orEmpty(), endpoint.host)) return failed(SyncErrorKind.ENDPOINT_REFUSED) - return withContext(io) { get(url, token) } + return withContext(io) { get(url, token, maxBodyBytes) } } /** C19 step 1b: null when every answer is private (C8 rule 5); else the outcome, nothing opened. */ @@ -166,7 +187,7 @@ class HomeAssistantStateClient( return if (local) null else failed(SyncErrorKind.NAME_NOT_LOCAL) } - private suspend fun get(url: URL, token: Secret): Exchange { + private suspend fun get(url: URL, token: Secret, maxBodyBytes: Int): Exchange { val connection = try { connectionFor(url, token) } catch (e: Throwable) { @@ -176,7 +197,7 @@ class HomeAssistantStateClient( cont.invokeOnCancellation { disconnectQuietly(connection) } if (!cont.isActive) return@suspendCancellableCoroutine val exchange = try { - answerOf(connection) + answerOf(connection, maxBodyBytes) } catch (e: Throwable) { failedBy(e, FailurePhase.CONNECT) } finally { @@ -200,31 +221,31 @@ class HomeAssistantStateClient( return connection } - /** Blocks for the head, then reads a 200's body under the cap; any other status answers no body. */ - private fun answerOf(connection: HttpURLConnection): Exchange { + /** Blocks for the head, then reads a 200's body under [maxBodyBytes]; any other status answers no body. */ + private fun answerOf(connection: HttpURLConnection, maxBodyBytes: Int): Exchange { val status = connection.responseCode if (status < 0) return failed(SyncErrorKind.UNREACHABLE) if (status != OK) return Exchange.Answered(HaHttpAnswer(status, connection.contentType, ByteArray(0), false)) val contentType = connection.contentType return try { - val (body, truncated) = capped(connection.inputStream) + val (body, truncated) = capped(connection.inputStream, maxBodyBytes) Exchange.Answered(HaHttpAnswer(status, contentType, body, truncated)) } catch (e: Throwable) { failedBy(e, FailurePhase.BODY) } } - /** At most [MAX_BODY_BYTES], and whether more followed. */ - private fun capped(input: InputStream): Pair = input.use { + /** At most [maxBodyBytes], and whether more followed; never a byte past the cap is read (review NOTE-5). */ + private fun capped(input: InputStream, maxBodyBytes: Int): Pair = input.use { val kept = ByteArrayOutputStream() val buffer = ByteArray(BUFFER_BYTES) - while (kept.size() <= MAX_BODY_BYTES) { - val read = it.read(buffer, 0, minOf(buffer.size, MAX_BODY_BYTES + 1 - kept.size())) + while (kept.size() <= maxBodyBytes) { + val read = it.read(buffer, 0, minOf(buffer.size, maxBodyBytes + 1 - kept.size())) if (read < 0) break kept.write(buffer, 0, read) } val bytes = kept.toByteArray() - if (bytes.size > MAX_BODY_BYTES) bytes.copyOf(MAX_BODY_BYTES) to true else bytes to false + if (bytes.size > maxBodyBytes) bytes.copyOf(maxBodyBytes) to true else bytes to false } /** N-4: TLS first, since #85's rule would fold it into `UNREACHABLE`; a cancellation or an Error propagates. */ @@ -241,11 +262,17 @@ class HomeAssistantStateClient( private companion object { const val STATES_PATH = "/api/states/" const val API_ROOT_PATH = "/api/" + + /** #105: the whole states list, read only on Choose entity and Refresh. */ + const val STATES_LIST_PATH = "/api/states" const val OK = 200 const val CONNECT_MILLIS = 10_000 const val IDLE_MILLIS = 15_000 const val CALL_MILLIS = 30_000L const val MAX_BODY_BYTES = 64 * 1024 + + /** #105 (owner ruling Q2): the list call's own cap; the poll keeps [MAX_BODY_BYTES]. */ + const val LIST_MAX_BODY_BYTES = 8 * 1024 * 1024 const val BUFFER_BYTES = 8 * 1024 const val MAX_CAUSE_DEPTH = 16 diff --git a/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncSheet.kt b/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncSheet.kt index e0c089f6..4f3fb2aa 100644 --- a/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncSheet.kt +++ b/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncSheet.kt @@ -1,17 +1,22 @@ package com.loosecannon.servicetag.ui.asset +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.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField @@ -31,14 +36,19 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.compose.viewModel import com.loosecannon.servicetag.core.model.AssetId +import com.loosecannon.servicetag.core.seasonsync.HaEntityCandidate +import com.loosecannon.servicetag.core.seasonsync.MAX_LISTED_ENTITIES import com.loosecannon.servicetag.di.AppGraph import com.loosecannon.servicetag.ui.attachments.SAVE_LABEL +import com.loosecannon.servicetag.ui.components.QuietLine import com.loosecannon.servicetag.ui.theme.ControlShape /** * #16 (C27) — the setup sheet, opened by the card's Link (P16-22) or by Resume on an asset no longer MANUAL (C-4): - * Entity ID (P16-42, helper P16-43; Link only), the asset's mode's sentence (P16-44/45/46), Save and Cancel (reused). - * Nothing is written before Save. A refusal keeps the sheet open with its sentence; after a write out of YEAR_ROUND + * the entity (Link only), the asset's mode's sentence (P16-44/45/46), Save and Cancel (reused). Since #105 (B3) the + * entity is chosen from a browser that is a mode of this same sheet (owner ruling Q6): a Choose entity row (P105-1) + * opens it, a tap on a row picks the exact entity, and Enter entity ID manually (P105-3) keeps the typed field + * (P16-42/43/49). Nothing is read until Choose entity or Refresh; nothing is written before Save. A refusal keeps the sheet open with its sentence; after a write out of YEAR_ROUND * #78's question (P78-1a/1b, P78-2, P78-3) takes the sheet's place, and dismissing it answers P78-3, as in the * editor. [opening] keys a fresh model for each opening, kept across a rotation. */ @@ -61,6 +71,12 @@ internal fun LinkSeasonSyncSheet( // Swipe, scrim and back all ask this first: Hidden is refused while saving, so a refusal is never left in a hidden // sheet (MaterializeSheet's idiom). val saving by rememberUpdatedState(state.saving) + // Every way out but Save — Cancel, back, the scrim, a swipe — goes through the model first, so a list read still + // running stops with the sheet (#105). + val dismiss = { + model.dismiss() + done() + } LaunchedEffect(state.finished) { when (state.finished) { SeasonSheetExit.CLOSED -> done() @@ -79,13 +95,32 @@ internal fun LinkSeasonSyncSheet( dismissButton = { TextButton(onClick = model::keepSchedules) { Text(KEEP_SCHEDULES_AS_IS) } }, ) null -> ModalBottomSheet( - onDismissRequest = { if (!state.saving) onDone() }, + onDismissRequest = { if (!state.saving) dismiss() }, sheetState = rememberModalBottomSheetState( skipPartiallyExpanded = true, confirmValueChange = { it != SheetValue.Hidden || !saving }, ), ) { - LinkSeasonSyncForm(state, model::onEntityId, model::save, onCancel = onDone) + val browse = state.browse + if (browse != null) { + EntityBrowser( + browse = browse, + onQuery = model::onQuery, + onPick = model::pick, + onRefresh = model::refreshEntities, + onEnterManually = model::enterManually, + onBack = model::closeBrowse, + ) + } else { + LinkSeasonSyncForm( + state = state, + onEntityId = model::onEntityId, + onChooseEntity = model::chooseEntity, + onEnterManually = model::enterManually, + onSave = model::save, + onCancel = dismiss, + ) + } } } } @@ -94,6 +129,8 @@ internal fun LinkSeasonSyncSheet( private fun LinkSeasonSyncForm( state: LinkSeasonSyncState, onEntityId: (String) -> Unit, + onChooseEntity: () -> Unit, + onEnterManually: () -> Unit, onSave: () -> Unit, onCancel: () -> Unit, ) { @@ -105,21 +142,27 @@ private fun LinkSeasonSyncForm( .padding(bottom = 24.dp), ) { if (state.purpose == SeasonSyncSheetPurpose.LINK) { - OutlinedTextField( - value = state.entityId, - onValueChange = onEntityId, - label = { Text(SEASON_SYNC_ENTITY_ID) }, - supportingText = { Text(state.entityLine ?: SEASON_SYNC_ENTITY_ID_HELP) }, - isError = state.entityLine != null, - singleLine = true, - keyboardOptions = KeyboardOptions( - capitalization = KeyboardCapitalization.None, - autoCorrectEnabled = false, - keyboardType = KeyboardType.Ascii, - ), - shape = ControlShape, - modifier = Modifier.fillMaxWidth(), - ) + if (state.manualEntry) { + OutlinedTextField( + value = state.entityId, + onValueChange = onEntityId, + label = { Text(SEASON_SYNC_ENTITY_ID) }, + supportingText = { Text(state.entityLine ?: SEASON_SYNC_ENTITY_ID_HELP) }, + isError = state.entityLine != null, + singleLine = true, + keyboardOptions = KeyboardOptions( + capitalization = KeyboardCapitalization.None, + autoCorrectEnabled = false, + keyboardType = KeyboardType.Ascii, + ), + shape = ControlShape, + modifier = Modifier.fillMaxWidth(), + ) + TextButton(onClick = onChooseEntity) { Text(SEASON_SYNC_CHOOSE_ENTITY) } + } else { + ChosenEntityRow(chosen = state.chosen, entityLine = state.entityLine, onChooseEntity = onChooseEntity) + TextButton(onClick = onEnterManually) { Text(SEASON_SYNC_ENTER_MANUALLY) } + } } state.sentence?.let { Text(it, style = MaterialTheme.typography.bodyMedium) } state.refusal?.let { RefusalLine(it) } @@ -130,3 +173,102 @@ private fun LinkSeasonSyncForm( } } } + +/** + * #105 — the form's entity row. Nothing chosen: Choose entity (P105-1) over P16-43's helper, the whole row a tap. + * Chosen: the friendly name over the exact id (the id alone when Home Assistant gave no name) and Change (P105-2). + * A shape refusal (P16-49) under it, as under the field, though a picked id cannot earn one. + */ +@Composable +private fun ChosenEntityRow(chosen: HaEntityCandidate?, entityLine: String?, onChooseEntity: () -> Unit) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onChooseEntity) + .padding(vertical = 8.dp), + ) { + Column(modifier = Modifier.weight(1f)) { + if (chosen == null) { + Text(SEASON_SYNC_CHOOSE_ENTITY, style = MaterialTheme.typography.titleSmall) + QuietLine(SEASON_SYNC_ENTITY_ID_HELP) + } else { + Text(chosen.friendlyName ?: chosen.entityId, style = MaterialTheme.typography.titleSmall) + if (chosen.friendlyName != null) QuietLine(chosen.entityId) + } + entityLine?.let { RefusalLine(it) } + } + if (chosen != null) { + TextButton(onClick = onChooseEntity) { Text(SEASON_SYNC_CHANGE_ENTITY) } + } + } +} + +/** + * #105 — the browser, in the sheet's place while it is open: the title (P105-1), the search field (P105-4), the scope + * line (P105-6), the loading line (P105-5) while a read runs, the latest read's ratified failure sentences over the + * last good rows, P105-10 over a bounded list, P105-8 for an empty scope, P105-9 for a query that matches nothing, + * then the rows — friendly name over id, a tap the pick — and Enter entity ID manually, Cancel (back to the form, + * keeping what was chosen) and Refresh. + */ +@Composable +private fun EntityBrowser( + browse: EntityBrowseState, + onQuery: (String) -> Unit, + onPick: (HaEntityCandidate) -> Unit, + onRefresh: () -> Unit, + onEnterManually: () -> Unit, + onBack: () -> Unit, +) { + Column( + verticalArrangement = Arrangement.spacedBy(10.dp), + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = 24.dp), + ) { + Text(SEASON_SYNC_CHOOSE_ENTITY, style = MaterialTheme.typography.titleMedium) + OutlinedTextField( + value = browse.query, + onValueChange = onQuery, + placeholder = { Text(SEASON_SYNC_SEARCH_ENTITIES) }, + singleLine = true, + keyboardOptions = KeyboardOptions(capitalization = KeyboardCapitalization.None, autoCorrectEnabled = false), + shape = ControlShape, + modifier = Modifier.fillMaxWidth(), + ) + QuietLine(SEASON_SYNC_SCOPE_LINE) + if (browse.loading) QuietLine(SEASON_SYNC_READING_ENTITIES) + browse.failure.forEach { RefusalLine(it.text) } + if (browse.truncatedList) QuietLine(seasonSyncOnlyFirstShown(MAX_LISTED_ENTITIES)) + if (browse.failure.isEmpty() && browse.scopeEmpty) QuietLine(SEASON_SYNC_NO_HELPERS) + if (browse.noMatch) QuietLine(SEASON_SYNC_NO_MATCH) + // Bounded, because a lazy list cannot measure inside a scrolling column without a height of its own. + LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 320.dp)) { + items(browse.rows, key = { it.entityId }) { row -> + EntityRow(row = row, onClick = { onPick(row) }) + HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant) + } + } + Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) { + TextButton(onClick = onEnterManually) { Text(SEASON_SYNC_ENTER_MANUALLY) } + Spacer(Modifier.weight(1f)) + TextButton(onClick = onBack) { Text(CANCEL_BUTTON) } + TextButton(onClick = onRefresh, enabled = !browse.loading) { Text(SEASON_SYNC_REFRESH) } + } + } +} + +/** One candidate: its friendly name over its exact id, or the id alone; the whole row is the pick. */ +@Composable +private fun EntityRow(row: HaEntityCandidate, onClick: () -> Unit) { + Column( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 10.dp), + ) { + Text(row.friendlyName ?: row.entityId, style = MaterialTheme.typography.titleSmall) + if (row.friendlyName != null) QuietLine(row.entityId) + } +} diff --git a/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncViewModel.kt b/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncViewModel.kt index cb38e23d..00468129 100644 --- a/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncViewModel.kt +++ b/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncViewModel.kt @@ -6,13 +6,17 @@ import com.loosecannon.servicetag.core.model.AssetId import com.loosecannon.servicetag.core.model.SeasonMode import com.loosecannon.servicetag.core.ports.AssetRepository import com.loosecannon.servicetag.core.ports.ScheduleRepository +import com.loosecannon.servicetag.core.seasonsync.EntityScope import com.loosecannon.servicetag.core.seasonsync.HaConnectionRepository +import com.loosecannon.servicetag.core.seasonsync.HaEntityCandidate +import com.loosecannon.servicetag.core.seasonsync.HaListOutcome import com.loosecannon.servicetag.core.seasonsync.LinkSeasonSync import com.loosecannon.servicetag.core.seasonsync.ResumeSeasonSync import com.loosecannon.servicetag.core.seasonsync.SeasonSyncLinkRefusal import com.loosecannon.servicetag.core.seasonsync.SeasonSyncLinkRefused import com.loosecannon.servicetag.core.seasonsync.SeasonSyncLinked import com.loosecannon.servicetag.core.seasonsync.SeasonSyncNotLinked +import com.loosecannon.servicetag.core.seasonsync.pickerRows import com.loosecannon.servicetag.core.usecase.SeasonModeStrandsPolicy import com.loosecannon.servicetag.core.usecase.SeasonSyncOwnsSeason import com.loosecannon.servicetag.core.usecase.StrandedSchedule @@ -20,7 +24,9 @@ import com.loosecannon.servicetag.core.usecase.liveContinuousCount import com.loosecannon.servicetag.di.AppGraph import com.loosecannon.servicetag.ui.homeassistant.HA_ENTER_TOKEN_AGAIN import com.loosecannon.servicetag.ui.homeassistant.HA_NOT_CONNECTED +import com.loosecannon.servicetag.ui.homeassistant.Notice import kotlin.coroutines.cancellation.CancellationException +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -33,11 +39,34 @@ internal enum class SeasonSyncSheetPurpose { LINK, RESUME } /** How the sheet ended: closed, or closed onto the asset's schedules (#78's P78-2). */ internal enum class SeasonSheetExit { CLOSED, REVIEW_SCHEDULES } +/** + * #105 (B3) — the entity browser as drawn, one immutable value, inside the sheet's state while the browser is open. + * [all] is the last successful list (kept across a failed refresh); [rows] and [truncatedList] are `pickerRows` over it + * for [query]; [scopeEmpty] says the scope had nothing even with no query, which is P105-8 rather than P105-9; + * [failure] is the latest read's ratified sentences, empty after a success. The token is never here. + */ +internal data class EntityBrowseState( + val loading: Boolean = false, + val query: String = "", + val all: List = emptyList(), + val rows: List = emptyList(), + val truncatedList: Boolean = false, + val scopeEmpty: Boolean = false, + val loadedOnce: Boolean = false, + val failure: List = emptyList(), +) { + /** P105-9: a read succeeded, the scope has helpers, and the query matches none of them. */ + val noMatch: Boolean get() = loadedOnce && failure.isEmpty() && !scopeEmpty && rows.isEmpty() +} + /** * #16 (C27) — the setup sheet as drawn, one immutable value. [sentence] is the asset's mode's reconciliation sentence * (P16-44/45/46), null until the asset is read; Save is held until it is there, so nothing is written before the owner * has seen it. [entityLine] is P16-49 under the field; [refusal] is S55 or another refusal's sentence, the sheet open; * [prompt] is #78's question after a written link or resume out of YEAR_ROUND; [finished] tells the screen to close. + * #105: [chosen] is the candidate picked in the browser, [manualEntry] whether the typed field is shown instead of the + * Choose entity row, and [browse] the browser while it is open; [entityToLink] is what Save sends — the picked id, + * else the trimmed text — through the shipped link, unchanged. */ internal data class LinkSeasonSyncState( val purpose: SeasonSyncSheetPurpose, @@ -48,8 +77,15 @@ internal data class LinkSeasonSyncState( val prompt: EditPrompt? = null, val saving: Boolean = false, val finished: SeasonSheetExit? = null, + val chosen: HaEntityCandidate? = null, + val manualEntry: Boolean = false, + val browse: EntityBrowseState? = null, ) { - val canSave: Boolean get() = sentence != null && !saving && prompt == null && finished == null + /** Link also waits for something to send (#105 row 16): a pick, or typed text. Resume sends no entity. */ + val canSave: Boolean get() = sentence != null && !saving && prompt == null && finished == null && + (purpose == SeasonSyncSheetPurpose.RESUME || entityToLink.isNotEmpty()) + + val entityToLink: String get() = chosen?.entityId ?: entityId.trim() } /** @@ -66,11 +102,17 @@ internal class LinkSeasonSyncViewModel( private val connections: HaConnectionRepository, private val link: LinkSeasonSync, private val resume: ResumeSeasonSync, + /** + * #105: the one foreground list read, over the stored connection and its token (`AppGraph.listHaEntities`): null + * when no connection is stored, else the client's outcome. Called only by Choose entity and Refresh, never on + * construction. No default: a wiring that forgets it does not compile. + */ + private val listEntities: suspend () -> HaListOutcome?, ) : ViewModel() { constructor(graph: AppGraph, assetId: AssetId, purpose: SeasonSyncSheetPurpose) : this( assetId, purpose, graph.assets, graph.schedules, graph.haConnections, graph.linkSeasonSync, - graph.resumeSeasonSync, + graph.resumeSeasonSync, graph.listHaEntities, ) private val _state = MutableStateFlow(LinkSeasonSyncState(purpose)) @@ -83,7 +125,116 @@ internal class LinkSeasonSyncViewModel( } } - fun onEntityId(text: String) = _state.update { it.copy(entityId = text, entityLine = null, refusal = null) } + /** A typed id is the manual path: it clears any pick, so Save sends what the owner can see in the field. */ + fun onEntityId(text: String) = + _state.update { it.copy(entityId = text, chosen = null, entityLine = null, refusal = null) } + + // --- #105: the browser ---------------------------------------------------------------------------------------- + + /** Choose entity: open the browser and read the list once. Nothing was read before this. */ + fun chooseEntity() { + _state.update { it.copy(browse = EntityBrowseState(), entityLine = null, refusal = null) } + readEntities() + } + + /** Refresh: read again; the last good list and the selection stay until the answer arrives. */ + fun refreshEntities() { + if (_state.value.browse == null) return + readEntities() + } + + fun onQuery(text: String) = _state.update { state -> + state.browse?.let { state.copy(browse = withRows(it.copy(query = text))) } ?: state + } + + /** The pick: the exact candidate, the typed text cleared, the browser closed, the form back. */ + fun pick(candidate: HaEntityCandidate) { + stopReading() + _state.update { + it.copy( + chosen = candidate, entityId = "", manualEntry = false, browse = null, entityLine = null, refusal = null, + ) + } + } + + /** Back from the browser without a pick: whatever was chosen or typed before stays. */ + fun closeBrowse() { + stopReading() + _state.update { it.copy(browse = null) } + } + + /** + * The sheet is going away without a Save — Cancel, back, a scrim tap or a swipe: a list read still running stops, + * which disconnects it. Each opening's model is keyed under the asset page and outlives its sheet, so nothing else + * would stop it. + */ + fun dismiss() = closeBrowse() + + /** Enter entity ID manually: the field, prefilled with a pick's id if there was one, which it then replaces. */ + fun enterManually() { + stopReading() + _state.update { + it.copy(manualEntry = true, entityId = it.chosen?.entityId ?: it.entityId, chosen = null, browse = null) + } + } + + /** + * The read in flight, if any. A new read, a pick, manual entry and a close each cancel it, which disconnects the + * request (C19), so an answer that is no longer wanted never lands in a browser opened after it. + */ + private var listing: Job? = null + + private fun stopReading() { + listing?.cancel() + listing = null + } + + private fun readEntities() { + stopReading() + _state.update { state -> state.browse?.let { state.copy(browse = it.copy(loading = true)) } ?: state } + listing = viewModelScope.launch { + val outcome: HaListOutcome? = try { + listEntities() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // A store failed before anything was asked of Home Assistant: no sentence is ratified for it; the + // list and the selection stay as they were. + _state.update { state -> state.browse?.let { state.copy(browse = it.copy(loading = false)) } ?: state } + return@launch + } + _state.update { state -> + // The browser is gone (its close also cancelled this read): nothing changes. + val browse = state.browse ?: return@update state + state.copy( + browse = when (outcome) { + null -> browse.copy(loading = false, failure = listOf(Notice(HA_NOT_CONNECTED))) + is HaListOutcome.Failed -> browse.copy( + loading = false, + failure = seasonSyncErrorNotices(outcome.kind, outcome.detail, entityId = ""), + ) + is HaListOutcome.Listed -> withRows( + browse.copy( + loading = false, + all = outcome.entities, + loadedOnce = true, + failure = emptyList(), + // Once per list, not per keystroke: P105-8 rather than P105-9. + scopeEmpty = pickerRows(outcome.entities, EntityScope.INPUT_BOOLEANS, "") + .rows.isEmpty(), + ), + ) + }, + ) + } + } + } + + /** The rows for the browser's query over its last good list. */ + private fun withRows(browse: EntityBrowseState): EntityBrowseState { + val shown = pickerRows(browse.all, EntityScope.INPUT_BOOLEANS, browse.query) + return browse.copy(rows = shown.rows, truncatedList = shown.truncatedList) + } fun save() { val asked = _state.value @@ -92,7 +243,7 @@ internal class LinkSeasonSyncViewModel( viewModelScope.launch { val next = try { val written = when (asked.purpose) { - SeasonSyncSheetPurpose.LINK -> link.run(assetId, asked.entityId.trim()) + SeasonSyncSheetPurpose.LINK -> link.run(assetId, asked.entityToLink) SeasonSyncSheetPurpose.RESUME -> resume.run(assetId) } afterWrite(written) diff --git a/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/SeasonSyncStrings.kt b/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/SeasonSyncStrings.kt index b4235f42..525449a9 100644 --- a/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/SeasonSyncStrings.kt +++ b/app/src/main/kotlin/com/loosecannon/servicetag/ui/asset/SeasonSyncStrings.kt @@ -192,3 +192,35 @@ internal val SEASON_SYNC_FOLLOWS_HA: String get() = localized(R.string.season_sy /** P16-49 — C16's `BAD_ENTITY_ID`, under the field. */ internal val SEASON_SYNC_BAD_ENTITY_ID: String get() = localized(R.string.season_sync_bad_entity_id) + +// #105 (B3) — the setup sheet's entity browser, P105-1…10 RATIFIED 2026-10-05; resources in `strings_asset_edit.xml`. + +/** P105-1 — the form's row with nothing chosen, and the browser's title. */ +internal val SEASON_SYNC_CHOOSE_ENTITY: String get() = localized(R.string.season_sync_choose_entity) + +/** P105-2 — the form's row after a pick. */ +internal val SEASON_SYNC_CHANGE_ENTITY: String get() = localized(R.string.season_sync_change_entity) + +/** P105-3 — the advanced path, on the form and in the browser. */ +internal val SEASON_SYNC_ENTER_MANUALLY: String get() = localized(R.string.season_sync_enter_manually) + +/** P105-4 — the search field's hint. */ +internal val SEASON_SYNC_SEARCH_ENTITIES: String get() = localized(R.string.season_sync_search_entities) + +/** P105-5 — the bounded loading line. */ +internal val SEASON_SYNC_READING_ENTITIES: String get() = localized(R.string.season_sync_reading_entities) + +/** P105-6 — the scope line over the rows. */ +internal val SEASON_SYNC_SCOPE_LINE: String get() = localized(R.string.season_sync_scope_line) + +/** P105-7 — the browser's button. */ +internal val SEASON_SYNC_REFRESH: String get() = localized(R.string.season_sync_refresh) + +/** P105-8 — the scope is empty. */ +internal val SEASON_SYNC_NO_HELPERS: String get() = localized(R.string.season_sync_no_helpers) + +/** P105-9 — the search matches nothing. */ +internal val SEASON_SYNC_NO_MATCH: String get() = localized(R.string.season_sync_no_match) + +/** P105-10 — more matched than are drawn; [shown] is the number drawn. */ +internal fun seasonSyncOnlyFirstShown(shown: Int): String = localized(R.string.season_sync_only_first_shown, shown) diff --git a/app/src/main/res/values-b+zh+Hans/strings_asset_edit.xml b/app/src/main/res/values-b+zh+Hans/strings_asset_edit.xml index f1afca60..674ea37b 100644 --- a/app/src/main/res/values-b+zh+Hans/strings_asset_edit.xml +++ b/app/src/main/res/values-b+zh+Hans/strings_asset_edit.xml @@ -157,4 +157,14 @@ 从现在起,由 Home Assistant 开始和结束此设备的使用季。其使用季历史保持不变。 此设备的使用季跟随 Home Assistant。如需在此处更改,请先在设备页面上选择“停止同步”。 请输入实体 ID,例如 input_boolean.example_heater_in_season:只能包含小写字母、数字和下划线,并带有一个点。 + 选择实体 + 更改 + 手动输入实体 ID + 按名称或实体 ID 搜索 + 正在从 Home Assistant 读取实体… + 显示 Home Assistant 的开关型辅助元素(input_boolean)。 + 刷新 + 未在 Home Assistant 中找到开关型辅助元素。请在 Home Assistant 中创建一个,或手动输入实体 ID。 + 没有与搜索匹配的实体。 + 仅显示前 %1$d 个。请通过搜索缩小范围。 diff --git a/app/src/main/res/values-de/strings_asset_edit.xml b/app/src/main/res/values-de/strings_asset_edit.xml index 5895b5b1..88bebb49 100644 --- a/app/src/main/res/values-de/strings_asset_edit.xml +++ b/app/src/main/res/values-de/strings_asset_edit.xml @@ -159,4 +159,14 @@ Ab jetzt startet und beendet Home Assistant die Saison dieses Geräts. Sein Saisonverlauf bleibt unverändert. Die Saison dieses Geräts folgt Home Assistant. Wähle auf der Seite des Geräts „Synchronisierung beenden“, um sie hier zu ändern. Gib eine Entitäts-ID wie input_boolean.example_heater_in_season ein: Kleinbuchstaben, Ziffern und Unterstriche, mit einem Punkt. + Entität wählen + Ändern + Entitäts-ID manuell eingeben + Nach Name oder Entitäts-ID suchen + Entitäten werden aus Home Assistant gelesen … + Angezeigt werden die Ein/Aus-Helfer von Home Assistant (input_boolean). + Aktualisieren + Keine Ein/Aus-Helfer in Home Assistant gefunden. Lege einen in Home Assistant an oder gib eine Entitäts-ID manuell ein. + Keine Entität passt zu deiner Suche. + Nur die ersten %1$d werden angezeigt. Suche, um die Liste einzugrenzen. diff --git a/app/src/main/res/values-es/strings_asset_edit.xml b/app/src/main/res/values-es/strings_asset_edit.xml index 1de32dd4..1fd7b684 100644 --- a/app/src/main/res/values-es/strings_asset_edit.xml +++ b/app/src/main/res/values-es/strings_asset_edit.xml @@ -160,4 +160,14 @@ A partir de ahora, Home Assistant inicia y termina la temporada de este equipo. Su historial de temporadas se queda como está. La temporada de este equipo sigue a Home Assistant. Usa “Dejar de sincronizar” en la página del equipo para cambiarla aquí. Introduce un ID de entidad como input_boolean.example_heater_in_season: letras minúsculas, dígitos y guiones bajos, con un punto. + Elegir entidad + Cambiar + Introducir el ID de entidad manualmente + Buscar por nombre o ID de entidad + Leyendo entidades de Home Assistant… + Se muestran los ayudantes de encendido/apagado de Home Assistant (input_boolean). + Actualizar + No se encontraron ayudantes de encendido/apagado en Home Assistant. Crea uno en Home Assistant o introduce un ID de entidad manualmente. + Ninguna entidad coincide con tu búsqueda. + Solo se muestran los primeros %1$d. Busca para acotar la lista. diff --git a/app/src/main/res/values-fr/strings_asset_edit.xml b/app/src/main/res/values-fr/strings_asset_edit.xml index 720095e4..a2bdf8a8 100644 --- a/app/src/main/res/values-fr/strings_asset_edit.xml +++ b/app/src/main/res/values-fr/strings_asset_edit.xml @@ -159,4 +159,14 @@ Désormais, Home Assistant démarre et termine la saison de cet équipement. Son historique des saisons reste tel quel. La saison de cet équipement suit Home Assistant. Choisissez « Arrêter la synchronisation » sur la page de l\'équipement pour la modifier ici. Saisissez un ID d\'entité comme input_boolean.example_heater_in_season : lettres minuscules, chiffres et tirets bas, avec un seul point. + Choisir une entité + Modifier + Saisir l\'ID d\'entité manuellement + Rechercher par nom ou ID d\'entité + Lecture des entités depuis Home Assistant… + Affichage des entrées marche/arrêt de Home Assistant (input_boolean). + Actualiser + Aucune entrée marche/arrêt trouvée dans Home Assistant. Créez-en une dans Home Assistant, ou saisissez un ID d\'entité manuellement. + Aucune entité ne correspond à votre recherche. + Seules les %1$d premières sont affichées. Recherchez pour affiner la liste. diff --git a/app/src/main/res/values-hi/strings_asset_edit.xml b/app/src/main/res/values-hi/strings_asset_edit.xml index 7374e2e7..68f5a189 100644 --- a/app/src/main/res/values-hi/strings_asset_edit.xml +++ b/app/src/main/res/values-hi/strings_asset_edit.xml @@ -159,4 +159,14 @@ अब से Home Assistant इस उपकरण का मौसम शुरू और खत्म करेगा। इसके मौसम का इतिहास जैसा है वैसा ही रहेगा। इस उपकरण का मौसम Home Assistant के अनुसार चलता है। इसे यहाँ बदलने के लिए उपकरण के पेज पर “सिंक करना बंद करें” चुनें। input_boolean.example_heater_in_season जैसी एंटिटी ID डालें: अंग्रेज़ी के छोटे अक्षर, अंक और अंडरस्कोर, एक बिंदु के साथ। + एंटिटी चुनें + बदलें + एंटिटी ID मैन्युअल रूप से दर्ज करें + नाम या एंटिटी ID से खोजें + Home Assistant से एंटिटी पढ़ी जा रही हैं… + Home Assistant के चालू/बंद हेल्पर (input_boolean) दिखाए जा रहे हैं। + रीफ़्रेश करें + Home Assistant में कोई चालू/बंद हेल्पर नहीं मिला। Home Assistant में एक बनाएँ, या एंटिटी ID मैन्युअल रूप से दर्ज करें। + आपकी खोज से कोई एंटिटी मेल नहीं खाती। + केवल पहली %1$d दिखाई जा रही हैं। सूची छोटी करने के लिए खोजें। diff --git a/app/src/main/res/values-it/strings_asset_edit.xml b/app/src/main/res/values-it/strings_asset_edit.xml index 2d0d52f3..15b6cd9e 100644 --- a/app/src/main/res/values-it/strings_asset_edit.xml +++ b/app/src/main/res/values-it/strings_asset_edit.xml @@ -159,4 +159,14 @@ D\'ora in poi Home Assistant avvia e termina la stagione di questa attrezzatura. La sua cronologia della stagione resta com\'è. La stagione di questa attrezzatura segue Home Assistant. Usa “Interrompi sincronizzazione” nella pagina dell\'attrezzatura per modificarla qui. Inserisci un ID entità come input_boolean.example_heater_in_season: lettere minuscole, cifre e trattini bassi, con un punto. + Scegli entità + Cambia + Inserisci l\'ID entità manualmente + Cerca per nome o ID entità + Lettura delle entità da Home Assistant… + Sono mostrati gli helper on/off di Home Assistant (input_boolean). + Aggiorna + Nessun helper on/off trovato in Home Assistant. Creane uno in Home Assistant, oppure inserisci un ID entità manualmente. + Nessuna entità corrisponde alla ricerca. + Sono mostrate solo le prime %1$d. Cerca per restringere l\'elenco. diff --git a/app/src/main/res/values-ja/strings_asset_edit.xml b/app/src/main/res/values-ja/strings_asset_edit.xml index 791056f9..ae9f92a5 100644 --- a/app/src/main/res/values-ja/strings_asset_edit.xml +++ b/app/src/main/res/values-ja/strings_asset_edit.xml @@ -157,4 +157,14 @@ 今後はHome Assistantがこの機器のシーズンを開始・終了します。シーズンの履歴はそのまま残ります。 この機器のシーズンはHome Assistantに従っています。ここで変更するには、機器のページで「同期を停止」を選んでください。 input_boolean.example_heater_in_seasonのようなエンティティIDを入力してください(英小文字、数字、アンダースコアで構成し、ドットを1つ含めます)。 + エンティティを選択 + 変更 + エンティティIDを手動で入力 + 名前またはエンティティIDで検索 + Home Assistantからエンティティを読み込んでいます… + Home Assistantのオン/オフのヘルパー(input_boolean)を表示しています。 + 更新 + Home Assistantにオン/オフのヘルパーが見つかりません。Home Assistantで作成するか、エンティティIDを手動で入力してください。 + 検索に一致するエンティティはありません。 + 最初の%1$d件のみ表示しています。検索して絞り込んでください。 diff --git a/app/src/main/res/values-pt/strings_asset_edit.xml b/app/src/main/res/values-pt/strings_asset_edit.xml index 99cd9ce4..7f975192 100644 --- a/app/src/main/res/values-pt/strings_asset_edit.xml +++ b/app/src/main/res/values-pt/strings_asset_edit.xml @@ -159,4 +159,14 @@ A partir de agora, o Home Assistant inicia e encerra a temporada deste equipamento. O histórico da temporada continua como está. A temporada deste equipamento segue o Home Assistant. Escolha “Parar sincronização” na página do equipamento para alterá-la aqui. Digite um ID de entidade como input_boolean.example_heater_in_season: letras minúsculas, dígitos e sublinhados, com um ponto. + Escolher entidade + Alterar + Inserir o ID da entidade manualmente + Buscar por nome ou ID da entidade + Lendo entidades do Home Assistant… + Mostrando os auxiliares liga/desliga do Home Assistant (input_boolean). + Atualizar + Nenhum auxiliar liga/desliga encontrado no Home Assistant. Crie um no Home Assistant ou insira um ID de entidade manualmente. + Nenhuma entidade corresponde à sua busca. + Apenas os primeiros %1$d são mostrados. Busque para restringir a lista. diff --git a/app/src/main/res/values-ru/strings_asset_edit.xml b/app/src/main/res/values-ru/strings_asset_edit.xml index 90dec616..dffaa21b 100644 --- a/app/src/main/res/values-ru/strings_asset_edit.xml +++ b/app/src/main/res/values-ru/strings_asset_edit.xml @@ -163,4 +163,14 @@ С этого момента Home Assistant начинает и завершает сезон этого объекта. История сезонов остаётся без изменений. Сезон этого объекта следует за Home Assistant. Чтобы изменить его здесь, выберите «Остановить синхронизацию» на странице объекта. Введите ID сущности, например input_boolean.example_heater_in_season: строчные буквы, цифры и подчёркивания, с одной точкой. + Выбрать сущность + Изменить + Ввести ID сущности вручную + Поиск по имени или ID сущности + Чтение сущностей из Home Assistant… + Показаны вспомогательные переключатели (вкл./выкл.) Home Assistant (input_boolean). + Обновить + В Home Assistant не найдено вспомогательных переключателей (вкл./выкл.). Создайте его в Home Assistant или введите ID сущности вручную. + Ни одна сущность не соответствует вашему запросу. + Показаны только первые %1$d. Уточните запрос, чтобы сократить список. diff --git a/app/src/main/res/values/strings_asset_edit.xml b/app/src/main/res/values/strings_asset_edit.xml index fd045bbf..f3d1029e 100644 --- a/app/src/main/res/values/strings_asset_edit.xml +++ b/app/src/main/res/values/strings_asset_edit.xml @@ -304,4 +304,24 @@ This asset\'s season follows Home Assistant. Stop syncing on the asset\'s page to change it here. Enter an entity ID such as input_boolean.example_heater_in_season: lowercase letters, digits and underscores, with one dot. + + Choose entity + + Change + + Enter entity ID manually + + Search by name or entity ID + + Reading entities from Home Assistant… + + Showing Home Assistant\'s on/off helpers (input_boolean). + + Refresh + + No Home Assistant on/off helpers found. Create one in Home Assistant, or enter an entity ID manually. + + No entities match your search. + + Only the first %1$d are shown. Search to narrow the list. diff --git a/app/src/test/kotlin/com/loosecannon/servicetag/seasonsync/HomeAssistantStateClientTest.kt b/app/src/test/kotlin/com/loosecannon/servicetag/seasonsync/HomeAssistantStateClientTest.kt index a516d9dd..a13de12d 100644 --- a/app/src/test/kotlin/com/loosecannon/servicetag/seasonsync/HomeAssistantStateClientTest.kt +++ b/app/src/test/kotlin/com/loosecannon/servicetag/seasonsync/HomeAssistantStateClientTest.kt @@ -5,6 +5,8 @@ import com.loosecannon.servicetag.core.fetch.TransportFailure import com.loosecannon.servicetag.core.seasonsync.BackgroundChecks import com.loosecannon.servicetag.core.seasonsync.CurrentNetwork import com.loosecannon.servicetag.core.seasonsync.HaConnection +import com.loosecannon.servicetag.core.seasonsync.HaListOutcome +import com.loosecannon.servicetag.core.seasonsync.HaEntityCandidate import com.loosecannon.servicetag.core.seasonsync.HaReadOutcome import com.loosecannon.servicetag.core.seasonsync.HaReadOutcome.NoDecision import com.loosecannon.servicetag.core.seasonsync.HaReadOutcome.Observed @@ -175,6 +177,94 @@ class HomeAssistantStateClientTest { ) } + // --- #105 (B2): the setup sheet's one foreground list read --------------------------------------------------- + + /** Row 9: `GET /api/states` with the four headers, no redirect, no cache; the array maps to candidates. */ + @Test + fun listStatesGetsTheStatesPathWithTheSameHeadersAndMapsTheArray() = runBlocking { + val h = Harness().apply { ha.answer = { Script(200, body = statesJson()) } } + + val outcome = h.client().listStates(HOME_HTTP, TOKEN) + + assertEquals( + HaListOutcome.Listed( + listOf( + HaEntityCandidate(ENTITY, "Example Heater In Season"), + HaEntityCandidate("input_boolean.example_pump", null), + ), + ), + outcome, + ) + assertEquals(listOf(URL("http://192.168.0.10:8123/api/states")), h.ha.opened) + val connection = h.ha.connections.single() + assertEquals("GET", connection.methodAtHead) + assertEquals(false, connection.followAtHead) + assertEquals(false, connection.cachesAtHead) + assertEquals( + listOf( + "Authorization" to "Bearer fictional-token-1", + "Accept" to "application/json", + "Accept-Encoding" to "identity", + "User-Agent" to "ServiceTag", + ), + connection.properties, + ) + assertEquals(10_000 to 15_000, connection.connectTimeoutAtHead to connection.readTimeoutAtHead) + } + + /** Row 9: the same gate — off the home Wi-Fi nothing opens; a refused address opens nothing; 401 is AUTH_REFUSED. */ + @Test + fun listStatesIsUnderTheSameGateAndFailureMap() = runBlocking { + val elsewhere = Harness(network = NetworkReading(CurrentNetwork.Other, null)) + assertEquals( + HaListOutcome.Failed(SyncErrorKind.NOT_ON_LOCAL_NETWORK, null), + elsewhere.client().listStates(HOME_HTTP, TOKEN), + ) + assertEquals(emptyList(), elsewhere.ha.opened) + + val refused = Harness() + assertEquals( + HaListOutcome.Failed(SyncErrorKind.ENDPOINT_REFUSED, null), + refused.client().listStates(HOME_HTTP.copy(baseUrl = "http://192.0.2.10:8123"), TOKEN), + ) + assertEquals(emptyList(), refused.ha.opened) + + val denied = Harness().apply { ha.answer = { Script(401) } } + assertEquals(HaListOutcome.Failed(SyncErrorKind.AUTH_REFUSED, null), denied.client().listStates(HOME_HTTP, TOKEN)) + + val missing = Harness().apply { ha.answer = { Script(404) } } + assertEquals( + "a 404 on the list is an HTTP error, not a missing entity", + HaListOutcome.Failed(SyncErrorKind.HTTP_ERROR, "404"), + missing.client().listStates(HOME_HTTP, TOKEN), + ) + } + + /** Row 10: the list reads up to 8 MiB and no further; the poll's 64 KiB cap is untouched. */ + @Test + fun listStatesReadsUpToEightMebibytesAndThePollStaysAtSixtyFourKib() = runBlocking { + val cap = 8 * 1024 * 1024 + val over = Harness().apply { ha.answer = { Script(200, body = paddedStatesJson(cap + 1)) } } + assertEquals( + HaListOutcome.Failed(SyncErrorKind.MALFORMED, null), + over.client().listStates(HOME_HTTP, TOKEN), + ) + + val exactly = Harness().apply { ha.answer = { Script(200, body = paddedStatesJson(cap)) } } + assertEquals( + "exactly 8 MiB is whole", + HaListOutcome.Listed(listOf(HaEntityCandidate(ENTITY, null))), + exactly.client().listStates(HOME_HTTP, TOKEN), + ) + + val poll = Harness().apply { ha.answer = { Script(200, body = paddedStateJson(64 * 1024 + 1)) } } + assertEquals( + "the poll's cap did not move", + NoDecision(SyncErrorKind.MALFORMED, null), + poll.client().read(HOME_HTTP, ENTITY, TOKEN), + ) + } + /** Review NOTE-5: the cap bounds what is read, not only what is kept. */ @Test fun neverReadsPastTheCapFromALargeBody() = runBlocking { @@ -468,6 +558,21 @@ class HomeAssistantStateClientTest { fun failure(kind: SyncErrorKind, detail: String? = null) = ConnectionTestOutcome.Failed(kind, detail) + /** A states list of two fictional helpers, one named, one not (#105). */ + fun statesJson(): ByteArray = ( + """[{"entity_id":"$ENTITY","state":"on","attributes":{"friendly_name":"Example Heater In Season"}},""" + + """{"entity_id":"input_boolean.example_pump","state":"off","attributes":{}}]""" + ).toByteArray() + + /** A valid one-entity list of exactly [size] bytes: the padding is an attribute (#105). */ + fun paddedStatesJson(size: Int): ByteArray { + val head = """[{"entity_id":"$ENTITY","state":"on","attributes":{"pad":"""" + val tail = "\"}}]" + return (head + "x".repeat(size - head.length - tail.length) + tail).toByteArray().also { + check(it.size == size) + } + } + fun stateJson(state: String): ByteArray = """{"entity_id":"$ENTITY","state":"$state","last_changed":"$LAST_CHANGED","attributes":{}}""".toByteArray() diff --git a/app/src/test/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncViewModelTest.kt b/app/src/test/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncViewModelTest.kt index 9a3c8a7e..74ecd529 100644 --- a/app/src/test/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncViewModelTest.kt +++ b/app/src/test/kotlin/com/loosecannon/servicetag/ui/asset/LinkSeasonSyncViewModelTest.kt @@ -12,6 +12,14 @@ import com.loosecannon.servicetag.core.model.SeasonAction import com.loosecannon.servicetag.core.model.SeasonMode import com.loosecannon.servicetag.core.model.ServicePolicy import com.loosecannon.servicetag.core.seasonsync.NetworkEligibility +import com.loosecannon.servicetag.ui.homeassistant.Notice +import com.loosecannon.servicetag.ui.homeassistant.HA_TOKEN_REFUSED +import com.loosecannon.servicetag.ui.homeassistant.HA_NOT_CONNECTED +import com.loosecannon.servicetag.ui.homeassistant.HA_ENTER_TOKEN_AGAIN +import com.loosecannon.servicetag.ui.homeassistant.HA_COULD_NOT_REACH +import com.loosecannon.servicetag.core.seasonsync.SyncErrorKind +import com.loosecannon.servicetag.core.seasonsync.HaListOutcome +import com.loosecannon.servicetag.core.seasonsync.HaEntityCandidate import com.loosecannon.servicetag.core.seasonsync.Secret import com.loosecannon.servicetag.core.seasonsync.SyncCadence import com.loosecannon.servicetag.core.usecase.SeasonModeCommand @@ -21,7 +29,9 @@ import com.loosecannon.servicetag.testing.FakeGraph import com.loosecannon.servicetag.testing.assetRow import com.loosecannon.servicetag.testing.scheduleOf import com.loosecannon.servicetag.ui.condition.SeasonOfferPrompt +import kotlin.coroutines.cancellation.CancellationException import kotlin.io.path.createTempDirectory +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -55,6 +65,10 @@ class LinkSeasonSyncViewModelTest { private val helper = "input_boolean.example_heater_in_season" + /** #105: the sheet's list seam, scripted per case; counted, so "nothing is read on open" is a number. */ + private var listReads = 0 + private var entityList: suspend () -> HaListOutcome? = { listReads++; HaListOutcome.Listed(emptyList()) } + private val calendarSentence = "Linking this asset to Home Assistant replaces its calendar dates. From today, its season starts and ends " + "when Home Assistant says. Maintenance that counts from the start of the season will count from today." @@ -88,7 +102,7 @@ class LinkSeasonSyncViewModelTest { val model = LinkSeasonSyncViewModel( AssetId(id), purpose, graph.assets, graph.schedules, graph.haConnections, graph.linkSeasonSync, graph.resumeSeasonSync, - ) + ) { entityList() } advanceUntilIdle() return model } @@ -134,7 +148,7 @@ class LinkSeasonSyncViewModelTest { val pending = LinkSeasonSyncViewModel( AssetId("late"), SeasonSyncSheetPurpose.LINK, graph.assets, graph.schedules, graph.haConnections, graph.linkSeasonSync, graph.resumeSeasonSync, - ) + ) { entityList() } assertNull("the read is pending", pending.now.sentence) assertFalse(pending.now.canSave) pending.onEntityId(helper) @@ -331,4 +345,229 @@ class LinkSeasonSyncViewModelTest { assertEquals(SeasonMode.YEAR_ROUND, graph.assets.get(AssetId("hand"))!!.seasonMode) assertNull(binding("old")) } + + // --- #105 (B3): the entity browser, rows 11–17 --------------------------------------------------------------- + + private val stove = HaEntityCandidate("input_boolean.pellet_stove_in_season", "Pellet Stove In Season") + private val heaterHelper = HaEntityCandidate("input_boolean.example_heater_in_season", "Example Heater In Season") + private val lamp = HaEntityCandidate("switch.example_lamp", "Example Lamp") + + private suspend fun handAsset() = graph.assets.upsert(assetRow("hand", seasonMode = SeasonMode.MANUAL)) + + /** Rows 11, 12: nothing is read on open; Choose entity reads once and lists the scope sorted; a pick links the exact id. */ + @Test fun chooseEntityReadsOnceAndAPickLinksTheExactId() = runTest { + connect() + handAsset() + entityList = { listReads++; HaListOutcome.Listed(listOf(lamp, stove, heaterHelper)) } + val sheet = open("hand", SeasonSyncSheetPurpose.LINK) + assertEquals("nothing is read on open", 0, listReads) + assertNull(sheet.now.browse) + + act { sheet.chooseEntity() } + + assertEquals(1, listReads) + val browse = sheet.now.browse!! + assertEquals(listOf(heaterHelper, stove), browse.rows) + assertFalse(browse.loading) + assertTrue(browse.loadedOnce) + assertEquals(emptyList(), browse.failure) + + act { sheet.pick(stove) } + + assertNull("the browser closed on the pick", sheet.now.browse) + assertEquals(stove, sheet.now.chosen) + assertEquals("", sheet.now.entityId) + assertEquals(stove.entityId, sheet.now.entityToLink) + act { sheet.save() } + assertEquals("the exact id, through the shipped link", stove.entityId, binding("hand")!!.entityId) + assertEquals(SeasonSheetExit.CLOSED, sheet.now.finished) + assertEquals("no second read", 1, listReads) + } + + /** Row 13: typing clears the pick; Enter entity ID manually prefills the field with the pick; a bad shape is P16-49 as today. */ + @Test fun typingClearsThePickAndManualEntryPrefillsTheField() = runTest { + connect() + handAsset() + entityList = { HaListOutcome.Listed(listOf(stove)) } + val sheet = open("hand", SeasonSyncSheetPurpose.LINK) + act { sheet.chooseEntity() } + act { sheet.pick(stove) } + + act { sheet.enterManually() } + assertTrue(sheet.now.manualEntry) + assertNull(sheet.now.chosen) + assertEquals("the field starts with the pick's id", stove.entityId, sheet.now.entityId) + + sheet.onEntityId("not an id") + assertEquals("not an id", sheet.now.entityToLink) + act { sheet.save() } + assertEquals(SEASON_SYNC_BAD_ENTITY_ID, sheet.now.entityLine) + assertNull("a refusal writes nothing", binding("hand")) + + act { sheet.chooseEntity() } + act { sheet.pick(stove) } + assertFalse("a pick leaves manual entry", sheet.now.manualEntry) + assertEquals("", sheet.now.entityId) + assertEquals(stove, sheet.now.chosen) + } + + /** Row 14: a failed first read draws its sentence and no rows; a failed refresh keeps the rows; nothing is chosen by a failure. */ + @Test fun aFailedFirstReadDrawsTheSentenceAndAFailedRefreshKeepsTheRows() = runTest { + connect() + handAsset() + entityList = { HaListOutcome.Failed(SyncErrorKind.UNREACHABLE, null) } + val sheet = open("hand", SeasonSyncSheetPurpose.LINK) + + act { sheet.chooseEntity() } + var browse = sheet.now.browse!! + assertEquals(listOf(Notice(HA_COULD_NOT_REACH)), browse.failure) + assertEquals(emptyList(), browse.rows) + assertFalse(browse.loadedOnce) + assertFalse(browse.scopeEmpty) + assertFalse(browse.noMatch) + + entityList = { HaListOutcome.Listed(listOf(stove, heaterHelper)) } + act { sheet.refreshEntities() } + browse = sheet.now.browse!! + assertEquals(emptyList(), browse.failure) + assertEquals(listOf(heaterHelper, stove), browse.rows) + + entityList = { HaListOutcome.Failed(SyncErrorKind.AUTH_REFUSED, null) } + act { sheet.refreshEntities() } + browse = sheet.now.browse!! + assertEquals(listOf(Notice(HA_TOKEN_REFUSED)), browse.failure) + assertEquals("the last good list stays", listOf(heaterHelper, stove), browse.rows) + + act { sheet.closeBrowse() } + assertNull(sheet.now.browse) + assertNull("nothing was chosen by a failure", sheet.now.chosen) + assertNull(binding("hand")) + } + + /** Row 15: an empty scope and a query that matches nothing are two states; the search is live over the list. */ + @Test fun noHelpersAndNoMatchAreTwoDifferentStates() = runTest { + connect() + handAsset() + entityList = { HaListOutcome.Listed(listOf(lamp)) } + val sheet = open("hand", SeasonSyncSheetPurpose.LINK) + act { sheet.chooseEntity() } + assertTrue("a switch is out of scope: nothing to list", sheet.now.browse!!.scopeEmpty) + assertFalse(sheet.now.browse!!.noMatch) + + entityList = { HaListOutcome.Listed(listOf(lamp, stove, heaterHelper)) } + act { sheet.refreshEntities() } + assertFalse(sheet.now.browse!!.scopeEmpty) + sheet.onQuery("pellet") + assertEquals(listOf(stove), sheet.now.browse!!.rows) + sheet.onQuery("zzz") + assertTrue(sheet.now.browse!!.noMatch) + assertFalse(sheet.now.browse!!.scopeEmpty) + sheet.onQuery("") + assertEquals(listOf(heaterHelper, stove), sheet.now.browse!!.rows) + } + + /** + * Row 14: a read still running when the browser closes is cancelled — the request is disconnected, not merely + * ignored — so its late answer cannot land in a browser opened after it. + */ + @Test fun aReadStillRunningIsCancelledByCloseAndNeverLandsInTheNextBrowser() = runTest { + connect() + handAsset() + val slow = CompletableDeferred() + var cancelled = false + entityList = { + try { + slow.await() + } catch (e: CancellationException) { + cancelled = true + throw e + } + } + val sheet = open("hand", SeasonSyncSheetPurpose.LINK) + act { sheet.chooseEntity() } + assertTrue(sheet.now.browse!!.loading) + + act { sheet.closeBrowse() } + assertTrue("closing the browser cancels its read", cancelled) + + entityList = { HaListOutcome.Listed(listOf(stove)) } + act { sheet.chooseEntity() } + slow.complete(HaListOutcome.Listed(listOf(heaterHelper))) + advanceUntilIdle() + + assertEquals("the second read's list, never the first's", listOf(stove), sheet.now.browse!!.rows) + assertFalse(sheet.now.browse!!.loading) + } + + /** + * Row 14 (review of #107): the whole sheet going away — Cancel, back, a scrim tap or a swipe all call [dismiss] — + * cancels a read still running. The model is keyed per opening and outlives its sheet, so nothing else would. + */ + @Test fun dismissingTheSheetCancelsAReadStillRunning() = runTest { + connect() + handAsset() + val slow = CompletableDeferred() + var cancelled = false + entityList = { + try { + slow.await() + } catch (e: CancellationException) { + cancelled = true + throw e + } + } + val sheet = open("hand", SeasonSyncSheetPurpose.LINK) + act { sheet.chooseEntity() } + assertTrue(sheet.now.browse!!.loading) + + act { sheet.dismiss() } + + assertTrue("the sheet's dismissal cancels its read", cancelled) + assertNull(sheet.now.browse) + assertNull("nothing written", binding("hand")) + } + + /** + * Row 16 (review of #107): Link's Save waits for an entity — a pick, or typed text that is not blank — so the + * browse-first sheet never answers P16-49 before anything was chosen. Resume sends no entity and waits for none. + */ + @Test fun linkSaveWaitsForAnEntityAndResumeDoesNot() = runTest { + connect() + handAsset() + entityList = { HaListOutcome.Listed(listOf(stove)) } + val sheet = open("hand", SeasonSyncSheetPurpose.LINK) + assertNotNull("the sentence is read", sheet.now.sentence) + assertFalse("nothing chosen, nothing typed", sheet.now.canSave) + act { sheet.save() } + assertNull("a held Save writes nothing", binding("hand")) + assertNull("and refuses nothing", sheet.now.entityLine) + + act { sheet.enterManually() } + sheet.onEntityId(" ") + assertFalse("blank text is no entity", sheet.now.canSave) + sheet.onEntityId(stove.entityId) + assertTrue("typed text is", sheet.now.canSave) + + sheet.onEntityId("") + act { sheet.chooseEntity() } + act { sheet.pick(stove) } + assertTrue("so is a pick", sheet.now.canSave) + + assertTrue("Resume waits for no entity", open("hand", SeasonSyncSheetPurpose.RESUME).now.canSave) + } + + /** Rows 14, 17: no connection and no token draw their sentences; no state value carries the token. */ + @Test fun noConnectionAndNoTokenDrawTheirSentencesAndNoStateCarriesTheToken() = runTest { + connect() + handAsset() + entityList = { null } + val sheet = open("hand", SeasonSyncSheetPurpose.LINK) + act { sheet.chooseEntity() } + assertEquals(listOf(Notice(HA_NOT_CONNECTED)), sheet.now.browse!!.failure) + + entityList = { HaListOutcome.Failed(SyncErrorKind.NEEDS_TOKEN, null) } + act { sheet.refreshEntities() } + assertEquals(listOf(Notice(HA_ENTER_TOKEN_AGAIN)), sheet.now.browse!!.failure) + assertFalse(sheet.now.toString().contains("fictional-token")) + } } diff --git a/core/src/main/kotlin/com/loosecannon/servicetag/core/seasonsync/EntityPicker.kt b/core/src/main/kotlin/com/loosecannon/servicetag/core/seasonsync/EntityPicker.kt new file mode 100644 index 00000000..c2bf56be --- /dev/null +++ b/core/src/main/kotlin/com/loosecannon/servicetag/core/seasonsync/EntityPicker.kt @@ -0,0 +1,35 @@ +package com.loosecannon.servicetag.core.seasonsync + +/** + * #105 — what the browser lists. One scope in 1.8.0 (owner ruling Q3): Home Assistant's on/off helpers, the entities + * whose id is in the `input_boolean` domain. A candidate is in a scope by its id's domain alone — never by its state. + */ +enum class EntityScope(val domain: String) { + INPUT_BOOLEANS("input_boolean"), +} + +/** #105 — the most rows the browser lists; past it the owner is asked to search (P105-10). */ +const val MAX_LISTED_ENTITIES = 2_000 + +/** #105 — the rows to draw, and whether more matched than are drawn. */ +data class PickerRows(val rows: List, val truncatedList: Boolean) + +/** + * #105 (B1) — the browser's rows, pure: [all]'s candidates in [scope] (the id's domain before the dot equals the + * scope's), matching [query] — trimmed and casefolded, a substring of the casefolded friendly name or of the + * casefolded entity id; empty matches all — sorted by the casefolded friendly name then the entity id, a candidate + * with no name sorting by its id; at most [MAX_LISTED_ENTITIES], with [PickerRows.truncatedList] when more matched. + */ +fun pickerRows(all: List, scope: EntityScope, query: String): PickerRows { + val needle = query.trim().lowercase() + val matched = all.asSequence() + .filter { it.entityId.substringBefore('.') == scope.domain } + .filter { candidate -> + needle.isEmpty() || + candidate.entityId.lowercase().contains(needle) || + candidate.friendlyName?.lowercase()?.contains(needle) == true + } + .sortedWith(compareBy({ (it.friendlyName ?: it.entityId).lowercase() }, { it.entityId })) + .toList() + return PickerRows(matched.take(MAX_LISTED_ENTITIES), matched.size > MAX_LISTED_ENTITIES) +} diff --git a/core/src/main/kotlin/com/loosecannon/servicetag/core/seasonsync/HaEntityList.kt b/core/src/main/kotlin/com/loosecannon/servicetag/core/seasonsync/HaEntityList.kt new file mode 100644 index 00000000..842fd499 --- /dev/null +++ b/core/src/main/kotlin/com/loosecannon/servicetag/core/seasonsync/HaEntityList.kt @@ -0,0 +1,72 @@ +package com.loosecannon.servicetag.core.seasonsync + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject + +/** + * #105 — one Home Assistant entity as the setup sheet's browser lists it: its exact id, and the `friendly_name` Home + * Assistant reported for it, or null when it reported none. Presentation only: the binding stores [entityId] and + * nothing is ever inferred from [friendlyName]. + */ +data class HaEntityCandidate(val entityId: String, val friendlyName: String?) + +/** #105 — the answer to one foreground `GET /api/states`: the candidates, or a failure in the poll's own kinds. */ +sealed interface HaListOutcome { + data class Listed(val entities: List) : HaListOutcome + + data class Failed(val kind: SyncErrorKind, val detail: String?) : HaListOutcome +} + +/** #105 — the longest friendly name kept from an answer; the id keeps C5's [MAX_HA_TEXT_LENGTH]. */ +const val MAX_HA_NAME_LENGTH = 128 + +private val MALFORMED_LIST = HaListOutcome.Failed(SyncErrorKind.MALFORMED, null) + +/** + * #105 (B1) — the states list, pure. In order: + * 1. 401 or 403 → [SyncErrorKind.AUTH_REFUSED]; 300–399 → [SyncErrorKind.REDIRECTED]; any other status but 200 → + * [SyncErrorKind.HTTP_ERROR] with the status as `detail` — Test connection's map, not the poll's: a 404 here is not + * a missing entity. + * 2. A 200 that was [HaHttpAnswer.truncated], whose content type is not `application/json`, or whose body is not one + * JSON array → [SyncErrorKind.MALFORMED]. The cap is the client's (8 MiB for this call, owner ruling Q2). + * 3. Each array element that is an object with a string `entity_id` of C4's shape ([isValidEntityId]) and no longer + * than [MAX_HA_TEXT_LENGTH] is one candidate, the first of any duplicate id winning; anything else is skipped, + * never a failure. Its `attributes.friendly_name` is kept when it is a non-blank string, bounded by + * [MAX_HA_NAME_LENGTH] with control characters as `?` (C5 rule 4); otherwise null. + * + * Nothing about an entity's `state` is read: a helper is listed for what it is, never for what it reports (#105). + */ +fun mapHaStatesAnswer(answer: HaHttpAnswer): HaListOutcome { + val status = answer.status + if (status == 401 || status == 403) return HaListOutcome.Failed(SyncErrorKind.AUTH_REFUSED, null) + if (status in 300..399) return HaListOutcome.Failed(SyncErrorKind.REDIRECTED, null) + if (status != 200) return HaListOutcome.Failed(SyncErrorKind.HTTP_ERROR, status.toString()) + + if (answer.truncated) return MALFORMED_LIST + if (!isJsonMediaType(answer.contentType)) return MALFORMED_LIST + val array = parseArray(answer.body) ?: return MALFORMED_LIST + + val seen = HashSet() + val entities = ArrayList() + for (element in array) { + val entity = element as? JsonObject ?: continue + val id = entity.stringOrNull("entity_id") ?: continue + if (id.length > MAX_HA_TEXT_LENGTH || !isValidEntityId(id)) continue + if (!seen.add(id)) continue + val name = (entity["attributes"] as? JsonObject) + ?.stringOrNull("friendly_name") + ?.let { bounded(it, MAX_HA_NAME_LENGTH) } + ?.takeIf { it.isNotBlank() } + entities += HaEntityCandidate(id, name) + } + return HaListOutcome.Listed(entities) +} + +/** One JSON array, or null for anything else: an object, a scalar, or text that does not parse. */ +private fun parseArray(body: ByteArray): JsonArray? = try { + Json.parseToJsonElement(body.decodeToString()) as? JsonArray +} catch (e: IllegalArgumentException) { + // kotlinx.serialization's SerializationException is an IllegalArgumentException. + null +} diff --git a/core/src/main/kotlin/com/loosecannon/servicetag/core/seasonsync/HaStateMapper.kt b/core/src/main/kotlin/com/loosecannon/servicetag/core/seasonsync/HaStateMapper.kt index e6c2ca2c..39afc974 100644 --- a/core/src/main/kotlin/com/loosecannon/servicetag/core/seasonsync/HaStateMapper.kt +++ b/core/src/main/kotlin/com/loosecannon/servicetag/core/seasonsync/HaStateMapper.kt @@ -73,14 +73,17 @@ fun parseObject(body: ByteArray): JsonObject? = try { } /** The member's text when it is a JSON string; null when it is absent, `null`, a number, a boolean or a container. */ -private fun JsonObject.stringOrNull(key: String): String? = +internal fun JsonObject.stringOrNull(key: String): String? = (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content -/** C5 rule 4: at most [MAX_HA_TEXT_LENGTH] characters (never half a surrogate pair), control characters as `?`. */ -private fun bounded(text: String): String { +/** + * C5 rule 4: at most [max] characters (never half a surrogate pair), control characters as `?`. [max] defaults to + * [MAX_HA_TEXT_LENGTH]; the entity list (#105) bounds a friendly name at [MAX_HA_NAME_LENGTH] through the same rule. + */ +internal fun bounded(text: String, max: Int = MAX_HA_TEXT_LENGTH): String { var kept = text - if (kept.length > MAX_HA_TEXT_LENGTH) { - kept = kept.substring(0, MAX_HA_TEXT_LENGTH) + if (kept.length > max) { + kept = kept.substring(0, max) if (kept.last().isHighSurrogate()) kept = kept.dropLast(1) } return buildString(kept.length) { diff --git a/core/src/test/kotlin/com/loosecannon/servicetag/core/seasonsync/EntityPickerTest.kt b/core/src/test/kotlin/com/loosecannon/servicetag/core/seasonsync/EntityPickerTest.kt new file mode 100644 index 00000000..c005a283 --- /dev/null +++ b/core/src/test/kotlin/com/loosecannon/servicetag/core/seasonsync/EntityPickerTest.kt @@ -0,0 +1,62 @@ +package com.loosecannon.servicetag.core.seasonsync + +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import org.junit.jupiter.api.Test + +/** #105 rows 5–8 (B1): the browser's rows, pure; every entity fictional. */ +class EntityPickerTest { + + private val stove = HaEntityCandidate("input_boolean.pellet_stove_in_season", "Pellet Stove In Season") + private val heater = HaEntityCandidate("input_boolean.example_heater_in_season", "Example Heater In Season") + private val nameless = HaEntityCandidate("input_boolean.example_unnamed", null) + private val lamp = HaEntityCandidate("switch.example_lamp", "Example Lamp") + private val sensor = HaEntityCandidate("binary_sensor.example_door", "Pellet Stove Door") + private val lookalike = HaEntityCandidate("input_boolean_x.example", "Pellet Stove Lookalike") + private val all = listOf(lamp, stove, sensor, nameless, lookalike, heater) + + private fun rows(query: String) = pickerRows(all, EntityScope.INPUT_BOOLEANS, query) + + /** Row 5: the scope is the id's domain, exactly — `input_boolean_x.` and `switch.` are out, whatever their names say. */ + @Test + fun theScopeIsTheDomainBeforeTheDot() { + assertEquals(listOf(heater, nameless, stove), rows("").rows) + } + + /** Row 6: a casefolded substring of the name or of the id; trimmed; empty matches all. */ + @Test + fun searchIsACasefoldedSubstringOfNameOrId() { + assertEquals(listOf(stove), rows("pellet").rows) + assertEquals(listOf(stove), rows(" STOVE IN ").rows) + assertEquals(listOf(stove), rows("pellet_stove").rows) + assertEquals(listOf(heater, nameless), rows("example_").rows) + assertEquals(listOf(nameless), rows("unnamed").rows, "a nameless candidate is found by its id") + assertEquals(emptyList(), rows("door").rows, "the sensor's name is out of scope") + assertEquals(listOf(heater, nameless, stove), rows(" ").rows) + } + + /** Row 7: name casefolded then id; a nameless candidate sorts by its id among the names. */ + @Test + fun orderIsNameCasefoldedThenId() { + val b = HaEntityCandidate("input_boolean.b", "alpha") + val a = HaEntityCandidate("input_boolean.a", "Alpha") + val c = HaEntityCandidate("input_boolean.c", "Beta") + val n = HaEntityCandidate("input_boolean.aardvark", null) + // "alpha" twice, told apart by id; "beta"; then the nameless one, whose key is its own id ("input_boolean…"). + assertEquals(listOf(a, b, c, n), pickerRows(listOf(c, b, n, a), EntityScope.INPUT_BOOLEANS, "").rows) + } + + /** Row 8: at most MAX_LISTED_ENTITIES rows, and the flag when more matched. */ + @Test + fun theListIsBoundedAndSaysSo() { + val many = (1..MAX_LISTED_ENTITIES + 1).map { HaEntityCandidate("input_boolean.e${"%05d".format(it)}", null) } + val over = pickerRows(many, EntityScope.INPUT_BOOLEANS, "") + assertEquals(MAX_LISTED_ENTITIES, over.rows.size) + assertTrue(over.truncatedList) + val exact = pickerRows(many.dropLast(1), EntityScope.INPUT_BOOLEANS, "") + assertEquals(MAX_LISTED_ENTITIES, exact.rows.size) + assertFalse(exact.truncatedList) + assertFalse(pickerRows(many, EntityScope.INPUT_BOOLEANS, "e00001").truncatedList, "a narrowed list is whole") + } +} diff --git a/core/src/test/kotlin/com/loosecannon/servicetag/core/seasonsync/HaStatesMapperTest.kt b/core/src/test/kotlin/com/loosecannon/servicetag/core/seasonsync/HaStatesMapperTest.kt new file mode 100644 index 00000000..6d953e63 --- /dev/null +++ b/core/src/test/kotlin/com/loosecannon/servicetag/core/seasonsync/HaStatesMapperTest.kt @@ -0,0 +1,104 @@ +package com.loosecannon.servicetag.core.seasonsync + +import kotlin.test.assertEquals +import kotlin.test.assertIs +import org.junit.jupiter.api.Test + +/** #105 rows 1–4 (B1): the states-list mapper over scripted answers; every value fictional. */ +class HaStatesMapperTest { + + private fun answer( + body: String, + status: Int = 200, + contentType: String? = "application/json", + truncated: Boolean = false, + ) = HaHttpAnswer(status, contentType, body.toByteArray(), truncated) + + private fun listed(body: String): List = + assertIs(mapHaStatesAnswer(answer(body))).entities + + /** Row 1: every object with a valid id maps; the name only when a string; invalid ids, non-objects and duplicates go. */ + @Test + fun aStatesArrayMapsEveryValidEntityOnceWithItsNameWhenItHasOne() { + val body = """[ + {"entity_id":"input_boolean.example_heater_in_season","state":"on","attributes":{"friendly_name":"Example Heater In Season"}}, + {"entity_id":"input_boolean.example_pump","state":"off","attributes":{}}, + {"entity_id":"input_boolean.example_numeric_name","state":"off","attributes":{"friendly_name":42}}, + {"entity_id":"input_boolean.example_blank_name","state":"off","attributes":{"friendly_name":" "}}, + {"entity_id":"switch.example_lamp","state":"on","attributes":{"friendly_name":"Example Lamp"}}, + {"entity_id":"Input_Boolean.Bad_Case","state":"on","attributes":{}}, + {"entity_id":"no_dot","state":"on","attributes":{}}, + {"state":"on","attributes":{"friendly_name":"No id"}}, + {"entity_id":42,"state":"on"}, + "not an object", + null, + {"entity_id":"input_boolean.example_heater_in_season","state":"off","attributes":{"friendly_name":"Duplicate"}} + ]""" + + assertEquals( + listOf( + HaEntityCandidate("input_boolean.example_heater_in_season", "Example Heater In Season"), + HaEntityCandidate("input_boolean.example_pump", null), + HaEntityCandidate("input_boolean.example_numeric_name", null), + HaEntityCandidate("input_boolean.example_blank_name", null), + HaEntityCandidate("switch.example_lamp", "Example Lamp"), + ), + listed(body), + ) + assertEquals(emptyList(), listed("[]")) + } + + /** Row 2: the statuses map as Test connection's do — a 404 is an HTTP error here, never a missing entity. */ + @Test + fun statusesMapAsTestConnectionsDo() { + assertEquals(HaListOutcome.Failed(SyncErrorKind.AUTH_REFUSED, null), mapHaStatesAnswer(answer("", status = 401))) + assertEquals(HaListOutcome.Failed(SyncErrorKind.AUTH_REFUSED, null), mapHaStatesAnswer(answer("", status = 403))) + for (status in listOf(301, 302, 307, 308)) { + assertEquals(HaListOutcome.Failed(SyncErrorKind.REDIRECTED, null), mapHaStatesAnswer(answer("", status = status)), "$status") + } + for (status in listOf(404, 500, 502, 503, 418)) { + assertEquals( + HaListOutcome.Failed(SyncErrorKind.HTTP_ERROR, status.toString()), + mapHaStatesAnswer(answer("", status = status)), + "$status", + ) + } + } + + /** Row 3: a cut-off body, the wrong type, an object, a scalar or non-JSON are MALFORMED, never a partial list. */ + @Test + fun truncatedWrongTypeOrNonArrayIsMalformed() { + val malformed = HaListOutcome.Failed(SyncErrorKind.MALFORMED, null) + assertEquals(malformed, mapHaStatesAnswer(answer("""[{"entity_id":"input_boolean.a"}]""", truncated = true))) + assertEquals(malformed, mapHaStatesAnswer(answer("[]", contentType = "text/html"))) + assertEquals(malformed, mapHaStatesAnswer(answer("[]", contentType = null))) + assertEquals(malformed, mapHaStatesAnswer(answer("""{"entity_id":"input_boolean.a"}"""))) + assertEquals(malformed, mapHaStatesAnswer(answer("\"on\""))) + assertEquals(malformed, mapHaStatesAnswer(answer("[{\"entity_id\":"))) + assertEquals( + listOf(HaEntityCandidate("input_boolean.a", null)), + assertIs( + mapHaStatesAnswer(answer("""[{"entity_id":"input_boolean.a"}]""", contentType = "Application/JSON; charset=utf-8")), + ).entities, + "the media type's parameters and case do not matter", + ) + } + + /** Row 4: the name is bounded at 128 with control characters as `?` and no split pair; an over-long id is skipped. */ + @Test + fun namesAreBoundedAndControlFreeAndAnOverLongIdIsSkipped() { + val longName = "n".repeat(127) + "😀" + "tail" + val longId = "input_boolean." + "x".repeat(MAX_HA_TEXT_LENGTH) + val body = """[ + {"entity_id":"input_boolean.example_long","attributes":{"friendly_name":"$longName"}}, + {"entity_id":"input_boolean.example_control","attributes":{"friendly_name":"Line\nbreak\ttab"}}, + {"entity_id":"$longId","attributes":{"friendly_name":"Too long an id"}} + ]""" + + val entities = listed(body) + + assertEquals(2, entities.size) + assertEquals("n".repeat(127), entities[0].friendlyName, "the pair would be split, so it goes with the tail") + assertEquals("Line?break?tab", entities[1].friendlyName) + } +} diff --git a/docs/capabilities.md b/docs/capabilities.md index ed864ed0..8d03c1c2 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -58,6 +58,7 @@ The phone supports: - **Force in season** - **Force out of season** - **Sync now** +- choosing the helper from a searchable list of Home Assistant's `input_boolean` entities, or entering its entity ID by hand; - polling every 12 hours, daily, weekly, or monthly; - HTTPS on any network, or a selected home Wi-Fi mode with optional background checks. diff --git a/docs/home-assistant-season-sync.md b/docs/home-assistant-season-sync.md index c487e024..246d7d64 100644 --- a/docs/home-assistant-season-sync.md +++ b/docs/home-assistant-season-sync.md @@ -115,9 +115,16 @@ Open Settings › Utilities › **Home Assistant**. ## On the phone: linking an Asset -With a connection saved, an Asset's season card offers **Link to Home Assistant**. Enter the **Entity ID** -(`input_boolean.example_heater_in_season`): lowercase letters, digits and underscores, with one dot. Before -anything is written, the sheet says what linking does to the Asset's operating season: +With a connection saved, an Asset's season card offers **Link to Home Assistant**. Tap **Choose entity**: the sheet +reads Home Assistant's on/off helpers (its `input_boolean` entities) once and lists them by name, each over its exact +entity ID; search by the name or by the ID, tap the one that is on while this Asset is in season, and it is shown +on the sheet with **Change** beside it. The list is read only when you tap Choose entity or **Refresh**, over the +same address, token and network rule as every other check; if it cannot be read, the sheet says why and keeps +whatever you had chosen. **Enter entity ID manually** is always there instead: type the **Entity ID** +(`input_boolean.example_heater_in_season`): lowercase letters, digits and underscores, with one dot. Either way the +link stores the exact entity ID and never a name: a helper renamed in Home Assistant keeps working, and the next +list shows its new name. Before anything is written, the sheet says what linking does to the Asset's operating +season: - **Started and ended by hand already:** "From now on Home Assistant starts and ends this asset's season. Its season history stays as it is." @@ -190,6 +197,10 @@ it came from. Neither shows the address, the home Wi-Fi's name or the token, and deletes the link's record of its last change; the season history stays. - **Proven on an emulator, not yet on a phone.** A phone's own Wi-Fi, its private DNS and Android's battery saving (Doze) have not been observed, and a background check's read of the Wi-Fi name is proven only on an emulator. +- **The entity list is `input_boolean` helpers only**, read through the REST states list; a switch or a sensor is + entered by hand. Devices are not shown: Home Assistant's device and display registries are not read in this + release. Lists past the first 2,000 helpers are cut, and the sheet asks you to search; a list over 8 MB is refused + as unreadable, and manual entry remains. - **Some cases are silent.** If the phone's key store or database fails during Sync now or a card action, nothing is shown and the last status stays; try again. A capture that reads a blank Wi-Fi name, or a name Android hides without a reason, captures nothing and shows no line. diff --git a/docs/superpowers/plans/2026-10-05-issue-105-home-assistant-entity-picker.md b/docs/superpowers/plans/2026-10-05-issue-105-home-assistant-entity-picker.md new file mode 100644 index 00000000..8a0713c3 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-issue-105-home-assistant-entity-picker.md @@ -0,0 +1,282 @@ +# #105 — browse and pick a Home Assistant entity during season-sync setup: design and plan (rev 1.0, 2026-10-05) + +> **Status: RULED 2026-10-05; B1, B2 and B3 authorized** (§8 carries the rulings verbatim in substance; §5's ten strings +> are RATIFIED). **Release:** content of **ServiceTag 1.8.0** — the former 1.7.2 work, folded into the combined 1.8.0 +> train with the former 1.7.1 work and #102 (#104); no release of its own. **Classification stands as the issue states:** a UX improvement to the shipped #16 setup flow — the stored +> binding is still the exact entity id, `on`/`off` are still the only season decisions, no new integration type. + +**Base:** master `c994341` (1.7.0 / code 20 released; on master unreleased: #102's localization layer and nine language +packs, the 1.7.1 train #94 #99 #101 #103; Room schema 21 / backup format 20; gitlink `7e0377a`; MCP 90 tools). +**Inputs:** #105 (the owner's; its acceptance list is §9's); #16's plan (`2026-10-02-issue-16-home-assistant-season-sync.md`, +whose C5, C8, C19, C27 and §5 this extends and never re-opens); `docs/home-assistant-season-sync.md`; `docs/localization.md` +(every new word is a resource in every pack, from the first commit); `docs/superpowers/planning-policy.md`. Paths as the +#16 plan: `C/`, `CT/` = core main and test under `com/loosecannon/servicetag/core/`; `A/`, `T/`, `AT/` = app main, test and +androidTest under `com/loosecannon/servicetag/`. Every `file:line` was read on `c994341`. +**Process:** the planning policy — plans specify; one task review per brief, batched fixes, at most one scoped re-review; +JVM first, Compose instrumented second, no UI driving; hygiene as #16's briefs (no real host, token, entity or e-mail in +any file; fixtures are `192.168.0.10`, `ha.example`, `fictional-token-1`, `input_boolean.example_heater_in_season`). + +## 1. The problem, and the shape of the answer + +Today the setup sheet (`A/ui/asset/LinkSeasonSyncSheet.kt`, opened by **Link to Home Assistant** on the season card) asks +for the **Entity ID** typed by hand (P16-42/43), validates its shape (`isValidEntityId`, C4) and links. The owner has to +know `input_boolean.pellet_stove_in_season`, which is an implementation identifier, and a typo is found only by the fresh +check after Save. + +The answer is a **browser inside the same sheet**: a **Choose entity** row in place of the bare field; tapping it turns +the sheet into a searchable list of Home Assistant's `input_boolean` helpers — friendly name over entity id, sorted by +name — read once from Home Assistant through the shipped client under every #16 rule; a tap chooses one and returns to +the form with the choice shown; **Enter entity ID manually** keeps the typed path. Save links exactly as today, with the +exact entity id. Nothing about the binding, the poll, the applier, the modes, the token, the network gate, the schema, +the backup, the API or the MCP changes. + +**What validates a picked entity, and when (owner correction 2026-10-05).** `LinkSeasonSync.run()` checks the entity +id's shape, **commits** the binding (and any switch into MANUAL) and only then asks the scheduler for a fresh read: the +fresh Home Assistant read happens **after** the binding is written, exactly as #16 shipped it. For a picker-selected +entity the pre-save evidence is the successful foreground `/api/states` list that produced that exact candidate; Save +passes the exact id through `LinkSeasonSync` unchanged; the existing post-commit fresh read remains the authoritative +runtime check, and a helper that has since disappeared or reports a state that is neither on nor off is drawn on the +card by the existing status lines (P16-17, P16-18). **No per-entity network request is added to change that order.** +Manual entry keeps today's behaviour in full. + +Three things the design settles up front: + +1. **Entity-first, REST-only in this cut (Q1).** The list comes from `GET /api/states` — the one authenticated read + endpoint the client already speaks, over the same origin, policy, headers, timeouts and failure map — filtered on the + phone to `input_boolean.*`, with `attributes.friendly_name` as the display name. Home Assistant's display-registry and + device-registry lists are WebSocket-only; #16's scope ruling (R16-0) excludes a second transport, and a device view is + a convenience the issue names as optional. So **no device grouping in this cut**: the acceptance bullet about device + context is **deferred by owner ruling** (Q1), not satisfied; helpers without a device remain fully selectable; a later + issue may add the WebSocket registry reads. +2. **The browser is a mode of the sheet, not a route (Q6).** The sheet already fills the height + (`skipPartiallyExpanded = true`); its one view model gains a browse state, so the pick needs no result passing between + destinations and a rotation keeps both the list and the choice. Nothing is read until the owner taps Choose entity. +3. **Nothing is inferred from state or name.** A helper is listed because its id starts with `input_boolean.`, never + because its state is `on`/`off`; the binding stores the id the owner tapped; a renamed helper in Home Assistant changes + the name shown next time and nothing else. + +## 2. Global constraints (binding on every brief) + +- **R16-0 stands.** No write to Home Assistant, no service call, no WebSocket/SSE, no inbound listener, no second HTTP + stack or credential store, no background inventory read, no persisted registry: the list lives in the sheet's view + model and is gone when the sheet closes. +- **Every read goes through `HomeAssistantStateClient.exchange`** (C19): the address rule, the network permission, the + home-network gate and the private-name rule run before anything is opened; the four request properties, no redirect, + no cache; the token is a parameter for one request and reaches no state, log, message or `toString`. +- **Bounded.** The list answer is read under its own cap (Q2) and marked `truncated` beyond it; each kept entity id and + friendly name is bounded by C5's rule 4 (`MAX_HA_TEXT_LENGTH` for the id, a longer bound for the name, §3); at most + `MAX_LISTED_ENTITIES` candidates are kept after filtering (§3). A truncated or non-array answer is `MALFORMED`, drawn + as P16-19. +- **Exact id, exact rules.** A picker candidate was observed in the explicit foreground list before selection; the + chosen id goes to `LinkSeasonSync.run` unchanged, which validates the shape and writes exactly as #16 does today; the + existing fresh read runs **after the commit** and reports a vanished or unsupported helper through the card's existing + status (P16-17/18). No extra pre-save request. +- **A failed read changes nothing**: not the typed or chosen id, not the binding, not the season. Refresh keeps the + current selection and the last good list until a new one arrives. +- **Strings are resources from the first commit** (`docs/localization.md`): each §5 string in `values/strings_asset_edit.xml` + with its P105 id in the comment, read through a getter or `stringResource`, and in **every pack** in the same change, + marked as a draft translation in the report. `UiLiteralGuardTest` and `LocalizationCoverageTest` are the gate. +- **No schema, format, API, MCP, permission or manifest change.** `docs/api/v1.md` is untouched; `GET /v1/assets/{id}/season-sync` + reads the same binding. +- No device but `emulator-5554` for implementers; the real-HA proof is the controller's (§7). + +## 3. Contracts (frozen; B1 builds, B2 and B3 consume) + +```kotlin +// C/seasonsync/HaEntityList.kt (B1) +data class HaEntityCandidate(val entityId: String, val friendlyName: String?) // name null when HA gave none +sealed interface HaListOutcome { + data class Listed(val entities: List) : HaListOutcome // the count bound is pickerRows's + data class Failed(val kind: SyncErrorKind, val detail: String?) : HaListOutcome // the poll's kinds, reused +} +const val MAX_HA_NAME_LENGTH = 128 +const val MAX_LISTED_ENTITIES = 2_000 +/** A 200 JSON array of objects → every object with a string `entity_id` whose shape passes `isValidEntityId`, its + * `attributes.friendly_name` when a string; ids bounded by MAX_HA_TEXT_LENGTH, names by MAX_HA_NAME_LENGTH, control + * characters as `?`; duplicates by id dropped (first wins). Statuses as `testOutcomeOf`: 401/403 AUTH_REFUSED, 3xx + * REDIRECTED, other non-200 HTTP_ERROR(status); truncated body, non-JSON type or non-array → MALFORMED. */ +fun mapHaStatesAnswer(answer: HaHttpAnswer): HaListOutcome + +// C/seasonsync/EntityPicker.kt (B1) — pure +enum class EntityScope { INPUT_BOOLEANS } // one scope in this cut (Q3) +/** The scope's candidates, searched and sorted: a candidate matches [query] (trimmed, casefolded) as a substring of its + * casefolded friendly name or entity id; empty query matches all; sorted by (friendly name casefolded, entity id), a + * candidate with no name sorting by its id; at most MAX_LISTED_ENTITIES, `truncatedList` when more matched the scope. */ +fun pickerRows(all: List, scope: EntityScope, query: String): PickerRows +data class PickerRows(val rows: List, val truncatedList: Boolean) + +// A/seasonsync/HomeAssistantStateClient.kt (B2) +suspend fun listStates(connection: HaConnection, token: Secret): HaListOutcome // GET /api/states, cap LIST_MAX_BODY_BYTES +// LIST_MAX_BODY_BYTES = 8 MiB (Q2); everything else exactly `exchange`'s + +// A/ui/asset/LinkSeasonSyncViewModel.kt (B3) +data class EntityBrowseState( + val loading: Boolean, val query: String, val all: List, val rows: List, + val truncatedList: Boolean, val loadedOnce: Boolean, val failure: List, // P16 sentences, or empty +) +// LinkSeasonSyncState gains: chosen: HaEntityCandidate?, manualEntry: Boolean, browse: EntityBrowseState? +// methods: chooseEntity() (opens browse, reads once), refreshEntities(), onQuery(text), pick(candidate), closeBrowse(), +// enterManually(); save() sends chosen?.entityId ?: entityId.trim() +``` + +The view model takes the list read as a seam, `listEntities: suspend () -> HaListOutcome`, wired in `AppGraph` to +`{ haStateClient.listStates(connection, token) }` over the stored connection and `secretStore.get(HA_TOKEN_KEY)`; no +connection or no token answers `Failed(NEEDS_TOKEN)` / `NO_CONNECTION`'s sentence without opening anything. + +## 4. Behaviour (the contract the tests pin) + +**The form (LINK purpose only; RESUME is unchanged).** +- With no choice and manual entry off: a **Choose entity** row (P105-1) with P16-43's helper under it; Save disabled + until a choice or a manual id exists (`canSave` gains `hasEntity`). +- After a pick: the row shows the friendly name over the entity id (or the id alone when HA gave no name) and a + **Change** affordance (P105-2); Save enabled. +- **Enter entity ID manually** (P105-3) swaps the row for today's text field (P16-42/43/49 unchanged); a typed id clears + the chosen candidate; **Choose entity** is still offered under the field, and a pick clears the typed text. +- Save: `link.run(assetId, id)` with the exact id — the picked candidate's or the trimmed typed text — so the shape + check, the commit and the post-commit fresh read are #16's unchanged; every refusal and the #78 question exactly as today. + +**The browser.** +- Opens on Choose entity; reads once (`loading`, P105-5 while it does, bounded by the client's `CALL_MILLIS`); draws a + search field (P105-4), the scope line (P105-6), **Refresh** (P105-7), and the rows — friendly name over entity id, + `LazyColumn`; the list is `pickerRows(all, INPUT_BOOLEANS, query)`. +- A tap on a row is the pick: back to the form with the choice shown. **Enter entity ID manually** is offered here too. +- Empty results are two different sentences: no helper at all in the scope (P105-8), or none matching the query (P105-9). + A truncated list adds P105-10 above the rows. +- A failure draws its P16 sentence (the `seasonSyncErrorNotices` map by kind, with the entity id argument empty) above + the last good list, which stays; the selection stays; Refresh retries. A failure on the first read shows the sentence + and no rows. Authentication/network failures are therefore never confused with "no matching entities". +- Closing the browser without a pick keeps whatever was chosen or typed before. +- Nothing is read on open, rotation or return; only Choose entity and Refresh read. + +**Home Assistant's rename of a bound helper** changes nothing: the card draws the stored id as today (P16-27); the +browser shows the new name next time it is opened. + +## 5. Strings — RATIFIED by the owner 2026-10-05 (P105-1…10; P105-8 and P105-9 in the owner's wording) + +Voice as #16's §5. Each is one resource in `values/strings_asset_edit.xml` (prefix `season_sync_`), with the P105 id in +its comment, and in every pack. Reused unchanged: P16-42 `Entity ID`, P16-43 the helper, P16-49 the shape refusal, +P16-19 `MALFORMED`, P16-13/14/15/16/20/21/50/51/52 by kind, `Save`, `Cancel`. + +| id | proposed wording | where | +|---|---|---| +| P105-1 | `Choose entity` | the form's row with nothing chosen; the browser's title | +| P105-2 | `Change` | on the form's row after a pick | +| P105-3 | `Enter entity ID manually` | the form's and the browser's advanced path | +| P105-4 | `Search by name or entity ID` | the search field's hint | +| P105-5 | `Reading entities from Home Assistant…` | the bounded loading line | +| P105-6 | `Showing Home Assistant's on/off helpers (input_boolean).` | the scope line over the rows | +| P105-7 | `Refresh` | the button | +| P105-8 | `No Home Assistant on/off helpers found. Create one in Home Assistant, or enter an entity ID manually.` | the scope is empty | +| P105-9 | `No entities match your search.` | the query matches nothing | +| P105-10 | `Only the first %1$d are shown. Search to narrow the list.` | `truncatedList` — `%1$d` is `MAX_LISTED_ENTITIES` | + +## 6. Briefs, order, files + +| brief | scope | layer | files | +|---|---|---|---| +| **B1 — the list mapper and the picker rules** (C/seasonsync) | `HaEntityCandidate`, `HaListOutcome`, `mapHaStatesAnswer`, `EntityScope`, `pickerRows` | core JVM | create `C/seasonsync/HaEntityList.kt`, `C/seasonsync/EntityPicker.kt`; `CT/seasonsync/HaStatesMapperTest.kt`, `CT/seasonsync/EntityPickerTest.kt` | +| **B2 — the client's list read** (A/seasonsync) | `listStates` through `exchange`, its own cap, `truncated` honoured; `AppGraph` seam | app JVM | modify `A/seasonsync/HomeAssistantStateClient.kt`, `A/di/AppGraph.kt`; `T/seasonsync/HomeAssistantStateClientTest.kt` (rows on the existing `Harness`/`Script`) | +| **B3 — the sheet's browser** (A/ui/asset) | the states and methods of §3, the form and browser composables, the strings and their nine translations | app JVM + Compose | modify `A/ui/asset/LinkSeasonSyncViewModel.kt`, `LinkSeasonSyncSheet.kt`, `SeasonSyncStrings.kt`; `res/values*/strings_asset_edit.xml` (ten packs); `T/ui/asset/LinkSeasonSyncViewModelTest.kt`; `AT/seasonsync/SeasonSyncScreensTest.kt` (the sheet's rows) | +| **B4 — the documents** | the linking section, the limits, the capabilities page | docs | `docs/home-assistant-season-sync.md` ("On the phone: linking an Asset", "Limits"), `docs/capabilities.md` (the HA paragraph: one sentence) | + +**Order:** B1 → B2 → B3 → B4, each `` the previous accepted tip; B2 may start beside B1 only with §3 frozen (it +is). One branch. **Untouched by all four:** `C/seasonsync/HaStateMapper.kt`, `LinkSeasonSync.kt`, `SeasonSyncRunner`, +the applier, the guard, the schema, `BackupData`, the codec, `docs/api/v1.md`, the MCP, the manifest, `libs/nfc-tag-core`, +`SeasonSyncPlatformProofTest`, every #16 string. + +## 7. Test matrix (every row names what fails without the change) + +| # | case | where | fails without | +|---|---|---|---| +| 1 | a 200 array maps every object with a valid `entity_id`; `friendly_name` when a string, null otherwise; invalid ids and non-objects dropped; duplicates by id first-wins | `HaStatesMapperTest` | a map that trusts the shape | +| 2 | statuses: 401/403 → AUTH_REFUSED, 3xx → REDIRECTED, 404/500 → HTTP_ERROR(status) | same | the poll's 404 → ENTITY_NOT_FOUND reused by mistake | +| 3 | truncated, non-JSON type, a JSON object or scalar → MALFORMED | same | a parse of a cut-off array | +| 4 | id bound 64, name bound 128, control characters `?`, surrogate pair never split | same | C5 rule 4 skipped | +| 5 | scope keeps `input_boolean.*` only; `switch.`, `binary_sensor.`, `input_boolean_x.` dropped | `EntityPickerTest` | a prefix test on `input_boolean` without the dot | +| 6 | search is a casefolded substring on name and on id; trimmed; empty matches all; `Pellet Stove In Season` found by `pellet`, by `stove in`, by `pellet_stove` | same | an exact or prefix match | +| 7 | order: name casefolded then id; a nameless candidate sorts by its id and draws it alone | same | an id order, or nulls first | +| 8 | at most MAX_LISTED_ENTITIES rows, `truncatedList` true beyond | same | an unbounded list | +| 9 | `listStates` GETs exactly `/api/states` with the four headers, no redirect, no cache, under the home-network gate; off the home Wi-Fi nothing opens | `HomeAssistantStateClientTest` | a path or header drift | +| 10 | a body over `LIST_MAX_BODY_BYTES` is `truncated` → MALFORMED; one just under maps | same | the 64 KiB cap reused | +| 11 | `Choose entity` reads once and fills `browse.rows`; nothing is read on construction | `LinkSeasonSyncViewModelTest` | a read in `init` | +| 12 | a pick sets `chosen`, clears typed text, closes the browser; `save()` sends the exact id | same | a name sent, or trailing whitespace | +| 13 | manual entry: typing clears `chosen`; Save sends the trimmed text; P16-49 on a bad shape as today | same | the two paths crossing | +| 14 | a failed first read draws the kind's sentence and no rows; a failed refresh keeps the last rows and the selection | same | the list cleared on failure | +| 15 | no helpers vs no match are two different states | same | one empty sentence | +| 16 | `canSave` false with nothing chosen and nothing typed; true after a pick; the #78 prompt flow unchanged | same | Save with an empty id | +| 17 | the token reaches no state: `toString` of every state value names no token, id list only | same | a leak into state | +| 18 | the form shows Choose entity, the pick fills the row with name and id, Save is enabled, and Enter entity ID manually brings the field back | `SeasonSyncScreensTest` | wiring | +| 19 | the browser's search narrows the rows; Refresh re-reads; a failure sentence is drawn over the rows | same | wiring | +| 20 | every P105 string exists in every pack, placeholders match | `LocalizationCoverageTest` (existing) | a missing translation | +| 21 | no English literal in the new Kotlin | `UiLiteralGuardTest` (existing) | a literal | + +The fake Home Assistant is the client test's scripted `HttpURLConnection` (`Harness`, `Script`) with a states-array body; +no socket in any JVM test. The view-model tests script `listEntities` directly. + +## 8. Owner rulings (2026-10-05; binding on every brief) + +- **Q1 — APPROVED, with a scope amendment.** REST `GET /api/states` only for 1.8.0; no WebSocket transport merely to + obtain the entity, display or device registries. Device grouping/context, `config/entity_registry/list_for_display` + and `config/device_registry/list` are **explicitly deferred**. The acceptance bullet about device context is deferred + by this ruling, not satisfied; helpers without devices remain fully selectable. +- **Q2 — APPROVED.** An **8 MiB cap for the explicit foreground `/api/states` list call** only; the ordinary #16 poll + stays at 64 KiB and its cap is not raised globally. Beyond 8 MiB the read fails boundedly (MALFORMED, P16-19) and + manual entry remains. +- **Q3 — APPROVED.** The browser exposes **`input_boolean.*` only** in this release; manual entry is the escape hatch for + anything else; no "all on/off entities" mode and no warning copy in #105. +- **Q4 — APPROVED.** Changing the entity of an already-linked Asset is out of scope; `ChangeSeasonSyncEntity` is not + surfaced by this work. #105 improves initial linking only. +- **Q5 — RATIFIED** with two wording edits (P105-8, P105-9), as §5 now reads. All ten go into the ten packs. +- **Q6 — APPROVED.** The browser is a mode of the existing setup sheet, not a pushed destination: one setup transaction. +- **Required correction, applied** (§1, §2, §4, §9): the fresh read happens after the commit, as shipped; a picked + candidate's pre-save evidence is the foreground list it came from; no per-entity request is added. +- **Administrative:** #105 is no longer a standalone 1.7.2 item; it is retitled and reclassified as 1.8.0 content. + +## 9. Acceptance map (#105's bullets → rows) + +choose without knowing the id → 18; searchable by name, id shown → 6, 18; search matches the id → 6; default results +are `input_boolean` → 5; helpers without a device remain selectable → 1, 5 (no device is read, so none can gate a row); +device context → **deferred by owner ruling Q1** (not met in 1.8.0); exact id into the #16 flow → 12 (the candidate was +observed in the foreground list; `LinkSeasonSync` validates the shape and commits; the post-commit fresh read reports +through the card); manual entry remains → 13, 18; no writes, #16's rules → 9, 17 and §2; failed discovery changes +nothing → 14; names are presentation only → 12 and §4; JVM/Android/fake-HA coverage → §7. + +## 9a. Execution record (2026-10-05) + +- **B1, B2, B3 and B4 executed** on `claude/sleepy-fermat-h5k5ul` after the rulings. Deviations from the text above, + each small: `HaListOutcome.Listed` carries the entities only (the count bound is `pickerRows`'s, §3 amended); + `HaStateMapper.kt`'s `stringOrNull` and `bounded` became `internal`, the latter taking its bound as a parameter, so + the list mapper applies C5 rule 4 through the same code (behaviour unchanged; the file is otherwise untouched); + ~~Save stays enabled with nothing chosen and nothing typed~~ (reversed by the PR review, below: row 16 holds as + written); the two device rows prove the + form's Choose entity row, the manual path and the browser's static parts and its Cancel, while the rows, the + sentences and the pick are the JVM's (no Home Assistant answers on the emulator); `enterManually` prefills the field + with a pick's id. Translations are drafts until a native speaker reviews them. +- **Gates run here:** `:core:test` (the two B1 classes, 8 cases, and the whole core suite green). **Not run here:** + `:app:testDebugUnitTest` (no Android SDK in the planning environment; CI on the branch is the proof of record for + the client rows, the view-model rows, `LocalizationCoverageTest` and `UiLiteralGuardTest`) and the connected class + (R2 on `emulator-5554`, the controller's step). CI's command was green on `471ba25`. +- **Task review (one, B1–B4 together; policy).** Three findings, fixed in one batch: (1) the list's JSON — up to + 8 MiB — was mapped on the caller's thread, the sheet's main one; `listStates` now maps it on the client's `io`; + (2) a read still running when the browser closed, or when Choose entity started another, was only ignored, so its + late answer could land in the next browser over a newer list; the view model now holds the read's job and a new + read, a pick, manual entry and a close each cancel it (the client disconnects on cancel), pinned by a new row-14 + case that fails without it; (3) P105-8's "scope empty" was re-sorted on every keystroke; it is computed once per + list. Noted, not changed: the system back gesture while the browser is open closes the whole sheet (no pick, nothing + written), as the shipped sheet's back does; a key-store or database failure before the read draws no sentence, as + Save's does (none is ratified for it). +- **PR review (#107, at `206a00e`).** Two blocking findings, fixed together: (1) the sheet's own dismissals — back, a + scrim tap, a swipe and the form's Cancel — called the screen's close directly, so a list read still running outlived + the sheet (each opening's model is keyed under the asset page, which stays); they now go through the model's + `dismiss()`, which cancels it; (2) Save is held on Link until there is an entity to send — a pick or non-blank typed + text — as row 16 specifies; Resume is unchanged. The fix also caught two shipped device rows (the S55 sheet and #78's + YEAR_ROUND link) that typed into a field the browse-first sheet no longer shows: their typing now opens Enter + entity ID manually first, and the S55 row asserts Save held, then enabled. **Proof gate kept by the review:** the + connected `SeasonSyncScreensTest` on `emulator-5554` before merge (CI does not run connected tests). + +## 10. Proofs at the tip (controller) + +CI's command; the connected classes `SeasonSyncScreensTest` first, then the whole suite on `emulator-5554`; R6 hygiene +greps; the real-HA proof as #16's §7, once: open the sheet against the owner's instance, see the helpers listed by name, +pick one, Save, the fresh check lands (no address, entity or token in any file). No upgrade paragraph: no schema or format +step. 1.8.0's release notes carry one paragraph for this.