-
-
Notifications
You must be signed in to change notification settings - Fork 28
Device controls support #41
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 3 commits
5664632
f318228
c8480f9
86ca26d
a09ec33
f5e47e6
abd2b43
91ab26e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,221 @@ | ||
| package ca.cgagnier.wlednativeandroid.service | ||
|
|
||
| import android.app.PendingIntent | ||
| import android.content.Intent | ||
| import android.os.Build | ||
| import android.service.controls.Control | ||
| import android.service.controls.ControlsProviderService | ||
| import android.service.controls.DeviceTypes | ||
| import android.service.controls.actions.BooleanAction | ||
| import android.service.controls.actions.ControlAction | ||
| import android.service.controls.actions.FloatAction | ||
| import android.service.controls.templates.ControlButton | ||
| import android.service.controls.templates.RangeTemplate | ||
| import android.service.controls.templates.ToggleRangeTemplate | ||
| import android.service.controls.templates.ToggleTemplate | ||
| import androidx.annotation.RequiresApi | ||
| import ca.cgagnier.wlednativeandroid.R | ||
| import ca.cgagnier.wlednativeandroid.model.Device | ||
| import ca.cgagnier.wlednativeandroid.model.wledapi.JsonPost | ||
| import ca.cgagnier.wlednativeandroid.repository.DeviceRepository | ||
| import ca.cgagnier.wlednativeandroid.service.device.StateFactory | ||
| import ca.cgagnier.wlednativeandroid.service.device.api.request.StateChangeRequest | ||
| import ca.cgagnier.wlednativeandroid.ui.MainActivity | ||
| import dagger.hilt.android.AndroidEntryPoint | ||
| import kotlinx.coroutines.CoroutineScope | ||
| import kotlinx.coroutines.Dispatchers | ||
| import kotlinx.coroutines.ExperimentalCoroutinesApi | ||
| import kotlinx.coroutines.SupervisorJob | ||
| import kotlinx.coroutines.flow.asFlow | ||
| import kotlinx.coroutines.flow.flatMapLatest | ||
| import kotlinx.coroutines.flow.map | ||
| import kotlinx.coroutines.flow.merge | ||
| import kotlinx.coroutines.flow.take | ||
| import kotlinx.coroutines.jdk9.asPublisher | ||
| import kotlinx.coroutines.launch | ||
| import java.util.concurrent.Flow | ||
| import java.util.function.Consumer | ||
| import javax.inject.Inject | ||
|
|
||
| @RequiresApi(Build.VERSION_CODES.R) | ||
| @AndroidEntryPoint | ||
| class DeviceControlsProviderService : ControlsProviderService() { | ||
| @Inject | ||
| lateinit var repository: DeviceRepository | ||
| @Inject | ||
| lateinit var stateFactory: StateFactory | ||
|
|
||
| private val job = SupervisorJob() | ||
| private val scope = CoroutineScope(Dispatchers.IO + job) | ||
|
|
||
| @OptIn(ExperimentalCoroutinesApi::class) | ||
| override fun createPublisherForAllAvailable(): Flow.Publisher<Control> { | ||
| return repository.allDevices | ||
| .take(1) | ||
| .flatMapLatest { deviceList -> | ||
| deviceList.asFlow() | ||
| }.map { device -> | ||
| createStatelessControl(device) | ||
| }.asPublisher() | ||
| } | ||
|
|
||
| override fun createPublisherFor(controlIds: List<String>): Flow.Publisher<Control> { | ||
| val flows = controlIds | ||
| .map { controlId -> | ||
| repository.findLiveDeviceByAddress(controlId) | ||
| .map { device -> | ||
| if (device != null) | ||
| createStatefulControl(device) | ||
| else | ||
| createErrorControl(controlId) | ||
| } | ||
| } | ||
|
|
||
| return flows.merge().asPublisher() | ||
| } | ||
|
|
||
| override fun performControlAction( | ||
| controlId: String, | ||
| action: ControlAction, | ||
| consumer: Consumer<Int> | ||
| ) { | ||
| scope.launch { | ||
| // controlId is device address | ||
| val device = repository.findDeviceByAddress(controlId) | ||
| device?.let { | ||
| when (action) { | ||
| is BooleanAction -> toggleDevicePower(device, action.newState) | ||
| is FloatAction -> setDeviceBrightness(device, action.newValue.toInt()) | ||
|
|
||
| else -> consumer.accept(ControlAction.RESPONSE_FAIL) | ||
| } | ||
|
|
||
| consumer.accept(ControlAction.RESPONSE_OK) | ||
| } ?: consumer.accept(ControlAction.RESPONSE_FAIL) | ||
| } | ||
| } | ||
|
|
||
| private fun createStatelessControl(device: Device): Control { | ||
| return Control.StatelessBuilder(device.address, createAppIntentForDevice(device)) | ||
| .setTitle(device.name) | ||
| .setDeviceType(DeviceTypes.TYPE_LIGHT) | ||
| .build() | ||
| } | ||
|
|
||
| private fun createStatefulControl(device: Device): Control { | ||
| val control = Control.StatefulBuilder(device.address, createAppIntentForDevice(device)) | ||
| .setTitle(device.name) | ||
| .setDeviceType(DeviceTypes.TYPE_LIGHT) | ||
| .setStatus(Control.STATUS_OK) | ||
|
|
||
| // set a proper message instead of the ones in Control.STATUS_* | ||
| if (device.isOnline) { | ||
| // set status text based on state | ||
| if (device.isPoweredOn) { | ||
| control.setStatusText("On") | ||
| } else { | ||
| control.setStatusText("Off") | ||
pacjo marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| // and add controls | ||
| control.setControlTemplate( | ||
| ToggleRangeTemplate( | ||
| device.address, | ||
| ControlButton( | ||
| device.isPoweredOn, | ||
| applicationContext.getString(R.string.device_controls_control_button_action_description) | ||
| ), | ||
| // TODO: not sure about the mapping here | ||
| RangeTemplate( | ||
| device.address, | ||
| 1f, | ||
| 255f, | ||
| device.brightness.toFloat(), | ||
| 1f, | ||
| null | ||
| ) | ||
| ) | ||
| ) | ||
| } else { | ||
| // offline | ||
| control.setStatusText("Offline") | ||
pacjo marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| // set the template to toggle with forced value of off | ||
| control.setControlTemplate( | ||
| ToggleTemplate( | ||
| device.address, | ||
| ControlButton( | ||
| false, | ||
| applicationContext.getString(R.string.device_controls_control_button_action_description) | ||
| ) | ||
| ) | ||
| ) | ||
| } | ||
|
|
||
| return control.build() | ||
| } | ||
|
|
||
| /** | ||
| * Create error like control for use when corresponding device was removed in app | ||
| */ | ||
| private fun createErrorControl(controlId: String): Control { | ||
| val intent = Intent(this, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | ||
| val pendingIntent = PendingIntent.getActivity(this, 0, intent, | ||
| PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE) | ||
|
|
||
| return Control.StatefulBuilder(controlId, pendingIntent) | ||
| .setTitle(applicationContext.getString(R.string.device_controls_device_not_found)) | ||
| .setStatus(Control.STATUS_NOT_FOUND) | ||
| .build() | ||
| } | ||
|
|
||
| /** | ||
| * Create [PendingIntent] that launches device specific screen in the app | ||
| */ | ||
| private fun createAppIntentForDevice(device: Device): PendingIntent { | ||
| // TODO: we can just remove everything but numbers and be left with a string of a unique integer | ||
| val integerId = device.macAddress.hashCode() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using A more robust approach is to generate a unique integer from the device's MAC address. val integerId = try {
// Use the last 6 characters of the MAC address, which should be unique enough for this purpose.
device.macAddress.replace(":", "").takeLast(6).toInt(16)
} catch (e: Exception) {
// Fallback to hashCode if parsing fails for some reason (e.g. invalid MAC format)
device.macAddress.hashCode()
} |
||
|
|
||
| val intent = Intent(this, MainActivity::class.java) // TODO: maybe open device specific screen | ||
| .putExtra(EXTRA_DEVICE_MAC, device.macAddress) | ||
| .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | ||
| val action = PendingIntent.getActivity( | ||
| this, | ||
| integerId, | ||
| intent, | ||
| PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE | ||
| ) | ||
|
|
||
| return action | ||
| } | ||
|
|
||
| // copied from DeviceListViewModel | ||
| private fun toggleDevicePower(device: Device, isOn: Boolean) { | ||
| val deviceSetPost = JsonPost(isOn = isOn) | ||
| scope.launch(Dispatchers.IO) { | ||
| stateFactory.getState(device).requestsManager.addRequest( | ||
| StateChangeRequest(device, deviceSetPost) | ||
| ) | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method is copied from Additionally, this function is called from a coroutine that already runs on private fun toggleDevicePower(device: Device, isOn: Boolean) {
val deviceSetPost = JsonPost(isOn = isOn)
stateFactory.getState(device).requestsManager.addRequest(
StateChangeRequest(device, deviceSetPost)
)
}
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. duplicated scope.launch removed, I'm leaving those functions in for now |
||
|
|
||
| // copied from DeviceListViewModel | ||
| private fun setDeviceBrightness(device: Device, brightness: Int) { | ||
| val deviceSetPost = JsonPost(brightness = brightness) | ||
| scope.launch(Dispatchers.IO) { | ||
| stateFactory.getState(device).requestsManager.addRequest( | ||
| StateChangeRequest(device, deviceSetPost) | ||
| ) | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method is copied from Additionally, this function is called from a coroutine that already runs on private fun setDeviceBrightness(device: Device, brightness: Int) {
val deviceSetPost = JsonPost(brightness = brightness)
stateFactory.getState(device).requestsManager.addRequest(
StateChangeRequest(device, deviceSetPost)
)
}
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. duplicated scope.launch removed, I'm leaving those functions in for now |
||
|
|
||
| override fun onDestroy() { | ||
| super.onDestroy() | ||
| job.cancel() | ||
| } | ||
|
|
||
| companion object { | ||
| private const val TAG = "DeviceControlsProviderService" | ||
|
|
||
| const val EXTRA_DEVICE_MAC = "device_controls_device_mac" | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.