Compare commits

..

No commits in common. "13b80be8841761568ed17db6b23a2e9116591411" and "a2b5880da99f6718afcff5a54a7f3cd9e11db767" have entirely different histories.

13 changed files with 73 additions and 277 deletions

3
.gitignore vendored
View File

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

View File

@ -7,7 +7,6 @@
- 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,18 +166,12 @@ public data class ActionResultMessage(
}
/**
* 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
* Notifies listeners that a new binary with given [binaryID] is available. The binary itself could not be provided via [DeviceMessage] API.
*/
@Serializable
@SerialName("binary.notification")
public data class BinaryNotificationMessage(
val contentId: String,
val contentMeta: Meta,
val binaryID: String,
override val sourceDevice: Name,
override val targetDevice: Name? = null,
override val comment: String? = null,

View File

@ -1,42 +0,0 @@
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

@ -1,12 +0,0 @@
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,7 +15,6 @@ 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
@ -32,18 +31,10 @@ 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()
@ -117,15 +108,7 @@ private class PlotterModel(
val xy: DeviceState<XY<Meters>> = combineState(x, y) { x, y -> XY(x, y) }
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 ->
val plotter = Plotter(context, xDrive, yDrive) { 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.collective.MainKt"
mainClass = "space.kscience.controls.demo.map.MainKt"
}
}

View File

@ -1,4 +1,4 @@
package space.kscience.controls.demo.collective
package space.kscience.controls.demo.map
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 SampleDeviceState<T>(
class DebounceDeviceState<T>(
val origin: DeviceState<T>,
val interval: Duration,
) : DeviceState<T> {
@ -17,4 +17,4 @@ class SampleDeviceState<T>(
override fun toString(): String = "DebounceDeviceState($value, interval=$interval)"
}
fun <T> DeviceState<T>.sample(interval: Duration) = SampleDeviceState(this, interval)
fun <T> DeviceState<T>.debounce(interval: Duration) = DebounceDeviceState(this, interval)

View File

@ -1,59 +1,43 @@
package space.kscience.controls.demo.collective
package space.kscience.controls.demo.map
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.*
import space.kscience.maps.coordinates.Gmc
typealias DeviceId = String
typealias RemoteDeviceId = String
internal data class VirtualDeviceState(
val id: DeviceId,
val configuration: CollectiveDeviceConfiguration,
data class RemoteDeviceState(
val id: RemoteDeviceId,
val configuration: RemoteDeviceConfiguration,
val position: MutableDeviceState<Gmc>,
val velocity: MutableDeviceState<GmcVelocity>,
)
internal fun VirtualDeviceState(
id: DeviceId,
public fun RemoteDeviceState(
id: RemoteDeviceId,
position: Gmc,
configuration: CollectiveDeviceConfiguration.() -> Unit = {},
) = VirtualDeviceState(
configuration: RemoteDeviceConfiguration.() -> Unit = {},
) = RemoteDeviceState(
id,
CollectiveDeviceConfiguration(id).apply(configuration),
RemoteDeviceConfiguration(configuration),
MutableDeviceState(position),
MutableDeviceState(GmcVelocity.zero)
)
internal class DeviceCollectiveModel(
class DeviceCollectiveModel(
context: Context,
val deviceStates: Collection<VirtualDeviceState>,
val visibilityRange: Distance,
val deviceStates: Collection<RemoteDeviceState>,
) : 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.collective
package space.kscience.controls.demo.map
import kotlinx.serialization.Serializable
import space.kscience.kmath.geometry.Angle

View File

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

View File

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

View File

@ -1,42 +1,27 @@
@file:OptIn(ExperimentalFoundationApi::class, ExperimentalSplitPaneApi::class)
package space.kscience.controls.demo.map
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.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.useProperty
import space.kscience.controls.spec.propertyFlow
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.*
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 java.nio.file.Path
@ -49,6 +34,7 @@ fun rememberDeviceManager(): DeviceManager = remember {
context.request(DeviceManager)
}
@Composable
fun App() {
val scope = rememberCoroutineScope()
@ -61,24 +47,14 @@ fun App() {
generateModel(deviceManager.context)
}
val devices: Map<DeviceId, CollectiveDevice> = remember {
val devices: Map<RemoteDeviceId, RemoteDevice> = remember {
collectiveModel.deviceStates.associate {
val device = CollectiveDeviceConstructor(
context = deviceManager.context,
configuration = it.configuration,
position = it.position,
velocity = it.velocity
) {
collectiveModel.locateVisible(it.id).keys
}
val device = RemoteDeviceConstructor(deviceManager.context, it.configuration, it.position, it.velocity)
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),
@ -86,93 +62,21 @@ fun App() {
)
}
HorizontalSplitPane(
splitPaneState = rememberSplitPaneState(0.9f)
MapView(
mapTileProvider = mapTileProvider,
config = ViewConfig()
) {
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.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)
}
}
}
}
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)
}
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()
)
}
}
}
}
devices.forEach { (id, device) ->
device.propertyFlow(RemoteDevice.position).onEach { position ->
rectangle(position, id = id).color(Color.Blue)
}.launchIn(scope)
}
}
}