Skip to content

Commit 2261905

Browse files
authored
Add Improv external bus messages models (#6817)
1 parent cb1345f commit 2261905

12 files changed

Lines changed: 273 additions & 0 deletions

File tree

app/src/main/kotlin/io/homeassistant/companion/android/frontend/FrontendViewModel.kt

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,15 @@ internal class FrontendViewModel @VisibleForTesting constructor(
588588
exoPlayerManager.handle(result)
589589
}
590590

591+
is FrontendHandlerEvent.StartImprovScan,
592+
is FrontendHandlerEvent.ConfigureImprovDevice,
593+
-> {
594+
// Improv handling lands in a follow-up PR; the messages are already typed and
595+
// canSetupImprov will be `false` on devices without BLE so the frontend should
596+
// not be sending these yet on hardware that lacks Bluetooth LE.
597+
Timber.d("Improv event received but not yet handled: $result")
598+
}
599+
591600
is FrontendHandlerEvent.ConfigSent,
592601
is FrontendHandlerEvent.UnknownMessage,
593602
-> {

app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessage.kt

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,3 +216,36 @@ data class ExoPlayerResizePayload(
216216
val right: Double = 0.0,
217217
val bottom: Double = 0.0,
218218
)
219+
220+
/**
221+
* Message requesting the app to start scanning for nearby BLE devices that advertise the
222+
* Improv Wi-Fi service.
223+
*
224+
* The frontend sends this once when the user opens its "Add device" flow. The app responds
225+
* out-of-band with a stream of [io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDiscoveredDeviceMessage]
226+
* commands as devices are discovered. No `result`-shaped response is expected.
227+
*
228+
* Will not be sent by the frontend when the device reports
229+
* [io.homeassistant.companion.android.frontend.externalbus.outgoing.ConfigResult.canSetupImprov] = `false`.
230+
*/
231+
@Serializable
232+
@SerialName("improv/scan")
233+
data class ImprovScanMessage(override val id: Int? = null) : IncomingExternalBusMessage
234+
235+
/**
236+
* Message requesting the app to begin Wi-Fi onboarding for the named improv device the user
237+
* picked from the discovery list.
238+
*
239+
* The app should open the credentials dialog for [ImprovConfigureDevicePayload.name], submit
240+
* the entered Wi-Fi credentials over BLE, and finally — once the device reports it has been
241+
* provisioned — emit
242+
* [io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDeviceSetupDoneMessage]
243+
* and navigate the frontend to the matching `config_flow_start` URL.
244+
*/
245+
@Serializable
246+
@SerialName("improv/configure_device")
247+
data class ImprovConfigureDeviceMessage(override val id: Int? = null, val payload: ImprovConfigureDevicePayload) :
248+
IncomingExternalBusMessage
249+
250+
@Serializable
251+
data class ImprovConfigureDevicePayload(val name: String)

app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/CommandMessage.kt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,3 +68,32 @@ object NavigateToMessage {
6868
* @see CommandMessage
6969
*/
7070
val ShowSidebarMessage: OutgoingExternalBusMessage = CommandMessage(command = "sidebar/show")
71+
72+
/**
73+
* Notifies the frontend that an improv-capable BLE device has been discovered while scanning.
74+
*
75+
* Emitted once per device — the frontend deduplicates by [name] when adding the entry to its
76+
* "Add device" list. This is a one-way command; the frontend does not respond.
77+
*
78+
* @see CommandMessage
79+
*/
80+
object ImprovDiscoveredDeviceMessage {
81+
operator fun invoke(name: String): OutgoingExternalBusMessage = CommandMessage(
82+
command = "improv/discovered_device",
83+
payload = frontendExternalBusJson.encodeToJsonElement(DiscoveredDevicePayload(name = name)),
84+
)
85+
86+
@Serializable
87+
private data class DiscoveredDevicePayload(val name: String)
88+
}
89+
90+
/**
91+
* Notifies the frontend that the user-selected improv device has finished its Wi-Fi onboarding
92+
* (BLE state moved to provisioned). The frontend uses this to dismiss its progress UI before
93+
* the app navigates the WebView to the `config_flow_start` URL for the device's domain.
94+
*
95+
* One-way command; no response is expected.
96+
*
97+
* @see CommandMessage
98+
*/
99+
val ImprovDeviceSetupDoneMessage: OutgoingExternalBusMessage = CommandMessage(command = "improv/device_setup_done")

app/src/main/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/OutgoingExternalBusMessage.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,12 +88,14 @@ data class ConfigResult(
8888
canCommissionMatter: Boolean,
8989
canExportThread: Boolean,
9090
hasBarCodeScanner: Int,
91+
canSetupImprov: Boolean,
9192
appVersion: AppVersion,
9293
) = ConfigResult(
9394
canWriteTag = hasNfc,
9495
canCommissionMatter = canCommissionMatter,
9596
canImportThreadCredentials = canExportThread,
9697
hasBarCodeScanner = hasBarCodeScanner,
98+
canSetupImprov = canSetupImprov,
9799
appVersion = appVersion.value,
98100
)
99101
}

app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendHandlerEvent.kt

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,26 @@ sealed interface FrontendHandlerEvent {
8181
*/
8282
data class WriteNfcTag(val messageId: Int, val tagId: String?) : FrontendHandlerEvent
8383

84+
/**
85+
* Frontend requested the app to start scanning for improv (Wi-Fi onboarding) BLE devices.
86+
*
87+
* The ViewModel is responsible for the BLE-feature gate, the runtime permission flow
88+
* (Bluetooth + Location), and emitting
89+
* [io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDiscoveredDeviceMessage]
90+
* for each device the scanner reports.
91+
*/
92+
data object StartImprovScan : FrontendHandlerEvent
93+
94+
/**
95+
* User picked an improv device in the frontend's list. The ViewModel should open the
96+
* Wi-Fi-credentials dialog seeded with [deviceName], drive the BLE provisioning flow, and
97+
* emit [io.homeassistant.companion.android.frontend.externalbus.outgoing.ImprovDeviceSetupDoneMessage]
98+
* once provisioning succeeds.
99+
*
100+
* @param deviceName Advertised name of the BLE device the user selected.
101+
*/
102+
data class ConfigureImprovDevice(val deviceName: String) : FrontendHandlerEvent
103+
84104
sealed interface ExoPlayerAction : FrontendHandlerEvent {
85105

86106
/**

app/src/main/kotlin/io/homeassistant/companion/android/frontend/handler/FrontendMessageHandler.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import io.homeassistant.companion.android.frontend.externalbus.incoming.ExoPlaye
1616
import io.homeassistant.companion.android.frontend.externalbus.incoming.ExoPlayerStopMessage
1717
import io.homeassistant.companion.android.frontend.externalbus.incoming.HandleBlobMessage
1818
import io.homeassistant.companion.android.frontend.externalbus.incoming.HapticMessage
19+
import io.homeassistant.companion.android.frontend.externalbus.incoming.ImprovConfigureDeviceMessage
20+
import io.homeassistant.companion.android.frontend.externalbus.incoming.ImprovScanMessage
1921
import io.homeassistant.companion.android.frontend.externalbus.incoming.IncomingExternalBusMessage
2022
import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenAssistMessage
2123
import io.homeassistant.companion.android.frontend.externalbus.incoming.OpenAssistSettingsMessage
@@ -25,6 +27,7 @@ import io.homeassistant.companion.android.frontend.externalbus.incoming.ThemeUpd
2527
import io.homeassistant.companion.android.frontend.externalbus.incoming.UnknownIncomingMessage
2628
import io.homeassistant.companion.android.frontend.externalbus.outgoing.ConfigResult
2729
import io.homeassistant.companion.android.frontend.externalbus.outgoing.ResultMessage
30+
import io.homeassistant.companion.android.frontend.improv.BluetoothCapabilities
2831
import io.homeassistant.companion.android.frontend.js.FrontendJsHandler
2932
import io.homeassistant.companion.android.frontend.session.AuthPayload
3033
import io.homeassistant.companion.android.frontend.session.ExternalAuthResult
@@ -64,6 +67,7 @@ class FrontendMessageHandler @Inject constructor(
6467
private val appVersionProvider: AppVersionProvider,
6568
private val sessionManager: ServerSessionManager,
6669
private val downloadManager: FrontendDownloadManager,
70+
private val bluetoothCapabilities: BluetoothCapabilities,
6771
@param:IsAutomotive private val isAutomotive: Boolean,
6872
) : FrontendJsHandler,
6973
FrontendBusObserver {
@@ -224,6 +228,16 @@ class FrontendMessageHandler @Inject constructor(
224228
FrontendHandlerEvent.DownloadCompleted(result)
225229
}
226230

231+
is ImprovScanMessage -> {
232+
Timber.d("improv/scan received with id: ${message.id}")
233+
FrontendHandlerEvent.StartImprovScan
234+
}
235+
236+
is ImprovConfigureDeviceMessage -> {
237+
Timber.d("improv/configure_device received with id: ${message.id}")
238+
FrontendHandlerEvent.ConfigureImprovDevice(deviceName = message.payload.name)
239+
}
240+
227241
is UnknownIncomingMessage -> {
228242
Timber.d("Unknown message type received: ${message.content}")
229243
FrontendHandlerEvent.UnknownMessage
@@ -250,6 +264,7 @@ class FrontendMessageHandler @Inject constructor(
250264
canCommissionMatter = canCommissionMatter,
251265
canExportThread = canExportThread,
252266
hasBarCodeScanner = hasBarCodeScanner,
267+
canSetupImprov = bluetoothCapabilities.hasBluetoothLe(),
253268
appVersion = appVersionProvider(),
254269
),
255270
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package io.homeassistant.companion.android.frontend.improv
2+
3+
/**
4+
* Reports the device's Bluetooth-related capabilities.
5+
*
6+
* Exposed as an interface so call sites (e.g. the config/get response, the improv scan flow)
7+
* can stay testable without Robolectric.
8+
*/
9+
fun interface BluetoothCapabilities {
10+
11+
/**
12+
* @return `true` when the device declares Bluetooth Low Energy support
13+
* ([android.content.pm.PackageManager.FEATURE_BLUETOOTH_LE]).
14+
*/
15+
fun hasBluetoothLe(): Boolean
16+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package io.homeassistant.companion.android.frontend.improv
2+
3+
import android.content.pm.PackageManager
4+
import dagger.Module
5+
import dagger.Provides
6+
import dagger.hilt.InstallIn
7+
import dagger.hilt.components.SingletonComponent
8+
9+
/**
10+
* Hilt bindings for the frontend's improv (Wi-Fi onboarding for BLE devices) integration.
11+
*/
12+
@Module
13+
@InstallIn(SingletonComponent::class)
14+
object FrontendImprovModule {
15+
16+
@Provides
17+
fun provideBluetoothCapabilities(packageManager: PackageManager): BluetoothCapabilities = BluetoothCapabilities {
18+
packageManager.hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE)
19+
}
20+
}

app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/incoming/IncomingExternalBusMessageTest.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,38 @@ class IncomingExternalBusMessageTest {
203203
assertEquals(200.5, resize.payload.bottom)
204204
}
205205

206+
@Test
207+
fun `Given improv scan JSON then parses to ImprovScanMessage`() {
208+
val json = """{"type":"improv/scan","id":50}"""
209+
210+
val message = frontendExternalBusJson.decodeFromString<IncomingExternalBusMessage>(json)
211+
212+
assertInstanceOf(ImprovScanMessage::class.java, message)
213+
assertEquals(50, (message as ImprovScanMessage).id)
214+
}
215+
216+
@Test
217+
fun `Given improv scan JSON without id then parses to ImprovScanMessage with null id`() {
218+
val json = """{"type":"improv/scan"}"""
219+
220+
val message = frontendExternalBusJson.decodeFromString<IncomingExternalBusMessage>(json)
221+
222+
assertInstanceOf(ImprovScanMessage::class.java, message)
223+
assertNull((message as ImprovScanMessage).id)
224+
}
225+
226+
@Test
227+
fun `Given improv configure_device JSON then parses to ImprovConfigureDeviceMessage with name`() {
228+
val json = """{"type":"improv/configure_device","id":51,"payload":{"name":"Smart Plug"}}"""
229+
230+
val message = frontendExternalBusJson.decodeFromString<IncomingExternalBusMessage>(json)
231+
232+
assertInstanceOf(ImprovConfigureDeviceMessage::class.java, message)
233+
val configureMessage = message as ImprovConfigureDeviceMessage
234+
assertEquals(51, configureMessage.id)
235+
assertEquals("Smart Plug", configureMessage.payload.name)
236+
}
237+
206238
@Test
207239
fun `Given exoplayer resize JSON without payload then parses with zero defaults`() {
208240
val json = """{"type":"exoplayer/resize","id":25}"""

app/src/test/kotlin/io/homeassistant/companion/android/frontend/externalbus/outgoing/CommandMessageTest.kt

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,26 @@ class CommandMessageTest {
4242
json,
4343
)
4444
}
45+
46+
@Test
47+
fun `Given ImprovDiscoveredDeviceMessage when serializing then produces command with name payload`() {
48+
val message = ImprovDiscoveredDeviceMessage(name = "Smart Plug")
49+
50+
val json = frontendExternalBusJson.encodeToString<OutgoingExternalBusMessage>(message)
51+
52+
assertEquals(
53+
"""{"type":"command","id":null,"command":"improv/discovered_device","payload":{"name":"Smart Plug"}}""",
54+
json,
55+
)
56+
}
57+
58+
@Test
59+
fun `Given ImprovDeviceSetupDoneMessage when serializing then produces command without payload`() {
60+
val json = frontendExternalBusJson.encodeToString<OutgoingExternalBusMessage>(ImprovDeviceSetupDoneMessage)
61+
62+
assertEquals(
63+
"""{"type":"command","id":null,"command":"improv/device_setup_done","payload":null}""",
64+
json,
65+
)
66+
}
4567
}

0 commit comments

Comments
 (0)