Compare commits

..

2 Commits

Author SHA1 Message Date
13b80be884 Implement visibility range for collective device 2024-06-07 20:20:39 +03:00
5c7d3d8a7a Add PeerConnection 2024-06-07 10:52:28 +03:00
13 changed files with 277 additions and 73 deletions

3
.gitignore vendored
View File

@ -9,4 +9,7 @@
out/ out/
build/ build/
!gradle-wrapper.jar !gradle-wrapper.jar
/demo/device-collective/mapCache/

View File

@ -7,6 +7,7 @@
- PLC4X bindings - PLC4X bindings
- Shortcuts to access all Controls devices in a magix network. - Shortcuts to access all Controls devices in a magix network.
- `DeviceClient` properly evaluates lifecycle and logs - `DeviceClient` properly evaluates lifecycle and logs
- `PeerConnection` API for direct device-device binary sharing
### Changed ### Changed
- Constructor properties return `DeviceState` in order to be able to subscribe to them - Constructor properties return `DeviceState` in order to be able to subscribe to them

View File

@ -166,12 +166,18 @@ public data class ActionResultMessage(
} }
/** /**
* Notifies listeners that a new binary with given [binaryID] is available. The binary itself could not be provided via [DeviceMessage] API. * Notifies listeners that a new binary with given [contentId] and [contentMeta] is available.
*
* [contentMeta] includes public information that could be shared with loop subscribers. It should not contain sensitive data.
*
* The binary itself could not be provided via [DeviceMessage] API.
* [space.kscience.controls.peer.PeerConnection] must be used instead
*/ */
@Serializable @Serializable
@SerialName("binary.notification") @SerialName("binary.notification")
public data class BinaryNotificationMessage( public data class BinaryNotificationMessage(
val binaryID: String, val contentId: String,
val contentMeta: Meta,
override val sourceDevice: Name, override val sourceDevice: Name,
override val targetDevice: Name? = null, override val targetDevice: Name? = null,
override val comment: String? = null, override val comment: String? = null,

View File

@ -0,0 +1,42 @@
package space.kscience.controls.peer
import space.kscience.dataforge.io.Envelope
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.names.Name
/**
* A manager that allows direct synchronous sending and receiving binary data
*/
public interface PeerConnection {
/**
* Receive an [Envelope] from a device with name [deviceName] on a given [address] with given [contentId].
*
* The address depends on the specifics of given [PeerConnection]. For example, it could be a TCP/IP port or
* magix endpoint name.
*
* Depending on [PeerConnection] implementation, the resulting [Envelope] could be lazy loaded
*
* Additional metadata in [requestMeta] could be required for authentication.
*/
public suspend fun receive(
address: String,
deviceName: Name,
contentId: String,
requestMeta: Meta = Meta.EMPTY,
): Envelope
/**
* Send an [envelope] to a device with name [deviceName] on a given [address]
*
* The address depends on the specifics of given [PeerConnection]. For example, it could be a TCP/IP port or
* magix endpoint name.
*
* Additional metadata in [requestMeta] could be required for authentication.
*/
public suspend fun send(
address: String,
deviceName: Name,
envelope: Envelope,
requestMeta: Meta = Meta.EMPTY,
)
}

View File

@ -0,0 +1,12 @@
package space.kscience.controls.compose
import androidx.compose.ui.Modifier
public inline fun Modifier.conditional(
condition: Boolean,
modifier: Modifier.() -> Modifier,
): Modifier = if (condition) {
then(modifier(Modifier))
} else {
this
}

View File

@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import space.kscience.controls.constructor.* import space.kscience.controls.constructor.*
import space.kscience.controls.constructor.devices.LimitSwitch
import space.kscience.controls.constructor.devices.StepDrive import space.kscience.controls.constructor.devices.StepDrive
import space.kscience.controls.constructor.devices.angle import space.kscience.controls.constructor.devices.angle
import space.kscience.controls.constructor.models.Leadscrew import space.kscience.controls.constructor.models.Leadscrew
@ -31,10 +32,18 @@ private class Plotter(
context: Context, context: Context,
xDrive: StepDrive, xDrive: StepDrive,
yDrive: StepDrive, yDrive: StepDrive,
xStartLimit: LimitSwitch,
xEndLimit: LimitSwitch,
yStartLimit: LimitSwitch,
yEndLimit: LimitSwitch,
val paint: suspend (Color) -> Unit, val paint: suspend (Color) -> Unit,
) : DeviceConstructor(context) { ) : DeviceConstructor(context) {
val xDrive by device(xDrive) val xDrive by device(xDrive)
val yDrive by device(yDrive) val yDrive by device(yDrive)
val xStartLimit by device(xStartLimit)
val xEndLimit by device(xEndLimit)
val yStartLimit by device(yStartLimit)
val yEndLimit by device(yEndLimit)
public fun moveToXY(x: Number, y: Number) { public fun moveToXY(x: Number, y: Number) {
xDrive.target.value = x.toLong() xDrive.target.value = x.toLong()
@ -108,7 +117,15 @@ private class PlotterModel(
val xy: DeviceState<XY<Meters>> = combineState(x, y) { x, y -> XY(x, y) } val xy: DeviceState<XY<Meters>> = combineState(x, y) { x, y -> XY(x, y) }
val plotter = Plotter(context, xDrive, yDrive) { color -> val plotter = Plotter(
context = context,
xDrive = xDrive,
yDrive = yDrive,
xStartLimit = LimitSwitch(context,x.atStart),
xEndLimit = LimitSwitch(context,x.atEnd),
yStartLimit = LimitSwitch(context,x.atStart),
yEndLimit = LimitSwitch(context,x.atEnd),
) { color ->
println("Point X: ${x.value.value}, Y: ${y.value.value}, color: $color") println("Point X: ${x.value.value}, Y: ${y.value.value}, color: $color")
callback(PlotterPoint(x.value, y.value, color)) callback(PlotterPoint(x.value, y.value, color))
} }

View File

@ -37,6 +37,6 @@ kotlin.explicitApi = ExplicitApiMode.Disabled
compose.desktop { compose.desktop {
application { application {
mainClass = "space.kscience.controls.demo.map.MainKt" mainClass = "space.kscience.controls.demo.collective.MainKt"
} }
} }

View File

@ -1,6 +1,6 @@
@file:OptIn(DFExperimental::class) @file:OptIn(DFExperimental::class)
package space.kscience.controls.demo.map package space.kscience.controls.demo.collective
import space.kscience.controls.api.Device import space.kscience.controls.api.Device
import space.kscience.controls.constructor.DeviceConstructor import space.kscience.controls.constructor.DeviceConstructor
@ -10,17 +10,20 @@ import space.kscience.controls.spec.DeviceSpec
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.MetaConverter import space.kscience.dataforge.meta.MetaConverter
import space.kscience.dataforge.meta.Scheme import space.kscience.dataforge.meta.Scheme
import space.kscience.dataforge.meta.SchemeSpec import space.kscience.dataforge.meta.string
import space.kscience.dataforge.misc.DFExperimental import space.kscience.dataforge.misc.DFExperimental
import space.kscience.maps.coordinates.Gmc import space.kscience.maps.coordinates.Gmc
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class RemoteDeviceConfiguration : Scheme() { class CollectiveDeviceConfiguration(deviceId: DeviceId) : Scheme() {
companion object : SchemeSpec<RemoteDeviceConfiguration>(::RemoteDeviceConfiguration) var deviceId by string(deviceId)
var description by string()
} }
interface RemoteDevice : Device { interface CollectiveDevice : Device {
public val id: DeviceId
suspend fun getPosition(): Gmc suspend fun getPosition(): Gmc
@ -28,8 +31,10 @@ interface RemoteDevice : Device {
suspend fun setVelocity(value: GmcVelocity) suspend fun setVelocity(value: GmcVelocity)
suspend fun listVisible(): Collection<DeviceId>
companion object : DeviceSpec<RemoteDevice>() {
companion object : DeviceSpec<CollectiveDevice>() {
val position by property<Gmc>( val position by property<Gmc>(
converter = MetaConverter.serializable(), converter = MetaConverter.serializable(),
read = { getPosition() } read = { getPosition() }
@ -44,15 +49,18 @@ interface RemoteDevice : Device {
} }
class RemoteDeviceConstructor( class CollectiveDeviceConstructor(
context: Context, context: Context,
val configuration: RemoteDeviceConfiguration, val configuration: CollectiveDeviceConfiguration,
position: MutableDeviceState<Gmc>, position: MutableDeviceState<Gmc>,
velocity: MutableDeviceState<GmcVelocity>, velocity: MutableDeviceState<GmcVelocity>,
) : DeviceConstructor(context, configuration.meta), RemoteDevice { private val listVisible: suspend () -> Collection<DeviceId>,
) : DeviceConstructor(context, configuration.meta), CollectiveDevice {
val position = registerAsProperty(RemoteDevice.position, position.debounce(500.milliseconds)) override val id: DeviceId get() = configuration.deviceId
val velocity = registerAsProperty(RemoteDevice.velocity, velocity)
val position = registerAsProperty(CollectiveDevice.position, position.sample(500.milliseconds))
val velocity = registerAsProperty(CollectiveDevice.velocity, velocity)
override suspend fun getPosition(): Gmc = position.value override suspend fun getPosition(): Gmc = position.value
@ -61,4 +69,6 @@ class RemoteDeviceConstructor(
override suspend fun setVelocity(value: GmcVelocity) { override suspend fun setVelocity(value: GmcVelocity) {
velocity.value = value velocity.value = value
} }
override suspend fun listVisible(): Collection<DeviceId> = listVisible.invoke()
} }

View File

@ -1,43 +1,59 @@
package space.kscience.controls.demo.map package space.kscience.controls.demo.collective
import space.kscience.controls.constructor.ModelConstructor import space.kscience.controls.constructor.ModelConstructor
import space.kscience.controls.constructor.MutableDeviceState import space.kscience.controls.constructor.MutableDeviceState
import space.kscience.controls.constructor.onTimer import space.kscience.controls.constructor.onTimer
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.maps.coordinates.Gmc import space.kscience.maps.coordinates.*
typealias RemoteDeviceId = String typealias DeviceId = String
data class RemoteDeviceState( internal data class VirtualDeviceState(
val id: RemoteDeviceId, val id: DeviceId,
val configuration: RemoteDeviceConfiguration, val configuration: CollectiveDeviceConfiguration,
val position: MutableDeviceState<Gmc>, val position: MutableDeviceState<Gmc>,
val velocity: MutableDeviceState<GmcVelocity>, val velocity: MutableDeviceState<GmcVelocity>,
) )
public fun RemoteDeviceState( internal fun VirtualDeviceState(
id: RemoteDeviceId, id: DeviceId,
position: Gmc, position: Gmc,
configuration: RemoteDeviceConfiguration.() -> Unit = {}, configuration: CollectiveDeviceConfiguration.() -> Unit = {},
) = RemoteDeviceState( ) = VirtualDeviceState(
id, id,
RemoteDeviceConfiguration(configuration), CollectiveDeviceConfiguration(id).apply(configuration),
MutableDeviceState(position), MutableDeviceState(position),
MutableDeviceState(GmcVelocity.zero) MutableDeviceState(GmcVelocity.zero)
) )
class DeviceCollectiveModel( internal class DeviceCollectiveModel(
context: Context, context: Context,
val deviceStates: Collection<RemoteDeviceState>, val deviceStates: Collection<VirtualDeviceState>,
val visibilityRange: Distance,
) : ModelConstructor(context) { ) : ModelConstructor(context) {
/**
* Propagate movement
*/
private val movement = onTimer { prev, next -> private val movement = onTimer { prev, next ->
val delta = (next - prev) val delta = (next - prev)
deviceStates.forEach { state -> deviceStates.forEach { state ->
state.position.value = state.position.value.moveWith(state.velocity.value, delta) state.position.value = state.position.value.moveWith(state.velocity.value, delta)
} }
} }
suspend fun locateVisible(id: DeviceId): Map<DeviceId, GmcCurve> {
val coordinatesSnapshot = deviceStates.associate { it.id to it.position.value }
val selected = coordinatesSnapshot[id] ?: error("Can't find device with id $id")
val allCurves = coordinatesSnapshot
.filterKeys { it != id }
.mapValues { GeoEllipsoid.WGS84.curveBetween(selected, it.value) }
return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange }
}
} }

View File

@ -1,4 +1,4 @@
package space.kscience.controls.demo.map package space.kscience.controls.demo.collective
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import space.kscience.kmath.geometry.Angle import space.kscience.kmath.geometry.Angle

View File

@ -1,4 +1,4 @@
package space.kscience.controls.demo.map package space.kscience.controls.demo.collective
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@ -7,7 +7,7 @@ import space.kscience.controls.constructor.DeviceState
import kotlin.time.Duration import kotlin.time.Duration
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
class DebounceDeviceState<T>( class SampleDeviceState<T>(
val origin: DeviceState<T>, val origin: DeviceState<T>,
val interval: Duration, val interval: Duration,
) : DeviceState<T> { ) : DeviceState<T> {
@ -17,4 +17,4 @@ class DebounceDeviceState<T>(
override fun toString(): String = "DebounceDeviceState($value, interval=$interval)" override fun toString(): String = "DebounceDeviceState($value, interval=$interval)"
} }
fun <T> DeviceState<T>.debounce(interval: Duration) = DebounceDeviceState(this, interval) fun <T> DeviceState<T>.sample(interval: Duration) = SampleDeviceState(this, interval)

View File

@ -1,4 +1,4 @@
package space.kscience.controls.demo.map package space.kscience.controls.demo.collective
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@ -20,31 +20,32 @@ private val radius = 0.01.degrees
internal fun generateModel(context: Context): DeviceCollectiveModel { internal fun generateModel(context: Context): DeviceCollectiveModel {
val devices: List<RemoteDeviceState> = buildList { val devices: List<VirtualDeviceState> = List(100) { index ->
repeat(100) { val id = "device[$index]"
add(
RemoteDeviceState( VirtualDeviceState(
"device[$it]", id = id,
Gmc( Gmc(
center.latitude + radius * Random.nextDouble(), center.latitude + radius * Random.nextDouble(),
center.longitude + radius * Random.nextDouble() center.longitude + radius * Random.nextDouble()
)
)
) )
) {
deviceId = id
description = "Virtual remote device $id"
} }
} }
val model = DeviceCollectiveModel(context, devices) val model = DeviceCollectiveModel(context, devices, 0.2.kilometers)
return model return model
} }
fun RemoteDevice.moveInCircles(): Job = launch { fun CollectiveDevice.moveInCircles(): Job = launch {
var bearing = Random.nextDouble(-PI, PI).radians var bearing = Random.nextDouble(-PI, PI).radians
write(RemoteDevice.velocity, GmcVelocity(bearing, deviceVelocity)) write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
while (isActive) { while (isActive) {
delay(500) delay(500)
bearing += 5.degrees bearing += 5.degrees
write(RemoteDevice.velocity, GmcVelocity(bearing, deviceVelocity)) write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
} }
} }

View File

@ -1,27 +1,42 @@
package space.kscience.controls.demo.map @file:OptIn(ExperimentalFoundationApi::class, ExperimentalSplitPaneApi::class)
package space.kscience.controls.demo.collective
import androidx.compose.foundation.*
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.Card
import androidx.compose.material.Checkbox
import androidx.compose.material.Text
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.remember import androidx.compose.ui.Alignment
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
import org.jetbrains.compose.splitpane.HorizontalSplitPane
import org.jetbrains.compose.splitpane.rememberSplitPaneState
import space.kscience.controls.compose.conditional
import space.kscience.controls.manager.DeviceManager import space.kscience.controls.manager.DeviceManager
import space.kscience.controls.spec.propertyFlow import space.kscience.controls.spec.useProperty
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.request import space.kscience.dataforge.context.request
import space.kscience.maps.compose.MapView import space.kscience.maps.compose.MapView
import space.kscience.maps.compose.OpenStreetMapTileProvider import space.kscience.maps.compose.OpenStreetMapTileProvider
import space.kscience.maps.features.ViewConfig import space.kscience.maps.features.*
import space.kscience.maps.features.circle
import space.kscience.maps.features.color
import space.kscience.maps.features.rectangle
import java.nio.file.Path import java.nio.file.Path
@ -34,7 +49,6 @@ fun rememberDeviceManager(): DeviceManager = remember {
context.request(DeviceManager) context.request(DeviceManager)
} }
@Composable @Composable
fun App() { fun App() {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -47,14 +61,24 @@ fun App() {
generateModel(deviceManager.context) generateModel(deviceManager.context)
} }
val devices: Map<RemoteDeviceId, RemoteDevice> = remember { val devices: Map<DeviceId, CollectiveDevice> = remember {
collectiveModel.deviceStates.associate { collectiveModel.deviceStates.associate {
val device = RemoteDeviceConstructor(deviceManager.context, it.configuration, it.position, it.velocity) val device = CollectiveDeviceConstructor(
context = deviceManager.context,
configuration = it.configuration,
position = it.position,
velocity = it.velocity
) {
collectiveModel.locateVisible(it.id).keys
}
device.moveInCircles() device.moveInCircles()
it.id to device it.id to device
} }
} }
var selectedDeviceId by remember { mutableStateOf<DeviceId?>(null) }
var showOnlyVisible by remember { mutableStateOf(false) }
val mapTileProvider = remember { val mapTileProvider = remember {
OpenStreetMapTileProvider( OpenStreetMapTileProvider(
client = HttpClient(CIO), client = HttpClient(CIO),
@ -62,21 +86,93 @@ fun App() {
) )
} }
MapView( HorizontalSplitPane(
mapTileProvider = mapTileProvider, splitPaneState = rememberSplitPaneState(0.9f)
config = ViewConfig()
) { ) {
collectiveModel.deviceStates.forEach { device -> first(400.dp) {
circle(device.position.value, id = device.id + ".position").color(Color.Red) MapView(
device.position.valueFlow.onEach { mapTileProvider = mapTileProvider,
circle(device.position.value, id = device.id + ".position").color(Color.Red) config = ViewConfig()
}.launchIn(scope) ) {
} collectiveModel.deviceStates.forEach { device ->
circle(device.position.value, id = device.id + ".position").color(Color.Red)
device.position.valueFlow.onEach {
circle(device.position.value, id = device.id + ".position", size = 3.dp)
.color(Color.Red)
.modifyAttribute(AlphaAttribute, 0.5f) // does not work right now
}.launchIn(scope)
}
devices.forEach { (id, device) -> devices.forEach { (id, device) ->
device.propertyFlow(RemoteDevice.position).onEach { position -> device.useProperty(CollectiveDevice.position, scope = scope) { position ->
rectangle(position, id = id).color(Color.Blue)
}.launchIn(scope) val activeDevice = selectedDeviceId?.let { devices[it] }
if (activeDevice == null || id == selectedDeviceId || !showOnlyVisible || id in activeDevice.listVisible()) {
rectangle(
position,
id = id,
size = if (selectedDeviceId == id) DpSize(10.dp, 10.dp) else DpSize(5.dp, 5.dp)
).color(if (selectedDeviceId == id) Color.Magenta else Color.Blue)
.modifyAttribute(AlphaAttribute, if (selectedDeviceId == id) 1f else 0.5f)
.onClick { selectedDeviceId = id }
} else {
removeFeature(id)
}
}
}
}
}
second(200.dp) {
Column {
selectedDeviceId?.let { id ->
Column(
modifier = Modifier
.padding(8.dp)
.border(2.dp, Color.DarkGray)
) {
Card(
elevation = 16.dp,
) {
Text(
text = "Выбран: $id",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(10.dp).fillMaxWidth()
)
}
devices[id]?.let {
Text(it.meta.toString(), Modifier.padding(10.dp))
}
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(10.dp).fillMaxWidth()) {
Text("Показать только видимые")
Checkbox(showOnlyVisible, { showOnlyVisible = it })
}
}
}
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
devices.forEach { (id, device) ->
Card(
elevation = 16.dp,
modifier = Modifier.padding(8.dp).onClick {
selectedDeviceId = id
}.conditional(id == selectedDeviceId) {
border(2.dp, Color.Blue)
}
) {
Text(
text = id,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(10.dp).fillMaxWidth()
)
}
}
}
}
} }
} }
} }