diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt index f91beb5..1aeabf6 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt @@ -73,7 +73,7 @@ public data class PropertySetMessage( public val property: String, public val value: Meta, override val sourceDevice: Name? = null, - override val targetDevice: Name, + override val targetDevice: Name?, override val comment: String? = null, @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt index a15bcef..3988c3c 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt @@ -68,7 +68,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess /** * Process incoming [DeviceMessage], using hub naming to find target. - * If the `targetDevice` is `null`, then message is sent to each device in this hub + * If the `targetDevice` is `null`, then the message is sent to each device in this hub */ public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List { return try { diff --git a/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt index 82170b9..c550830 100644 --- a/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt +++ b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt @@ -23,7 +23,11 @@ class CollectiveDeviceConfiguration(deviceId: CollectiveDeviceId) : Scheme() { var deviceId by string(deviceId) var description by string() var reportInterval by int(500) - var radioFrequency by string(default = "169 MHz") + var radioFrequency by string(default = DEFAULT_FREQUENCY) + + companion object { + const val DEFAULT_FREQUENCY = "169 MHz" + } } typealias CollectiveDeviceRoster = Map @@ -111,4 +115,4 @@ class CollectiveDeviceConstructor( } override suspend fun listVisible(): Collection = observation.invoke().keys -} \ No newline at end of file +} diff --git a/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt index 320f477..b5e67e4 100644 --- a/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt +++ b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt @@ -1,12 +1,14 @@ package space.kscience.controls.demo.collective -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.launch +import kotlinx.coroutines.* +import kotlinx.io.writeString import kotlinx.serialization.json.Json import space.kscience.controls.api.DeviceMessage +import space.kscience.controls.api.PropertySetMessage +import space.kscience.controls.client.DeviceClient import space.kscience.controls.client.launchMagixService +import space.kscience.controls.client.write +import space.kscience.controls.constructor.DeviceState import space.kscience.controls.constructor.ModelConstructor import space.kscience.controls.constructor.MutableDeviceState import space.kscience.controls.constructor.onTimer @@ -14,18 +16,38 @@ import space.kscience.controls.manager.DeviceManager import space.kscience.controls.manager.install import space.kscience.controls.manager.respondMessage import space.kscience.controls.peer.PeerConnection +import space.kscience.controls.spec.name +import space.kscience.controls.spec.write import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.request import space.kscience.dataforge.io.Envelope import space.kscience.dataforge.io.toByteArray import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.names.parseAsName +import space.kscience.kmath.geometry.degrees +import space.kscience.kmath.geometry.radians import space.kscience.magix.api.MagixEndpoint import space.kscience.magix.rsocket.rSocketWithWebSockets import space.kscience.magix.server.startMagixServer import space.kscience.maps.coordinates.* +import kotlin.math.PI +import kotlin.random.Random +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +private val deviceVelocity = 0.1.kilometers + +private val center = Gmc.ofDegrees(55.925, 37.514) +private val radius = 0.01.degrees + +private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true +} + internal data class CollectiveDeviceState( val id: CollectiveDeviceId, val configuration: CollectiveDeviceConfiguration, @@ -33,7 +55,7 @@ internal data class CollectiveDeviceState( val velocity: MutableDeviceState, ) -internal fun VirtualDeviceState( +internal fun CollectiveDeviceState( id: CollectiveDeviceId, position: Gmc, configuration: CollectiveDeviceConfiguration.() -> Unit = {}, @@ -44,16 +66,11 @@ internal fun VirtualDeviceState( MutableDeviceState(GmcVelocity.zero) ) -private val json = Json { - ignoreUnknownKeys = true - prettyPrint = true -} - internal class DeviceCollectiveModel( context: Context, val deviceStates: Collection, val visibilityRange: Distance = 0.5.kilometers, - val radioRange: Distance = 5.kilometers, + val radioRange: Distance = 1.kilometers, ) : ModelConstructor(context) { /** @@ -78,41 +95,89 @@ internal class DeviceCollectiveModel( return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange } } - inner class RadioPeerConnection(private val peerState: CollectiveDeviceState) : PeerConnection { + inner class RadioPeerConnectionModel(private val position: DeviceState) : PeerConnection { override suspend fun receive(address: String, contentId: String, requestMeta: Meta): Envelope? = null override suspend fun send(address: String, envelope: Envelope, requestMeta: Meta) { - devices.filter { it.value.configuration.radioFrequency == address }.filter { - GeoEllipsoid.WGS84.curveBetween(peerState.position.value, it.value.position.value).distance < radioRange - }.forEach { (id, target) -> + devices.values.filter { it.configuration.radioFrequency == address }.filter { + GeoEllipsoid.WGS84.curveBetween(position.value, it.position.value).distance < radioRange + }.forEach { target -> check(envelope.data != null) { "Envelope data is empty" } val message = json.decodeFromString( DeviceMessage.serializer(), envelope.data?.toByteArray()?.decodeToString() ?: "" ) - target.respondMessage(id.parseAsName(), message) + target.respondMessage(target.configuration.deviceId.parseAsName(), message) } } } - val devices = deviceStates.associate { + val devices = deviceStates.associate { state -> val device = CollectiveDeviceConstructor( context = context, - configuration = it.configuration, - position = it.position, - velocity = it.velocity, - peerConnection = RadioPeerConnection(it), + configuration = state.configuration, + position = state.position, + velocity = state.velocity, + peerConnection = RadioPeerConnectionModel(state.position), ) { - locateVisible(it.id) + locateVisible(state.id) } - it.id to device + state.id to device + } + + internal fun createTrawler(position: Gmc, id: CollectiveDeviceId = "trawler"): CollectiveDeviceConstructor { + val state = CollectiveDeviceState( + id = id, + configuration = CollectiveDeviceConfiguration(id), + position = MutableDeviceState(position), + velocity = MutableDeviceState(GmcVelocity.zero) + ) + + val result = CollectiveDeviceConstructor( + context = context, + configuration = state.configuration, + position = state.position, + velocity = state.velocity, + peerConnection = RadioPeerConnectionModel(state.position), + ) { + locateVisible(state.id) + } + + // TODO move to CollectiveDeviceState + onTimer { prev, next -> + val delta = (next - prev) + state.position.value = state.position.value.moveWith(state.velocity.value, delta) + } + + result.onTimer(1.seconds) { _, _ -> + val envelope = Envelope { + data { + writeString( + json.encodeToString( + DeviceMessage.serializer(), + PropertySetMessage( + property = CollectiveDevice.velocity.name, + value = gmcVelocityMetaConverter.convert(state.velocity.value), + targetDevice = null + ) + ) + ) + } + } + + result.peerConnection.send( + CollectiveDeviceConfiguration.DEFAULT_FREQUENCY, + envelope + ) + } + + return result } val roster = deviceStates.associate { it.id to it.configuration } - - } + internal fun CoroutineScope.launchCollectiveMagixServer( collectiveModel: DeviceCollectiveModel, ): Job = launch(Dispatchers.IO) { @@ -133,4 +198,58 @@ internal fun CoroutineScope.launchCollectiveMagixServer( deviceContext.request(DeviceManager).launchMagixService(deviceEndpoint, id) } +} + + +internal fun generateModel( + context: Context, + size: Int = 50, + reportInterval: Duration = 500.milliseconds, + additionalConfiguration: CollectiveDeviceConfiguration.() -> Unit = {}, +): DeviceCollectiveModel { + val devices: List = List(size) { index -> + val id = "device[$index]" + + CollectiveDeviceState( + id = id, + Gmc( + center.latitude + radius * Random.nextDouble(), + center.longitude + radius * Random.nextDouble() + ) + ) { + deviceId = id + description = "Virtual remote device $id" + this.reportInterval = reportInterval.inWholeMilliseconds.toInt() + additionalConfiguration() + } + } + + val model = DeviceCollectiveModel(context, devices) + + return model +} + +fun DeviceClient.moveInCircles(scope: CoroutineScope = this): Job = scope.launch { + var bearing = Random.nextDouble(-PI, PI).radians + write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity)) + while (isActive) { + delay(500) + bearing += 5.degrees + write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity)) + } +} + + +internal fun CollectiveDeviceConstructor.moveTo( + targetPosition: Gmc, + speedLimit: Distance = deviceVelocity, + scope: CoroutineScope = this, +): Job = scope.launch { + do { + val curve = GeoEllipsoid.WGS84.curveBetween(position.value, targetPosition) + write(CollectiveDevice.velocity, GmcVelocity(curve.forward.bearing, speedLimit)) + delay(1.seconds) + } while (curve.distance > 0.1.kilometers) + write(CollectiveDevice.velocity, GmcVelocity.zero) + } \ No newline at end of file diff --git a/demo/device-collective/src/jvmMain/kotlin/debugModel.kt b/demo/device-collective/src/jvmMain/kotlin/debugModel.kt deleted file mode 100644 index c9e59b6..0000000 --- a/demo/device-collective/src/jvmMain/kotlin/debugModel.kt +++ /dev/null @@ -1,58 +0,0 @@ -package space.kscience.controls.demo.collective - -import kotlinx.coroutines.* -import space.kscience.controls.client.DeviceClient -import space.kscience.controls.client.write -import space.kscience.dataforge.context.Context -import space.kscience.kmath.geometry.degrees -import space.kscience.kmath.geometry.radians -import space.kscience.maps.coordinates.Gmc -import space.kscience.maps.coordinates.kilometers -import kotlin.math.PI -import kotlin.random.Random -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -private val deviceVelocity = 0.1.kilometers - -private val center = Gmc.ofDegrees(55.925, 37.514) -private val radius = 0.01.degrees - - -internal fun generateModel( - context: Context, - size: Int = 50, - reportInterval: Duration = 500.milliseconds, - additionalConfiguration: CollectiveDeviceConfiguration.() -> Unit = {}, -): DeviceCollectiveModel { - val devices: List = List(size) { index -> - val id = "device[$index]" - - VirtualDeviceState( - id = id, - Gmc( - center.latitude + radius * Random.nextDouble(), - center.longitude + radius * Random.nextDouble() - ) - ) { - deviceId = id - description = "Virtual remote device $id" - this.reportInterval = reportInterval.inWholeMilliseconds.toInt() - additionalConfiguration() - } - } - - val model = DeviceCollectiveModel(context, devices) - - return model -} - -fun DeviceClient.moveInCircles(scope: CoroutineScope = this): Job = scope.launch { - var bearing = Random.nextDouble(-PI, PI).radians - write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity)) - while (isActive) { - delay(500) - bearing += 5.degrees - write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity)) - } -} \ No newline at end of file diff --git a/demo/device-collective/src/jvmMain/kotlin/main.kt b/demo/device-collective/src/jvmMain/kotlin/main.kt index 0d08cc8..f92b0eb 100644 --- a/demo/device-collective/src/jvmMain/kotlin/main.kt +++ b/demo/device-collective/src/jvmMain/kotlin/main.kt @@ -7,16 +7,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding -import androidx.compose.material.Button -import androidx.compose.material.Card -import androidx.compose.material.Checkbox -import androidx.compose.material.Text +import androidx.compose.material.* import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.isSecondaryPressed import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -58,7 +56,8 @@ fun rememberContext(name: String, contextBuilder: ContextBuilder.() -> Unit = {} Context(name, contextBuilder) } -private val gmcMetaConverter = MetaConverter.serializable() +internal val gmcMetaConverter = MetaConverter.serializable() +internal val gmcVelocityMetaConverter = MetaConverter.serializable() @Composable fun App() { @@ -80,7 +79,6 @@ fun App() { val devices = remember { mutableStateMapOf() } - LaunchedEffect(collectiveModel) { launchCollectiveMagixServer(collectiveModel) @@ -113,10 +111,27 @@ fun App() { var movementProgram: Job? by remember { mutableStateOf(null) } + val trawler: CollectiveDeviceConstructor = remember { + collectiveModel.createTrawler(Gmc.ofDegrees(55.925, 37.50)) + } + HorizontalSplitPane( splitPaneState = rememberSplitPaneState(0.9f) ) { first(400.dp) { + var clickPoint by remember { mutableStateOf(null) } + + CursorDropdownMenu(clickPoint != null, { clickPoint = null }) { + clickPoint?.let { point -> + TextButton({ + trawler.moveTo(point) + clickPoint = null + }) { + Text("Move trawler here") + } + } + } + MapView( mapTileProvider = remember { OpenStreetMapTileProvider( @@ -124,8 +139,15 @@ fun App() { cacheDirectory = Path.of("mapCache") ) }, - config = ViewConfig() + config = ViewConfig( + onClick = { event, point -> + if (event.buttons.isSecondaryPressed) { + clickPoint = point.focus + } + } + ) ) { + //draw real positions collectiveModel.deviceStates.forEach { device -> circle(device.position.value, id = device.id + ".position").color(Color.Red) device.position.valueFlow.sample(50.milliseconds).onEach { @@ -154,8 +176,8 @@ fun App() { }.launchIn(scope) } + //draw received data scope.launch { - client.await().subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) -> if (deviceMessage is PropertyChangedMessage && deviceMessage.property == "position") { val id = magixMessage.sourceEndpoint @@ -169,6 +191,12 @@ fun App() { }.launchIn(scope) } + + // draw trawler + + trawler.position.valueFlow.onEach { + circle(it, id = "trawler").color(Color.Black) + }.launchIn(scope) } } second(200.dp) {