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/
build/
!gradle-wrapper.jar
/demo/device-collective/mapCache/

View File

@ -7,6 +7,7 @@
- PLC4X bindings
- Shortcuts to access all Controls devices in a magix network.
- `DeviceClient` properly evaluates lifecycle and logs
- `PeerConnection` API for direct device-device binary sharing
### Changed
- 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
@SerialName("binary.notification")
public data class BinaryNotificationMessage(
val binaryID: String,
val contentId: String,
val contentMeta: Meta,
override val sourceDevice: Name,
override val targetDevice: Name? = 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.isActive
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.angle
import space.kscience.controls.constructor.models.Leadscrew
@ -31,10 +32,18 @@ private class Plotter(
context: Context,
xDrive: StepDrive,
yDrive: StepDrive,
xStartLimit: LimitSwitch,
xEndLimit: LimitSwitch,
yStartLimit: LimitSwitch,
yEndLimit: LimitSwitch,
val paint: suspend (Color) -> Unit,
) : DeviceConstructor(context) {
val xDrive by device(xDrive)
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) {
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 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")
callback(PlotterPoint(x.value, y.value, color))
}

View File

@ -37,6 +37,6 @@ kotlin.explicitApi = ExplicitApiMode.Disabled
compose.desktop {
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)
package space.kscience.controls.demo.map
package space.kscience.controls.demo.collective
import space.kscience.controls.api.Device
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.meta.MetaConverter
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.maps.coordinates.Gmc
import kotlin.time.Duration.Companion.milliseconds
class RemoteDeviceConfiguration : Scheme() {
companion object : SchemeSpec<RemoteDeviceConfiguration>(::RemoteDeviceConfiguration)
class CollectiveDeviceConfiguration(deviceId: DeviceId) : Scheme() {
var deviceId by string(deviceId)
var description by string()
}
interface RemoteDevice : Device {
interface CollectiveDevice : Device {
public val id: DeviceId
suspend fun getPosition(): Gmc
@ -28,8 +31,10 @@ interface RemoteDevice : Device {
suspend fun setVelocity(value: GmcVelocity)
suspend fun listVisible(): Collection<DeviceId>
companion object : DeviceSpec<RemoteDevice>() {
companion object : DeviceSpec<CollectiveDevice>() {
val position by property<Gmc>(
converter = MetaConverter.serializable(),
read = { getPosition() }
@ -44,15 +49,18 @@ interface RemoteDevice : Device {
}
class RemoteDeviceConstructor(
class CollectiveDeviceConstructor(
context: Context,
val configuration: RemoteDeviceConfiguration,
val configuration: CollectiveDeviceConfiguration,
position: MutableDeviceState<Gmc>,
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))
val velocity = registerAsProperty(RemoteDevice.velocity, velocity)
override val id: DeviceId get() = configuration.deviceId
val position = registerAsProperty(CollectiveDevice.position, position.sample(500.milliseconds))
val velocity = registerAsProperty(CollectiveDevice.velocity, velocity)
override suspend fun getPosition(): Gmc = position.value
@ -61,4 +69,6 @@ class RemoteDeviceConstructor(
override suspend fun setVelocity(value: GmcVelocity) {
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.MutableDeviceState
import space.kscience.controls.constructor.onTimer
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(
val id: RemoteDeviceId,
val configuration: RemoteDeviceConfiguration,
internal data class VirtualDeviceState(
val id: DeviceId,
val configuration: CollectiveDeviceConfiguration,
val position: MutableDeviceState<Gmc>,
val velocity: MutableDeviceState<GmcVelocity>,
)
public fun RemoteDeviceState(
id: RemoteDeviceId,
internal fun VirtualDeviceState(
id: DeviceId,
position: Gmc,
configuration: RemoteDeviceConfiguration.() -> Unit = {},
) = RemoteDeviceState(
configuration: CollectiveDeviceConfiguration.() -> Unit = {},
) = VirtualDeviceState(
id,
RemoteDeviceConfiguration(configuration),
CollectiveDeviceConfiguration(id).apply(configuration),
MutableDeviceState(position),
MutableDeviceState(GmcVelocity.zero)
)
class DeviceCollectiveModel(
internal class DeviceCollectiveModel(
context: Context,
val deviceStates: Collection<RemoteDeviceState>,
val deviceStates: Collection<VirtualDeviceState>,
val visibilityRange: Distance,
) : ModelConstructor(context) {
/**
* Propagate movement
*/
private val movement = onTimer { prev, next ->
val delta = (next - prev)
deviceStates.forEach { state ->
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 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.flow.Flow
@ -7,7 +7,7 @@ import space.kscience.controls.constructor.DeviceState
import kotlin.time.Duration
@OptIn(FlowPreview::class)
class DebounceDeviceState<T>(
class SampleDeviceState<T>(
val origin: DeviceState<T>,
val interval: Duration,
) : DeviceState<T> {
@ -17,4 +17,4 @@ class DebounceDeviceState<T>(
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.delay
@ -20,31 +20,32 @@ private val radius = 0.01.degrees
internal fun generateModel(context: Context): DeviceCollectiveModel {
val devices: List<RemoteDeviceState> = buildList {
repeat(100) {
add(
RemoteDeviceState(
"device[$it]",
Gmc(
center.latitude + radius * Random.nextDouble(),
center.longitude + radius * Random.nextDouble()
)
)
val devices: List<VirtualDeviceState> = List(100) { 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"
}
}
val model = DeviceCollectiveModel(context, devices)
val model = DeviceCollectiveModel(context, devices, 0.2.kilometers)
return model
}
fun RemoteDevice.moveInCircles(): Job = launch {
fun CollectiveDevice.moveInCircles(): Job = launch {
var bearing = Random.nextDouble(-PI, PI).radians
write(RemoteDevice.velocity, GmcVelocity(bearing, deviceVelocity))
write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
while (isActive) {
delay(500)
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.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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.application
import io.ktor.client.HttpClient
import io.ktor.client.engine.cio.CIO
import kotlinx.coroutines.flow.launchIn
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.spec.propertyFlow
import space.kscience.controls.spec.useProperty
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.request
import space.kscience.maps.compose.MapView
import space.kscience.maps.compose.OpenStreetMapTileProvider
import space.kscience.maps.features.ViewConfig
import space.kscience.maps.features.circle
import space.kscience.maps.features.color
import space.kscience.maps.features.rectangle
import space.kscience.maps.features.*
import java.nio.file.Path
@ -34,7 +49,6 @@ fun rememberDeviceManager(): DeviceManager = remember {
context.request(DeviceManager)
}
@Composable
fun App() {
val scope = rememberCoroutineScope()
@ -47,14 +61,24 @@ fun App() {
generateModel(deviceManager.context)
}
val devices: Map<RemoteDeviceId, RemoteDevice> = remember {
val devices: Map<DeviceId, CollectiveDevice> = remember {
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()
it.id to device
}
}
var selectedDeviceId by remember { mutableStateOf<DeviceId?>(null) }
var showOnlyVisible by remember { mutableStateOf(false) }
val mapTileProvider = remember {
OpenStreetMapTileProvider(
client = HttpClient(CIO),
@ -62,21 +86,93 @@ fun App() {
)
}
MapView(
mapTileProvider = mapTileProvider,
config = ViewConfig()
HorizontalSplitPane(
splitPaneState = rememberSplitPaneState(0.9f)
) {
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").color(Color.Red)
}.launchIn(scope)
}
first(400.dp) {
MapView(
mapTileProvider = mapTileProvider,
config = ViewConfig()
) {
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) ->
device.propertyFlow(RemoteDevice.position).onEach { position ->
rectangle(position, id = id).color(Color.Blue)
}.launchIn(scope)
devices.forEach { (id, device) ->
device.useProperty(CollectiveDevice.position, scope = scope) { position ->
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()
)
}
}
}
}
}
}
}