From 4c93b5c9b3feda0321fe95d54266821b6df7b0c7 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 23 Aug 2023 16:37:35 +0300
Subject: [PATCH 001/125] Update readme

---
 README.md        | 8 +++++---
 build.gradle.kts | 1 +
 2 files changed, 6 insertions(+), 3 deletions(-)

diff --git a/README.md b/README.md
index d5b40c3..5518d1f 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,14 @@
 [![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub)
 
-# Controls.kt
+[![DOI](https://zenodo.org/badge/240888288.svg)](https://zenodo.org/badge/latestdoi/240888288)
 
-Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing.
+# Controls-kt
+
+Controls-kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing.
 This repository contains a prototype of API and simple implementation
 of a slow control system, including a demo.
 
-Controls.kt uses some concepts and modules of DataForge,
+Controls-kt uses some concepts and modules of DataForge,
 such as `Meta` (tree-like value structure).
 
 To learn more about DataForge, please consult the following URLs:
diff --git a/build.gradle.kts b/build.gradle.kts
index df7c664..8dfafdc 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -32,6 +32,7 @@ ksciencePublish {
             "https://maven.pkg.jetbrains.space/spc/p/sci/maven"
         }
     )
+    sonatype("https://oss.sonatype.org")
 }
 
 readme.readmeTemplate = file("docs/templates/README-TEMPLATE.md")
\ No newline at end of file

From 0f610a5e198e9f3f263f38915236454aec6709c8 Mon Sep 17 00:00:00 2001
From: darksnake <altavir@gmail.com>
Date: Thu, 24 Aug 2023 16:25:17 +0300
Subject: [PATCH 002/125] Fix mass demo plot

---
 .../space/kscience/controls/demo/MassDevice.kt       | 12 +++++-------
 magix/magix-zmq/build.gradle.kts                     |  2 +-
 2 files changed, 6 insertions(+), 8 deletions(-)

diff --git a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
index 5c89c26..040d288 100644
--- a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
+++ b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
@@ -23,10 +23,7 @@ import space.kscience.magix.rsocket.rSocketWithTcp
 import space.kscience.magix.rsocket.rSocketWithWebSockets
 import space.kscience.magix.server.RSocketMagixFlowPlugin
 import space.kscience.magix.server.startMagixServer
-import space.kscience.plotly.Plotly
-import space.kscience.plotly.bar
-import space.kscience.plotly.layout
-import space.kscience.plotly.plot
+import space.kscience.plotly.*
 import space.kscience.plotly.server.PlotlyUpdateMode
 import space.kscience.plotly.server.serve
 import space.kscience.plotly.server.show
@@ -88,9 +85,10 @@ suspend fun main() {
         updateMode = PlotlyUpdateMode.PUSH
         updateInterval = 1000
         page { container ->
-            plot(renderer = container) {
+            plot(renderer = container, config = PlotlyConfig { saveAsSvg() }) {
                 layout {
-                    title = "Latest event"
+//                    title = "Latest event"
+
                     xaxis.title = "Device number"
                     yaxis.title = "Maximum latency in ms"
                 }
@@ -119,7 +117,7 @@ suspend fun main() {
                                 latest.clear()
                                 max.clear()
                                 x.numbers = sorted.keys
-                                y.numbers = sorted.values.map { it.inWholeMilliseconds / 1000.0 + 0.0001 }
+                                y.numbers = sorted.values.map { it.inWholeMicroseconds / 1000.0 + 0.0001 }
                             }
                         }
                     }
diff --git a/magix/magix-zmq/build.gradle.kts b/magix/magix-zmq/build.gradle.kts
index cf4ee9b..20cc246 100644
--- a/magix/magix-zmq/build.gradle.kts
+++ b/magix/magix-zmq/build.gradle.kts
@@ -12,7 +12,7 @@ description = """
 dependencies {
     api(projects.magix.magixApi)
     api("org.slf4j:slf4j-api:2.0.6")
-    api("org.zeromq:jeromq:0.5.2")
+    api("org.zeromq:jeromq:0.5.3")
 }
 
 readme {

From cc36ef805bdb91aee6f2a9c42acccb41c27f271a Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 4 Sep 2023 14:56:18 +0300
Subject: [PATCH 003/125] update version

---
 build.gradle.kts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index 8dfafdc..60e263d 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.2.0"
+    version = "0.2.1-dev-1"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }

From 036bef1adb4daa3053f0e5a4d1f3e64d445e60b8 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 16 Sep 2023 15:54:36 +0300
Subject: [PATCH 004/125] fix dataforge version

---
 build.gradle.kts                         | 2 +-
 controls-pi/build.gradle.kts             | 8 +++++---
 gradle/wrapper/gradle-wrapper.properties | 2 +-
 3 files changed, 7 insertions(+), 5 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index 60e263d..b18f0a2 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,7 +6,7 @@ plugins {
     id("space.kscience.gradle.project")
 }
 
-val dataforgeVersion: String by extra("0.6.2-dev-3")
+val dataforgeVersion: String by extra("0.6.2")
 val ktorVersion: String by extra(space.kscience.gradle.KScienceVersions.ktorVersion)
 val rsocketVersion by extra("0.15.4")
 val xodusVersion by extra("2.0.1")
diff --git a/controls-pi/build.gradle.kts b/controls-pi/build.gradle.kts
index a763396..cf7bfbe 100644
--- a/controls-pi/build.gradle.kts
+++ b/controls-pi/build.gradle.kts
@@ -7,10 +7,12 @@ description = """
     Utils to work with controls-kt on Raspberry pi
 """.trimIndent()
 
+val pi4jVerstion = "2.3.0"
+
 dependencies{
     api(project(":controls-core"))
     api("com.pi4j:pi4j-ktx:2.4.0") // Kotlin DSL
-    api("com.pi4j:pi4j-core:2.3.0")
-    api("com.pi4j:pi4j-plugin-raspberrypi:2.3.0")
-    api("com.pi4j:pi4j-plugin-pigpio:2.3.0")
+    api("com.pi4j:pi4j-core:$pi4jVerstion")
+    api("com.pi4j:pi4j-plugin-raspberrypi:$pi4jVerstion")
+    api("com.pi4j:pi4j-plugin-pigpio:$pi4jVerstion")
 }
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index fae0804..db9a6b8 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists

From bc5037b256c5df6250f18c03e2ae7d2e7cd3f594 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 16 Sep 2023 16:09:47 +0300
Subject: [PATCH 005/125] fix dataforge version

---
 build.gradle.kts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index b18f0a2..619948b 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.2.1-dev-1"
+    version = "0.2.1"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }

From 8b6a6abd9245e07d53402f724ff6f9f6f7c47f09 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 18 Sep 2023 09:00:04 +0300
Subject: [PATCH 006/125] Update to PiPlugin logic

---
 build.gradle.kts                              |  2 +-
 .../space/kscience/controls/pi/PiPlugin.kt    | 24 +++++++++++++++++++
 .../kscience/controls/pi/PiSerialPort.kt      | 23 +++++++++++-------
 docs/templates/README-TEMPLATE.md             |  2 ++
 4 files changed, 42 insertions(+), 9 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index 619948b..d188449 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.2.1"
+    version = "0.2.2-dev-1"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt
index 547a142..20d4c80 100644
--- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt
+++ b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt
@@ -1,22 +1,46 @@
 package space.kscience.controls.pi
 
+import com.pi4j.Pi4J
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.controls.ports.PortFactory
 import space.kscience.controls.ports.Ports
 import space.kscience.dataforge.context.AbstractPlugin
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.PluginFactory
 import space.kscience.dataforge.context.PluginTag
 import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.parseAsName
+import com.pi4j.context.Context as PiContext
 
 public class PiPlugin : AbstractPlugin() {
     public val ports: Ports by require(Ports)
+    public val devices: DeviceManager by require(DeviceManager)
 
     override val tag: PluginTag get() = Companion.tag
 
+    public val piContext: PiContext by lazy { createPiContext(context, meta) }
+
+    override fun content(target: String): Map<Name, Any> = when (target) {
+        PortFactory.TYPE -> mapOf(
+            PiSerialPort.type.parseAsName() to PiSerialPort,
+        )
+
+        else -> super.content(target)
+    }
+
+    override fun detach() {
+        piContext.shutdown()
+        super.detach()
+    }
+
     public companion object : PluginFactory<PiPlugin> {
 
         override val tag: PluginTag = PluginTag("controls.ports.pi", group = PluginTag.DATAFORGE_GROUP)
 
         override fun build(context: Context, meta: Meta): PiPlugin = PiPlugin()
 
+        public fun createPiContext(context: Context, meta: Meta): PiContext = Pi4J.newAutoContext()
+
     }
 }
\ No newline at end of file
diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt
index 4924b8d..6b9662d 100644
--- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt
+++ b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt
@@ -1,6 +1,5 @@
 package space.kscience.controls.pi
 
-import com.pi4j.Pi4J
 import com.pi4j.io.serial.Baud
 import com.pi4j.io.serial.Serial
 import com.pi4j.io.serial.SerialConfigBuilder
@@ -13,20 +12,25 @@ import space.kscience.controls.ports.toArray
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.error
 import space.kscience.dataforge.context.logger
+import space.kscience.dataforge.context.request
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.enum
 import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.meta.string
 import java.nio.ByteBuffer
 import kotlin.coroutines.CoroutineContext
+import com.pi4j.context.Context as PiContext
 
 public class PiSerialPort(
     context: Context,
     coroutineContext: CoroutineContext = context.coroutineContext,
-    public val serialBuilder: () -> Serial,
+    public val serialBuilder: PiContext.() -> Serial,
 ) : AbstractPort(context, coroutineContext) {
 
-    private val serial: Serial by lazy { serialBuilder() }
+    private val serial: Serial by lazy {
+        val pi = context.request(PiPlugin)
+        pi.piContext.serialBuilder()
+    }
 
 
     private val listenerJob = this.scope.launch(Dispatchers.IO) {
@@ -57,15 +61,18 @@ public class PiSerialPort(
     public companion object : PortFactory {
         override val type: String get() = "pi"
 
-        public fun open(context: Context, device: String, block: SerialConfigBuilder.() -> Unit): PiSerialPort =
-            PiSerialPort(context) {
-                Pi4J.newAutoContext().serial(device, block)
-            }
+        public fun open(
+            context: Context,
+            device: String,
+            block: SerialConfigBuilder.() -> Unit,
+        ): PiSerialPort = PiSerialPort(context) {
+            serial(device, block)
+        }
 
         override fun build(context: Context, meta: Meta): Port = PiSerialPort(context) {
             val device: String = meta["device"].string ?: error("Device name not defined")
             val baudRate: Baud = meta["baudRate"].enum<Baud>() ?: Baud._9600
-            Pi4J.newAutoContext().serial(device) {
+            serial(device) {
                 baud8N1(baudRate)
             }
         }
diff --git a/docs/templates/README-TEMPLATE.md b/docs/templates/README-TEMPLATE.md
index 11e0905..ef2cb74 100644
--- a/docs/templates/README-TEMPLATE.md
+++ b/docs/templates/README-TEMPLATE.md
@@ -1,5 +1,7 @@
 [![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub)
 
+[![](https://maven.sciprog.center/api/badge/latest/kscience/space/kscience/controls-core-jvm?color=40c14a&name=repo.kotlin.link&prefix=v)](https://maven.sciprog.center/)
+
 # Controls.kt
 
 Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing.

From aef94767c5b6689868fb897318a484a9f7c8c539 Mon Sep 17 00:00:00 2001
From: darksnake <altavir@gmail.com>
Date: Mon, 18 Sep 2023 13:38:45 +0300
Subject: [PATCH 007/125] Fix all-things demo

---
 build.gradle.kts                 | 1 +
 demo/all-things/build.gradle.kts | 4 +++-
 2 files changed, 4 insertions(+), 1 deletion(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index d188449..aa9f9fb 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -7,6 +7,7 @@ plugins {
 }
 
 val dataforgeVersion: String by extra("0.6.2")
+val visionforgeVersion by extra("0.3.0-dev-10")
 val ktorVersion: String by extra(space.kscience.gradle.KScienceVersions.ktorVersion)
 val rsocketVersion by extra("0.15.4")
 val xodusVersion by extra("2.0.1")
diff --git a/demo/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts
index 05d7395..bb6d5ec 100644
--- a/demo/all-things/build.gradle.kts
+++ b/demo/all-things/build.gradle.kts
@@ -12,6 +12,7 @@ repositories {
 
 val ktorVersion: String by rootProject.extra
 val rsocketVersion: String by rootProject.extra
+val visionforgeVersion: String by rootProject.extra
 
 dependencies {
     implementation(projects.controlsCore)
@@ -24,7 +25,8 @@ dependencies {
 
     implementation("io.ktor:ktor-client-cio:$ktorVersion")
     implementation("no.tornado:tornadofx:1.7.20")
-    implementation("space.kscience:plotlykt-server:0.5.3")
+    implementation("space.kscience:plotlykt-server:0.6.0")
+    implementation("space.kscience:visionforge-plotly:$visionforgeVersion")
 //    implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6")
     implementation(spclibs.logback.classic)
 }

From a51510606f2c5681a34d9137535e302f539a0b21 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 24 Sep 2023 13:02:52 +0300
Subject: [PATCH 008/125] add customizeable scopes to property listeners

---
 .../commonMain/kotlin/space/kscience/controls/api/Device.kt | 4 ++--
 .../space/kscience/controls/spec/DevicePropertySpec.kt      | 6 ++++--
 .../kotlin/space/kscience/controls/modbus/ModbusDevice.kt   | 4 ----
 3 files changed, 6 insertions(+), 8 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index 4fc6365..8a19e68 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -124,5 +124,5 @@ public fun Device.getAllProperties(): Meta = Meta {
 /**
  * Subscribe on property changes for the whole device
  */
-public fun Device.onPropertyChange(callback: suspend PropertyChangedMessage.() -> Unit): Job =
-    messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(this)
+public fun Device.onPropertyChange(scope: CoroutineScope = this, callback: suspend PropertyChangedMessage.() -> Unit): Job =
+    messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(scope)
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
index cf8c741..7f3aa06 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
@@ -115,6 +115,7 @@ public fun <D : Device, T> D.propertyFlow(spec: DevicePropertySpec<D, T>): Flow<
  */
 public fun <D : Device, T> D.onPropertyChange(
     spec: DevicePropertySpec<D, T>,
+    scope: CoroutineScope = this,
     callback: suspend PropertyChangedMessage.(T) -> Unit,
 ): Job = messageFlow
     .filterIsInstance<PropertyChangedMessage>()
@@ -124,15 +125,16 @@ public fun <D : Device, T> D.onPropertyChange(
         if (newValue != null) {
             change.callback(newValue)
         }
-    }.launchIn(this)
+    }.launchIn(scope)
 
 /**
  * Call [callback] on initial property value and each value change
  */
 public fun <D : Device, T> D.useProperty(
     spec: DevicePropertySpec<D, T>,
+    scope: CoroutineScope = this,
     callback: suspend (T) -> Unit,
-): Job = launch {
+): Job = scope.launch {
     callback(read(spec))
     messageFlow
         .filterIsInstance<PropertyChangedMessage>()
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
index ea4330c..3522f15 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
@@ -183,10 +183,6 @@ public fun ModbusDevice.writeHoldingRegisters(address: Int, buffer: ByteBuffer):
     return writeHoldingRegisters(address, array)
 }
 
-public fun ModbusDevice.writeShortRegister(address: Int, value: Short) {
-    master.writeSingleRegister(address, SimpleInputRegister(value.toInt()))
-}
-
 public fun ModbusDevice.modbusRegister(
     address: Int,
 ): ReadWriteProperty<ModbusDevice, Short> = object : ReadWriteProperty<ModbusDevice, Short> {

From a337daee9319ab2de541dbe9caa0f9ba0e64be76 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 24 Sep 2023 13:21:01 +0300
Subject: [PATCH 009/125] Add read-after-write for DeviceBase property writers

---
 .../kscience/controls/spec/DeviceBase.kt      | 21 ++++++++++++-------
 .../kscience/controls/spec/DeviceSpec.kt      |  2 +-
 .../sciprog/devices/mks/MksPdr900Device.kt    | 14 ++++++-------
 .../pimotionmaster/PiMotionMasterDevice.kt    |  4 ++--
 4 files changed, 23 insertions(+), 18 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index 56f5aa9..dc43637 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -87,7 +87,7 @@ public abstract class DeviceBase<D : Device>(
     /**
      * Update logical property state and notify listeners
      */
-    protected suspend fun updateLogical(propertyName: String, value: Meta?) {
+    protected suspend fun propertyChanged(propertyName: String, value: Meta?) {
         if (value != logicalState[propertyName]) {
             stateLock.withLock {
                 logicalState[propertyName] = value
@@ -99,10 +99,10 @@ public abstract class DeviceBase<D : Device>(
     }
 
     /**
-     * Update logical state using given [spec] and its convertor
+     * Notify the device that a property with [spec] value is changed
      */
-    public suspend fun <T> updateLogical(spec: DevicePropertySpec<D, T>, value: T) {
-        updateLogical(spec.name, spec.converter.objectToMeta(value))
+    protected suspend fun <T> propertyChanged(spec: DevicePropertySpec<D, T>, value: T) {
+        propertyChanged(spec.name, spec.converter.objectToMeta(value))
     }
 
     /**
@@ -112,7 +112,7 @@ public abstract class DeviceBase<D : Device>(
     override suspend fun readProperty(propertyName: String): Meta {
         val spec = properties[propertyName] ?: error("Property with name $propertyName not found")
         val meta = spec.readMeta(self) ?: error("Failed to read property $propertyName")
-        updateLogical(propertyName, meta)
+        propertyChanged(propertyName, meta)
         return meta
     }
 
@@ -122,7 +122,7 @@ public abstract class DeviceBase<D : Device>(
     public suspend fun readPropertyOrNull(propertyName: String): Meta? {
         val spec = properties[propertyName] ?: return null
         val meta = spec.readMeta(self) ?: return null
-        updateLogical(propertyName, meta)
+        propertyChanged(propertyName, meta)
         return meta
     }
 
@@ -137,13 +137,18 @@ public abstract class DeviceBase<D : Device>(
     override suspend fun writeProperty(propertyName: String, value: Meta): Unit {
         when (val property = properties[propertyName]) {
             null -> {
-                //If there is a physical property with a given name, invalidate logical property and write physical one
-                updateLogical(propertyName, value)
+                //If there are no physical properties with given name, write a logical one.
+                propertyChanged(propertyName, value)
             }
 
             is WritableDevicePropertySpec -> {
+                //if there is a writeable property with a given name, invalidate logical and write physical
                 invalidate(propertyName)
                 property.writeMeta(self, value)
+                // perform read after writing if the writer did not set the value
+                if (logicalState[propertyName] == null) {
+                    readPropertyOrNull(propertyName)
+                }
             }
 
             else -> {
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index d05bf30..17c3ecb 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -22,7 +22,7 @@ public val MetaConverter.Companion.unit: MetaConverter<Unit> get() = UnitMetaCon
 
 @OptIn(InternalDeviceAPI::class)
 public abstract class DeviceSpec<D : Device> {
-    //initializing meta property for everyone
+    //initializing the metadata property for everyone
     private val _properties = hashMapOf<String, DevicePropertySpec<D, *>>(
         DeviceMetaPropertySpec.name to DeviceMetaPropertySpec
     )
diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
index 949ced9..7771709 100644
--- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
+++ b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
@@ -49,16 +49,16 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi
         if (powerOnValue) {
             val ans = talk("FP!ON")
             if (ans == "ON") {
-                updateLogical(powerOn, true)
+                propertyChanged(powerOn, true)
             } else {
-                updateLogical(error, "Failed to set power state")
+                propertyChanged(error, "Failed to set power state")
             }
         } else {
             val ans = talk("FP!OFF")
             if (ans == "OFF") {
-                updateLogical(powerOn, false)
+                propertyChanged(powerOn, false)
             } else {
-                updateLogical(error, "Failed to set power state")
+                propertyChanged(error, "Failed to set power state")
             }
         }
     }
@@ -68,13 +68,13 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi
         invalidate(error)
         return if (answer.isNullOrEmpty()) {
             //            updateState(PortSensor.CONNECTED_STATE, false)
-            updateLogical(error, "No connection")
+            propertyChanged(error, "No connection")
             null
         } else {
             val res = answer.toDouble()
             if (res <= 0) {
-                updateLogical(powerOn, false)
-                updateLogical(error, "No power")
+                propertyChanged(powerOn, false)
+                propertyChanged(error, "No power")
                 null
             } else {
                 res
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index 4eff6c4..54573e4 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -172,7 +172,7 @@ class PiMotionMasterDevice(
             //Update port
             //address = portSpec.node
             port = portFactory(portSpec, context)
-            updateLogical(connected, true)
+            propertyChanged(connected, true)
 //        connector.open()
             //Initialize axes
             val idn = read(identity)
@@ -196,7 +196,7 @@ class PiMotionMasterDevice(
                 it.close()
             }
             port = null
-            updateLogical(connected, false)
+            propertyChanged(connected, false)
         }
 
 

From 34e7dd2c6d12b1c82a714dd3d3d5eedbe8d206d5 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 24 Sep 2023 13:29:15 +0300
Subject: [PATCH 010/125] Add read-after-write for DeviceBase property writers

---
 CHANGELOG.md                                  | 30 ++++++++++++-------
 .../kscience/controls/spec/DeviceBase.kt      |  3 +-
 demo/all-things/build.gradle.kts              |  2 +-
 3 files changed, 23 insertions(+), 12 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index d6f9d13..93d9645 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,26 @@
 
 ## Unreleased
 
+### Added
+
+### Changed
+
+### Deprecated
+
+### Removed
+
+### Fixed
+
+### Security
+
+## 0.2.2-dev-1 - 2023-09-24
+
+### Changed
+- updating logical state in `DeviceBase` is now protected and called `propertyChanged()`
+- `DeviceBase` tries to read property after write if the writer does not set the value.
+
+## 0.2.1 - 2023-09-24
+
 ### Added
 - Core interfaces for building a device server
 - Magix service for binding controls devices (both as RPC client and server)
@@ -20,13 +40,3 @@
 - A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes.
 - Magix history database API
 - ZMQ client endpoint for Magix
-
-### Changed
-
-### Deprecated
-
-### Removed
-
-### Fixed
-
-### Security
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index dc43637..82a4d4f 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -147,7 +147,8 @@ public abstract class DeviceBase<D : Device>(
                 property.writeMeta(self, value)
                 // perform read after writing if the writer did not set the value
                 if (logicalState[propertyName] == null) {
-                    readPropertyOrNull(propertyName)
+                    val meta = property.readMeta(self)
+                    propertyChanged(propertyName, meta)
                 }
             }
 
diff --git a/demo/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts
index 05d7395..33b654e 100644
--- a/demo/all-things/build.gradle.kts
+++ b/demo/all-things/build.gradle.kts
@@ -24,7 +24,7 @@ dependencies {
 
     implementation("io.ktor:ktor-client-cio:$ktorVersion")
     implementation("no.tornado:tornadofx:1.7.20")
-    implementation("space.kscience:plotlykt-server:0.5.3")
+    implementation("space.kscience:plotlykt-server:0.6.0")
 //    implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6")
     implementation(spclibs.logback.classic)
 }

From efe9a2e842f0c26193f43bac0ac3f4e3d7779231 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 2 Oct 2023 21:24:01 +0300
Subject: [PATCH 011/125] Fixex in modbus and write-protection for same meta

---
 CHANGELOG.md                                  |  2 +
 build.gradle.kts                              |  2 +-
 .../kscience/controls/spec/DeviceBase.kt      | 17 ++++-
 .../controls/modbus/DeviceProcessImage.kt     | 69 +++++++++++++------
 .../kscience/controls/modbus/ModbusDevice.kt  |  2 +-
 5 files changed, 65 insertions(+), 27 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93d9645..b7673a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,8 @@
 ### Removed
 
 ### Fixed
+- Property writing does not trigger change if logical state already is the same as value to be set.
+- Modbus-slave triggers only once for multi-register write.
 
 ### Security
 
diff --git a/build.gradle.kts b/build.gradle.kts
index d188449..c851997 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.2.2-dev-1"
+    version = "0.2.2-dev-2"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index 82a4d4f..2a6773e 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -7,18 +7,24 @@ import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import space.kscience.controls.api.*
 import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.debug
 import space.kscience.dataforge.context.error
 import space.kscience.dataforge.context.logger
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.misc.DFExperimental
 import kotlin.coroutines.CoroutineContext
 
-
+/**
+ * Write a meta [item] to [device]
+ */
 @OptIn(InternalDeviceAPI::class)
 private suspend fun <D : Device, T> WritableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) {
     write(device, converter.metaToObject(item) ?: error("Meta $item could not be read with $converter"))
 }
 
+/**
+ * Read Meta item from the [device]
+ */
 @OptIn(InternalDeviceAPI::class)
 private suspend fun <D : Device, T> DevicePropertySpec<D, T>.readMeta(device: D): Meta? =
     read(device)?.let(converter::objectToMeta)
@@ -135,9 +141,14 @@ public abstract class DeviceBase<D : Device>(
     }
 
     override suspend fun writeProperty(propertyName: String, value: Meta): Unit {
+        //bypass property setting if it already has that value
+        if (logicalState[propertyName] == value) {
+            logger.debug { "Skipping setting $propertyName to $value because value is already set" }
+            return
+        }
         when (val property = properties[propertyName]) {
             null -> {
-                //If there are no physical properties with given name, write a logical one.
+                //If there are no registered physical properties with given name, write a logical one.
                 propertyChanged(propertyName, value)
             }
 
@@ -145,7 +156,7 @@ public abstract class DeviceBase<D : Device>(
                 //if there is a writeable property with a given name, invalidate logical and write physical
                 invalidate(propertyName)
                 property.writeMeta(self, value)
-                // perform read after writing if the writer did not set the value
+                // perform read after writing if the writer did not set the value and the value is still in invalid state
                 if (logicalState[propertyName] == null) {
                     val meta = property.readMeta(self)
                     propertyChanged(propertyName, meta)
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
index 2f56a8a..32adfb3 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
@@ -1,9 +1,9 @@
 package space.kscience.controls.modbus
 
 import com.ghgande.j2mod.modbus.procimg.*
-import io.ktor.utils.io.core.buildPacket
-import io.ktor.utils.io.core.readByteBuffer
-import io.ktor.utils.io.core.writeShort
+import io.ktor.utils.io.core.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import space.kscience.controls.api.Device
 import space.kscience.controls.spec.DevicePropertySpec
@@ -118,22 +118,48 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
         }
     }
 
+    /**
+     * Trigger [block] if one of register changes.
+     */
+    private fun List<ObservableRegister>.onChange(block: (ByteReadPacket) -> Unit) {
+        var ready = false
+
+        forEach { register ->
+            register.addObserver { _, _ ->
+                ready = true
+            }
+        }
+
+        device.launch {
+            val builder = BytePacketBuilder()
+            while (isActive) {
+                delay(1)
+                if (ready) {
+                    val packet = builder.apply {
+                        forEach { value ->
+                            writeShort(value.toShort())
+                        }
+                    }.build()
+                    block(packet)
+                    ready = false
+                }
+            }
+        }
+    }
+
     public fun <T> bind(key: ModbusRegistryKey.HoldingRange<T>, propertySpec: WritableDevicePropertySpec<D, T>) {
         val registers = List(key.count) {
             ObservableRegister()
         }
+
         registers.forEachIndexed { index, register ->
-            register.addObserver { _, _ ->
-                val packet = buildPacket {
-                    registers.forEach { value ->
-                        writeShort(value.toShort())
-                    }
-                }
-                device[propertySpec] = key.format.readObject(packet)
-            }
             image.addRegister(key.address + index, register)
         }
 
+        registers.onChange { packet ->
+            device[propertySpec] = key.format.readObject(packet)
+        }
+
         device.useProperty(propertySpec) { value ->
             val packet = buildPacket {
                 key.format.writeObject(this, value)
@@ -182,20 +208,17 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
         val registers = List(key.count) {
             ObservableRegister()
         }
+
         registers.forEachIndexed { index, register ->
-            register.addObserver { _, _ ->
-                val packet = buildPacket {
-                    registers.forEach { value ->
-                        writeShort(value.toShort())
-                    }
-                }
-                device.launch {
-                    device.action(key.format.readObject(packet))
-                }
-            }
             image.addRegister(key.address + index, register)
         }
 
+        registers.onChange { packet ->
+            device.launch {
+                device.action(key.format.readObject(packet))
+            }
+        }
+
         return registers
     }
 
@@ -205,11 +228,13 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
  * Bind the device to Modbus slave (server) image.
  */
 public fun <D : Device> D.bindProcessImage(
+    unitId: Int = 0,
     openOnBind: Boolean = true,
     binding: DeviceProcessImageBuilder<D>.() -> Unit,
 ): ProcessImage {
-    val image = SimpleProcessImage()
+    val image = SimpleProcessImage(unitId)
     DeviceProcessImageBuilder(this, image).apply(binding)
+    image.setLocked(true)
     if (openOnBind) {
         launch {
             open()
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
index 3522f15..2f9e923 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
@@ -61,7 +61,7 @@ public interface ModbusDevice : Device {
     }
 
     public operator fun <T> ModbusRegistryKey.HoldingRange<T>.getValue(thisRef: Any?, property: KProperty<*>): T {
-        val packet = readInputRegistersToPacket(address, count)
+        val packet = readHoldingRegistersToPacket(address, count)
         return format.readObject(packet)
     }
 

From 2cc0a5bcbca0cd43f77e7bd93242ad805bdd72e5 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 2 Oct 2023 22:12:11 +0300
Subject: [PATCH 012/125] Fixex in modbus and write-protection for same meta

---
 .../space/kscience/controls/spec/DeviceBase.kt     | 14 +++++---------
 1 file changed, 5 insertions(+), 9 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index 2a6773e..5e07880 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -1,14 +1,16 @@
 package space.kscience.controls.spec
 
-import kotlinx.coroutines.*
+import kotlinx.coroutines.CoroutineName
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.newCoroutineContext
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import space.kscience.controls.api.*
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.debug
-import space.kscience.dataforge.context.error
 import space.kscience.dataforge.context.logger
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.misc.DFExperimental
@@ -65,13 +67,7 @@ public abstract class DeviceBase<D : Device>(
         get() = actions.values.map { it.descriptor }
 
     override val coroutineContext: CoroutineContext by lazy {
-        context.newCoroutineContext(
-            SupervisorJob(context.coroutineContext[Job]) +
-                    CoroutineName("Device $this") +
-                    CoroutineExceptionHandler { _, throwable ->
-                        logger.error(throwable) { "Exception in device $this job" }
-                    }
-        )
+        context.newCoroutineContext(SupervisorJob(context.coroutineContext[Job]) + CoroutineName("Device $this"))
     }
 
 

From 01606af30799d719472b7cc91125a73afccc890c Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Thu, 5 Oct 2023 07:43:49 +0300
Subject: [PATCH 013/125] clientId -> unitId

---
 .../kscience/controls/modbus/ModbusDevice.kt  | 34 +++++++++----------
 .../controls/modbus/ModbusDeviceBySpec.kt     |  2 +-
 .../controls/modbus/ModbusRegistryMap.kt      |  2 +-
 3 files changed, 19 insertions(+), 19 deletions(-)

diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
index 2f9e923..7297616 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
@@ -21,9 +21,9 @@ import kotlin.reflect.KProperty
 public interface ModbusDevice : Device {
 
     /**
-     * Client id for this specific device
+     * Unit id for this specific device
      */
-    public val clientId: Int
+    public val unitId: Int
 
     /**
      * The modubus master connector
@@ -82,35 +82,35 @@ public interface ModbusDevice : Device {
  * Read multiple sequential modbus coils (bit-values)
  */
 public fun ModbusDevice.readCoils(address: Int, count: Int): BitVector =
-    master.readCoils(clientId, address, count)
+    master.readCoils(unitId, address, count)
 
 public fun ModbusDevice.readCoil(address: Int): Boolean =
-    master.readCoils(clientId, address, 1).getBit(0)
+    master.readCoils(unitId, address, 1).getBit(0)
 
 public fun ModbusDevice.writeCoils(address: Int, values: BooleanArray) {
     val bitVector = BitVector(values.size)
     values.forEachIndexed { index, value ->
         bitVector.setBit(index, value)
     }
-    master.writeMultipleCoils(clientId, address, bitVector)
+    master.writeMultipleCoils(unitId, address, bitVector)
 }
 
 public fun ModbusDevice.writeCoil(address: Int, value: Boolean) {
-    master.writeCoil(clientId, address, value)
+    master.writeCoil(unitId, address, value)
 }
 
 public fun ModbusDevice.writeCoil(key: ModbusRegistryKey.Coil, value: Boolean) {
-    master.writeCoil(clientId, key.address, value)
+    master.writeCoil(unitId, key.address, value)
 }
 
 public fun ModbusDevice.readInputDiscretes(address: Int, count: Int): BitVector =
-    master.readInputDiscretes(clientId, address, count)
+    master.readInputDiscretes(unitId, address, count)
 
 public fun ModbusDevice.readInputDiscrete(address: Int): Boolean =
-    master.readInputDiscretes(clientId, address, 1).getBit(0)
+    master.readInputDiscretes(unitId, address, 1).getBit(0)
 
 public fun ModbusDevice.readInputRegisters(address: Int, count: Int): List<InputRegister> =
-    master.readInputRegisters(clientId, address, count).toList()
+    master.readInputRegisters(unitId, address, count).toList()
 
 private fun Array<out InputRegister>.toBuffer(): ByteBuffer {
     val buffer: ByteBuffer = ByteBuffer.allocate(size * 2)
@@ -129,10 +129,10 @@ private fun Array<out InputRegister>.toPacket(): ByteReadPacket = buildPacket {
 }
 
 public fun ModbusDevice.readInputRegistersToBuffer(address: Int, count: Int): ByteBuffer =
-    master.readInputRegisters(clientId, address, count).toBuffer()
+    master.readInputRegisters(unitId, address, count).toBuffer()
 
 public fun ModbusDevice.readInputRegistersToPacket(address: Int, count: Int): ByteReadPacket =
-    master.readInputRegisters(clientId, address, count).toPacket()
+    master.readInputRegisters(unitId, address, count).toPacket()
 
 public fun ModbusDevice.readDoubleInput(address: Int): Double =
     readInputRegistersToBuffer(address, Double.SIZE_BYTES).getDouble()
@@ -141,7 +141,7 @@ public fun ModbusDevice.readInputRegister(address: Int): Short =
     readInputRegisters(address, 1).first().toShort()
 
 public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List<Register> =
-    master.readMultipleRegisters(clientId, address, count).toList()
+    master.readMultipleRegisters(unitId, address, count).toList()
 
 /**
  * Read a number of registers to a [ByteBuffer]
@@ -149,10 +149,10 @@ public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List<Reg
  * @param count number of 2-bytes registers to read. Buffer size is 2*[count]
  */
 public fun ModbusDevice.readHoldingRegistersToBuffer(address: Int, count: Int): ByteBuffer =
-    master.readMultipleRegisters(clientId, address, count).toBuffer()
+    master.readMultipleRegisters(unitId, address, count).toBuffer()
 
 public fun ModbusDevice.readHoldingRegistersToPacket(address: Int, count: Int): ByteReadPacket =
-    master.readMultipleRegisters(clientId, address, count).toPacket()
+    master.readMultipleRegisters(unitId, address, count).toPacket()
 
 public fun ModbusDevice.readDoubleRegister(address: Int): Double =
     readHoldingRegistersToBuffer(address, Double.SIZE_BYTES).getDouble()
@@ -162,14 +162,14 @@ public fun ModbusDevice.readHoldingRegister(address: Int): Short =
 
 public fun ModbusDevice.writeHoldingRegisters(address: Int, values: ShortArray): Int =
     master.writeMultipleRegisters(
-        clientId,
+        unitId,
         address,
         Array<Register>(values.size) { SimpleInputRegister(values[it].toInt()) }
     )
 
 public fun ModbusDevice.writeHoldingRegister(address: Int, value: Short): Int =
     master.writeSingleRegister(
-        clientId,
+        unitId,
         address,
         SimpleInputRegister(value.toInt())
     )
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
index 916187f..48f48a4 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
@@ -15,7 +15,7 @@ import space.kscience.dataforge.names.NameToken
 public open class ModbusDeviceBySpec<D: Device>(
     context: Context,
     spec: DeviceSpec<D>,
-    override val clientId: Int,
+    override val unitId: Int,
     override val master: AbstractModbusMaster,
     private val disposeMasterOnClose: Boolean = true,
     meta: Meta = Meta.EMPTY,
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
index a6fc894..6e3c1da 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
@@ -152,7 +152,7 @@ public abstract class ModbusRegistryMap {
                     val rangeString = if (key.count == 1) {
                         key.address.toString()
                     } else {
-                        "${key.address} - ${key.address + key.count}"
+                        "${key.address} - ${key.address + key.count - 1}"
                     }
                     to.appendLine("${typeString}\t$rangeString\t$description")
                 }

From f1b63c3951c5da04a4e4ee7c836702dbc1086c02 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 7 Oct 2023 18:34:44 +0300
Subject: [PATCH 014/125] Add buffer to device messages

---
 .../kotlin/space/kscience/controls/spec/DeviceBase.kt  | 10 ++++++++--
 1 file changed, 8 insertions(+), 2 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index 5e07880..38aa20a 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -3,6 +3,7 @@ package space.kscience.controls.spec
 import kotlinx.coroutines.CoroutineName
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharedFlow
 import kotlinx.coroutines.newCoroutineContext
@@ -13,6 +14,8 @@ import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.debug
 import space.kscience.dataforge.context.logger
 import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.int
 import space.kscience.dataforge.misc.DFExperimental
 import kotlin.coroutines.CoroutineContext
 
@@ -47,7 +50,7 @@ private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta
  */
 public abstract class DeviceBase<D : Device>(
     final override val context: Context,
-    override val meta: Meta = Meta.EMPTY,
+    final override val meta: Meta = Meta.EMPTY,
 ) : Device {
 
     /**
@@ -76,7 +79,10 @@ public abstract class DeviceBase<D : Device>(
      */
     private val logicalState: HashMap<String, Meta?> = HashMap()
 
-    private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow()
+    private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow(
+        replay = meta["message.buffer"].int ?: 1000,
+        onBufferOverflow = BufferOverflow.DROP_OLDEST
+    )
 
     public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow
 

From 290010fc8c2b71f789f8aa990080b8502b7325d8 Mon Sep 17 00:00:00 2001
From: darksnake <altavir@gmail.com>
Date: Thu, 19 Oct 2023 16:38:50 +0300
Subject: [PATCH 015/125] Add writeable flag to mutable properties

---
 .../kotlin/space/kscience/controls/spec/DeviceSpec.kt       | 5 +++--
 .../kotlin/space/kscience/controls/demo/demoDeviceServer.kt | 6 +++---
 2 files changed, 6 insertions(+), 5 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index 17c3ecb..0376635 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -12,7 +12,7 @@ import kotlin.reflect.KMutableProperty1
 import kotlin.reflect.KProperty
 import kotlin.reflect.KProperty1
 
-public object UnitMetaConverter: MetaConverter<Unit>{
+public object UnitMetaConverter : MetaConverter<Unit> {
     override fun metaToObject(meta: Meta): Unit = Unit
 
     override fun objectToMeta(obj: Unit): Meta = Meta.EMPTY
@@ -127,7 +127,8 @@ public abstract class DeviceSpec<D : Device> {
         PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
             val propertyName = name ?: property.name
             val deviceProperty = object : WritableDevicePropertySpec<D, T> {
-                override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
+                override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName, writable = true)
+                    .apply(descriptorBuilder)
                 override val converter: MetaConverter<T> = converter
 
                 override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() }
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt
index fda8ead..5ff1287 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt
@@ -57,15 +57,15 @@ fun CoroutineScope.startDemoDeviceServer(magixEndpoint: MagixEndpoint): Applicat
     //share subscription to a parse message only once
     val subscription = magixEndpoint.subscribe(DeviceManager.magixFormat).shareIn(this, SharingStarted.Lazily)
 
-    val sinFlow = subscription.mapNotNull {  (_, payload) ->
+    val sinFlow = subscription.mapNotNull { (_, payload) ->
         (payload as? PropertyChangedMessage)?.takeIf { it.property == DemoDevice.sin.name }
     }.map { it.value }
 
-    val cosFlow = subscription.mapNotNull {  (_, payload) ->
+    val cosFlow = subscription.mapNotNull { (_, payload) ->
         (payload as? PropertyChangedMessage)?.takeIf { it.property == DemoDevice.cos.name }
     }.map { it.value }
 
-    val sinCosFlow = subscription.mapNotNull {  (_, payload) ->
+    val sinCosFlow = subscription.mapNotNull { (_, payload) ->
         (payload as? PropertyChangedMessage)?.takeIf { it.property == DemoDevice.coordinates.name }
     }.map { it.value }
 

From 7f71d0c9e998eab4c27873071c9668a31cd74e50 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 20 Oct 2023 10:14:14 +0300
Subject: [PATCH 016/125] modbus registry to json rendering

---
 build.gradle.kts                              |  14 +-
 .../space/kscience/controls/ports/phrases.kt  |   5 +
 controls-modbus/build.gradle.kts              |   2 +-
 .../controls/modbus/ModbusRegistryMap.kt      | 129 +++++++++++++-----
 gradle.properties                             |   2 +-
 5 files changed, 107 insertions(+), 45 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index f88ec4b..347a66e 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,4 +1,3 @@
-import space.kscience.gradle.isInDevelopment
 import space.kscience.gradle.useApache2Licence
 import space.kscience.gradle.useSPCTeam
 
@@ -14,25 +13,18 @@ val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.2.2-dev-2"
+    version = "0.2.2-dev-3"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
 }
 
 ksciencePublish {
-    pom("https://github.com/SciProgCentre/controls.kt") {
+    pom("https://github.com/SciProgCentre/controls-kt") {
         useApache2Licence()
         useSPCTeam()
     }
-    github("controls.kt", "SciProgCentre")
-    space(
-        if (isInDevelopment) {
-            "https://maven.pkg.jetbrains.space/spc/p/sci/dev"
-        } else {
-            "https://maven.pkg.jetbrains.space/spc/p/sci/maven"
-        }
-    )
+    repository("spc","https://maven.sciprog.center/kscience")
     sonatype("https://oss.sonatype.org")
 }
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
index 1214d01..02a0058 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
@@ -5,6 +5,7 @@ import io.ktor.utils.io.core.readBytes
 import io.ktor.utils.io.core.reset
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.transform
 
 /**
@@ -16,6 +17,10 @@ public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray>
     val output = BytePacketBuilder()
     var matcherPosition = 0
 
+    onCompletion {
+        output.close()
+    }
+
     return transform { chunk ->
         chunk.forEach { byte ->
             output.writeByte(byte)
diff --git a/controls-modbus/build.gradle.kts b/controls-modbus/build.gradle.kts
index aee64d5..1ec39d6 100644
--- a/controls-modbus/build.gradle.kts
+++ b/controls-modbus/build.gradle.kts
@@ -12,7 +12,7 @@ description = """
 
 dependencies {
     api(projects.controlsCore)
-    api("com.ghgande:j2mod:3.1.1")
+    api("com.ghgande:j2mod:3.2.0")
 }
 
 readme{
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
index 6e3c1da..1d81d1b 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
@@ -1,8 +1,15 @@
 package space.kscience.controls.modbus
 
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
 import space.kscience.dataforge.io.IOFormat
 
 
+/**
+ * Modbus registry key
+ */
 public sealed class ModbusRegistryKey {
     public abstract val address: Int
     public open val count: Int = 1
@@ -25,6 +32,9 @@ public sealed class ModbusRegistryKey {
         override fun toString(): String = "InputRegister(address=$address)"
     }
 
+    /**
+     * A range of read-only register encoding a single value
+     */
     public class InputRange<T>(
         address: Int,
         override val count: Int,
@@ -36,10 +46,16 @@ public sealed class ModbusRegistryKey {
 
     }
 
+    /**
+     * A single read-write register
+     */
     public open class HoldingRegister(override val address: Int) : ModbusRegistryKey() {
         override fun toString(): String = "HoldingRegister(address=$address)"
     }
 
+    /**
+     * A range of read-write registers encoding a single value
+     */
     public class HoldingRange<T>(
         address: Int,
         override val count: Int,
@@ -52,6 +68,9 @@ public sealed class ModbusRegistryKey {
     }
 }
 
+/**
+ * A base class for modbus registers
+ */
 public abstract class ModbusRegistryMap {
 
     private val _entries: MutableMap<ModbusRegistryKey, String> = mutableMapOf<ModbusRegistryKey, String>()
@@ -63,36 +82,56 @@ public abstract class ModbusRegistryMap {
         return key
     }
 
+    /**
+     * Register a [ModbusRegistryKey.Coil] key and return it
+     */
     protected fun coil(address: Int, description: String = ""): ModbusRegistryKey.Coil =
         register(ModbusRegistryKey.Coil(address), description)
 
 
+    /**
+     * Register a [ModbusRegistryKey.DiscreteInput] key and return it
+     */
     protected fun discrete(address: Int, description: String = ""): ModbusRegistryKey.DiscreteInput =
         register(ModbusRegistryKey.DiscreteInput(address), description)
 
+    /**
+     * Register a [ModbusRegistryKey.InputRegister] key and return it
+     */
     protected fun input(address: Int, description: String = ""): ModbusRegistryKey.InputRegister =
         register(ModbusRegistryKey.InputRegister(address), description)
 
+    /**
+     * Register a [ModbusRegistryKey.InputRange] key and return it
+     */
     protected fun <T> input(
         address: Int,
         count: Int,
         reader: IOFormat<T>,
         description: String = "",
-    ): ModbusRegistryKey.InputRange<T> =
-        register(ModbusRegistryKey.InputRange(address, count, reader), description)
+    ): ModbusRegistryKey.InputRange<T> = register(ModbusRegistryKey.InputRange(address, count, reader), description)
 
+    /**
+     * Register a [ModbusRegistryKey.HoldingRegister] key and return it
+     */
     protected fun register(address: Int, description: String = ""): ModbusRegistryKey.HoldingRegister =
         register(ModbusRegistryKey.HoldingRegister(address), description)
 
+    /**
+     * Register a [ModbusRegistryKey.HoldingRange] key and return it
+     */
     protected fun <T> register(
         address: Int,
         count: Int,
         format: IOFormat<T>,
         description: String = "",
-    ): ModbusRegistryKey.HoldingRange<T> =
-        register(ModbusRegistryKey.HoldingRange(address, count, format), description)
+    ): ModbusRegistryKey.HoldingRange<T> = register(ModbusRegistryKey.HoldingRange(address, count, format), description)
 
     public companion object {
+
+        /**
+         * Validate the register map. Throw an error if the map is invalid
+         */
         public fun validate(map: ModbusRegistryMap) {
             var lastCoil: ModbusRegistryKey.Coil? = null
             var lastDiscreteInput: ModbusRegistryKey.DiscreteInput? = null
@@ -127,36 +166,62 @@ public abstract class ModbusRegistryMap {
             }
         }
 
-        private val ModbusRegistryKey.sectionNumber
-            get() = when (this) {
-                is ModbusRegistryKey.Coil -> 1
-                is ModbusRegistryKey.DiscreteInput -> 2
-                is ModbusRegistryKey.HoldingRegister -> 4
-                is ModbusRegistryKey.InputRegister -> 3
-            }
+    }
+}
 
-        public fun print(map: ModbusRegistryMap, to: Appendable = System.out) {
-            validate(map)
-            map.entries.entries
-                .sortedWith(
-                    Comparator.comparingInt<Map.Entry<ModbusRegistryKey, String>?> { it.key.sectionNumber }
-                        .thenComparingInt { it.key.address }
-                )
-                .forEach { (key, description) ->
-                    val typeString = when (key) {
-                        is ModbusRegistryKey.Coil -> "Coil"
-                        is ModbusRegistryKey.DiscreteInput -> "Discrete"
-                        is ModbusRegistryKey.HoldingRegister -> "Register"
-                        is ModbusRegistryKey.InputRegister -> "Input"
-                    }
-                    val rangeString = if (key.count == 1) {
-                        key.address.toString()
-                    } else {
-                        "${key.address} - ${key.address + key.count - 1}"
-                    }
-                    to.appendLine("${typeString}\t$rangeString\t$description")
-                }
+private val ModbusRegistryKey.sectionNumber
+    get() = when (this) {
+        is ModbusRegistryKey.Coil -> 1
+        is ModbusRegistryKey.DiscreteInput -> 2
+        is ModbusRegistryKey.HoldingRegister -> 4
+        is ModbusRegistryKey.InputRegister -> 3
+    }
+
+public fun ModbusRegistryMap.print(to: Appendable = System.out) {
+    ModbusRegistryMap.validate(this)
+    entries.entries
+        .sortedWith(
+            Comparator.comparingInt<Map.Entry<ModbusRegistryKey, String>?> { it.key.sectionNumber }
+                .thenComparingInt { it.key.address }
+        )
+        .forEach { (key, description) ->
+            val typeString = when (key) {
+                is ModbusRegistryKey.Coil -> "Coil"
+                is ModbusRegistryKey.DiscreteInput -> "Discrete"
+                is ModbusRegistryKey.HoldingRegister -> "Register"
+                is ModbusRegistryKey.InputRegister -> "Input"
+            }
+            val rangeString = if (key.count == 1) {
+                key.address.toString()
+            } else {
+                "${key.address} - ${key.address + key.count - 1}"
+            }
+            to.appendLine("${typeString}\t$rangeString\t$description")
         }
+}
+
+public fun ModbusRegistryMap.toJson(): JsonArray = buildJsonArray {
+    ModbusRegistryMap.validate(this@toJson)
+    entries.forEach { (key, description) ->
+
+        val entry = buildJsonObject {
+            put(
+                "type",
+                when (key) {
+                    is ModbusRegistryKey.Coil -> "Coil"
+                    is ModbusRegistryKey.DiscreteInput -> "Discrete"
+                    is ModbusRegistryKey.HoldingRegister -> "Register"
+                    is ModbusRegistryKey.InputRegister -> "Input"
+                }
+            )
+            put("address", key.address)
+            if (key.count > 1) {
+                put("count", key.count)
+            }
+            put("description", description)
+        }
+
+        add(entry)
     }
 }
 
diff --git a/gradle.properties b/gradle.properties
index 5b956f1..c7e0d88 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -10,4 +10,4 @@ publishing.sonatype=false
 org.gradle.configureondemand=true
 org.gradle.jvmargs=-Xmx4096m
 
-toolsVersion=0.14.10-kotlin-1.9.0
\ No newline at end of file
+toolsVersion=0.15.0-kotlin-1.9.20-RC
\ No newline at end of file

From 1619fdadf23e033b049d7ad3b724f8acffa012bb Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 25 Oct 2023 22:31:36 +0300
Subject: [PATCH 017/125] Refactoring. Developing composer

---
 build.gradle.kts                              |   2 +-
 controls-constructor/build.gradle.kts         |  20 +++
 .../kscience/controls/constructor/Drive.kt    |  51 ++++++
 .../controls/constructor/LimitSwitch.kt       |  31 ++++
 .../controls/constructor/PidRegulator.kt      | 146 ++++++++++++++++++
 .../space/kscience/controls/api/Device.kt     |  18 ++-
 .../kscience/controls/api/DeviceMessage.kt    |   8 +-
 .../kscience/controls/api/descriptors.kt      |   4 +-
 .../controls/manager/DeviceManager.kt         |   2 +-
 .../kscience/controls/spec/DeviceBase.kt      |  58 +++++--
 .../kscience/controls/spec/DeviceBySpec.kt    |   7 +-
 .../controls/spec/DevicePropertySpec.kt       |   4 +-
 .../kscience/controls/spec/DeviceSpec.kt      |   8 +-
 .../kscience/controls/spec/DeviceTree.kt      |  36 +++++
 .../controls/modbus/DeviceProcessImage.kt     |   2 +-
 .../controls/modbus/ModbusDeviceBySpec.kt     |   6 +-
 .../opcua/client/OpcUaDeviceBySpec.kt         |   3 +-
 .../controls/opcua/server/DeviceNameSpace.kt  |   4 +-
 .../controls/opcua/client/OpcUaClientTest.kt  |   7 +-
 .../controls/demo/DemoControllerView.kt       |   2 +-
 .../kscience/controls/demo/DemoDevice.kt      |   2 +-
 .../controls/demo/car/MagixVirtualCar.kt      |   9 +-
 .../kscience/controls/demo/car/VirtualCar.kt  |   3 +-
 .../controls/demo/car/VirtualCarController.kt |   4 +-
 .../pimotionmaster/PiMotionMasterDevice.kt    |  24 +--
 gradle.properties                             |   2 +-
 settings.gradle.kts                           |   1 +
 27 files changed, 388 insertions(+), 76 deletions(-)
 create mode 100644 controls-constructor/build.gradle.kts
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceTree.kt

diff --git a/build.gradle.kts b/build.gradle.kts
index 347a66e..4ca88d6 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.2.2-dev-3"
+    version = "0.3.0-dev-1"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-constructor/build.gradle.kts b/controls-constructor/build.gradle.kts
new file mode 100644
index 0000000..5815444
--- /dev/null
+++ b/controls-constructor/build.gradle.kts
@@ -0,0 +1,20 @@
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+description = """
+    A low-code constructor foe composite devices simulation
+""".trimIndent()
+
+kscience{
+    jvm()
+    js()
+    dependencies {
+        api(projects.controlsCore)
+    }
+}
+
+readme{
+    maturity = space.kscience.gradle.Maturity.PROTOTYPE
+}
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
new file mode 100644
index 0000000..ede628a
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
@@ -0,0 +1,51 @@
+package center.sciprog.controls.devices.misc
+
+import kotlinx.coroutines.Job
+import space.kscience.controls.api.Device
+import space.kscience.controls.spec.DeviceBySpec
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.DeviceSpec
+import space.kscience.controls.spec.doubleProperty
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.transformations.MetaConverter
+
+
+/**
+ * A single axis drive
+ */
+public interface Drive : Device {
+    /**
+     * Get or set target value
+     */
+    public var target: Double
+
+    /**
+     * Current position value
+     */
+    public val position: Double
+
+    public companion object : DeviceSpec<Drive>() {
+        public val target: DevicePropertySpec<Drive, Double> by property(MetaConverter.double, Drive::target)
+
+        public val position: DevicePropertySpec<Drive, Double> by doubleProperty { position }
+    }
+}
+
+/**
+ * Virtual [Drive] with speed limit
+ */
+public class VirtualDrive(
+    context: Context,
+    value: Double,
+    private val speed: Double,
+) : DeviceBySpec<Drive>(Drive, context), Drive {
+
+    private var moveJob: Job? = null
+
+    override var position: Double = value
+        private set
+
+    override var target: Double = value
+
+
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
new file mode 100644
index 0000000..6e82064
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
@@ -0,0 +1,31 @@
+package center.sciprog.controls.devices.misc
+
+import space.kscience.controls.api.Device
+import space.kscience.controls.spec.DeviceBySpec
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.DeviceSpec
+import space.kscience.controls.spec.booleanProperty
+import space.kscience.dataforge.context.Context
+
+
+/**
+ * A limit switch device
+ */
+public interface LimitSwitch : Device {
+
+    public val locked: Boolean
+
+    public companion object : DeviceSpec<LimitSwitch>() {
+        public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { locked }
+    }
+}
+
+/**
+ * Virtual [LimitSwitch]
+ */
+public class VirtualLimitSwitch(
+    context: Context,
+    private val lockedFunction: () -> Boolean,
+) : DeviceBySpec<LimitSwitch>(LimitSwitch, context), LimitSwitch {
+    override val locked: Boolean get() = lockedFunction()
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
new file mode 100644
index 0000000..8dbe6ba
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
@@ -0,0 +1,146 @@
+package center.sciprog.controls.devices.misc
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import space.kscience.controls.api.Device
+import space.kscience.controls.spec.DeviceBySpec
+import space.kscience.controls.spec.DeviceSpec
+import space.kscience.controls.spec.doubleProperty
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.double
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import kotlin.math.pow
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.DurationUnit
+
+
+interface PidRegulator : Device {
+    /**
+     * Proportional coefficient
+     */
+    val kp: Double
+
+    /**
+     * Integral coefficient
+     */
+    val ki: Double
+
+    /**
+     * Differential coefficient
+     */
+    val kd: Double
+
+    /**
+     * The target value for PID
+     */
+    var target: Double
+
+    /**
+     * Read current value
+     */
+    suspend fun read(): Double
+
+    companion object : DeviceSpec<PidRegulator>() {
+        val target by property(MetaConverter.double, PidRegulator::target)
+        val value by doubleProperty { read() }
+    }
+}
+
+/**
+ *
+ */
+class VirtualPid(
+    context: Context,
+    override val kp: Double,
+    override val ki: Double,
+    override val kd: Double,
+    val mass: Double,
+    override var target: Double = 0.0,
+    private val dt: Duration = 0.5.milliseconds,
+    private val clock: Clock = Clock.System,
+) : DeviceBySpec<PidRegulator>(PidRegulator, context), PidRegulator {
+
+    private val mutex = Mutex()
+
+
+    private var lastTime: Instant = clock.now()
+    private var lastValue: Double = target
+
+    private var value: Double = target
+    private var velocity: Double = 0.0
+    private var acceleration: Double = 0.0
+    private var integral: Double = 0.0
+
+
+    private var updateJob: Job? = null
+
+    override suspend fun onStart() {
+        updateJob = launch {
+            while (isActive) {
+                delay(dt)
+                mutex.withLock {
+                    val realTime = clock.now()
+                    val delta = target - value
+                    val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
+                    integral += delta * dtSeconds
+                    val derivative = (value - lastValue) / dtSeconds
+
+                    //set last time and value to new values
+                    lastTime = realTime
+                    lastValue = value
+
+                    // compute new value based on velocity and acceleration from the previous step
+                    value += velocity * dtSeconds + acceleration * dtSeconds.pow(2) / 2
+
+                    // compute new velocity based on acceleration on the previous step
+                    velocity += acceleration * dtSeconds
+
+                    //compute force for the next step based on current values
+                    acceleration = (kp * delta + ki * integral + kd * derivative) / mass
+
+
+                    check(value.isFinite() && velocity.isFinite()) {
+                        "Value $value is not finite"
+                    }
+                }
+            }
+        }
+    }
+
+    override fun onStop() {
+        updateJob?.cancel()
+        super<PidRegulator>.stop()
+    }
+
+    override suspend fun read(): Double = value
+
+    suspend fun readVelocity(): Double = velocity
+
+    suspend fun readAcceleration(): Double = acceleration
+
+    suspend fun write(newTarget: Double) = mutex.withLock {
+        require(newTarget.isFinite()) { "Value $newTarget is not valid" }
+        target = newTarget
+    }
+
+    companion object : Factory<Device> {
+        override fun build(context: Context, meta: Meta) = VirtualPid(
+            context,
+            meta["kp"].double ?: error("Kp is not defined"),
+            meta["ki"].double ?: error("Ki is not defined"),
+            meta["kd"].double ?: error("Kd is not defined"),
+            meta["m"].double ?: error("Mass is not defined"),
+        )
+
+    }
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index 8a19e68..dad1d90 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -20,8 +20,19 @@ import space.kscience.dataforge.names.Name
  * A lifecycle state of a device
  */
 public enum class DeviceLifecycleState{
+    /**
+     * Device is initializing
+      */
     INIT,
+
+    /**
+     * The Device is initialized and running
+     */
     OPEN,
+
+    /**
+     * The Device is closed
+     */
     CLOSED
 }
 
@@ -31,13 +42,14 @@ public enum class DeviceLifecycleState{
  *  When canceled, cancels all running processes.
  */
 @Type(DEVICE_TARGET)
-public interface Device : AutoCloseable, ContextAware, CoroutineScope {
+public interface Device : ContextAware, CoroutineScope {
 
     /**
      * Initial configuration meta for the device
      */
     public val meta: Meta get() = Meta.EMPTY
 
+
     /**
      * List of supported property descriptors
      */
@@ -87,12 +99,12 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope {
     /**
      * Initialize the device. This function suspends until the device is finished initialization
      */
-    public suspend fun open(): Unit = Unit
+    public suspend fun start(): Unit = Unit
 
     /**
      * Close and terminate the device. This function does not wait for the device to be closed.
      */
-    override fun close() {
+    public fun stop() {
         logger.info { "Device $this is closed" }
         cancel("The device is closed")
     }
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 45d7f0b..c59e4c3 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
@@ -25,7 +25,7 @@ public sealed class DeviceMessage {
     public abstract val time: Instant?
 
     /**
-     * Update the source device name for composition. If the original name is null, resulting name is also null.
+     * Update the source device name for composition. If the original name is null, the resulting name is also null.
      */
     public abstract fun changeSource(block: (Name) -> Name): DeviceMessage
 
@@ -203,12 +203,12 @@ public data class EmptyDeviceMessage(
 public data class DeviceLogMessage(
     val message: String,
     val data: Meta? = null,
-    override val sourceDevice: Name? = null,
+    override val sourceDevice: Name = Name.EMPTY,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
     @EncodeDefault override val time: Instant? = Clock.System.now(),
 ) : DeviceMessage() {
-    override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
+    override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
 }
 
 /**
@@ -220,7 +220,7 @@ public data class DeviceErrorMessage(
     public val errorMessage: String?,
     public val errorType: String? = null,
     public val errorStackTrace: String? = null,
-    override val sourceDevice: Name,
+    override val sourceDevice: Name = Name.EMPTY,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
     @EncodeDefault override val time: Instant? = Clock.System.now(),
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt
index 8e1705b..6f7d27c 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt
@@ -12,10 +12,10 @@ import space.kscience.dataforge.meta.descriptors.MetaDescriptorBuilder
 @Serializable
 public class PropertyDescriptor(
     public val name: String,
-    public var info: String? = null,
+    public var description: String? = null,
     public var metaDescriptor: MetaDescriptor = MetaDescriptor(),
     public var readable: Boolean = true,
-    public var writable: Boolean = false
+    public var mutable: Boolean = false
 )
 
 public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Unit){
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
index cc043c7..be59b4c 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
@@ -40,7 +40,7 @@ public class DeviceManager : AbstractPlugin(), DeviceHub {
 public fun <D : Device> DeviceManager.install(name: String, device: D): D {
     registerDevice(NameToken(name), device)
     device.launch {
-        device.open()
+        device.start()
     }
     return device
 }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index 38aa20a..b1544e0 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -1,12 +1,9 @@
 package space.kscience.controls.spec
 
-import kotlinx.coroutines.CoroutineName
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.*
 import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharedFlow
-import kotlinx.coroutines.newCoroutineContext
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import space.kscience.controls.api.*
@@ -69,8 +66,28 @@ public abstract class DeviceBase<D : Device>(
     override val actionDescriptors: Collection<ActionDescriptor>
         get() = actions.values.map { it.descriptor }
 
+
+    private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow(
+        replay = meta["message.buffer"].int ?: 1000,
+        onBufferOverflow = BufferOverflow.DROP_OLDEST
+    )
+
     override val coroutineContext: CoroutineContext by lazy {
-        context.newCoroutineContext(SupervisorJob(context.coroutineContext[Job]) + CoroutineName("Device $this"))
+        context.newCoroutineContext(
+            SupervisorJob(context.coroutineContext[Job]) +
+                    CoroutineName("Device $this") +
+                    CoroutineExceptionHandler { _, throwable ->
+                        launch {
+                            sharedMessageFlow.emit(
+                                DeviceErrorMessage(
+                                    errorMessage = throwable.message,
+                                    errorType = throwable::class.simpleName,
+                                    errorStackTrace = throwable.stackTraceToString()
+                                )
+                            )
+                        }
+                    }
+        )
     }
 
 
@@ -79,11 +96,6 @@ public abstract class DeviceBase<D : Device>(
      */
     private val logicalState: HashMap<String, Meta?> = HashMap()
 
-    private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow(
-        replay = meta["message.buffer"].int ?: 1000,
-        onBufferOverflow = BufferOverflow.DROP_OLDEST
-    )
-
     public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow
 
     @Suppress("UNCHECKED_CAST")
@@ -180,18 +192,30 @@ public abstract class DeviceBase<D : Device>(
     override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.INIT
         protected set
 
-    @OptIn(DFExperimental::class)
-    override suspend fun open() {
-        super.open()
-        lifecycleState = DeviceLifecycleState.OPEN
+    protected open suspend fun onStart() {
+
     }
 
     @OptIn(DFExperimental::class)
-    override fun close() {
-        lifecycleState = DeviceLifecycleState.CLOSED
-        super.close()
+    final override suspend fun start() {
+        super.start()
+        lifecycleState = DeviceLifecycleState.INIT
+        onStart()
+        lifecycleState = DeviceLifecycleState.OPEN
     }
 
+    protected open fun onStop() {
+
+    }
+
+    @OptIn(DFExperimental::class)
+    final override fun stop() {
+        onStop()
+        lifecycleState = DeviceLifecycleState.CLOSED
+        super.stop()
+    }
+
+
     abstract override fun toString(): String
 
 }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt
index 9309224..31f4c09 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt
@@ -16,15 +16,14 @@ public open class DeviceBySpec<D : Device>(
     override val properties: Map<String, DevicePropertySpec<D, *>> get() = spec.properties
     override val actions: Map<String, DeviceActionSpec<D, *, *>> get() = spec.actions
 
-    override suspend fun open(): Unit = with(spec) {
-        super.open()
+    override suspend fun onStart(): Unit = with(spec) {
         self.onOpen()
     }
 
-    override fun close(): Unit = with(spec) {
+    override fun onStop(): Unit = with(spec){
         self.onClose()
-        super.close()
     }
 
+
     override fun toString(): String = "Device(spec=$spec)"
 }
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
index 7f3aa06..c42a23e 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
@@ -20,7 +20,7 @@ public annotation class InternalDeviceAPI
 /**
  * Specification for a device read-only property
  */
-public interface DevicePropertySpec<in D : Device, T> {
+public interface DevicePropertySpec<in D, T> {
     /**
      * Property descriptor
      */
@@ -53,7 +53,7 @@ public interface WritableDevicePropertySpec<in D : Device, T> : DevicePropertySp
 
 }
 
-public interface DeviceActionSpec<in D : Device, I, O> {
+public interface DeviceActionSpec<in D, I, O> {
     /**
      * Action descriptor
      */
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index 0376635..14966ca 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -53,7 +53,7 @@ public abstract class DeviceSpec<D : Device> {
             val deviceProperty = object : DevicePropertySpec<D, T> {
                 override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply {
                     //TODO add type from converter
-                    writable = true
+                    mutable = true
                 }.apply(descriptorBuilder)
 
                 override val converter: MetaConverter<T> = converter
@@ -78,7 +78,7 @@ public abstract class DeviceSpec<D : Device> {
 
                 override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply {
                     //TODO add the type from converter
-                    writable = true
+                    mutable = true
                 }.apply(descriptorBuilder)
 
                 override val converter: MetaConverter<T> = converter
@@ -127,7 +127,7 @@ public abstract class DeviceSpec<D : Device> {
         PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
             val propertyName = name ?: property.name
             val deviceProperty = object : WritableDevicePropertySpec<D, T> {
-                override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName, writable = true)
+                override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName, mutable = true)
                     .apply(descriptorBuilder)
                 override val converter: MetaConverter<T> = converter
 
@@ -224,7 +224,7 @@ public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
             val propertyName = name ?: property.name
             override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
                 //TODO add type from converter
-                writable = true
+                mutable = true
             }.apply(descriptorBuilder)
 
             override val converter: MetaConverter<T> = converter
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceTree.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceTree.kt
new file mode 100644
index 0000000..f100af6
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceTree.kt
@@ -0,0 +1,36 @@
+package space.kscience.controls.spec
+
+import space.kscience.controls.api.Device
+import space.kscience.controls.api.DeviceHub
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.names.NameToken
+import kotlin.collections.Map
+import kotlin.collections.component1
+import kotlin.collections.component2
+import kotlin.collections.mapValues
+import kotlin.collections.mutableMapOf
+import kotlin.collections.set
+
+
+public class DeviceTree(
+    public val deviceManager: DeviceManager,
+    public val meta: Meta,
+    builder: Builder,
+) : DeviceHub {
+    public class Builder(public val manager: DeviceManager) {
+        internal val childrenFactories = mutableMapOf<NameToken, Factory<Device>>()
+
+        public fun <D : Device> device(name: String, factory: Factory<Device>) {
+            childrenFactories[NameToken.parse(name)] = factory
+        }
+    }
+
+    override val devices: Map<NameToken, Device> = builder.childrenFactories.mapValues { (token, factory) ->
+        val devicesMeta = meta["devices"]
+        factory.build(deviceManager.context, devicesMeta?.get(token) ?: Meta.EMPTY)
+    }
+
+}
\ No newline at end of file
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
index 32adfb3..b4fae90 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
@@ -237,7 +237,7 @@ public fun <D : Device> D.bindProcessImage(
     image.setLocked(true)
     if (openOnBind) {
         launch {
-            open()
+            start()
         }
     }
     return image
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
index 48f48a4..995e0df 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
@@ -20,16 +20,14 @@ public open class ModbusDeviceBySpec<D: Device>(
     private val disposeMasterOnClose: Boolean = true,
     meta: Meta = Meta.EMPTY,
 ) : ModbusDevice, DeviceBySpec<D>(spec, context, meta){
-    override suspend fun open() {
+    override suspend fun onStart() {
         master.connect()
-        super<DeviceBySpec>.open()
     }
 
-    override fun close() {
+    override fun onStop() {
         if(disposeMasterOnClose){
             master.disconnect()
         }
-        super<ModbusDevice>.close()
     }
 }
 
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt
index eb9b688..188760e 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt
@@ -63,8 +63,7 @@ public open class OpcUaDeviceBySpec<D : Device>(
         }
     }
 
-    override fun close() {
+    override fun onStop() {
         client.disconnect()
-        super<DeviceBySpec>.close()
     }
 }
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
index 010c2c0..a05a81d 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
@@ -73,11 +73,11 @@ public class DeviceNameSpace(
                 //for now, use DF paths as ids
                 nodeId = newNodeId("${deviceName.tokens.joinToString("/")}/$propertyName")
                 when {
-                    descriptor.readable && descriptor.writable -> {
+                    descriptor.readable && descriptor.mutable -> {
                         setAccessLevel(AccessLevel.READ_WRITE)
                         setUserAccessLevel(AccessLevel.READ_WRITE)
                     }
-                    descriptor.writable -> {
+                    descriptor.mutable -> {
                         setAccessLevel(AccessLevel.WRITE_ONLY)
                         setUserAccessLevel(AccessLevel.WRITE_ONLY)
                     }
diff --git a/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt b/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt
index eedafe7..8537449 100644
--- a/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt
+++ b/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt
@@ -40,9 +40,10 @@ class OpcUaClientTest {
     @Test
     @Ignore
     fun testReadDouble() = runTest {
-        DemoOpcUaDevice.build().use{
-            println(it.read(DemoOpcUaDevice.randomDouble))
-        }
+        val device = DemoOpcUaDevice.build()
+        device.start()
+            println(device.read(DemoOpcUaDevice.randomDouble))
+        device.stop()
     }
 
 }
\ No newline at end of file
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
index 94815fa..8edd90e 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
@@ -78,7 +78,7 @@ class DemoController : Controller(), ContextAware {
         logger.info { "Visualization server stopped" }
         magixServer?.stop(1000, 5000)
         logger.info { "Magix server stopped" }
-        device?.close()
+        device?.stop()
         logger.info { "Device server stopped" }
         context.close()
     }
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
index dd65b78..4f902c0 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
@@ -44,7 +44,7 @@ class DemoDevice(context: Context, meta: Meta) : DeviceBySpec<IDemoDevice>(Compa
             metaDescriptor {
                 type(ValueType.NUMBER)
             }
-            info = "Real to virtual time scale"
+            description = "Real to virtual time scale"
         }
 
         val sinScale by mutableProperty(MetaConverter.double, IDemoDevice::sinScaleState)
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt
index 03c781b..2c5d65a 100644
--- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt
+++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt
@@ -14,7 +14,6 @@ import space.kscience.dataforge.names.Name
 import space.kscience.magix.api.MagixEndpoint
 import space.kscience.magix.api.subscribe
 import space.kscience.magix.rsocket.rSocketWithWebSockets
-import kotlin.time.ExperimentalTime
 
 class MagixVirtualCar(context: Context, meta: Meta) : VirtualCar(context, meta) {
 
@@ -31,17 +30,13 @@ class MagixVirtualCar(context: Context, meta: Meta) : VirtualCar(context, meta)
     }
 
 
-    @OptIn(ExperimentalTime::class)
-    override suspend fun open() {
-        super.open()
+    override suspend fun onStart() {
 
         val magixEndpoint = MagixEndpoint.rSocketWithWebSockets(
             meta["magixServerHost"].string ?: "localhost",
         )
 
-        launch {
-            magixEndpoint.launchMagixVirtualCarUpdate()
-        }
+        magixEndpoint.launchMagixVirtualCarUpdate()
     }
 
     companion object : Factory<MagixVirtualCar> {
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
index 1f1dc69..2ba0fdb 100644
--- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
+++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
@@ -100,8 +100,7 @@ open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec<VirtualCar>(I
     }
 
     @OptIn(ExperimentalTime::class)
-    override suspend fun open() {
-        super<DeviceBySpec>.open()
+    override suspend fun onStart() {
         //initializing the clock
         timeState = Clock.System.now()
         //starting regular updates
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
index 7a170ac..3a63003 100644
--- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
+++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
@@ -71,9 +71,9 @@ class VirtualCarController : Controller(), ContextAware {
         logger.info { "Shutting down..." }
         magixServer?.stop(1000, 5000)
         logger.info { "Magix server stopped" }
-        magixVirtualCar?.close()
+        magixVirtualCar?.stop()
         logger.info { "Magix virtual car server stopped" }
-        virtualCar?.close()
+        virtualCar?.stop()
         logger.info { "Virtual car server stopped" }
         context.close()
     }
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index 54573e4..18e5838 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -138,7 +138,7 @@ class PiMotionMasterDevice(
         override fun build(context: Context, meta: Meta): PiMotionMasterDevice = PiMotionMasterDevice(context)
 
         val connected by booleanProperty(descriptorBuilder = {
-            info = "True if the connection address is defined and the device is initialized"
+            description = "True if the connection address is defined and the device is initialized"
         }) {
             port != null
         }
@@ -201,7 +201,7 @@ class PiMotionMasterDevice(
 
 
         val timeout by mutableProperty(MetaConverter.duration, PiMotionMasterDevice::timeoutValue) {
-            info = "Timeout"
+            description = "Timeout"
         }
     }
 
@@ -267,7 +267,7 @@ class PiMotionMasterDevice(
             )
 
             val enabled by axisBooleanProperty("EAX") {
-                info = "Motor enable state."
+                description = "Motor enable state."
             }
 
             val halt by unitAction {
@@ -275,20 +275,20 @@ class PiMotionMasterDevice(
             }
 
             val targetPosition by axisNumberProperty("MOV") {
-                info = """
+                description = """
                 Sets a new absolute target position for the specified axis.
                 Servo mode must be switched on for the commanded axis prior to using this command (closed-loop operation).
             """.trimIndent()
             }
 
             val onTarget by booleanProperty({
-                info = "Queries the on-target state of the specified axis."
+                description = "Queries the on-target state of the specified axis."
             }) {
                 readAxisBoolean("ONT?")
             }
 
             val reference by booleanProperty({
-                info = "Get Referencing Result"
+                description = "Get Referencing Result"
             }) {
                 readAxisBoolean("FRF?")
             }
@@ -298,36 +298,36 @@ class PiMotionMasterDevice(
             }
 
             val minPosition by doubleProperty({
-                info = "Minimal position value for the axis"
+                description = "Minimal position value for the axis"
             }) {
                 mm.requestAndParse("TMN?", axisId)[axisId]?.toDoubleOrNull()
                     ?: error("Malformed `TMN?` response. Should include float value for $axisId")
             }
 
             val maxPosition by doubleProperty({
-                info = "Maximal position value for the axis"
+                description = "Maximal position value for the axis"
             }) {
                 mm.requestAndParse("TMX?", axisId)[axisId]?.toDoubleOrNull()
                     ?: error("Malformed `TMX?` response. Should include float value for $axisId")
             }
 
             val position by doubleProperty({
-                info = "The current axis position."
+                description = "The current axis position."
             }) {
                 mm.requestAndParse("POS?", axisId)[axisId]?.toDoubleOrNull()
                     ?: error("Malformed `POS?` response. Should include float value for $axisId")
             }
 
             val openLoopTarget by axisNumberProperty("OMA") {
-                info = "Position for open-loop operation."
+                description = "Position for open-loop operation."
             }
 
             val closedLoop by axisBooleanProperty("SVO") {
-                info = "Servo closed loop mode"
+                description = "Servo closed loop mode"
             }
 
             val velocity by axisNumberProperty("VEL") {
-                info = "Velocity value for closed-loop operation"
+                description = "Velocity value for closed-loop operation"
             }
 
             val move by action(MetaConverter.meta, MetaConverter.unit) {
diff --git a/gradle.properties b/gradle.properties
index c7e0d88..62d03f6 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -10,4 +10,4 @@ publishing.sonatype=false
 org.gradle.configureondemand=true
 org.gradle.jvmargs=-Xmx4096m
 
-toolsVersion=0.15.0-kotlin-1.9.20-RC
\ No newline at end of file
+toolsVersion=0.15.0-kotlin-1.9.20-RC2
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 20ac44e..0f53909 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -50,6 +50,7 @@ include(
 //    ":controls-mongo",
     ":controls-storage",
     ":controls-storage:controls-xodus",
+    ":controls-constructor",
     ":magix",
     ":magix:magix-api",
     ":magix:magix-server",

From 4f028ccee803207ce1db009062ea11a9a6e4bb3e Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 27 Oct 2023 10:57:46 +0300
Subject: [PATCH 018/125] Lifecycle change

---
 .../controls/constructor/PidRegulator.kt      | 237 +++++++++++-------
 .../constructor/{Drive.kt => Regulator.kt}    |  14 +-
 .../space/kscience/controls/api/Device.kt     |  16 +-
 .../kscience/controls/spec/DeviceBase.kt      |  16 +-
 .../kscience/controls/client/DeviceClient.kt  |   2 +-
 5 files changed, 171 insertions(+), 114 deletions(-)
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/{Drive.kt => Regulator.kt} (65%)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
index 8dbe6ba..cd38c93 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
@@ -8,110 +8,56 @@ import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.datetime.Clock
 import kotlinx.datetime.Instant
-import space.kscience.controls.api.Device
+import space.kscience.controls.api.DeviceLifecycleState
 import space.kscience.controls.spec.DeviceBySpec
-import space.kscience.controls.spec.DeviceSpec
-import space.kscience.controls.spec.doubleProperty
-import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.context.Factory
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.double
-import space.kscience.dataforge.meta.get
-import space.kscience.dataforge.meta.transformations.MetaConverter
-import kotlin.math.pow
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.DurationUnit
 
-
-interface PidRegulator : Device {
-    /**
-     * Proportional coefficient
-     */
-    val kp: Double
-
-    /**
-     * Integral coefficient
-     */
-    val ki: Double
-
-    /**
-     * Differential coefficient
-     */
-    val kd: Double
-
-    /**
-     * The target value for PID
-     */
-    var target: Double
-
-    /**
-     * Read current value
-     */
-    suspend fun read(): Double
-
-    companion object : DeviceSpec<PidRegulator>() {
-        val target by property(MetaConverter.double, PidRegulator::target)
-        val value by doubleProperty { read() }
-    }
-}
-
 /**
- *
+ * PID controller on top of a [Regulator]
  */
-class VirtualPid(
-    context: Context,
-    override val kp: Double,
-    override val ki: Double,
-    override val kd: Double,
-    val mass: Double,
-    override var target: Double = 0.0,
+public class PidRegulator(
+    public val regulator: Regulator,
+    public val kp: Double,
+    public val ki: Double,
+    public val kd: Double,
     private val dt: Duration = 0.5.milliseconds,
     private val clock: Clock = Clock.System,
-) : DeviceBySpec<PidRegulator>(PidRegulator, context), PidRegulator {
-
-    private val mutex = Mutex()
+) : DeviceBySpec<Regulator>(Regulator, regulator.context), Regulator {
 
+    override var target: Double = regulator.target
 
     private var lastTime: Instant = clock.now()
-    private var lastValue: Double = target
+    private var lastRegulatorTarget: Double = target
 
-    private var value: Double = target
-    private var velocity: Double = 0.0
-    private var acceleration: Double = 0.0
     private var integral: Double = 0.0
 
 
     private var updateJob: Job? = null
+    private val mutex = Mutex()
+
 
     override suspend fun onStart() {
+        if(regulator.lifecycleState == DeviceLifecycleState.STOPPED){
+            regulator.start()
+        }
+        regulator.start()
         updateJob = launch {
             while (isActive) {
                 delay(dt)
                 mutex.withLock {
                     val realTime = clock.now()
-                    val delta = target - value
+                    val delta = target - position
                     val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
                     integral += delta * dtSeconds
-                    val derivative = (value - lastValue) / dtSeconds
+                    val derivative = (regulator.target - lastRegulatorTarget) / dtSeconds
 
                     //set last time and value to new values
                     lastTime = realTime
-                    lastValue = value
+                    lastRegulatorTarget = regulator.target
 
-                    // compute new value based on velocity and acceleration from the previous step
-                    value += velocity * dtSeconds + acceleration * dtSeconds.pow(2) / 2
-
-                    // compute new velocity based on acceleration on the previous step
-                    velocity += acceleration * dtSeconds
-
-                    //compute force for the next step based on current values
-                    acceleration = (kp * delta + ki * integral + kd * derivative) / mass
-
-
-                    check(value.isFinite() && velocity.isFinite()) {
-                        "Value $value is not finite"
-                    }
+                    regulator.target = regulator.position + kp * delta + ki * integral + kd * derivative
                 }
             }
         }
@@ -119,28 +65,131 @@ class VirtualPid(
 
     override fun onStop() {
         updateJob?.cancel()
-        super<PidRegulator>.stop()
     }
 
-    override suspend fun read(): Double = value
+    override val position: Double get() = regulator.position
 
-    suspend fun readVelocity(): Double = velocity
+}
 
-    suspend fun readAcceleration(): Double = acceleration
 
-    suspend fun write(newTarget: Double) = mutex.withLock {
-        require(newTarget.isFinite()) { "Value $newTarget is not valid" }
-        target = newTarget
-    }
-
-    companion object : Factory<Device> {
-        override fun build(context: Context, meta: Meta) = VirtualPid(
-            context,
-            meta["kp"].double ?: error("Kp is not defined"),
-            meta["ki"].double ?: error("Ki is not defined"),
-            meta["kd"].double ?: error("Kd is not defined"),
-            meta["m"].double ?: error("Mass is not defined"),
-        )
-
-    }
-}
\ No newline at end of file
+//
+//interface PidRegulator : Device {
+//    /**
+//     * Proportional coefficient
+//     */
+//    val kp: Double
+//
+//    /**
+//     * Integral coefficient
+//     */
+//    val ki: Double
+//
+//    /**
+//     * Differential coefficient
+//     */
+//    val kd: Double
+//
+//    /**
+//     * The target value for PID
+//     */
+//    var target: Double
+//
+//    /**
+//     * Read current value
+//     */
+//    suspend fun read(): Double
+//
+//    companion object : DeviceSpec<PidRegulator>() {
+//        val target by property(MetaConverter.double, PidRegulator::target)
+//        val value by doubleProperty { read() }
+//    }
+//}
+//
+///**
+// *
+// */
+//class VirtualPid(
+//    context: Context,
+//    override val kp: Double,
+//    override val ki: Double,
+//    override val kd: Double,
+//    val mass: Double,
+//    override var target: Double = 0.0,
+//    private val dt: Duration = 0.5.milliseconds,
+//    private val clock: Clock = Clock.System,
+//) : DeviceBySpec<PidRegulator>(PidRegulator, context), PidRegulator {
+//
+//    private val mutex = Mutex()
+//
+//
+//    private var lastTime: Instant = clock.now()
+//    private var lastValue: Double = target
+//
+//    private var value: Double = target
+//    private var velocity: Double = 0.0
+//    private var acceleration: Double = 0.0
+//    private var integral: Double = 0.0
+//
+//
+//    private var updateJob: Job? = null
+//
+//    override suspend fun onStart() {
+//        updateJob = launch {
+//            while (isActive) {
+//                delay(dt)
+//                mutex.withLock {
+//                    val realTime = clock.now()
+//                    val delta = target - value
+//                    val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
+//                    integral += delta * dtSeconds
+//                    val derivative = (value - lastValue) / dtSeconds
+//
+//                    //set last time and value to new values
+//                    lastTime = realTime
+//                    lastValue = value
+//
+//                    // compute new value based on velocity and acceleration from the previous step
+//                    value += velocity * dtSeconds + acceleration * dtSeconds.pow(2) / 2
+//
+//                    // compute new velocity based on acceleration on the previous step
+//                    velocity += acceleration * dtSeconds
+//
+//                    //compute force for the next step based on current values
+//                    acceleration = (kp * delta + ki * integral + kd * derivative) / mass
+//
+//
+//                    check(value.isFinite() && velocity.isFinite()) {
+//                        "Value $value is not finite"
+//                    }
+//                }
+//            }
+//        }
+//    }
+//
+//    override fun onStop() {
+//        updateJob?.cancel()
+//        super<PidRegulator>.stop()
+//    }
+//
+//    override suspend fun read(): Double = value
+//
+//    suspend fun readVelocity(): Double = velocity
+//
+//    suspend fun readAcceleration(): Double = acceleration
+//
+//    suspend fun write(newTarget: Double) = mutex.withLock {
+//        require(newTarget.isFinite()) { "Value $newTarget is not valid" }
+//        target = newTarget
+//    }
+//
+//    companion object : Factory<Device> {
+//        override fun build(context: Context, meta: Meta) = VirtualPid(
+//            context,
+//            meta["kp"].double ?: error("Kp is not defined"),
+//            meta["ki"].double ?: error("Ki is not defined"),
+//            meta["kd"].double ?: error("Kd is not defined"),
+//            meta["m"].double ?: error("Mass is not defined"),
+//        )
+//
+//    }
+//}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
similarity index 65%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
index ede628a..2db263d 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
@@ -13,7 +13,7 @@ import space.kscience.dataforge.meta.transformations.MetaConverter
 /**
  * A single axis drive
  */
-public interface Drive : Device {
+public interface Regulator : Device {
     /**
      * Get or set target value
      */
@@ -24,21 +24,21 @@ public interface Drive : Device {
      */
     public val position: Double
 
-    public companion object : DeviceSpec<Drive>() {
-        public val target: DevicePropertySpec<Drive, Double> by property(MetaConverter.double, Drive::target)
+    public companion object : DeviceSpec<Regulator>() {
+        public val target: DevicePropertySpec<Regulator, Double> by property(MetaConverter.double, Regulator::target)
 
-        public val position: DevicePropertySpec<Drive, Double> by doubleProperty { position }
+        public val position: DevicePropertySpec<Regulator, Double> by doubleProperty { position }
     }
 }
 
 /**
- * Virtual [Drive] with speed limit
+ * Virtual [Regulator] with speed limit
  */
-public class VirtualDrive(
+public class VirtualRegulator(
     context: Context,
     value: Double,
     private val speed: Double,
-) : DeviceBySpec<Drive>(Drive, context), Drive {
+) : DeviceBySpec<Regulator>(Regulator, context), Regulator {
 
     private var moveJob: Job? = null
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index dad1d90..a0268c2 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -19,21 +19,22 @@ import space.kscience.dataforge.names.Name
 /**
  * A lifecycle state of a device
  */
-public enum class DeviceLifecycleState{
+public enum class DeviceLifecycleState {
+
     /**
      * Device is initializing
-      */
-    INIT,
+     */
+    STARTING,
 
     /**
      * The Device is initialized and running
      */
-    OPEN,
+    STARTED,
 
     /**
      * The Device is closed
      */
-    CLOSED
+    STOPPED
 }
 
 /**
@@ -136,5 +137,8 @@ public fun Device.getAllProperties(): Meta = Meta {
 /**
  * Subscribe on property changes for the whole device
  */
-public fun Device.onPropertyChange(scope: CoroutineScope = this, callback: suspend PropertyChangedMessage.() -> Unit): Job =
+public fun Device.onPropertyChange(
+    scope: CoroutineScope = this,
+    callback: suspend PropertyChangedMessage.() -> Unit,
+): Job =
     messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(scope)
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index b1544e0..3b6bce0 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -189,7 +189,7 @@ public abstract class DeviceBase<D : Device>(
     }
 
     @DFExperimental
-    override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.INIT
+    override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
         protected set
 
     protected open suspend fun onStart() {
@@ -198,10 +198,14 @@ public abstract class DeviceBase<D : Device>(
 
     @OptIn(DFExperimental::class)
     final override suspend fun start() {
-        super.start()
-        lifecycleState = DeviceLifecycleState.INIT
-        onStart()
-        lifecycleState = DeviceLifecycleState.OPEN
+        if(lifecycleState == DeviceLifecycleState.STOPPED) {
+            super.start()
+            lifecycleState = DeviceLifecycleState.STARTING
+            onStart()
+            lifecycleState = DeviceLifecycleState.STARTED
+        } else {
+            logger.debug { "Device $this is already started" }
+        }
     }
 
     protected open fun onStop() {
@@ -211,7 +215,7 @@ public abstract class DeviceBase<D : Device>(
     @OptIn(DFExperimental::class)
     final override fun stop() {
         onStop()
-        lifecycleState = DeviceLifecycleState.CLOSED
+        lifecycleState = DeviceLifecycleState.STOPPED
         super.stop()
     }
 
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index 64e8a9e..2fcecff 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
@@ -99,7 +99,7 @@ public class DeviceClient(
     }
 
     @DFExperimental
-    override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.OPEN
+    override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STARTED
 }
 
 /**

From 1fcdbdc9f4f174c8075b149f6b85c0e094c635de Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 28 Oct 2023 14:18:00 +0300
Subject: [PATCH 019/125] Update constructor

---
 CHANGELOG.md                                  |   2 +
 controls-constructor/build.gradle.kts         |   2 +-
 .../controls/constructor/DeviceState.kt       |  25 +++
 .../controls/constructor}/DeviceTree.kt       |   5 +-
 .../kscience/controls/constructor/Drive.kt    |  91 +++++++++++
 .../controls/constructor/LimitSwitch.kt       |   2 +-
 .../controls/constructor/PidRegulator.kt      | 153 ++----------------
 .../controls/constructor/Regulator.kt         |  26 +--
 .../space/kscience/controls/api/Device.kt     |  12 +-
 .../kscience/controls/api/DeviceMessage.kt    |  17 +-
 .../controls/manager/respondMessage.kt        |   3 +-
 .../kscience/controls/spec/DeviceBase.kt      |  13 +-
 controls-vision/build.gradle.kts              |  20 +++
 settings.gradle.kts                           |   1 +
 14 files changed, 196 insertions(+), 176 deletions(-)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
 rename {controls-core/src/commonMain/kotlin/space/kscience/controls/spec => controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor}/DeviceTree.kt (88%)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
 create mode 100644 controls-vision/build.gradle.kts

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7673a4..4f3b5a9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,8 @@
 ## Unreleased
 
 ### Added
+- Device lifecycle message
+- Low-code constructor
 
 ### Changed
 
diff --git a/controls-constructor/build.gradle.kts b/controls-constructor/build.gradle.kts
index 5815444..e62dd8e 100644
--- a/controls-constructor/build.gradle.kts
+++ b/controls-constructor/build.gradle.kts
@@ -4,7 +4,7 @@ plugins {
 }
 
 description = """
-    A low-code constructor foe composite devices simulation
+    A low-code constructor for composite devices simulation
 """.trimIndent()
 
 kscience{
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
new file mode 100644
index 0000000..2e6461f
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -0,0 +1,25 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.flow.Flow
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.transformations.MetaConverter
+
+/**
+ * An observable state of a device
+ */
+public interface DeviceState<T> {
+    public val converter: MetaConverter<T>
+    public val value: T
+
+    public val valueFlow: Flow<T>
+
+    public val metaFlow: Flow<Meta>
+}
+
+
+/**
+ * A mutable state of a device
+ */
+public interface MutableDeviceState<T> : DeviceState<T>{
+    override var value: T
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceTree.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceTree.kt
similarity index 88%
rename from controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceTree.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceTree.kt
index f100af6..8d971bb 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceTree.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceTree.kt
@@ -1,4 +1,4 @@
-package space.kscience.controls.spec
+package space.kscience.controls.constructor
 
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.DeviceHub
@@ -7,11 +7,8 @@ import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.names.NameToken
-import kotlin.collections.Map
 import kotlin.collections.component1
 import kotlin.collections.component2
-import kotlin.collections.mapValues
-import kotlin.collections.mutableMapOf
 import kotlin.collections.set
 
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
new file mode 100644
index 0000000..6ac5a07
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
@@ -0,0 +1,91 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Clock
+import space.kscience.controls.api.Device
+import space.kscience.controls.spec.DeviceBySpec
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.DeviceSpec
+import space.kscience.controls.spec.doubleProperty
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.double
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import kotlin.math.pow
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.DurationUnit
+
+/**
+ * A classic drive regulated by force with encoder
+ */
+public interface Drive : Device {
+    /**
+     * Get or set drive force or momentum
+     */
+    public var force: Double
+
+    /**
+     * Current position value
+     */
+    public val position: Double
+
+    public companion object : DeviceSpec<Drive>() {
+        public val force: DevicePropertySpec<Drive, Double> by Drive.property(
+            MetaConverter.double,
+            Drive::force
+        )
+
+        public val position: DevicePropertySpec<Drive, Double> by doubleProperty { position }
+    }
+}
+
+/**
+ * A virtual drive
+ */
+public class VirtualDrive(
+    context: Context,
+    private val mass: Double,
+    position: Double,
+) : Drive, DeviceBySpec<Drive>(Drive, context) {
+
+    private val dt = meta["time.step"].double?.milliseconds ?: 5.milliseconds
+    private val clock = Clock.System
+
+    override var force: Double = 0.0
+
+    override var position: Double = position
+        private set
+
+    public var velocity: Double = 0.0
+        private set
+
+    private var updateJob: Job? = null
+
+    override suspend fun onStart() {
+        updateJob = launch {
+            var lastTime = clock.now()
+            while (isActive) {
+                delay(dt)
+                val realTime = clock.now()
+                val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
+
+                //set last time and value to new values
+                lastTime = realTime
+
+                // compute new value based on velocity and acceleration from the previous step
+                position += velocity * dtSeconds + force/mass * dtSeconds.pow(2) / 2
+
+                // compute new velocity based on acceleration on the previous step
+                velocity += force/mass * dtSeconds
+            }
+        }
+    }
+
+    override fun onStop() {
+        updateJob?.cancel()
+    }
+}
+
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
index 6e82064..dd0dfed 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
@@ -1,4 +1,4 @@
-package center.sciprog.controls.devices.misc
+package space.kscience.controls.constructor
 
 import space.kscience.controls.api.Device
 import space.kscience.controls.spec.DeviceBySpec
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
index cd38c93..4a4d0f3 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
@@ -1,4 +1,4 @@
-package center.sciprog.controls.devices.misc
+package space.kscience.controls.constructor
 
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
@@ -8,28 +8,27 @@ import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.datetime.Clock
 import kotlinx.datetime.Instant
-import space.kscience.controls.api.DeviceLifecycleState
 import space.kscience.controls.spec.DeviceBySpec
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.DurationUnit
 
 /**
- * PID controller on top of a [Regulator]
+ * A drive with PID regulator
  */
 public class PidRegulator(
-    public val regulator: Regulator,
+    public val drive: Drive,
     public val kp: Double,
     public val ki: Double,
     public val kd: Double,
-    private val dt: Duration = 0.5.milliseconds,
+    private val dt: Duration = 1.milliseconds,
     private val clock: Clock = Clock.System,
-) : DeviceBySpec<Regulator>(Regulator, regulator.context), Regulator {
+) : DeviceBySpec<Regulator>(Regulator, drive.context), Regulator {
 
-    override var target: Double = regulator.target
+    override var target: Double = drive.position
 
     private var lastTime: Instant = clock.now()
-    private var lastRegulatorTarget: Double = target
+    private var lastPosition: Double = target
 
     private var integral: Double = 0.0
 
@@ -39,10 +38,7 @@ public class PidRegulator(
 
 
     override suspend fun onStart() {
-        if(regulator.lifecycleState == DeviceLifecycleState.STOPPED){
-            regulator.start()
-        }
-        regulator.start()
+        drive.start()
         updateJob = launch {
             while (isActive) {
                 delay(dt)
@@ -51,13 +47,13 @@ public class PidRegulator(
                     val delta = target - position
                     val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
                     integral += delta * dtSeconds
-                    val derivative = (regulator.target - lastRegulatorTarget) / dtSeconds
+                    val derivative = (drive.position - lastPosition) / dtSeconds
 
                     //set last time and value to new values
                     lastTime = realTime
-                    lastRegulatorTarget = regulator.target
+                    lastPosition = drive.position
 
-                    regulator.target = regulator.position + kp * delta + ki * integral + kd * derivative
+                    drive.force = kp * delta + ki * integral + kd * derivative
                 }
             }
         }
@@ -67,129 +63,6 @@ public class PidRegulator(
         updateJob?.cancel()
     }
 
-    override val position: Double get() = regulator.position
+    override val position: Double get() = drive.position
 
-}
-
-
-//
-//interface PidRegulator : Device {
-//    /**
-//     * Proportional coefficient
-//     */
-//    val kp: Double
-//
-//    /**
-//     * Integral coefficient
-//     */
-//    val ki: Double
-//
-//    /**
-//     * Differential coefficient
-//     */
-//    val kd: Double
-//
-//    /**
-//     * The target value for PID
-//     */
-//    var target: Double
-//
-//    /**
-//     * Read current value
-//     */
-//    suspend fun read(): Double
-//
-//    companion object : DeviceSpec<PidRegulator>() {
-//        val target by property(MetaConverter.double, PidRegulator::target)
-//        val value by doubleProperty { read() }
-//    }
-//}
-//
-///**
-// *
-// */
-//class VirtualPid(
-//    context: Context,
-//    override val kp: Double,
-//    override val ki: Double,
-//    override val kd: Double,
-//    val mass: Double,
-//    override var target: Double = 0.0,
-//    private val dt: Duration = 0.5.milliseconds,
-//    private val clock: Clock = Clock.System,
-//) : DeviceBySpec<PidRegulator>(PidRegulator, context), PidRegulator {
-//
-//    private val mutex = Mutex()
-//
-//
-//    private var lastTime: Instant = clock.now()
-//    private var lastValue: Double = target
-//
-//    private var value: Double = target
-//    private var velocity: Double = 0.0
-//    private var acceleration: Double = 0.0
-//    private var integral: Double = 0.0
-//
-//
-//    private var updateJob: Job? = null
-//
-//    override suspend fun onStart() {
-//        updateJob = launch {
-//            while (isActive) {
-//                delay(dt)
-//                mutex.withLock {
-//                    val realTime = clock.now()
-//                    val delta = target - value
-//                    val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
-//                    integral += delta * dtSeconds
-//                    val derivative = (value - lastValue) / dtSeconds
-//
-//                    //set last time and value to new values
-//                    lastTime = realTime
-//                    lastValue = value
-//
-//                    // compute new value based on velocity and acceleration from the previous step
-//                    value += velocity * dtSeconds + acceleration * dtSeconds.pow(2) / 2
-//
-//                    // compute new velocity based on acceleration on the previous step
-//                    velocity += acceleration * dtSeconds
-//
-//                    //compute force for the next step based on current values
-//                    acceleration = (kp * delta + ki * integral + kd * derivative) / mass
-//
-//
-//                    check(value.isFinite() && velocity.isFinite()) {
-//                        "Value $value is not finite"
-//                    }
-//                }
-//            }
-//        }
-//    }
-//
-//    override fun onStop() {
-//        updateJob?.cancel()
-//        super<PidRegulator>.stop()
-//    }
-//
-//    override suspend fun read(): Double = value
-//
-//    suspend fun readVelocity(): Double = velocity
-//
-//    suspend fun readAcceleration(): Double = acceleration
-//
-//    suspend fun write(newTarget: Double) = mutex.withLock {
-//        require(newTarget.isFinite()) { "Value $newTarget is not valid" }
-//        target = newTarget
-//    }
-//
-//    companion object : Factory<Device> {
-//        override fun build(context: Context, meta: Meta) = VirtualPid(
-//            context,
-//            meta["kp"].double ?: error("Kp is not defined"),
-//            meta["ki"].double ?: error("Ki is not defined"),
-//            meta["kd"].double ?: error("Kd is not defined"),
-//            meta["m"].double ?: error("Mass is not defined"),
-//        )
-//
-//    }
-//}
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
index 2db263d..fa43b57 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
@@ -1,17 +1,14 @@
-package center.sciprog.controls.devices.misc
+package space.kscience.controls.constructor
 
-import kotlinx.coroutines.Job
 import space.kscience.controls.api.Device
-import space.kscience.controls.spec.DeviceBySpec
 import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.DeviceSpec
 import space.kscience.controls.spec.doubleProperty
-import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.transformations.MetaConverter
 
 
 /**
- * A single axis drive
+ * A regulator with target value and current position
  */
 public interface Regulator : Device {
     /**
@@ -29,23 +26,4 @@ public interface Regulator : Device {
 
         public val position: DevicePropertySpec<Regulator, Double> by doubleProperty { position }
     }
-}
-
-/**
- * Virtual [Regulator] with speed limit
- */
-public class VirtualRegulator(
-    context: Context,
-    value: Double,
-    private val speed: Double,
-) : DeviceBySpec<Regulator>(Regulator, context), Regulator {
-
-    private var moveJob: Job? = null
-
-    override var position: Double = value
-        private set
-
-    override var target: Double = value
-
-
 }
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index a0268c2..872a5e6 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.filterIsInstance
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
+import kotlinx.serialization.Serializable
 import space.kscience.controls.api.Device.Companion.DEVICE_TARGET
 import space.kscience.dataforge.context.ContextAware
 import space.kscience.dataforge.context.info
@@ -19,6 +20,7 @@ import space.kscience.dataforge.names.Name
 /**
  * A lifecycle state of a device
  */
+@Serializable
 public enum class DeviceLifecycleState {
 
     /**
@@ -34,7 +36,12 @@ public enum class DeviceLifecycleState {
     /**
      * The Device is closed
      */
-    STOPPED
+    STOPPED,
+
+    /**
+     * The device encountered irrecoverable error
+     */
+    ERROR
 }
 
 /**
@@ -98,7 +105,8 @@ public interface Device : ContextAware, CoroutineScope {
     public suspend fun execute(actionName: String, argument: Meta? = null): Meta?
 
     /**
-     * Initialize the device. This function suspends until the device is finished initialization
+     * Initialize the device. This function suspends until the device is finished initialization.
+     * Does nothing if the device is started or is starting
      */
     public suspend fun start(): Unit = Unit
 
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 c59e4c3..32df1bb 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
@@ -1,4 +1,4 @@
-@file:OptIn(ExperimentalSerializationApi::class)
+@file:OptIn(ExperimentalSerializationApi::class, ExperimentalSerializationApi::class)
 
 package space.kscience.controls.api
 
@@ -228,6 +228,21 @@ public data class DeviceErrorMessage(
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
 }
 
+/**
+ * Device [Device.lifecycleState] is changed
+ */
+@Serializable
+@SerialName("lifecycle")
+public data class DeviceLifeCycleMessage(
+    val state: DeviceLifecycleState,
+    override val sourceDevice: Name = Name.EMPTY,
+    override val targetDevice: Name? = null,
+    override val comment: String? = null,
+    @EncodeDefault override val time: Instant? = Clock.System.now(),
+) : DeviceMessage() {
+    override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
+}
+
 
 public fun DeviceMessage.toMeta(): Meta = Json.encodeToJsonElement(this).toMeta()
 
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 13d072c..d3abc03 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
@@ -64,6 +64,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
         is DeviceErrorMessage,
         is EmptyDeviceMessage,
         is DeviceLogMessage,
+        is DeviceLifeCycleMessage,
         -> null
     }
 } catch (ex: Exception) {
@@ -87,7 +88,7 @@ public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): DeviceMe
  * Collect all messages from given [DeviceHub], applying proper relative names.
  */
 public fun DeviceHub.hubMessageFlow(scope: CoroutineScope): Flow<DeviceMessage> {
-    
+
     //TODO could we avoid using downstream scope?
     val outbox = MutableSharedFlow<DeviceMessage>()
     if (this is Device) {
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index 3b6bce0..da730f8 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -190,7 +190,16 @@ public abstract class DeviceBase<D : Device>(
 
     @DFExperimental
     override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
-        protected set
+        protected set(value) {
+            if (field != value) {
+                launch {
+                    sharedMessageFlow.emit(
+                        DeviceLifeCycleMessage(value)
+                    )
+                }
+            }
+            field = value
+        }
 
     protected open suspend fun onStart() {
 
@@ -198,7 +207,7 @@ public abstract class DeviceBase<D : Device>(
 
     @OptIn(DFExperimental::class)
     final override suspend fun start() {
-        if(lifecycleState == DeviceLifecycleState.STOPPED) {
+        if (lifecycleState == DeviceLifecycleState.STOPPED) {
             super.start()
             lifecycleState = DeviceLifecycleState.STARTING
             onStart()
diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
new file mode 100644
index 0000000..0b3e9f1
--- /dev/null
+++ b/controls-vision/build.gradle.kts
@@ -0,0 +1,20 @@
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+description = """
+    Dashboard and visualization extensions for devices
+""".trimIndent()
+
+kscience{
+    jvm()
+    js()
+    dependencies {
+        api(projects.controlsCore)
+    }
+}
+
+readme{
+    maturity = space.kscience.gradle.Maturity.PROTOTYPE
+}
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 0f53909..ee40d35 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -51,6 +51,7 @@ include(
     ":controls-storage",
     ":controls-storage:controls-xodus",
     ":controls-constructor",
+    ":controls-vision",
     ":magix",
     ":magix:magix-api",
     ":magix:magix-server",

From 1414cf5a2f074a941999a2f5989bc0551a413f32 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 30 Oct 2023 21:35:46 +0300
Subject: [PATCH 020/125] implement constructor

---
 CHANGELOG.md                                  |   1 +
 .../controls/constructor/DeviceGroup.kt       | 258 ++++++++++++++++++
 .../controls/constructor/DeviceState.kt       | 108 +++++++-
 .../controls/constructor/DeviceTree.kt        |  33 ---
 .../kscience/controls/constructor/Drive.kt    |  27 +-
 .../controls/constructor/LimitSwitch.kt       |  10 +-
 .../controls/constructor/PidRegulator.kt      |  31 ++-
 .../controls/constructor/Regulator.kt         |   3 +-
 .../controls/constructor/customState.kt       |  41 +++
 .../space/kscience/controls/api/Device.kt     |  52 ++--
 .../kscience/controls/api/DeviceMessage.kt    |   2 +-
 .../kscience/controls/manager/ClockManager.kt |  25 ++
 .../controls/manager/respondMessage.kt        |  10 +-
 .../kscience/controls/spec/DeviceBase.kt      |  10 +-
 .../controls/spec/DevicePropertySpec.kt       |  18 +-
 .../kscience/controls/spec/DeviceSpec.kt      |  14 +-
 .../controls/spec/propertySpecDelegates.kt    |  10 +-
 .../kscience/controls/client/DeviceClient.kt  |   2 +-
 .../kscience/controls/client/tangoMagix.kt    |   6 +-
 .../controls/modbus/DeviceProcessImage.kt     |  19 +-
 .../controls/opcua/server/DeviceNameSpace.kt  |  15 +-
 controls-vision/build.gradle.kts              |  13 +-
 .../controls/vision/plotExtensions.kt         |  56 ++++
 .../kscience/controls/demo/car/VirtualCar.kt  |   8 +-
 demo/constructor/build.gradle.kts             |  20 ++
 .../src/jsMain/kotlin/constructorJs.kt        |   6 +
 demo/constructor/src/jvmMain/kotlin/Main.kt   | 103 +++++++
 .../src/jvmMain/resources/logback.xml         |  11 +
 .../sciprog/devices/mks/MksPdr900Device.kt    |   2 +-
 .../pimotionmaster/fxDeviceProperties.kt      |   4 +-
 settings.gradle.kts                           |   3 +-
 31 files changed, 770 insertions(+), 151 deletions(-)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceTree.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
 create mode 100644 controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt
 create mode 100644 demo/constructor/build.gradle.kts
 create mode 100644 demo/constructor/src/jsMain/kotlin/constructorJs.kt
 create mode 100644 demo/constructor/src/jvmMain/kotlin/Main.kt
 create mode 100644 demo/constructor/src/jvmMain/resources/logback.xml

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4f3b5a9..4083a94 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@
 - Low-code constructor
 
 ### Changed
+- Property caching moved from core `Device` to the `CachingDevice`
 
 ### Deprecated
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
new file mode 100644
index 0000000..f7bf494
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -0,0 +1,258 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import space.kscience.controls.api.*
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.controls.manager.install
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.meta.Laminate
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MutableMeta
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.misc.DFExperimental
+import space.kscience.dataforge.names.*
+import kotlin.collections.set
+import kotlin.coroutines.CoroutineContext
+
+
+/**
+ * A mutable group of devices and properties to be used for lightweight design and simulations.
+ */
+public class DeviceGroup(
+    public val deviceManager: DeviceManager,
+    override val meta: Meta,
+) : DeviceHub, CachingDevice {
+
+    internal class Property(
+        val state: DeviceState<out Any>,
+        val descriptor: PropertyDescriptor,
+    )
+
+    internal class Action(
+        val invoke: suspend (Meta?) -> Meta?,
+        val descriptor: ActionDescriptor,
+    )
+
+
+    override val context: Context get() = deviceManager.context
+
+    override val coroutineContext: CoroutineContext by lazy {
+        context.newCoroutineContext(
+            SupervisorJob(context.coroutineContext[Job]) +
+                    CoroutineName("Device $this") +
+                    CoroutineExceptionHandler { _, throwable ->
+                        launch {
+                            sharedMessageFlow.emit(
+                                DeviceErrorMessage(
+                                    errorMessage = throwable.message,
+                                    errorType = throwable::class.simpleName,
+                                    errorStackTrace = throwable.stackTraceToString()
+                                )
+                            )
+                        }
+                    }
+        )
+    }
+
+    private val _devices = hashMapOf<NameToken, Device>()
+
+    override val devices: Map<NameToken, Device> = _devices
+
+    public fun <D : Device> device(token: NameToken, device: D): D {
+        check(_devices[token] == null) { "A child device with name $token already exists" }
+        _devices[token] = device
+        return device
+    }
+
+    private val properties: MutableMap<Name, Property> = hashMapOf()
+
+    public fun property(descriptor: PropertyDescriptor, state: DeviceState<out Any>) {
+        val name = descriptor.name.parseAsName()
+        require(properties[name] == null) { "Can't add property with name $name. It already exists." }
+        properties[name] = Property(state, descriptor)
+    }
+
+    private val actions: MutableMap<Name, Action> = hashMapOf()
+
+    override val propertyDescriptors: Collection<PropertyDescriptor>
+        get() = properties.values.map { it.descriptor }
+
+    override val actionDescriptors: Collection<ActionDescriptor>
+        get() = actions.values.map { it.descriptor }
+
+    override suspend fun readProperty(propertyName: String): Meta =
+        properties[propertyName.parseAsName()]?.state?.valueAsMeta
+            ?: error("Property with name $propertyName not found")
+
+    override fun getProperty(propertyName: String): Meta? = properties[propertyName.parseAsName()]?.state?.valueAsMeta
+
+    override suspend fun invalidate(propertyName: String) {
+        //does nothing for this implementation
+    }
+
+    override suspend fun writeProperty(propertyName: String, value: Meta) {
+        val property = (properties[propertyName.parseAsName()]?.state as? MutableDeviceState)
+            ?: error("Property with name $propertyName not found")
+        property.valueAsMeta = value
+    }
+
+    private val sharedMessageFlow = MutableSharedFlow<DeviceMessage>()
+
+    override val messageFlow: Flow<DeviceMessage>
+        get() = sharedMessageFlow
+
+    override suspend fun execute(actionName: String, argument: Meta?): Meta? {
+        val action = actions[actionName] ?: error("Action with name $actionName not found")
+        return action.invoke(argument)
+    }
+
+    @DFExperimental
+    override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
+        private set(value) {
+            if (field != value) {
+                launch {
+                    sharedMessageFlow.emit(
+                        DeviceLifeCycleMessage(value)
+                    )
+                }
+            }
+            field = value
+        }
+
+
+    @OptIn(DFExperimental::class)
+    override suspend fun start() {
+        lifecycleState = DeviceLifecycleState.STARTING
+        super.start()
+        devices.values.forEach {
+            it.start()
+        }
+        lifecycleState = DeviceLifecycleState.STARTED
+    }
+
+    @OptIn(DFExperimental::class)
+    override fun stop() {
+        devices.values.forEach {
+            it.stop()
+        }
+        super.stop()
+        lifecycleState = DeviceLifecycleState.STOPPED
+    }
+
+    public companion object {
+
+    }
+}
+
+public fun DeviceManager.deviceGroup(
+    name: String = "@group",
+    meta: Meta = Meta.EMPTY,
+    block: DeviceGroup.() -> Unit,
+): DeviceGroup {
+    val group = DeviceGroup(this, meta).apply(block)
+    install(name, group)
+    return group
+}
+
+private fun DeviceGroup.getOrCreateGroup(name: Name): DeviceGroup {
+    return when (name.length) {
+        0 -> this
+        1 -> {
+            val token = name.first()
+            when (val d = devices[token]) {
+                null -> device(
+                    token,
+                    DeviceGroup(deviceManager, meta[token] ?: Meta.EMPTY)
+                )
+
+                else -> (d as? DeviceGroup) ?: error("Device $name is not a DeviceGroup")
+            }
+        }
+
+        else -> getOrCreateGroup(name.first().asName()).getOrCreateGroup(name.cutFirst())
+    }
+}
+
+/**
+ * Register a device at given [name] path
+ */
+public fun <D : Device> DeviceGroup.device(name: Name, device: D): D {
+    return when (name.length) {
+        0 -> error("Can't use empty name for a child device")
+        1 -> device(name.first(), device)
+        else -> getOrCreateGroup(name.cutLast()).device(name.tokens.last(), device)
+    }
+}
+
+public fun <D: Device> DeviceGroup.device(name: String, device: D): D = device(name.parseAsName(), device)
+
+/**
+ * Add a device creating intermediate groups if necessary. If device with given [name] already exists, throws an error.
+ */
+public fun DeviceGroup.device(name: Name, factory: Factory<Device>, deviceMeta: Meta? = null): Device {
+    val newDevice = factory.build(deviceManager.context, Laminate(deviceMeta, meta[name]))
+    device(name, newDevice)
+    return newDevice
+}
+
+public fun DeviceGroup.device(
+    name: String,
+    factory: Factory<Device>,
+    metaBuilder: (MutableMeta.() -> Unit)? = null,
+): Device = device(name.parseAsName(), factory, metaBuilder?.let { Meta(it) })
+
+/**
+ * Create or edit a group with a given [name].
+ */
+public fun DeviceGroup.deviceGroup(name: Name, block: DeviceGroup.() -> Unit): DeviceGroup =
+    getOrCreateGroup(name).apply(block)
+
+public fun DeviceGroup.deviceGroup(name: String, block: DeviceGroup.() -> Unit): DeviceGroup =
+    deviceGroup(name.parseAsName(), block)
+
+public fun <T : Any> DeviceGroup.property(
+    name: String,
+    state: DeviceState<T>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+): DeviceState<T> {
+    property(
+        PropertyDescriptor(name).apply(descriptorBuilder),
+        state
+    )
+    return state
+}
+
+public fun <T : Any> DeviceGroup.mutableProperty(
+    name: String,
+    state: MutableDeviceState<T>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+): MutableDeviceState<T> {
+    property(
+        PropertyDescriptor(name).apply(descriptorBuilder),
+        state
+    )
+    return state
+}
+
+public fun <T : Any> DeviceGroup.virtualProperty(
+    name: String,
+    initialValue: T,
+    converter: MetaConverter<T>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+): MutableDeviceState<T> {
+    val state = VirtualDeviceState<T>(converter, initialValue)
+    return mutableProperty(name, state, descriptorBuilder)
+}
+
+/**
+ * Create a virtual [MutableDeviceState], but do not register it to a device
+ */
+@Suppress("UnusedReceiverParameter")
+public fun <T : Any> DeviceGroup.standAloneProperty(
+    initialValue: T,
+    converter: MetaConverter<T>,
+): MutableDeviceState<T> = VirtualDeviceState<T>(converter, initialValue)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index 2e6461f..783752a 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -1,6 +1,12 @@
 package space.kscience.controls.constructor
 
-import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import space.kscience.controls.api.Device
+import space.kscience.controls.api.PropertyChangedMessage
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.MutableDevicePropertySpec
+import space.kscience.controls.spec.name
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.transformations.MetaConverter
 
@@ -12,14 +18,106 @@ public interface DeviceState<T> {
     public val value: T
 
     public val valueFlow: Flow<T>
-
-    public val metaFlow: Flow<Meta>
 }
 
+public val <T> DeviceState<T>.metaFlow: Flow<Meta> get() = valueFlow.map(converter::objectToMeta)
+
+public val <T> DeviceState<T>.valueAsMeta: Meta get() = converter.objectToMeta(value)
+
 
 /**
  * A mutable state of a device
  */
-public interface MutableDeviceState<T> : DeviceState<T>{
+public interface MutableDeviceState<T> : DeviceState<T> {
     override var value: T
-}
\ No newline at end of file
+}
+
+public var <T : Any> MutableDeviceState<T>.valueAsMeta: Meta
+    get() = converter.objectToMeta(value)
+    set(arg) {
+        value = converter.metaToObject(arg) ?: error("Conversion for meta $arg to property type with $converter failed")
+    }
+
+/**
+ * A [MutableDeviceState] that does not correspond to a physical state
+ */
+public class VirtualDeviceState<T>(
+    override val converter: MetaConverter<T>,
+    initialValue: T,
+) : MutableDeviceState<T> {
+    private val flow = MutableStateFlow(initialValue)
+    override val valueFlow: Flow<T> get() = flow
+
+    override var value: T by flow::value
+}
+
+private open class BoundDeviceState<T>(
+    override val converter: MetaConverter<T>,
+    val device: Device,
+    val propertyName: String,
+    private val initialValue: T,
+) : DeviceState<T> {
+
+    override val valueFlow: StateFlow<T> = device.messageFlow.filterIsInstance<PropertyChangedMessage>().filter {
+        it.property == propertyName
+    }.mapNotNull {
+        converter.metaToObject(it.value)
+    }.stateIn(device.context, SharingStarted.Eagerly, initialValue)
+
+    override val value: T get() = valueFlow.value
+}
+
+/**
+ * Bind a read-only [DeviceState] to a [Device] property
+ */
+public suspend fun <T> Device.bindStateToProperty(
+    propertyName: String,
+    metaConverter: MetaConverter<T>,
+): DeviceState<T> {
+    val initialValue = metaConverter.metaToObject(readProperty(propertyName)) ?: error("Conversion of property failed")
+    return BoundDeviceState(metaConverter, this, propertyName, initialValue)
+}
+
+public suspend fun <D : Device, T> D.bindStateToProperty(
+    propertySpec: DevicePropertySpec<D, T>,
+): DeviceState<T> = bindStateToProperty(propertySpec.name, propertySpec.converter)
+
+public fun <T, R> DeviceState<T>.map(
+    converter: MetaConverter<R>, mapper: (T) -> R,
+): DeviceState<R> = object : DeviceState<R> {
+    override val converter: MetaConverter<R> = converter
+    override val value: R
+        get() = mapper(this@map.value)
+
+    override val valueFlow: Flow<R> = this@map.valueFlow.map(mapper)
+}
+
+private class MutableBoundDeviceState<T>(
+    converter: MetaConverter<T>,
+    device: Device,
+    propertyName: String,
+    initialValue: T,
+) : BoundDeviceState<T>(converter, device, propertyName, initialValue), MutableDeviceState<T> {
+
+    override var value: T
+        get() = valueFlow.value
+        set(newValue) {
+            device.launch {
+                device.writeProperty(propertyName, converter.objectToMeta(newValue))
+            }
+        }
+}
+
+public suspend fun <T> Device.bindMutableStateToProperty(
+    propertyName: String,
+    metaConverter: MetaConverter<T>,
+): MutableDeviceState<T> {
+    val initialValue = metaConverter.metaToObject(readProperty(propertyName)) ?: error("Conversion of property failed")
+    return MutableBoundDeviceState(metaConverter, this, propertyName, initialValue)
+}
+
+public suspend fun <D : Device, T> D.bindMutableStateToProperty(
+    propertySpec: MutableDevicePropertySpec<D, T>,
+): MutableDeviceState<T> = bindMutableStateToProperty(propertySpec.name, propertySpec.converter)
+
+
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceTree.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceTree.kt
deleted file mode 100644
index 8d971bb..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceTree.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package space.kscience.controls.constructor
-
-import space.kscience.controls.api.Device
-import space.kscience.controls.api.DeviceHub
-import space.kscience.controls.manager.DeviceManager
-import space.kscience.dataforge.context.Factory
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.get
-import space.kscience.dataforge.names.NameToken
-import kotlin.collections.component1
-import kotlin.collections.component2
-import kotlin.collections.set
-
-
-public class DeviceTree(
-    public val deviceManager: DeviceManager,
-    public val meta: Meta,
-    builder: Builder,
-) : DeviceHub {
-    public class Builder(public val manager: DeviceManager) {
-        internal val childrenFactories = mutableMapOf<NameToken, Factory<Device>>()
-
-        public fun <D : Device> device(name: String, factory: Factory<Device>) {
-            childrenFactories[NameToken.parse(name)] = factory
-        }
-    }
-
-    override val devices: Map<NameToken, Device> = builder.childrenFactories.mapValues { (token, factory) ->
-        val devicesMeta = meta["devices"]
-        factory.build(deviceManager.context, devicesMeta?.get(token) ?: Meta.EMPTY)
-    }
-
-}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
index 6ac5a07..8111ff6 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
@@ -4,12 +4,9 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
-import kotlinx.datetime.Clock
 import space.kscience.controls.api.Device
-import space.kscience.controls.spec.DeviceBySpec
-import space.kscience.controls.spec.DevicePropertySpec
-import space.kscience.controls.spec.DeviceSpec
-import space.kscience.controls.spec.doubleProperty
+import space.kscience.controls.manager.clock
+import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.double
 import space.kscience.dataforge.meta.get
@@ -33,7 +30,7 @@ public interface Drive : Device {
     public val position: Double
 
     public companion object : DeviceSpec<Drive>() {
-        public val force: DevicePropertySpec<Drive, Double> by Drive.property(
+        public val force: MutableDevicePropertySpec<Drive, Double> by Drive.mutableProperty(
             MetaConverter.double,
             Drive::force
         )
@@ -48,16 +45,15 @@ public interface Drive : Device {
 public class VirtualDrive(
     context: Context,
     private val mass: Double,
-    position: Double,
+    public val positionState: MutableDeviceState<Double>,
 ) : Drive, DeviceBySpec<Drive>(Drive, context) {
 
     private val dt = meta["time.step"].double?.milliseconds ?: 5.milliseconds
-    private val clock = Clock.System
+    private val clock = context.clock
 
     override var force: Double = 0.0
 
-    override var position: Double = position
-        private set
+    override val position: Double get() = positionState.value
 
     public var velocity: Double = 0.0
         private set
@@ -76,10 +72,10 @@ public class VirtualDrive(
                 lastTime = realTime
 
                 // compute new value based on velocity and acceleration from the previous step
-                position += velocity * dtSeconds + force/mass * dtSeconds.pow(2) / 2
+                positionState.value += velocity * dtSeconds + force / mass * dtSeconds.pow(2) / 2
 
                 // compute new velocity based on acceleration on the previous step
-                velocity += force/mass * dtSeconds
+                velocity += force / mass * dtSeconds
             }
         }
     }
@@ -89,3 +85,10 @@ public class VirtualDrive(
     }
 }
 
+public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = bindMutableStateToProperty(Drive.force)
+
+public fun DeviceGroup.virtualDrive(
+    name: String,
+    mass: Double,
+    positionState: MutableDeviceState<Double>,
+): VirtualDrive = device(name, VirtualDrive(context, mass, positionState))
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
index dd0dfed..f692e00 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
@@ -6,6 +6,7 @@ import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.DeviceSpec
 import space.kscience.controls.spec.booleanProperty
 import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.names.parseAsName
 
 
 /**
@@ -25,7 +26,10 @@ public interface LimitSwitch : Device {
  */
 public class VirtualLimitSwitch(
     context: Context,
-    private val lockedFunction: () -> Boolean,
+    public val lockedState: DeviceState<Boolean>,
 ) : DeviceBySpec<LimitSwitch>(LimitSwitch, context), LimitSwitch {
-    override val locked: Boolean get() = lockedFunction()
-}
\ No newline at end of file
+    override val locked: Boolean get() = lockedState.value
+}
+
+public fun DeviceGroup.virtualLimitSwitch(name: String, lockedState: DeviceState<Boolean>): VirtualLimitSwitch =
+    device(name.parseAsName(), VirtualLimitSwitch(context, lockedState))
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
index 4a4d0f3..e8e2ec3 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
@@ -6,25 +6,33 @@ import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
-import kotlinx.datetime.Clock
 import kotlinx.datetime.Instant
+import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.DeviceBySpec
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.DurationUnit
 
+/**
+ * Pid regulator parameters
+ */
+public data class PidParameters(
+    public val kp: Double,
+    public val ki: Double,
+    public val kd: Double,
+    public val timeStep: Duration = 1.milliseconds,
+)
+
 /**
  * A drive with PID regulator
  */
 public class PidRegulator(
     public val drive: Drive,
-    public val kp: Double,
-    public val ki: Double,
-    public val kd: Double,
-    private val dt: Duration = 1.milliseconds,
-    private val clock: Clock = Clock.System,
+    public val pidParameters: PidParameters,
 ) : DeviceBySpec<Regulator>(Regulator, drive.context), Regulator {
 
+    private val clock = drive.context.clock
+
     override var target: Double = drive.position
 
     private var lastTime: Instant = clock.now()
@@ -41,7 +49,7 @@ public class PidRegulator(
         drive.start()
         updateJob = launch {
             while (isActive) {
-                delay(dt)
+                delay(pidParameters.timeStep)
                 mutex.withLock {
                     val realTime = clock.now()
                     val delta = target - position
@@ -53,7 +61,7 @@ public class PidRegulator(
                     lastTime = realTime
                     lastPosition = drive.position
 
-                    drive.force = kp * delta + ki * integral + kd * derivative
+                    drive.force = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative
                 }
             }
         }
@@ -64,5 +72,10 @@ public class PidRegulator(
     }
 
     override val position: Double get() = drive.position
+}
 
-}
\ No newline at end of file
+public fun DeviceGroup.pid(
+    name: String,
+    drive: Drive,
+    pidParameters: PidParameters,
+): PidRegulator = device(name, PidRegulator(drive, pidParameters))
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
index fa43b57..71cb3b0 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
@@ -3,6 +3,7 @@ package space.kscience.controls.constructor
 import space.kscience.controls.api.Device
 import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.DeviceSpec
+import space.kscience.controls.spec.MutableDevicePropertySpec
 import space.kscience.controls.spec.doubleProperty
 import space.kscience.dataforge.meta.transformations.MetaConverter
 
@@ -22,7 +23,7 @@ public interface Regulator : Device {
     public val position: Double
 
     public companion object : DeviceSpec<Regulator>() {
-        public val target: DevicePropertySpec<Regulator, Double> by property(MetaConverter.double, Regulator::target)
+        public val target: MutableDevicePropertySpec<Regulator, Double> by mutableProperty(MetaConverter.double, Regulator::target)
 
         public val position: DevicePropertySpec<Regulator, Double> by doubleProperty { position }
     }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
new file mode 100644
index 0000000..e6a29a5
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
@@ -0,0 +1,41 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import space.kscience.dataforge.meta.transformations.MetaConverter
+
+
+/**
+ *  A state describing a [Double] value in the [range]
+ */
+public class DoubleRangeState(
+    initialValue: Double,
+    public val range: ClosedFloatingPointRange<Double>,
+) : MutableDeviceState<Double> {
+
+    init {
+        require(initialValue in range) { "Initial value should be in range" }
+    }
+
+    override val converter: MetaConverter<Double> = MetaConverter.double
+
+    private val _valueFlow = MutableStateFlow(initialValue)
+
+    override var value: Double
+        get() = _valueFlow.value
+        set(newValue) {
+            _valueFlow.value = newValue.coerceIn(range)
+        }
+
+    override val valueFlow: StateFlow<Double> get() = _valueFlow
+
+    /**
+     * A state showing that the range is on its lower boundary
+     */
+    public val atStartState: DeviceState<Boolean> = map(MetaConverter.boolean) { it == range.start }
+
+    /**
+     * A state showing that the range is on its higher boundary
+     */
+    public val atEndState: DeviceState<Boolean> = map(MetaConverter.boolean) { it == range.endInclusive }
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index 872a5e6..f967c89 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -3,10 +3,7 @@ package space.kscience.controls.api
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.cancel
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.filterIsInstance
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.*
 import kotlinx.serialization.Serializable
 import space.kscience.controls.api.Device.Companion.DEVICE_TARGET
 import space.kscience.dataforge.context.ContextAware
@@ -74,18 +71,6 @@ public interface Device : ContextAware, CoroutineScope {
      */
     public suspend fun readProperty(propertyName: String): Meta
 
-    /**
-     * Get the logical state of property or return null if it is invalid
-     */
-    public fun getProperty(propertyName: String): Meta?
-
-    /**
-     * Invalidate property (set logical state to invalid)
-     *
-     * This message is suspended to provide lock-free local property changes (they require coroutine context).
-     */
-    public suspend fun invalidate(propertyName: String)
-
     /**
      * Set property [value] for a property with name [propertyName].
      * In rare cases could suspend if the [Device] supports command queue, and it is full at the moment.
@@ -126,17 +111,38 @@ public interface Device : ContextAware, CoroutineScope {
     }
 }
 
+/**
+ * Device that caches properties values
+ */
+public interface CachingDevice : Device {
+
+    /**
+     * Immediately (without waiting) get the cached (logical) state of property or return null if it is invalid
+     */
+    public fun getProperty(propertyName: String): Meta?
+
+    /**
+     * Invalidate property (set logical state to invalid).
+     *
+     * This message is suspended to provide lock-free local property changes (they require coroutine context).
+     */
+    public suspend fun invalidate(propertyName: String)
+}
+
 /**
  * Get the logical state of property or suspend to read the physical value.
  */
-public suspend fun Device.getOrReadProperty(propertyName: String): Meta =
+public suspend fun Device.requestProperty(propertyName: String): Meta = if (this is CachingDevice) {
     getProperty(propertyName) ?: readProperty(propertyName)
+} else {
+    readProperty(propertyName)
+}
 
 /**
  * Get a snapshot of the device logical state
  *
  */
-public fun Device.getAllProperties(): Meta = Meta {
+public fun CachingDevice.getAllProperties(): Meta = Meta {
     for (descriptor in propertyDescriptors) {
         setMeta(Name.parse(descriptor.name), getProperty(descriptor.name))
     }
@@ -148,5 +154,11 @@ public fun Device.getAllProperties(): Meta = Meta {
 public fun Device.onPropertyChange(
     scope: CoroutineScope = this,
     callback: suspend PropertyChangedMessage.() -> Unit,
-): Job =
-    messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(scope)
+): Job = messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(scope)
+
+/**
+ * A [Flow] of property change messages for specific property.
+ */
+public fun Device.propertyMessageFlow(propertyName: String): Flow<PropertyChangedMessage> = messageFlow
+    .filterIsInstance<PropertyChangedMessage>()
+    .filter { it.property == propertyName }
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 32df1bb..b3436e9 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
@@ -71,7 +71,7 @@ public data class PropertyChangedMessage(
 @SerialName("property.set")
 public data class PropertySetMessage(
     public val property: String,
-    public val value: Meta?,
+    public val value: Meta,
     override val sourceDevice: Name? = null,
     override val targetDevice: Name,
     override val comment: String? = null,
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
new file mode 100644
index 0000000..ca69b91
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
@@ -0,0 +1,25 @@
+package space.kscience.controls.manager
+
+import kotlinx.datetime.Clock
+import space.kscience.dataforge.context.AbstractPlugin
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.PluginFactory
+import space.kscience.dataforge.context.PluginTag
+import space.kscience.dataforge.meta.Meta
+
+public class ClockManager : AbstractPlugin() {
+    override val tag: PluginTag get() = DeviceManager.tag
+
+    public val clock: Clock by lazy {
+        //TODO add clock customization
+        Clock.System
+    }
+
+    public companion object : PluginFactory<ClockManager> {
+        override val tag: PluginTag = PluginTag("clock", group = PluginTag.DATAFORGE_GROUP)
+
+        override fun build(context: Context, meta: Meta): ClockManager = ClockManager()
+    }
+}
+
+public val Context.clock: Clock get() = plugins[ClockManager]?.clock ?: Clock.System
\ No newline at end of file
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 d3abc03..4d319af 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
@@ -17,21 +17,17 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
         is PropertyGetMessage -> {
             PropertyChangedMessage(
                 property = request.property,
-                value = getOrReadProperty(request.property),
+                value = requestProperty(request.property),
                 sourceDevice = deviceTarget,
                 targetDevice = request.sourceDevice
             )
         }
 
         is PropertySetMessage -> {
-            if (request.value == null) {
-                invalidate(request.property)
-            } else {
-                writeProperty(request.property, request.value)
-            }
+            writeProperty(request.property, request.value)
             PropertyChangedMessage(
                 property = request.property,
-                value = getOrReadProperty(request.property),
+                value = requestProperty(request.property),
                 sourceDevice = deviceTarget,
                 targetDevice = request.sourceDevice
             )
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index da730f8..43574cf 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -20,7 +20,7 @@ import kotlin.coroutines.CoroutineContext
  * Write a meta [item] to [device]
  */
 @OptIn(InternalDeviceAPI::class)
-private suspend fun <D : Device, T> WritableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) {
+private suspend fun <D : Device, T> MutableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) {
     write(device, converter.metaToObject(item) ?: error("Meta $item could not be read with $converter"))
 }
 
@@ -48,7 +48,7 @@ private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta
 public abstract class DeviceBase<D : Device>(
     final override val context: Context,
     final override val meta: Meta = Meta.EMPTY,
-) : Device {
+) : CachingDevice {
 
     /**
      * Collection of property specifications
@@ -166,7 +166,7 @@ public abstract class DeviceBase<D : Device>(
                 propertyChanged(propertyName, value)
             }
 
-            is WritableDevicePropertySpec -> {
+            is MutableDevicePropertySpec -> {
                 //if there is a writeable property with a given name, invalidate logical and write physical
                 invalidate(propertyName)
                 property.writeMeta(self, value)
@@ -189,8 +189,8 @@ public abstract class DeviceBase<D : Device>(
     }
 
     @DFExperimental
-    override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
-        protected set(value) {
+    final override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
+        private set(value) {
             if (field != value) {
                 launch {
                     sharedMessageFlow.emit(
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
index c42a23e..cb511ea 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
@@ -4,10 +4,7 @@ import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.launch
-import space.kscience.controls.api.ActionDescriptor
-import space.kscience.controls.api.Device
-import space.kscience.controls.api.PropertyChangedMessage
-import space.kscience.controls.api.PropertyDescriptor
+import space.kscience.controls.api.*
 import space.kscience.dataforge.meta.transformations.MetaConverter
 
 
@@ -44,7 +41,7 @@ public interface DevicePropertySpec<in D, T> {
 public val DevicePropertySpec<*, *>.name: String get() = descriptor.name
 
 
-public interface WritableDevicePropertySpec<in D : Device, T> : DevicePropertySpec<D, T> {
+public interface MutableDevicePropertySpec<in D : Device, T> : DevicePropertySpec<D, T> {
     /**
      * Write physical value to a device
      */
@@ -84,21 +81,20 @@ public suspend fun <T, D : Device> D.read(propertySpec: DevicePropertySpec<D, T>
 public suspend fun <T, D : DeviceBase<D>> D.readOrNull(propertySpec: DevicePropertySpec<D, T>): T? =
     readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::metaToObject)
 
-
-public operator fun <T, D : Device> D.get(propertySpec: DevicePropertySpec<D, T>): T? =
-    getProperty(propertySpec.name)?.let(propertySpec.converter::metaToObject)
+public suspend fun <T, D : Device> D.request(propertySpec: DevicePropertySpec<D, T>): T? =
+    propertySpec.converter.metaToObject(requestProperty(propertySpec.name))
 
 /**
  * Write typed property state and invalidate logical state
  */
-public suspend fun <T, D : Device> D.write(propertySpec: WritableDevicePropertySpec<D, T>, value: T) {
+public suspend fun <T, D : Device> D.write(propertySpec: MutableDevicePropertySpec<D, T>, value: T) {
     writeProperty(propertySpec.name, propertySpec.converter.objectToMeta(value))
 }
 
 /**
  * Fire and forget variant of property writing. Actual write is performed asynchronously on a [Device] scope
  */
-public operator fun <T, D : Device> D.set(propertySpec: WritableDevicePropertySpec<D, T>, value: T): Job = launch {
+public fun <T, D : Device> D.writeAsync(propertySpec: MutableDevicePropertySpec<D, T>, value: T): Job = launch {
     write(propertySpec, value)
 }
 
@@ -151,7 +147,7 @@ public fun <D : Device, T> D.useProperty(
 /**
  * Reset the logical state of a property
  */
-public suspend fun <D : Device> D.invalidate(propertySpec: DevicePropertySpec<D, *>) {
+public suspend fun <D : CachingDevice> D.invalidate(propertySpec: DevicePropertySpec<D, *>) {
     invalidate(propertySpec.name)
 }
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index 14966ca..55122d9 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -72,9 +72,9 @@ public abstract class DeviceSpec<D : Device> {
         converter: MetaConverter<T>,
         readWriteProperty: KMutableProperty1<D, T>,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, WritableDevicePropertySpec<D, T>>> =
+    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, MutableDevicePropertySpec<D, T>>> =
         PropertyDelegateProvider { _, property ->
-            val deviceProperty = object : WritableDevicePropertySpec<D, T> {
+            val deviceProperty = object : MutableDevicePropertySpec<D, T> {
 
                 override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply {
                     //TODO add the type from converter
@@ -123,10 +123,10 @@ public abstract class DeviceSpec<D : Device> {
         name: String? = null,
         read: suspend D.() -> T?,
         write: suspend D.(T) -> Unit,
-    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>>> =
+    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
         PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
             val propertyName = name ?: property.name
-            val deviceProperty = object : WritableDevicePropertySpec<D, T> {
+            val deviceProperty = object : MutableDevicePropertySpec<D, T> {
                 override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName, mutable = true)
                     .apply(descriptorBuilder)
                 override val converter: MetaConverter<T> = converter
@@ -138,7 +138,7 @@ public abstract class DeviceSpec<D : Device> {
                 }
             }
             _properties[propertyName] = deviceProperty
-            ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>> { _, _ ->
+            ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>> { _, _ ->
                 deviceProperty
             }
         }
@@ -218,9 +218,9 @@ public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
     converter: MetaConverter<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, WritableDevicePropertySpec<D, T>>> =
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, MutableDevicePropertySpec<D, T>>> =
     PropertyDelegateProvider { _, property ->
-        val deviceProperty = object : WritableDevicePropertySpec<D, T> {
+        val deviceProperty = object : MutableDevicePropertySpec<D, T> {
             val propertyName = name ?: property.name
             override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
                 //TODO add type from converter
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
index ff89662..70ef94a 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
@@ -97,7 +97,7 @@ public fun <D : Device> DeviceSpec<D>.booleanProperty(
     name: String? = null,
     read: suspend D.() -> Boolean?,
     write: suspend D.(Boolean) -> Unit
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Boolean>>> =
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Boolean>>> =
     mutableProperty(
         MetaConverter.boolean,
         {
@@ -117,7 +117,7 @@ public fun <D : Device> DeviceSpec<D>.numberProperty(
     name: String? = null,
     read: suspend D.() -> Number,
     write: suspend D.(Number) -> Unit
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Number>>> =
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Number>>> =
     mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write)
 
 public fun <D : Device> DeviceSpec<D>.doubleProperty(
@@ -125,7 +125,7 @@ public fun <D : Device> DeviceSpec<D>.doubleProperty(
     name: String? = null,
     read: suspend D.() -> Double,
     write: suspend D.(Double) -> Unit
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Double>>> =
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Double>>> =
     mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write)
 
 public fun <D : Device> DeviceSpec<D>.stringProperty(
@@ -133,7 +133,7 @@ public fun <D : Device> DeviceSpec<D>.stringProperty(
     name: String? = null,
     read: suspend D.() -> String,
     write: suspend D.(String) -> Unit
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, String>>> =
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, String>>> =
     mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write)
 
 public fun <D : Device> DeviceSpec<D>.metaProperty(
@@ -141,5 +141,5 @@ public fun <D : Device> DeviceSpec<D>.metaProperty(
     name: String? = null,
     read: suspend D.() -> Meta,
     write: suspend D.(Meta) -> Unit
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Meta>>> =
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Meta>>> =
     mutableProperty(MetaConverter.meta, descriptorBuilder, name, read, write)
\ No newline at end of file
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index 2fcecff..474f86a 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
@@ -26,7 +26,7 @@ public class DeviceClient(
     private val deviceName: Name,
     incomingFlow: Flow<DeviceMessage>,
     private val send: suspend (DeviceMessage) -> Unit,
-) : Device {
+) : CachingDevice {
 
     @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
     override val coroutineContext: CoroutineContext = newCoroutineContext(context.coroutineContext)
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt
index d0bdda4..9c30f8c 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt
@@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
 import kotlinx.serialization.Serializable
 import space.kscience.controls.api.get
-import space.kscience.controls.api.getOrReadProperty
+import space.kscience.controls.api.requestProperty
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.dataforge.context.error
 import space.kscience.dataforge.context.logger
@@ -91,7 +91,7 @@ public fun DeviceManager.launchTangoMagix(
                 val device = get(payload.device)
                 when (payload.action) {
                     TangoAction.read -> {
-                        val value = device.getOrReadProperty(payload.name)
+                        val value = device.requestProperty(payload.name)
                         respond(request, payload) { requestPayload ->
                             requestPayload.copy(
                                 value = value,
@@ -104,7 +104,7 @@ public fun DeviceManager.launchTangoMagix(
                             device.writeProperty(payload.name, value)
                         }
                         //wait for value to be written and return final state
-                        val value = device.getOrReadProperty(payload.name)
+                        val value = device.requestProperty(payload.name)
                         respond(request, payload) { requestPayload ->
                             requestPayload.copy(
                                 value = value,
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
index b4fae90..8f4a2b4 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
@@ -6,10 +6,7 @@ import kotlinx.coroutines.delay
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import space.kscience.controls.api.Device
-import space.kscience.controls.spec.DevicePropertySpec
-import space.kscience.controls.spec.WritableDevicePropertySpec
-import space.kscience.controls.spec.set
-import space.kscience.controls.spec.useProperty
+import space.kscience.controls.spec.*
 
 
 public class DeviceProcessImageBuilder<D : Device> internal constructor(
@@ -29,10 +26,10 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
 
     public fun bind(
         key: ModbusRegistryKey.Coil,
-        propertySpec: WritableDevicePropertySpec<D, Boolean>,
+        propertySpec: MutableDevicePropertySpec<D, Boolean>,
     ): ObservableDigitalOut = bind(key) { coil ->
         coil.addObserver { _, _ ->
-            device[propertySpec] = coil.isSet
+            device.writeAsync(propertySpec, coil.isSet)
         }
         device.useProperty(propertySpec) { value ->
             coil.set(value)
@@ -89,10 +86,10 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
 
     public fun bind(
         key: ModbusRegistryKey.HoldingRegister,
-        propertySpec: WritableDevicePropertySpec<D, Short>,
+        propertySpec: MutableDevicePropertySpec<D, Short>,
     ): ObservableRegister = bind(key) { register ->
         register.addObserver { _, _ ->
-            device[propertySpec] = register.toShort()
+            device.writeAsync(propertySpec, register.toShort())
         }
         device.useProperty(propertySpec) { value ->
             register.setValue(value)
@@ -121,7 +118,7 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
     /**
      * Trigger [block] if one of register changes.
      */
-    private fun List<ObservableRegister>.onChange(block: (ByteReadPacket) -> Unit) {
+    private fun List<ObservableRegister>.onChange(block: suspend (ByteReadPacket) -> Unit) {
         var ready = false
 
         forEach { register ->
@@ -147,7 +144,7 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
         }
     }
 
-    public fun <T> bind(key: ModbusRegistryKey.HoldingRange<T>, propertySpec: WritableDevicePropertySpec<D, T>) {
+    public fun <T> bind(key: ModbusRegistryKey.HoldingRange<T>, propertySpec: MutableDevicePropertySpec<D, T>) {
         val registers = List(key.count) {
             ObservableRegister()
         }
@@ -157,7 +154,7 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
         }
 
         registers.onChange { packet ->
-            device[propertySpec] = key.format.readObject(packet)
+            device.write(propertySpec, key.format.readObject(packet))
         }
 
         device.useProperty(propertySpec) { value ->
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
index a05a81d..05fc3c6 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
@@ -19,10 +19,7 @@ import org.eclipse.milo.opcua.stack.core.AttributeId
 import org.eclipse.milo.opcua.stack.core.Identifiers
 import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime
 import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText
-import space.kscience.controls.api.Device
-import space.kscience.controls.api.DeviceHub
-import space.kscience.controls.api.PropertyDescriptor
-import space.kscience.controls.api.onPropertyChange
+import space.kscience.controls.api.*
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.MetaSerializer
@@ -31,7 +28,7 @@ import space.kscience.dataforge.names.Name
 import space.kscience.dataforge.names.plus
 
 
-public operator fun Device.get(propertyDescriptor: PropertyDescriptor): Meta? = getProperty(propertyDescriptor.name)
+public operator fun CachingDevice.get(propertyDescriptor: PropertyDescriptor): Meta? = getProperty(propertyDescriptor.name)
 
 public suspend fun Device.read(propertyDescriptor: PropertyDescriptor): Meta = readProperty(propertyDescriptor.name)
 
@@ -106,9 +103,11 @@ public class DeviceNameSpace(
                 setTypeDefinition(Identifiers.BaseDataVariableType)
             }.build()
 
-
-            device[descriptor]?.toOpc(sourceTime = null, serverTime = null)?.let {
-                node.value = it
+            // Update initial value, but only if it is cached
+            if(device is CachingDevice) {
+                device[descriptor]?.toOpc(sourceTime = null, serverTime = null)?.let {
+                    node.value = it
+                }
             }
 
             /**
diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
index 0b3e9f1..e1401d9 100644
--- a/controls-vision/build.gradle.kts
+++ b/controls-vision/build.gradle.kts
@@ -7,14 +7,23 @@ description = """
     Dashboard and visualization extensions for devices
 """.trimIndent()
 
-kscience{
+val visionforgeVersion = "0.3.0-dev-10"
+
+kscience {
     jvm()
     js()
     dependencies {
         api(projects.controlsCore)
+        api(projects.controlsConstructor)
+        api("space.kscience:visionforge-plotly:$visionforgeVersion")
+        api("space.kscience:visionforge-markdown:$visionforgeVersion")
+    }
+
+    jvmMain{
+        api("space.kscience:visionforge-server:$visionforgeVersion")
     }
 }
 
-readme{
+readme {
     maturity = space.kscience.gradle.Maturity.PROTOTYPE
 }
diff --git a/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt
new file mode 100644
index 0000000..392930c
--- /dev/null
+++ b/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt
@@ -0,0 +1,56 @@
+package space.kscience.controls.vision
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.datetime.Clock
+import space.kscience.controls.api.Device
+import space.kscience.controls.api.propertyMessageFlow
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.manager.clock
+import space.kscience.dataforge.meta.ListValue
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.Null
+import space.kscience.dataforge.meta.Value
+import space.kscience.plotly.Plot
+import space.kscience.plotly.models.Scatter
+import space.kscience.plotly.models.TraceValues
+import space.kscience.plotly.scatter
+
+private var TraceValues.values: List<Value>
+    get() = value?.list ?: emptyList()
+    set(newValues) {
+        value = ListValue(newValues)
+    }
+
+/**
+ * Add a trace that shows a [Device] property change over time. Show only latest [pointsNumber] .
+ * @return a [Job] that handles the listener
+ */
+public fun Plot.plotDeviceProperty(
+    device: Device,
+    propertyName: String,
+    extractValue: Meta.() -> Value = { value ?: Null },
+    pointsNumber: Int = 400,
+    coroutineScope: CoroutineScope = device,
+    configuration: Scatter.() -> Unit = {},
+): Job = scatter(configuration).run {
+    val clock = device.context.clock
+    device.propertyMessageFlow(propertyName).onEach { message ->
+        x.strings = (x.strings + (message.time ?: clock.now()).toString()).takeLast(pointsNumber)
+        y.values = (y.values + message.value.extractValue()).takeLast(pointsNumber)
+    }.launchIn(coroutineScope)
+}
+
+public fun Plot.plotDeviceState(
+    scope: CoroutineScope,
+    state: DeviceState<out Number>,
+    pointsNumber: Int = 400,
+    configuration: Scatter.() -> Unit = {},
+): Job = scatter(configuration).run {
+    state.valueFlow.onEach {
+        x.strings = (x.strings + Clock.System.now().toString()).takeLast(pointsNumber)
+        y.numbers = (y.numbers + it).takeLast(pointsNumber)
+    }.launchIn(scope)
+}
\ No newline at end of file
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
index 2ba0fdb..8ccb8a4 100644
--- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
+++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
@@ -4,8 +4,8 @@ package space.kscience.controls.demo.car
 
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
-import kotlinx.datetime.Clock
 import kotlinx.datetime.Instant
+import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.DeviceBySpec
 import space.kscience.controls.spec.doRecurring
 import space.kscience.controls.spec.read
@@ -41,6 +41,8 @@ data class Vector2D(var x: Double = 0.0, var y: Double = 0.0) : MetaRepr {
 }
 
 open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec<VirtualCar>(IVirtualCar, context, meta), IVirtualCar {
+    private val clock = context.clock
+
     private val timeScale = 1e-3
 
     private val mass by meta.double(1000.0) // mass in kilograms
@@ -57,7 +59,7 @@ open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec<VirtualCar>(I
 
     private var timeState: Instant? = null
 
-    private fun update(newTime: Instant = Clock.System.now()) {
+    private fun update(newTime: Instant = clock.now()) {
         //initialize time if it is not initialized
         if (timeState == null) {
             timeState = newTime
@@ -102,7 +104,7 @@ open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec<VirtualCar>(I
     @OptIn(ExperimentalTime::class)
     override suspend fun onStart() {
         //initializing the clock
-        timeState = Clock.System.now()
+        timeState = clock.now()
         //starting regular updates
         doRecurring(100.milliseconds) {
             update()
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
new file mode 100644
index 0000000..031c3a0
--- /dev/null
+++ b/demo/constructor/build.gradle.kts
@@ -0,0 +1,20 @@
+plugins {
+    id("space.kscience.gradle.mpp")
+    application
+}
+
+kscience {
+    fullStack("js/constructor.js")
+    useKtor()
+    dependencies {
+        api(projects.controlsVision)
+    }
+    jvmMain {
+        implementation("io.ktor:ktor-server-cio")
+        implementation(spclibs.logback.classic)
+    }
+}
+
+application {
+    mainClass.set("space.kscience.controls.demo.constructor.MainKt")
+}
\ No newline at end of file
diff --git a/demo/constructor/src/jsMain/kotlin/constructorJs.kt b/demo/constructor/src/jsMain/kotlin/constructorJs.kt
new file mode 100644
index 0000000..27bbc28
--- /dev/null
+++ b/demo/constructor/src/jsMain/kotlin/constructorJs.kt
@@ -0,0 +1,6 @@
+import space.kscience.visionforge.plotly.PlotlyPlugin
+import space.kscience.visionforge.runVisionClient
+
+public fun main(): Unit = runVisionClient {
+    plugin(PlotlyPlugin)
+}
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/Main.kt b/demo/constructor/src/jvmMain/kotlin/Main.kt
new file mode 100644
index 0000000..fdba90c
--- /dev/null
+++ b/demo/constructor/src/jvmMain/kotlin/Main.kt
@@ -0,0 +1,103 @@
+package space.kscience.controls.demo.constructor
+
+import io.ktor.server.cio.CIO
+import io.ktor.server.engine.embeddedServer
+import io.ktor.server.http.content.staticResources
+import io.ktor.server.routing.routing
+import space.kscience.controls.api.get
+import space.kscience.controls.constructor.*
+import space.kscience.controls.manager.ClockManager
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.controls.manager.clock
+import space.kscience.controls.spec.doRecurring
+import space.kscience.controls.spec.name
+import space.kscience.controls.spec.write
+import space.kscience.controls.vision.plotDeviceProperty
+import space.kscience.controls.vision.plotDeviceState
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.request
+import space.kscience.visionforge.VisionManager
+import space.kscience.visionforge.html.VisionPage
+import space.kscience.visionforge.plotly.PlotlyPlugin
+import space.kscience.visionforge.plotly.plotly
+import space.kscience.visionforge.server.close
+import space.kscience.visionforge.server.openInBrowser
+import space.kscience.visionforge.server.visionPage
+import kotlin.math.PI
+import kotlin.math.sin
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.DurationUnit
+
+@Suppress("ExtractKtorModule")
+public fun main() {
+    val context = Context {
+        plugin(DeviceManager)
+        plugin(PlotlyPlugin)
+        plugin(ClockManager)
+    }
+
+    val deviceManager = context.request(DeviceManager)
+    val visionManager = context.request(VisionManager)
+
+    val state = DoubleRangeState(0.0, -100.0..100.0)
+
+    val pidParameters = PidParameters(
+        kp = 2.5,
+        ki = 0.0,
+        kd = -0.1,
+        timeStep = 0.005.seconds
+    )
+
+    val device = deviceManager.deviceGroup {
+        val drive = virtualDrive("drive", 0.005, state)
+        val pid = pid("pid", drive, pidParameters)
+        virtualLimitSwitch("start", state.atStartState)
+        virtualLimitSwitch("end", state.atEndState)
+
+        val clock = context.clock
+        val clockStart = clock.now()
+
+        doRecurring(10.milliseconds) {
+            val timeFromStart = clock.now() - clockStart
+            val t = timeFromStart.toDouble(DurationUnit.SECONDS)
+            val freq = 0.1
+            val target = 5 * sin(2.0 * PI * freq * t) +
+                    sin(2 * PI * 21 * freq * t + 0.1 * (timeFromStart / pidParameters.timeStep))
+            pid.write(Regulator.target, target)
+        }
+    }
+
+    val server = embeddedServer(CIO, port = 7777) {
+        routing {
+            staticResources("", null, null)
+        }
+
+        visionPage(
+            visionManager,
+            VisionPage.scriptHeader("js/constructor.js")
+        ) {
+            vision {
+                plotly {
+                    plotDeviceState(this@embeddedServer, state){
+                        name = "value"
+                    }
+                    plotDeviceProperty(device["pid"], Regulator.target.name){
+                        name = "target"
+                    }
+                }
+            }
+        }
+
+    }.start(false)
+
+    server.openInBrowser()
+
+
+    println("Enter 'exit' to close server")
+    while (readlnOrNull() != "exit") {
+        //
+    }
+
+    server.close()
+}
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/resources/logback.xml b/demo/constructor/src/jvmMain/resources/logback.xml
new file mode 100644
index 0000000..3865b14
--- /dev/null
+++ b/demo/constructor/src/jvmMain/resources/logback.xml
@@ -0,0 +1,11 @@
+<configuration>
+    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
+        </encoder>
+    </appender>
+
+    <root level="INFO">
+        <appender-ref ref="STDOUT"/>
+    </root>
+</configuration>
\ No newline at end of file
diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
index 7771709..df54bfa 100644
--- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
+++ b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
@@ -94,7 +94,7 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi
         val channel by logicalProperty(MetaConverter.int)
 
         val value by doubleProperty(read = {
-            readChannelData(get(channel) ?: DEFAULT_CHANNEL)
+            readChannelData(request(channel) ?: DEFAULT_CHANNEL)
         })
 
         val error by logicalProperty(MetaConverter.string)
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt
index 0328631..adef540 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt
@@ -32,7 +32,7 @@ fun <D : Device, T : Any> D.fxProperty(
     }
 }
 
-fun <D : Device, T : Any> D.fxProperty(spec: WritableDevicePropertySpec<D, T>): Property<T> =
+fun <D : Device, T : Any> D.fxProperty(spec: MutableDevicePropertySpec<D, T>): Property<T> =
     object : ObjectPropertyBase<T>() {
         override fun getBean(): Any = this
         override fun getName(): String = spec.name
@@ -51,7 +51,7 @@ fun <D : Device, T : Any> D.fxProperty(spec: WritableDevicePropertySpec<D, T>):
 
             onChange { newValue ->
                 if (newValue != null) {
-                    set(spec, newValue)
+                    writeAsync(spec, newValue)
                 }
             }
         }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index ee40d35..453519f 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -69,5 +69,6 @@ include(
     ":demo:car",
     ":demo:motors",
     ":demo:echo",
-    ":demo:mks-pdr900"
+    ":demo:mks-pdr900",
+    ":demo:constructor"
 )

From 984e7f12ef4ad6e0a4fe8e4a4cfb77ae8bcacadc Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 30 Oct 2023 21:47:41 +0300
Subject: [PATCH 021/125] Add JVM application for constructor demo

---
 .../space/kscience/controls/vision/plotExtensions.kt     | 9 +++++----
 demo/constructor/build.gradle.kts                        | 2 +-
 demo/constructor/src/jvmMain/kotlin/{Main.kt => main.kt} | 2 +-
 3 files changed, 7 insertions(+), 6 deletions(-)
 rename demo/constructor/src/jvmMain/kotlin/{Main.kt => main.kt} (97%)

diff --git a/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt
index 392930c..958a9ff 100644
--- a/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt
@@ -4,11 +4,11 @@ import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
-import kotlinx.datetime.Clock
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.propertyMessageFlow
 import space.kscience.controls.constructor.DeviceState
 import space.kscience.controls.manager.clock
+import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.ListValue
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.Null
@@ -44,13 +44,14 @@ public fun Plot.plotDeviceProperty(
 }
 
 public fun Plot.plotDeviceState(
-    scope: CoroutineScope,
+    context: Context,
     state: DeviceState<out Number>,
     pointsNumber: Int = 400,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
+    val clock = context.clock
     state.valueFlow.onEach {
-        x.strings = (x.strings + Clock.System.now().toString()).takeLast(pointsNumber)
+        x.strings = (x.strings + clock.now().toString()).takeLast(pointsNumber)
         y.numbers = (y.numbers + it).takeLast(pointsNumber)
-    }.launchIn(scope)
+    }.launchIn(context)
 }
\ No newline at end of file
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
index 031c3a0..897b9af 100644
--- a/demo/constructor/build.gradle.kts
+++ b/demo/constructor/build.gradle.kts
@@ -4,7 +4,7 @@ plugins {
 }
 
 kscience {
-    fullStack("js/constructor.js")
+    fullStack("js/constructor.js", jvmConfig = {withJava()})
     useKtor()
     dependencies {
         api(projects.controlsVision)
diff --git a/demo/constructor/src/jvmMain/kotlin/Main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
similarity index 97%
rename from demo/constructor/src/jvmMain/kotlin/Main.kt
rename to demo/constructor/src/jvmMain/kotlin/main.kt
index fdba90c..76d4b51 100644
--- a/demo/constructor/src/jvmMain/kotlin/Main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -79,7 +79,7 @@ public fun main() {
         ) {
             vision {
                 plotly {
-                    plotDeviceState(this@embeddedServer, state){
+                    plotDeviceState(context, state){
                         name = "value"
                     }
                     plotDeviceProperty(device["pid"], Regulator.target.name){

From 811477a6367dae6aa39755fd0be9df3c48c86164 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 30 Oct 2023 22:51:17 +0300
Subject: [PATCH 022/125] add limit readers

---
 .../kscience/controls/constructor/Drive.kt    |  1 +
 .../controls/constructor/LimitSwitch.kt       |  9 +++++
 .../controls/constructor/PidRegulator.kt      |  1 +
 .../controls/constructor/customState.kt       |  4 +--
 .../controls/vision/plotExtensions.kt         | 25 ++++++++++----
 demo/constructor/src/jvmMain/kotlin/main.kt   | 34 ++++++++++++++++---
 6 files changed, 61 insertions(+), 13 deletions(-)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
index 8111ff6..e7c3d39 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
@@ -73,6 +73,7 @@ public class VirtualDrive(
 
                 // compute new value based on velocity and acceleration from the previous step
                 positionState.value += velocity * dtSeconds + force / mass * dtSeconds.pow(2) / 2
+                propertyChanged(Drive.position, positionState.value)
 
                 // compute new velocity based on acceleration on the previous step
                 velocity += force / mass * dtSeconds
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
index f692e00..3db6512 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
@@ -1,5 +1,7 @@
 package space.kscience.controls.constructor
 
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
 import space.kscience.controls.api.Device
 import space.kscience.controls.spec.DeviceBySpec
 import space.kscience.controls.spec.DevicePropertySpec
@@ -28,6 +30,13 @@ public class VirtualLimitSwitch(
     context: Context,
     public val lockedState: DeviceState<Boolean>,
 ) : DeviceBySpec<LimitSwitch>(LimitSwitch, context), LimitSwitch {
+
+    init {
+        lockedState.valueFlow.onEach {
+            propertyChanged(LimitSwitch.locked, it)
+        }.launchIn(this)
+    }
+
     override val locked: Boolean get() = lockedState.value
 }
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
index e8e2ec3..9268d02 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
@@ -62,6 +62,7 @@ public class PidRegulator(
                     lastPosition = drive.position
 
                     drive.force = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative
+                    propertyChanged(Regulator.position, drive.position)
                 }
             }
         }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
index e6a29a5..e745875 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
@@ -32,10 +32,10 @@ public class DoubleRangeState(
     /**
      * A state showing that the range is on its lower boundary
      */
-    public val atStartState: DeviceState<Boolean> = map(MetaConverter.boolean) { it == range.start }
+    public val atStartState: DeviceState<Boolean> = map(MetaConverter.boolean) { it <= range.start }
 
     /**
      * A state showing that the range is on its higher boundary
      */
-    public val atEndState: DeviceState<Boolean> = map(MetaConverter.boolean) { it == range.endInclusive }
+    public val atEndState: DeviceState<Boolean> = map(MetaConverter.boolean) { it >= range.endInclusive }
 }
\ No newline at end of file
diff --git a/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt
index 958a9ff..14899d8 100644
--- a/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt
@@ -9,11 +9,10 @@ import space.kscience.controls.api.propertyMessageFlow
 import space.kscience.controls.constructor.DeviceState
 import space.kscience.controls.manager.clock
 import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.meta.ListValue
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.Null
-import space.kscience.dataforge.meta.Value
+import space.kscience.dataforge.meta.*
 import space.kscience.plotly.Plot
+import space.kscience.plotly.bar
+import space.kscience.plotly.models.Bar
 import space.kscience.plotly.models.Scatter
 import space.kscience.plotly.models.TraceValues
 import space.kscience.plotly.scatter
@@ -33,7 +32,7 @@ public fun Plot.plotDeviceProperty(
     propertyName: String,
     extractValue: Meta.() -> Value = { value ?: Null },
     pointsNumber: Int = 400,
-    coroutineScope: CoroutineScope = device,
+    coroutineScope: CoroutineScope = device.context,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
     val clock = device.context.clock
@@ -43,7 +42,8 @@ public fun Plot.plotDeviceProperty(
     }.launchIn(coroutineScope)
 }
 
-public fun Plot.plotDeviceState(
+
+public fun Plot.plotNumberState(
     context: Context,
     state: DeviceState<out Number>,
     pointsNumber: Int = 400,
@@ -54,4 +54,17 @@ public fun Plot.plotDeviceState(
         x.strings = (x.strings + clock.now().toString()).takeLast(pointsNumber)
         y.numbers = (y.numbers + it).takeLast(pointsNumber)
     }.launchIn(context)
+}
+
+public fun Plot.plotBooleanState(
+    context: Context,
+    state: DeviceState<Boolean>,
+    pointsNumber: Int = 400,
+    configuration: Bar.() -> Unit = {},
+): Job = bar(configuration).run {
+    val clock = context.clock
+    state.valueFlow.onEach {
+        x.strings = (x.strings + clock.now().toString()).takeLast(pointsNumber)
+        y.values = (y.values + it.asValue()).takeLast(pointsNumber)
+    }.launchIn(context)
 }
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 76d4b51..1df0f33 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -13,9 +13,10 @@ import space.kscience.controls.spec.doRecurring
 import space.kscience.controls.spec.name
 import space.kscience.controls.spec.write
 import space.kscience.controls.vision.plotDeviceProperty
-import space.kscience.controls.vision.plotDeviceState
+import space.kscience.controls.vision.plotNumberState
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.request
+import space.kscience.plotly.models.ScatterMode
 import space.kscience.visionforge.VisionManager
 import space.kscience.visionforge.html.VisionPage
 import space.kscience.visionforge.plotly.PlotlyPlugin
@@ -40,7 +41,7 @@ public fun main() {
     val deviceManager = context.request(DeviceManager)
     val visionManager = context.request(VisionManager)
 
-    val state = DoubleRangeState(0.0, -100.0..100.0)
+    val state = DoubleRangeState(0.0, -5.0..5.0)
 
     val pidParameters = PidParameters(
         kp = 2.5,
@@ -79,14 +80,37 @@ public fun main() {
         ) {
             vision {
                 plotly {
-                    plotDeviceState(context, state){
-                        name = "value"
+                    plotNumberState(context, state) {
+                        name = "real position"
                     }
-                    plotDeviceProperty(device["pid"], Regulator.target.name){
+                    plotDeviceProperty(device["pid"], Regulator.position.name) {
+                        name = "read position"
+                    }
+
+                    plotDeviceProperty(device["pid"], Regulator.target.name) {
                         name = "target"
                     }
                 }
             }
+
+            vision {
+                plotly {
+//                    plotBooleanState(context, state.atStartState) {
+//                        name = "start"
+//                    }
+//                    plotBooleanState(context, state.atEndState) {
+//                        name = "end"
+//                    }
+                    plotDeviceProperty(device["start"], LimitSwitch.locked.name) {
+                        name = "start measured"
+                        mode = ScatterMode.markers
+                    }
+                    plotDeviceProperty(device["end"], LimitSwitch.locked.name) {
+                        name = "end measured"
+                        mode = ScatterMode.markers
+                    }
+                }
+            }
         }
 
     }.start(false)

From 2698cee80b1de7f0bb2bbf8c48782c8061695b8c Mon Sep 17 00:00:00 2001
From: darksnake <altavir@gmail.com>
Date: Thu, 2 Nov 2023 15:36:10 +0300
Subject: [PATCH 023/125] Remove automatic reads from virtual drive and pid

---
 .../kotlin/space/kscience/controls/constructor/Drive.kt        | 3 +--
 .../kotlin/space/kscience/controls/constructor/PidRegulator.kt | 1 -
 demo/constructor/src/jvmMain/kotlin/main.kt                    | 2 ++
 3 files changed, 3 insertions(+), 3 deletions(-)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
index e7c3d39..f87d0e3 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
@@ -48,7 +48,7 @@ public class VirtualDrive(
     public val positionState: MutableDeviceState<Double>,
 ) : Drive, DeviceBySpec<Drive>(Drive, context) {
 
-    private val dt = meta["time.step"].double?.milliseconds ?: 5.milliseconds
+    private val dt = meta["time.step"].double?.milliseconds ?: 1.milliseconds
     private val clock = context.clock
 
     override var force: Double = 0.0
@@ -73,7 +73,6 @@ public class VirtualDrive(
 
                 // compute new value based on velocity and acceleration from the previous step
                 positionState.value += velocity * dtSeconds + force / mass * dtSeconds.pow(2) / 2
-                propertyChanged(Drive.position, positionState.value)
 
                 // compute new velocity based on acceleration on the previous step
                 velocity += force / mass * dtSeconds
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
index 9268d02..e8e2ec3 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
@@ -62,7 +62,6 @@ public class PidRegulator(
                     lastPosition = drive.position
 
                     drive.force = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative
-                    propertyChanged(Regulator.position, drive.position)
                 }
             }
         }
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 1df0f33..2996da2 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -11,6 +11,7 @@ import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.doRecurring
 import space.kscience.controls.spec.name
+import space.kscience.controls.spec.read
 import space.kscience.controls.spec.write
 import space.kscience.controls.vision.plotDeviceProperty
 import space.kscience.controls.vision.plotNumberState
@@ -66,6 +67,7 @@ public fun main() {
             val target = 5 * sin(2.0 * PI * freq * t) +
                     sin(2 * PI * 21 * freq * t + 0.1 * (timeFromStart / pidParameters.timeStep))
             pid.write(Regulator.target, target)
+            pid.read(Regulator.position)
         }
     }
 

From 0e963a7b136b487b1192469aea1bd4e4ab3b4f93 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 5 Nov 2023 09:47:58 +0300
Subject: [PATCH 024/125] Simplify UI management in constructor

---
 .../controls/constructor/DeviceGroup.kt       |  7 ++
 controls-vision/build.gradle.kts              |  3 +
 .../kscience/controls/vision/dashboard.kt     | 61 ++++++++++++++
 demo/constructor/build.gradle.kts             |  1 +
 demo/constructor/src/jvmMain/kotlin/main.kt   | 80 ++++++-------------
 5 files changed, 95 insertions(+), 57 deletions(-)
 create mode 100644 controls-vision/src/jvmMain/kotlin/space/kscience/controls/vision/dashboard.kt

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index f7bf494..b39d7b6 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -8,6 +8,7 @@ import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.context.request
 import space.kscience.dataforge.meta.Laminate
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.MutableMeta
@@ -158,6 +159,12 @@ public fun DeviceManager.deviceGroup(
     return group
 }
 
+public fun Context.deviceGroup(
+    name: String = "@group",
+    meta: Meta = Meta.EMPTY,
+    block: DeviceGroup.() -> Unit,
+): DeviceGroup  = request(DeviceManager).deviceGroup(name, meta, block)
+
 private fun DeviceGroup.getOrCreateGroup(name: Name): DeviceGroup {
     return when (name.length) {
         0 -> this
diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
index e1401d9..335bde2 100644
--- a/controls-vision/build.gradle.kts
+++ b/controls-vision/build.gradle.kts
@@ -12,6 +12,8 @@ val visionforgeVersion = "0.3.0-dev-10"
 kscience {
     jvm()
     js()
+    useKtor()
+    useContextReceivers()
     dependencies {
         api(projects.controlsCore)
         api(projects.controlsConstructor)
@@ -21,6 +23,7 @@ kscience {
 
     jvmMain{
         api("space.kscience:visionforge-server:$visionforgeVersion")
+        api("io.ktor:ktor-server-cio")
     }
 }
 
diff --git a/controls-vision/src/jvmMain/kotlin/space/kscience/controls/vision/dashboard.kt b/controls-vision/src/jvmMain/kotlin/space/kscience/controls/vision/dashboard.kt
new file mode 100644
index 0000000..2597e48
--- /dev/null
+++ b/controls-vision/src/jvmMain/kotlin/space/kscience/controls/vision/dashboard.kt
@@ -0,0 +1,61 @@
+package space.kscience.controls.vision
+
+import io.ktor.server.cio.CIO
+import io.ktor.server.engine.ApplicationEngine
+import io.ktor.server.engine.embeddedServer
+import io.ktor.server.http.content.staticResources
+import io.ktor.server.routing.Routing
+import io.ktor.server.routing.routing
+import kotlinx.html.TagConsumer
+import space.kscience.dataforge.context.Context
+import space.kscience.plotly.Plot
+import space.kscience.plotly.PlotlyConfig
+import space.kscience.visionforge.html.HtmlVisionFragment
+import space.kscience.visionforge.html.VisionPage
+import space.kscience.visionforge.html.VisionTagConsumer
+import space.kscience.visionforge.plotly.plotly
+import space.kscience.visionforge.server.VisionRoute
+import space.kscience.visionforge.server.close
+import space.kscience.visionforge.server.openInBrowser
+import space.kscience.visionforge.server.visionPage
+import space.kscience.visionforge.visionManager
+
+public fun Context.showDashboard(
+    port: Int = 7777,
+    routes: Routing.() -> Unit = {},
+    configurationBuilder: VisionRoute.() -> Unit = {},
+    visionFragment: HtmlVisionFragment,
+): ApplicationEngine = embeddedServer(CIO, port = port) {
+    routing {
+        staticResources("", null, null)
+        routes()
+    }
+
+    visionPage(
+        visionManager,
+        VisionPage.scriptHeader("js/constructor.js"),
+        configurationBuilder = configurationBuilder,
+        visionFragment = visionFragment
+    )
+}.also {
+    it.start(false)
+    it.openInBrowser()
+
+
+    println("Enter 'exit' to close server")
+    while (readlnOrNull() != "exit") {
+        //
+    }
+
+    it.close()
+}
+
+context(VisionTagConsumer<*>)
+public fun TagConsumer<*>.plot(
+    config: PlotlyConfig = PlotlyConfig(),
+    block: Plot.() -> Unit,
+) {
+    vision {
+        plotly(config, block)
+    }
+}
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
index 897b9af..daa4381 100644
--- a/demo/constructor/build.gradle.kts
+++ b/demo/constructor/build.gradle.kts
@@ -6,6 +6,7 @@ plugins {
 kscience {
     fullStack("js/constructor.js", jvmConfig = {withJava()})
     useKtor()
+    useContextReceivers()
     dependencies {
         api(projects.controlsVision)
     }
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 1df0f33..8fc1dac 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -1,9 +1,5 @@
 package space.kscience.controls.demo.constructor
 
-import io.ktor.server.cio.CIO
-import io.ktor.server.engine.embeddedServer
-import io.ktor.server.http.content.staticResources
-import io.ktor.server.routing.routing
 import space.kscience.controls.api.get
 import space.kscience.controls.constructor.*
 import space.kscience.controls.manager.ClockManager
@@ -12,18 +8,13 @@ import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.doRecurring
 import space.kscience.controls.spec.name
 import space.kscience.controls.spec.write
+import space.kscience.controls.vision.plot
 import space.kscience.controls.vision.plotDeviceProperty
 import space.kscience.controls.vision.plotNumberState
+import space.kscience.controls.vision.showDashboard
 import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.context.request
 import space.kscience.plotly.models.ScatterMode
-import space.kscience.visionforge.VisionManager
-import space.kscience.visionforge.html.VisionPage
 import space.kscience.visionforge.plotly.PlotlyPlugin
-import space.kscience.visionforge.plotly.plotly
-import space.kscience.visionforge.server.close
-import space.kscience.visionforge.server.openInBrowser
-import space.kscience.visionforge.server.visionPage
 import kotlin.math.PI
 import kotlin.math.sin
 import kotlin.time.Duration.Companion.milliseconds
@@ -38,9 +29,6 @@ public fun main() {
         plugin(ClockManager)
     }
 
-    val deviceManager = context.request(DeviceManager)
-    val visionManager = context.request(VisionManager)
-
     val state = DoubleRangeState(0.0, -5.0..5.0)
 
     val pidParameters = PidParameters(
@@ -50,7 +38,7 @@ public fun main() {
         timeStep = 0.005.seconds
     )
 
-    val device = deviceManager.deviceGroup {
+    val device = context.deviceGroup {
         val drive = virtualDrive("drive", 0.005, state)
         val pid = pid("pid", drive, pidParameters)
         virtualLimitSwitch("start", state.atStartState)
@@ -69,59 +57,37 @@ public fun main() {
         }
     }
 
-    val server = embeddedServer(CIO, port = 7777) {
-        routing {
-            staticResources("", null, null)
-        }
 
-        visionPage(
-            visionManager,
-            VisionPage.scriptHeader("js/constructor.js")
-        ) {
-            vision {
-                plotly {
-                    plotNumberState(context, state) {
-                        name = "real position"
-                    }
-                    plotDeviceProperty(device["pid"], Regulator.position.name) {
-                        name = "read position"
-                    }
-
-                    plotDeviceProperty(device["pid"], Regulator.target.name) {
-                        name = "target"
-                    }
-                }
+    context.showDashboard {
+        plot {
+            plotNumberState(context, state) {
+                name = "real position"
+            }
+            plotDeviceProperty(device["pid"], Regulator.position.name) {
+                name = "read position"
             }
 
-            vision {
-                plotly {
+            plotDeviceProperty(device["pid"], Regulator.target.name) {
+                name = "target"
+            }
+        }
+
+        plot {
 //                    plotBooleanState(context, state.atStartState) {
 //                        name = "start"
 //                    }
 //                    plotBooleanState(context, state.atEndState) {
 //                        name = "end"
 //                    }
-                    plotDeviceProperty(device["start"], LimitSwitch.locked.name) {
-                        name = "start measured"
-                        mode = ScatterMode.markers
-                    }
-                    plotDeviceProperty(device["end"], LimitSwitch.locked.name) {
-                        name = "end measured"
-                        mode = ScatterMode.markers
-                    }
-                }
+            plotDeviceProperty(device["start"], LimitSwitch.locked.name) {
+                name = "start measured"
+                mode = ScatterMode.markers
+            }
+            plotDeviceProperty(device["end"], LimitSwitch.locked.name) {
+                name = "end measured"
+                mode = ScatterMode.markers
             }
         }
 
-    }.start(false)
-
-    server.openInBrowser()
-
-
-    println("Enter 'exit' to close server")
-    while (readlnOrNull() != "exit") {
-        //
     }
-
-    server.close()
 }
\ No newline at end of file

From 78b18ebda63d0bba12183d96bed3a575c75062b0 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 5 Nov 2023 10:18:26 +0300
Subject: [PATCH 025/125] Move server to controls-vision

---
 controls-vision/build.gradle.kts                           | 4 ++--
 .../{space/kscience/controls/vision => }/plotExtensions.kt | 0
 .../src/jsMain/kotlin/client.kt                            | 5 +++++
 .../{space/kscience/controls/vision => }/dashboard.kt      | 2 +-
 demo/constructor/build.gradle.kts                          | 4 +++-
 demo/constructor/src/jvmMain/kotlin/main.kt                | 7 -------
 6 files changed, 11 insertions(+), 11 deletions(-)
 rename controls-vision/src/commonMain/kotlin/{space/kscience/controls/vision => }/plotExtensions.kt (100%)
 rename demo/constructor/src/jsMain/kotlin/constructorJs.kt => controls-vision/src/jsMain/kotlin/client.kt (53%)
 rename controls-vision/src/jvmMain/kotlin/{space/kscience/controls/vision => }/dashboard.kt (96%)

diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
index 335bde2..3659986 100644
--- a/controls-vision/build.gradle.kts
+++ b/controls-vision/build.gradle.kts
@@ -10,8 +10,7 @@ description = """
 val visionforgeVersion = "0.3.0-dev-10"
 
 kscience {
-    jvm()
-    js()
+    fullStack("js/controls-vision.js", development = true)
     useKtor()
     useContextReceivers()
     dependencies {
@@ -19,6 +18,7 @@ kscience {
         api(projects.controlsConstructor)
         api("space.kscience:visionforge-plotly:$visionforgeVersion")
         api("space.kscience:visionforge-markdown:$visionforgeVersion")
+        api("space.kscience:visionforge-tables:$visionforgeVersion")
     }
 
     jvmMain{
diff --git a/controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
similarity index 100%
rename from controls-vision/src/commonMain/kotlin/space/kscience/controls/vision/plotExtensions.kt
rename to controls-vision/src/commonMain/kotlin/plotExtensions.kt
diff --git a/demo/constructor/src/jsMain/kotlin/constructorJs.kt b/controls-vision/src/jsMain/kotlin/client.kt
similarity index 53%
rename from demo/constructor/src/jsMain/kotlin/constructorJs.kt
rename to controls-vision/src/jsMain/kotlin/client.kt
index 27bbc28..a835f9a 100644
--- a/demo/constructor/src/jsMain/kotlin/constructorJs.kt
+++ b/controls-vision/src/jsMain/kotlin/client.kt
@@ -1,6 +1,11 @@
+package space.kscience.controls.vision
+
+import space.kscience.visionforge.markup.MarkupPlugin
 import space.kscience.visionforge.plotly.PlotlyPlugin
 import space.kscience.visionforge.runVisionClient
 
 public fun main(): Unit = runVisionClient {
     plugin(PlotlyPlugin)
+    plugin(MarkupPlugin)
+//    plugin(TableVisionJsPlugin)
 }
\ No newline at end of file
diff --git a/controls-vision/src/jvmMain/kotlin/space/kscience/controls/vision/dashboard.kt b/controls-vision/src/jvmMain/kotlin/dashboard.kt
similarity index 96%
rename from controls-vision/src/jvmMain/kotlin/space/kscience/controls/vision/dashboard.kt
rename to controls-vision/src/jvmMain/kotlin/dashboard.kt
index 2597e48..29c4740 100644
--- a/controls-vision/src/jvmMain/kotlin/space/kscience/controls/vision/dashboard.kt
+++ b/controls-vision/src/jvmMain/kotlin/dashboard.kt
@@ -33,7 +33,7 @@ public fun Context.showDashboard(
 
     visionPage(
         visionManager,
-        VisionPage.scriptHeader("js/constructor.js"),
+        VisionPage.scriptHeader("js/controls-vision.js"),
         configurationBuilder = configurationBuilder,
         visionFragment = visionFragment
     )
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
index daa4381..dec09b7 100644
--- a/demo/constructor/build.gradle.kts
+++ b/demo/constructor/build.gradle.kts
@@ -4,7 +4,9 @@ plugins {
 }
 
 kscience {
-    fullStack("js/constructor.js", jvmConfig = {withJava()})
+    jvm{
+        withJava()
+    }
     useKtor()
     useContextReceivers()
     dependencies {
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 8fc1dac..7a88b7f 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -21,7 +21,6 @@ import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.Duration.Companion.seconds
 import kotlin.time.DurationUnit
 
-@Suppress("ExtractKtorModule")
 public fun main() {
     val context = Context {
         plugin(DeviceManager)
@@ -73,12 +72,6 @@ public fun main() {
         }
 
         plot {
-//                    plotBooleanState(context, state.atStartState) {
-//                        name = "start"
-//                    }
-//                    plotBooleanState(context, state.atEndState) {
-//                        name = "end"
-//                    }
             plotDeviceProperty(device["start"], LimitSwitch.locked.name) {
                 name = "start measured"
                 mode = ScatterMode.markers

From 0443fdc3c04a8ae02fea6c103f8b243460224219 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 6 Nov 2023 11:39:56 +0300
Subject: [PATCH 026/125] Add fixed age plots for properties and states.

---
 .../kscience/controls/misc/ValueWithTime.kt   |  69 ++++++++++
 .../space/kscience/controls/misc/timeIO.kt    |  40 ++++++
 .../space/kscience/controls/misc/timeMeta.kt  |  18 ---
 .../controls/opcua/client/MetaBsdParser.kt    |   2 +-
 .../src/commonMain/kotlin/plotExtensions.kt   | 123 +++++++++++++++---
 demo/constructor/src/jvmMain/kotlin/main.kt   |  13 +-
 6 files changed, 222 insertions(+), 43 deletions(-)
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt
 delete mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt
new file mode 100644
index 0000000..ce651e4
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt
@@ -0,0 +1,69 @@
+package space.kscience.controls.misc
+
+import io.ktor.utils.io.core.Input
+import io.ktor.utils.io.core.Output
+import kotlinx.datetime.Instant
+import space.kscience.dataforge.io.IOFormat
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+
+/**
+ * A value coupled to a time it was obtained at
+ */
+public data class ValueWithTime<T>(val value: T, val time: Instant) {
+    public companion object {
+        /**
+         * Create a [ValueWithTime] format for given value value [IOFormat]
+         */
+        public fun <T> ioFormat(
+            valueFormat: IOFormat<T>,
+        ): IOFormat<ValueWithTime<T>> = ValueWithTimeIOFormat(valueFormat)
+
+        /**
+         * Create a [MetaConverter] with time for given value [MetaConverter]
+         */
+        public fun <T> metaConverter(
+            valueConverter: MetaConverter<T>,
+        ): MetaConverter<ValueWithTime<T>> = ValueWithTimeMetaConverter(valueConverter)
+
+
+        public const val META_TIME_KEY: String = "time"
+        public const val META_VALUE_KEY: String = "value"
+    }
+}
+
+private class ValueWithTimeIOFormat<T>(val valueFormat: IOFormat<T>) : IOFormat<ValueWithTime<T>> {
+    override val type: KType get() = typeOf<ValueWithTime<T>>()
+
+    override fun readObject(input: Input): ValueWithTime<T> {
+        val timestamp = InstantIOFormat.readObject(input)
+        val value = valueFormat.readObject(input)
+        return ValueWithTime(value, timestamp)
+    }
+
+    override fun writeObject(output: Output, obj: ValueWithTime<T>) {
+        InstantIOFormat.writeObject(output, obj.time)
+        valueFormat.writeObject(output, obj.value)
+    }
+
+}
+
+private class ValueWithTimeMetaConverter<T>(
+    val valueConverter: MetaConverter<T>,
+) : MetaConverter<ValueWithTime<T>> {
+    override fun metaToObject(
+        meta: Meta,
+    ): ValueWithTime<T>? = valueConverter.metaToObject(meta[ValueWithTime.META_VALUE_KEY] ?: Meta.EMPTY)?.let {
+        ValueWithTime(it, meta[ValueWithTime.META_TIME_KEY]?.instant ?: Instant.DISTANT_PAST)
+    }
+
+    override fun objectToMeta(obj: ValueWithTime<T>): Meta = Meta {
+        ValueWithTime.META_TIME_KEY put obj.time.toMeta()
+        ValueWithTime.META_VALUE_KEY put valueConverter.objectToMeta(obj.value)
+    }
+}
+
+public fun <T : Any> MetaConverter<T>.withTime(): MetaConverter<ValueWithTime<T>> = ValueWithTimeMetaConverter(this)
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt
new file mode 100644
index 0000000..aef7401
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt
@@ -0,0 +1,40 @@
+package space.kscience.controls.misc
+
+import io.ktor.utils.io.core.*
+import kotlinx.datetime.Instant
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.io.IOFormat
+import space.kscience.dataforge.io.IOFormatFactory
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.string
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.asName
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
+
+
+/**
+ * An [IOFormat] for [Instant]
+ */
+public object InstantIOFormat : IOFormat<Instant>, IOFormatFactory<Instant> {
+    override fun build(context: Context, meta: Meta): IOFormat<Instant> = this
+
+    override val name: Name = "instant".asName()
+
+    override val type: KType get() = typeOf<Instant>()
+
+    override fun writeObject(output: Output, obj: Instant) {
+        output.writeLong(obj.epochSeconds)
+        output.writeInt(obj.nanosecondsOfSecond)
+    }
+
+    override fun readObject(input: Input): Instant {
+        val seconds = input.readLong()
+        val nanoseconds = input.readInt()
+        return Instant.fromEpochSeconds(seconds, nanoseconds)
+    }
+}
+
+public fun Instant.toMeta(): Meta = Meta(toString())
+
+public val Meta.instant: Instant? get() = value?.string?.let { Instant.parse(it) }
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt
deleted file mode 100644
index 11683d9..0000000
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt
+++ /dev/null
@@ -1,18 +0,0 @@
-package space.kscience.controls.misc
-
-import kotlinx.datetime.Instant
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.get
-import space.kscience.dataforge.meta.long
-
-// TODO move to core
-
-public fun Instant.toMeta(): Meta = Meta {
-    "seconds" put epochSeconds
-    "nanos" put nanosecondsOfSecond
-}
-
-public fun Meta.instant(): Instant = value?.long?.let { Instant.fromEpochMilliseconds(it) } ?: Instant.fromEpochSeconds(
-    get("seconds")?.long ?: 0L,
-    get("nanos")?.long ?: 0L,
-)
\ No newline at end of file
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
index 574343e..0442e31 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
@@ -147,7 +147,7 @@ internal class MetaStructureCodec(
             "Float" -> member.value?.numberOrNull?.toFloat()
             "Double" -> member.value?.numberOrNull?.toDouble()
             "String" -> member.string
-            "DateTime" -> DateTime(member.instant().toJavaInstant())
+            "DateTime" -> DateTime(member.instant.toJavaInstant())
             "Guid" -> member.string?.let { UUID.fromString(it) }
             "ByteString" -> member.value?.list?.let { list ->
                 ByteString(list.map { it.number.toByte() }.toByteArray())
diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index 14899d8..ce85483 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -4,18 +4,27 @@ import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.transform
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.propertyMessageFlow
 import space.kscience.controls.constructor.DeviceState
 import space.kscience.controls.manager.clock
+import space.kscience.controls.misc.ValueWithTime
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.*
 import space.kscience.plotly.Plot
 import space.kscience.plotly.bar
 import space.kscience.plotly.models.Bar
 import space.kscience.plotly.models.Scatter
+import space.kscience.plotly.models.Trace
 import space.kscience.plotly.models.TraceValues
 import space.kscience.plotly.scatter
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.hours
 
 private var TraceValues.values: List<Value>
     get() = value?.list ?: emptyList()
@@ -23,48 +32,126 @@ private var TraceValues.values: List<Value>
         value = ListValue(newValues)
     }
 
+
+private var TraceValues.times: List<Instant>
+    get() = value?.list?.map { Instant.parse(it.string) } ?: emptyList()
+    set(newValues) {
+        value = ListValue(newValues.map { it.toString().asValue() })
+    }
+
+
+private class TimeData(private var points: MutableList<ValueWithTime<Value>> = mutableListOf()) {
+    private val mutex = Mutex()
+
+    suspend fun append(time: Instant, value: Value) = mutex.withLock {
+        points.add(ValueWithTime(value, time))
+    }
+
+    suspend fun trim(maxAge: Duration, maxPoints: Int = 800, minPoints: Int = 400) {
+        require(maxPoints > 2)
+        require(minPoints > 0)
+        require(maxPoints > minPoints)
+        val now = Clock.System.now()
+        // filter old points
+        points.removeAll { now - it.time > maxAge }
+
+        if (points.size > maxPoints) {
+            val durationBetweenPoints = maxAge / minPoints
+            val markedForRemoval = buildList<ValueWithTime<Value>> {
+                var lastTime: Instant? = null
+                points.forEach { point ->
+                    if (lastTime?.let { point.time - it < durationBetweenPoints } == true) {
+                        add(point)
+                    } else {
+                        lastTime = point.time
+                    }
+                }
+            }
+            points.removeAll(markedForRemoval)
+        }
+    }
+
+    suspend fun fillPlot(x: TraceValues, y: TraceValues) = mutex.withLock {
+        x.strings = points.map { it.time.toString() }
+        y.values = points.map { it.value }
+    }
+}
+
 /**
- * Add a trace that shows a [Device] property change over time. Show only latest [pointsNumber] .
+ * Add a trace that shows a [Device] property change over time. Show only latest [maxPoints] .
  * @return a [Job] that handles the listener
  */
 public fun Plot.plotDeviceProperty(
     device: Device,
     propertyName: String,
     extractValue: Meta.() -> Value = { value ?: Null },
-    pointsNumber: Int = 400,
+    maxAge: Duration = 1.hours,
+    maxPoints: Int = 800,
+    minPoints: Int = 400,
     coroutineScope: CoroutineScope = device.context,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
     val clock = device.context.clock
-    device.propertyMessageFlow(propertyName).onEach { message ->
-        x.strings = (x.strings + (message.time ?: clock.now()).toString()).takeLast(pointsNumber)
-        y.values = (y.values + message.value.extractValue()).takeLast(pointsNumber)
+    val data = TimeData()
+    device.propertyMessageFlow(propertyName).transform {
+        data.append(it.time ?: clock.now(), it.value.extractValue())
+        data.trim(maxAge, maxPoints, minPoints)
+        emit(data)
+    }.onEach {
+        it.fillPlot(x, y)
     }.launchIn(coroutineScope)
 }
 
+private fun <T> Trace.updateFromState(
+    context: Context,
+    state: DeviceState<T>,
+    extractValue: T.() -> Value = { state.converter.objectToMeta(this).value ?: space.kscience.dataforge.meta.Null },
+    maxAge: Duration = 1.hours,
+    maxPoints: Int = 800,
+    minPoints: Int = 400,
+): Job{
+    val clock = context.clock
+    val data = TimeData()
+    return state.valueFlow.transform<T, TimeData> {
+        data.append(clock.now(), it.extractValue())
+        data.trim(maxAge, maxPoints, minPoints)
+    }.onEach {
+        it.fillPlot(x, y)
+    }.launchIn(context)
+}
+
+public fun <T> Plot.plotDeviceState(
+    context: Context,
+    state: DeviceState<T>,
+    extractValue: T.() -> Value = { state.converter.objectToMeta(this).value ?: Null },
+    maxAge: Duration = 1.hours,
+    maxPoints: Int = 800,
+    minPoints: Int = 400,
+    configuration: Scatter.() -> Unit = {},
+): Job = scatter(configuration).run {
+    updateFromState(context, state, extractValue, maxAge, maxPoints, minPoints)
+}
+
 
 public fun Plot.plotNumberState(
     context: Context,
     state: DeviceState<out Number>,
-    pointsNumber: Int = 400,
+    maxAge: Duration = 1.hours,
+    maxPoints: Int = 800,
+    minPoints: Int = 400,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
-    val clock = context.clock
-    state.valueFlow.onEach {
-        x.strings = (x.strings + clock.now().toString()).takeLast(pointsNumber)
-        y.numbers = (y.numbers + it).takeLast(pointsNumber)
-    }.launchIn(context)
+    updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints)
 }
 
+
 public fun Plot.plotBooleanState(
     context: Context,
     state: DeviceState<Boolean>,
-    pointsNumber: Int = 400,
+    maxAge: Duration = 1.hours,
+    maxPoints: Int = 800,
+    minPoints: Int = 400,
     configuration: Bar.() -> Unit = {},
-): Job = bar(configuration).run {
-    val clock = context.clock
-    state.valueFlow.onEach {
-        x.strings = (x.strings + clock.now().toString()).takeLast(pointsNumber)
-        y.values = (y.values + it.asValue()).takeLast(pointsNumber)
-    }.launchIn(context)
+): Job =  bar(configuration).run {
+    updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints)
 }
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 7a88b7f..079499d 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -51,32 +51,33 @@ public fun main() {
             val t = timeFromStart.toDouble(DurationUnit.SECONDS)
             val freq = 0.1
             val target = 5 * sin(2.0 * PI * freq * t) +
-                    sin(2 * PI * 21 * freq * t + 0.1 * (timeFromStart / pidParameters.timeStep))
+                    sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))
             pid.write(Regulator.target, target)
         }
     }
 
+    val maxAge = 10.seconds
 
     context.showDashboard {
         plot {
-            plotNumberState(context, state) {
+            plotNumberState(context, state, maxAge = maxAge) {
                 name = "real position"
             }
-            plotDeviceProperty(device["pid"], Regulator.position.name) {
+            plotDeviceProperty(device["pid"], Regulator.position.name, maxAge = maxAge) {
                 name = "read position"
             }
 
-            plotDeviceProperty(device["pid"], Regulator.target.name) {
+            plotDeviceProperty(device["pid"], Regulator.target.name, maxAge = maxAge) {
                 name = "target"
             }
         }
 
         plot {
-            plotDeviceProperty(device["start"], LimitSwitch.locked.name) {
+            plotDeviceProperty(device["start"], LimitSwitch.locked.name, maxAge = maxAge) {
                 name = "start measured"
                 mode = ScatterMode.markers
             }
-            plotDeviceProperty(device["end"], LimitSwitch.locked.name) {
+            plotDeviceProperty(device["end"], LimitSwitch.locked.name, maxAge = maxAge) {
                 name = "end measured"
                 mode = ScatterMode.markers
             }

From 825f1a4d049a0d6b4198a500137a58e41c95dc2c Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 6 Nov 2023 16:46:16 +0300
Subject: [PATCH 027/125] Add DeviceConstructor

---
 .../controls/constructor/DeviceConstructor.kt | 126 ++++++++++++++++++
 .../controls/constructor/DeviceGroup.kt       | 124 ++++++++++-------
 .../controls/constructor/DeviceState.kt       |  62 ++++++++-
 .../kscience/controls/constructor/Drive.kt    |  16 ++-
 .../controls/constructor/LimitSwitch.kt       |  10 +-
 .../controls/constructor/PidRegulator.kt      |   2 +-
 .../controls/constructor/customState.kt       |   8 +-
 .../controls/opcua/client/MetaBsdParser.kt    |   2 +-
 controls-vision/build.gradle.kts              |   4 +-
 demo/constructor/src/jvmMain/kotlin/main.kt   |   8 +-
 gradle/wrapper/gradle-wrapper.properties      |   2 +-
 11 files changed, 297 insertions(+), 67 deletions(-)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
new file mode 100644
index 0000000..54ebc4e
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -0,0 +1,126 @@
+package space.kscience.controls.constructor
+
+import space.kscience.controls.api.Device
+import space.kscience.controls.api.PropertyDescriptor
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.asName
+import kotlin.properties.PropertyDelegateProvider
+import kotlin.properties.ReadOnlyProperty
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
+import kotlin.time.Duration
+
+/**
+ * A base for strongly typed device constructor blocks. Has additional delegates for type-safe devices
+ */
+public abstract class DeviceConstructor(
+    deviceManager: DeviceManager,
+    meta: Meta,
+) : DeviceGroup(deviceManager, meta) {
+
+    /**
+     * Register a device, provided by a given [factory] and
+     */
+    public fun <D : Device> device(
+        factory: Factory<D>,
+        meta: Meta? = null,
+        nameOverride: Name? = null,
+        metaLocation: Name? = null,
+    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> =
+        PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> ->
+            val name = nameOverride ?: property.name.asName()
+            val device = registerDevice(name, factory, meta, metaLocation ?: name)
+            ReadOnlyProperty { _: DeviceConstructor, _ ->
+                device
+            }
+        }
+
+    public fun <D : Device> device(
+        device: D,
+        nameOverride: Name? = null,
+    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> =
+        PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> ->
+            val name = nameOverride ?: property.name.asName()
+            registerDevice(name, device)
+            ReadOnlyProperty { _: DeviceConstructor, _ ->
+                device
+            }
+        }
+
+
+    /**
+     * Register a property and provide a direct reader for it
+     */
+    public fun <T : Any> property(
+        state: DeviceState<T>,
+        nameOverride: String? = null,
+        descriptorBuilder: PropertyDescriptor.() -> Unit,
+    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> =
+        PropertyDelegateProvider { _: DeviceConstructor, property ->
+            val name = nameOverride ?: property.name
+            val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
+            registerProperty(descriptor, state)
+            ReadOnlyProperty { _: DeviceConstructor, _ ->
+                state.value
+            }
+        }
+
+    /**
+     * Register external state as a property
+     */
+    public fun <T : Any> property(
+        metaConverter: MetaConverter<T>,
+        reader: suspend () -> T,
+        readInterval: Duration,
+        initialState: T,
+        nameOverride: String? = null,
+        descriptorBuilder: PropertyDescriptor.() -> Unit,
+    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> = property(
+        DeviceState.external(this, metaConverter, readInterval, initialState, reader),
+        nameOverride, descriptorBuilder
+    )
+
+
+    /**
+     * Register a mutable property and provide a direct reader for it
+     */
+    public fun <T : Any> mutableProperty(
+        state: MutableDeviceState<T>,
+        nameOverride: String? = null,
+        descriptorBuilder: PropertyDescriptor.() -> Unit,
+    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> =
+        PropertyDelegateProvider { _: DeviceConstructor, property ->
+            val name = nameOverride ?: property.name
+            val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
+            registerProperty(descriptor, state)
+            object : ReadWriteProperty<DeviceConstructor, T> {
+                override fun getValue(thisRef: DeviceConstructor, property: KProperty<*>): T = state.value
+
+                override fun setValue(thisRef: DeviceConstructor, property: KProperty<*>, value: T) {
+                    state.value = value
+                }
+
+            }
+        }
+
+    /**
+     * Register external state as a property
+     */
+    public fun <T : Any> mutableProperty(
+        metaConverter: MetaConverter<T>,
+        reader: suspend () -> T,
+        writer: suspend (T) -> Unit,
+        readInterval: Duration,
+        initialState: T,
+        nameOverride: String? = null,
+        descriptorBuilder: PropertyDescriptor.() -> Unit,
+    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> = mutableProperty(
+        DeviceState.external(this, metaConverter, readInterval, initialState, reader, writer),
+        nameOverride,
+        descriptorBuilder
+    )
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index b39d7b6..936dd04 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -4,6 +4,7 @@ import kotlinx.coroutines.*
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import space.kscience.controls.api.*
+import space.kscience.controls.api.DeviceLifecycleState.*
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
 import space.kscience.dataforge.context.Context
@@ -23,7 +24,7 @@ import kotlin.coroutines.CoroutineContext
 /**
  * A mutable group of devices and properties to be used for lightweight design and simulations.
  */
-public class DeviceGroup(
+public open class DeviceGroup(
     public val deviceManager: DeviceManager,
     override val meta: Meta,
 ) : DeviceHub, CachingDevice {
@@ -63,15 +64,28 @@ public class DeviceGroup(
 
     override val devices: Map<NameToken, Device> = _devices
 
-    public fun <D : Device> device(token: NameToken, device: D): D {
-        check(_devices[token] == null) { "A child device with name $token already exists" }
+    /**
+     * Register and initialize (synchronize child's lifecycle state with group state) a new device in this group
+     */
+    @OptIn(DFExperimental::class)
+    public fun <D : Device> registerDevice(token: NameToken, device: D): D {
+        require(_devices[token] == null) { "A child device with name $token already exists" }
+        //start or stop the child if needed
+        when (lifecycleState) {
+            STARTING, STARTED -> launch { device.start() }
+            STOPPED -> device.stop()
+            ERROR -> {}
+        }
         _devices[token] = device
         return device
     }
 
     private val properties: MutableMap<Name, Property> = hashMapOf()
 
-    public fun property(descriptor: PropertyDescriptor, state: DeviceState<out Any>) {
+    /**
+     * Register a new property based on [DeviceState]. Properties could be modified dynamically
+     */
+    public fun registerProperty(descriptor: PropertyDescriptor, state: DeviceState<out Any>) {
         val name = descriptor.name.parseAsName()
         require(properties[name] == null) { "Can't add property with name $name. It already exists." }
         properties[name] = Property(state, descriptor)
@@ -112,8 +126,8 @@ public class DeviceGroup(
     }
 
     @DFExperimental
-    override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
-        private set(value) {
+    override var lifecycleState: DeviceLifecycleState = STOPPED
+        protected set(value) {
             if (field != value) {
                 launch {
                     sharedMessageFlow.emit(
@@ -127,12 +141,12 @@ public class DeviceGroup(
 
     @OptIn(DFExperimental::class)
     override suspend fun start() {
-        lifecycleState = DeviceLifecycleState.STARTING
+        lifecycleState = STARTING
         super.start()
         devices.values.forEach {
             it.start()
         }
-        lifecycleState = DeviceLifecycleState.STARTED
+        lifecycleState = STARTED
     }
 
     @OptIn(DFExperimental::class)
@@ -141,7 +155,7 @@ public class DeviceGroup(
             it.stop()
         }
         super.stop()
-        lifecycleState = DeviceLifecycleState.STOPPED
+        lifecycleState = STOPPED
     }
 
     public companion object {
@@ -149,7 +163,7 @@ public class DeviceGroup(
     }
 }
 
-public fun DeviceManager.deviceGroup(
+public fun DeviceManager.registerDeviceGroup(
     name: String = "@group",
     meta: Meta = Meta.EMPTY,
     block: DeviceGroup.() -> Unit,
@@ -159,11 +173,11 @@ public fun DeviceManager.deviceGroup(
     return group
 }
 
-public fun Context.deviceGroup(
+public fun Context.registerDeviceGroup(
     name: String = "@group",
     meta: Meta = Meta.EMPTY,
     block: DeviceGroup.() -> Unit,
-): DeviceGroup  = request(DeviceManager).deviceGroup(name, meta, block)
+): DeviceGroup = request(DeviceManager).registerDeviceGroup(name, meta, block)
 
 private fun DeviceGroup.getOrCreateGroup(name: Name): DeviceGroup {
     return when (name.length) {
@@ -171,7 +185,7 @@ private fun DeviceGroup.getOrCreateGroup(name: Name): DeviceGroup {
         1 -> {
             val token = name.first()
             when (val d = devices[token]) {
-                null -> device(
+                null -> registerDevice(
                     token,
                     DeviceGroup(deviceManager, meta[token] ?: Meta.EMPTY)
                 )
@@ -187,79 +201,99 @@ private fun DeviceGroup.getOrCreateGroup(name: Name): DeviceGroup {
 /**
  * Register a device at given [name] path
  */
-public fun <D : Device> DeviceGroup.device(name: Name, device: D): D {
+public fun <D : Device> DeviceGroup.registerDevice(name: Name, device: D): D {
     return when (name.length) {
         0 -> error("Can't use empty name for a child device")
-        1 -> device(name.first(), device)
-        else -> getOrCreateGroup(name.cutLast()).device(name.tokens.last(), device)
+        1 -> registerDevice(name.first(), device)
+        else -> getOrCreateGroup(name.cutLast()).registerDevice(name.tokens.last(), device)
     }
 }
 
-public fun <D: Device> DeviceGroup.device(name: String, device: D): D = device(name.parseAsName(), device)
+public fun <D : Device> DeviceGroup.registerDevice(name: String, device: D): D = registerDevice(name.parseAsName(), device)
 
 /**
  * Add a device creating intermediate groups if necessary. If device with given [name] already exists, throws an error.
+ * @param name the name of the device in the group
+ * @param factory a factory used to create a device
+ * @param deviceMeta meta override for this specific device
+ * @param metaLocation location of the template meta in parent group meta
  */
-public fun DeviceGroup.device(name: Name, factory: Factory<Device>, deviceMeta: Meta? = null): Device {
-    val newDevice = factory.build(deviceManager.context, Laminate(deviceMeta, meta[name]))
-    device(name, newDevice)
+public fun <D : Device> DeviceGroup.registerDevice(
+    name: Name,
+    factory: Factory<D>,
+    deviceMeta: Meta? = null,
+    metaLocation: Name = name,
+): D {
+    val newDevice = factory.build(deviceManager.context, Laminate(deviceMeta, meta[metaLocation]))
+    registerDevice(name, newDevice)
     return newDevice
 }
 
-public fun DeviceGroup.device(
+public fun <D : Device> DeviceGroup.registerDevice(
     name: String,
-    factory: Factory<Device>,
+    factory: Factory<D>,
+    metaLocation: Name = name.parseAsName(),
     metaBuilder: (MutableMeta.() -> Unit)? = null,
-): Device = device(name.parseAsName(), factory, metaBuilder?.let { Meta(it) })
+): D = registerDevice(name.parseAsName(), factory, metaBuilder?.let { Meta(it) }, metaLocation)
 
 /**
  * Create or edit a group with a given [name].
  */
-public fun DeviceGroup.deviceGroup(name: Name, block: DeviceGroup.() -> Unit): DeviceGroup =
+public fun DeviceGroup.registerDeviceGroup(name: Name, block: DeviceGroup.() -> Unit): DeviceGroup =
     getOrCreateGroup(name).apply(block)
 
-public fun DeviceGroup.deviceGroup(name: String, block: DeviceGroup.() -> Unit): DeviceGroup =
-    deviceGroup(name.parseAsName(), block)
+public fun DeviceGroup.registerDeviceGroup(name: String, block: DeviceGroup.() -> Unit): DeviceGroup =
+    registerDeviceGroup(name.parseAsName(), block)
 
-public fun <T : Any> DeviceGroup.property(
+/**
+ * Register read-only property based on [state]
+ */
+public fun <T : Any> DeviceGroup.registerProperty(
     name: String,
     state: DeviceState<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-): DeviceState<T> {
-    property(
+) {
+    registerProperty(
         PropertyDescriptor(name).apply(descriptorBuilder),
         state
     )
-    return state
 }
 
-public fun <T : Any> DeviceGroup.mutableProperty(
+/**
+ * Register a mutable property based on mutable [state]
+ */
+public fun <T : Any> DeviceGroup.registerMutableProperty(
     name: String,
     state: MutableDeviceState<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-): MutableDeviceState<T> {
-    property(
+) {
+    registerProperty(
         PropertyDescriptor(name).apply(descriptorBuilder),
         state
     )
-    return state
 }
 
-public fun <T : Any> DeviceGroup.virtualProperty(
-    name: String,
-    initialValue: T,
-    converter: MetaConverter<T>,
-    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-): MutableDeviceState<T> {
-    val state = VirtualDeviceState<T>(converter, initialValue)
-    return mutableProperty(name, state, descriptorBuilder)
-}
 
 /**
  * Create a virtual [MutableDeviceState], but do not register it to a device
  */
 @Suppress("UnusedReceiverParameter")
-public fun <T : Any> DeviceGroup.standAloneProperty(
+public fun <T : Any> DeviceGroup.state(
+    converter: MetaConverter<T>,
+    initialValue: T,
+): MutableDeviceState<T> = VirtualDeviceState<T>(converter, initialValue)
+
+/**
+ * Create a new virtual mutable state and a property based on it.
+ * @return the mutable state used in property
+ */
+public fun <T : Any> DeviceGroup.registerVirtualProperty(
+    name: String,
     initialValue: T,
     converter: MetaConverter<T>,
-): MutableDeviceState<T> = VirtualDeviceState<T>(converter, initialValue)
\ No newline at end of file
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+): MutableDeviceState<T> {
+    val state = state(converter, initialValue)
+    registerMutableProperty(name, state, descriptorBuilder)
+    return state
+}
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index 783752a..6e841f8 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -1,5 +1,7 @@
 package space.kscience.controls.constructor
 
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.launch
 import space.kscience.controls.api.Device
@@ -9,6 +11,7 @@ import space.kscience.controls.spec.MutableDevicePropertySpec
 import space.kscience.controls.spec.name
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.transformations.MetaConverter
+import kotlin.time.Duration
 
 /**
  * An observable state of a device
@@ -18,6 +21,8 @@ public interface DeviceState<T> {
     public val value: T
 
     public val valueFlow: Flow<T>
+
+    public companion object
 }
 
 public val <T> DeviceState<T>.metaFlow: Flow<Meta> get() = valueFlow.map(converter::objectToMeta)
@@ -55,7 +60,7 @@ private open class BoundDeviceState<T>(
     override val converter: MetaConverter<T>,
     val device: Device,
     val propertyName: String,
-    private val initialValue: T,
+    initialValue: T,
 ) : DeviceState<T> {
 
     override val valueFlow: StateFlow<T> = device.messageFlow.filterIsInstance<PropertyChangedMessage>().filter {
@@ -121,3 +126,58 @@ public suspend fun <D : Device, T> D.bindMutableStateToProperty(
 ): MutableDeviceState<T> = bindMutableStateToProperty(propertySpec.name, propertySpec.converter)
 
 
+private open class ExternalState<T>(
+    val scope: CoroutineScope,
+    override val converter: MetaConverter<T>,
+    val readInterval: Duration,
+    initialValue: T,
+    val reader: suspend () -> T,
+) : DeviceState<T> {
+
+    protected val flow: StateFlow<T> = flow {
+        while (true) {
+            delay(readInterval)
+            emit(reader())
+        }
+    }.stateIn(scope, SharingStarted.Eagerly, initialValue)
+
+    override val value: T get() = flow.value
+    override val valueFlow: Flow<T> get() = flow
+}
+
+/**
+ * Create a [DeviceState] which is constructed by periodically reading external value
+ */
+public fun <T> DeviceState.Companion.external(
+    scope: CoroutineScope,
+    converter: MetaConverter<T>,
+    readInterval: Duration,
+    initialValue: T,
+    reader: suspend () -> T,
+): DeviceState<T> = ExternalState(scope, converter, readInterval, initialValue, reader)
+
+private class MutableExternalState<T>(
+    scope: CoroutineScope,
+    converter: MetaConverter<T>,
+    readInterval: Duration,
+    initialValue: T,
+    reader: suspend () -> T,
+    val writer: suspend (T) -> Unit,
+) : ExternalState<T>(scope, converter, readInterval, initialValue, reader), MutableDeviceState<T> {
+    override var value: T
+        get() = super.value
+        set(value) {
+            scope.launch {
+                writer(value)
+            }
+        }
+}
+
+public fun <T> DeviceState.Companion.external(
+    scope: CoroutineScope,
+    converter: MetaConverter<T>,
+    readInterval: Duration,
+    initialValue: T,
+    reader: suspend () -> T,
+    writer: suspend (T) -> Unit,
+): MutableDeviceState<T> = MutableExternalState(scope, converter, readInterval, initialValue, reader, writer)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
index e7c3d39..d8802eb 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
@@ -8,6 +8,7 @@ import space.kscience.controls.api.Device
 import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.meta.double
 import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.meta.transformations.MetaConverter
@@ -84,12 +85,15 @@ public class VirtualDrive(
     override fun onStop() {
         updateJob?.cancel()
     }
+
+    public companion object {
+        public fun factory(
+            mass: Double,
+            positionState: MutableDeviceState<Double>,
+        ): Factory<Drive> = Factory { context, _ ->
+            VirtualDrive(context, mass, positionState)
+        }
+    }
 }
 
 public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = bindMutableStateToProperty(Drive.force)
-
-public fun DeviceGroup.virtualDrive(
-    name: String,
-    mass: Double,
-    positionState: MutableDeviceState<Double>,
-): VirtualDrive = device(name, VirtualDrive(context, mass, positionState))
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
index 3db6512..977930d 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
@@ -8,7 +8,7 @@ import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.DeviceSpec
 import space.kscience.controls.spec.booleanProperty
 import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.names.parseAsName
+import space.kscience.dataforge.context.Factory
 
 
 /**
@@ -20,6 +20,9 @@ public interface LimitSwitch : Device {
 
     public companion object : DeviceSpec<LimitSwitch>() {
         public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { locked }
+        public fun factory(lockedState: DeviceState<Boolean>): Factory<LimitSwitch> = Factory { context, _ ->
+            VirtualLimitSwitch(context, lockedState)
+        }
     }
 }
 
@@ -38,7 +41,4 @@ public class VirtualLimitSwitch(
     }
 
     override val locked: Boolean get() = lockedState.value
-}
-
-public fun DeviceGroup.virtualLimitSwitch(name: String, lockedState: DeviceState<Boolean>): VirtualLimitSwitch =
-    device(name.parseAsName(), VirtualLimitSwitch(context, lockedState))
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
index 9268d02..e6f9df1 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
@@ -79,4 +79,4 @@ public fun DeviceGroup.pid(
     name: String,
     drive: Drive,
     pidParameters: PidParameters,
-): PidRegulator = device(name, PidRegulator(drive, pidParameters))
\ No newline at end of file
+): PidRegulator = registerDevice(name, PidRegulator(drive, pidParameters))
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
index e745875..6e95ccd 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
@@ -38,4 +38,10 @@ public class DoubleRangeState(
      * A state showing that the range is on its higher boundary
      */
     public val atEndState: DeviceState<Boolean> = map(MetaConverter.boolean) { it >= range.endInclusive }
-}
\ No newline at end of file
+}
+
+@Suppress("UnusedReceiverParameter")
+public fun DeviceGroup.rangeState(
+    initialValue: Double,
+    range: ClosedFloatingPointRange<Double>,
+): DoubleRangeState = DoubleRangeState(initialValue, range)
\ No newline at end of file
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
index 0442e31..b760184 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
@@ -147,7 +147,7 @@ internal class MetaStructureCodec(
             "Float" -> member.value?.numberOrNull?.toFloat()
             "Double" -> member.value?.numberOrNull?.toDouble()
             "String" -> member.string
-            "DateTime" -> DateTime(member.instant.toJavaInstant())
+            "DateTime" -> member.instant?.toJavaInstant()?.let { DateTime(it) }
             "Guid" -> member.string?.let { UUID.fromString(it) }
             "ByteString" -> member.value?.list?.let { list ->
                 ByteString(list.map { it.number.toByte() }.toByteArray())
diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
index 3659986..a69329a 100644
--- a/controls-vision/build.gradle.kts
+++ b/controls-vision/build.gradle.kts
@@ -10,7 +10,7 @@ description = """
 val visionforgeVersion = "0.3.0-dev-10"
 
 kscience {
-    fullStack("js/controls-vision.js", development = true)
+    fullStack("js/controls-vision.js")
     useKtor()
     useContextReceivers()
     dependencies {
@@ -29,4 +29,4 @@ kscience {
 
 readme {
     maturity = space.kscience.gradle.Maturity.PROTOTYPE
-}
+}
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 079499d..59881e5 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -37,11 +37,11 @@ public fun main() {
         timeStep = 0.005.seconds
     )
 
-    val device = context.deviceGroup {
-        val drive = virtualDrive("drive", 0.005, state)
+    val device = context.registerDeviceGroup {
+        val drive = VirtualDrive(context,  0.005, state)
         val pid = pid("pid", drive, pidParameters)
-        virtualLimitSwitch("start", state.atStartState)
-        virtualLimitSwitch("end", state.atEndState)
+        registerDevice("start", LimitSwitch.factory(state.atStartState))
+        registerDevice("end", LimitSwitch.factory(state.atEndState))
 
         val clock = context.clock
         val clockStart = clock.now()
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index db9a6b8..e411586 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists

From 53fc240c759daa9791463af03a7422d907c4e19b Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 7 Nov 2023 08:46:56 +0300
Subject: [PATCH 028/125] Test device constructor

---
 .../controls/constructor/DeviceConstructor.kt | 16 ++--
 .../controls/constructor/DeviceGroup.kt       | 82 +++++++++++--------
 .../controls/constructor/DeviceState.kt       | 25 ++++--
 .../kscience/controls/constructor/Drive.kt    |  2 +-
 .../controls/constructor/PidRegulator.kt      |  2 +-
 .../kscience/controls/spec/DeviceBase.kt      | 28 +++----
 demo/constructor/build.gradle.kts             |  6 +-
 demo/constructor/src/jvmMain/kotlin/main.kt   | 46 +++++++----
 8 files changed, 123 insertions(+), 84 deletions(-)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 54ebc4e..01d9087 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -33,7 +33,7 @@ public abstract class DeviceConstructor(
     ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> =
         PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> ->
             val name = nameOverride ?: property.name.asName()
-            val device = registerDevice(name, factory, meta, metaLocation ?: name)
+            val device = install(name, factory, meta, metaLocation ?: name)
             ReadOnlyProperty { _: DeviceConstructor, _ ->
                 device
             }
@@ -45,7 +45,7 @@ public abstract class DeviceConstructor(
     ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> =
         PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> ->
             val name = nameOverride ?: property.name.asName()
-            registerDevice(name, device)
+            install(name, device)
             ReadOnlyProperty { _: DeviceConstructor, _ ->
                 device
             }
@@ -58,7 +58,7 @@ public abstract class DeviceConstructor(
     public fun <T : Any> property(
         state: DeviceState<T>,
         nameOverride: String? = null,
-        descriptorBuilder: PropertyDescriptor.() -> Unit,
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> =
         PropertyDelegateProvider { _: DeviceConstructor, property ->
             val name = nameOverride ?: property.name
@@ -78,7 +78,7 @@ public abstract class DeviceConstructor(
         readInterval: Duration,
         initialState: T,
         nameOverride: String? = null,
-        descriptorBuilder: PropertyDescriptor.() -> Unit,
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> = property(
         DeviceState.external(this, metaConverter, readInterval, initialState, reader),
         nameOverride, descriptorBuilder
@@ -91,8 +91,8 @@ public abstract class DeviceConstructor(
     public fun <T : Any> mutableProperty(
         state: MutableDeviceState<T>,
         nameOverride: String? = null,
-        descriptorBuilder: PropertyDescriptor.() -> Unit,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> =
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    ): PropertyDelegateProvider<DeviceConstructor, ReadWriteProperty<DeviceConstructor, T>> =
         PropertyDelegateProvider { _: DeviceConstructor, property ->
             val name = nameOverride ?: property.name
             val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
@@ -117,8 +117,8 @@ public abstract class DeviceConstructor(
         readInterval: Duration,
         initialState: T,
         nameOverride: String? = null,
-        descriptorBuilder: PropertyDescriptor.() -> Unit,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> = mutableProperty(
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    ): PropertyDelegateProvider<DeviceConstructor, ReadWriteProperty<DeviceConstructor, T>> = mutableProperty(
         DeviceState.external(this, metaConverter, readInterval, initialState, reader, writer),
         nameOverride,
         descriptorBuilder
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index 936dd04..68f8301 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -3,6 +3,8 @@ package space.kscience.controls.constructor
 import kotlinx.coroutines.*
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
 import space.kscience.controls.api.*
 import space.kscience.controls.api.DeviceLifecycleState.*
 import space.kscience.controls.manager.DeviceManager
@@ -40,25 +42,30 @@ public open class DeviceGroup(
     )
 
 
-    override val context: Context get() = deviceManager.context
+    override final val context: Context get() = deviceManager.context
 
-    override val coroutineContext: CoroutineContext by lazy {
-        context.newCoroutineContext(
-            SupervisorJob(context.coroutineContext[Job]) +
-                    CoroutineName("Device $this") +
-                    CoroutineExceptionHandler { _, throwable ->
-                        launch {
-                            sharedMessageFlow.emit(
-                                DeviceErrorMessage(
-                                    errorMessage = throwable.message,
-                                    errorType = throwable::class.simpleName,
-                                    errorStackTrace = throwable.stackTraceToString()
-                                )
+
+    private val sharedMessageFlow = MutableSharedFlow<DeviceMessage>()
+
+    override val messageFlow: Flow<DeviceMessage>
+        get() = sharedMessageFlow
+
+    override val coroutineContext: CoroutineContext = context.newCoroutineContext(
+        SupervisorJob(context.coroutineContext[Job]) +
+                CoroutineName("Device $this") +
+                CoroutineExceptionHandler { _, throwable ->
+                    context.launch {
+                        sharedMessageFlow.emit(
+                            DeviceErrorMessage(
+                                errorMessage = throwable.message,
+                                errorType = throwable::class.simpleName,
+                                errorStackTrace = throwable.stackTraceToString()
                             )
-                        }
+                        )
                     }
-        )
-    }
+                }
+    )
+
 
     private val _devices = hashMapOf<NameToken, Device>()
 
@@ -68,14 +75,10 @@ public open class DeviceGroup(
      * Register and initialize (synchronize child's lifecycle state with group state) a new device in this group
      */
     @OptIn(DFExperimental::class)
-    public fun <D : Device> registerDevice(token: NameToken, device: D): D {
+    public fun <D : Device> install(token: NameToken, device: D): D {
         require(_devices[token] == null) { "A child device with name $token already exists" }
-        //start or stop the child if needed
-        when (lifecycleState) {
-            STARTING, STARTED -> launch { device.start() }
-            STOPPED -> device.stop()
-            ERROR -> {}
-        }
+        //start the child device if needed
+        if(lifecycleState == STARTED || lifecycleState == STARTING) launch { device.start() }
         _devices[token] = device
         return device
     }
@@ -89,6 +92,14 @@ public open class DeviceGroup(
         val name = descriptor.name.parseAsName()
         require(properties[name] == null) { "Can't add property with name $name. It already exists." }
         properties[name] = Property(state, descriptor)
+        state.metaFlow.onEach {
+            sharedMessageFlow.emit(
+                PropertyChangedMessage(
+                    descriptor.name,
+                    it
+                )
+            )
+        }.launchIn(this)
     }
 
     private val actions: MutableMap<Name, Action> = hashMapOf()
@@ -115,10 +126,6 @@ public open class DeviceGroup(
         property.valueAsMeta = value
     }
 
-    private val sharedMessageFlow = MutableSharedFlow<DeviceMessage>()
-
-    override val messageFlow: Flow<DeviceMessage>
-        get() = sharedMessageFlow
 
     override suspend fun execute(actionName: String, argument: Meta?): Meta? {
         val action = actions[actionName] ?: error("Action with name $actionName not found")
@@ -185,7 +192,7 @@ private fun DeviceGroup.getOrCreateGroup(name: Name): DeviceGroup {
         1 -> {
             val token = name.first()
             when (val d = devices[token]) {
-                null -> registerDevice(
+                null -> install(
                     token,
                     DeviceGroup(deviceManager, meta[token] ?: Meta.EMPTY)
                 )
@@ -201,15 +208,18 @@ private fun DeviceGroup.getOrCreateGroup(name: Name): DeviceGroup {
 /**
  * Register a device at given [name] path
  */
-public fun <D : Device> DeviceGroup.registerDevice(name: Name, device: D): D {
+public fun <D : Device> DeviceGroup.install(name: Name, device: D): D {
     return when (name.length) {
         0 -> error("Can't use empty name for a child device")
-        1 -> registerDevice(name.first(), device)
-        else -> getOrCreateGroup(name.cutLast()).registerDevice(name.tokens.last(), device)
+        1 -> install(name.first(), device)
+        else -> getOrCreateGroup(name.cutLast()).install(name.tokens.last(), device)
     }
 }
 
-public fun <D : Device> DeviceGroup.registerDevice(name: String, device: D): D = registerDevice(name.parseAsName(), device)
+public fun <D : Device> DeviceGroup.install(name: String, device: D): D =
+    install(name.parseAsName(), device)
+
+public fun <D : Device> Context.install(name: String, device: D): D = request(DeviceManager).install(name, device)
 
 /**
  * Add a device creating intermediate groups if necessary. If device with given [name] already exists, throws an error.
@@ -218,23 +228,23 @@ public fun <D : Device> DeviceGroup.registerDevice(name: String, device: D): D =
  * @param deviceMeta meta override for this specific device
  * @param metaLocation location of the template meta in parent group meta
  */
-public fun <D : Device> DeviceGroup.registerDevice(
+public fun <D : Device> DeviceGroup.install(
     name: Name,
     factory: Factory<D>,
     deviceMeta: Meta? = null,
     metaLocation: Name = name,
 ): D {
     val newDevice = factory.build(deviceManager.context, Laminate(deviceMeta, meta[metaLocation]))
-    registerDevice(name, newDevice)
+    install(name, newDevice)
     return newDevice
 }
 
-public fun <D : Device> DeviceGroup.registerDevice(
+public fun <D : Device> DeviceGroup.install(
     name: String,
     factory: Factory<D>,
     metaLocation: Name = name.parseAsName(),
     metaBuilder: (MutableMeta.() -> Unit)? = null,
-): D = registerDevice(name.parseAsName(), factory, metaBuilder?.let { Meta(it) }, metaLocation)
+): D = install(name.parseAsName(), factory, metaBuilder?.let { Meta(it) }, metaLocation)
 
 /**
  * Create or edit a group with a given [name].
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index 6e841f8..ed7027a 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -75,7 +75,7 @@ private open class BoundDeviceState<T>(
 /**
  * Bind a read-only [DeviceState] to a [Device] property
  */
-public suspend fun <T> Device.bindStateToProperty(
+public suspend fun <T> Device.propertyAsState(
     propertyName: String,
     metaConverter: MetaConverter<T>,
 ): DeviceState<T> {
@@ -83,9 +83,9 @@ public suspend fun <T> Device.bindStateToProperty(
     return BoundDeviceState(metaConverter, this, propertyName, initialValue)
 }
 
-public suspend fun <D : Device, T> D.bindStateToProperty(
+public suspend fun <D : Device, T> D.propertyAsState(
     propertySpec: DevicePropertySpec<D, T>,
-): DeviceState<T> = bindStateToProperty(propertySpec.name, propertySpec.converter)
+): DeviceState<T> = propertyAsState(propertySpec.name, propertySpec.converter)
 
 public fun <T, R> DeviceState<T>.map(
     converter: MetaConverter<R>, mapper: (T) -> R,
@@ -113,17 +113,28 @@ private class MutableBoundDeviceState<T>(
         }
 }
 
-public suspend fun <T> Device.bindMutableStateToProperty(
+public fun <T> Device.mutablePropertyAsState(
+    propertyName: String,
+    metaConverter: MetaConverter<T>,
+    initialValue: T,
+): MutableDeviceState<T> = MutableBoundDeviceState(metaConverter, this, propertyName, initialValue)
+
+public suspend fun <T> Device.mutablePropertyAsState(
     propertyName: String,
     metaConverter: MetaConverter<T>,
 ): MutableDeviceState<T> {
     val initialValue = metaConverter.metaToObject(readProperty(propertyName)) ?: error("Conversion of property failed")
-    return MutableBoundDeviceState(metaConverter, this, propertyName, initialValue)
+    return mutablePropertyAsState(propertyName, metaConverter, initialValue)
 }
 
-public suspend fun <D : Device, T> D.bindMutableStateToProperty(
+public suspend fun <D : Device, T> D.mutablePropertyAsState(
     propertySpec: MutableDevicePropertySpec<D, T>,
-): MutableDeviceState<T> = bindMutableStateToProperty(propertySpec.name, propertySpec.converter)
+): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter)
+
+public fun <D : Device, T> D.mutablePropertyAsState(
+    propertySpec: MutableDevicePropertySpec<D, T>,
+    initialValue: T,
+): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue)
 
 
 private open class ExternalState<T>(
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
index d8802eb..9366971 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
@@ -96,4 +96,4 @@ public class VirtualDrive(
     }
 }
 
-public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = bindMutableStateToProperty(Drive.force)
+public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = mutablePropertyAsState(Drive.force)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
index e6f9df1..9fc6e18 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
@@ -79,4 +79,4 @@ public fun DeviceGroup.pid(
     name: String,
     drive: Drive,
     pidParameters: PidParameters,
-): PidRegulator = registerDevice(name, PidRegulator(drive, pidParameters))
\ No newline at end of file
+): PidRegulator = install(name, PidRegulator(drive, pidParameters))
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index 43574cf..846815c 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -72,23 +72,21 @@ public abstract class DeviceBase<D : Device>(
         onBufferOverflow = BufferOverflow.DROP_OLDEST
     )
 
-    override val coroutineContext: CoroutineContext by lazy {
-        context.newCoroutineContext(
-            SupervisorJob(context.coroutineContext[Job]) +
-                    CoroutineName("Device $this") +
-                    CoroutineExceptionHandler { _, throwable ->
-                        launch {
-                            sharedMessageFlow.emit(
-                                DeviceErrorMessage(
-                                    errorMessage = throwable.message,
-                                    errorType = throwable::class.simpleName,
-                                    errorStackTrace = throwable.stackTraceToString()
-                                )
+    override val coroutineContext: CoroutineContext = context.newCoroutineContext(
+        SupervisorJob(context.coroutineContext[Job]) +
+                CoroutineName("Device $this") +
+                CoroutineExceptionHandler { _, throwable ->
+                    launch {
+                        sharedMessageFlow.emit(
+                            DeviceErrorMessage(
+                                errorMessage = throwable.message,
+                                errorType = throwable::class.simpleName,
+                                errorStackTrace = throwable.stackTraceToString()
                             )
-                        }
+                        )
                     }
-        )
-    }
+                }
+    )
 
 
     /**
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
index dec09b7..06cb285 100644
--- a/demo/constructor/build.gradle.kts
+++ b/demo/constructor/build.gradle.kts
@@ -1,3 +1,5 @@
+import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
+
 plugins {
     id("space.kscience.gradle.mpp")
     application
@@ -20,4 +22,6 @@ kscience {
 
 application {
     mainClass.set("space.kscience.controls.demo.constructor.MainKt")
-}
\ No newline at end of file
+}
+
+kotlin.explicitApi = ExplicitApiMode.Disabled
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 59881e5..385ccfc 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -1,18 +1,18 @@
 package space.kscience.controls.demo.constructor
 
-import space.kscience.controls.api.get
 import space.kscience.controls.constructor.*
 import space.kscience.controls.manager.ClockManager
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.doRecurring
 import space.kscience.controls.spec.name
-import space.kscience.controls.spec.write
 import space.kscience.controls.vision.plot
 import space.kscience.controls.vision.plotDeviceProperty
 import space.kscience.controls.vision.plotNumberState
 import space.kscience.controls.vision.showDashboard
 import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.request
+import space.kscience.dataforge.meta.Meta
 import space.kscience.plotly.models.ScatterMode
 import space.kscience.visionforge.plotly.PlotlyPlugin
 import kotlin.math.PI
@@ -21,6 +21,27 @@ import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.Duration.Companion.seconds
 import kotlin.time.DurationUnit
 
+
+class LinearDrive(
+    context: Context,
+    state: DoubleRangeState,
+    mass: Double,
+    pidParameters: PidParameters,
+    meta: Meta = Meta.EMPTY,
+) : DeviceConstructor(context.request(DeviceManager), meta) {
+
+    val drive by device(VirtualDrive.factory(mass, state))
+    val pid by device(PidRegulator(drive, pidParameters))
+
+    val start by device(LimitSwitch.factory(state.atStartState))
+    val end by device(LimitSwitch.factory(state.atEndState))
+
+
+    val position by property(state)
+    var target by mutableProperty(pid.mutablePropertyAsState(Regulator.target, 0.0))
+}
+
+
 public fun main() {
     val context = Context {
         plugin(DeviceManager)
@@ -37,25 +58,20 @@ public fun main() {
         timeStep = 0.005.seconds
     )
 
-    val device = context.registerDeviceGroup {
-        val drive = VirtualDrive(context,  0.005, state)
-        val pid = pid("pid", drive, pidParameters)
-        registerDevice("start", LimitSwitch.factory(state.atStartState))
-        registerDevice("end", LimitSwitch.factory(state.atEndState))
-
+    val device = context.install("device", LinearDrive(context, state, 0.005, pidParameters)).apply {
         val clock = context.clock
         val clockStart = clock.now()
-
         doRecurring(10.milliseconds) {
             val timeFromStart = clock.now() - clockStart
             val t = timeFromStart.toDouble(DurationUnit.SECONDS)
             val freq = 0.1
-            val target = 5 * sin(2.0 * PI * freq * t) +
+
+            target = 5 * sin(2.0 * PI * freq * t) +
                     sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))
-            pid.write(Regulator.target, target)
         }
     }
 
+
     val maxAge = 10.seconds
 
     context.showDashboard {
@@ -63,21 +79,21 @@ public fun main() {
             plotNumberState(context, state, maxAge = maxAge) {
                 name = "real position"
             }
-            plotDeviceProperty(device["pid"], Regulator.position.name, maxAge = maxAge) {
+            plotDeviceProperty(device.pid, Regulator.position.name, maxAge = maxAge) {
                 name = "read position"
             }
 
-            plotDeviceProperty(device["pid"], Regulator.target.name, maxAge = maxAge) {
+            plotDeviceProperty(device.pid, Regulator.target.name, maxAge = maxAge) {
                 name = "target"
             }
         }
 
         plot {
-            plotDeviceProperty(device["start"], LimitSwitch.locked.name, maxAge = maxAge) {
+            plotDeviceProperty(device.start, LimitSwitch.locked.name, maxAge = maxAge) {
                 name = "start measured"
                 mode = ScatterMode.markers
             }
-            plotDeviceProperty(device["end"], LimitSwitch.locked.name, maxAge = maxAge) {
+            plotDeviceProperty(device.end, LimitSwitch.locked.name, maxAge = maxAge) {
                 name = "end measured"
                 mode = ScatterMode.markers
             }

From 0f687c3c51ab48375c279cf7e7c431dc08a8214c Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 8 Nov 2023 11:52:57 +0300
Subject: [PATCH 029/125] Update jupyter integration

---
 build.gradle.kts                              |   2 +-
 controls-jupyter/build.gradle.kts             |  19 ++
 .../src/jsMain/kotlin/commonJupyter.kt        |  14 ++
 .../src/jvmMain/kotlin/ControlsJupyter.kt     |  71 ++++++++
 controls-server/build.gradle.kts              |  26 +--
 .../controls/server/deviceWebServer.kt        |   0
 .../kscience/controls/server/responses.kt     |   0
 controls-vision/build.gradle.kts              |   2 +-
 demo/constructor/src/jvmMain/kotlin/main.kt   |   2 +-
 demo/notebooks/constructor.ipynb              | 162 ++++++++++++++++++
 gradle.properties                             |   5 +-
 settings.gradle.kts                           |   1 +
 12 files changed, 286 insertions(+), 18 deletions(-)
 create mode 100644 controls-jupyter/build.gradle.kts
 create mode 100644 controls-jupyter/src/jsMain/kotlin/commonJupyter.kt
 create mode 100644 controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt
 rename controls-server/src/{main => jvmMain}/kotlin/space/kscience/controls/server/deviceWebServer.kt (100%)
 rename controls-server/src/{main => jvmMain}/kotlin/space/kscience/controls/server/responses.kt (100%)
 create mode 100644 demo/notebooks/constructor.ipynb

diff --git a/build.gradle.kts b/build.gradle.kts
index 4ca88d6..a135960 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,7 +6,7 @@ plugins {
 }
 
 val dataforgeVersion: String by extra("0.6.2")
-val visionforgeVersion by extra("0.3.0-dev-10")
+val visionforgeVersion by extra("0.3.0-dev-14")
 val ktorVersion: String by extra(space.kscience.gradle.KScienceVersions.ktorVersion)
 val rsocketVersion by extra("0.15.4")
 val xodusVersion by extra("2.0.1")
diff --git a/controls-jupyter/build.gradle.kts b/controls-jupyter/build.gradle.kts
new file mode 100644
index 0000000..3191720
--- /dev/null
+++ b/controls-jupyter/build.gradle.kts
@@ -0,0 +1,19 @@
+plugins {
+    id("space.kscience.gradle.mpp")
+}
+
+val visionforgeVersion: String by rootProject.extra
+
+kscience {
+    fullStack("js/controls-jupyter.js")
+    useKtor()
+    useContextReceivers()
+    jupyterLibrary()
+    dependencies {
+        implementation(projects.controlsVision)
+        implementation("space.kscience:visionforge-jupyter:$visionforgeVersion")
+    }
+    jvmMain {
+        implementation(spclibs.logback.classic)
+    }
+}
\ No newline at end of file
diff --git a/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt b/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt
new file mode 100644
index 0000000..41711d7
--- /dev/null
+++ b/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt
@@ -0,0 +1,14 @@
+import space.kscience.visionforge.jupyter.VFNotebookClient
+import space.kscience.visionforge.markup.MarkupPlugin
+import space.kscience.visionforge.plotly.PlotlyPlugin
+import space.kscience.visionforge.runVisionClient
+
+public fun main(): Unit = runVisionClient {
+//    plugin(DeviceManager)
+//    plugin(ClockManager)
+    plugin(PlotlyPlugin)
+    plugin(MarkupPlugin)
+//    plugin(TableVisionJsPlugin)
+    plugin(VFNotebookClient)
+}
+
diff --git a/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt b/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt
new file mode 100644
index 0000000..2e05e5d
--- /dev/null
+++ b/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt
@@ -0,0 +1,71 @@
+import org.jetbrains.kotlinx.jupyter.api.declare
+import org.jetbrains.kotlinx.jupyter.api.libraries.resources
+import space.kscience.controls.manager.ClockManager
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.misc.DFExperimental
+import space.kscience.plotly.Plot
+import space.kscience.tables.Table
+import space.kscience.visionforge.jupyter.VisionForge
+import space.kscience.visionforge.jupyter.VisionForgeIntegration
+import space.kscience.visionforge.markup.MarkupPlugin
+import space.kscience.visionforge.plotly.PlotlyPlugin
+import space.kscience.visionforge.plotly.asVision
+import space.kscience.visionforge.tables.toVision
+import space.kscience.visionforge.visionManager
+
+
+@OptIn(DFExperimental::class)
+public class ControlsJupyter : VisionForgeIntegration(CONTEXT.visionManager) {
+
+    override fun Builder.afterLoaded(vf: VisionForge) {
+
+        resources {
+            js("controls-jupyter") {
+                classPath("js/controls-jupyter.js")
+            }
+        }
+
+        onLoaded {
+            declare("context" to CONTEXT)
+        }
+
+        import(
+            "kotlin.time.*",
+            "kotlin.time.Duration.Companion.milliseconds",
+            "kotlin.time.Duration.Companion.seconds",
+            "space.kscience.tables.*",
+            "space.kscience.dataforge.meta.*",
+            "space.kscience.dataforge.context.*",
+            "space.kscience.plotly.*",
+            "space.kscience.plotly.models.*",
+            "space.kscience.visionforge.plotly.*",
+            "space.kscience.controls.manager.*",
+            "space.kscience.controls.constructor.*",
+            "space.kscience.controls.vision.*",
+            "space.kscience.controls.spec.*"
+        )
+
+        render<Table<*>> { table ->
+            vf.produceHtml {
+                vision { table.toVision() }
+            }
+        }
+
+        render<Plot> { plot ->
+            vf.produceHtml {
+                vision { plot.asVision() }
+            }
+        }
+    }
+
+    public companion object {
+        private val CONTEXT: Context = Context("controls-jupyter") {
+            plugin(DeviceManager)
+            plugin(ClockManager)
+            plugin(PlotlyPlugin)
+//            plugin(TableVisionPlugin)
+            plugin(MarkupPlugin)
+        }
+    }
+}
diff --git a/controls-server/build.gradle.kts b/controls-server/build.gradle.kts
index 43a9d61..f6a293f 100644
--- a/controls-server/build.gradle.kts
+++ b/controls-server/build.gradle.kts
@@ -1,7 +1,7 @@
 import space.kscience.gradle.Maturity
 
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
 }
 
@@ -12,16 +12,20 @@ description = """
 val dataforgeVersion: String by rootProject.extra
 val ktorVersion: String by rootProject.extra
 
-dependencies {
-    implementation(projects.controlsCore)
-    implementation(projects.controlsPortsKtor)
-    implementation(projects.magix.magixServer)
-    implementation("io.ktor:ktor-server-cio:$ktorVersion")
-    implementation("io.ktor:ktor-server-websockets:$ktorVersion")
-    implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
-    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
-    implementation("io.ktor:ktor-server-html-builder:$ktorVersion")
-    implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
+
+kscience {
+    jvm()
+    dependencies {
+        implementation(projects.controlsCore)
+        implementation(projects.controlsPortsKtor)
+        implementation(projects.magix.magixServer)
+        implementation("io.ktor:ktor-server-cio:$ktorVersion")
+        implementation("io.ktor:ktor-server-websockets:$ktorVersion")
+        implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
+        implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
+        implementation("io.ktor:ktor-server-html-builder:$ktorVersion")
+        implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
+    }
 }
 
 readme{
diff --git a/controls-server/src/main/kotlin/space/kscience/controls/server/deviceWebServer.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
similarity index 100%
rename from controls-server/src/main/kotlin/space/kscience/controls/server/deviceWebServer.kt
rename to controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
diff --git a/controls-server/src/main/kotlin/space/kscience/controls/server/responses.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt
similarity index 100%
rename from controls-server/src/main/kotlin/space/kscience/controls/server/responses.kt
rename to controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt
diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
index a69329a..89080a0 100644
--- a/controls-vision/build.gradle.kts
+++ b/controls-vision/build.gradle.kts
@@ -7,7 +7,7 @@ description = """
     Dashboard and visualization extensions for devices
 """.trimIndent()
 
-val visionforgeVersion = "0.3.0-dev-10"
+val visionforgeVersion: String by rootProject.extra
 
 kscience {
     fullStack("js/controls-vision.js")
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 385ccfc..4ddf8e6 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -42,7 +42,7 @@ class LinearDrive(
 }
 
 
-public fun main() {
+fun main() {
     val context = Context {
         plugin(DeviceManager)
         plugin(PlotlyPlugin)
diff --git a/demo/notebooks/constructor.ipynb b/demo/notebooks/constructor.ipynb
new file mode 100644
index 0000000..ec09359
--- /dev/null
+++ b/demo/notebooks/constructor.ipynb
@@ -0,0 +1,162 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": true
+   },
+   "outputs": [],
+   "source": [
+    "USE(ControlsJupyter())"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "outputs": [],
+   "source": [
+    "class LinearDrive(\n",
+    "    context: Context,\n",
+    "    state: DoubleRangeState,\n",
+    "    mass: Double,\n",
+    "    pidParameters: PidParameters,\n",
+    "    meta: Meta = Meta.EMPTY,\n",
+    ") : DeviceConstructor(context.request(DeviceManager), meta) {\n",
+    "\n",
+    "    val drive by device(VirtualDrive.factory(mass, state))\n",
+    "    val pid by device(PidRegulator(drive, pidParameters))\n",
+    "\n",
+    "    val start by device(LimitSwitch.factory(state.atStartState))\n",
+    "    val end by device(LimitSwitch.factory(state.atEndState))\n",
+    "\n",
+    "\n",
+    "    val position by property(state)\n",
+    "    var target by mutableProperty(pid.mutablePropertyAsState(Regulator.target, 0.0))\n",
+    "}\n"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "outputs": [],
+   "source": [
+    "import kotlin.time.Duration.Companion.milliseconds\n",
+    "import kotlin.time.Duration.Companion.seconds\n",
+    "\n",
+    "val state = DoubleRangeState(0.0, -5.0..5.0)\n",
+    "\n",
+    "val pidParameters = PidParameters(\n",
+    "    kp = 2.5,\n",
+    "    ki = 0.0,\n",
+    "    kd = -0.1,\n",
+    "    timeStep = 0.005.seconds\n",
+    ")\n",
+    "\n",
+    "val device = context.install(\"device\", LinearDrive(context, state, 0.005, pidParameters)).apply {\n",
+    "    val clock = context.clock\n",
+    "    val clockStart = clock.now()\n",
+    "    doRecurring(10.milliseconds) {\n",
+    "        val timeFromStart = clock.now() - clockStart\n",
+    "        val t = timeFromStart.toDouble(DurationUnit.SECONDS)\n",
+    "        val freq = 0.1\n",
+    "\n",
+    "        target = 5 * sin(2.0 * PI * freq * t) +\n",
+    "                sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))\n",
+    "    }\n",
+    "}"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "outputs": [],
+   "source": [
+    "val maxAge = 10.seconds\n",
+    "\n",
+    "\n",
+    "VisionForge.fragment {\n",
+    "    vision {\n",
+    "        plotly {\n",
+    "            plotNumberState(context, state, maxAge = maxAge) {\n",
+    "                name = \"real position\"\n",
+    "            }\n",
+    "            plotDeviceProperty(device.pid, Regulator.position.name, maxAge = maxAge) {\n",
+    "                name = \"read position\"\n",
+    "            }\n",
+    "\n",
+    "            plotDeviceProperty(device.pid, Regulator.target.name, maxAge = maxAge) {\n",
+    "                name = \"target\"\n",
+    "            }\n",
+    "        }\n",
+    "    }\n",
+    "\n",
+    "    vision {\n",
+    "        plotly {\n",
+    "            plotDeviceProperty(device.start, LimitSwitch.locked.name, maxAge = maxAge) {\n",
+    "                name = \"start measured\"\n",
+    "                mode = ScatterMode.markers\n",
+    "            }\n",
+    "            plotDeviceProperty(device.end, LimitSwitch.locked.name, maxAge = maxAge) {\n",
+    "                name = \"end measured\"\n",
+    "                mode = ScatterMode.markers\n",
+    "            }\n",
+    "        }\n",
+    "    }\n",
+    "}"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "outputs": [],
+   "source": [
+    "device.stop()"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "outputs": [],
+   "source": [],
+   "metadata": {
+    "collapsed": false
+   }
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Kotlin",
+   "language": "kotlin",
+   "name": "kotlin"
+  },
+  "language_info": {
+   "name": "kotlin",
+   "version": "1.9.0",
+   "mimetype": "text/x-kotlin",
+   "file_extension": ".kt",
+   "pygments_lexer": "kotlin",
+   "codemirror_mode": "text/x-kotlin",
+   "nbconvert_exporter": ""
+  },
+  "ktnbPluginMetadata": {
+   "projectDependencies": [
+    "controls-kt.controls-jupyter.jvmMain"
+   ]
+  }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 0
+}
diff --git a/gradle.properties b/gradle.properties
index 62d03f6..ea080f4 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -4,10 +4,7 @@ kotlin.native.ignoreDisabledTargets=true
 
 org.gradle.parallel=true
 
-publishing.github=false
-publishing.sonatype=false
-
 org.gradle.configureondemand=true
 org.gradle.jvmargs=-Xmx4096m
 
-toolsVersion=0.15.0-kotlin-1.9.20-RC2
\ No newline at end of file
+toolsVersion=0.15.0-kotlin-1.9.20
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 453519f..7de4241 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -52,6 +52,7 @@ include(
     ":controls-storage:controls-xodus",
     ":controls-constructor",
     ":controls-vision",
+    ":controls-jupyter",
     ":magix",
     ":magix:magix-api",
     ":magix:magix-server",

From 4e17c9051c54a009683f51d04033c5ae2aae9815 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 8 Nov 2023 15:31:12 +0300
Subject: [PATCH 030/125] Update jupyter integration

---
 demo/notebooks/constructor.ipynb | 18 ++++++++++++++++--
 1 file changed, 16 insertions(+), 2 deletions(-)

diff --git a/demo/notebooks/constructor.ipynb b/demo/notebooks/constructor.ipynb
index ec09359..c670638 100644
--- a/demo/notebooks/constructor.ipynb
+++ b/demo/notebooks/constructor.ipynb
@@ -56,7 +56,19 @@
     "    timeStep = 0.005.seconds\n",
     ")\n",
     "\n",
-    "val device = context.install(\"device\", LinearDrive(context, state, 0.005, pidParameters)).apply {\n",
+    "val device = context.install(\"device\", LinearDrive(context, state, 0.005, pidParameters))"
+   ],
+   "metadata": {
+    "collapsed": false
+   }
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "outputs": [],
+   "source": [
+    "\n",
+    "val job = device.run {\n",
     "    val clock = context.clock\n",
     "    val clockStart = clock.now()\n",
     "    doRecurring(10.milliseconds) {\n",
@@ -120,7 +132,9 @@
    "execution_count": null,
    "outputs": [],
    "source": [
-    "device.stop()"
+    "import kotlinx.coroutines.cancel\n",
+    "\n",
+    "job.cancel()"
    ],
    "metadata": {
     "collapsed": false

From fe98a836f89648dd3a1b8c114f3ea8c2626d8466 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 8 Nov 2023 21:01:42 +0300
Subject: [PATCH 031/125] Update jupyter integration

---
 build.gradle.kts                                 |  2 +-
 controls-jupyter/build.gradle.kts                |  3 ++-
 .../src/jvmMain/kotlin/ControlsJupyter.kt        | 16 ++++++++--------
 controls-vision/build.gradle.kts                 |  3 ++-
 4 files changed, 13 insertions(+), 11 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index a135960..2b3f6b3 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.3.0-dev-1"
+    version = "0.3.0-dev-2"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-jupyter/build.gradle.kts b/controls-jupyter/build.gradle.kts
index 3191720..c8486bd 100644
--- a/controls-jupyter/build.gradle.kts
+++ b/controls-jupyter/build.gradle.kts
@@ -1,5 +1,6 @@
 plugins {
     id("space.kscience.gradle.mpp")
+    `maven-publish`
 }
 
 val visionforgeVersion: String by rootProject.extra
@@ -8,7 +9,7 @@ kscience {
     fullStack("js/controls-jupyter.js")
     useKtor()
     useContextReceivers()
-    jupyterLibrary()
+    jupyterLibrary("space.kscience.controls.jupyter.ControlsJupyter")
     dependencies {
         implementation(projects.controlsVision)
         implementation("space.kscience:visionforge-jupyter:$visionforgeVersion")
diff --git a/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt b/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt
index 2e05e5d..ec4bcee 100644
--- a/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt
+++ b/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt
@@ -1,3 +1,5 @@
+package space.kscience.controls.jupyter
+
 import org.jetbrains.kotlinx.jupyter.api.declare
 import org.jetbrains.kotlinx.jupyter.api.libraries.resources
 import space.kscience.controls.manager.ClockManager
@@ -5,13 +7,11 @@ import space.kscience.controls.manager.DeviceManager
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.misc.DFExperimental
 import space.kscience.plotly.Plot
-import space.kscience.tables.Table
 import space.kscience.visionforge.jupyter.VisionForge
 import space.kscience.visionforge.jupyter.VisionForgeIntegration
 import space.kscience.visionforge.markup.MarkupPlugin
 import space.kscience.visionforge.plotly.PlotlyPlugin
 import space.kscience.visionforge.plotly.asVision
-import space.kscience.visionforge.tables.toVision
 import space.kscience.visionforge.visionManager
 
 
@@ -34,7 +34,7 @@ public class ControlsJupyter : VisionForgeIntegration(CONTEXT.visionManager) {
             "kotlin.time.*",
             "kotlin.time.Duration.Companion.milliseconds",
             "kotlin.time.Duration.Companion.seconds",
-            "space.kscience.tables.*",
+//            "space.kscience.tables.*",
             "space.kscience.dataforge.meta.*",
             "space.kscience.dataforge.context.*",
             "space.kscience.plotly.*",
@@ -46,11 +46,11 @@ public class ControlsJupyter : VisionForgeIntegration(CONTEXT.visionManager) {
             "space.kscience.controls.spec.*"
         )
 
-        render<Table<*>> { table ->
-            vf.produceHtml {
-                vision { table.toVision() }
-            }
-        }
+//        render<Table<*>> { table ->
+//            vf.produceHtml {
+//                vision { table.toVision() }
+//            }
+//        }
 
         render<Plot> { plot ->
             vf.produceHtml {
diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
index 89080a0..9395b92 100644
--- a/controls-vision/build.gradle.kts
+++ b/controls-vision/build.gradle.kts
@@ -18,7 +18,8 @@ kscience {
         api(projects.controlsConstructor)
         api("space.kscience:visionforge-plotly:$visionforgeVersion")
         api("space.kscience:visionforge-markdown:$visionforgeVersion")
-        api("space.kscience:visionforge-tables:$visionforgeVersion")
+//        api("space.kscience:tables-kt:0.2.1")
+//        api("space.kscience:visionforge-tables:$visionforgeVersion")
     }
 
     jvmMain{

From 74301afb42fe3046ee0de2ae8d05cdfe25c3b187 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 8 Nov 2023 22:28:26 +0300
Subject: [PATCH 032/125] Return notifications about pid and drive updates.
 Introduce debounce

---
 .../kscience/controls/constructor/Drive.kt    |  1 +
 .../controls/constructor/PidRegulator.kt      |  1 +
 .../src/commonMain/kotlin/plotExtensions.kt   | 24 +++--
 demo/notebooks/constructor.ipynb              | 91 +++++++++++--------
 4 files changed, 74 insertions(+), 43 deletions(-)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
index 0cf4ce0..c2391c8 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
@@ -74,6 +74,7 @@ public class VirtualDrive(
 
                 // compute new value based on velocity and acceleration from the previous step
                 positionState.value += velocity * dtSeconds + force / mass * dtSeconds.pow(2) / 2
+                propertyChanged(Drive.position, positionState.value)
 
                 // compute new velocity based on acceleration on the previous step
                 velocity += force / mass * dtSeconds
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
index 860af60..9fc6e18 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
@@ -62,6 +62,7 @@ public class PidRegulator(
                     lastPosition = drive.position
 
                     drive.force = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative
+                    propertyChanged(Regulator.position, drive.position)
                 }
             }
         }
diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index ce85483..40f746b 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -1,7 +1,11 @@
+@file:OptIn(FlowPreview::class)
+
 package space.kscience.controls.vision
 
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.debounce
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.transform
@@ -25,6 +29,7 @@ import space.kscience.plotly.models.TraceValues
 import space.kscience.plotly.scatter
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.hours
+import kotlin.time.Duration.Companion.milliseconds
 
 private var TraceValues.values: List<Value>
     get() = value?.list ?: emptyList()
@@ -88,12 +93,13 @@ public fun Plot.plotDeviceProperty(
     maxAge: Duration = 1.hours,
     maxPoints: Int = 800,
     minPoints: Int = 400,
+    debounceDuration: Duration = 10.milliseconds,
     coroutineScope: CoroutineScope = device.context,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
     val clock = device.context.clock
     val data = TimeData()
-    device.propertyMessageFlow(propertyName).transform {
+    device.propertyMessageFlow(propertyName).debounce(debounceDuration).transform {
         data.append(it.time ?: clock.now(), it.value.extractValue())
         data.trim(maxAge, maxPoints, minPoints)
         emit(data)
@@ -109,10 +115,11 @@ private fun <T> Trace.updateFromState(
     maxAge: Duration = 1.hours,
     maxPoints: Int = 800,
     minPoints: Int = 400,
-): Job{
+    debounceDuration: Duration = 10.milliseconds,
+): Job {
     val clock = context.clock
     val data = TimeData()
-    return state.valueFlow.transform<T, TimeData> {
+    return state.valueFlow.debounce(debounceDuration).transform<T, TimeData> {
         data.append(clock.now(), it.extractValue())
         data.trim(maxAge, maxPoints, minPoints)
     }.onEach {
@@ -127,9 +134,10 @@ public fun <T> Plot.plotDeviceState(
     maxAge: Duration = 1.hours,
     maxPoints: Int = 800,
     minPoints: Int = 400,
+    debounceDuration: Duration = 10.milliseconds,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
-    updateFromState(context, state, extractValue, maxAge, maxPoints, minPoints)
+    updateFromState(context, state, extractValue, maxAge, maxPoints, minPoints, debounceDuration)
 }
 
 
@@ -139,9 +147,10 @@ public fun Plot.plotNumberState(
     maxAge: Duration = 1.hours,
     maxPoints: Int = 800,
     minPoints: Int = 400,
+    debounceDuration: Duration = 10.milliseconds,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
-    updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints)
+    updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, debounceDuration)
 }
 
 
@@ -151,7 +160,8 @@ public fun Plot.plotBooleanState(
     maxAge: Duration = 1.hours,
     maxPoints: Int = 800,
     minPoints: Int = 400,
+    debounceDuration: Duration = 10.milliseconds,
     configuration: Bar.() -> Unit = {},
-): Job =  bar(configuration).run {
-    updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints)
+): Job = bar(configuration).run {
+    updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, debounceDuration)
 }
\ No newline at end of file
diff --git a/demo/notebooks/constructor.ipynb b/demo/notebooks/constructor.ipynb
index c670638..3b4d73e 100644
--- a/demo/notebooks/constructor.ipynb
+++ b/demo/notebooks/constructor.ipynb
@@ -3,17 +3,28 @@
   {
    "cell_type": "code",
    "execution_count": null,
-   "metadata": {
-    "collapsed": true
-   },
+   "metadata": {},
    "outputs": [],
    "source": [
-    "USE(ControlsJupyter())"
+    "//import space.kscience.controls.jupyter.ControlsJupyter\n",
+    "\n",
+    "//USE(ControlsJupyter())\n",
+    "USE{\n",
+    "    repositories{\n",
+    "        maven(\"https://repo.kotlin.link\")\n",
+    "    }\n",
+    "    dependencies{\n",
+    "        implementation(\"space.kscience:controls-jupyter-jvm:0.3.0-dev-2\")\n",
+    "    }\n",
+    "}"
    ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {
+    "collapsed": false
+   },
    "outputs": [],
    "source": [
     "class LinearDrive(\n",
@@ -34,14 +45,14 @@
     "    val position by property(state)\n",
     "    var target by mutableProperty(pid.mutablePropertyAsState(Regulator.target, 0.0))\n",
     "}\n"
-   ],
-   "metadata": {
-    "collapsed": false
-   }
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {
+    "collapsed": false
+   },
    "outputs": [],
    "source": [
     "import kotlin.time.Duration.Companion.milliseconds\n",
@@ -57,14 +68,14 @@
     ")\n",
     "\n",
     "val device = context.install(\"device\", LinearDrive(context, state, 0.005, pidParameters))"
-   ],
-   "metadata": {
-    "collapsed": false
-   }
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {
+    "collapsed": false
+   },
    "outputs": [],
    "source": [
     "\n",
@@ -80,14 +91,14 @@
     "                sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))\n",
     "    }\n",
     "}"
-   ],
-   "metadata": {
-    "collapsed": false
-   }
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {
+    "collapsed": false
+   },
    "outputs": [],
    "source": [
     "val maxAge = 10.seconds\n",
@@ -96,16 +107,18 @@
     "VisionForge.fragment {\n",
     "    vision {\n",
     "        plotly {\n",
+    "            \n",
+    "            plotDeviceProperty(device.pid, Regulator.target.name, maxAge = maxAge) {\n",
+    "                name = \"target\"\n",
+    "            }\n",
+    "            \n",
     "            plotNumberState(context, state, maxAge = maxAge) {\n",
     "                name = \"real position\"\n",
     "            }\n",
+    "            \n",
     "            plotDeviceProperty(device.pid, Regulator.position.name, maxAge = maxAge) {\n",
     "                name = \"read position\"\n",
     "            }\n",
-    "\n",
-    "            plotDeviceProperty(device.pid, Regulator.target.name, maxAge = maxAge) {\n",
-    "                name = \"target\"\n",
-    "            }\n",
     "        }\n",
     "    }\n",
     "\n",
@@ -122,23 +135,29 @@
     "        }\n",
     "    }\n",
     "}"
-   ],
-   "metadata": {
-    "collapsed": false
-   }
+   ]
   },
   {
    "cell_type": "code",
    "execution_count": null,
+   "metadata": {
+    "collapsed": false
+   },
    "outputs": [],
    "source": [
     "import kotlinx.coroutines.cancel\n",
     "\n",
     "job.cancel()"
-   ],
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
    "metadata": {
     "collapsed": false
-   }
+   },
+   "outputs": [],
+   "source": []
   },
   {
    "cell_type": "code",
@@ -156,21 +175,21 @@
    "language": "kotlin",
    "name": "kotlin"
   },
-  "language_info": {
-   "name": "kotlin",
-   "version": "1.9.0",
-   "mimetype": "text/x-kotlin",
-   "file_extension": ".kt",
-   "pygments_lexer": "kotlin",
-   "codemirror_mode": "text/x-kotlin",
-   "nbconvert_exporter": ""
-  },
   "ktnbPluginMetadata": {
    "projectDependencies": [
     "controls-kt.controls-jupyter.jvmMain"
    ]
+  },
+  "language_info": {
+   "codemirror_mode": "text/x-kotlin",
+   "file_extension": ".kt",
+   "mimetype": "text/x-kotlin",
+   "name": "kotlin",
+   "nbconvert_exporter": "",
+   "pygments_lexer": "kotlin",
+   "version": "1.8.20"
   }
  },
  "nbformat": 4,
- "nbformat_minor": 0
+ "nbformat_minor": 4
 }

From fb8ee59f148e1312ae4bbf06718d1465b391755b Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 8 Nov 2023 22:33:49 +0300
Subject: [PATCH 033/125] replace debounce by sample

---
 .../src/commonMain/kotlin/plotExtensions.kt   | 22 +++++++++----------
 1 file changed, 11 insertions(+), 11 deletions(-)

diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index 40f746b..3640ee9 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -5,9 +5,9 @@ package space.kscience.controls.vision
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.FlowPreview
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.debounce
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.sample
 import kotlinx.coroutines.flow.transform
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
@@ -93,13 +93,13 @@ public fun Plot.plotDeviceProperty(
     maxAge: Duration = 1.hours,
     maxPoints: Int = 800,
     minPoints: Int = 400,
-    debounceDuration: Duration = 10.milliseconds,
+    sampling: Duration = 10.milliseconds,
     coroutineScope: CoroutineScope = device.context,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
     val clock = device.context.clock
     val data = TimeData()
-    device.propertyMessageFlow(propertyName).debounce(debounceDuration).transform {
+    device.propertyMessageFlow(propertyName).sample(sampling).transform {
         data.append(it.time ?: clock.now(), it.value.extractValue())
         data.trim(maxAge, maxPoints, minPoints)
         emit(data)
@@ -115,11 +115,11 @@ private fun <T> Trace.updateFromState(
     maxAge: Duration = 1.hours,
     maxPoints: Int = 800,
     minPoints: Int = 400,
-    debounceDuration: Duration = 10.milliseconds,
+    sampling: Duration = 10.milliseconds,
 ): Job {
     val clock = context.clock
     val data = TimeData()
-    return state.valueFlow.debounce(debounceDuration).transform<T, TimeData> {
+    return state.valueFlow.sample(sampling).transform<T, TimeData> {
         data.append(clock.now(), it.extractValue())
         data.trim(maxAge, maxPoints, minPoints)
     }.onEach {
@@ -134,10 +134,10 @@ public fun <T> Plot.plotDeviceState(
     maxAge: Duration = 1.hours,
     maxPoints: Int = 800,
     minPoints: Int = 400,
-    debounceDuration: Duration = 10.milliseconds,
+    sampling: Duration = 10.milliseconds,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
-    updateFromState(context, state, extractValue, maxAge, maxPoints, minPoints, debounceDuration)
+    updateFromState(context, state, extractValue, maxAge, maxPoints, minPoints, sampling)
 }
 
 
@@ -147,10 +147,10 @@ public fun Plot.plotNumberState(
     maxAge: Duration = 1.hours,
     maxPoints: Int = 800,
     minPoints: Int = 400,
-    debounceDuration: Duration = 10.milliseconds,
+    sampling: Duration = 10.milliseconds,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
-    updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, debounceDuration)
+    updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, sampling)
 }
 
 
@@ -160,8 +160,8 @@ public fun Plot.plotBooleanState(
     maxAge: Duration = 1.hours,
     maxPoints: Int = 800,
     minPoints: Int = 400,
-    debounceDuration: Duration = 10.milliseconds,
+    sampling: Duration = 10.milliseconds,
     configuration: Bar.() -> Unit = {},
 ): Job = bar(configuration).run {
-    updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, debounceDuration)
+    updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, sampling)
 }
\ No newline at end of file

From afee2f0a02bdd97bf82c8e48d2050825c6608f1a Mon Sep 17 00:00:00 2001
From: darksnake <altavir@gmail.com>
Date: Fri, 17 Nov 2023 12:22:06 +0300
Subject: [PATCH 034/125] minor update to constructor

---
 demo/constructor/src/jvmMain/kotlin/main.kt | 15 +++++++++++++++
 1 file changed, 15 insertions(+)

diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 4ddf8e6..16a6283 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -1,11 +1,15 @@
 package space.kscience.controls.demo.constructor
 
+import kotlinx.coroutines.delay
+import space.kscience.controls.api.get
 import space.kscience.controls.constructor.*
 import space.kscience.controls.manager.ClockManager
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.doRecurring
 import space.kscience.controls.spec.name
+import space.kscience.controls.spec.read
+import space.kscience.controls.spec.write
 import space.kscience.controls.vision.plot
 import space.kscience.controls.vision.plotDeviceProperty
 import space.kscience.controls.vision.plotNumberState
@@ -100,4 +104,15 @@ fun main() {
         }
 
     }
+}
+
+suspend fun DeviceGroup.findEnd(): Double{
+    val regulator = get("pid") as Regulator
+    val limitEnd = get("end") as LimitSwitch
+
+    while(!limitEnd.read(LimitSwitch.locked)){
+        delay(10.milliseconds)
+        regulator.write(Regulator.target, regulator.read(Regulator.position) + 0.1)
+    }
+    return regulator.read(Regulator.position)
 }
\ No newline at end of file

From b539c2046a8237acab772d65080485a2a26c8077 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 18 Nov 2023 14:49:23 +0300
Subject: [PATCH 035/125] `DeviceSpec` properties no explicitly pass property
 name to getters and setters

---
 CHANGELOG.md                                  |   1 +
 .../controls/constructor/Regulator.kt         |   5 +-
 .../kscience/controls/api/descriptors.kt      |   2 +-
 .../kscience/controls/spec/DeviceSpec.kt      | 141 +++++++-----------
 .../controls/spec/deviceExtensions.kt         |   2 +-
 .../controls/spec/propertySpecDelegates.kt    |  32 ++--
 .../controls/spec/propertyReflection.kt       |   8 +
 .../controls/opcua/client/OpcUaClientTest.kt  |   4 +-
 .../commonMain/kotlin/ControlVisionPlugin.kt  |  19 +++
 .../src/commonMain/kotlin/IndicatorVision.kt  |  20 +++
 .../jsMain/kotlin/ControlsVisionPlugin.js.kt  |  33 ++++
 .../kotlin/ControlsVisionPlugin.jvm.kt        |  21 +++
 .../kscience/controls/demo/DemoDevice.kt      |   6 +-
 .../kscience/controls/demo/car/IVirtualCar.kt |   2 +
 .../sciprog/devices/mks/MksPdr900Device.kt    |   2 +-
 .../pimotionmaster/PiMotionMasterDevice.kt    |  12 +-
 16 files changed, 185 insertions(+), 125 deletions(-)
 create mode 100644 controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/propertyReflection.kt
 create mode 100644 controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
 create mode 100644 controls-vision/src/commonMain/kotlin/IndicatorVision.kt
 create mode 100644 controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
 create mode 100644 controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4083a94..abf5789 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
 
 ### Changed
 - Property caching moved from core `Device` to the `CachingDevice`
+- `DeviceSpec` properties no explicitly pass property name to getters and setters.
 
 ### Deprecated
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
index 71cb3b0..7495d33 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
@@ -1,10 +1,7 @@
 package space.kscience.controls.constructor
 
 import space.kscience.controls.api.Device
-import space.kscience.controls.spec.DevicePropertySpec
-import space.kscience.controls.spec.DeviceSpec
-import space.kscience.controls.spec.MutableDevicePropertySpec
-import space.kscience.controls.spec.doubleProperty
+import space.kscience.controls.spec.*
 import space.kscience.dataforge.meta.transformations.MetaConverter
 
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt
index 6f7d27c..2adb89a 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt
@@ -27,6 +27,6 @@ public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Un
  */
 @Serializable
 public class ActionDescriptor(public val name: String) {
-    public var info: String? = null
+    public var description: String? = null
 }
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index 55122d9..b5813b7 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -44,64 +44,11 @@ public abstract class DeviceSpec<D : Device> {
         return deviceProperty
     }
 
-    public fun <T> property(
+    public inline fun <reified T> property(
         converter: MetaConverter<T>,
-        readOnlyProperty: KProperty1<D, T>,
-        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, DevicePropertySpec<D, T>>> =
-        PropertyDelegateProvider { _, property ->
-            val deviceProperty = object : DevicePropertySpec<D, T> {
-                override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply {
-                    //TODO add type from converter
-                    mutable = true
-                }.apply(descriptorBuilder)
-
-                override val converter: MetaConverter<T> = converter
-
-                override suspend fun read(device: D): T = withContext(device.coroutineContext) {
-                    readOnlyProperty.get(device)
-                }
-            }
-            registerProperty(deviceProperty)
-            ReadOnlyProperty { _, _ ->
-                deviceProperty
-            }
-        }
-
-    public fun <T> mutableProperty(
-        converter: MetaConverter<T>,
-        readWriteProperty: KMutableProperty1<D, T>,
-        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, MutableDevicePropertySpec<D, T>>> =
-        PropertyDelegateProvider { _, property ->
-            val deviceProperty = object : MutableDevicePropertySpec<D, T> {
-
-                override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply {
-                    //TODO add the type from converter
-                    mutable = true
-                }.apply(descriptorBuilder)
-
-                override val converter: MetaConverter<T> = converter
-
-                override suspend fun read(device: D): T = withContext(device.coroutineContext) {
-                    readWriteProperty.get(device)
-                }
-
-                override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) {
-                    readWriteProperty.set(device, value)
-                }
-            }
-            registerProperty(deviceProperty)
-            ReadOnlyProperty { _, _ ->
-                deviceProperty
-            }
-        }
-
-    public fun <T> property(
-        converter: MetaConverter<T>,
-        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {},
         name: String? = null,
-        read: suspend D.() -> T?,
+        crossinline read: suspend D.(propertyName: String) -> T?,
     ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> =
         PropertyDelegateProvider { _: DeviceSpec<D>, property ->
             val propertyName = name ?: property.name
@@ -109,7 +56,7 @@ public abstract class DeviceSpec<D : Device> {
                 override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
                 override val converter: MetaConverter<T> = converter
 
-                override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() }
+                override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read(propertyName) }
             }
             registerProperty(deviceProperty)
             ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>> { _, _ ->
@@ -117,27 +64,30 @@ public abstract class DeviceSpec<D : Device> {
             }
         }
 
-    public fun <T> mutableProperty(
+    public inline fun <reified T> mutableProperty(
         converter: MetaConverter<T>,
-        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {},
         name: String? = null,
-        read: suspend D.() -> T?,
-        write: suspend D.(T) -> Unit,
+        crossinline read: suspend D.(propertyName: String) -> T?,
+        crossinline write: suspend D.(propertyName: String, value: T) -> Unit,
     ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
         PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
             val propertyName = name ?: property.name
             val deviceProperty = object : MutableDevicePropertySpec<D, T> {
-                override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName, mutable = true)
-                    .apply(descriptorBuilder)
+                override val descriptor: PropertyDescriptor = PropertyDescriptor(
+                    propertyName,
+                    mutable = true
+                ).apply(descriptorBuilder)
                 override val converter: MetaConverter<T> = converter
 
-                override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() }
+                override suspend fun read(device: D): T? =
+                    withContext(device.coroutineContext) { device.read(propertyName) }
 
                 override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) {
-                    device.write(value)
+                    device.write(propertyName, value)
                 }
             }
-            _properties[propertyName] = deviceProperty
+            registerProperty(deviceProperty)
             ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>> { _, _ ->
                 deviceProperty
             }
@@ -209,33 +159,42 @@ public abstract class DeviceSpec<D : Device> {
         }
 }
 
+public inline fun <reified T, D : Device> DeviceSpec<D>.property(
+    converter: MetaConverter<T>,
+    readOnlyProperty: KProperty1<D, T>,
+    crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = property(
+    converter,
+    descriptorBuilder,
+    name = readOnlyProperty.name,
+    read = { readOnlyProperty.get(this) }
+)
+
+public inline fun <reified T, D : Device> DeviceSpec<D>.mutableProperty(
+    converter: MetaConverter<T>,
+    readWriteProperty: KMutableProperty1<D, T>,
+    crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
+    mutableProperty(
+        converter,
+        descriptorBuilder,
+        readWriteProperty.name,
+        read = { _ -> readWriteProperty.get(this) },
+        write = { _, value: T -> readWriteProperty.set(this, value) }
+    )
 
 /**
- * Register a mutable logical property for a device
+ * Register a mutable logical property (without a corresponding physical state) for a device
  */
-@OptIn(InternalDeviceAPI::class)
-public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
+public inline fun <reified T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
     converter: MetaConverter<T>,
-    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, MutableDevicePropertySpec<D, T>>> =
-    PropertyDelegateProvider { _, property ->
-        val deviceProperty = object : MutableDevicePropertySpec<D, T> {
-            val propertyName = name ?: property.name
-            override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
-                //TODO add type from converter
-                mutable = true
-            }.apply(descriptorBuilder)
-
-            override val converter: MetaConverter<T> = converter
-
-            override suspend fun read(device: D): T? = device.getProperty(propertyName)?.let(converter::metaToObject)
-
-            override suspend fun write(device: D, value: T): Unit =
-                device.writeProperty(propertyName, converter.objectToMeta(value))
-        }
-        registerProperty(deviceProperty)
-        ReadOnlyProperty { _, _ ->
-            deviceProperty
-        }
-    }
\ No newline at end of file
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
+    mutableProperty(
+        converter,
+        descriptorBuilder,
+        name,
+        read = { propertyName -> getProperty(propertyName)?.let(converter::metaToObject) },
+        write = { propertyName, value -> writeProperty(propertyName, converter.objectToMeta(value)) }
+    )
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt
index af42f60..fefb65e 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt
@@ -12,7 +12,7 @@ import kotlin.time.Duration
 /**
  * Perform a recurring asynchronous read action and return a flow of results.
  * The flow is lazy, so action is not performed unless flow is consumed.
- * The flow uses called context. In order to call it on device context, use `flowOn(coroutineContext)`.
+ * The flow uses caller context. To call it on device context, use `flowOn(coroutineContext)`.
  *
  * The flow is canceled when the device scope is canceled
  */
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
index 70ef94a..8314838 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
@@ -14,7 +14,7 @@ import kotlin.properties.ReadOnlyProperty
 public fun <D : Device> DeviceSpec<D>.booleanProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> Boolean?
+    read: suspend D.(propertyName: String) -> Boolean?
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Boolean>>> = property(
     MetaConverter.boolean,
     {
@@ -37,9 +37,9 @@ private inline fun numberDescriptor(
 }
 
 public fun <D : Device> DeviceSpec<D>.numberProperty(
-    name: String? = null,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    read: suspend D.() -> Number?
+    name: String? = null,
+    read: suspend D.(propertyName: String) -> Number?
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Number>>> = property(
     MetaConverter.number,
     numberDescriptor(descriptorBuilder),
@@ -50,7 +50,7 @@ public fun <D : Device> DeviceSpec<D>.numberProperty(
 public fun <D : Device> DeviceSpec<D>.doubleProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> Double?
+    read: suspend D.(propertyName: String) -> Double?
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Double>>> = property(
     MetaConverter.double,
     numberDescriptor(descriptorBuilder),
@@ -61,7 +61,7 @@ public fun <D : Device> DeviceSpec<D>.doubleProperty(
 public fun <D : Device> DeviceSpec<D>.stringProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> String?
+    read: suspend D.(propertyName: String) -> String?
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, String>>> = property(
     MetaConverter.string,
     {
@@ -77,7 +77,7 @@ public fun <D : Device> DeviceSpec<D>.stringProperty(
 public fun <D : Device> DeviceSpec<D>.metaProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> Meta?
+    read: suspend D.(propertyName: String) -> Meta?
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Meta>>> = property(
     MetaConverter.meta,
     {
@@ -95,8 +95,8 @@ public fun <D : Device> DeviceSpec<D>.metaProperty(
 public fun <D : Device> DeviceSpec<D>.booleanProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> Boolean?,
-    write: suspend D.(Boolean) -> Unit
+    read: suspend D.(propertyName: String) -> Boolean?,
+    write: suspend D.(propertyName: String, value: Boolean) -> Unit
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Boolean>>> =
     mutableProperty(
         MetaConverter.boolean,
@@ -115,31 +115,31 @@ public fun <D : Device> DeviceSpec<D>.booleanProperty(
 public fun <D : Device> DeviceSpec<D>.numberProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> Number,
-    write: suspend D.(Number) -> Unit
+    read: suspend D.(propertyName: String) -> Number,
+    write: suspend D.(propertyName: String, value: Number) -> Unit
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Number>>> =
     mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write)
 
 public fun <D : Device> DeviceSpec<D>.doubleProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> Double,
-    write: suspend D.(Double) -> Unit
+    read: suspend D.(propertyName: String) -> Double,
+    write: suspend D.(propertyName: String, value: Double) -> Unit
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Double>>> =
     mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write)
 
 public fun <D : Device> DeviceSpec<D>.stringProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> String,
-    write: suspend D.(String) -> Unit
+    read: suspend D.(propertyName: String) -> String,
+    write: suspend D.(propertyName: String, value: String) -> Unit
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, String>>> =
     mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write)
 
 public fun <D : Device> DeviceSpec<D>.metaProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> Meta,
-    write: suspend D.(Meta) -> Unit
+    read: suspend D.(propertyName: String) -> Meta,
+    write: suspend D.(propertyName: String, value: Meta) -> Unit
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Meta>>> =
     mutableProperty(MetaConverter.meta, descriptorBuilder, name, read, write)
\ No newline at end of file
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/propertyReflection.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/propertyReflection.kt
new file mode 100644
index 0000000..1d4cb56
--- /dev/null
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/propertyReflection.kt
@@ -0,0 +1,8 @@
+package space.kscience.controls.spec
+
+import space.kscience.controls.api.PropertyDescriptor
+import kotlin.reflect.KProperty
+
+internal fun PropertyDescriptor.fromSpec(property: KProperty<*>){
+    property.annotations
+}
\ No newline at end of file
diff --git a/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt b/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt
index 8537449..33aa8fb 100644
--- a/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt
+++ b/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt
@@ -29,7 +29,7 @@ class OpcUaClientTest {
                 return DemoOpcUaDevice(config)
             }
 
-            val randomDouble by doubleProperty(read = DemoOpcUaDevice::readRandomDouble)
+            val randomDouble by doubleProperty { readRandomDouble() }
 
         }
 
@@ -42,7 +42,7 @@ class OpcUaClientTest {
     fun testReadDouble() = runTest {
         val device = DemoOpcUaDevice.build()
         device.start()
-            println(device.read(DemoOpcUaDevice.randomDouble))
+        println(device.read(DemoOpcUaDevice.randomDouble))
         device.stop()
     }
 
diff --git a/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
new file mode 100644
index 0000000..d587250
--- /dev/null
+++ b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
@@ -0,0 +1,19 @@
+package space.kscience.controls.vision
+
+import kotlinx.serialization.modules.SerializersModule
+import kotlinx.serialization.modules.polymorphic
+import kotlinx.serialization.modules.subclass
+import space.kscience.dataforge.context.PluginFactory
+import space.kscience.visionforge.Vision
+import space.kscience.visionforge.VisionPlugin
+import space.kscience.visionforge.plotly.VisionOfPlotly
+
+public expect class ControlVisionPlugin: VisionPlugin{
+    public companion object: PluginFactory<ControlVisionPlugin>
+}
+
+internal val controlsVisionSerializersModule = SerializersModule {
+    polymorphic(Vision::class) {
+        subclass(VisionOfPlotly.serializer())
+    }
+}
\ No newline at end of file
diff --git a/controls-vision/src/commonMain/kotlin/IndicatorVision.kt b/controls-vision/src/commonMain/kotlin/IndicatorVision.kt
new file mode 100644
index 0000000..9ec2319
--- /dev/null
+++ b/controls-vision/src/commonMain/kotlin/IndicatorVision.kt
@@ -0,0 +1,20 @@
+package space.kscience.controls.vision
+
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.node
+import space.kscience.visionforge.AbstractVision
+import space.kscience.visionforge.Vision
+
+/**
+ * A [Vision] that shows an indicator
+ */
+public class IndicatorVision: AbstractVision() {
+    public val value: Meta? by properties.node()
+}
+
+///**
+// * A [Vision] that allows both showing the value and changing it
+// */
+//public interface RegulatorVision: IndicatorVision{
+//
+//}
\ No newline at end of file
diff --git a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
new file mode 100644
index 0000000..0d928ac
--- /dev/null
+++ b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
@@ -0,0 +1,33 @@
+package space.kscience.controls.vision
+
+import kotlinx.serialization.modules.SerializersModule
+import org.w3c.dom.Element
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.PluginFactory
+import space.kscience.dataforge.context.PluginTag
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.names.Name
+import space.kscience.visionforge.ElementVisionRenderer
+import space.kscience.visionforge.Vision
+import space.kscience.visionforge.VisionPlugin
+
+public actual class ControlVisionPlugin : VisionPlugin(), ElementVisionRenderer {
+    override val tag: PluginTag get() = Companion.tag
+
+    override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule
+
+    override fun rateVision(vision: Vision): Int {
+        TODO("Not yet implemented")
+    }
+
+    override fun render(element: Element, name: Name, vision: Vision, meta: Meta) {
+        TODO("Not yet implemented")
+    }
+
+    public actual companion object : PluginFactory<ControlVisionPlugin> {
+        override val tag: PluginTag = PluginTag("controls.vision")
+
+        override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin()
+
+    }
+}
\ No newline at end of file
diff --git a/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt b/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt
new file mode 100644
index 0000000..55074ae
--- /dev/null
+++ b/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt
@@ -0,0 +1,21 @@
+package space.kscience.controls.vision
+
+import kotlinx.serialization.modules.SerializersModule
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.PluginFactory
+import space.kscience.dataforge.context.PluginTag
+import space.kscience.dataforge.meta.Meta
+import space.kscience.visionforge.VisionPlugin
+
+public actual class ControlVisionPlugin : VisionPlugin() {
+    override val tag: PluginTag get() = Companion.tag
+
+    override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule
+
+    public actual companion object : PluginFactory<ControlVisionPlugin> {
+        override val tag: PluginTag = PluginTag("controls.vision")
+
+        override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin()
+
+    }
+}
\ No newline at end of file
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
index 4f902c0..eaa1bff 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
@@ -16,7 +16,7 @@ import kotlin.math.sin
 import kotlin.time.Duration.Companion.milliseconds
 
 
-interface IDemoDevice: Device {
+interface IDemoDevice : Device {
     var timeScaleState: Double
     var sinScaleState: Double
     var cosScaleState: Double
@@ -50,8 +50,8 @@ class DemoDevice(context: Context, meta: Meta) : DeviceBySpec<IDemoDevice>(Compa
         val sinScale by mutableProperty(MetaConverter.double, IDemoDevice::sinScaleState)
         val cosScale by mutableProperty(MetaConverter.double, IDemoDevice::cosScaleState)
 
-        val sin by doubleProperty(read = IDemoDevice::sinValue)
-        val cos by doubleProperty(read = IDemoDevice::cosValue)
+        val sin by doubleProperty { sinValue() }
+        val cos by doubleProperty { cosValue() }
 
         val coordinates by metaProperty(
             descriptorBuilder = {
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt
index 3bb8c79..c61041b 100644
--- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt
+++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt
@@ -2,6 +2,8 @@ package space.kscience.controls.demo.car
 
 import space.kscience.controls.api.Device
 import space.kscience.controls.spec.DeviceSpec
+import space.kscience.controls.spec.mutableProperty
+import space.kscience.controls.spec.property
 
 interface IVirtualCar : Device {
     var speedState: Vector2D
diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
index df54bfa..cb8030c 100644
--- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
+++ b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
@@ -89,7 +89,7 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi
 
         override fun build(context: Context, meta: Meta): MksPdr900Device = MksPdr900Device(context, meta)
 
-        val powerOn by booleanProperty(read = MksPdr900Device::readPowerOn, write = MksPdr900Device::writePowerOn)
+        val powerOn by booleanProperty(read = { readPowerOn() }, write = { _, value -> writePowerOn(value) })
 
         val channel by logicalProperty(MetaConverter.int)
 
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index 18e5838..dde2750 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -157,13 +157,13 @@ class PiMotionMasterDevice(
         }
 
         val stop by unitAction({
-            info = "Stop all axis"
+            description = "Stop all axis"
         }) {
             send("STP")
         }
 
         val connect by action(MetaConverter.meta, MetaConverter.unit, descriptorBuilder = {
-            info = "Connect to specific port and initialize axis"
+            description = "Connect to specific port and initialize axis"
         }) { portSpec ->
             //Clear current actions if present
             if (port != null) {
@@ -189,7 +189,7 @@ class PiMotionMasterDevice(
         }
 
         val disconnect by unitAction({
-            info = "Disconnect the program from the device if it is connected"
+            description = "Disconnect the program from the device if it is connected"
         }) {
             port?.let {
                 execute(stop)
@@ -245,8 +245,8 @@ class PiMotionMasterDevice(
                 read = {
                     readAxisBoolean("$command?")
                 },
-                write = {
-                    writeAxisBoolean(command, it)
+                write = { _, value ->
+                    writeAxisBoolean(command, value)
                 },
                 descriptorBuilder = descriptorBuilder
             )
@@ -259,7 +259,7 @@ class PiMotionMasterDevice(
                     mm.requestAndParse("$command?", axisId)[axisId]?.toDoubleOrNull()
                         ?: error("Malformed $command response. Should include float value for $axisId")
                 },
-                write = { newValue ->
+                write = { _, newValue ->
                     mm.send(command, axisId, newValue.toString())
                     mm.failIfError()
                 },

From 0c647cff306ab732e49378a1b16e152b270a15a5 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 18 Nov 2023 15:39:56 +0300
Subject: [PATCH 036/125] `DeviceSpec` properties no explicitly pass property
 name to getters and setters

---
 .../kscience/controls/spec/DeviceSpec.kt      | 115 ++++++------------
 .../controls/spec/propertySpecDelegates.kt    |  49 ++++++++
 2 files changed, 87 insertions(+), 77 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index b5813b7..19e5ab2 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -8,9 +8,7 @@ import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.transformations.MetaConverter
 import kotlin.properties.PropertyDelegateProvider
 import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KMutableProperty1
 import kotlin.reflect.KProperty
-import kotlin.reflect.KProperty1
 
 public object UnitMetaConverter : MetaConverter<Unit> {
     override fun metaToObject(meta: Meta): Unit = Unit
@@ -44,11 +42,11 @@ public abstract class DeviceSpec<D : Device> {
         return deviceProperty
     }
 
-    public inline fun <reified T> property(
+    public fun <T> property(
         converter: MetaConverter<T>,
-        crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
         name: String? = null,
-        crossinline read: suspend D.(propertyName: String) -> T?,
+        read: suspend D.(propertyName: String) -> T?,
     ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> =
         PropertyDelegateProvider { _: DeviceSpec<D>, property ->
             val propertyName = name ?: property.name
@@ -56,7 +54,8 @@ public abstract class DeviceSpec<D : Device> {
                 override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
                 override val converter: MetaConverter<T> = converter
 
-                override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read(propertyName) }
+                override suspend fun read(device: D): T? =
+                    withContext(device.coroutineContext) { device.read(propertyName) }
             }
             registerProperty(deviceProperty)
             ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>> { _, _ ->
@@ -64,12 +63,12 @@ public abstract class DeviceSpec<D : Device> {
             }
         }
 
-    public inline fun <reified T> mutableProperty(
+    public fun <T> mutableProperty(
         converter: MetaConverter<T>,
-        crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
         name: String? = null,
-        crossinline read: suspend D.(propertyName: String) -> T?,
-        crossinline write: suspend D.(propertyName: String, value: T) -> Unit,
+        read: suspend D.(propertyName: String) -> T?,
+        write: suspend D.(propertyName: String, value: T) -> Unit,
     ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
         PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
             val propertyName = name ?: property.name
@@ -106,7 +105,7 @@ public abstract class DeviceSpec<D : Device> {
         name: String? = null,
         execute: suspend D.(I) -> O,
     ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, I, O>>> =
-        PropertyDelegateProvider { _: DeviceSpec<D>, property ->
+        PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
             val actionName = name ?: property.name
             val deviceAction = object : DeviceActionSpec<D, I, O> {
                 override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply(descriptorBuilder)
@@ -124,77 +123,39 @@ public abstract class DeviceSpec<D : Device> {
             }
         }
 
-    /**
-     * An action that takes [Meta] and returns [Meta]. No conversions are done
-     */
-    public fun metaAction(
-        descriptorBuilder: ActionDescriptor.() -> Unit = {},
-        name: String? = null,
-        execute: suspend D.(Meta) -> Meta,
-    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> =
-        action(
-            MetaConverter.Companion.meta,
-            MetaConverter.Companion.meta,
-            descriptorBuilder,
-            name
-        ) {
-            execute(it)
-        }
-
-    /**
-     * An action that takes no parameters and returns no values
-     */
-    public fun unitAction(
-        descriptorBuilder: ActionDescriptor.() -> Unit = {},
-        name: String? = null,
-        execute: suspend D.() -> Unit,
-    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Unit, Unit>>> =
-        action(
-            MetaConverter.Companion.unit,
-            MetaConverter.Companion.unit,
-            descriptorBuilder,
-            name
-        ) {
-            execute()
-        }
 }
 
-public inline fun <reified T, D : Device> DeviceSpec<D>.property(
-    converter: MetaConverter<T>,
-    readOnlyProperty: KProperty1<D, T>,
-    crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = property(
-    converter,
-    descriptorBuilder,
-    name = readOnlyProperty.name,
-    read = { readOnlyProperty.get(this) }
-)
-
-public inline fun <reified T, D : Device> DeviceSpec<D>.mutableProperty(
-    converter: MetaConverter<T>,
-    readWriteProperty: KMutableProperty1<D, T>,
-    crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
-    mutableProperty(
-        converter,
+/**
+ * An action that takes no parameters and returns no values
+ */
+public fun <D : Device> DeviceSpec<D>.unitAction(
+    descriptorBuilder: ActionDescriptor.() -> Unit = {},
+    name: String? = null,
+    execute: suspend D.() -> Unit,
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Unit, Unit>>> =
+    action(
+        MetaConverter.Companion.unit,
+        MetaConverter.Companion.unit,
         descriptorBuilder,
-        readWriteProperty.name,
-        read = { _ -> readWriteProperty.get(this) },
-        write = { _, value: T -> readWriteProperty.set(this, value) }
-    )
+        name
+    ) {
+        execute()
+    }
 
 /**
- * Register a mutable logical property (without a corresponding physical state) for a device
+ * An action that takes [Meta] and returns [Meta]. No conversions are done
  */
-public inline fun <reified T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
-    converter: MetaConverter<T>,
-    crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+public fun <D : Device> DeviceSpec<D>.metaAction(
+    descriptorBuilder: ActionDescriptor.() -> Unit = {},
     name: String? = null,
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
-    mutableProperty(
-        converter,
+    execute: suspend D.(Meta) -> Meta,
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> =
+    action(
+        MetaConverter.Companion.meta,
+        MetaConverter.Companion.meta,
         descriptorBuilder,
-        name,
-        read = { propertyName -> getProperty(propertyName)?.let(converter::metaToObject) },
-        write = { propertyName, value -> writeProperty(propertyName, converter.objectToMeta(value)) }
-    )
\ No newline at end of file
+        name
+    ) {
+        execute(it)
+    }
+
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
index 8314838..6f599a8 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
@@ -8,6 +8,55 @@ import space.kscience.dataforge.meta.ValueType
 import space.kscience.dataforge.meta.transformations.MetaConverter
 import kotlin.properties.PropertyDelegateProvider
 import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KMutableProperty1
+import kotlin.reflect.KProperty1
+
+/**
+ * A read-only device property that delegates reading to a device [KProperty1]
+ */
+public fun <T, D : Device> DeviceSpec<D>.property(
+    converter: MetaConverter<T>,
+    readOnlyProperty: KProperty1<D, T>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = property(
+    converter,
+    descriptorBuilder,
+    name = readOnlyProperty.name,
+    read = { readOnlyProperty.get(this) }
+)
+
+/**
+ * Mutable property that delegates reading and writing to a device [KMutableProperty1]
+ */
+public fun <T, D : Device> DeviceSpec<D>.mutableProperty(
+    converter: MetaConverter<T>,
+    readWriteProperty: KMutableProperty1<D, T>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
+    mutableProperty(
+        converter,
+        descriptorBuilder,
+        readWriteProperty.name,
+        read = { _ -> readWriteProperty.get(this) },
+        write = { _, value: T -> readWriteProperty.set(this, value) }
+    )
+
+/**
+ * Register a mutable logical property (without a corresponding physical state) for a device
+ */
+public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
+    converter: MetaConverter<T>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    name: String? = null,
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
+    mutableProperty(
+        converter,
+        descriptorBuilder,
+        name,
+        read = { propertyName -> getProperty(propertyName)?.let(converter::metaToObject) },
+        write = { propertyName, value -> writeProperty(propertyName, converter.objectToMeta(value)) }
+    )
+
 
 //read only delegates
 

From 07cc41c645692f1004ba293498f9768482150cd4 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 18 Nov 2023 19:02:56 +0300
Subject: [PATCH 037/125] Automatic description generation for spec properties
 (JVM only)

---
 CHANGELOG.md                                   |  1 +
 .../space/kscience/controls/spec/DeviceSpec.kt | 17 ++++++++++++++---
 .../space/kscience/controls/spec/fromSpec.kt   | 12 ++++++++++++
 .../kscience/controls/spec/fromSpec.js.kt      |  9 +++++++++
 .../kscience/controls/spec/fromSpec.jvm.kt     | 18 ++++++++++++++++++
 .../controls/spec/propertyReflection.kt        |  8 --------
 .../kscience/controls/spec/fromSpec.native.kt  |  9 +++++++++
 7 files changed, 63 insertions(+), 11 deletions(-)
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt
 create mode 100644 controls-core/src/jsMain/kotlin/space/kscience/controls/spec/fromSpec.js.kt
 create mode 100644 controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt
 delete mode 100644 controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/propertyReflection.kt
 create mode 100644 controls-core/src/nativeMain/kotlin/space/kscience/controls/spec/fromSpec.native.kt

diff --git a/CHANGELOG.md b/CHANGELOG.md
index abf5789..b4de4b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,7 @@
 ### Added
 - Device lifecycle message
 - Low-code constructor
+- Automatic description generation for spec properties (JVM only)
 
 ### Changed
 - Property caching moved from core `Device` to the `CachingDevice`
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index 19e5ab2..cd0d7cc 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -51,7 +51,12 @@ public abstract class DeviceSpec<D : Device> {
         PropertyDelegateProvider { _: DeviceSpec<D>, property ->
             val propertyName = name ?: property.name
             val deviceProperty = object : DevicePropertySpec<D, T> {
-                override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
+
+                override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
+                    fromSpec(property)
+                    descriptorBuilder()
+                }
+
                 override val converter: MetaConverter<T> = converter
 
                 override suspend fun read(device: D): T? =
@@ -76,7 +81,10 @@ public abstract class DeviceSpec<D : Device> {
                 override val descriptor: PropertyDescriptor = PropertyDescriptor(
                     propertyName,
                     mutable = true
-                ).apply(descriptorBuilder)
+                ).apply {
+                    fromSpec(property)
+                    descriptorBuilder()
+                }
                 override val converter: MetaConverter<T> = converter
 
                 override suspend fun read(device: D): T? =
@@ -108,7 +116,10 @@ public abstract class DeviceSpec<D : Device> {
         PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
             val actionName = name ?: property.name
             val deviceAction = object : DeviceActionSpec<D, I, O> {
-                override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply(descriptorBuilder)
+                override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply {
+                    fromSpec(property)
+                    descriptorBuilder()
+                }
 
                 override val inputConverter: MetaConverter<I> = inputConverter
                 override val outputConverter: MetaConverter<O> = outputConverter
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt
new file mode 100644
index 0000000..000458e
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt
@@ -0,0 +1,12 @@
+package space.kscience.controls.spec
+
+import space.kscience.controls.api.ActionDescriptor
+import space.kscience.controls.api.PropertyDescriptor
+import kotlin.reflect.KProperty
+
+@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FIELD)
+public annotation class Description(val content: String)
+
+internal expect fun PropertyDescriptor.fromSpec(property: KProperty<*>)
+
+internal expect fun ActionDescriptor.fromSpec(property: KProperty<*>)
\ No newline at end of file
diff --git a/controls-core/src/jsMain/kotlin/space/kscience/controls/spec/fromSpec.js.kt b/controls-core/src/jsMain/kotlin/space/kscience/controls/spec/fromSpec.js.kt
new file mode 100644
index 0000000..cd77248
--- /dev/null
+++ b/controls-core/src/jsMain/kotlin/space/kscience/controls/spec/fromSpec.js.kt
@@ -0,0 +1,9 @@
+package space.kscience.controls.spec
+
+import space.kscience.controls.api.ActionDescriptor
+import space.kscience.controls.api.PropertyDescriptor
+import kotlin.reflect.KProperty
+
+internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>){}
+
+internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){}
\ No newline at end of file
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt
new file mode 100644
index 0000000..7ae572f
--- /dev/null
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt
@@ -0,0 +1,18 @@
+package space.kscience.controls.spec
+
+import space.kscience.controls.api.ActionDescriptor
+import space.kscience.controls.api.PropertyDescriptor
+import kotlin.reflect.KProperty
+import kotlin.reflect.full.findAnnotation
+
+internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) {
+    property.findAnnotation<Description>()?.let {
+        description = it.content
+    }
+}
+
+internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){
+    property.findAnnotation<Description>()?.let {
+        description = it.content
+    }
+}
\ No newline at end of file
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/propertyReflection.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/propertyReflection.kt
deleted file mode 100644
index 1d4cb56..0000000
--- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/propertyReflection.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package space.kscience.controls.spec
-
-import space.kscience.controls.api.PropertyDescriptor
-import kotlin.reflect.KProperty
-
-internal fun PropertyDescriptor.fromSpec(property: KProperty<*>){
-    property.annotations
-}
\ No newline at end of file
diff --git a/controls-core/src/nativeMain/kotlin/space/kscience/controls/spec/fromSpec.native.kt b/controls-core/src/nativeMain/kotlin/space/kscience/controls/spec/fromSpec.native.kt
new file mode 100644
index 0000000..1d1ccc4
--- /dev/null
+++ b/controls-core/src/nativeMain/kotlin/space/kscience/controls/spec/fromSpec.native.kt
@@ -0,0 +1,9 @@
+package space.kscience.controls.spec
+
+import space.kscience.controls.api.ActionDescriptor
+import space.kscience.controls.api.PropertyDescriptor
+import kotlin.reflect.KProperty
+
+internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) {}
+
+internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){}
\ No newline at end of file

From 81d6b672cfcd8bf223ea73c5866ca0cb722685bf Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 22 Nov 2023 21:55:13 +0300
Subject: [PATCH 038/125] Add compose controls to pid simulator

---
 .../controls/constructor/PidRegulator.kt      |  22 ++-
 demo/constructor/build.gradle.kts             |  34 ++++-
 demo/constructor/src/jvmMain/kotlin/main.kt   | 139 ++++++++++++++----
 3 files changed, 154 insertions(+), 41 deletions(-)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
index 9fc6e18..d782e05 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
@@ -16,12 +16,22 @@ import kotlin.time.DurationUnit
 /**
  * Pid regulator parameters
  */
-public data class PidParameters(
-    public val kp: Double,
-    public val ki: Double,
-    public val kd: Double,
-    public val timeStep: Duration = 1.milliseconds,
-)
+public interface PidParameters {
+    public val kp: Double
+    public val ki: Double
+    public val kd: Double
+    public val timeStep: Duration
+}
+
+private data class PidParametersImpl(
+    override val kp: Double,
+    override val ki: Double,
+    override val kd: Double,
+    override val timeStep: Duration,
+) : PidParameters
+
+public fun PidParameters(kp: Double, ki: Double, kd: Double, timeStep: Duration = 1.milliseconds): PidParameters =
+    PidParametersImpl(kp, ki, kd, timeStep)
 
 /**
  * A drive with PID regulator
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
index 06cb285..9026247 100644
--- a/demo/constructor/build.gradle.kts
+++ b/demo/constructor/build.gradle.kts
@@ -1,12 +1,13 @@
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
 import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
 
 plugins {
     id("space.kscience.gradle.mpp")
-    application
+    id("org.jetbrains.compose") version "1.5.10"
 }
 
 kscience {
-    jvm{
+    jvm {
         withJava()
     }
     useKtor()
@@ -20,8 +21,31 @@ kscience {
     }
 }
 
-application {
-    mainClass.set("space.kscience.controls.demo.constructor.MainKt")
+kotlin {
+    sourceSets {
+        jvmMain {
+            dependencies {
+                implementation(compose.desktop.currentOs)
+            }
+        }
+    }
 }
 
-kotlin.explicitApi = ExplicitApiMode.Disabled
\ No newline at end of file
+//application {
+//    mainClass.set("space.kscience.controls.demo.constructor.MainKt")
+//}
+
+kotlin.explicitApi = ExplicitApiMode.Disabled
+
+
+compose.desktop {
+    application {
+        mainClass = "space.kscience.controls.demo.constructor.MainKt"
+
+        nativeDistributions {
+            targetFormats(TargetFormat.Exe)
+            packageName = "PidConstructor"
+            packageVersion = "1.0.0"
+        }
+    }
+}
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 16a6283..636bd82 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -1,15 +1,26 @@
 package space.kscience.controls.demo.constructor
 
-import kotlinx.coroutines.delay
-import space.kscience.controls.api.get
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.*
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import kotlinx.coroutines.launch
 import space.kscience.controls.constructor.*
 import space.kscience.controls.manager.ClockManager
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.doRecurring
 import space.kscience.controls.spec.name
-import space.kscience.controls.spec.read
-import space.kscience.controls.spec.write
 import space.kscience.controls.vision.plot
 import space.kscience.controls.vision.plotDeviceProperty
 import space.kscience.controls.vision.plotNumberState
@@ -21,6 +32,7 @@ import space.kscience.plotly.models.ScatterMode
 import space.kscience.visionforge.plotly.PlotlyPlugin
 import kotlin.math.PI
 import kotlin.math.sin
+import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.Duration.Companion.seconds
 import kotlin.time.DurationUnit
@@ -46,23 +58,15 @@ class LinearDrive(
 }
 
 
-fun main() {
-    val context = Context {
-        plugin(DeviceManager)
-        plugin(PlotlyPlugin)
-        plugin(ClockManager)
-    }
-
-    val state = DoubleRangeState(0.0, -5.0..5.0)
-
-    val pidParameters = PidParameters(
-        kp = 2.5,
-        ki = 0.0,
-        kd = -0.1,
-        timeStep = 0.005.seconds
-    )
-
-    val device = context.install("device", LinearDrive(context, state, 0.005, pidParameters)).apply {
+private fun Context.launchPidDevice(
+    state: DoubleRangeState,
+    pidParameters: PidParameters,
+    mass: Double,
+) = launch {
+    val device = install(
+        "device",
+        LinearDrive(this@launchPidDevice, state, mass, pidParameters)
+    ).apply {
         val clock = context.clock
         val clockStart = clock.now()
         doRecurring(10.milliseconds) {
@@ -78,7 +82,7 @@ fun main() {
 
     val maxAge = 10.seconds
 
-    context.showDashboard {
+    showDashboard {
         plot {
             plotNumberState(context, state, maxAge = maxAge) {
                 name = "real position"
@@ -106,13 +110,88 @@ fun main() {
     }
 }
 
-suspend fun DeviceGroup.findEnd(): Double{
-    val regulator = get("pid") as Regulator
-    val limitEnd = get("end") as LimitSwitch
-
-    while(!limitEnd.read(LimitSwitch.locked)){
-        delay(10.milliseconds)
-        regulator.write(Regulator.target, regulator.read(Regulator.position) + 0.1)
+fun main() = application {
+    val context = Context {
+        plugin(DeviceManager)
+        plugin(PlotlyPlugin)
+        plugin(ClockManager)
+    }
+
+    class MutablePidParameters(
+        kp: Double,
+        ki: Double,
+        kd: Double,
+        timeStep: Duration,
+    ) : PidParameters {
+        override var kp by  mutableStateOf(kp)
+        override var ki by  mutableStateOf(ki)
+        override var kd by  mutableStateOf(kd)
+        override var timeStep by  mutableStateOf(timeStep)
+    }
+
+    val pidParameters = remember {
+        MutablePidParameters(
+            kp = 2.5,
+            ki = 0.0,
+            kd = -0.1,
+            timeStep = 0.005.seconds
+        )
+    }
+
+    context.launchPidDevice(
+        DoubleRangeState(0.0, -6.0..6.0),
+        pidParameters,
+        mass = 0.05
+    )
+
+    Window(onCloseRequest = ::exitApplication) {
+        MaterialTheme {
+            Column {
+                Row {
+                    Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
+                    TextField(pidParameters.kp.toString(),{pidParameters.kp = it.toDouble()}, enabled = false)
+                    Slider(
+                        pidParameters.kp.toFloat(),
+                        { pidParameters.kp = it.toDouble()},
+                        valueRange = 0f..10f,
+                        steps = 100
+                    )
+                }
+                Row {
+                    Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
+                    TextField(pidParameters.ki.toString(),{pidParameters.ki = it.toDouble()}, enabled = false)
+
+                    Slider(
+                        pidParameters.ki.toFloat(),
+                        { pidParameters.ki = it.toDouble()},
+                        valueRange = -5f..5f,
+                        steps = 100
+                    )
+                }
+                Row {
+                    Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
+                    TextField(pidParameters.kd.toString(),{pidParameters.kd = it.toDouble()}, enabled = false)
+
+                    Slider(
+                        pidParameters.kd.toFloat(),
+                        { pidParameters.kd = it.toDouble()},
+                        valueRange = -5f..5f,
+                        steps = 100
+                    )
+                }
+                Row {
+                    Button({
+                        pidParameters.run {
+                            kp = 2.5
+                            ki = 0.0
+                            kd = -0.1
+                            timeStep = 0.005.seconds
+                        }
+                    }){
+                        Text("Reset")
+                    }
+                }
+            }
+        }
     }
-    return regulator.read(Regulator.position)
 }
\ No newline at end of file

From 827eb6e4c1f036f04c29c941e41ace5356199421 Mon Sep 17 00:00:00 2001
From: darksnake <altavir@gmail.com>
Date: Thu, 23 Nov 2023 16:52:07 +0300
Subject: [PATCH 039/125] minor update to constructor

---
 demo/constructor/src/jvmMain/kotlin/main.kt | 62 ++++++++++++++++-----
 1 file changed, 47 insertions(+), 15 deletions(-)

diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 636bd82..3054399 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -123,10 +123,10 @@ fun main() = application {
         kd: Double,
         timeStep: Duration,
     ) : PidParameters {
-        override var kp by  mutableStateOf(kp)
-        override var ki by  mutableStateOf(ki)
-        override var kd by  mutableStateOf(kd)
-        override var timeStep by  mutableStateOf(timeStep)
+        override var kp by mutableStateOf(kp)
+        override var ki by mutableStateOf(ki)
+        override var kd by mutableStateOf(kd)
+        override var timeStep by mutableStateOf(timeStep)
     }
 
     val pidParameters = remember {
@@ -144,38 +144,70 @@ fun main() = application {
         mass = 0.05
     )
 
-    Window(onCloseRequest = ::exitApplication) {
+    Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
         MaterialTheme {
             Column {
                 Row {
                     Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
-                    TextField(pidParameters.kp.toString(),{pidParameters.kp = it.toDouble()}, enabled = false)
+                    TextField(
+                        String.format("%.2f",pidParameters.kp),
+                        { pidParameters.kp = it.toDouble() },
+                        Modifier.width(100.dp),
+                        enabled = false
+                    )
                     Slider(
                         pidParameters.kp.toFloat(),
-                        { pidParameters.kp = it.toDouble()},
-                        valueRange = 0f..10f,
+                        { pidParameters.kp = it.toDouble() },
+                        valueRange = 0f..20f,
                         steps = 100
                     )
                 }
                 Row {
                     Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
-                    TextField(pidParameters.ki.toString(),{pidParameters.ki = it.toDouble()}, enabled = false)
+                    TextField(
+                        String.format("%.2f",pidParameters.ki),
+                        { pidParameters.ki = it.toDouble() },
+                        Modifier.width(100.dp),
+                        enabled = false
+                    )
 
                     Slider(
                         pidParameters.ki.toFloat(),
-                        { pidParameters.ki = it.toDouble()},
-                        valueRange = -5f..5f,
+                        { pidParameters.ki = it.toDouble() },
+                        valueRange = -10f..10f,
                         steps = 100
                     )
                 }
                 Row {
                     Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
-                    TextField(pidParameters.kd.toString(),{pidParameters.kd = it.toDouble()}, enabled = false)
+                    TextField(
+                        String.format("%.2f",pidParameters.kd),
+                        { pidParameters.kd = it.toDouble() },
+                        Modifier.width(100.dp),
+                        enabled = false
+                    )
 
                     Slider(
                         pidParameters.kd.toFloat(),
-                        { pidParameters.kd = it.toDouble()},
-                        valueRange = -5f..5f,
+                        { pidParameters.kd = it.toDouble() },
+                        valueRange = -10f..10f,
+                        steps = 100
+                    )
+                }
+
+                Row {
+                    Text("dt:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
+                    TextField(
+                        pidParameters.timeStep.toString(DurationUnit.MILLISECONDS),
+                        { pidParameters.timeStep = it.toDouble().milliseconds },
+                        Modifier.width(100.dp),
+                        enabled = false
+                    )
+
+                    Slider(
+                        pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(),
+                        { pidParameters.timeStep = it.toDouble().milliseconds },
+                        valueRange = 0f..100f,
                         steps = 100
                     )
                 }
@@ -187,7 +219,7 @@ fun main() = application {
                             kd = -0.1
                             timeStep = 0.005.seconds
                         }
-                    }){
+                    }) {
                         Text("Reset")
                     }
                 }

From cf129b6242ac53395a972dd5e9a859d64e186c13 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 12 Dec 2023 09:59:52 +0300
Subject: [PATCH 040/125] Migrate to DF 0.7

---
 build.gradle.kts                              |  6 ++---
 .../space/kscience/controls/api/Device.kt     |  8 +++----
 .../space/kscience/controls/api/Socket.kt     |  3 +--
 .../kscience/controls/misc/ValueWithTime.kt   | 22 +++++++++++--------
 .../space/kscience/controls/misc/timeIO.kt    | 16 ++++++++------
 .../space/kscience/controls/ports/Port.kt     |  5 +++--
 .../space/kscience/controls/ports/phrases.kt  |  6 ++---
 .../kscience/controls/spec/DeviceSpec.kt      |  7 +++++-
 .../space/kscience/controls/spec/misc.kt      |  6 ++++-
 .../controls/spec/propertySpecDelegates.kt    | 10 ++++-----
 demo/constructor/build.gradle.kts             |  2 +-
 gradle.properties                             |  2 +-
 magix/README.md                               |  4 ++++
 13 files changed, 57 insertions(+), 40 deletions(-)
 create mode 100644 magix/README.md

diff --git a/build.gradle.kts b/build.gradle.kts
index 2b3f6b3..fd2f3d8 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -5,15 +5,15 @@ plugins {
     id("space.kscience.gradle.project")
 }
 
-val dataforgeVersion: String by extra("0.6.2")
-val visionforgeVersion by extra("0.3.0-dev-14")
+val dataforgeVersion: String by extra("0.7.1")
+val visionforgeVersion by extra("0.3.0-RC")
 val ktorVersion: String by extra(space.kscience.gradle.KScienceVersions.ktorVersion)
 val rsocketVersion by extra("0.15.4")
 val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.3.0-dev-2"
+    version = "0.3.0-dev-3"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index f967c89..4b78f24 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -11,8 +11,8 @@ import space.kscience.dataforge.context.info
 import space.kscience.dataforge.context.logger
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.misc.DFExperimental
-import space.kscience.dataforge.misc.Type
-import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.misc.DfType
+import space.kscience.dataforge.names.parseAsName
 
 /**
  * A lifecycle state of a device
@@ -46,7 +46,7 @@ public enum class DeviceLifecycleState {
  *  [Device] is a supervisor scope encompassing all operations on a device.
  *  When canceled, cancels all running processes.
  */
-@Type(DEVICE_TARGET)
+@DfType(DEVICE_TARGET)
 public interface Device : ContextAware, CoroutineScope {
 
     /**
@@ -144,7 +144,7 @@ public suspend fun Device.requestProperty(propertyName: String): Meta = if (this
  */
 public fun CachingDevice.getAllProperties(): Meta = Meta {
     for (descriptor in propertyDescriptors) {
-        setMeta(Name.parse(descriptor.name), getProperty(descriptor.name))
+        set(descriptor.name.parseAsName(), getProperty(descriptor.name))
     }
 }
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt
index 02598ba..700db2c 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt
@@ -1,6 +1,5 @@
 package space.kscience.controls.api
 
-import io.ktor.utils.io.core.Closeable
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.Flow
@@ -9,7 +8,7 @@ import kotlinx.coroutines.launch
 /**
  * A generic bidirectional sender/receiver object
  */
-public interface Socket<T> : Closeable {
+public interface Socket<T> : AutoCloseable {
     /**
      * Send an object to the socket
      */
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt
index ce651e4..0bf07d3 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt
@@ -1,8 +1,8 @@
 package space.kscience.controls.misc
 
-import io.ktor.utils.io.core.Input
-import io.ktor.utils.io.core.Output
 import kotlinx.datetime.Instant
+import kotlinx.io.Sink
+import kotlinx.io.Source
 import space.kscience.dataforge.io.IOFormat
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.get
@@ -38,15 +38,16 @@ public data class ValueWithTime<T>(val value: T, val time: Instant) {
 private class ValueWithTimeIOFormat<T>(val valueFormat: IOFormat<T>) : IOFormat<ValueWithTime<T>> {
     override val type: KType get() = typeOf<ValueWithTime<T>>()
 
-    override fun readObject(input: Input): ValueWithTime<T> {
-        val timestamp = InstantIOFormat.readObject(input)
-        val value = valueFormat.readObject(input)
+
+    override fun readFrom(source: Source): ValueWithTime<T> {
+        val timestamp = InstantIOFormat.readFrom(source)
+        val value = valueFormat.readFrom(source)
         return ValueWithTime(value, timestamp)
     }
 
-    override fun writeObject(output: Output, obj: ValueWithTime<T>) {
-        InstantIOFormat.writeObject(output, obj.time)
-        valueFormat.writeObject(output, obj.value)
+    override fun writeTo(sink: Sink, obj: ValueWithTime<T>) {
+        InstantIOFormat.writeTo(sink, obj.time)
+        valueFormat.writeTo(sink, obj.value)
     }
 
 }
@@ -54,7 +55,10 @@ private class ValueWithTimeIOFormat<T>(val valueFormat: IOFormat<T>) : IOFormat<
 private class ValueWithTimeMetaConverter<T>(
     val valueConverter: MetaConverter<T>,
 ) : MetaConverter<ValueWithTime<T>> {
-    override fun metaToObject(
+
+    override val type: KType = typeOf<ValueWithTime<T>>()
+
+    override fun metaToObjectOrNull(
         meta: Meta,
     ): ValueWithTime<T>? = valueConverter.metaToObject(meta[ValueWithTime.META_VALUE_KEY] ?: Meta.EMPTY)?.let {
         ValueWithTime(it, meta[ValueWithTime.META_TIME_KEY]?.instant ?: Instant.DISTANT_PAST)
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt
index aef7401..8686c67 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt
@@ -1,7 +1,9 @@
 package space.kscience.controls.misc
 
-import io.ktor.utils.io.core.*
+
 import kotlinx.datetime.Instant
+import kotlinx.io.Sink
+import kotlinx.io.Source
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.io.IOFormat
 import space.kscience.dataforge.io.IOFormatFactory
@@ -23,14 +25,14 @@ public object InstantIOFormat : IOFormat<Instant>, IOFormatFactory<Instant> {
 
     override val type: KType get() = typeOf<Instant>()
 
-    override fun writeObject(output: Output, obj: Instant) {
-        output.writeLong(obj.epochSeconds)
-        output.writeInt(obj.nanosecondsOfSecond)
+    override fun writeTo(sink: Sink, obj: Instant) {
+        sink.writeLong(obj.epochSeconds)
+        sink.writeInt(obj.nanosecondsOfSecond)
     }
 
-    override fun readObject(input: Input): Instant {
-        val seconds = input.readLong()
-        val nanoseconds = input.readInt()
+    override fun readFrom(source: Source): Instant {
+        val seconds = source.readLong()
+        val nanoseconds = source.readInt()
         return Instant.fromEpochSeconds(seconds, nanoseconds)
     }
 }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
index 1f07251..83044ec 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
@@ -6,7 +6,8 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.receiveAsFlow
 import space.kscience.controls.api.Socket
 import space.kscience.dataforge.context.*
-import space.kscience.dataforge.misc.Type
+import space.kscience.dataforge.misc.DfType
+
 import kotlin.coroutines.CoroutineContext
 
 /**
@@ -17,7 +18,7 @@ public interface Port : ContextAware, Socket<ByteArray>
 /**
  * A specialized factory for [Port]
  */
-@Type(PortFactory.TYPE)
+@DfType(PortFactory.TYPE)
 public interface PortFactory : Factory<Port> {
     public val type: String
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
index 02a0058..0f2b9bd 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
@@ -1,12 +1,10 @@
 package space.kscience.controls.ports
 
-import io.ktor.utils.io.core.BytePacketBuilder
-import io.ktor.utils.io.core.readBytes
-import io.ktor.utils.io.core.reset
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.transform
+import kotlinx.io.Buffer
 
 /**
  * Transform byte fragments into complete phrases using given delimiter. Not thread safe.
@@ -14,7 +12,7 @@ import kotlinx.coroutines.flow.transform
 public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray> {
     require(delimiter.isNotEmpty()) { "Delimiter must not be empty" }
 
-    val output = BytePacketBuilder()
+    val output = Buffer()
     var matcherPosition = 0
 
     onCompletion {
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index cd0d7cc..4091921 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -9,9 +9,14 @@ import space.kscience.dataforge.meta.transformations.MetaConverter
 import kotlin.properties.PropertyDelegateProvider
 import kotlin.properties.ReadOnlyProperty
 import kotlin.reflect.KProperty
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
 
 public object UnitMetaConverter : MetaConverter<Unit> {
-    override fun metaToObject(meta: Meta): Unit = Unit
+
+    override val type: KType = typeOf<Unit>()
+
+    override fun metaToObjectOrNull(meta: Meta): Unit = Unit
 
     override fun objectToMeta(obj: Unit): Meta = Meta.EMPTY
 }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt
index e264212..ee14237 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt
@@ -2,6 +2,8 @@ package space.kscience.controls.spec
 
 import space.kscience.dataforge.meta.*
 import space.kscience.dataforge.meta.transformations.MetaConverter
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
 import kotlin.time.Duration
 import kotlin.time.DurationUnit
 import kotlin.time.toDuration
@@ -10,7 +12,9 @@ public fun Double.asMeta(): Meta = Meta(asValue())
 
 //TODO to be moved to DF
 public object DurationConverter : MetaConverter<Duration> {
-    override fun metaToObject(meta: Meta): Duration = meta.value?.double?.toDuration(DurationUnit.SECONDS)
+    override val type: KType = typeOf<Duration>()
+
+    override fun metaToObjectOrNull(meta: Meta): Duration = meta.value?.double?.toDuration(DurationUnit.SECONDS)
         ?: run {
             val unit: DurationUnit = meta["unit"].enum<DurationUnit>() ?: DurationUnit.SECONDS
             val value = meta[Meta.VALUE_KEY].double ?: error("No value present for Duration")
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
index 6f599a8..231891c 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
@@ -68,7 +68,7 @@ public fun <D : Device> DeviceSpec<D>.booleanProperty(
     MetaConverter.boolean,
     {
         metaDescriptor {
-            type(ValueType.BOOLEAN)
+            valueType(ValueType.BOOLEAN)
         }
         descriptorBuilder()
     },
@@ -80,7 +80,7 @@ private inline fun numberDescriptor(
     crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {}
 ): PropertyDescriptor.() -> Unit = {
     metaDescriptor {
-        type(ValueType.NUMBER)
+        valueType(ValueType.NUMBER)
     }
     descriptorBuilder()
 }
@@ -115,7 +115,7 @@ public fun <D : Device> DeviceSpec<D>.stringProperty(
     MetaConverter.string,
     {
         metaDescriptor {
-            type(ValueType.STRING)
+            valueType(ValueType.STRING)
         }
         descriptorBuilder()
     },
@@ -131,7 +131,7 @@ public fun <D : Device> DeviceSpec<D>.metaProperty(
     MetaConverter.meta,
     {
         metaDescriptor {
-            type(ValueType.STRING)
+            valueType(ValueType.STRING)
         }
         descriptorBuilder()
     },
@@ -151,7 +151,7 @@ public fun <D : Device> DeviceSpec<D>.booleanProperty(
         MetaConverter.boolean,
         {
             metaDescriptor {
-                type(ValueType.BOOLEAN)
+                valueType(ValueType.BOOLEAN)
             }
             descriptorBuilder()
         },
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
index 9026247..470533f 100644
--- a/demo/constructor/build.gradle.kts
+++ b/demo/constructor/build.gradle.kts
@@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
 
 plugins {
     id("space.kscience.gradle.mpp")
-    id("org.jetbrains.compose") version "1.5.10"
+    id("org.jetbrains.compose") version "1.5.11"
 }
 
 kscience {
diff --git a/gradle.properties b/gradle.properties
index ea080f4..717cec4 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -7,4 +7,4 @@ org.gradle.parallel=true
 org.gradle.configureondemand=true
 org.gradle.jvmargs=-Xmx4096m
 
-toolsVersion=0.15.0-kotlin-1.9.20
\ No newline at end of file
+toolsVersion=0.15.2-kotlin-1.9.21
\ No newline at end of file
diff --git a/magix/README.md b/magix/README.md
new file mode 100644
index 0000000..de1d7a3
--- /dev/null
+++ b/magix/README.md
@@ -0,0 +1,4 @@
+# Module magix
+
+
+

From fb03fcc982b92be102a8047137aa41ea1d11c12b Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 13 Dec 2023 12:29:06 +0300
Subject: [PATCH 041/125] Finish migration to kotlinx-io

---
 .../controls/constructor/DeviceState.kt       |  2 +-
 controls-core/build.gradle.kts                |  6 +++-
 .../space/kscience/controls/ports/Port.kt     |  3 +-
 .../kscience/controls/ports/ioExtensions.kt   |  5 +++
 .../space/kscience/controls/ports/phrases.kt  |  6 ++--
 .../kscience/controls/ports/ChannelPort.kt    | 18 +++++-----
 .../kscience/controls/ports/PortIOTest.kt     | 35 +++++++++++++++----
 .../controls/modbus/DeviceProcessImage.kt     | 30 ++++++++--------
 .../kscience/controls/modbus/ModbusDevice.kt  | 30 +++++++++-------
 .../controls/opcua/client/MetaBsdParser.kt    |  2 +-
 .../controls/opcua/client/OpcUaDevice.kt      |  2 +-
 .../jsMain/kotlin/ControlsVisionPlugin.js.kt  |  3 +-
 .../kscience/controls/demo/DemoDevice.kt      |  2 +-
 .../kscience/controls/demo/car/VirtualCar.kt  | 10 ++++--
 .../mks/NullableStringMetaConverter.kt        | 10 ------
 magix/magix-api/build.gradle.kts              |  4 +++
 16 files changed, 104 insertions(+), 64 deletions(-)
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt
 delete mode 100644 demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/NullableStringMetaConverter.kt

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index ed7027a..a9cff51 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -40,7 +40,7 @@ public interface MutableDeviceState<T> : DeviceState<T> {
 public var <T : Any> MutableDeviceState<T>.valueAsMeta: Meta
     get() = converter.objectToMeta(value)
     set(arg) {
-        value = converter.metaToObject(arg) ?: error("Conversion for meta $arg to property type with $converter failed")
+        value = converter.metaToObject(arg)
     }
 
 /**
diff --git a/controls-core/build.gradle.kts b/controls-core/build.gradle.kts
index bbe32eb..859b067 100644
--- a/controls-core/build.gradle.kts
+++ b/controls-core/build.gradle.kts
@@ -20,10 +20,14 @@ kscience {
         json()
     }
     useContextReceivers()
-    dependencies {
+    commonMain {
         api("space.kscience:dataforge-io:$dataforgeVersion")
         api(spclibs.kotlinx.datetime)
     }
+
+    jvmTest{
+        implementation(spclibs.logback.classic)
+    }
 }
 
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
index 83044ec..b17de92 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
@@ -38,7 +38,7 @@ public abstract class AbstractPort(
     protected val scope: CoroutineScope = CoroutineScope(coroutineContext + SupervisorJob(coroutineContext[Job]))
 
     private val outgoing = Channel<ByteArray>(100)
-    private val incoming = Channel<ByteArray>(Channel.CONFLATED)
+    private val incoming = Channel<ByteArray>(100)
 
     init {
         scope.coroutineContext[Job]?.invokeOnCompletion {
@@ -88,7 +88,6 @@ public abstract class AbstractPort(
     override fun close() {
         outgoing.close()
         incoming.close()
-        sendJob.cancel()
         scope.cancel()
     }
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt
new file mode 100644
index 0000000..dbe7256
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt
@@ -0,0 +1,5 @@
+package space.kscience.controls.ports
+
+import space.kscience.dataforge.io.Binary
+
+public fun Binary.readShort(position: Int): Short = read(position) { readShort() }
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
index 0f2b9bd..732e5d4 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
@@ -5,6 +5,7 @@ import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onCompletion
 import kotlinx.coroutines.flow.transform
 import kotlinx.io.Buffer
+import kotlinx.io.readByteArray
 
 /**
  * Transform byte fragments into complete phrases using given delimiter. Not thread safe.
@@ -27,9 +28,8 @@ public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray>
                 matcherPosition++
                 if (matcherPosition == delimiter.size) {
                     //full match achieved, sending result
-                    val bytes = output.build()
-                    emit(bytes.readBytes())
-                    output.reset()
+                    emit(output.readByteArray())
+                    output.clear()
                     matcherPosition = 0
                 }
             } else if (matcherPosition > 0) {
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
index d7983f2..1e187b6 100644
--- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
@@ -8,6 +8,7 @@ import space.kscience.dataforge.context.logger
 import space.kscience.dataforge.meta.*
 import java.net.InetSocketAddress
 import java.nio.ByteBuffer
+import java.nio.channels.AsynchronousCloseException
 import java.nio.channels.ByteChannel
 import java.nio.channels.DatagramChannel
 import java.nio.channels.SocketChannel
@@ -30,7 +31,7 @@ public class ChannelPort(
     channelBuilder: suspend () -> ByteChannel,
 ) : AbstractPort(context, coroutineContext), AutoCloseable {
 
-    private val futureChannel: Deferred<ByteChannel> = this.scope.async(Dispatchers.IO) {
+    private val futureChannel: Deferred<ByteChannel> = scope.async(Dispatchers.IO) {
         channelBuilder()
     }
 
@@ -39,10 +40,10 @@ public class ChannelPort(
      */
     public val startJob: Job get() = futureChannel
 
-    private val listenerJob = this.scope.launch(Dispatchers.IO) {
+    private val listenerJob = scope.launch(Dispatchers.IO) {
         val channel = futureChannel.await()
         val buffer = ByteBuffer.allocate(1024)
-        while (isActive) {
+        while (isActive && channel.isOpen) {
             try {
                 val num = channel.read(buffer)
                 if (num > 0) {
@@ -50,8 +51,12 @@ public class ChannelPort(
                 }
                 if (num < 0) cancel("The input channel is exhausted")
             } catch (ex: Exception) {
-                logger.error(ex) { "Channel read error" }
-                delay(1000)
+                if(ex is AsynchronousCloseException){
+                    logger.info { "Channel closed" }
+                } else {
+                    logger.error(ex) { "Channel read error, retrying in 1 second" }
+                    delay(1000)
+                }
             }
         }
     }
@@ -62,11 +67,8 @@ public class ChannelPort(
 
     @OptIn(ExperimentalCoroutinesApi::class)
     override fun close() {
-        listenerJob.cancel()
         if (futureChannel.isCompleted) {
             futureChannel.getCompleted().close()
-        } else {
-            futureChannel.cancel()
         }
         super.close()
     }
diff --git a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt
index bdf6891..3f92500 100644
--- a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt
+++ b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt
@@ -1,25 +1,46 @@
 package space.kscience.controls.ports
 
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.toList
+import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runTest
 import org.junit.jupiter.api.Test
+import space.kscience.dataforge.context.Global
 import kotlin.test.assertEquals
 
 
-internal class PortIOTest{
+internal class PortIOTest {
 
     @Test
-    fun testDelimiteredByteArrayFlow(){
-        val flow = flowOf("bb?b","ddd?",":defgb?:ddf","34fb?:--").map { it.encodeToByteArray() }
+    fun testDelimiteredByteArrayFlow() {
+        val flow = flowOf("bb?b", "ddd?", ":defgb?:ddf", "34fb?:--").map { it.encodeToByteArray() }
         val chunked = flow.withDelimiter("?:".encodeToByteArray())
         runBlocking {
             val result = chunked.toList()
             assertEquals(3, result.size)
-            assertEquals("bb?bddd?:",result[0].decodeToString())
+            assertEquals("bb?bddd?:", result[0].decodeToString())
             assertEquals("defgb?:", result[1].decodeToString())
             assertEquals("ddf34fb?:", result[2].decodeToString())
         }
     }
+
+    @Test
+    fun testUdpCommunication() = runTest {
+        val receiver = UdpPort.open(Global, "localhost", 8811, localPort = 8812)
+        val sender = UdpPort.open(Global, "localhost", 8812, localPort = 8811)
+
+        repeat(10) {
+            sender.send("Line number $it\n")
+        }
+
+        val res = receiver
+            .receiving()
+            .onEach { println("ARRAY: ${it.decodeToString()}") }
+            .withStringDelimiter("\n")
+            .onEach { println("LINE: $it") }
+            .take(10).toList()
+
+        assertEquals("Line number 3", res[3].trim())
+        receiver.close()
+        sender.close()
+    }
 }
\ No newline at end of file
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
index 8f4a2b4..3b3f91b 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
@@ -1,12 +1,14 @@
 package space.kscience.controls.modbus
 
 import com.ghgande.j2mod.modbus.procimg.*
-import io.ktor.utils.io.core.*
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
+import kotlinx.io.Buffer
 import space.kscience.controls.api.Device
+import space.kscience.controls.ports.readShort
 import space.kscience.controls.spec.*
+import space.kscience.dataforge.io.Binary
 
 
 public class DeviceProcessImageBuilder<D : Device> internal constructor(
@@ -106,11 +108,11 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
         }
 
         device.useProperty(propertySpec) { value ->
-            val packet = buildPacket {
-                key.format.writeObject(this, value)
-            }.readByteBuffer()
+            val binary = Binary {
+                key.format.writeTo(this, value)
+            }
             registers.forEachIndexed { index, register ->
-                register.setValue(packet.getShort(index * 2))
+                register.setValue(binary.readShort(index * 2))
             }
         }
     }
@@ -118,7 +120,7 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
     /**
      * Trigger [block] if one of register changes.
      */
-    private fun List<ObservableRegister>.onChange(block: suspend (ByteReadPacket) -> Unit) {
+    private fun List<ObservableRegister>.onChange(block: suspend (Buffer) -> Unit) {
         var ready = false
 
         forEach { register ->
@@ -128,7 +130,7 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
         }
 
         device.launch {
-            val builder = BytePacketBuilder()
+            val builder = Buffer()
             while (isActive) {
                 delay(1)
                 if (ready) {
@@ -136,7 +138,7 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
                         forEach { value ->
                             writeShort(value.toShort())
                         }
-                    }.build()
+                    }
                     block(packet)
                     ready = false
                 }
@@ -154,15 +156,15 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
         }
 
         registers.onChange { packet ->
-            device.write(propertySpec, key.format.readObject(packet))
+            device.write(propertySpec, key.format.readFrom(packet))
         }
 
         device.useProperty(propertySpec) { value ->
-            val packet = buildPacket {
-                key.format.writeObject(this, value)
-            }.readByteBuffer()
+            val binary = Binary {
+                key.format.writeTo(this, value)
+            }
             registers.forEachIndexed { index, observableRegister ->
-                observableRegister.setValue(packet.getShort(index * 2))
+                observableRegister.setValue(binary.readShort(index * 2))
             }
         }
     }
@@ -212,7 +214,7 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
 
         registers.onChange { packet ->
             device.launch {
-                device.action(key.format.readObject(packet))
+                device.action(key.format.readFrom(packet))
             }
         }
 
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
index 7297616..2585302 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
+++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
@@ -5,11 +5,10 @@ import com.ghgande.j2mod.modbus.procimg.InputRegister
 import com.ghgande.j2mod.modbus.procimg.Register
 import com.ghgande.j2mod.modbus.procimg.SimpleInputRegister
 import com.ghgande.j2mod.modbus.util.BitVector
-import io.ktor.utils.io.core.ByteReadPacket
-import io.ktor.utils.io.core.buildPacket
-import io.ktor.utils.io.core.readByteBuffer
-import io.ktor.utils.io.core.writeShort
+import kotlinx.io.Buffer
 import space.kscience.controls.api.Device
+import space.kscience.dataforge.io.Buffer
+import space.kscience.dataforge.io.ByteArray
 import java.nio.ByteBuffer
 import kotlin.properties.ReadWriteProperty
 import kotlin.reflect.KProperty
@@ -45,7 +44,7 @@ public interface ModbusDevice : Device {
 
     public operator fun <T> ModbusRegistryKey.InputRange<T>.getValue(thisRef: Any?, property: KProperty<*>): T {
         val packet = readInputRegistersToPacket(address, count)
-        return format.readObject(packet)
+        return format.readFrom(packet)
     }
 
 
@@ -62,7 +61,7 @@ public interface ModbusDevice : Device {
 
     public operator fun <T> ModbusRegistryKey.HoldingRange<T>.getValue(thisRef: Any?, property: KProperty<*>): T {
         val packet = readHoldingRegistersToPacket(address, count)
-        return format.readObject(packet)
+        return format.readFrom(packet)
     }
 
     public operator fun <T> ModbusRegistryKey.HoldingRange<T>.setValue(
@@ -70,9 +69,9 @@ public interface ModbusDevice : Device {
         property: KProperty<*>,
         value: T,
     ) {
-        val buffer = buildPacket {
-            format.writeObject(this, value)
-        }.readByteBuffer()
+        val buffer = ByteArray {
+            format.writeTo(this, value)
+        }
         writeHoldingRegisters(address, buffer)
     }
 
@@ -122,7 +121,7 @@ private fun Array<out InputRegister>.toBuffer(): ByteBuffer {
     return buffer
 }
 
-private fun Array<out InputRegister>.toPacket(): ByteReadPacket = buildPacket {
+private fun Array<out InputRegister>.toPacket(): Buffer = Buffer {
     forEach { value ->
         writeShort(value.toShort())
     }
@@ -131,7 +130,7 @@ private fun Array<out InputRegister>.toPacket(): ByteReadPacket = buildPacket {
 public fun ModbusDevice.readInputRegistersToBuffer(address: Int, count: Int): ByteBuffer =
     master.readInputRegisters(unitId, address, count).toBuffer()
 
-public fun ModbusDevice.readInputRegistersToPacket(address: Int, count: Int): ByteReadPacket =
+public fun ModbusDevice.readInputRegistersToPacket(address: Int, count: Int): Buffer =
     master.readInputRegisters(unitId, address, count).toPacket()
 
 public fun ModbusDevice.readDoubleInput(address: Int): Double =
@@ -151,7 +150,7 @@ public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List<Reg
 public fun ModbusDevice.readHoldingRegistersToBuffer(address: Int, count: Int): ByteBuffer =
     master.readMultipleRegisters(unitId, address, count).toBuffer()
 
-public fun ModbusDevice.readHoldingRegistersToPacket(address: Int, count: Int): ByteReadPacket =
+public fun ModbusDevice.readHoldingRegistersToPacket(address: Int, count: Int): Buffer =
     master.readMultipleRegisters(unitId, address, count).toPacket()
 
 public fun ModbusDevice.readDoubleRegister(address: Int): Double =
@@ -183,6 +182,13 @@ public fun ModbusDevice.writeHoldingRegisters(address: Int, buffer: ByteBuffer):
     return writeHoldingRegisters(address, array)
 }
 
+public fun ModbusDevice.writeHoldingRegisters(address: Int, byteArray: ByteArray): Int {
+    val buffer = ByteBuffer.wrap(byteArray)
+    val array: ShortArray = ShortArray(buffer.limit().floorDiv(2)) { buffer.getShort(it * 2) }
+
+    return writeHoldingRegisters(address, array)
+}
+
 public fun ModbusDevice.modbusRegister(
     address: Int,
 ): ReadWriteProperty<ModbusDevice, Short> = object : ReadWriteProperty<ModbusDevice, Short> {
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
index b760184..27330bb 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
@@ -107,7 +107,7 @@ internal class MetaStructureCodec(
 
     override fun createStructure(name: String, members: LinkedHashMap<String, Meta>): Meta = Meta {
         members.forEach { (property: String, value: Meta?) ->
-            setMeta(Name.parse(property), value)
+            set(Name.parse(property), value)
         }
     }
 
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt
index dd83e58..51e6032 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt
@@ -43,7 +43,7 @@ public suspend inline fun <reified T: Any> OpcUaDevice.readOpcWithTime(
         else -> error("Incompatible OPC property value $content")
     }
 
-    val res: T = converter.metaToObject(meta) ?: error("Meta $meta could not be converted to ${T::class}")
+    val res: T = converter.metaToObject(meta)
     return res to time
 }
 
diff --git a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
index 0d928ac..b34cc44 100644
--- a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
+++ b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
@@ -9,6 +9,7 @@ import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.names.Name
 import space.kscience.visionforge.ElementVisionRenderer
 import space.kscience.visionforge.Vision
+import space.kscience.visionforge.VisionClient
 import space.kscience.visionforge.VisionPlugin
 
 public actual class ControlVisionPlugin : VisionPlugin(), ElementVisionRenderer {
@@ -20,7 +21,7 @@ public actual class ControlVisionPlugin : VisionPlugin(), ElementVisionRenderer
         TODO("Not yet implemented")
     }
 
-    override fun render(element: Element, name: Name, vision: Vision, meta: Meta) {
+    override fun render(element: Element, client: VisionClient, name: Name, vision: Vision, meta: Meta) {
         TODO("Not yet implemented")
     }
 
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
index eaa1bff..067792c 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
@@ -42,7 +42,7 @@ class DemoDevice(context: Context, meta: Meta) : DeviceBySpec<IDemoDevice>(Compa
         // register virtual properties based on actual object state
         val timeScale by mutableProperty(MetaConverter.double, IDemoDevice::timeScaleState) {
             metaDescriptor {
-                type(ValueType.NUMBER)
+                valueType(ValueType.NUMBER)
             }
             description = "Real to virtual time scale"
         }
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
index 8ccb8a4..05bb2f0 100644
--- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
+++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
@@ -17,6 +17,8 @@ import space.kscience.dataforge.meta.double
 import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.meta.transformations.MetaConverter
 import kotlin.math.pow
+import kotlin.reflect.KType
+import kotlin.reflect.typeOf
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.ExperimentalTime
@@ -28,7 +30,10 @@ data class Vector2D(var x: Double = 0.0, var y: Double = 0.0) : MetaRepr {
     operator fun div(arg: Double): Vector2D = Vector2D(x / arg, y / arg)
 
     companion object CoordinatesMetaConverter : MetaConverter<Vector2D> {
-        override fun metaToObject(meta: Meta): Vector2D = Vector2D(
+
+        override val type: KType = typeOf<Vector2D>()
+
+        override fun metaToObjectOrNull(meta: Meta): Vector2D = Vector2D(
             meta["x"].double ?: 0.0,
             meta["y"].double ?: 0.0
         )
@@ -40,7 +45,8 @@ data class Vector2D(var x: Double = 0.0, var y: Double = 0.0) : MetaRepr {
     }
 }
 
-open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec<VirtualCar>(IVirtualCar, context, meta), IVirtualCar {
+open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec<VirtualCar>(IVirtualCar, context, meta),
+    IVirtualCar {
     private val clock = context.clock
 
     private val timeScale = 1e-3
diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/NullableStringMetaConverter.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/NullableStringMetaConverter.kt
deleted file mode 100644
index 40c20ed..0000000
--- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/NullableStringMetaConverter.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package center.sciprog.devices.mks
-
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.string
-import space.kscience.dataforge.meta.transformations.MetaConverter
-
-object NullableStringMetaConverter : MetaConverter<String?> {
-    override fun metaToObject(meta: Meta): String? = meta.string
-    override fun objectToMeta(obj: String?): Meta = Meta {}
-}
\ No newline at end of file
diff --git a/magix/magix-api/build.gradle.kts b/magix/magix-api/build.gradle.kts
index 159989d..68151c3 100644
--- a/magix/magix-api/build.gradle.kts
+++ b/magix/magix-api/build.gradle.kts
@@ -17,6 +17,10 @@ kscience {
     useSerialization{
         json()
     }
+
+    commonMain{
+        implementation(spclibs.atomicfu)
+    }
 }
 
 readme{

From 606c2cf5b14401648eef6d36a44711d1ddbec8bc Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 13 Dec 2023 14:50:56 +0300
Subject: [PATCH 042/125] Finish migration to kotlinx-io

---
 .../jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
index 1e187b6..ec50d47 100644
--- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
@@ -52,7 +52,7 @@ public class ChannelPort(
                 if (num < 0) cancel("The input channel is exhausted")
             } catch (ex: Exception) {
                 if(ex is AsynchronousCloseException){
-                    logger.info { "Channel closed" }
+                    logger.info { "Channel $channel closed" }
                 } else {
                     logger.error(ex) { "Channel read error, retrying in 1 second" }
                     delay(1000)

From a12cf440e8a7bd686f2ae69d6f526f3863c61d09 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 13 Dec 2023 20:20:03 +0300
Subject: [PATCH 043/125] Finish migration to kotlinx-io

---
 build.gradle.kts                                 |  2 +-
 .../space/kscience/controls/ports/ChannelPort.kt |  6 +++---
 .../space/kscience/controls/ports/PortIOTest.kt  | 16 ++++++++++------
 3 files changed, 14 insertions(+), 10 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index fd2f3d8..d6ab9cf 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.3.0-dev-3"
+    version = "0.3.0-dev-4"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
index ec50d47..f450607 100644
--- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
@@ -51,7 +51,7 @@ public class ChannelPort(
                 }
                 if (num < 0) cancel("The input channel is exhausted")
             } catch (ex: Exception) {
-                if(ex is AsynchronousCloseException){
+                if (ex is AsynchronousCloseException) {
                     logger.info { "Channel $channel closed" }
                 } else {
                     logger.error(ex) { "Channel read error, retrying in 1 second" }
@@ -108,7 +108,7 @@ public object UdpPort : PortFactory {
     /**
      * Connect a datagram channel to a remote host/port. If [localPort] is provided, it is used to bind local port for receiving messages.
      */
-    public fun open(
+    public fun openChannel(
         context: Context,
         remoteHost: String,
         remotePort: Int,
@@ -130,6 +130,6 @@ public object UdpPort : PortFactory {
         val remotePort by meta.number { error("Remote port is not specified") }
         val localHost: String? by meta.string()
         val localPort: Int? by meta.int()
-        return open(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost")
+        return openChannel(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost")
     }
 }
\ No newline at end of file
diff --git a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt
index 3f92500..daa6173 100644
--- a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt
+++ b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt
@@ -1,6 +1,10 @@
 package space.kscience.controls.ports
 
-import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.toList
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.test.runTest
 import org.junit.jupiter.api.Test
@@ -25,19 +29,19 @@ internal class PortIOTest {
 
     @Test
     fun testUdpCommunication() = runTest {
-        val receiver = UdpPort.open(Global, "localhost", 8811, localPort = 8812)
-        val sender = UdpPort.open(Global, "localhost", 8812, localPort = 8811)
+        val receiver = UdpPort.openChannel(Global, "localhost", 8811, localPort = 8812)
+        val sender = UdpPort.openChannel(Global, "localhost", 8812, localPort = 8811)
 
+        delay(30)
         repeat(10) {
             sender.send("Line number $it\n")
         }
 
         val res = receiver
             .receiving()
-            .onEach { println("ARRAY: ${it.decodeToString()}") }
             .withStringDelimiter("\n")
-            .onEach { println("LINE: $it") }
-            .take(10).toList()
+            .take(10)
+            .toList()
 
         assertEquals("Line number 3", res[3].trim())
         receiver.close()

From 701ea8cf57928f58c2c8bc9276b7fe80b5b73474 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 15 Dec 2023 16:55:56 +0300
Subject: [PATCH 044/125] Minor fixes to port implementations

---
 .../kscience/controls/ports/UdpSocketPort.kt  | 55 +++++++++++++++++++
 .../kscience/controls/ports/KtorTcpPort.kt    |  7 ++-
 .../kscience/controls/ports/KtorUdpPort.kt    | 30 +++++-----
 controls-serial/build.gradle.kts              |  2 +-
 .../controls/serial/JSerialCommPort.kt        |  2 +-
 5 files changed, 79 insertions(+), 17 deletions(-)
 create mode 100644 controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt

diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt
new file mode 100644
index 0000000..27bb0d8
--- /dev/null
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt
@@ -0,0 +1,55 @@
+package space.kscience.controls.ports
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import space.kscience.dataforge.context.Context
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * A port based on [DatagramSocket] for cases, where [ChannelPort] does not work for some reason
+ */
+public class UdpSocketPort(
+    override val context: Context,
+    private val socket: DatagramSocket,
+    coroutineContext: CoroutineContext = context.coroutineContext,
+) : AbstractPort(context, coroutineContext) {
+
+    private val listenerJob = context.launch(Dispatchers.IO) {
+        while (isActive) {
+            val buf = ByteArray(socket.receiveBufferSize)
+
+            val packet = DatagramPacket(
+                buf,
+                buf.size,
+            )
+            socket.receive(packet)
+
+            val bytes = packet.data.copyOfRange(
+                packet.offset,
+                packet.offset + packet.length
+            )
+            receive(bytes)
+        }
+    }
+
+    override fun close() {
+        listenerJob.cancel()
+    }
+
+    override fun isOpen(): Boolean = listenerJob.isActive
+
+
+    override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) {
+        val packet = DatagramPacket(
+            data,
+            data.size,
+            socket.remoteSocketAddress
+        )
+        socket.send(packet)
+    }
+
+}
\ No newline at end of file
diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
index 7f906d3..a7a698e 100644
--- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
+++ b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
@@ -1,6 +1,7 @@
 package space.kscience.controls.ports
 
 import io.ktor.network.selector.ActorSelectorManager
+import io.ktor.network.sockets.SocketOptions
 import io.ktor.network.sockets.aSocket
 import io.ktor.network.sockets.openReadChannel
 import io.ktor.network.sockets.openWriteChannel
@@ -23,12 +24,13 @@ public class KtorTcpPort internal constructor(
     public val host: String,
     public val port: Int,
     coroutineContext: CoroutineContext = context.coroutineContext,
+    socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {}
 ) : AbstractPort(context, coroutineContext), Closeable {
 
     override fun toString(): String = "port[tcp:$host:$port]"
 
     private val futureSocket = scope.async {
-        aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().connect(host, port)
+        aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().connect(host, port, socketOptions)
     }
 
     private val writeChannel = scope.async {
@@ -64,8 +66,9 @@ public class KtorTcpPort internal constructor(
             host: String,
             port: Int,
             coroutineContext: CoroutineContext = context.coroutineContext,
+            socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {}
         ): KtorTcpPort {
-            return KtorTcpPort(context, host, port, coroutineContext)
+            return KtorTcpPort(context, host, port, coroutineContext, socketOptions)
         }
 
         override fun build(context: Context, meta: Meta): Port {
diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
index 8b8446c..1f30914 100644
--- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
+++ b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
@@ -1,17 +1,12 @@
 package space.kscience.controls.ports
 
 import io.ktor.network.selector.ActorSelectorManager
-import io.ktor.network.sockets.InetSocketAddress
-import io.ktor.network.sockets.aSocket
-import io.ktor.network.sockets.openReadChannel
-import io.ktor.network.sockets.openWriteChannel
+import io.ktor.network.sockets.*
+import io.ktor.utils.io.ByteWriteChannel
 import io.ktor.utils.io.consumeEachBufferRange
 import io.ktor.utils.io.core.Closeable
 import io.ktor.utils.io.writeAvailable
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.*
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.int
@@ -26,6 +21,7 @@ public class KtorUdpPort internal constructor(
     public val localPort: Int? = null,
     public val localHost: String = "localhost",
     coroutineContext: CoroutineContext = context.coroutineContext,
+    socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {}
 ) : AbstractPort(context, coroutineContext), Closeable {
 
     override fun toString(): String = "port[udp:$remoteHost:$remotePort]"
@@ -33,11 +29,12 @@ public class KtorUdpPort internal constructor(
     private val futureSocket = scope.async {
         aSocket(ActorSelectorManager(Dispatchers.IO)).udp().connect(
             remoteAddress = InetSocketAddress(remoteHost, remotePort),
-            localAddress = localPort?.let { InetSocketAddress(localHost, localPort) }
+            localAddress = localPort?.let { InetSocketAddress(localHost, localPort) },
+            configure = socketOptions
         )
     }
 
-    private val writeChannel = scope.async {
+    private val writeChannel: Deferred<ByteWriteChannel> = scope.async {
         futureSocket.await().openWriteChannel(true)
     }
 
@@ -72,9 +69,16 @@ public class KtorUdpPort internal constructor(
             localPort: Int? = null,
             localHost: String = "localhost",
             coroutineContext: CoroutineContext = context.coroutineContext,
-        ): KtorUdpPort {
-            return KtorUdpPort(context, remoteHost, remotePort, localPort, localHost, coroutineContext)
-        }
+            socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {}
+        ): KtorUdpPort = KtorUdpPort(
+            context = context,
+            remoteHost = remoteHost,
+            remotePort = remotePort,
+            localPort = localPort,
+            localHost = localHost,
+            coroutineContext = coroutineContext,
+            socketOptions = socketOptions
+        )
 
         override fun build(context: Context, meta: Meta): Port {
             val remoteHost by meta.string { error("Remote host is not specified") }
diff --git a/controls-serial/build.gradle.kts b/controls-serial/build.gradle.kts
index a9afc41..aa950e1 100644
--- a/controls-serial/build.gradle.kts
+++ b/controls-serial/build.gradle.kts
@@ -9,7 +9,7 @@ description = "Implementation of direct serial port communication with JSerialCo
 
 dependencies{
     api(project(":controls-core"))
-    implementation("com.fazecast:jSerialComm:2.10.3")
+    implementation("com.fazecast:jSerialComm:2.10.4")
 }
 
 readme{
diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt
index 3e0601c..8a2caab 100644
--- a/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt
+++ b/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt
@@ -28,7 +28,7 @@ public class JSerialCommPort(
         override fun getListeningEvents(): Int = SerialPort.LISTENING_EVENT_DATA_AVAILABLE
 
         override fun serialEvent(event: SerialPortEvent) {
-            if (event.eventType == SerialPort.LISTENING_EVENT_DATA_AVAILABLE) {
+            if (event.eventType == SerialPort.LISTENING_EVENT_DATA_AVAILABLE && event.receivedData != null) {
                 scope.launch { receive(event.receivedData) }
             }
         }

From bec075328b3075ea4ac5c27282d0972e52580e23 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 22 Dec 2023 09:28:39 +0300
Subject: [PATCH 045/125] Make constructor device use context instead of device
 manager

---
 build.gradle.kts                              |  2 +-
 .../controls/constructor/DeviceConstructor.kt |  6 +-
 .../controls/constructor/DeviceGroup.kt       | 11 ++--
 .../space/kscience/controls/ports/phrases.kt  |  2 +
 .../controls/spec/DevicePropertySpec.kt       |  2 +-
 .../src/commonMain/kotlin/plotExtensions.kt   | 66 ++++++++++++-------
 demo/constructor/src/jvmMain/kotlin/main.kt   |  3 +-
 7 files changed, 55 insertions(+), 37 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index d6ab9cf..6c169fe 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.3.0-dev-4"
+    version = "0.3.0-dev-5"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 01d9087..1eb3b91 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -2,7 +2,7 @@ package space.kscience.controls.constructor
 
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.PropertyDescriptor
-import space.kscience.controls.manager.DeviceManager
+import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.transformations.MetaConverter
@@ -18,9 +18,9 @@ import kotlin.time.Duration
  * A base for strongly typed device constructor blocks. Has additional delegates for type-safe devices
  */
 public abstract class DeviceConstructor(
-    deviceManager: DeviceManager,
+    context: Context,
     meta: Meta,
-) : DeviceGroup(deviceManager, meta) {
+) : DeviceGroup(context, meta) {
 
     /**
      * Register a device, provided by a given [factory] and
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index 68f8301..cea9d71 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -27,7 +27,7 @@ import kotlin.coroutines.CoroutineContext
  * A mutable group of devices and properties to be used for lightweight design and simulations.
  */
 public open class DeviceGroup(
-    public val deviceManager: DeviceManager,
+    final override val context: Context,
     override val meta: Meta,
 ) : DeviceHub, CachingDevice {
 
@@ -42,9 +42,6 @@ public open class DeviceGroup(
     )
 
 
-    override final val context: Context get() = deviceManager.context
-
-
     private val sharedMessageFlow = MutableSharedFlow<DeviceMessage>()
 
     override val messageFlow: Flow<DeviceMessage>
@@ -175,7 +172,7 @@ public fun DeviceManager.registerDeviceGroup(
     meta: Meta = Meta.EMPTY,
     block: DeviceGroup.() -> Unit,
 ): DeviceGroup {
-    val group = DeviceGroup(this, meta).apply(block)
+    val group = DeviceGroup(context, meta).apply(block)
     install(name, group)
     return group
 }
@@ -194,7 +191,7 @@ private fun DeviceGroup.getOrCreateGroup(name: Name): DeviceGroup {
             when (val d = devices[token]) {
                 null -> install(
                     token,
-                    DeviceGroup(deviceManager, meta[token] ?: Meta.EMPTY)
+                    DeviceGroup(context, meta[token] ?: Meta.EMPTY)
                 )
 
                 else -> (d as? DeviceGroup) ?: error("Device $name is not a DeviceGroup")
@@ -234,7 +231,7 @@ public fun <D : Device> DeviceGroup.install(
     deviceMeta: Meta? = null,
     metaLocation: Name = name,
 ): D {
-    val newDevice = factory.build(deviceManager.context, Laminate(deviceMeta, meta[metaLocation]))
+    val newDevice = factory.build(context, Laminate(deviceMeta, meta[metaLocation]))
     install(name, newDevice)
     return newDevice
 }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
index 732e5d4..b86591f 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
@@ -9,6 +9,8 @@ import kotlinx.io.readByteArray
 
 /**
  * Transform byte fragments into complete phrases using given delimiter. Not thread safe.
+ *
+ * TODO add type wrapper for phrases
  */
 public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray> {
     require(delimiter.isNotEmpty()) { "Delimiter must not be empty" }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
index cb511ea..8e0d6de 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
@@ -81,7 +81,7 @@ public suspend fun <T, D : Device> D.read(propertySpec: DevicePropertySpec<D, T>
 public suspend fun <T, D : DeviceBase<D>> D.readOrNull(propertySpec: DevicePropertySpec<D, T>): T? =
     readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::metaToObject)
 
-public suspend fun <T, D : Device> D.request(propertySpec: DevicePropertySpec<D, T>): T? =
+public suspend fun <T, D : Device> D.request(propertySpec: DevicePropertySpec<D, T>): T =
     propertySpec.converter.metaToObject(requestProperty(propertySpec.name))
 
 /**
diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index 3640ee9..8c7b02f 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -18,6 +18,8 @@ import space.kscience.controls.api.propertyMessageFlow
 import space.kscience.controls.constructor.DeviceState
 import space.kscience.controls.manager.clock
 import space.kscience.controls.misc.ValueWithTime
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.name
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.*
 import space.kscience.plotly.Plot
@@ -28,8 +30,8 @@ import space.kscience.plotly.models.Trace
 import space.kscience.plotly.models.TraceValues
 import space.kscience.plotly.scatter
 import kotlin.time.Duration
-import kotlin.time.Duration.Companion.hours
-import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
 
 private var TraceValues.values: List<Value>
     get() = value?.list ?: emptyList()
@@ -82,6 +84,11 @@ private class TimeData(private var points: MutableList<ValueWithTime<Value>> = m
     }
 }
 
+private val defaultMaxAge get() = 10.minutes
+private val defaultMaxPoints get() = 800
+private val defaultMinPoints get() = 400
+private val defaultSampling get() = 1.seconds
+
 /**
  * Add a trace that shows a [Device] property change over time. Show only latest [maxPoints] .
  * @return a [Job] that handles the listener
@@ -90,10 +97,10 @@ public fun Plot.plotDeviceProperty(
     device: Device,
     propertyName: String,
     extractValue: Meta.() -> Value = { value ?: Null },
-    maxAge: Duration = 1.hours,
-    maxPoints: Int = 800,
-    minPoints: Int = 400,
-    sampling: Duration = 10.milliseconds,
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    sampling: Duration = defaultSampling,
     coroutineScope: CoroutineScope = device.context,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
@@ -108,14 +115,27 @@ public fun Plot.plotDeviceProperty(
     }.launchIn(coroutineScope)
 }
 
+public fun Plot.plotDeviceProperty(
+    device: Device,
+    property: DevicePropertySpec<*, Number>,
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    sampling: Duration = defaultSampling,
+    coroutineScope: CoroutineScope = device.context,
+    configuration: Scatter.() -> Unit = {},
+): Job = plotDeviceProperty(
+    device, property.name, { value ?: Null }, maxAge, maxPoints, minPoints, sampling, coroutineScope, configuration
+)
+
 private fun <T> Trace.updateFromState(
     context: Context,
     state: DeviceState<T>,
-    extractValue: T.() -> Value = { state.converter.objectToMeta(this).value ?: space.kscience.dataforge.meta.Null },
-    maxAge: Duration = 1.hours,
-    maxPoints: Int = 800,
-    minPoints: Int = 400,
-    sampling: Duration = 10.milliseconds,
+    extractValue: T.() -> Value,
+    maxAge: Duration,
+    maxPoints: Int,
+    minPoints: Int,
+    sampling: Duration,
 ): Job {
     val clock = context.clock
     val data = TimeData()
@@ -131,10 +151,10 @@ public fun <T> Plot.plotDeviceState(
     context: Context,
     state: DeviceState<T>,
     extractValue: T.() -> Value = { state.converter.objectToMeta(this).value ?: Null },
-    maxAge: Duration = 1.hours,
-    maxPoints: Int = 800,
-    minPoints: Int = 400,
-    sampling: Duration = 10.milliseconds,
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    sampling: Duration = defaultSampling,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
     updateFromState(context, state, extractValue, maxAge, maxPoints, minPoints, sampling)
@@ -144,10 +164,10 @@ public fun <T> Plot.plotDeviceState(
 public fun Plot.plotNumberState(
     context: Context,
     state: DeviceState<out Number>,
-    maxAge: Duration = 1.hours,
-    maxPoints: Int = 800,
-    minPoints: Int = 400,
-    sampling: Duration = 10.milliseconds,
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    sampling: Duration = defaultSampling,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
     updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, sampling)
@@ -157,10 +177,10 @@ public fun Plot.plotNumberState(
 public fun Plot.plotBooleanState(
     context: Context,
     state: DeviceState<Boolean>,
-    maxAge: Duration = 1.hours,
-    maxPoints: Int = 800,
-    minPoints: Int = 400,
-    sampling: Duration = 10.milliseconds,
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    sampling: Duration = defaultSampling,
     configuration: Bar.() -> Unit = {},
 ): Job = bar(configuration).run {
     updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, sampling)
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 3054399..5cbef67 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -26,7 +26,6 @@ import space.kscience.controls.vision.plotDeviceProperty
 import space.kscience.controls.vision.plotNumberState
 import space.kscience.controls.vision.showDashboard
 import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.context.request
 import space.kscience.dataforge.meta.Meta
 import space.kscience.plotly.models.ScatterMode
 import space.kscience.visionforge.plotly.PlotlyPlugin
@@ -44,7 +43,7 @@ class LinearDrive(
     mass: Double,
     pidParameters: PidParameters,
     meta: Meta = Meta.EMPTY,
-) : DeviceConstructor(context.request(DeviceManager), meta) {
+) : DeviceConstructor(context, meta) {
 
     val drive by device(VirtualDrive.factory(mass, state))
     val pid by device(PidRegulator(drive, pidParameters))

From 34f9108ef7901afa7c7ef0eabdba8fd2ae429f5e Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 25 Dec 2023 19:09:40 +0300
Subject: [PATCH 046/125] New builders for devices

---
 build.gradle.kts                              |  4 +--
 .../controls/constructor/DeviceConstructor.kt | 28 +++++++++++----
 .../controls/constructor/DeviceGroup.kt       | 20 +++++------
 .../controls/constructor/DeviceState.kt       | 36 +++++++++++++++++--
 .../space/kscience/controls/api/Device.kt     |  7 ++++
 .../controls/manager/DeviceManager.kt         |  3 ++
 6 files changed, 76 insertions(+), 22 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index 6c169fe..aebfc4f 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,14 +6,14 @@ plugins {
 }
 
 val dataforgeVersion: String by extra("0.7.1")
-val visionforgeVersion by extra("0.3.0-RC")
+val visionforgeVersion by extra("0.3.1")
 val ktorVersion: String by extra(space.kscience.gradle.KScienceVersions.ktorVersion)
 val rsocketVersion by extra("0.15.4")
 val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.3.0-dev-5"
+    version = "0.3.0-dev-6"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 1eb3b91..5dbbe13 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -57,8 +57,8 @@ public abstract class DeviceConstructor(
      */
     public fun <T : Any> property(
         state: DeviceState<T>,
-        nameOverride: String? = null,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        nameOverride: String? = null,
     ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> =
         PropertyDelegateProvider { _: DeviceConstructor, property ->
             val name = nameOverride ?: property.name
@@ -77,11 +77,12 @@ public abstract class DeviceConstructor(
         reader: suspend () -> T,
         readInterval: Duration,
         initialState: T,
-        nameOverride: String? = null,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        nameOverride: String? = null,
     ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> = property(
         DeviceState.external(this, metaConverter, readInterval, initialState, reader),
-        nameOverride, descriptorBuilder
+        descriptorBuilder,
+        nameOverride,
     )
 
 
@@ -90,8 +91,8 @@ public abstract class DeviceConstructor(
      */
     public fun <T : Any> mutableProperty(
         state: MutableDeviceState<T>,
-        nameOverride: String? = null,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        nameOverride: String? = null,
     ): PropertyDelegateProvider<DeviceConstructor, ReadWriteProperty<DeviceConstructor, T>> =
         PropertyDelegateProvider { _: DeviceConstructor, property ->
             val name = nameOverride ?: property.name
@@ -116,11 +117,26 @@ public abstract class DeviceConstructor(
         writer: suspend (T) -> Unit,
         readInterval: Duration,
         initialState: T,
-        nameOverride: String? = null,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        nameOverride: String? = null,
     ): PropertyDelegateProvider<DeviceConstructor, ReadWriteProperty<DeviceConstructor, T>> = mutableProperty(
         DeviceState.external(this, metaConverter, readInterval, initialState, reader, writer),
+        descriptorBuilder,
+        nameOverride,
+    )
+
+    /**
+     * Create and register a virtual property with optional [callback]
+     */
+    public fun <T : Any> state(
+        metaConverter: MetaConverter<T>,
+        initialState: T,
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        nameOverride: String? = null,
+        callback: (T) -> Unit = {},
+    ): PropertyDelegateProvider<DeviceConstructor, ReadWriteProperty<DeviceConstructor, T>> = mutableProperty(
+        DeviceState.virtual(metaConverter, initialState, callback),
+        descriptorBuilder,
         nameOverride,
-        descriptorBuilder
     )
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index cea9d71..26b5896 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -47,9 +47,10 @@ public open class DeviceGroup(
     override val messageFlow: Flow<DeviceMessage>
         get() = sharedMessageFlow
 
+    @OptIn(ExperimentalCoroutinesApi::class)
     override val coroutineContext: CoroutineContext = context.newCoroutineContext(
         SupervisorJob(context.coroutineContext[Job]) +
-                CoroutineName("Device $this") +
+                CoroutineName("Device $id") +
                 CoroutineExceptionHandler { _, throwable ->
                     context.launch {
                         sharedMessageFlow.emit(
@@ -75,7 +76,7 @@ public open class DeviceGroup(
     public fun <D : Device> install(token: NameToken, device: D): D {
         require(_devices[token] == null) { "A child device with name $token already exists" }
         //start the child device if needed
-        if(lifecycleState == STARTED || lifecycleState == STARTING) launch { device.start() }
+        if (lifecycleState == STARTED || lifecycleState == STARTING) launch { device.start() }
         _devices[token] = device
         return device
     }
@@ -216,6 +217,9 @@ public fun <D : Device> DeviceGroup.install(name: Name, device: D): D {
 public fun <D : Device> DeviceGroup.install(name: String, device: D): D =
     install(name.parseAsName(), device)
 
+public fun <D : Device> DeviceGroup.install(device: D): D =
+    install(device.id, device)
+
 public fun <D : Device> Context.install(name: String, device: D): D = request(DeviceManager).install(name, device)
 
 /**
@@ -281,15 +285,6 @@ public fun <T : Any> DeviceGroup.registerMutableProperty(
 }
 
 
-/**
- * Create a virtual [MutableDeviceState], but do not register it to a device
- */
-@Suppress("UnusedReceiverParameter")
-public fun <T : Any> DeviceGroup.state(
-    converter: MetaConverter<T>,
-    initialValue: T,
-): MutableDeviceState<T> = VirtualDeviceState<T>(converter, initialValue)
-
 /**
  * Create a new virtual mutable state and a property based on it.
  * @return the mutable state used in property
@@ -299,8 +294,9 @@ public fun <T : Any> DeviceGroup.registerVirtualProperty(
     initialValue: T,
     converter: MetaConverter<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    callback: (T) -> Unit = {},
 ): MutableDeviceState<T> {
-    val state = state(converter, initialValue)
+    val state = DeviceState.virtual<T>(converter, initialValue, callback)
     registerMutableProperty(name, state, descriptorBuilder)
     return state
 }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index a9cff51..672b0a6 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -45,17 +45,49 @@ public var <T : Any> MutableDeviceState<T>.valueAsMeta: Meta
 
 /**
  * A [MutableDeviceState] that does not correspond to a physical state
+ *
+ * @param callback a synchronous callback that could be used without a scope
  */
-public class VirtualDeviceState<T>(
+private class VirtualDeviceState<T>(
     override val converter: MetaConverter<T>,
     initialValue: T,
+    private val callback: (T) -> Unit = {},
 ) : MutableDeviceState<T> {
     private val flow = MutableStateFlow(initialValue)
     override val valueFlow: Flow<T> get() = flow
 
-    override var value: T by flow::value
+    override var value: T
+        get() = flow.value
+        set(value) {
+            flow.value = value
+            callback(value)
+        }
 }
 
+
+/**
+ * A [MutableDeviceState] that does not correspond to a physical state
+ *
+ * @param callback a synchronous callback that could be used without a scope
+ */
+public fun <T> DeviceState.Companion.virtual(
+    converter: MetaConverter<T>,
+    initialValue: T,
+    callback: (T) -> Unit = {},
+): MutableDeviceState<T> = VirtualDeviceState(converter, initialValue, callback)
+
+private class StateFlowAsState<T>(
+    override val converter: MetaConverter<T>,
+    val flow: MutableStateFlow<T>,
+) : MutableDeviceState<T> {
+    override var value: T by flow::value
+    override val valueFlow: Flow<T> get() = flow
+}
+
+public fun <T> MutableStateFlow<T>.asDeviceState(converter: MetaConverter<T>): DeviceState<T> =
+    StateFlowAsState(converter, this)
+
+
 private open class BoundDeviceState<T>(
     override val converter: MetaConverter<T>,
     val device: Device,
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index 4b78f24..89040c0 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -10,6 +10,8 @@ import space.kscience.dataforge.context.ContextAware
 import space.kscience.dataforge.context.info
 import space.kscience.dataforge.context.logger
 import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.string
 import space.kscience.dataforge.misc.DFExperimental
 import space.kscience.dataforge.misc.DfType
 import space.kscience.dataforge.names.parseAsName
@@ -111,6 +113,11 @@ public interface Device : ContextAware, CoroutineScope {
     }
 }
 
+/**
+ * Inner id of a device. Not necessary corresponds to the name in the parent container
+ */
+public val Device.id: String get() = meta["id"].string?: "device[${hashCode().toString(16)}]"
+
 /**
  * Device that caches properties values
  */
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
index be59b4c..c8c7ffc 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
@@ -4,6 +4,7 @@ import kotlinx.coroutines.launch
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.DeviceHub
 import space.kscience.controls.api.getOrNull
+import space.kscience.controls.api.id
 import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.MutableMeta
@@ -45,6 +46,8 @@ public fun <D : Device> DeviceManager.install(name: String, device: D): D {
     return device
 }
 
+public fun <D : Device> DeviceManager.install(device: D): D = install(device.id, device)
+
 
 /**
  * Register and start a device built by [factory] with current [Context] and [meta].

From aa52b4b92791054debc07d900808c80a756ca534 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Thu, 28 Dec 2023 21:09:23 +0300
Subject: [PATCH 047/125] hub returns list of messages.

---
 CHANGELOG.md                                  |  1 +
 .../space/kscience/controls/api/DeviceHub.kt  | 34 +++++++++++--------
 .../kscience/controls/api/DeviceMessage.kt    |  2 +-
 .../controls/manager/respondMessage.kt        | 19 +++++++----
 .../kscience/controls/client/controlsMagix.kt |  4 +--
 .../controls/server/deviceWebServer.kt        | 16 ++++-----
 .../kscience/controls/server/responses.kt     |  5 +--
 demo/all-things/build.gradle.kts              |  2 +-
 .../kscience/controls/demo/DemoDevice.kt      | 11 +++++-
 9 files changed, 59 insertions(+), 35 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index b4de4b7..93b9611 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -10,6 +10,7 @@
 ### Changed
 - Property caching moved from core `Device` to the `CachingDevice`
 - `DeviceSpec` properties no explicitly pass property name to getters and setters.
+- `DeviceHub.respondHubMessage` now returns a list of messages to allow querying multiple devices. Device server also returns an array.
 
 ### Deprecated
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
index 8bd7452..72b0dc3 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
@@ -14,22 +14,27 @@ public interface DeviceHub : Provider {
 
     override val defaultChainTarget: String get() = Device.DEVICE_TARGET
 
-    override fun content(target: String): Map<Name, Any> = if (target == Device.DEVICE_TARGET) {
-        buildMap {
-            fun putAll(prefix: Name, hub: DeviceHub) {
-                hub.devices.forEach {
-                    put(prefix + it.key, it.value)
-                }
-            }
-
-            devices.forEach {
-                val name = it.key.asName()
-                put(name, it.value)
-                (it.value as? DeviceHub)?.let { hub ->
-                    putAll(name, hub)
-                }
+    /**
+     * List all devices, including sub-devices
+     */
+    public fun buildDeviceTree(): Map<Name, Device> = buildMap {
+        fun putAll(prefix: Name, hub: DeviceHub) {
+            hub.devices.forEach {
+                put(prefix + it.key, it.value)
             }
         }
+
+        devices.forEach {
+            val name = it.key.asName()
+            put(name, it.value)
+            (it.value as? DeviceHub)?.let { hub ->
+                putAll(name, hub)
+            }
+        }
+    }
+
+    override fun content(target: String): Map<Name, Any> = if (target == Device.DEVICE_TARGET) {
+        buildDeviceTree()
     } else {
         emptyMap()
     }
@@ -37,6 +42,7 @@ public interface DeviceHub : Provider {
     public companion object
 }
 
+
 public operator fun DeviceHub.get(nameToken: NameToken): Device =
     devices[nameToken] ?: error("Device with name $nameToken not found in $this")
 
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 b3436e9..4cd91b0 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
@@ -103,7 +103,7 @@ public data class PropertyGetMessage(
 @SerialName("description.get")
 public data class GetDescriptionMessage(
     override val sourceDevice: Name? = null,
-    override val targetDevice: Name,
+    override val targetDevice: Name? = null,
     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 4d319af..f8a641f 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,15 +68,22 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
 }
 
 /**
- * Process incoming [DeviceMessage], using hub naming to evaluate target.
+ * Process incoming [DeviceMessage], using hub naming to find target.
+ * If the `targetDevice` is `null`, then message is sent to each device in this hub
  */
-public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): DeviceMessage? {
+public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List<DeviceMessage> {
     return try {
-        val targetName = request.targetDevice ?: return null
-        val device = getOrNull(targetName) ?: error("The device with name $targetName not found in $this")
-        device.respondMessage(targetName, request)
+        val targetName = request.targetDevice
+        if(targetName == null) {
+            buildDeviceTree().mapNotNull {
+                it.value.respondMessage(it.key, request)
+            }
+        } else {
+            val device = getOrNull(targetName) ?: error("The device with name $targetName not found in $this")
+            listOfNotNull(device.respondMessage(targetName, request))
+        }
     } catch (ex: Exception) {
-        DeviceMessage.error(ex, sourceDevice = Name.EMPTY, targetDevice = request.sourceDevice)
+        listOf(DeviceMessage.error(ex, sourceDevice = Name.EMPTY, targetDevice = request.sourceDevice))
     }
 }
 
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
index ed69bff..5e7f4fb 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
@@ -39,10 +39,10 @@ public fun DeviceManager.launchMagixService(
 ): Job = context.launch {
     endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID)).onEach { (request, payload) ->
         val responsePayload = respondHubMessage(payload)
-        if (responsePayload != null) {
+        responsePayload.forEach {
             endpoint.send(
                 format = controlsMagixFormat,
-                payload = responsePayload,
+                payload = it,
                 source = endpointID,
                 target = request.sourceEndpoint,
                 id = generateId(request),
diff --git a/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
index ba63583..04bb46d 100644
--- a/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
+++ b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
@@ -157,8 +157,8 @@ public fun Application.deviceManagerModule(
                 val body = call.receiveText()
                 val request: DeviceMessage = MagixEndpoint.magixJson.decodeFromString(DeviceMessage.serializer(), body)
                 val response = manager.respondHubMessage(request)
-                if (response != null) {
-                    call.respondMessage(response)
+                if (response.isNotEmpty()) {
+                    call.respondMessages(response)
                 } else {
                     call.respondText("No response")
                 }
@@ -177,9 +177,9 @@ public fun Application.deviceManagerModule(
                             property = property,
                         )
 
-                        val response = manager.respondHubMessage(request)
-                        if (response != null) {
-                            call.respondMessage(response)
+                        val responses = manager.respondHubMessage(request)
+                        if (responses.isNotEmpty()) {
+                            call.respondMessages(responses)
                         } else {
                             call.respond(HttpStatusCode.InternalServerError)
                         }
@@ -197,9 +197,9 @@ public fun Application.deviceManagerModule(
                             value = json.toMeta()
                         )
 
-                        val response = manager.respondHubMessage(request)
-                        if (response != null) {
-                            call.respondMessage(response)
+                        val responses = manager.respondHubMessage(request)
+                        if (responses.isNotEmpty()) {
+                            call.respondMessages(responses)
                         } else {
                             call.respond(HttpStatusCode.InternalServerError)
                         }
diff --git a/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt
index 93d11c4..ffe489f 100644
--- a/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt
+++ b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt
@@ -5,6 +5,7 @@ import io.ktor.server.application.ApplicationCall
 import io.ktor.server.response.respondText
 import kotlinx.serialization.json.JsonObjectBuilder
 import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.serializer
 import space.kscience.controls.api.DeviceMessage
 import space.kscience.magix.api.MagixEndpoint
 
@@ -25,7 +26,7 @@ internal suspend fun ApplicationCall.respondJson(builder: JsonObjectBuilder.() -
     respondText(json.toString(), contentType = ContentType.Application.Json)
 }
 
-internal suspend fun ApplicationCall.respondMessage(message: DeviceMessage): Unit = respondText(
-    MagixEndpoint.magixJson.encodeToString(DeviceMessage.serializer(), message),
+internal suspend fun ApplicationCall.respondMessages(messages: List<DeviceMessage>): Unit = respondText(
+    MagixEndpoint.magixJson.encodeToString(serializer<List<DeviceMessage>>(), messages),
     contentType = ContentType.Application.Json
 )
\ No newline at end of file
diff --git a/demo/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts
index 33b654e..6888d35 100644
--- a/demo/all-things/build.gradle.kts
+++ b/demo/all-things/build.gradle.kts
@@ -24,7 +24,7 @@ dependencies {
 
     implementation("io.ktor:ktor-client-cio:$ktorVersion")
     implementation("no.tornado:tornadofx:1.7.20")
-    implementation("space.kscience:plotlykt-server:0.6.0")
+    implementation("space.kscience:plotlykt-server:0.6.1")
 //    implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6")
     implementation(spclibs.logback.classic)
 }
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
index 067792c..657d275 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
@@ -47,7 +47,12 @@ class DemoDevice(context: Context, meta: Meta) : DeviceBySpec<IDemoDevice>(Compa
             description = "Real to virtual time scale"
         }
 
-        val sinScale by mutableProperty(MetaConverter.double, IDemoDevice::sinScaleState)
+        val sinScale by mutableProperty(MetaConverter.double, IDemoDevice::sinScaleState){
+            description = "The scale of sin plot"
+            metaDescriptor {
+                valueType(ValueType.NUMBER)
+            }
+        }
         val cosScale by mutableProperty(MetaConverter.double, IDemoDevice::cosScaleState)
 
         val sin by doubleProperty { sinValue() }
@@ -74,6 +79,10 @@ class DemoDevice(context: Context, meta: Meta) : DeviceBySpec<IDemoDevice>(Compa
             write(cosScale, 1.0)
         }
 
+        val setSinScale by action(MetaConverter.double, MetaConverter.unit){ value: Double ->
+            write(sinScale, value)
+        }
+
         override suspend fun IDemoDevice.onOpen() {
             launch {
                 read(sinScale)

From 7579ddfad4ed2605551793d070c01c0345a6ab33 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Thu, 28 Dec 2023 22:40:58 +0300
Subject: [PATCH 048/125] Quick fix for OPC us server

---
 .../controls/opcua/client/MetaBsdParser.kt    | 18 +++--
 .../controls/opcua/server/DeviceNameSpace.kt  | 79 ++++++++++---------
 2 files changed, 53 insertions(+), 44 deletions(-)

diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
index 27330bb..48aa6dc 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt
@@ -56,6 +56,7 @@ internal class MetaEnumCodec : OpcUaBinaryDataTypeCodec<Number> {
 
 internal fun opcToMeta(value: Any?): Meta = when (value) {
     null -> Meta(Null)
+    is Variant -> opcToMeta(value.value)
     is Meta -> value
     is Value -> Meta(value)
     is Number -> when (value) {
@@ -79,12 +80,17 @@ internal fun opcToMeta(value: Any?): Meta = when (value) {
         "text" put value.text?.asValue()
     }
     is DataValue -> Meta {
-        "value" put opcToMeta(value.value) // need SerializationContext to do that properly
-        value.statusCode?.value?.let { "status" put Meta(it.asValue()) }
-        value.sourceTime?.javaInstant?.let { "sourceTime" put it.toKotlinInstant().toMeta() }
-        value.sourcePicoseconds?.let { "sourcePicoseconds" put Meta(it.asValue()) }
-        value.serverTime?.javaInstant?.let { "serverTime" put it.toKotlinInstant().toMeta() }
-        value.serverPicoseconds?.let { "serverPicoseconds" put Meta(it.asValue()) }
+        val variant= opcToMeta(value.value)
+        update(variant)// need SerializationContext to do that properly
+        //TODO remove after DF 0.7.2
+        this.value =  variant.value
+        "@opc" put {
+            value.statusCode?.value?.let { "status" put Meta(it.asValue()) }
+            value.sourceTime?.javaInstant?.let { "sourceTime" put it.toKotlinInstant().toMeta() }
+            value.sourcePicoseconds?.let { "sourcePicoseconds" put Meta(it.asValue()) }
+            value.serverTime?.javaInstant?.let { "serverTime" put it.toKotlinInstant().toMeta() }
+            value.serverPicoseconds?.let { "serverPicoseconds" put Meta(it.asValue()) }
+        }
     }
     is ByteString -> Meta(value.bytesOrEmpty().asValue())
     is XmlElement -> Meta(value.fragment?.asValue() ?: Null)
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
index 05fc3c6..057eb2e 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
@@ -2,7 +2,6 @@ package space.kscience.controls.opcua.server
 
 import kotlinx.coroutines.launch
 import kotlinx.datetime.toJavaInstant
-import kotlinx.serialization.json.Json
 import org.eclipse.milo.opcua.sdk.core.AccessLevel
 import org.eclipse.milo.opcua.sdk.core.Reference
 import org.eclipse.milo.opcua.sdk.server.Lifecycle
@@ -21,14 +20,15 @@ import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime
 import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText
 import space.kscience.controls.api.*
 import space.kscience.controls.manager.DeviceManager
+import space.kscience.controls.opcua.client.opcToMeta
 import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.MetaSerializer
 import space.kscience.dataforge.meta.ValueType
 import space.kscience.dataforge.names.Name
 import space.kscience.dataforge.names.plus
 
 
-public operator fun CachingDevice.get(propertyDescriptor: PropertyDescriptor): Meta? = getProperty(propertyDescriptor.name)
+public operator fun CachingDevice.get(propertyDescriptor: PropertyDescriptor): Meta? =
+    getProperty(propertyDescriptor.name)
 
 public suspend fun Device.read(propertyDescriptor: PropertyDescriptor): Meta = readProperty(propertyDescriptor.name)
 
@@ -38,29 +38,11 @@ https://github.com/eclipse/milo/blob/master/milo-examples/server-examples/src/ma
 
 public class DeviceNameSpace(
     server: OpcUaServer,
-    public val deviceManager: DeviceManager
+    public val deviceManager: DeviceManager,
 ) : ManagedNamespaceWithLifecycle(server, NAMESPACE_URI) {
 
     private val subscription = SubscriptionModel(server, this)
 
-    init {
-        lifecycleManager.addLifecycle(subscription)
-
-        lifecycleManager.addStartupTask {
-            nodeContext.registerHub(deviceManager, Name.EMPTY)
-        }
-
-        lifecycleManager.addLifecycle(object : Lifecycle {
-            override fun startup() {
-                server.addressSpaceManager.register(this@DeviceNameSpace)
-            }
-
-            override fun shutdown() {
-                server.addressSpaceManager.unregister(this@DeviceNameSpace)
-            }
-        })
-    }
-
     private fun UaFolderNode.registerDeviceNodes(deviceName: Name, device: Device) {
         val nodes = device.propertyDescriptors.associate { descriptor ->
             val propertyName = descriptor.name
@@ -74,14 +56,17 @@ public class DeviceNameSpace(
                         setAccessLevel(AccessLevel.READ_WRITE)
                         setUserAccessLevel(AccessLevel.READ_WRITE)
                     }
+
                     descriptor.mutable -> {
                         setAccessLevel(AccessLevel.WRITE_ONLY)
                         setUserAccessLevel(AccessLevel.WRITE_ONLY)
                     }
+
                     descriptor.readable -> {
                         setAccessLevel(AccessLevel.READ_ONLY)
                         setUserAccessLevel(AccessLevel.READ_ONLY)
                     }
+
                     else -> {
                         setAccessLevel(AccessLevel.NONE)
                         setUserAccessLevel(AccessLevel.NONE)
@@ -104,26 +89,23 @@ public class DeviceNameSpace(
             }.build()
 
             // Update initial value, but only if it is cached
-            if(device is CachingDevice) {
+            if (device is CachingDevice) {
                 device[descriptor]?.toOpc(sourceTime = null, serverTime = null)?.let {
                     node.value = it
                 }
             }
 
-            /**
-             * Subscribe to node value changes
-             */
-            node.addAttributeObserver { _: UaNode, attributeId: AttributeId, value: Any ->
-                if (attributeId == AttributeId.Value) {
-                    val meta: Meta = when (value) {
-                        is Meta -> value
-                        is Boolean -> Meta(value)
-                        is Number -> Meta(value)
-                        is String -> Json.decodeFromString(MetaSerializer, value)
-                        else -> return@addAttributeObserver //TODO("other types not implemented")
-                    }
-                    deviceManager.context.launch {
-                        device.writeProperty(propertyName, meta)
+            if (descriptor.mutable) {
+
+                /**
+                 * Subscribe to node value changes
+                 */
+                node.addAttributeObserver { _: UaNode, attributeId: AttributeId, value: Any? ->
+                    if (attributeId == AttributeId.Value) {
+                        val meta: Meta = opcToMeta(value)
+                        deviceManager.context.launch {
+                            device.writeProperty(propertyName, meta)
+                        }
                     }
                 }
             }
@@ -137,7 +119,10 @@ public class DeviceNameSpace(
         device.onPropertyChange {
             nodes[property]?.let { node ->
                 val sourceTime = time?.let { DateTime(it.toJavaInstant()) }
-                node.value = value.toOpc(sourceTime = sourceTime)
+                val newValue = value.toOpc(sourceTime = sourceTime)
+                if (node.value.value != newValue.value) {
+                    node.value = newValue
+                }
             }
         }
         //recursively add sub-devices
@@ -168,6 +153,24 @@ public class DeviceNameSpace(
         }
     }
 
+    init {
+        lifecycleManager.addLifecycle(subscription)
+
+        lifecycleManager.addStartupTask {
+            nodeContext.registerHub(deviceManager, Name.EMPTY)
+        }
+
+        lifecycleManager.addLifecycle(object : Lifecycle {
+            override fun startup() {
+                server.addressSpaceManager.register(this@DeviceNameSpace)
+            }
+
+            override fun shutdown() {
+                server.addressSpaceManager.unregister(this@DeviceNameSpace)
+            }
+        })
+    }
+
     override fun onDataItemsCreated(dataItems: List<DataItem?>?) {
         subscription.onDataItemsCreated(dataItems)
     }

From fa2414ef473feaa33402e13c68b6d89f949757c8 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 2 Feb 2024 16:04:41 +0300
Subject: [PATCH 049/125] Add demo for device message listening

---
 .../kscience/controls/api/DeviceMessage.kt    |  2 +-
 .../controls/demo/DemoControllerView.kt       | 21 +++++++++++++++++++
 2 files changed, 22 insertions(+), 1 deletion(-)

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 4cd91b0..406cfca 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
@@ -1,4 +1,4 @@
-@file:OptIn(ExperimentalSerializationApi::class, ExperimentalSerializationApi::class)
+@file:OptIn(ExperimentalSerializationApi::class)
 
 package space.kscience.controls.api
 
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
index 8edd90e..2d2086b 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
@@ -5,10 +5,17 @@ import javafx.scene.Parent
 import javafx.scene.control.Slider
 import javafx.scene.layout.Priority
 import javafx.stage.Stage
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
+import kotlinx.serialization.json.Json
 import org.eclipse.milo.opcua.sdk.server.OpcUaServer
 import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText
+import space.kscience.controls.api.DeviceMessage
+import space.kscience.controls.api.GetDescriptionMessage
+import space.kscience.controls.api.PropertyChangedMessage
 import space.kscience.controls.client.launchMagixService
+import space.kscience.controls.client.magixFormat
 import space.kscience.controls.demo.DemoDevice.Companion.cosScale
 import space.kscience.controls.demo.DemoDevice.Companion.sinScale
 import space.kscience.controls.demo.DemoDevice.Companion.timeScale
@@ -20,6 +27,8 @@ import space.kscience.controls.opcua.server.serveDevices
 import space.kscience.controls.spec.write
 import space.kscience.dataforge.context.*
 import space.kscience.magix.api.MagixEndpoint
+import space.kscience.magix.api.send
+import space.kscience.magix.api.subscribe
 import space.kscience.magix.rsocket.rSocketWithTcp
 import space.kscience.magix.rsocket.rSocketWithWebSockets
 import space.kscience.magix.server.RSocketMagixFlowPlugin
@@ -49,6 +58,7 @@ class DemoController : Controller(), ContextAware {
 
     private val deviceManager = context.request(DeviceManager)
 
+
     fun init() {
         context.launch {
             device = deviceManager.install("demo", DemoDevice)
@@ -67,6 +77,17 @@ class DemoController : Controller(), ContextAware {
             //serve devices as OPC-UA namespace
             opcUaServer.startup()
             opcUaServer.serveDevices(deviceManager)
+
+
+            val listenerEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+            listenerEndpoint.subscribe(DeviceManager.magixFormat).onEach { (_, deviceMessage)->
+                // print all messages that are not property change message
+                if(deviceMessage !is PropertyChangedMessage){
+                    println(">> ${Json.encodeToString(DeviceMessage.serializer(), deviceMessage)}")
+                }
+            }.launchIn(this)
+            listenerEndpoint.send(DeviceManager.magixFormat, GetDescriptionMessage(), "listener", "controls-kt")
+
         }
     }
 

From b1121d61cb9e8832ed3e2cb25a23424182c620ee Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 5 Feb 2024 14:08:15 +0300
Subject: [PATCH 050/125] Allow controls magix endpoint to receive broadcast.

---
 .../kscience/controls/client/controlsMagix.kt |  4 ++-
 .../space/kscience/magix/api/MagixFormat.kt   |  2 +-
 .../kscience/magix/api/MagixMessageFilter.kt  |  2 +-
 .../magix/storage/xodus/XodusMagixStorage.kt  | 28 ++++++++++---------
 4 files changed, 20 insertions(+), 16 deletions(-)

diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
index 5e7f4fb..2257c74 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
@@ -32,12 +32,14 @@ internal fun generateId(request: MagixMessage): String = if (request.id != null)
 
 /**
  * Communicate with server in [Magix format](https://github.com/waltz-controls/rfc/tree/master/1)
+ *
+ * Accepts messages with target that equals [endpointID] or null (broadcast messages)
  */
 public fun DeviceManager.launchMagixService(
     endpoint: MagixEndpoint,
     endpointID: String = controlsMagixFormat.defaultFormat,
 ): Job = context.launch {
-    endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID)).onEach { (request, payload) ->
+    endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID, null)).onEach { (request, payload) ->
         val responsePayload = respondHubMessage(payload)
         responsePayload.forEach {
             endpoint.send(
diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt
index 115fcff..42d55b7 100644
--- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt
+++ b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt
@@ -26,7 +26,7 @@ public data class MagixFormat<T>(
 public fun <T> MagixEndpoint.subscribe(
     format: MagixFormat<T>,
     originFilter: Collection<String>? = null,
-    targetFilter: Collection<String>? = null,
+    targetFilter: Collection<String?>? = null,
 ): Flow<Pair<MagixMessage, T>> = subscribe(
     MagixMessageFilter(format = format.formats, source = originFilter, target = targetFilter)
 ).map {
diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt
index 4bd4746..72c7f7f 100644
--- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt
+++ b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt
@@ -11,7 +11,7 @@ import kotlinx.serialization.Serializable
 public data class MagixMessageFilter(
     val format: Collection<String>? = null,
     val source: Collection<String>? = null,
-    val target: Collection<String>? = null,
+    val target: Collection<String?>? = null,
 ) {
 
     public fun accepts(message: MagixMessage): Boolean =
diff --git a/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt b/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt
index 4a3efa3..8d0d852 100644
--- a/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt
+++ b/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt
@@ -39,9 +39,8 @@ public class XodusMagixHistory(private val store: PersistentEntityStore) : Write
 
             setBlobString(MagixMessage::payload.name, magixJson.encodeToString(message.payload))
 
-            message.targetEndpoint?.let {
-                setProperty(MagixMessage::targetEndpoint.name, it)
-            }
+            setProperty(MagixMessage::targetEndpoint.name, (message.targetEndpoint ?: ""))
+
             message.id?.let {
                 setProperty(MagixMessage::id.name, it)
             }
@@ -68,14 +67,14 @@ public class XodusMagixHistory(private val store: PersistentEntityStore) : Write
     ): Unit = store.executeInReadonlyTransaction { transaction ->
         val all = transaction.getAll(XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE)
 
-        fun StoreTransaction.findAllIn(
+        fun findAllIn(
             entityType: String,
             field: String,
-            values: Collection<String>?,
+            values: Collection<String?>?,
         ): EntityIterable? {
             var union: EntityIterable? = null
             values?.forEach {
-                val filter = transaction.find(entityType, field, it)
+                val filter = transaction.find(entityType, field, it ?: "")
                 union = union?.union(filter) ?: filter
             }
             return union
@@ -84,21 +83,24 @@ public class XodusMagixHistory(private val store: PersistentEntityStore) : Write
         // filter by magix filter
         val filteredByMagix: EntityIterable = magixFilter?.let { mf ->
             var res = all
-            transaction.findAllIn(XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE, MagixMessage::format.name, mf.format)
-                ?.let {
-                    res = res.intersect(it)
-                }
-            transaction.findAllIn(
+            findAllIn(
+                XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE,
+                MagixMessage::format.name,
+                mf.format
+            )?.let {
+                res = res.intersect(it)
+            }
+            findAllIn(
                 XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE,
                 MagixMessage::sourceEndpoint.name,
                 mf.source
             )?.let {
                 res = res.intersect(it)
             }
-            transaction.findAllIn(
+            findAllIn(
                 XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE,
                 MagixMessage::targetEndpoint.name,
-                mf.target
+                mf.target?.filterNotNull()
             )?.let {
                 res = res.intersect(it)
             }

From 8bd9bcc6a6a310fd765c4ad592f947597c538b8c Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Thu, 15 Feb 2024 21:04:59 +0300
Subject: [PATCH 051/125] Fix bizzare NPE in context generation for
 DeviceClient. Add test for remote client

---
 controls-magix/build.gradle.kts               |  1 +
 .../kscience/controls/client/DeviceClient.kt  |  6 +-
 .../controls/client/RemoteDeviceConnect.kt    | 87 +++++++++++++++++++
 3 files changed, 91 insertions(+), 3 deletions(-)
 create mode 100644 controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt

diff --git a/controls-magix/build.gradle.kts b/controls-magix/build.gradle.kts
index 0296b8c..3a819bf 100644
--- a/controls-magix/build.gradle.kts
+++ b/controls-magix/build.gradle.kts
@@ -12,6 +12,7 @@ description = """
 kscience {
     jvm()
     js()
+    useCoroutines("1.8.0")
     useSerialization {
         json()
     }
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index 474f86a..924fad9 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
@@ -1,8 +1,8 @@
 package space.kscience.controls.client
 
 import com.benasher44.uuid.uuid4
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.newCoroutineContext
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import space.kscience.controls.api.*
@@ -28,8 +28,8 @@ public class DeviceClient(
     private val send: suspend (DeviceMessage) -> Unit,
 ) : CachingDevice {
 
-    @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
-    override val coroutineContext: CoroutineContext = newCoroutineContext(context.coroutineContext)
+
+    override val coroutineContext: CoroutineContext = context.coroutineContext + Job(context.coroutineContext[Job])
 
     private val mutex = Mutex()
 
diff --git a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
new file mode 100644
index 0000000..f0f65fa
--- /dev/null
+++ b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
@@ -0,0 +1,87 @@
+package space.kscience.controls.client
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.test.runTest
+import kotlinx.serialization.json.Json
+import space.kscience.controls.api.Device
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.controls.manager.install
+import space.kscience.controls.manager.respondMessage
+import space.kscience.controls.spec.*
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.context.request
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.int
+import space.kscience.dataforge.names.Name
+import space.kscience.magix.api.MagixEndpoint
+import space.kscience.magix.api.MagixMessage
+import space.kscience.magix.api.MagixMessageFilter
+import kotlin.random.Random
+import kotlin.test.Test
+import kotlin.test.assertContains
+import kotlin.time.Duration.Companion.milliseconds
+
+
+public suspend fun <T> Device.readUnsafe(propertySpec: DevicePropertySpec<*, T>): T =
+    propertySpec.converter.metaToObject(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
+
+internal class RemoteDeviceConnect {
+
+    class TestDevice(context: Context, meta: Meta) : DeviceBySpec<TestDevice>(TestDevice, context, meta) {
+        private val rng = Random(meta["seed"].int ?: 0)
+
+        private val randomValue get() = rng.nextDouble()
+
+        companion object : DeviceSpec<TestDevice>(), Factory<TestDevice> {
+
+            override fun build(context: Context, meta: Meta): TestDevice = TestDevice(context, meta)
+
+            val value by doubleProperty { randomValue }
+
+            override suspend fun TestDevice.onOpen() {
+                doRecurring((meta["delay"].int ?: 10).milliseconds) {
+                    read(value)
+                }
+            }
+        }
+    }
+
+    @Test
+    fun wrapper() = runTest {
+        val context = Context {
+            plugin(DeviceManager)
+        }
+
+        val device = context.request(DeviceManager).install("test", TestDevice)
+
+        val virtualMagixEndpoint = object : MagixEndpoint {
+
+
+            override fun subscribe(filter: MagixMessageFilter): Flow<MagixMessage> = device.messageFlow.map {
+                MagixMessage(
+                    format = DeviceManager.magixFormat.defaultFormat,
+                    payload = MagixEndpoint.magixJson.encodeToJsonElement(DeviceManager.magixFormat.serializer, it),
+                    sourceEndpoint = "test",
+                )
+            }
+
+            override suspend fun broadcast(message: MagixMessage) {
+                device.respondMessage(
+                    Name.EMPTY,
+                    Json.decodeFromJsonElement(DeviceManager.magixFormat.serializer, message.payload)
+                )
+            }
+
+            override fun close() {
+                //
+            }
+        }
+
+        val remoteDevice = virtualMagixEndpoint.remoteDevice(context, "test", Name.EMPTY)
+
+        assertContains(0.0..1.0, remoteDevice.readUnsafe(TestDevice.value))
+    }
+}
\ No newline at end of file

From 231f1bc858a8823c76a3f7109e4c4df90a77870c Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 19 Feb 2024 14:27:36 +0300
Subject: [PATCH 052/125] Add special unsafe calls for DeviceClient to mirror
 safe device

---
 .../controls/client/clientPropertyAccess.kt   | 79 +++++++++++++++++++
 .../controls/client/RemoteDeviceConnect.kt    |  6 +-
 2 files changed, 80 insertions(+), 5 deletions(-)
 create mode 100644 controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt

diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt
new file mode 100644
index 0000000..22b0413
--- /dev/null
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt
@@ -0,0 +1,79 @@
+package space.kscience.controls.client
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import space.kscience.controls.api.PropertyChangedMessage
+import space.kscience.controls.api.requestProperty
+import space.kscience.controls.spec.DeviceActionSpec
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.MutableDevicePropertySpec
+import space.kscience.controls.spec.name
+import space.kscience.dataforge.meta.Meta
+
+
+/**
+ * An accessor that allows DeviceClient to connect to any property without type checks
+ */
+public suspend fun <T> DeviceClient.read(propertySpec: DevicePropertySpec<*, T>): T =
+    propertySpec.converter.metaToObject(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
+
+
+public suspend fun <T> DeviceClient.request(propertySpec: DevicePropertySpec<*, T>): T =
+    propertySpec.converter.metaToObject(requestProperty(propertySpec.name))
+
+public suspend fun <T> DeviceClient.write(propertySpec: MutableDevicePropertySpec<*, T>, value: T) {
+    writeProperty(propertySpec.name, propertySpec.converter.objectToMeta(value))
+}
+
+public fun <T> DeviceClient.writeAsync(propertySpec: MutableDevicePropertySpec<*, T>, value: T): Job = launch {
+    write(propertySpec, value)
+}
+
+public fun <T> DeviceClient.propertyFlow(spec: DevicePropertySpec<*, T>): Flow<T> = messageFlow
+    .filterIsInstance<PropertyChangedMessage>()
+    .filter { it.property == spec.name }
+    .mapNotNull { spec.converter.metaToObject(it.value) }
+
+public fun <T> DeviceClient.onPropertyChange(
+    spec: DevicePropertySpec<*, T>,
+    scope: CoroutineScope = this,
+    callback: suspend PropertyChangedMessage.(T) -> Unit,
+): Job = messageFlow
+    .filterIsInstance<PropertyChangedMessage>()
+    .filter { it.property == spec.name }
+    .onEach { change ->
+        val newValue = spec.converter.metaToObject(change.value)
+        if (newValue != null) {
+            change.callback(newValue)
+        }
+    }.launchIn(scope)
+
+public fun <T> DeviceClient.useProperty(
+    spec: DevicePropertySpec<*, T>,
+    scope: CoroutineScope = this,
+    callback: suspend (T) -> Unit,
+): Job = scope.launch {
+    callback(read(spec))
+    messageFlow
+        .filterIsInstance<PropertyChangedMessage>()
+        .filter { it.property == spec.name }
+        .collect { change ->
+            val newValue = spec.converter.metaToObject(change.value)
+            if (newValue != null) {
+                callback(newValue)
+            }
+        }
+}
+
+public suspend fun <I, O> DeviceClient.execute(actionSpec: DeviceActionSpec<*, I, O>, input: I): O {
+    val inputMeta = actionSpec.inputConverter.objectToMeta(input)
+    val res = execute(actionSpec.name, inputMeta)
+    return actionSpec.outputConverter.metaToObject(res ?: Meta.EMPTY)
+}
+
+public suspend fun <O> DeviceClient.execute(actionSpec: DeviceActionSpec<*, Unit, O>): O {
+    val res = execute(actionSpec.name, Meta.EMPTY)
+    return actionSpec.outputConverter.metaToObject(res ?: Meta.EMPTY)
+}
\ No newline at end of file
diff --git a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
index f0f65fa..d55dce1 100644
--- a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
+++ b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
@@ -4,7 +4,6 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.test.runTest
 import kotlinx.serialization.json.Json
-import space.kscience.controls.api.Device
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
 import space.kscience.controls.manager.respondMessage
@@ -25,9 +24,6 @@ import kotlin.test.assertContains
 import kotlin.time.Duration.Companion.milliseconds
 
 
-public suspend fun <T> Device.readUnsafe(propertySpec: DevicePropertySpec<*, T>): T =
-    propertySpec.converter.metaToObject(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
-
 internal class RemoteDeviceConnect {
 
     class TestDevice(context: Context, meta: Meta) : DeviceBySpec<TestDevice>(TestDevice, context, meta) {
@@ -82,6 +78,6 @@ internal class RemoteDeviceConnect {
 
         val remoteDevice = virtualMagixEndpoint.remoteDevice(context, "test", Name.EMPTY)
 
-        assertContains(0.0..1.0, remoteDevice.readUnsafe(TestDevice.value))
+        assertContains(0.0..1.0, remoteDevice.read(TestDevice.value))
     }
 }
\ No newline at end of file

From 57e9df140b72cfb156ff9d252fe1a9f61ac540e2 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 19 Feb 2024 15:10:51 +0300
Subject: [PATCH 053/125] Add utilities to work with remote devices

---
 .../kscience/controls/client/DeviceClient.kt  | 73 ++++++++++++++++++-
 1 file changed, 69 insertions(+), 4 deletions(-)

diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index 924fad9..31dbd89 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
@@ -7,6 +7,8 @@ import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import space.kscience.controls.api.*
 import space.kscience.controls.manager.DeviceManager
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.name
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.misc.DFExperimental
@@ -106,12 +108,75 @@ public class DeviceClient(
  * Connect to a remote device via this endpoint.
  *
  * @param context a [Context] to run device in
- * @param endpointName the name of endpoint in Magix to connect to
+ * @param sourceEndpointName the name of this endpoint
+ * @param targetEndpointName the name of endpoint in Magix to connect to
  * @param deviceName the name of device within endpoint
  */
-public fun MagixEndpoint.remoteDevice(context: Context, endpointName: String, deviceName: Name): DeviceClient {
-    val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second }
+public fun MagixEndpoint.remoteDevice(
+    context: Context,
+    sourceEndpointName: String,
+    targetEndpointName: String,
+    deviceName: Name,
+): DeviceClient {
+    val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(targetEndpointName)).map { it.second }
     return DeviceClient(context, deviceName, subscription) {
-        send(DeviceManager.magixFormat, it, endpointName, id = stringUID())
+        send(
+            format = DeviceManager.magixFormat,
+            payload = it,
+            source = sourceEndpointName,
+            target = targetEndpointName,
+            id = stringUID()
+        )
     }
+}
+
+/**
+ * Subscribe on specific property of a device without creating a device
+ */
+public fun <T> MagixEndpoint.controlsPropertyFlow(
+    endpointName: String,
+    deviceName: Name,
+    propertySpec: DevicePropertySpec<*, T>,
+): Flow<T> {
+    val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second }
+
+    return subscription.filterIsInstance<PropertyChangedMessage>()
+        .filter { message ->
+            message.sourceDevice == deviceName && message.property == propertySpec.name
+        }.map {
+            propertySpec.converter.metaToObject(it.value)
+        }
+}
+
+public suspend fun <T> MagixEndpoint.sendControlsPropertyChange(
+    sourceEndpointName: String,
+    targetEndpointName: String,
+    deviceName: Name,
+    propertySpec: DevicePropertySpec<*, T>,
+    value: T,
+) {
+    val message = PropertySetMessage(
+        property = propertySpec.name,
+        value = propertySpec.converter.objectToMeta(value),
+        targetDevice = deviceName
+    )
+    send(DeviceManager.magixFormat, message, source = sourceEndpointName, target = targetEndpointName)
+}
+
+/**
+ * Subscribe on property change messages together with property values
+ */
+public fun <T> MagixEndpoint.controlsPropertyMessageFlow(
+    endpointName: String,
+    deviceName: Name,
+    propertySpec: DevicePropertySpec<*, T>,
+): Flow<Pair<PropertyChangedMessage, T>> {
+    val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second }
+
+    return subscription.filterIsInstance<PropertyChangedMessage>()
+        .filter { message ->
+            message.sourceDevice == deviceName && message.property == propertySpec.name
+        }.map {
+            it to propertySpec.converter.metaToObject(it.value)
+        }
 }
\ No newline at end of file

From 9edf3b13efaf238a60bdc54de31a3d2acd63ac49 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 27 Feb 2024 10:31:35 +0300
Subject: [PATCH 054/125] Remove unnecessary scope in hub message flow

---
 CHANGELOG.md                                  |  1 +
 .../controls/manager/respondMessage.kt        | 37 +++++++------------
 .../controls/storage/storageCommon.kt         |  2 +-
 3 files changed, 16 insertions(+), 24 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 93b9611..8183d79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@
 ### Fixed
 - Property writing does not trigger change if logical state already is the same as value to be set.
 - Modbus-slave triggers only once for multi-register write.
+- Removed unnecessary scope in hub messageFlow
 
 ### Security
 
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 f8a641f..3a0ac7b 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
@@ -1,10 +1,9 @@
 package space.kscience.controls.manager
 
-import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
 import space.kscience.controls.api.*
 import space.kscience.dataforge.names.Name
 import space.kscience.dataforge.names.plus
@@ -74,7 +73,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
 public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List<DeviceMessage> {
     return try {
         val targetName = request.targetDevice
-        if(targetName == null) {
+        if (targetName == null) {
             buildDeviceTree().mapNotNull {
                 it.value.respondMessage(it.key, request)
             }
@@ -90,27 +89,19 @@ public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List<Dev
 /**
  * Collect all messages from given [DeviceHub], applying proper relative names.
  */
-public fun DeviceHub.hubMessageFlow(scope: CoroutineScope): Flow<DeviceMessage> {
+public fun DeviceHub.hubMessageFlow(): Flow<DeviceMessage> {
 
-    //TODO could we avoid using downstream scope?
-    val outbox = MutableSharedFlow<DeviceMessage>()
-    if (this is Device) {
-        messageFlow.onEach {
-            outbox.emit(it)
-        }.launchIn(scope)
-    }
-    //TODO maybe better create map of all devices to limit copying
-    devices.forEach { (token, childDevice) ->
-        val flow = if (childDevice is DeviceHub) {
-            childDevice.hubMessageFlow(scope)
+    val deviceMessageFlow = if (this is Device) messageFlow else emptyFlow()
+
+    val childrenFlows = devices.map { (token, childDevice) ->
+        if (childDevice is DeviceHub) {
+            childDevice.hubMessageFlow()
         } else {
             childDevice.messageFlow
+        }.map { deviceMessage ->
+            deviceMessage.changeSource { token + it }
         }
-        flow.onEach { deviceMessage ->
-            outbox.emit(
-                deviceMessage.changeSource { token + it }
-            )
-        }.launchIn(scope)
     }
-    return outbox
+
+    return merge(deviceMessageFlow, *childrenFlows.toTypedArray())
 }
\ No newline at end of file
diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt
index 2a453cf..f0bf1f2 100644
--- a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt
+++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt
@@ -12,7 +12,7 @@ import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.context.debug
 import space.kscience.dataforge.context.logger
 
-//TODO replace by plugin?
+
 public fun DeviceManager.storage(
     factory: Factory<DeviceMessageStorage>,
 ): DeviceMessageStorage = factory.build(context, meta)

From cfd9eb053ca5b6f22237e4a592bf485010e0d80d Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 4 Mar 2024 11:11:56 +0300
Subject: [PATCH 055/125] Make DeviceMessage time mandatory

---
 .../kscience/controls/api/DeviceMessage.kt    | 26 +++++++++----------
 .../kscience/controls/client/controlsMagix.kt |  2 +-
 .../controls/client/RemoteDeviceConnect.kt    |  4 +--
 .../controls/opcua/server/DeviceNameSpace.kt  |  2 +-
 .../xodus/XodusDeviceMessageStorage.kt        |  5 +---
 .../controls/storage/storageCommon.kt         |  2 +-
 .../src/commonMain/kotlin/plotExtensions.kt   |  3 +--
 .../kscience/controls/demo/MassDevice.kt      |  4 +--
 8 files changed, 22 insertions(+), 26 deletions(-)

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 406cfca..dda89de 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
@@ -22,7 +22,7 @@ public sealed class DeviceMessage {
     public abstract val sourceDevice: Name?
     public abstract val targetDevice: Name?
     public abstract val comment: String?
-    public abstract val time: Instant?
+    public abstract val time: Instant
 
     /**
      * Update the source device name for composition. If the original name is null, the resulting name is also null.
@@ -59,7 +59,7 @@ public data class PropertyChangedMessage(
     override val sourceDevice: Name = Name.EMPTY,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
 }
@@ -75,7 +75,7 @@ public data class PropertySetMessage(
     override val sourceDevice: Name? = null,
     override val targetDevice: Name,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
 }
@@ -91,7 +91,7 @@ public data class PropertyGetMessage(
     override val sourceDevice: Name? = null,
     override val targetDevice: Name,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
 }
@@ -105,7 +105,7 @@ public data class GetDescriptionMessage(
     override val sourceDevice: Name? = null,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
 }
@@ -122,7 +122,7 @@ public data class DescriptionMessage(
     override val sourceDevice: Name,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
 }
@@ -141,7 +141,7 @@ public data class ActionExecuteMessage(
     override val sourceDevice: Name? = null,
     override val targetDevice: Name,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
 }
@@ -160,7 +160,7 @@ public data class ActionResultMessage(
     override val sourceDevice: Name,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
 }
@@ -175,7 +175,7 @@ public data class BinaryNotificationMessage(
     override val sourceDevice: Name,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
 }
@@ -190,7 +190,7 @@ public data class EmptyDeviceMessage(
     override val sourceDevice: Name? = null,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
 }
@@ -206,7 +206,7 @@ public data class DeviceLogMessage(
     override val sourceDevice: Name = Name.EMPTY,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
 }
@@ -223,7 +223,7 @@ public data class DeviceErrorMessage(
     override val sourceDevice: Name = Name.EMPTY,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
 }
@@ -238,7 +238,7 @@ public data class DeviceLifeCycleMessage(
     override val sourceDevice: Name = Name.EMPTY,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
-    @EncodeDefault override val time: Instant? = Clock.System.now(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
 ) : DeviceMessage() {
     override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
 }
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
index 2257c74..8aa4383 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
@@ -55,7 +55,7 @@ public fun DeviceManager.launchMagixService(
         logger.error(error) { "Error while responding to message: ${error.message}" }
     }.launchIn(this)
 
-    hubMessageFlow(this).onEach { payload ->
+    hubMessageFlow().onEach { payload ->
         endpoint.send(
             format = controlsMagixFormat,
             payload = payload,
diff --git a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
index d55dce1..1478899 100644
--- a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
+++ b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
@@ -60,7 +60,7 @@ internal class RemoteDeviceConnect {
                 MagixMessage(
                     format = DeviceManager.magixFormat.defaultFormat,
                     payload = MagixEndpoint.magixJson.encodeToJsonElement(DeviceManager.magixFormat.serializer, it),
-                    sourceEndpoint = "test",
+                    sourceEndpoint = "source",
                 )
             }
 
@@ -76,7 +76,7 @@ internal class RemoteDeviceConnect {
             }
         }
 
-        val remoteDevice = virtualMagixEndpoint.remoteDevice(context, "test", Name.EMPTY)
+        val remoteDevice = virtualMagixEndpoint.remoteDevice(context, "source", "target", Name.EMPTY)
 
         assertContains(0.0..1.0, remoteDevice.read(TestDevice.value))
     }
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
index 057eb2e..e108b21 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
@@ -118,7 +118,7 @@ public class DeviceNameSpace(
         //Subscribe on properties updates
         device.onPropertyChange {
             nodes[property]?.let { node ->
-                val sourceTime = time?.let { DateTime(it.toJavaInstant()) }
+                val sourceTime = DateTime(time.toJavaInstant())
                 val newValue = value.toOpc(sourceTime = sourceTime)
                 if (node.value.value != newValue.value) {
                     node.value = newValue
diff --git a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt b/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
index e5d2e4a..e1b5a8d 100644
--- a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
+++ b/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
@@ -19,7 +19,6 @@ import space.kscience.dataforge.context.request
 import space.kscience.dataforge.io.IOPlugin
 import space.kscience.dataforge.io.workDirectory
 import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.meta.string
 import space.kscience.dataforge.misc.DFExperimental
 import space.kscience.dataforge.names.Name
@@ -39,9 +38,7 @@ internal fun StoreTransaction.writeMessage(message: DeviceMessage): Unit {
     message.targetDevice?.let {
         entity.setProperty(DeviceMessage::targetDevice.name, it.toString())
     }
-    message.time?.let {
-        entity.setProperty(DeviceMessage::targetDevice.name, it.toString())
-    }
+    entity.setProperty(DeviceMessage::targetDevice.name, message.time.toString())
     entity.setBlobString("json", Json.encodeToString(json))
 }
 
diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt
index f0bf1f2..04b633a 100644
--- a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt
+++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt
@@ -31,7 +31,7 @@ public fun DeviceManager.storeMessages(
     val storage = factory.build(context, meta)
     logger.debug { "Message storage with meta = $meta created" }
 
-    return hubMessageFlow(context).filter(filterCondition).onEach { message ->
+    return hubMessageFlow().filter(filterCondition).onEach { message ->
         storage.write(message)
     }.onCompletion {
         storage.close()
diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index 8c7b02f..fde716b 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -104,10 +104,9 @@ public fun Plot.plotDeviceProperty(
     coroutineScope: CoroutineScope = device.context,
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
-    val clock = device.context.clock
     val data = TimeData()
     device.propertyMessageFlow(propertyName).sample(sampling).transform {
-        data.append(it.time ?: clock.now(), it.value.extractValue())
+        data.append(it.time, it.value.extractValue())
         data.trim(maxAge, maxPoints, minPoints)
         emit(data)
     }.onEach {
diff --git a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
index 040d288..22d7e1c 100644
--- a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
+++ b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
@@ -103,8 +103,8 @@ suspend fun main() {
 
                         monitorEndpoint.subscribe(DeviceManager.magixFormat).onEach { (magixMessage, payload) ->
                             mutex.withLock {
-                                val delay = Clock.System.now() - payload.time!!
-                                latest[magixMessage.sourceEndpoint] = Clock.System.now() - payload.time!!
+                                val delay = Clock.System.now() - payload.time
+                                latest[magixMessage.sourceEndpoint] = Clock.System.now() - payload.time
                                 max[magixMessage.sourceEndpoint] =
                                     maxOf(delay, max[magixMessage.sourceEndpoint] ?: ZERO)
                             }

From 28ec2bc8b8135072ddb154abbaf17325f667cc41 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 4 Mar 2024 11:12:16 +0300
Subject: [PATCH 056/125] Add PropertyHistory API

---
 .../kscience/controls/misc/PropertyHistory.kt | 66 +++++++++++++++++++
 1 file changed, 66 insertions(+)
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt
new file mode 100644
index 0000000..5b58321
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt
@@ -0,0 +1,66 @@
+package space.kscience.controls.misc
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.*
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import space.kscience.controls.api.Device
+import space.kscience.controls.api.DeviceMessage
+import space.kscience.controls.api.PropertyChangedMessage
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.name
+import space.kscience.dataforge.meta.transformations.MetaConverter
+
+/**
+ * An interface for device property history.
+ */
+public interface PropertyHistory<T> {
+    /**
+     * Flow property values filtered by a time range. The implementation could flow it as a chunk or provide paging.
+     * So the resulting flow is allowed to suspend.
+     *
+     * If [until] is in the future, the resulting flow is potentially unlimited.
+     * Theoretically, it could be also unlimited if the event source keeps producing new event with timestamp in a given range.
+     */
+    public fun flowHistory(
+        from: Instant = Instant.DISTANT_PAST,
+        until: Instant = Clock.System.now(),
+    ): Flow<ValueWithTime<T>>
+}
+
+/**
+ * An in-memory property values history collector
+ */
+public class CollectedPropertyHistory<T>(
+    public val scope: CoroutineScope,
+    eventFlow: Flow<DeviceMessage>,
+    public val propertyName: String,
+    public val converter: MetaConverter<T>,
+    maxSize: Int = 1000,
+) : PropertyHistory<T> {
+
+    private val store: SharedFlow<ValueWithTime<T>> = eventFlow
+        .filterIsInstance<PropertyChangedMessage>()
+        .filter { it.property == propertyName }
+        .map { ValueWithTime(converter.metaToObject(it.value), it.time) }
+        .shareIn(scope, started = SharingStarted.Eagerly, replay = maxSize)
+
+    override fun flowHistory(from: Instant, until: Instant): Flow<ValueWithTime<T>> =
+        store.filter { it.time in from..until }
+}
+
+/**
+ * Collect and store in memory device property changes for a given property
+ */
+public fun <T> Device.collectPropertyHistory(
+    scope: CoroutineScope = this,
+    propertyName: String,
+    converter: MetaConverter<T>,
+    maxSize: Int = 1000,
+): PropertyHistory<T> = CollectedPropertyHistory(scope, messageFlow, propertyName, converter, maxSize)
+
+public fun <D : Device, T> D.collectPropertyHistory(
+    scope: CoroutineScope = this,
+    spec: DevicePropertySpec<D, T>,
+    maxSize: Int = 1000,
+): PropertyHistory<T> = collectPropertyHistory(scope, spec.name, spec.converter, maxSize)
\ No newline at end of file

From dbacdbc7cff379b573d538c279aa422cec5f235c Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 4 Mar 2024 12:47:40 +0300
Subject: [PATCH 057/125] Replace event controls-storage with async api

---
 .../xodus/XodusDeviceMessageStorage.kt        | 33 +++++++------------
 .../controls/storage/DeviceMessageStorage.kt  | 30 +++++++++++++++--
 .../controls/storage/propertyHistory.kt       | 20 +++++++++++
 .../controls/storage/storageCommon.kt         | 22 -------------
 4 files changed, 58 insertions(+), 47 deletions(-)
 create mode 100644 controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt

diff --git a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt b/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
index e1b5a8d..35c9b89 100644
--- a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
+++ b/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
@@ -4,9 +4,11 @@ import jetbrains.exodus.entitystore.Entity
 import jetbrains.exodus.entitystore.PersistentEntityStore
 import jetbrains.exodus.entitystore.PersistentEntityStores
 import jetbrains.exodus.entitystore.StoreTransaction
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
 import kotlinx.datetime.Instant
-import kotlinx.serialization.ExperimentalSerializationApi
-import kotlinx.serialization.descriptors.serialDescriptor
 import kotlinx.serialization.encodeToString
 import kotlinx.serialization.json.Json
 import kotlinx.serialization.json.jsonObject
@@ -65,12 +67,12 @@ public class XodusDeviceMessageStorage(
         }
     }
 
-    override suspend fun readAll(): List<DeviceMessage> = entityStore.computeInReadonlyTransaction { transaction ->
+    override fun readAll(): Flow<DeviceMessage> = entityStore.computeInReadonlyTransaction { transaction ->
         transaction.sort(
             DEVICE_MESSAGE_ENTITY_TYPE,
             DeviceMessage::time.name,
             true
-        ).map {
+        ).asFlow().map {
             Json.decodeFromString(
                 DeviceMessage.serializer(),
                 it.getBlobString("json") ?: error("No json content found")
@@ -78,17 +80,17 @@ public class XodusDeviceMessageStorage(
         }
     }
 
-    override suspend fun read(
+    override fun read(
         eventType: String,
         range: ClosedRange<Instant>?,
         sourceDevice: Name?,
         targetDevice: Name?,
-    ): List<DeviceMessage> = entityStore.computeInReadonlyTransaction { transaction ->
+    ): Flow<DeviceMessage> = entityStore.computeInReadonlyTransaction { transaction ->
         transaction.find(
             DEVICE_MESSAGE_ENTITY_TYPE,
             "type",
             eventType
-        ).asSequence().filter {
+        ).asFlow().filter {
             it.timeInRange(range) &&
                     it.propertyMatchesName(DeviceMessage::sourceDevice.name, sourceDevice) &&
                     it.propertyMatchesName(DeviceMessage::targetDevice.name, targetDevice)
@@ -97,7 +99,7 @@ public class XodusDeviceMessageStorage(
                 DeviceMessage.serializer(),
                 it.getBlobString("json") ?: error("No json content found")
             )
-        }.sortedBy { it.time }.toList()
+        }
     }
 
     override fun close() {
@@ -120,17 +122,4 @@ public class XodusDeviceMessageStorage(
             return XodusDeviceMessageStorage(entityStore)
         }
     }
-}
-
-/**
- * Query all messages of given type
- */
-@OptIn(ExperimentalSerializationApi::class)
-public suspend inline fun <reified T : DeviceMessage> XodusDeviceMessageStorage.query(
-    range: ClosedRange<Instant>? = null,
-    sourceDevice: Name? = null,
-    targetDevice: Name? = null,
-): List<T> = read(serialDescriptor<T>().serialName, range, sourceDevice, targetDevice).map {
-    //Check that all types are correct
-    it as T
-}
+}
\ No newline at end of file
diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt
index 87f4b74..b9cc84f 100644
--- a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt
+++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt
@@ -1,6 +1,10 @@
 package space.kscience.controls.storage
 
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
 import kotlinx.datetime.Instant
+import kotlinx.serialization.ExperimentalSerializationApi
+import kotlinx.serialization.descriptors.serialDescriptor
 import space.kscience.controls.api.DeviceMessage
 import space.kscience.dataforge.names.Name
 
@@ -10,14 +14,34 @@ import space.kscience.dataforge.names.Name
 public interface DeviceMessageStorage {
     public suspend fun write(event: DeviceMessage)
 
-    public suspend fun readAll(): List<DeviceMessage>
+    /**
+     * Return all messages in a storage as a flow
+     */
+    public fun readAll(): Flow<DeviceMessage>
 
-    public suspend fun read(
+    /**
+     * Flow messages with given [eventType] and filters by [range], [sourceDevice] and [targetDevice].
+     * Null in filters means that there is not filtering for this field.
+     */
+    public fun read(
         eventType: String,
         range: ClosedRange<Instant>? = null,
         sourceDevice: Name? = null,
         targetDevice: Name? = null,
-    ): List<DeviceMessage>
+    ): Flow<DeviceMessage>
 
     public fun close()
+}
+
+/**
+ * Query all messages of given type
+ */
+@OptIn(ExperimentalSerializationApi::class)
+public inline fun <reified T : DeviceMessage> DeviceMessageStorage.read(
+    range: ClosedRange<Instant>? = null,
+    sourceDevice: Name? = null,
+    targetDevice: Name? = null,
+): Flow<T> = read(serialDescriptor<T>().serialName, range, sourceDevice, targetDevice).map {
+    //Check that all types are correct
+    it as T
 }
\ No newline at end of file
diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt
new file mode 100644
index 0000000..0ca30cc
--- /dev/null
+++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt
@@ -0,0 +1,20 @@
+package space.kscience.controls.storage
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.map
+import kotlinx.datetime.Instant
+import space.kscience.controls.api.PropertyChangedMessage
+import space.kscience.controls.misc.PropertyHistory
+import space.kscience.controls.misc.ValueWithTime
+import space.kscience.dataforge.meta.transformations.MetaConverter
+
+public fun <T> DeviceMessageStorage.propertyHistory(
+    propertyName: String,
+    converter: MetaConverter<T>,
+): PropertyHistory<T> = object : PropertyHistory<T> {
+    override fun flowHistory(from: Instant, until: Instant): Flow<ValueWithTime<T>> =
+        read<PropertyChangedMessage>(from..until)
+            .filter { it.property == propertyName }
+            .map { ValueWithTime(converter.metaToObject(it.value), it.time) }
+}
\ No newline at end of file
diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt
index 04b633a..ed96efc 100644
--- a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt
+++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt
@@ -39,26 +39,4 @@ public fun DeviceManager.storeMessages(
     }.launchIn(context)
 }
 
-///**
-// * @return the list of deviceMessages that describes changes of specified property of specified device sorted by time
-// * @param sourceDeviceName a name of device, history of which property we want to get
-// * @param propertyName a name of property, history of which we want to get
-// * @param factory a factory that produce mongo clients
-// */
-//public suspend fun getPropertyHistory(
-//    sourceDeviceName: String,
-//    propertyName: String,
-//    factory: Factory<EventStorage>,
-//    meta: Meta = Meta.EMPTY,
-//): List<PropertyChangedMessage> {
-//    return factory(meta).use {
-//        it.getPropertyHistory(sourceDeviceName, propertyName)
-//    }
-//}
-//
-//
-//public enum class StorageKind {
-//    DEVICE_HUB,
-//    MAGIX_SERVER
-//}
 

From 2a700a5a2ab452c4fc8fa92a0d91f527ae633c18 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 4 Mar 2024 15:24:27 +0300
Subject: [PATCH 058/125] Migrate to DataForge 0.8.0

---
 build.gradle.kts                              |  4 ++--
 .../controls/constructor/DeviceConstructor.kt |  2 +-
 .../controls/constructor/DeviceGroup.kt       |  6 +-----
 .../controls/constructor/DeviceState.kt       | 18 ++++++++---------
 .../kscience/controls/constructor/Drive.kt    |  2 +-
 .../controls/constructor/Regulator.kt         |  2 +-
 .../controls/constructor/customState.kt       |  2 +-
 .../space/kscience/controls/api/Device.kt     |  2 +-
 .../controls/manager/respondMessage.kt        |  4 ++--
 .../kscience/controls/misc/PropertyHistory.kt |  4 ++--
 .../kscience/controls/misc/ValueWithTime.kt   | 20 ++++++++-----------
 .../kscience/controls/spec/DeviceBase.kt      | 10 +++++-----
 .../controls/spec/DeviceMetaPropertySpec.kt   |  4 ++--
 .../controls/spec/DevicePropertySpec.kt       | 18 ++++++++---------
 .../kscience/controls/spec/DeviceSpec.kt      | 10 +++-------
 .../space/kscience/controls/spec/misc.kt      | 13 ++++--------
 .../controls/spec/propertySpecDelegates.kt    |  6 +++---
 .../src/jsMain/kotlin/commonJupyter.kt        |  2 +-
 .../kscience/controls/client/DeviceClient.kt  |  6 +++---
 .../controls/client/clientPropertyAccess.kt   | 20 +++++++++----------
 .../kscience/controls/client/tangoMagix.kt    |  6 +++---
 .../controls/opcua/client/OpcUaDevice.kt      |  8 ++++----
 .../opcua/client/OpcUaDeviceBySpec.kt         |  2 +-
 .../controls/opcua/client/OpcUaClientTest.kt  |  2 +-
 .../xodus/XodusDeviceMessageStorage.kt        | 10 ++++------
 .../src/test/kotlin/PropertyHistoryTest.kt    |  5 +++--
 .../controls/storage/propertyHistory.kt       |  4 ++--
 .../src/commonMain/kotlin/plotExtensions.kt   |  2 +-
 .../jsMain/kotlin/ControlsVisionPlugin.js.kt  |  5 ++---
 controls-vision/src/jsMain/kotlin/client.kt   |  2 +-
 demo/all-things/build.gradle.kts              |  2 +-
 .../kscience/controls/demo/DemoDevice.kt      |  2 +-
 .../controls/demo/car/MagixVirtualCar.kt      |  2 +-
 .../kscience/controls/demo/car/VirtualCar.kt  | 20 ++++++-------------
 .../sciprog/devices/mks/MksPdr900Device.kt    |  4 ++--
 .../pimotionmaster/PiMotionMasterDevice.kt    |  6 +-----
 36 files changed, 103 insertions(+), 134 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index aebfc4f..b0b93d5 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -5,8 +5,8 @@ plugins {
     id("space.kscience.gradle.project")
 }
 
-val dataforgeVersion: String by extra("0.7.1")
-val visionforgeVersion by extra("0.3.1")
+val dataforgeVersion: String by extra("0.8.0")
+val visionforgeVersion by extra("0.4.0")
 val ktorVersion: String by extra(space.kscience.gradle.KScienceVersions.ktorVersion)
 val rsocketVersion by extra("0.15.4")
 val xodusVersion by extra("2.0.1")
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 5dbbe13..5c11f27 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -5,7 +5,7 @@ import space.kscience.controls.api.PropertyDescriptor
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.names.Name
 import space.kscience.dataforge.names.asName
 import kotlin.properties.PropertyDelegateProvider
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index 26b5896..6cdb784 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -12,11 +12,7 @@ import space.kscience.controls.manager.install
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.context.request
-import space.kscience.dataforge.meta.Laminate
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.MutableMeta
-import space.kscience.dataforge.meta.get
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.*
 import space.kscience.dataforge.misc.DFExperimental
 import space.kscience.dataforge.names.*
 import kotlin.collections.set
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index 672b0a6..1828edd 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -10,7 +10,7 @@ import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.MutableDevicePropertySpec
 import space.kscience.controls.spec.name
 import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
 import kotlin.time.Duration
 
 /**
@@ -25,9 +25,9 @@ public interface DeviceState<T> {
     public companion object
 }
 
-public val <T> DeviceState<T>.metaFlow: Flow<Meta> get() = valueFlow.map(converter::objectToMeta)
+public val <T> DeviceState<T>.metaFlow: Flow<Meta> get() = valueFlow.map(converter::convert)
 
-public val <T> DeviceState<T>.valueAsMeta: Meta get() = converter.objectToMeta(value)
+public val <T> DeviceState<T>.valueAsMeta: Meta get() = converter.convert(value)
 
 
 /**
@@ -38,9 +38,9 @@ public interface MutableDeviceState<T> : DeviceState<T> {
 }
 
 public var <T : Any> MutableDeviceState<T>.valueAsMeta: Meta
-    get() = converter.objectToMeta(value)
+    get() = converter.convert(value)
     set(arg) {
-        value = converter.metaToObject(arg)
+        value = converter.read(arg)
     }
 
 /**
@@ -98,7 +98,7 @@ private open class BoundDeviceState<T>(
     override val valueFlow: StateFlow<T> = device.messageFlow.filterIsInstance<PropertyChangedMessage>().filter {
         it.property == propertyName
     }.mapNotNull {
-        converter.metaToObject(it.value)
+        converter.read(it.value)
     }.stateIn(device.context, SharingStarted.Eagerly, initialValue)
 
     override val value: T get() = valueFlow.value
@@ -111,7 +111,7 @@ public suspend fun <T> Device.propertyAsState(
     propertyName: String,
     metaConverter: MetaConverter<T>,
 ): DeviceState<T> {
-    val initialValue = metaConverter.metaToObject(readProperty(propertyName)) ?: error("Conversion of property failed")
+    val initialValue = metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed")
     return BoundDeviceState(metaConverter, this, propertyName, initialValue)
 }
 
@@ -140,7 +140,7 @@ private class MutableBoundDeviceState<T>(
         get() = valueFlow.value
         set(newValue) {
             device.launch {
-                device.writeProperty(propertyName, converter.objectToMeta(newValue))
+                device.writeProperty(propertyName, converter.convert(newValue))
             }
         }
 }
@@ -155,7 +155,7 @@ public suspend fun <T> Device.mutablePropertyAsState(
     propertyName: String,
     metaConverter: MetaConverter<T>,
 ): MutableDeviceState<T> {
-    val initialValue = metaConverter.metaToObject(readProperty(propertyName)) ?: error("Conversion of property failed")
+    val initialValue = metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed")
     return mutablePropertyAsState(propertyName, metaConverter, initialValue)
 }
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
index c2391c8..8301622 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
@@ -9,9 +9,9 @@ import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.meta.double
 import space.kscience.dataforge.meta.get
-import space.kscience.dataforge.meta.transformations.MetaConverter
 import kotlin.math.pow
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.DurationUnit
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
index 7495d33..cbe6967 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
@@ -2,7 +2,7 @@ package space.kscience.controls.constructor
 
 import space.kscience.controls.api.Device
 import space.kscience.controls.spec.*
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
 
 
 /**
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
index 6e95ccd..100f755 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
@@ -2,7 +2,7 @@ package space.kscience.controls.constructor
 
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
 
 
 /**
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index 89040c0..3226422 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -139,7 +139,7 @@ public interface CachingDevice : Device {
 /**
  * Get the logical state of property or suspend to read the physical value.
  */
-public suspend fun Device.requestProperty(propertyName: String): Meta = if (this is CachingDevice) {
+public suspend fun Device.getOrReadProperty(propertyName: String): Meta = if (this is CachingDevice) {
     getProperty(propertyName) ?: readProperty(propertyName)
 } else {
     readProperty(propertyName)
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 3a0ac7b..fc6fb5d 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
@@ -16,7 +16,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
         is PropertyGetMessage -> {
             PropertyChangedMessage(
                 property = request.property,
-                value = requestProperty(request.property),
+                value = getOrReadProperty(request.property),
                 sourceDevice = deviceTarget,
                 targetDevice = request.sourceDevice
             )
@@ -26,7 +26,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
             writeProperty(request.property, request.value)
             PropertyChangedMessage(
                 property = request.property,
-                value = requestProperty(request.property),
+                value = getOrReadProperty(request.property),
                 sourceDevice = deviceTarget,
                 targetDevice = request.sourceDevice
             )
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt
index 5b58321..96bd8d5 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt
@@ -9,7 +9,7 @@ import space.kscience.controls.api.DeviceMessage
 import space.kscience.controls.api.PropertyChangedMessage
 import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.name
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
 
 /**
  * An interface for device property history.
@@ -42,7 +42,7 @@ public class CollectedPropertyHistory<T>(
     private val store: SharedFlow<ValueWithTime<T>> = eventFlow
         .filterIsInstance<PropertyChangedMessage>()
         .filter { it.property == propertyName }
-        .map { ValueWithTime(converter.metaToObject(it.value), it.time) }
+        .map { ValueWithTime(converter.read(it.value), it.time) }
         .shareIn(scope, started = SharingStarted.Eagerly, replay = maxSize)
 
     override fun flowHistory(from: Instant, until: Instant): Flow<ValueWithTime<T>> =
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt
index 0bf07d3..f25c4f4 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt
@@ -5,10 +5,8 @@ import kotlinx.io.Sink
 import kotlinx.io.Source
 import space.kscience.dataforge.io.IOFormat
 import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.meta.get
-import space.kscience.dataforge.meta.transformations.MetaConverter
-import kotlin.reflect.KType
-import kotlin.reflect.typeOf
 
 /**
  * A value coupled to a time it was obtained at
@@ -36,8 +34,6 @@ public data class ValueWithTime<T>(val value: T, val time: Instant) {
 }
 
 private class ValueWithTimeIOFormat<T>(val valueFormat: IOFormat<T>) : IOFormat<ValueWithTime<T>> {
-    override val type: KType get() = typeOf<ValueWithTime<T>>()
-
 
     override fun readFrom(source: Source): ValueWithTime<T> {
         val timestamp = InstantIOFormat.readFrom(source)
@@ -56,18 +52,18 @@ private class ValueWithTimeMetaConverter<T>(
     val valueConverter: MetaConverter<T>,
 ) : MetaConverter<ValueWithTime<T>> {
 
-    override val type: KType = typeOf<ValueWithTime<T>>()
 
-    override fun metaToObjectOrNull(
-        meta: Meta,
-    ): ValueWithTime<T>? = valueConverter.metaToObject(meta[ValueWithTime.META_VALUE_KEY] ?: Meta.EMPTY)?.let {
-        ValueWithTime(it, meta[ValueWithTime.META_TIME_KEY]?.instant ?: Instant.DISTANT_PAST)
+    override fun readOrNull(
+        source: Meta,
+    ): ValueWithTime<T>? = valueConverter.read(source[ValueWithTime.META_VALUE_KEY] ?: Meta.EMPTY)?.let {
+        ValueWithTime(it, source[ValueWithTime.META_TIME_KEY]?.instant ?: Instant.DISTANT_PAST)
     }
 
-    override fun objectToMeta(obj: ValueWithTime<T>): Meta = Meta {
+    override fun convert(obj: ValueWithTime<T>): Meta = Meta {
         ValueWithTime.META_TIME_KEY put obj.time.toMeta()
-        ValueWithTime.META_VALUE_KEY put valueConverter.objectToMeta(obj.value)
+        ValueWithTime.META_VALUE_KEY put valueConverter.convert(obj.value)
     }
 }
 
+
 public fun <T : Any> MetaConverter<T>.withTime(): MetaConverter<ValueWithTime<T>> = ValueWithTimeMetaConverter(this)
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index 846815c..dcb48b4 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -21,7 +21,7 @@ import kotlin.coroutines.CoroutineContext
  */
 @OptIn(InternalDeviceAPI::class)
 private suspend fun <D : Device, T> MutableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) {
-    write(device, converter.metaToObject(item) ?: error("Meta $item could not be read with $converter"))
+    write(device, converter.readOrNull(item) ?: error("Meta $item could not be read with $converter"))
 }
 
 /**
@@ -29,16 +29,16 @@ private suspend fun <D : Device, T> MutableDevicePropertySpec<D, T>.writeMeta(de
  */
 @OptIn(InternalDeviceAPI::class)
 private suspend fun <D : Device, T> DevicePropertySpec<D, T>.readMeta(device: D): Meta? =
-    read(device)?.let(converter::objectToMeta)
+    read(device)?.let(converter::convert)
 
 
 private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta(
     device: D,
     item: Meta,
 ): Meta? {
-    val arg: I = inputConverter.metaToObject(item) ?: error("Failed to convert $item with $inputConverter")
+    val arg: I = inputConverter.readOrNull(item) ?: error("Failed to convert $item with $inputConverter")
     val res = execute(device, arg)
-    return res?.let { outputConverter.objectToMeta(res) }
+    return res?.let { outputConverter.convert(res) }
 }
 
 
@@ -120,7 +120,7 @@ public abstract class DeviceBase<D : Device>(
      * Notify the device that a property with [spec] value is changed
      */
     protected suspend fun <T> propertyChanged(spec: DevicePropertySpec<D, T>, value: T) {
-        propertyChanged(spec.name, spec.converter.objectToMeta(value))
+        propertyChanged(spec.name, spec.converter.convert(value))
     }
 
     /**
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt
index 809d940..c5fe8b5 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt
@@ -3,9 +3,9 @@ package space.kscience.controls.spec
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.PropertyDescriptor
 import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
 
-internal object DeviceMetaPropertySpec: DevicePropertySpec<Device,Meta> {
+internal object DeviceMetaPropertySpec : DevicePropertySpec<Device, Meta> {
     override val descriptor: PropertyDescriptor = PropertyDescriptor("@meta")
 
     override val converter: MetaConverter<Meta> = MetaConverter.meta
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
index 8e0d6de..edf9f93 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt
@@ -5,7 +5,7 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.launch
 import space.kscience.controls.api.*
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
 
 
 /**
@@ -72,23 +72,23 @@ public interface DeviceActionSpec<in D, I, O> {
 public val DeviceActionSpec<*, *, *>.name: String get() = descriptor.name
 
 public suspend fun <T, D : Device> D.read(propertySpec: DevicePropertySpec<D, T>): T =
-    propertySpec.converter.metaToObject(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
+    propertySpec.converter.readOrNull(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
 
 /**
  * Read typed value and update/push event if needed.
  * Return null if property read is not successful or property is undefined.
  */
 public suspend fun <T, D : DeviceBase<D>> D.readOrNull(propertySpec: DevicePropertySpec<D, T>): T? =
-    readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::metaToObject)
+    readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::readOrNull)
 
-public suspend fun <T, D : Device> D.request(propertySpec: DevicePropertySpec<D, T>): T =
-    propertySpec.converter.metaToObject(requestProperty(propertySpec.name))
+public suspend fun <T, D : Device> D.getOrRead(propertySpec: DevicePropertySpec<D, T>): T =
+    propertySpec.converter.read(getOrReadProperty(propertySpec.name))
 
 /**
  * Write typed property state and invalidate logical state
  */
 public suspend fun <T, D : Device> D.write(propertySpec: MutableDevicePropertySpec<D, T>, value: T) {
-    writeProperty(propertySpec.name, propertySpec.converter.objectToMeta(value))
+    writeProperty(propertySpec.name, propertySpec.converter.convert(value))
 }
 
 /**
@@ -104,7 +104,7 @@ public fun <T, D : Device> D.writeAsync(propertySpec: MutableDevicePropertySpec<
 public fun <D : Device, T> D.propertyFlow(spec: DevicePropertySpec<D, T>): Flow<T> = messageFlow
     .filterIsInstance<PropertyChangedMessage>()
     .filter { it.property == spec.name }
-    .mapNotNull { spec.converter.metaToObject(it.value) }
+    .mapNotNull { spec.converter.read(it.value) }
 
 /**
  * A type safe property change listener. Uses the device [CoroutineScope].
@@ -117,7 +117,7 @@ public fun <D : Device, T> D.onPropertyChange(
     .filterIsInstance<PropertyChangedMessage>()
     .filter { it.property == spec.name }
     .onEach { change ->
-        val newValue = spec.converter.metaToObject(change.value)
+        val newValue = spec.converter.read(change.value)
         if (newValue != null) {
             change.callback(newValue)
         }
@@ -136,7 +136,7 @@ public fun <D : Device, T> D.useProperty(
         .filterIsInstance<PropertyChangedMessage>()
         .filter { it.property == spec.name }
         .collect { change ->
-            val newValue = spec.converter.metaToObject(change.value)
+            val newValue = spec.converter.readOrNull(change.value)
             if (newValue != null) {
                 callback(newValue)
             }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index 4091921..2f90cb8 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -5,20 +5,16 @@ import space.kscience.controls.api.ActionDescriptor
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.PropertyDescriptor
 import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
 import kotlin.properties.PropertyDelegateProvider
 import kotlin.properties.ReadOnlyProperty
 import kotlin.reflect.KProperty
-import kotlin.reflect.KType
-import kotlin.reflect.typeOf
 
 public object UnitMetaConverter : MetaConverter<Unit> {
 
-    override val type: KType = typeOf<Unit>()
+    override fun readOrNull(source: Meta): Unit = Unit
 
-    override fun metaToObjectOrNull(meta: Meta): Unit = Unit
-
-    override fun objectToMeta(obj: Unit): Meta = Meta.EMPTY
+    override fun convert(obj: Unit): Meta = Meta.EMPTY
 }
 
 public val MetaConverter.Companion.unit: MetaConverter<Unit> get() = UnitMetaConverter
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt
index ee14237..1fc4649 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt
@@ -1,9 +1,6 @@
 package space.kscience.controls.spec
 
 import space.kscience.dataforge.meta.*
-import space.kscience.dataforge.meta.transformations.MetaConverter
-import kotlin.reflect.KType
-import kotlin.reflect.typeOf
 import kotlin.time.Duration
 import kotlin.time.DurationUnit
 import kotlin.time.toDuration
@@ -12,16 +9,14 @@ public fun Double.asMeta(): Meta = Meta(asValue())
 
 //TODO to be moved to DF
 public object DurationConverter : MetaConverter<Duration> {
-    override val type: KType = typeOf<Duration>()
-
-    override fun metaToObjectOrNull(meta: Meta): Duration = meta.value?.double?.toDuration(DurationUnit.SECONDS)
+    override fun readOrNull(source: Meta): Duration = source.value?.double?.toDuration(DurationUnit.SECONDS)
         ?: run {
-            val unit: DurationUnit = meta["unit"].enum<DurationUnit>() ?: DurationUnit.SECONDS
-            val value = meta[Meta.VALUE_KEY].double ?: error("No value present for Duration")
+            val unit: DurationUnit = source["unit"].enum<DurationUnit>() ?: DurationUnit.SECONDS
+            val value = source[Meta.VALUE_KEY].double ?: error("No value present for Duration")
             return@run value.toDuration(unit)
         }
 
-    override fun objectToMeta(obj: Duration): Meta = obj.toDouble(DurationUnit.SECONDS).asMeta()
+    override fun convert(obj: Duration): Meta = obj.toDouble(DurationUnit.SECONDS).asMeta()
 }
 
 public val MetaConverter.Companion.duration: MetaConverter<Duration> get() = DurationConverter
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
index 231891c..8bd22b6 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
@@ -4,8 +4,8 @@ import space.kscience.controls.api.Device
 import space.kscience.controls.api.PropertyDescriptor
 import space.kscience.controls.api.metaDescriptor
 import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.meta.ValueType
-import space.kscience.dataforge.meta.transformations.MetaConverter
 import kotlin.properties.PropertyDelegateProvider
 import kotlin.properties.ReadOnlyProperty
 import kotlin.reflect.KMutableProperty1
@@ -53,8 +53,8 @@ public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
         converter,
         descriptorBuilder,
         name,
-        read = { propertyName -> getProperty(propertyName)?.let(converter::metaToObject) },
-        write = { propertyName, value -> writeProperty(propertyName, converter.objectToMeta(value)) }
+        read = { propertyName -> getProperty(propertyName)?.let(converter::readOrNull) },
+        write = { propertyName, value -> writeProperty(propertyName, converter.convert(value)) }
     )
 
 
diff --git a/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt b/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt
index 41711d7..388e1ab 100644
--- a/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt
+++ b/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt
@@ -1,7 +1,7 @@
+import space.kscience.visionforge.html.runVisionClient
 import space.kscience.visionforge.jupyter.VFNotebookClient
 import space.kscience.visionforge.markup.MarkupPlugin
 import space.kscience.visionforge.plotly.PlotlyPlugin
-import space.kscience.visionforge.runVisionClient
 
 public fun main(): Unit = runVisionClient {
 //    plugin(DeviceManager)
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index 31dbd89..51836b9 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
@@ -144,7 +144,7 @@ public fun <T> MagixEndpoint.controlsPropertyFlow(
         .filter { message ->
             message.sourceDevice == deviceName && message.property == propertySpec.name
         }.map {
-            propertySpec.converter.metaToObject(it.value)
+            propertySpec.converter.read(it.value)
         }
 }
 
@@ -157,7 +157,7 @@ public suspend fun <T> MagixEndpoint.sendControlsPropertyChange(
 ) {
     val message = PropertySetMessage(
         property = propertySpec.name,
-        value = propertySpec.converter.objectToMeta(value),
+        value = propertySpec.converter.convert(value),
         targetDevice = deviceName
     )
     send(DeviceManager.magixFormat, message, source = sourceEndpointName, target = targetEndpointName)
@@ -177,6 +177,6 @@ public fun <T> MagixEndpoint.controlsPropertyMessageFlow(
         .filter { message ->
             message.sourceDevice == deviceName && message.property == propertySpec.name
         }.map {
-            it to propertySpec.converter.metaToObject(it.value)
+            it to propertySpec.converter.read(it.value)
         }
 }
\ No newline at end of file
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt
index 22b0413..20c59e8 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt
@@ -5,7 +5,7 @@ import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.launch
 import space.kscience.controls.api.PropertyChangedMessage
-import space.kscience.controls.api.requestProperty
+import space.kscience.controls.api.getOrReadProperty
 import space.kscience.controls.spec.DeviceActionSpec
 import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.MutableDevicePropertySpec
@@ -17,14 +17,14 @@ import space.kscience.dataforge.meta.Meta
  * An accessor that allows DeviceClient to connect to any property without type checks
  */
 public suspend fun <T> DeviceClient.read(propertySpec: DevicePropertySpec<*, T>): T =
-    propertySpec.converter.metaToObject(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
+    propertySpec.converter.readOrNull(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
 
 
 public suspend fun <T> DeviceClient.request(propertySpec: DevicePropertySpec<*, T>): T =
-    propertySpec.converter.metaToObject(requestProperty(propertySpec.name))
+    propertySpec.converter.read(getOrReadProperty(propertySpec.name))
 
 public suspend fun <T> DeviceClient.write(propertySpec: MutableDevicePropertySpec<*, T>, value: T) {
-    writeProperty(propertySpec.name, propertySpec.converter.objectToMeta(value))
+    writeProperty(propertySpec.name, propertySpec.converter.convert(value))
 }
 
 public fun <T> DeviceClient.writeAsync(propertySpec: MutableDevicePropertySpec<*, T>, value: T): Job = launch {
@@ -34,7 +34,7 @@ public fun <T> DeviceClient.writeAsync(propertySpec: MutableDevicePropertySpec<*
 public fun <T> DeviceClient.propertyFlow(spec: DevicePropertySpec<*, T>): Flow<T> = messageFlow
     .filterIsInstance<PropertyChangedMessage>()
     .filter { it.property == spec.name }
-    .mapNotNull { spec.converter.metaToObject(it.value) }
+    .mapNotNull { spec.converter.readOrNull(it.value) }
 
 public fun <T> DeviceClient.onPropertyChange(
     spec: DevicePropertySpec<*, T>,
@@ -44,7 +44,7 @@ public fun <T> DeviceClient.onPropertyChange(
     .filterIsInstance<PropertyChangedMessage>()
     .filter { it.property == spec.name }
     .onEach { change ->
-        val newValue = spec.converter.metaToObject(change.value)
+        val newValue = spec.converter.readOrNull(change.value)
         if (newValue != null) {
             change.callback(newValue)
         }
@@ -60,7 +60,7 @@ public fun <T> DeviceClient.useProperty(
         .filterIsInstance<PropertyChangedMessage>()
         .filter { it.property == spec.name }
         .collect { change ->
-            val newValue = spec.converter.metaToObject(change.value)
+            val newValue = spec.converter.readOrNull(change.value)
             if (newValue != null) {
                 callback(newValue)
             }
@@ -68,12 +68,12 @@ public fun <T> DeviceClient.useProperty(
 }
 
 public suspend fun <I, O> DeviceClient.execute(actionSpec: DeviceActionSpec<*, I, O>, input: I): O {
-    val inputMeta = actionSpec.inputConverter.objectToMeta(input)
+    val inputMeta = actionSpec.inputConverter.convert(input)
     val res = execute(actionSpec.name, inputMeta)
-    return actionSpec.outputConverter.metaToObject(res ?: Meta.EMPTY)
+    return actionSpec.outputConverter.read(res ?: Meta.EMPTY)
 }
 
 public suspend fun <O> DeviceClient.execute(actionSpec: DeviceActionSpec<*, Unit, O>): O {
     val res = execute(actionSpec.name, Meta.EMPTY)
-    return actionSpec.outputConverter.metaToObject(res ?: Meta.EMPTY)
+    return actionSpec.outputConverter.read(res ?: Meta.EMPTY)
 }
\ No newline at end of file
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt
index 9c30f8c..d0bdda4 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt
@@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
 import kotlinx.serialization.Serializable
 import space.kscience.controls.api.get
-import space.kscience.controls.api.requestProperty
+import space.kscience.controls.api.getOrReadProperty
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.dataforge.context.error
 import space.kscience.dataforge.context.logger
@@ -91,7 +91,7 @@ public fun DeviceManager.launchTangoMagix(
                 val device = get(payload.device)
                 when (payload.action) {
                     TangoAction.read -> {
-                        val value = device.requestProperty(payload.name)
+                        val value = device.getOrReadProperty(payload.name)
                         respond(request, payload) { requestPayload ->
                             requestPayload.copy(
                                 value = value,
@@ -104,7 +104,7 @@ public fun DeviceManager.launchTangoMagix(
                             device.writeProperty(payload.name, value)
                         }
                         //wait for value to be written and return final state
-                        val value = device.requestProperty(payload.name)
+                        val value = device.getOrReadProperty(payload.name)
                         respond(request, payload) { requestPayload ->
                             requestPayload.copy(
                                 value = value,
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt
index 51e6032..08a84b0 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt
@@ -9,8 +9,8 @@ import org.eclipse.milo.opcua.stack.core.types.builtin.*
 import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn
 import space.kscience.controls.api.Device
 import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.meta.MetaSerializer
-import space.kscience.dataforge.meta.transformations.MetaConverter
 import kotlin.properties.ReadWriteProperty
 import kotlin.reflect.KProperty
 
@@ -43,7 +43,7 @@ public suspend inline fun <reified T: Any> OpcUaDevice.readOpcWithTime(
         else -> error("Incompatible OPC property value $content")
     }
 
-    val res: T = converter.metaToObject(meta)
+    val res: T = converter.read(meta)
     return res to time
 }
 
@@ -69,7 +69,7 @@ public suspend inline fun <reified T> OpcUaDevice.readOpc(
         else -> error("Incompatible OPC property value $content")
     }
 
-    return converter.metaToObject(meta) ?: error("Meta $meta could not be converted to ${T::class}")
+    return converter.readOrNull(meta) ?: error("Meta $meta could not be converted to ${T::class}")
 }
 
 public suspend inline fun <reified T> OpcUaDevice.writeOpc(
@@ -77,7 +77,7 @@ public suspend inline fun <reified T> OpcUaDevice.writeOpc(
     converter: MetaConverter<T>,
     value: T
 ): StatusCode {
-    val meta = converter.objectToMeta(value)
+    val meta = converter.convert(value)
     return client.writeValue(nodeId, DataValue(Variant(meta))).await()
 }
 
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt
index 188760e..7debc0e 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt
@@ -31,7 +31,7 @@ public class MiloConfiguration : Scheme() {
 
     public var endpointUrl: String by string { error("Endpoint url is not defined") }
 
-    public var username: MiloUsername? by specOrNull(MiloUsername)
+    public var username: MiloUsername? by schemeOrNull(MiloUsername)
 
     public var securityPolicy: SecurityPolicy by enum(SecurityPolicy.None)
 
diff --git a/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt b/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt
index 33aa8fb..b4d63ad 100644
--- a/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt
+++ b/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt
@@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test
 import space.kscience.controls.spec.DeviceSpec
 import space.kscience.controls.spec.doubleProperty
 import space.kscience.controls.spec.read
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
 import kotlin.test.Ignore
 
 class OpcUaClientTest {
diff --git a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt b/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
index 35c9b89..559d2a0 100644
--- a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
+++ b/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
@@ -6,8 +6,6 @@ import jetbrains.exodus.entitystore.PersistentEntityStores
 import jetbrains.exodus.entitystore.StoreTransaction
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.filter
-import kotlinx.coroutines.flow.map
 import kotlinx.datetime.Instant
 import kotlinx.serialization.encodeToString
 import kotlinx.serialization.json.Json
@@ -72,13 +70,13 @@ public class XodusDeviceMessageStorage(
             DEVICE_MESSAGE_ENTITY_TYPE,
             DeviceMessage::time.name,
             true
-        ).asFlow().map {
+        ).map {
             Json.decodeFromString(
                 DeviceMessage.serializer(),
                 it.getBlobString("json") ?: error("No json content found")
             )
         }
-    }
+    }.asFlow()
 
     override fun read(
         eventType: String,
@@ -90,7 +88,7 @@ public class XodusDeviceMessageStorage(
             DEVICE_MESSAGE_ENTITY_TYPE,
             "type",
             eventType
-        ).asFlow().filter {
+        ).filter {
             it.timeInRange(range) &&
                     it.propertyMatchesName(DeviceMessage::sourceDevice.name, sourceDevice) &&
                     it.propertyMatchesName(DeviceMessage::targetDevice.name, targetDevice)
@@ -100,7 +98,7 @@ public class XodusDeviceMessageStorage(
                 it.getBlobString("json") ?: error("No json content found")
             )
         }
-    }
+    }.asFlow()
 
     override fun close() {
         entityStore.close()
diff --git a/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt b/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt
index 1724079..e7017b1 100644
--- a/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt
+++ b/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt
@@ -1,5 +1,6 @@
 import jetbrains.exodus.entitystore.PersistentEntityStores
 import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.test.runTest
 import kotlinx.datetime.Instant
 import org.junit.jupiter.api.AfterAll
@@ -7,8 +8,8 @@ import org.junit.jupiter.api.Assertions.assertEquals
 import org.junit.jupiter.api.BeforeAll
 import org.junit.jupiter.api.Test
 import space.kscience.controls.api.PropertyChangedMessage
+import space.kscience.controls.storage.read
 import space.kscience.controls.xodus.XodusDeviceMessageStorage
-import space.kscience.controls.xodus.query
 import space.kscience.controls.xodus.writeMessage
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.names.Name
@@ -67,7 +68,7 @@ internal class PropertyHistoryTest {
             XodusDeviceMessageStorage(entityStore).use { storage ->
                 assertEquals(
                     propertyChangedMessages[0],
-                    storage.query<PropertyChangedMessage>(
+                    storage.read<PropertyChangedMessage>(
                         sourceDevice = "virtual-car".asName()
                     ).first { it.property == "speed" }
                 )
diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt
index 0ca30cc..b52ed2b 100644
--- a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt
+++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt
@@ -7,7 +7,7 @@ import kotlinx.datetime.Instant
 import space.kscience.controls.api.PropertyChangedMessage
 import space.kscience.controls.misc.PropertyHistory
 import space.kscience.controls.misc.ValueWithTime
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
 
 public fun <T> DeviceMessageStorage.propertyHistory(
     propertyName: String,
@@ -16,5 +16,5 @@ public fun <T> DeviceMessageStorage.propertyHistory(
     override fun flowHistory(from: Instant, until: Instant): Flow<ValueWithTime<T>> =
         read<PropertyChangedMessage>(from..until)
             .filter { it.property == propertyName }
-            .map { ValueWithTime(converter.metaToObject(it.value), it.time) }
+            .map { ValueWithTime(converter.read(it.value), it.time) }
 }
\ No newline at end of file
diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index fde716b..6e92165 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -149,7 +149,7 @@ private fun <T> Trace.updateFromState(
 public fun <T> Plot.plotDeviceState(
     context: Context,
     state: DeviceState<T>,
-    extractValue: T.() -> Value = { state.converter.objectToMeta(this).value ?: Null },
+    extractValue: T.() -> Value = { state.converter.convert(this).value ?: Null },
     maxAge: Duration = defaultMaxAge,
     maxPoints: Int = defaultMaxPoints,
     minPoints: Int = defaultMinPoints,
diff --git a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
index b34cc44..017989b 100644
--- a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
+++ b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
@@ -7,10 +7,9 @@ import space.kscience.dataforge.context.PluginFactory
 import space.kscience.dataforge.context.PluginTag
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.names.Name
-import space.kscience.visionforge.ElementVisionRenderer
 import space.kscience.visionforge.Vision
-import space.kscience.visionforge.VisionClient
 import space.kscience.visionforge.VisionPlugin
+import space.kscience.visionforge.html.ElementVisionRenderer
 
 public actual class ControlVisionPlugin : VisionPlugin(), ElementVisionRenderer {
     override val tag: PluginTag get() = Companion.tag
@@ -21,7 +20,7 @@ public actual class ControlVisionPlugin : VisionPlugin(), ElementVisionRenderer
         TODO("Not yet implemented")
     }
 
-    override fun render(element: Element, client: VisionClient, name: Name, vision: Vision, meta: Meta) {
+    override fun render(element: Element, name: Name, vision: Vision, meta: Meta) {
         TODO("Not yet implemented")
     }
 
diff --git a/controls-vision/src/jsMain/kotlin/client.kt b/controls-vision/src/jsMain/kotlin/client.kt
index a835f9a..78da2e8 100644
--- a/controls-vision/src/jsMain/kotlin/client.kt
+++ b/controls-vision/src/jsMain/kotlin/client.kt
@@ -1,8 +1,8 @@
 package space.kscience.controls.vision
 
+import space.kscience.visionforge.html.runVisionClient
 import space.kscience.visionforge.markup.MarkupPlugin
 import space.kscience.visionforge.plotly.PlotlyPlugin
-import space.kscience.visionforge.runVisionClient
 
 public fun main(): Unit = runVisionClient {
     plugin(PlotlyPlugin)
diff --git a/demo/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts
index 6888d35..1496f67 100644
--- a/demo/all-things/build.gradle.kts
+++ b/demo/all-things/build.gradle.kts
@@ -24,7 +24,7 @@ dependencies {
 
     implementation("io.ktor:ktor-client-cio:$ktorVersion")
     implementation("no.tornado:tornadofx:1.7.20")
-    implementation("space.kscience:plotlykt-server:0.6.1")
+    implementation("space.kscience:plotlykt-server:0.7.1")
 //    implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6")
     implementation(spclibs.logback.classic)
 }
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
index 657d275..5cf1b28 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
@@ -7,9 +7,9 @@ import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.meta.ValueType
 import space.kscience.dataforge.meta.descriptors.value
-import space.kscience.dataforge.meta.transformations.MetaConverter
 import java.time.Instant
 import kotlin.math.cos
 import kotlin.math.sin
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt
index 2c5d65a..27ea243 100644
--- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt
+++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt
@@ -22,7 +22,7 @@ class MagixVirtualCar(context: Context, meta: Meta) : VirtualCar(context, meta)
             (payload as? PropertyChangedMessage)?.let { message ->
                 if (message.sourceDevice == Name.parse("virtual-car")) {
                     when (message.property) {
-                        "acceleration" -> write(IVirtualCar.acceleration, Vector2D.metaToObject(message.value))
+                        "acceleration" -> write(IVirtualCar.acceleration, Vector2D.read(message.value))
                     }
                 }
             }
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
index 05bb2f0..86564a5 100644
--- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
+++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
@@ -11,34 +11,26 @@ import space.kscience.controls.spec.doRecurring
 import space.kscience.controls.spec.read
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.MetaRepr
-import space.kscience.dataforge.meta.double
-import space.kscience.dataforge.meta.get
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.*
 import kotlin.math.pow
-import kotlin.reflect.KType
-import kotlin.reflect.typeOf
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.ExperimentalTime
 
 data class Vector2D(var x: Double = 0.0, var y: Double = 0.0) : MetaRepr {
 
-    override fun toMeta(): Meta = objectToMeta(this)
+    override fun toMeta(): Meta = convert(this)
 
     operator fun div(arg: Double): Vector2D = Vector2D(x / arg, y / arg)
 
     companion object CoordinatesMetaConverter : MetaConverter<Vector2D> {
 
-        override val type: KType = typeOf<Vector2D>()
-
-        override fun metaToObjectOrNull(meta: Meta): Vector2D = Vector2D(
-            meta["x"].double ?: 0.0,
-            meta["y"].double ?: 0.0
+        override fun readOrNull(source: Meta): Vector2D = Vector2D(
+            source["x"].double ?: 0.0,
+            source["y"].double ?: 0.0
         )
 
-        override fun objectToMeta(obj: Vector2D): Meta = Meta {
+        override fun convert(obj: Vector2D): Meta = Meta {
             "x" put obj.x
             "y" put obj.y
         }
diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
index cb8030c..1d48553 100644
--- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
+++ b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
@@ -10,9 +10,9 @@ import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.context.request
 import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.meta.int
-import space.kscience.dataforge.meta.transformations.MetaConverter
 
 
 //TODO this device is not tested
@@ -94,7 +94,7 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi
         val channel by logicalProperty(MetaConverter.int)
 
         val value by doubleProperty(read = {
-            readChannelData(request(channel) ?: DEFAULT_CHANNEL)
+            readChannelData(getOrRead(channel))
         })
 
         val error by logicalProperty(MetaConverter.string)
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index dde2750..5d05993 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -16,11 +16,7 @@ import space.kscience.controls.api.PropertyDescriptor
 import space.kscience.controls.ports.*
 import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.*
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.asValue
-import space.kscience.dataforge.meta.double
-import space.kscience.dataforge.meta.get
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.*
 import space.kscience.dataforge.names.NameToken
 import kotlin.collections.component1
 import kotlin.collections.component2

From e8c6e90a0fc4b9d9ec255791058bea2e7fe9c765 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 4 Mar 2024 15:58:53 +0300
Subject: [PATCH 059/125] Update readme and API

---
 CHANGELOG.md                                  | 30 ++++++++++++++-----
 README.md                                     | 26 +++++++++++++---
 build.gradle.kts                              |  2 +-
 controls-core/README.md                       |  6 ++--
 controls-jupyter/api/controls-jupyter.api     |  8 +++++
 controls-magix/README.md                      |  6 ++--
 controls-modbus/README.md                     |  6 ++--
 controls-opcua/README.md                      |  6 ++--
 controls-pi/README.md                         |  6 ++--
 controls-pi/api/controls-pi.api               | 11 +++++--
 controls-ports-ktor/README.md                 |  6 ++--
 controls-serial/README.md                     |  6 ++--
 controls-server/README.md                     |  6 ++--
 controls-storage/README.md                    |  6 ++--
 controls-storage/controls-xodus/README.md     |  6 ++--
 demo/all-things/api/all-things.api            |  7 +++--
 demo/car/api/car.api                          | 15 +++++-----
 demo/constructor/api/constructor.api          | 27 +++++++++++++++++
 demo/mks-pdr900/api/mks-pdr900.api            | 14 ++-------
 demo/motors/api/motors.api                    | 14 ++++-----
 magix/magix-api/README.md                     |  6 ++--
 magix/magix-java-endpoint/README.md           |  6 ++--
 magix/magix-mqtt/README.md                    |  6 ++--
 magix/magix-rabbit/README.md                  |  6 ++--
 magix/magix-rsocket/README.md                 |  6 ++--
 magix/magix-server/README.md                  |  6 ++--
 magix/magix-storage/README.md                 |  6 ++--
 .../magix-storage-xodus/README.md             |  6 ++--
 magix/magix-zmq/README.md                     |  6 ++--
 29 files changed, 147 insertions(+), 121 deletions(-)
 create mode 100644 controls-jupyter/api/controls-jupyter.api
 create mode 100644 demo/constructor/api/constructor.api

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8183d79..30a9fee 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,35 +3,49 @@
 ## Unreleased
 
 ### Added
-- Device lifecycle message
-- Low-code constructor
-- Automatic description generation for spec properties (JVM only)
 
 ### Changed
-- Property caching moved from core `Device` to the `CachingDevice`
-- `DeviceSpec` properties no explicitly pass property name to getters and setters.
-- `DeviceHub.respondHubMessage` now returns a list of messages to allow querying multiple devices. Device server also returns an array.
 
 ### Deprecated
 
 ### Removed
 
 ### Fixed
+
+### Security
+
+## 0.3.0 - 2024-03-04
+
+### Added
+
+- Device lifecycle message
+- Low-code constructor
+- Automatic description generation for spec properties (JVM only)
+
+### Changed
+
+- Property caching moved from core `Device` to the `CachingDevice`
+- `DeviceSpec` properties no explicitly pass property name to getters and setters.
+- `DeviceHub.respondHubMessage` now returns a list of messages to allow querying multiple devices. Device server also returns an array.
+- DataForge 0.8.0
+
+### Fixed
+
 - Property writing does not trigger change if logical state already is the same as value to be set.
 - Modbus-slave triggers only once for multi-register write.
 - Removed unnecessary scope in hub messageFlow
 
-### Security
-
 ## 0.2.2-dev-1 - 2023-09-24
 
 ### Changed
+
 - updating logical state in `DeviceBase` is now protected and called `propertyChanged()`
 - `DeviceBase` tries to read property after write if the writer does not set the value.
 
 ## 0.2.1 - 2023-09-24
 
 ### Added
+
 - Core interfaces for building a device server
 - Magix service for binding controls devices (both as RPC client and server)
 - A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols
diff --git a/README.md b/README.md
index 5518d1f..d6a602b 100644
--- a/README.md
+++ b/README.md
@@ -1,14 +1,14 @@
 [![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub)
 
-[![DOI](https://zenodo.org/badge/240888288.svg)](https://zenodo.org/badge/latestdoi/240888288)
+[![](https://maven.sciprog.center/api/badge/latest/kscience/space/kscience/controls-core-jvm?color=40c14a&name=repo.kotlin.link&prefix=v)](https://maven.sciprog.center/)
 
-# Controls-kt
+# Controls.kt
 
-Controls-kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing.
+Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing.
 This repository contains a prototype of API and simple implementation
 of a slow control system, including a demo.
 
-Controls-kt uses some concepts and modules of DataForge,
+Controls.kt uses some concepts and modules of DataForge,
 such as `Meta` (tree-like value structure).
 
 To learn more about DataForge, please consult the following URLs:
@@ -44,6 +44,11 @@ Example view of a demo:
 ## Modules
 
 
+### [controls-constructor](controls-constructor)
+> A low-code constructor for composite devices simulation
+>
+> **Maturity**: PROTOTYPE
+
 ### [controls-core](controls-core)
 > Core interfaces for building a device server
 >
@@ -58,6 +63,10 @@ Example view of a demo:
 > - [ports](controls-core/src/commonMain/kotlin/space/kscience/controls/ports) : Working with asynchronous data sending and receiving raw byte arrays
 
 
+### [controls-jupyter](controls-jupyter)
+>
+> **Maturity**: EXPERIMENTAL
+
 ### [controls-magix](controls-magix)
 > Magix service for binding controls devices (both as RPC client and server)
 >
@@ -115,6 +124,11 @@ Automatically checks consistency.
 >
 > **Maturity**: PROTOTYPE
 
+### [controls-vision](controls-vision)
+> Dashboard and visualization extensions for devices
+>
+> **Maturity**: PROTOTYPE
+
 ### [demo](demo)
 >
 > **Maturity**: EXPERIMENTAL
@@ -136,6 +150,10 @@ Automatically checks consistency.
 >
 > **Maturity**: EXPERIMENTAL
 
+### [demo/constructor](demo/constructor)
+>
+> **Maturity**: EXPERIMENTAL
+
 ### [demo/echo](demo/echo)
 >
 > **Maturity**: EXPERIMENTAL
diff --git a/build.gradle.kts b/build.gradle.kts
index b0b93d5..99946f1 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -13,7 +13,7 @@ val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.3.0-dev-6"
+    version = "0.3.0"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-core/README.md b/controls-core/README.md
index b75961d..b1edbdc 100644
--- a/controls-core/README.md
+++ b/controls-core/README.md
@@ -16,18 +16,16 @@ Core interfaces for building a device server
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-core:0.2.0`.
+The Maven coordinates of this project are `space.kscience:controls-core:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:controls-core:0.2.0")
+    implementation("space.kscience:controls-core:0.3.0")
 }
 ```
diff --git a/controls-jupyter/api/controls-jupyter.api b/controls-jupyter/api/controls-jupyter.api
new file mode 100644
index 0000000..726b523
--- /dev/null
+++ b/controls-jupyter/api/controls-jupyter.api
@@ -0,0 +1,8 @@
+public final class space/kscience/controls/jupyter/ControlsJupyter : space/kscience/visionforge/jupyter/VisionForgeIntegration {
+	public static final field Companion Lspace/kscience/controls/jupyter/ControlsJupyter$Companion;
+	public fun <init> ()V
+}
+
+public final class space/kscience/controls/jupyter/ControlsJupyter$Companion {
+}
+
diff --git a/controls-magix/README.md b/controls-magix/README.md
index 5473f02..4048990 100644
--- a/controls-magix/README.md
+++ b/controls-magix/README.md
@@ -12,18 +12,16 @@ Magix service for binding controls devices (both as RPC client and server)
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-magix:0.2.0`.
+The Maven coordinates of this project are `space.kscience:controls-magix:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:controls-magix:0.2.0")
+    implementation("space.kscience:controls-magix:0.3.0")
 }
 ```
diff --git a/controls-modbus/README.md b/controls-modbus/README.md
index 78d515a..18c37ee 100644
--- a/controls-modbus/README.md
+++ b/controls-modbus/README.md
@@ -14,18 +14,16 @@ Automatically checks consistency.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-modbus:0.2.0`.
+The Maven coordinates of this project are `space.kscience:controls-modbus:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:controls-modbus:0.2.0")
+    implementation("space.kscience:controls-modbus:0.3.0")
 }
 ```
diff --git a/controls-opcua/README.md b/controls-opcua/README.md
index 8cdd373..3befbb9 100644
--- a/controls-opcua/README.md
+++ b/controls-opcua/README.md
@@ -12,18 +12,16 @@ A client and server connectors for OPC-UA via Eclipse Milo
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-opcua:0.2.0`.
+The Maven coordinates of this project are `space.kscience:controls-opcua:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:controls-opcua:0.2.0")
+    implementation("space.kscience:controls-opcua:0.3.0")
 }
 ```
diff --git a/controls-pi/README.md b/controls-pi/README.md
index cd9ee0a..6235995 100644
--- a/controls-pi/README.md
+++ b/controls-pi/README.md
@@ -6,18 +6,16 @@ Utils to work with controls-kt on Raspberry pi
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-pi:0.2.0`.
+The Maven coordinates of this project are `space.kscience:controls-pi:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:controls-pi:0.2.0")
+    implementation("space.kscience:controls-pi:0.3.0")
 }
 ```
diff --git a/controls-pi/api/controls-pi.api b/controls-pi/api/controls-pi.api
index 2fdaf2d..34bfd53 100644
--- a/controls-pi/api/controls-pi.api
+++ b/controls-pi/api/controls-pi.api
@@ -1,6 +1,10 @@
 public final class space/kscience/controls/pi/PiPlugin : space/kscience/dataforge/context/AbstractPlugin {
 	public static final field Companion Lspace/kscience/controls/pi/PiPlugin$Companion;
 	public fun <init> ()V
+	public fun content (Ljava/lang/String;)Ljava/util/Map;
+	public fun detach ()V
+	public final fun getDevices ()Lspace/kscience/controls/manager/DeviceManager;
+	public final fun getPiContext ()Lcom/pi4j/context/Context;
 	public final fun getPorts ()Lspace/kscience/controls/ports/Ports;
 	public fun getTag ()Lspace/kscience/dataforge/context/PluginTag;
 }
@@ -8,15 +12,16 @@ public final class space/kscience/controls/pi/PiPlugin : space/kscience/dataforg
 public final class space/kscience/controls/pi/PiPlugin$Companion : space/kscience/dataforge/context/PluginFactory {
 	public synthetic fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object;
 	public fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/pi/PiPlugin;
+	public final fun createPiContext (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lcom/pi4j/context/Context;
 	public fun getTag ()Lspace/kscience/dataforge/context/PluginTag;
 }
 
 public final class space/kscience/controls/pi/PiSerialPort : space/kscience/controls/ports/AbstractPort {
 	public static final field Companion Lspace/kscience/controls/pi/PiSerialPort$Companion;
-	public fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;)V
-	public synthetic fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;)V
+	public synthetic fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
 	public fun close ()V
-	public final fun getSerialBuilder ()Lkotlin/jvm/functions/Function0;
+	public final fun getSerialBuilder ()Lkotlin/jvm/functions/Function1;
 }
 
 public final class space/kscience/controls/pi/PiSerialPort$Companion : space/kscience/controls/ports/PortFactory {
diff --git a/controls-ports-ktor/README.md b/controls-ports-ktor/README.md
index 7f935f9..b703521 100644
--- a/controls-ports-ktor/README.md
+++ b/controls-ports-ktor/README.md
@@ -6,18 +6,16 @@ Implementation of byte ports on top os ktor-io asynchronous API
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.2.0`.
+The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:controls-ports-ktor:0.2.0")
+    implementation("space.kscience:controls-ports-ktor:0.3.0")
 }
 ```
diff --git a/controls-serial/README.md b/controls-serial/README.md
index 209055d..861b6d3 100644
--- a/controls-serial/README.md
+++ b/controls-serial/README.md
@@ -6,18 +6,16 @@ Implementation of direct serial port communication with JSerialComm
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-serial:0.2.0`.
+The Maven coordinates of this project are `space.kscience:controls-serial:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:controls-serial:0.2.0")
+    implementation("space.kscience:controls-serial:0.3.0")
 }
 ```
diff --git a/controls-server/README.md b/controls-server/README.md
index 83408e8..86a5bf5 100644
--- a/controls-server/README.md
+++ b/controls-server/README.md
@@ -6,18 +6,16 @@ A combined Magix event loop server with web server for visualization.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-server:0.2.0`.
+The Maven coordinates of this project are `space.kscience:controls-server:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:controls-server:0.2.0")
+    implementation("space.kscience:controls-server:0.3.0")
 }
 ```
diff --git a/controls-storage/README.md b/controls-storage/README.md
index 51cdbcb..3242de2 100644
--- a/controls-storage/README.md
+++ b/controls-storage/README.md
@@ -6,18 +6,16 @@ An API for stand-alone Controls-kt device or a hub.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-storage:0.2.0`.
+The Maven coordinates of this project are `space.kscience:controls-storage:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:controls-storage:0.2.0")
+    implementation("space.kscience:controls-storage:0.3.0")
 }
 ```
diff --git a/controls-storage/controls-xodus/README.md b/controls-storage/controls-xodus/README.md
index 790b356..2a6c247 100644
--- a/controls-storage/controls-xodus/README.md
+++ b/controls-storage/controls-xodus/README.md
@@ -6,18 +6,16 @@ An implementation of controls-storage on top of JetBrains Xodus.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-xodus:0.2.0`.
+The Maven coordinates of this project are `space.kscience:controls-xodus:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:controls-xodus:0.2.0")
+    implementation("space.kscience:controls-xodus:0.3.0")
 }
 ```
diff --git a/demo/all-things/api/all-things.api b/demo/all-things/api/all-things.api
index 4d283e5..1a1b140 100644
--- a/demo/all-things/api/all-things.api
+++ b/demo/all-things/api/all-things.api
@@ -47,11 +47,12 @@ public final class space/kscience/controls/demo/DemoDevice$Companion : space/ksc
 	public fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/demo/DemoDevice;
 	public final fun getCoordinates ()Lspace/kscience/controls/spec/DevicePropertySpec;
 	public final fun getCos ()Lspace/kscience/controls/spec/DevicePropertySpec;
-	public final fun getCosScale ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
+	public final fun getCosScale ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
 	public final fun getResetScale ()Lspace/kscience/controls/spec/DeviceActionSpec;
+	public final fun getSetSinScale ()Lspace/kscience/controls/spec/DeviceActionSpec;
 	public final fun getSin ()Lspace/kscience/controls/spec/DevicePropertySpec;
-	public final fun getSinScale ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
-	public final fun getTimeScale ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
+	public final fun getSinScale ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
+	public final fun getTimeScale ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
 	public synthetic fun onOpen (Lspace/kscience/controls/api/Device;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public fun onOpen (Lspace/kscience/controls/demo/IDemoDevice;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
diff --git a/demo/car/api/car.api b/demo/car/api/car.api
index 5b1940f..f5bda95 100644
--- a/demo/car/api/car.api
+++ b/demo/car/api/car.api
@@ -9,7 +9,7 @@ public abstract interface class space/kscience/controls/demo/car/IVirtualCar : s
 }
 
 public final class space/kscience/controls/demo/car/IVirtualCar$Companion : space/kscience/controls/spec/DeviceSpec {
-	public final fun getAcceleration ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
+	public final fun getAcceleration ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
 	public final fun getLocation ()Lspace/kscience/controls/spec/DevicePropertySpec;
 	public final fun getSpeed ()Lspace/kscience/controls/spec/DevicePropertySpec;
 }
@@ -17,7 +17,6 @@ public final class space/kscience/controls/demo/car/IVirtualCar$Companion : spac
 public final class space/kscience/controls/demo/car/MagixVirtualCar : space/kscience/controls/demo/car/VirtualCar {
 	public static final field Companion Lspace/kscience/controls/demo/car/MagixVirtualCar$Companion;
 	public fun <init> (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)V
-	public fun open (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 }
 
 public final class space/kscience/controls/demo/car/MagixVirtualCar$Companion : space/kscience/dataforge/context/Factory {
@@ -45,11 +44,11 @@ public final class space/kscience/controls/demo/car/Vector2D : space/kscience/da
 	public fun toString ()Ljava/lang/String;
 }
 
-public final class space/kscience/controls/demo/car/Vector2D$CoordinatesMetaConverter : space/kscience/dataforge/meta/transformations/MetaConverter {
-	public synthetic fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object;
-	public fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/demo/car/Vector2D;
-	public synthetic fun objectToMeta (Ljava/lang/Object;)Lspace/kscience/dataforge/meta/Meta;
-	public fun objectToMeta (Lspace/kscience/controls/demo/car/Vector2D;)Lspace/kscience/dataforge/meta/Meta;
+public final class space/kscience/controls/demo/car/Vector2D$CoordinatesMetaConverter : space/kscience/dataforge/meta/MetaConverter {
+	public synthetic fun convert (Ljava/lang/Object;)Lspace/kscience/dataforge/meta/Meta;
+	public fun convert (Lspace/kscience/controls/demo/car/Vector2D;)Lspace/kscience/dataforge/meta/Meta;
+	public synthetic fun readOrNull (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object;
+	public fun readOrNull (Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/demo/car/Vector2D;
 }
 
 public class space/kscience/controls/demo/car/VirtualCar : space/kscience/controls/spec/DeviceBySpec, space/kscience/controls/demo/car/IVirtualCar {
@@ -59,7 +58,7 @@ public class space/kscience/controls/demo/car/VirtualCar : space/kscience/contro
 	public fun getAccelerationState ()Lspace/kscience/controls/demo/car/Vector2D;
 	public fun getLocationState ()Lspace/kscience/controls/demo/car/Vector2D;
 	public fun getSpeedState ()Lspace/kscience/controls/demo/car/Vector2D;
-	public fun open (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
+	protected fun onStart (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
 	public fun setAccelerationState (Lspace/kscience/controls/demo/car/Vector2D;)V
 	public fun setLocationState (Lspace/kscience/controls/demo/car/Vector2D;)V
 	public fun setSpeedState (Lspace/kscience/controls/demo/car/Vector2D;)V
diff --git a/demo/constructor/api/constructor.api b/demo/constructor/api/constructor.api
new file mode 100644
index 0000000..a6e5b7e
--- /dev/null
+++ b/demo/constructor/api/constructor.api
@@ -0,0 +1,27 @@
+public final class space/kscience/controls/demo/constructor/ComposableSingletons$MainKt {
+	public static final field INSTANCE Lspace/kscience/controls/demo/constructor/ComposableSingletons$MainKt;
+	public static field lambda-1 Lkotlin/jvm/functions/Function3;
+	public static field lambda-2 Lkotlin/jvm/functions/Function3;
+	public fun <init> ()V
+	public final fun getLambda-1$constructor ()Lkotlin/jvm/functions/Function3;
+	public final fun getLambda-2$constructor ()Lkotlin/jvm/functions/Function3;
+}
+
+public final class space/kscience/controls/demo/constructor/LinearDrive : space/kscience/controls/constructor/DeviceConstructor {
+	public static final field $stable I
+	public fun <init> (Lspace/kscience/dataforge/context/Context;Lspace/kscience/controls/constructor/DoubleRangeState;DLspace/kscience/controls/constructor/PidParameters;Lspace/kscience/dataforge/meta/Meta;)V
+	public synthetic fun <init> (Lspace/kscience/dataforge/context/Context;Lspace/kscience/controls/constructor/DoubleRangeState;DLspace/kscience/controls/constructor/PidParameters;Lspace/kscience/dataforge/meta/Meta;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
+	public final fun getDrive ()Lspace/kscience/controls/constructor/Drive;
+	public final fun getEnd ()Lspace/kscience/controls/constructor/LimitSwitch;
+	public final fun getPid ()Lspace/kscience/controls/constructor/PidRegulator;
+	public final fun getPosition ()D
+	public final fun getStart ()Lspace/kscience/controls/constructor/LimitSwitch;
+	public final fun getTarget ()D
+	public final fun setTarget (D)V
+}
+
+public final class space/kscience/controls/demo/constructor/MainKt {
+	public static final fun main ()V
+	public static synthetic fun main ([Ljava/lang/String;)V
+}
+
diff --git a/demo/mks-pdr900/api/mks-pdr900.api b/demo/mks-pdr900/api/mks-pdr900.api
index a9c9ecb..35e5f3c 100644
--- a/demo/mks-pdr900/api/mks-pdr900.api
+++ b/demo/mks-pdr900/api/mks-pdr900.api
@@ -10,19 +10,11 @@ public final class center/sciprog/devices/mks/MksPdr900Device : space/kscience/c
 public final class center/sciprog/devices/mks/MksPdr900Device$Companion : space/kscience/controls/spec/DeviceSpec, space/kscience/dataforge/context/Factory {
 	public fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lcenter/sciprog/devices/mks/MksPdr900Device;
 	public synthetic fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object;
-	public final fun getChannel ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
-	public final fun getError ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
-	public final fun getPowerOn ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
+	public final fun getChannel ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
+	public final fun getError ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
+	public final fun getPowerOn ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
 	public final fun getValue ()Lspace/kscience/controls/spec/DevicePropertySpec;
 	public fun onClose (Lcenter/sciprog/devices/mks/MksPdr900Device;)V
 	public synthetic fun onClose (Lspace/kscience/controls/api/Device;)V
 }
 
-public final class center/sciprog/devices/mks/NullableStringMetaConverter : space/kscience/dataforge/meta/transformations/MetaConverter {
-	public static final field INSTANCE Lcenter/sciprog/devices/mks/NullableStringMetaConverter;
-	public synthetic fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object;
-	public fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/String;
-	public synthetic fun objectToMeta (Ljava/lang/Object;)Lspace/kscience/dataforge/meta/Meta;
-	public fun objectToMeta (Ljava/lang/String;)Lspace/kscience/dataforge/meta/Meta;
-}
-
diff --git a/demo/motors/api/motors.api b/demo/motors/api/motors.api
index 506d7ee..05f430a 100644
--- a/demo/motors/api/motors.api
+++ b/demo/motors/api/motors.api
@@ -1,6 +1,6 @@
 public final class ru/mipt/npm/devices/pimotionmaster/FxDevicePropertiesKt {
 	public static final fun fxProperty (Lspace/kscience/controls/api/Device;Lspace/kscience/controls/spec/DevicePropertySpec;)Ljavafx/beans/property/ReadOnlyProperty;
-	public static final fun fxProperty (Lspace/kscience/controls/api/Device;Lspace/kscience/controls/spec/WritableDevicePropertySpec;)Ljavafx/beans/property/Property;
+	public static final fun fxProperty (Lspace/kscience/controls/api/Device;Lspace/kscience/controls/spec/MutableDevicePropertySpec;)Ljavafx/beans/property/Property;
 }
 
 public final class ru/mipt/npm/devices/pimotionmaster/PiDebugServerKt {
@@ -50,19 +50,19 @@ public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Axis
 }
 
 public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Axis$Companion : space/kscience/controls/spec/DeviceSpec {
-	public final fun getClosedLoop ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
-	public final fun getEnabled ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
+	public final fun getClosedLoop ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
+	public final fun getEnabled ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
 	public final fun getHalt ()Lspace/kscience/controls/spec/DeviceActionSpec;
 	public final fun getMaxPosition ()Lspace/kscience/controls/spec/DevicePropertySpec;
 	public final fun getMinPosition ()Lspace/kscience/controls/spec/DevicePropertySpec;
 	public final fun getMove ()Lspace/kscience/controls/spec/DeviceActionSpec;
 	public final fun getMoveToReference ()Lspace/kscience/controls/spec/DeviceActionSpec;
 	public final fun getOnTarget ()Lspace/kscience/controls/spec/DevicePropertySpec;
-	public final fun getOpenLoopTarget ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
+	public final fun getOpenLoopTarget ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
 	public final fun getPosition ()Lspace/kscience/controls/spec/DevicePropertySpec;
 	public final fun getReference ()Lspace/kscience/controls/spec/DevicePropertySpec;
-	public final fun getTargetPosition ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
-	public final fun getVelocity ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
+	public final fun getTargetPosition ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
+	public final fun getVelocity ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
 }
 
 public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Companion : space/kscience/controls/spec/DeviceSpec, space/kscience/dataforge/context/Factory {
@@ -75,7 +75,7 @@ public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Compa
 	public final fun getIdentity ()Lspace/kscience/controls/spec/DevicePropertySpec;
 	public final fun getInitialize ()Lspace/kscience/controls/spec/DeviceActionSpec;
 	public final fun getStop ()Lspace/kscience/controls/spec/DeviceActionSpec;
-	public final fun getTimeout ()Lspace/kscience/controls/spec/WritableDevicePropertySpec;
+	public final fun getTimeout ()Lspace/kscience/controls/spec/MutableDevicePropertySpec;
 }
 
 public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterView : tornadofx/View {
diff --git a/magix/magix-api/README.md b/magix/magix-api/README.md
index ee00c53..d624d84 100644
--- a/magix/magix-api/README.md
+++ b/magix/magix-api/README.md
@@ -6,18 +6,16 @@ A kotlin API for magix standard and some zero-dependency magix services
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-api:0.2.0`.
+The Maven coordinates of this project are `space.kscience:magix-api:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:magix-api:0.2.0")
+    implementation("space.kscience:magix-api:0.3.0")
 }
 ```
diff --git a/magix/magix-java-endpoint/README.md b/magix/magix-java-endpoint/README.md
index abcaa6f..40f4c8e 100644
--- a/magix/magix-java-endpoint/README.md
+++ b/magix/magix-java-endpoint/README.md
@@ -6,18 +6,16 @@ Java API to work with magix endpoints without Kotlin
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.2.0`.
+The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:magix-java-endpoint:0.2.0")
+    implementation("space.kscience:magix-java-endpoint:0.3.0")
 }
 ```
diff --git a/magix/magix-mqtt/README.md b/magix/magix-mqtt/README.md
index 6c34fdc..35165a0 100644
--- a/magix/magix-mqtt/README.md
+++ b/magix/magix-mqtt/README.md
@@ -6,18 +6,16 @@ MQTT client magix endpoint
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-mqtt:0.2.0`.
+The Maven coordinates of this project are `space.kscience:magix-mqtt:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:magix-mqtt:0.2.0")
+    implementation("space.kscience:magix-mqtt:0.3.0")
 }
 ```
diff --git a/magix/magix-rabbit/README.md b/magix/magix-rabbit/README.md
index 7fc42ad..609303d 100644
--- a/magix/magix-rabbit/README.md
+++ b/magix/magix-rabbit/README.md
@@ -6,18 +6,16 @@ RabbitMQ client magix endpoint
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-rabbit:0.2.0`.
+The Maven coordinates of this project are `space.kscience:magix-rabbit:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:magix-rabbit:0.2.0")
+    implementation("space.kscience:magix-rabbit:0.3.0")
 }
 ```
diff --git a/magix/magix-rsocket/README.md b/magix/magix-rsocket/README.md
index 799717d..0a1b34b 100644
--- a/magix/magix-rsocket/README.md
+++ b/magix/magix-rsocket/README.md
@@ -6,18 +6,16 @@ Magix endpoint (client) based on RSocket
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-rsocket:0.2.0`.
+The Maven coordinates of this project are `space.kscience:magix-rsocket:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:magix-rsocket:0.2.0")
+    implementation("space.kscience:magix-rsocket:0.3.0")
 }
 ```
diff --git a/magix/magix-server/README.md b/magix/magix-server/README.md
index 27d97e0..4175dcd 100644
--- a/magix/magix-server/README.md
+++ b/magix/magix-server/README.md
@@ -6,18 +6,16 @@ A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket route
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-server:0.2.0`.
+The Maven coordinates of this project are `space.kscience:magix-server:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:magix-server:0.2.0")
+    implementation("space.kscience:magix-server:0.3.0")
 }
 ```
diff --git a/magix/magix-storage/README.md b/magix/magix-storage/README.md
index fa367b4..db1d340 100644
--- a/magix/magix-storage/README.md
+++ b/magix/magix-storage/README.md
@@ -6,18 +6,16 @@ Magix history database API
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-storage:0.2.0`.
+The Maven coordinates of this project are `space.kscience:magix-storage:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:magix-storage:0.2.0")
+    implementation("space.kscience:magix-storage:0.3.0")
 }
 ```
diff --git a/magix/magix-storage/magix-storage-xodus/README.md b/magix/magix-storage/magix-storage-xodus/README.md
index b3d871b..2d9e202 100644
--- a/magix/magix-storage/magix-storage-xodus/README.md
+++ b/magix/magix-storage/magix-storage-xodus/README.md
@@ -6,18 +6,16 @@
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.2.0`.
+The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:magix-storage-xodus:0.2.0")
+    implementation("space.kscience:magix-storage-xodus:0.3.0")
 }
 ```
diff --git a/magix/magix-zmq/README.md b/magix/magix-zmq/README.md
index 1338cce..b016e6b 100644
--- a/magix/magix-zmq/README.md
+++ b/magix/magix-zmq/README.md
@@ -6,18 +6,16 @@ ZMQ client endpoint for Magix
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-zmq:0.2.0`.
+The Maven coordinates of this project are `space.kscience:magix-zmq:0.3.0`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
 repositories {
     maven("https://repo.kotlin.link")
-    //uncomment to access development builds
-    //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     mavenCentral()
 }
 
 dependencies {
-    implementation("space.kscience:magix-zmq:0.2.0")
+    implementation("space.kscience:magix-zmq:0.3.0")
 }
 ```

From 2946f23a4bffd2bfd6dda6df1f618df46c4fc3d6 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 4 Mar 2024 16:02:50 +0300
Subject: [PATCH 060/125] Update readme and API

---
 controls-constructor/README.md | 21 +++++++++++++++++++++
 controls-jupyter/README.md     | 21 +++++++++++++++++++++
 controls-vision/README.md      | 21 +++++++++++++++++++++
 demo/constructor/README.md     |  4 ++++
 4 files changed, 67 insertions(+)
 create mode 100644 controls-constructor/README.md
 create mode 100644 controls-jupyter/README.md
 create mode 100644 controls-vision/README.md
 create mode 100644 demo/constructor/README.md

diff --git a/controls-constructor/README.md b/controls-constructor/README.md
new file mode 100644
index 0000000..9a8b27c
--- /dev/null
+++ b/controls-constructor/README.md
@@ -0,0 +1,21 @@
+# Module controls-constructor
+
+A low-code constructor for composite devices simulation
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:controls-constructor:0.3.0`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:controls-constructor:0.3.0")
+}
+```
diff --git a/controls-jupyter/README.md b/controls-jupyter/README.md
new file mode 100644
index 0000000..15d8e2d
--- /dev/null
+++ b/controls-jupyter/README.md
@@ -0,0 +1,21 @@
+# Module controls-jupyter
+
+
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:controls-jupyter:0.3.0`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:controls-jupyter:0.3.0")
+}
+```
diff --git a/controls-vision/README.md b/controls-vision/README.md
new file mode 100644
index 0000000..41483ce
--- /dev/null
+++ b/controls-vision/README.md
@@ -0,0 +1,21 @@
+# Module controls-vision
+
+Dashboard and visualization extensions for devices
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:controls-vision:0.3.0`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:controls-vision:0.3.0")
+}
+```
diff --git a/demo/constructor/README.md b/demo/constructor/README.md
new file mode 100644
index 0000000..3bd1e74
--- /dev/null
+++ b/demo/constructor/README.md
@@ -0,0 +1,4 @@
+# Module constructor
+
+
+

From 4639fdb558dc313f43d6f1b64561699e3bef0c15 Mon Sep 17 00:00:00 2001
From: InsanusMokrassar <ovsyannikov.alexey95@gmail.com>
Date: Wed, 6 Mar 2024 00:21:55 +0600
Subject: [PATCH 061/125] update gradle wrapper

---
 gradle/wrapper/gradle-wrapper.properties |    2 +-
 gradle/yarn.lock                         | 2042 ++++++++++++++++++++++
 2 files changed, 2043 insertions(+), 1 deletion(-)
 create mode 100644 gradle/yarn.lock

diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index fae0804..17655d0 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/gradle/yarn.lock b/gradle/yarn.lock
new file mode 100644
index 0000000..d909f26
--- /dev/null
+++ b/gradle/yarn.lock
@@ -0,0 +1,2042 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@colors/colors@1.5.0":
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9"
+  integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==
+
+"@discoveryjs/json-ext@^0.5.0":
+  version "0.5.7"
+  resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
+  integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==
+
+"@jridgewell/gen-mapping@^0.3.0":
+  version "0.3.5"
+  resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36"
+  integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==
+  dependencies:
+    "@jridgewell/set-array" "^1.2.1"
+    "@jridgewell/sourcemap-codec" "^1.4.10"
+    "@jridgewell/trace-mapping" "^0.3.24"
+
+"@jridgewell/resolve-uri@^3.1.0":
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6"
+  integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==
+
+"@jridgewell/set-array@^1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280"
+  integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==
+
+"@jridgewell/source-map@^0.3.3":
+  version "0.3.5"
+  resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.5.tgz#a3bb4d5c6825aab0d281268f47f6ad5853431e91"
+  integrity sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ==
+  dependencies:
+    "@jridgewell/gen-mapping" "^0.3.0"
+    "@jridgewell/trace-mapping" "^0.3.9"
+
+"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14":
+  version "1.4.15"
+  resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32"
+  integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==
+
+"@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.9":
+  version "0.3.25"
+  resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0"
+  integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==
+  dependencies:
+    "@jridgewell/resolve-uri" "^3.1.0"
+    "@jridgewell/sourcemap-codec" "^1.4.14"
+
+"@js-joda/core@3.2.0":
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
+  integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
+
+"@socket.io/component-emitter@~3.1.0":
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553"
+  integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==
+
+"@types/cookie@^0.4.1":
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
+  integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==
+
+"@types/cors@^2.8.12":
+  version "2.8.17"
+  resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b"
+  integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==
+  dependencies:
+    "@types/node" "*"
+
+"@types/eslint-scope@^3.7.3":
+  version "3.7.7"
+  resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5"
+  integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==
+  dependencies:
+    "@types/eslint" "*"
+    "@types/estree" "*"
+
+"@types/eslint@*":
+  version "8.56.5"
+  resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.5.tgz#94b88cab77588fcecdd0771a6d576fa1c0af9d02"
+  integrity sha512-u5/YPJHo1tvkSF2CE0USEkxon82Z5DBy2xR+qfyYNszpX9qcs4sT6uq2kBbj4BXY1+DBGDPnrhMZV3pKWGNukw==
+  dependencies:
+    "@types/estree" "*"
+    "@types/json-schema" "*"
+
+"@types/estree@*", "@types/estree@^1.0.0":
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4"
+  integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==
+
+"@types/json-schema@*", "@types/json-schema@^7.0.8":
+  version "7.0.15"
+  resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841"
+  integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==
+
+"@types/node@*", "@types/node@>=10.0.0":
+  version "20.11.24"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.24.tgz#cc207511104694e84e9fb17f9a0c4c42d4517792"
+  integrity sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long==
+  dependencies:
+    undici-types "~5.26.4"
+
+"@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24"
+  integrity sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==
+  dependencies:
+    "@webassemblyjs/helper-numbers" "1.11.6"
+    "@webassemblyjs/helper-wasm-bytecode" "1.11.6"
+
+"@webassemblyjs/floating-point-hex-parser@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431"
+  integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==
+
+"@webassemblyjs/helper-api-error@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768"
+  integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==
+
+"@webassemblyjs/helper-buffer@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz#b66d73c43e296fd5e88006f18524feb0f2c7c093"
+  integrity sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==
+
+"@webassemblyjs/helper-numbers@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5"
+  integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==
+  dependencies:
+    "@webassemblyjs/floating-point-hex-parser" "1.11.6"
+    "@webassemblyjs/helper-api-error" "1.11.6"
+    "@xtuc/long" "4.2.2"
+
+"@webassemblyjs/helper-wasm-bytecode@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9"
+  integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==
+
+"@webassemblyjs/helper-wasm-section@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz#ff97f3863c55ee7f580fd5c41a381e9def4aa577"
+  integrity sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.6"
+    "@webassemblyjs/helper-buffer" "1.11.6"
+    "@webassemblyjs/helper-wasm-bytecode" "1.11.6"
+    "@webassemblyjs/wasm-gen" "1.11.6"
+
+"@webassemblyjs/ieee754@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a"
+  integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==
+  dependencies:
+    "@xtuc/ieee754" "^1.2.0"
+
+"@webassemblyjs/leb128@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7"
+  integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==
+  dependencies:
+    "@xtuc/long" "4.2.2"
+
+"@webassemblyjs/utf8@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a"
+  integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==
+
+"@webassemblyjs/wasm-edit@^1.11.5":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz#c72fa8220524c9b416249f3d94c2958dfe70ceab"
+  integrity sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.6"
+    "@webassemblyjs/helper-buffer" "1.11.6"
+    "@webassemblyjs/helper-wasm-bytecode" "1.11.6"
+    "@webassemblyjs/helper-wasm-section" "1.11.6"
+    "@webassemblyjs/wasm-gen" "1.11.6"
+    "@webassemblyjs/wasm-opt" "1.11.6"
+    "@webassemblyjs/wasm-parser" "1.11.6"
+    "@webassemblyjs/wast-printer" "1.11.6"
+
+"@webassemblyjs/wasm-gen@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz#fb5283e0e8b4551cc4e9c3c0d7184a65faf7c268"
+  integrity sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.6"
+    "@webassemblyjs/helper-wasm-bytecode" "1.11.6"
+    "@webassemblyjs/ieee754" "1.11.6"
+    "@webassemblyjs/leb128" "1.11.6"
+    "@webassemblyjs/utf8" "1.11.6"
+
+"@webassemblyjs/wasm-opt@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz#d9a22d651248422ca498b09aa3232a81041487c2"
+  integrity sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.6"
+    "@webassemblyjs/helper-buffer" "1.11.6"
+    "@webassemblyjs/wasm-gen" "1.11.6"
+    "@webassemblyjs/wasm-parser" "1.11.6"
+
+"@webassemblyjs/wasm-parser@1.11.6", "@webassemblyjs/wasm-parser@^1.11.5":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz#bb85378c527df824004812bbdb784eea539174a1"
+  integrity sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.6"
+    "@webassemblyjs/helper-api-error" "1.11.6"
+    "@webassemblyjs/helper-wasm-bytecode" "1.11.6"
+    "@webassemblyjs/ieee754" "1.11.6"
+    "@webassemblyjs/leb128" "1.11.6"
+    "@webassemblyjs/utf8" "1.11.6"
+
+"@webassemblyjs/wast-printer@1.11.6":
+  version "1.11.6"
+  resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz#a7bf8dd7e362aeb1668ff43f35cb849f188eff20"
+  integrity sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==
+  dependencies:
+    "@webassemblyjs/ast" "1.11.6"
+    "@xtuc/long" "4.2.2"
+
+"@webpack-cli/configtest@^2.1.0":
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.1.1.tgz#3b2f852e91dac6e3b85fb2a314fb8bef46d94646"
+  integrity sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw==
+
+"@webpack-cli/info@^2.0.1":
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.2.tgz#cc3fbf22efeb88ff62310cf885c5b09f44ae0fdd"
+  integrity sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A==
+
+"@webpack-cli/serve@^2.0.3":
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.5.tgz#325db42395cd49fe6c14057f9a900e427df8810e"
+  integrity sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ==
+
+"@xtuc/ieee754@^1.2.0":
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790"
+  integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==
+
+"@xtuc/long@4.2.2":
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d"
+  integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==
+
+abab@^2.0.6:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291"
+  integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==
+
+abort-controller@3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392"
+  integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==
+  dependencies:
+    event-target-shim "^5.0.0"
+
+accepts@~1.3.4:
+  version "1.3.8"
+  resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e"
+  integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==
+  dependencies:
+    mime-types "~2.1.34"
+    negotiator "0.6.3"
+
+acorn-import-assertions@^1.7.6:
+  version "1.9.0"
+  resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac"
+  integrity sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==
+
+acorn@^8.7.1, acorn@^8.8.2:
+  version "8.11.3"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a"
+  integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==
+
+ajv-keywords@^3.5.2:
+  version "3.5.2"
+  resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d"
+  integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==
+
+ajv@^6.12.5:
+  version "6.12.6"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
+  integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
+  dependencies:
+    fast-deep-equal "^3.1.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
+ansi-colors@4.1.1:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348"
+  integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA==
+
+ansi-regex@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
+ansi-styles@^4.0.0, ansi-styles@^4.1.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+  dependencies:
+    color-convert "^2.0.1"
+
+anymatch@~3.1.2:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e"
+  integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==
+  dependencies:
+    normalize-path "^3.0.0"
+    picomatch "^2.0.4"
+
+argparse@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+  integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
+
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+base64id@2.0.0, base64id@~2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6"
+  integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==
+
+binary-extensions@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d"
+  integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA==
+
+body-parser@^1.19.0:
+  version "1.20.2"
+  resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd"
+  integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==
+  dependencies:
+    bytes "3.1.2"
+    content-type "~1.0.5"
+    debug "2.6.9"
+    depd "2.0.0"
+    destroy "1.2.0"
+    http-errors "2.0.0"
+    iconv-lite "0.4.24"
+    on-finished "2.4.1"
+    qs "6.11.0"
+    raw-body "2.5.2"
+    type-is "~1.6.18"
+    unpipe "1.0.0"
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
+brace-expansion@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
+  integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
+  dependencies:
+    balanced-match "^1.0.0"
+
+braces@^3.0.2, braces@~3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
+  integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
+  dependencies:
+    fill-range "^7.0.1"
+
+browser-stdout@1.3.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60"
+  integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw==
+
+browserslist@^4.14.5:
+  version "4.23.0"
+  resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab"
+  integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==
+  dependencies:
+    caniuse-lite "^1.0.30001587"
+    electron-to-chromium "^1.4.668"
+    node-releases "^2.0.14"
+    update-browserslist-db "^1.0.13"
+
+buffer-from@^1.0.0:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5"
+  integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==
+
+bytes@3.1.2:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5"
+  integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==
+
+call-bind@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9"
+  integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==
+  dependencies:
+    es-define-property "^1.0.0"
+    es-errors "^1.3.0"
+    function-bind "^1.1.2"
+    get-intrinsic "^1.2.4"
+    set-function-length "^1.2.1"
+
+camelcase@^6.0.0:
+  version "6.3.0"
+  resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a"
+  integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==
+
+caniuse-lite@^1.0.30001587:
+  version "1.0.30001594"
+  resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001594.tgz#bea552414cd52c2d0c985ed9206314a696e685f5"
+  integrity sha512-VblSX6nYqyJVs8DKFMldE2IVCJjZ225LW00ydtUWwh5hk9IfkTOffO6r8gJNsH0qqqeAF8KrbMYA2VEwTlGW5g==
+
+chalk@^4.1.0:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
+  integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
+chokidar@3.5.3:
+  version "3.5.3"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
+  integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==
+  dependencies:
+    anymatch "~3.1.2"
+    braces "~3.0.2"
+    glob-parent "~5.1.2"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.6.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
+
+chokidar@^3.5.1:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b"
+  integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==
+  dependencies:
+    anymatch "~3.1.2"
+    braces "~3.0.2"
+    glob-parent "~5.1.2"
+    is-binary-path "~2.1.0"
+    is-glob "~4.0.1"
+    normalize-path "~3.0.0"
+    readdirp "~3.6.0"
+  optionalDependencies:
+    fsevents "~2.3.2"
+
+chrome-trace-event@^1.0.2:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac"
+  integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg==
+
+cliui@^7.0.2:
+  version "7.0.4"
+  resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f"
+  integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ==
+  dependencies:
+    string-width "^4.2.0"
+    strip-ansi "^6.0.0"
+    wrap-ansi "^7.0.0"
+
+clone-deep@^4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387"
+  integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==
+  dependencies:
+    is-plain-object "^2.0.4"
+    kind-of "^6.0.2"
+    shallow-clone "^3.0.0"
+
+color-convert@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+  dependencies:
+    color-name "~1.1.4"
+
+color-name@~1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+colorette@^2.0.14:
+  version "2.0.20"
+  resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a"
+  integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==
+
+commander@^10.0.1:
+  version "10.0.1"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06"
+  integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==
+
+commander@^2.20.0:
+  version "2.20.3"
+  resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33"
+  integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==
+
+connect@^3.7.0:
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8"
+  integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ==
+  dependencies:
+    debug "2.6.9"
+    finalhandler "1.1.2"
+    parseurl "~1.3.3"
+    utils-merge "1.0.1"
+
+content-type@~1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918"
+  integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==
+
+cookie@~0.4.1:
+  version "0.4.2"
+  resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432"
+  integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==
+
+cors@~2.8.5:
+  version "2.8.5"
+  resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29"
+  integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==
+  dependencies:
+    object-assign "^4"
+    vary "^1"
+
+cross-spawn@^7.0.3:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
+  integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
+  dependencies:
+    path-key "^3.1.0"
+    shebang-command "^2.0.0"
+    which "^2.0.1"
+
+custom-event@~1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425"
+  integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg==
+
+date-format@^4.0.14:
+  version "4.0.14"
+  resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.14.tgz#7a8e584434fb169a521c8b7aa481f355810d9400"
+  integrity sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==
+
+debug@2.6.9:
+  version "2.6.9"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f"
+  integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==
+  dependencies:
+    ms "2.0.0"
+
+debug@4.3.4, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4:
+  version "4.3.4"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865"
+  integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
+  dependencies:
+    ms "2.1.2"
+
+decamelize@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837"
+  integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ==
+
+define-data-property@^1.1.2:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e"
+  integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==
+  dependencies:
+    es-define-property "^1.0.0"
+    es-errors "^1.3.0"
+    gopd "^1.0.1"
+
+depd@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
+  integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==
+
+destroy@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015"
+  integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==
+
+di@^0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c"
+  integrity sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA==
+
+diff@5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
+  integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
+
+dom-serialize@^2.2.1:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b"
+  integrity sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ==
+  dependencies:
+    custom-event "~1.0.0"
+    ent "~2.2.0"
+    extend "^3.0.0"
+    void-elements "^2.0.0"
+
+ee-first@1.1.1:
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d"
+  integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==
+
+electron-to-chromium@^1.4.668:
+  version "1.4.692"
+  resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz#82139d20585a4b2318a02066af7593a3e6bec993"
+  integrity sha512-d5rZRka9n2Y3MkWRN74IoAsxR0HK3yaAt7T50e3iT9VZmCCQDT3geXUO5ZRMhDToa1pkCeQXuNo+0g+NfDOVPA==
+
+emoji-regex@^8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
+  integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==
+
+encodeurl@~1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"
+  integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==
+
+engine.io-parser@~5.2.1:
+  version "5.2.2"
+  resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49"
+  integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==
+
+engine.io@~6.5.2:
+  version "6.5.4"
+  resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.4.tgz#6822debf324e781add2254e912f8568508850cdc"
+  integrity sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==
+  dependencies:
+    "@types/cookie" "^0.4.1"
+    "@types/cors" "^2.8.12"
+    "@types/node" ">=10.0.0"
+    accepts "~1.3.4"
+    base64id "2.0.0"
+    cookie "~0.4.1"
+    cors "~2.8.5"
+    debug "~4.3.1"
+    engine.io-parser "~5.2.1"
+    ws "~8.11.0"
+
+enhanced-resolve@^5.13.0:
+  version "5.15.1"
+  resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.15.1.tgz#384391e025f099e67b4b00bfd7f0906a408214e1"
+  integrity sha512-3d3JRbwsCLJsYgvb6NuWEG44jjPSOMuS73L/6+7BZuoKm3W+qXnSoIYVHi8dG7Qcg4inAY4jbzkZ7MnskePeDg==
+  dependencies:
+    graceful-fs "^4.2.4"
+    tapable "^2.2.0"
+
+ent@~2.2.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d"
+  integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA==
+
+envinfo@^7.7.3:
+  version "7.11.1"
+  resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.11.1.tgz#2ffef77591057081b0129a8fd8cf6118da1b94e1"
+  integrity sha512-8PiZgZNIB4q/Lw4AhOvAfB/ityHAd2bli3lESSWmWSzSsl5dKpy5N1d1Rfkd2teq/g9xN90lc6o98DOjMeYHpg==
+
+es-define-property@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845"
+  integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==
+  dependencies:
+    get-intrinsic "^1.2.4"
+
+es-errors@^1.3.0:
+  version "1.3.0"
+  resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
+  integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
+
+es-module-lexer@^1.2.1:
+  version "1.4.1"
+  resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5"
+  integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w==
+
+escalade@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27"
+  integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==
+
+escape-html@~1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"
+  integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==
+
+escape-string-regexp@4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+  integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
+eslint-scope@5.1.1:
+  version "5.1.1"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c"
+  integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==
+  dependencies:
+    esrecurse "^4.3.0"
+    estraverse "^4.1.1"
+
+esrecurse@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
+  integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
+  dependencies:
+    estraverse "^5.2.0"
+
+estraverse@^4.1.1:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d"
+  integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==
+
+estraverse@^5.2.0:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
+  integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
+
+event-target-shim@^5.0.0:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789"
+  integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==
+
+eventemitter3@^4.0.0:
+  version "4.0.7"
+  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
+  integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
+
+events@^3.2.0:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400"
+  integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==
+
+extend@^3.0.0:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa"
+  integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==
+
+fast-deep-equal@^3.1.1:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
+
+fast-json-stable-stringify@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
+  integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
+
+fastest-levenshtein@^1.0.12:
+  version "1.0.16"
+  resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5"
+  integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==
+
+fill-range@^7.0.1:
+  version "7.0.1"
+  resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40"
+  integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==
+  dependencies:
+    to-regex-range "^5.0.1"
+
+finalhandler@1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d"
+  integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==
+  dependencies:
+    debug "2.6.9"
+    encodeurl "~1.0.2"
+    escape-html "~1.0.3"
+    on-finished "~2.3.0"
+    parseurl "~1.3.3"
+    statuses "~1.5.0"
+    unpipe "~1.0.0"
+
+find-up@5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc"
+  integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==
+  dependencies:
+    locate-path "^6.0.0"
+    path-exists "^4.0.0"
+
+find-up@^4.0.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19"
+  integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==
+  dependencies:
+    locate-path "^5.0.0"
+    path-exists "^4.0.0"
+
+flat@^5.0.2:
+  version "5.0.2"
+  resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241"
+  integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==
+
+flatted@^3.2.7:
+  version "3.3.1"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a"
+  integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==
+
+follow-redirects@^1.0.0:
+  version "1.15.5"
+  resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020"
+  integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw==
+
+format-util@^1.0.5:
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271"
+  integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg==
+
+fs-extra@^8.1.0:
+  version "8.1.0"
+  resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0"
+  integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==
+  dependencies:
+    graceful-fs "^4.2.0"
+    jsonfile "^4.0.0"
+    universalify "^0.1.0"
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
+
+fsevents@~2.3.2:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+  integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
+function-bind@^1.1.2:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c"
+  integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==
+
+get-caller-file@^2.0.5:
+  version "2.0.5"
+  resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
+  integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
+
+get-intrinsic@^1.1.3, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4:
+  version "1.2.4"
+  resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd"
+  integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==
+  dependencies:
+    es-errors "^1.3.0"
+    function-bind "^1.1.2"
+    has-proto "^1.0.1"
+    has-symbols "^1.0.3"
+    hasown "^2.0.0"
+
+glob-parent@~5.1.2:
+  version "5.1.2"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4"
+  integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==
+  dependencies:
+    is-glob "^4.0.1"
+
+glob-to-regexp@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e"
+  integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==
+
+glob@7.2.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
+  integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+glob@^7.1.3, glob@^7.1.7:
+  version "7.2.3"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b"
+  integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.1.1"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+gopd@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c"
+  integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==
+  dependencies:
+    get-intrinsic "^1.1.3"
+
+graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9:
+  version "4.2.11"
+  resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3"
+  integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==
+
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+has-property-descriptors@^1.0.1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854"
+  integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==
+  dependencies:
+    es-define-property "^1.0.0"
+
+has-proto@^1.0.1:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd"
+  integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==
+
+has-symbols@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8"
+  integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==
+
+hasown@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.1.tgz#26f48f039de2c0f8d3356c223fb8d50253519faa"
+  integrity sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA==
+  dependencies:
+    function-bind "^1.1.2"
+
+he@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f"
+  integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==
+
+http-errors@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3"
+  integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==
+  dependencies:
+    depd "2.0.0"
+    inherits "2.0.4"
+    setprototypeof "1.2.0"
+    statuses "2.0.1"
+    toidentifier "1.0.1"
+
+http-proxy@^1.18.1:
+  version "1.18.1"
+  resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549"
+  integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==
+  dependencies:
+    eventemitter3 "^4.0.0"
+    follow-redirects "^1.0.0"
+    requires-port "^1.0.0"
+
+iconv-lite@0.4.24:
+  version "0.4.24"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
+  integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3"
+
+iconv-lite@^0.6.3:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501"
+  integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==
+  dependencies:
+    safer-buffer ">= 2.1.2 < 3.0.0"
+
+import-local@^3.0.2:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4"
+  integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg==
+  dependencies:
+    pkg-dir "^4.2.0"
+    resolve-cwd "^3.0.0"
+
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2, inherits@2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+interpret@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4"
+  integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==
+
+is-binary-path@~2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09"
+  integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==
+  dependencies:
+    binary-extensions "^2.0.0"
+
+is-core-module@^2.13.0:
+  version "2.13.1"
+  resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384"
+  integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==
+  dependencies:
+    hasown "^2.0.0"
+
+is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==
+
+is-fullwidth-code-point@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d"
+  integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==
+
+is-glob@^4.0.1, is-glob@~4.0.1:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
+is-number@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b"
+  integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==
+
+is-plain-obj@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287"
+  integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==
+
+is-plain-object@^2.0.4:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677"
+  integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==
+  dependencies:
+    isobject "^3.0.1"
+
+is-unicode-supported@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7"
+  integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==
+
+isbinaryfile@^4.0.8:
+  version "4.0.10"
+  resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3"
+  integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw==
+
+isexe@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+  integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==
+
+isobject@^3.0.1:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
+  integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==
+
+jest-worker@^27.4.5:
+  version "27.5.1"
+  resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0"
+  integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==
+  dependencies:
+    "@types/node" "*"
+    merge-stream "^2.0.0"
+    supports-color "^8.0.0"
+
+js-yaml@4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
+  integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
+  dependencies:
+    argparse "^2.0.1"
+
+json-parse-even-better-errors@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d"
+  integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==
+
+json-schema-traverse@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+  integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
+
+jsonfile@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb"
+  integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==
+  optionalDependencies:
+    graceful-fs "^4.1.6"
+
+karma-chrome-launcher@3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz#eb9c95024f2d6dfbb3748d3415ac9b381906b9a9"
+  integrity sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==
+  dependencies:
+    which "^1.2.1"
+
+karma-mocha@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/karma-mocha/-/karma-mocha-2.0.1.tgz#4b0254a18dfee71bdbe6188d9a6861bf86b0cd7d"
+  integrity sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ==
+  dependencies:
+    minimist "^1.2.3"
+
+karma-sourcemap-loader@0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/karma-sourcemap-loader/-/karma-sourcemap-loader-0.4.0.tgz#b01d73f8f688f533bcc8f5d273d43458e13b5488"
+  integrity sha512-xCRL3/pmhAYF3I6qOrcn0uhbQevitc2DERMPH82FMnG+4WReoGcGFZb1pURf2a5apyrOHRdvD+O6K7NljqKHyA==
+  dependencies:
+    graceful-fs "^4.2.10"
+
+karma-webpack@5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-5.0.0.tgz#2a2c7b80163fe7ffd1010f83f5507f95ef39f840"
+  integrity sha512-+54i/cd3/piZuP3dr54+NcFeKOPnys5QeM1IY+0SPASwrtHsliXUiCL50iW+K9WWA7RvamC4macvvQ86l3KtaA==
+  dependencies:
+    glob "^7.1.3"
+    minimatch "^3.0.4"
+    webpack-merge "^4.1.5"
+
+karma@6.4.2:
+  version "6.4.2"
+  resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.2.tgz#a983f874cee6f35990c4b2dcc3d274653714de8e"
+  integrity sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ==
+  dependencies:
+    "@colors/colors" "1.5.0"
+    body-parser "^1.19.0"
+    braces "^3.0.2"
+    chokidar "^3.5.1"
+    connect "^3.7.0"
+    di "^0.0.1"
+    dom-serialize "^2.2.1"
+    glob "^7.1.7"
+    graceful-fs "^4.2.6"
+    http-proxy "^1.18.1"
+    isbinaryfile "^4.0.8"
+    lodash "^4.17.21"
+    log4js "^6.4.1"
+    mime "^2.5.2"
+    minimatch "^3.0.4"
+    mkdirp "^0.5.5"
+    qjobs "^1.2.0"
+    range-parser "^1.2.1"
+    rimraf "^3.0.2"
+    socket.io "^4.4.1"
+    source-map "^0.6.1"
+    tmp "^0.2.1"
+    ua-parser-js "^0.7.30"
+    yargs "^16.1.1"
+
+kind-of@^6.0.2:
+  version "6.0.3"
+  resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
+  integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
+
+loader-runner@^4.2.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1"
+  integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
+
+locate-path@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0"
+  integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==
+  dependencies:
+    p-locate "^4.1.0"
+
+locate-path@^6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286"
+  integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==
+  dependencies:
+    p-locate "^5.0.0"
+
+lodash@^4.17.15, lodash@^4.17.21:
+  version "4.17.21"
+  resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
+  integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
+
+log-symbols@4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503"
+  integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==
+  dependencies:
+    chalk "^4.1.0"
+    is-unicode-supported "^0.1.0"
+
+log4js@^6.4.1:
+  version "6.9.1"
+  resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.9.1.tgz#aba5a3ff4e7872ae34f8b4c533706753709e38b6"
+  integrity sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g==
+  dependencies:
+    date-format "^4.0.14"
+    debug "^4.3.4"
+    flatted "^3.2.7"
+    rfdc "^1.3.0"
+    streamroller "^3.1.5"
+
+media-typer@0.3.0:
+  version "0.3.0"
+  resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748"
+  integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==
+
+merge-stream@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60"
+  integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==
+
+mime-db@1.52.0:
+  version "1.52.0"
+  resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
+  integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
+
+mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34:
+  version "2.1.35"
+  resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
+  integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
+  dependencies:
+    mime-db "1.52.0"
+
+mime@^2.5.2:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367"
+  integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg==
+
+minimatch@5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b"
+  integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==
+  dependencies:
+    brace-expansion "^2.0.1"
+
+minimatch@^3.0.4, minimatch@^3.1.1:
+  version "3.1.2"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
+  integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+minimist@^1.2.3, minimist@^1.2.6:
+  version "1.2.8"
+  resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c"
+  integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==
+
+mkdirp@^0.5.5:
+  version "0.5.6"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6"
+  integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==
+  dependencies:
+    minimist "^1.2.6"
+
+mocha@10.2.0:
+  version "10.2.0"
+  resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.2.0.tgz#1fd4a7c32ba5ac372e03a17eef435bd00e5c68b8"
+  integrity sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg==
+  dependencies:
+    ansi-colors "4.1.1"
+    browser-stdout "1.3.1"
+    chokidar "3.5.3"
+    debug "4.3.4"
+    diff "5.0.0"
+    escape-string-regexp "4.0.0"
+    find-up "5.0.0"
+    glob "7.2.0"
+    he "1.2.0"
+    js-yaml "4.1.0"
+    log-symbols "4.1.0"
+    minimatch "5.0.1"
+    ms "2.1.3"
+    nanoid "3.3.3"
+    serialize-javascript "6.0.0"
+    strip-json-comments "3.1.1"
+    supports-color "8.1.1"
+    workerpool "6.2.1"
+    yargs "16.2.0"
+    yargs-parser "20.2.4"
+    yargs-unparser "2.0.0"
+
+ms@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
+  integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
+
+ms@2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+ms@2.1.3:
+  version "2.1.3"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
+  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
+nanoid@3.3.3:
+  version "3.3.3"
+  resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25"
+  integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w==
+
+negotiator@0.6.3:
+  version "0.6.3"
+  resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd"
+  integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==
+
+neo-async@^2.6.2:
+  version "2.6.2"
+  resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
+  integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
+
+node-fetch@2.6.7:
+  version "2.6.7"
+  resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad"
+  integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ==
+  dependencies:
+    whatwg-url "^5.0.0"
+
+node-releases@^2.0.14:
+  version "2.0.14"
+  resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b"
+  integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==
+
+normalize-path@^3.0.0, normalize-path@~3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
+  integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==
+
+object-assign@^4:
+  version "4.1.1"
+  resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
+  integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==
+
+object-inspect@^1.13.1:
+  version "1.13.1"
+  resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2"
+  integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==
+
+on-finished@2.4.1:
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f"
+  integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==
+  dependencies:
+    ee-first "1.1.1"
+
+on-finished@~2.3.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
+  integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww==
+  dependencies:
+    ee-first "1.1.1"
+
+once@^1.3.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==
+  dependencies:
+    wrappy "1"
+
+p-limit@^2.2.0:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1"
+  integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==
+  dependencies:
+    p-try "^2.0.0"
+
+p-limit@^3.0.2:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b"
+  integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==
+  dependencies:
+    yocto-queue "^0.1.0"
+
+p-locate@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07"
+  integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==
+  dependencies:
+    p-limit "^2.2.0"
+
+p-locate@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834"
+  integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==
+  dependencies:
+    p-limit "^3.0.2"
+
+p-try@^2.0.0:
+  version "2.2.0"
+  resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6"
+  integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==
+
+parseurl@~1.3.3:
+  version "1.3.3"
+  resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4"
+  integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==
+
+path-exists@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3"
+  integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==
+
+path-is-absolute@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+  integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==
+
+path-key@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
+  integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
+path-parse@^1.0.7:
+  version "1.0.7"
+  resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735"
+  integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
+
+picocolors@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c"
+  integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==
+
+picomatch@^2.0.4, picomatch@^2.2.1:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42"
+  integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+
+pkg-dir@^4.2.0:
+  version "4.2.0"
+  resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
+  integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==
+  dependencies:
+    find-up "^4.0.0"
+
+punycode@^2.1.0:
+  version "2.3.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
+  integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
+
+qjobs@^1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071"
+  integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg==
+
+qs@6.11.0:
+  version "6.11.0"
+  resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a"
+  integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==
+  dependencies:
+    side-channel "^1.0.4"
+
+randombytes@^2.1.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a"
+  integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==
+  dependencies:
+    safe-buffer "^5.1.0"
+
+range-parser@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031"
+  integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==
+
+raw-body@2.5.2:
+  version "2.5.2"
+  resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a"
+  integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==
+  dependencies:
+    bytes "3.1.2"
+    http-errors "2.0.0"
+    iconv-lite "0.4.24"
+    unpipe "1.0.0"
+
+readdirp@~3.6.0:
+  version "3.6.0"
+  resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7"
+  integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==
+  dependencies:
+    picomatch "^2.2.1"
+
+rechoir@^0.8.0:
+  version "0.8.0"
+  resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22"
+  integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==
+  dependencies:
+    resolve "^1.20.0"
+
+require-directory@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42"
+  integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==
+
+requires-port@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff"
+  integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==
+
+resolve-cwd@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d"
+  integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==
+  dependencies:
+    resolve-from "^5.0.0"
+
+resolve-from@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69"
+  integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==
+
+resolve@^1.20.0:
+  version "1.22.8"
+  resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d"
+  integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==
+  dependencies:
+    is-core-module "^2.13.0"
+    path-parse "^1.0.7"
+    supports-preserve-symlinks-flag "^1.0.0"
+
+rfdc@^1.3.0:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.1.tgz#2b6d4df52dffe8bb346992a10ea9451f24373a8f"
+  integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg==
+
+rimraf@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+  dependencies:
+    glob "^7.1.3"
+
+safe-buffer@^5.1.0:
+  version "5.2.1"
+  resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
+  integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
+
+"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0":
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a"
+  integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==
+
+schema-utils@^3.1.1, schema-utils@^3.1.2:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe"
+  integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==
+  dependencies:
+    "@types/json-schema" "^7.0.8"
+    ajv "^6.12.5"
+    ajv-keywords "^3.5.2"
+
+serialize-javascript@6.0.0:
+  version "6.0.0"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8"
+  integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag==
+  dependencies:
+    randombytes "^2.1.0"
+
+serialize-javascript@^6.0.1:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2"
+  integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==
+  dependencies:
+    randombytes "^2.1.0"
+
+set-function-length@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.1.tgz#47cc5945f2c771e2cf261c6737cf9684a2a5e425"
+  integrity sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g==
+  dependencies:
+    define-data-property "^1.1.2"
+    es-errors "^1.3.0"
+    function-bind "^1.1.2"
+    get-intrinsic "^1.2.3"
+    gopd "^1.0.1"
+    has-property-descriptors "^1.0.1"
+
+setprototypeof@1.2.0:
+  version "1.2.0"
+  resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424"
+  integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==
+
+shallow-clone@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3"
+  integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==
+  dependencies:
+    kind-of "^6.0.2"
+
+shebang-command@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+  integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+  dependencies:
+    shebang-regex "^3.0.0"
+
+shebang-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
+side-channel@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2"
+  integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==
+  dependencies:
+    call-bind "^1.0.7"
+    es-errors "^1.3.0"
+    get-intrinsic "^1.2.4"
+    object-inspect "^1.13.1"
+
+socket.io-adapter@~2.5.2:
+  version "2.5.4"
+  resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz#4fdb1358667f6d68f25343353bd99bd11ee41006"
+  integrity sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==
+  dependencies:
+    debug "~4.3.4"
+    ws "~8.11.0"
+
+socket.io-parser@~4.2.4:
+  version "4.2.4"
+  resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83"
+  integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==
+  dependencies:
+    "@socket.io/component-emitter" "~3.1.0"
+    debug "~4.3.1"
+
+socket.io@^4.4.1:
+  version "4.7.4"
+  resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.4.tgz#2401a2d7101e4bdc64da80b140d5d8b6a8c7738b"
+  integrity sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw==
+  dependencies:
+    accepts "~1.3.4"
+    base64id "~2.0.0"
+    cors "~2.8.5"
+    debug "~4.3.2"
+    engine.io "~6.5.2"
+    socket.io-adapter "~2.5.2"
+    socket.io-parser "~4.2.4"
+
+source-map-js@^1.0.2:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"
+  integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw==
+
+source-map-loader@4.0.1:
+  version "4.0.1"
+  resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-4.0.1.tgz#72f00d05f5d1f90f80974eda781cbd7107c125f2"
+  integrity sha512-oqXpzDIByKONVY8g1NUPOTQhe0UTU5bWUl32GSkqK2LjJj0HmwTMVKxcUip0RgAYhY1mqgOxjbQM48a0mmeNfA==
+  dependencies:
+    abab "^2.0.6"
+    iconv-lite "^0.6.3"
+    source-map-js "^1.0.2"
+
+source-map-support@~0.5.20:
+  version "0.5.21"
+  resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f"
+  integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==
+  dependencies:
+    buffer-from "^1.0.0"
+    source-map "^0.6.0"
+
+source-map@^0.6.0, source-map@^0.6.1:
+  version "0.6.1"
+  resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263"
+  integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
+
+statuses@2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"
+  integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==
+
+statuses@~1.5.0:
+  version "1.5.0"
+  resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c"
+  integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==
+
+streamroller@^3.1.5:
+  version "3.1.5"
+  resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.5.tgz#1263182329a45def1ffaef58d31b15d13d2ee7ff"
+  integrity sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw==
+  dependencies:
+    date-format "^4.0.14"
+    debug "^4.3.4"
+    fs-extra "^8.1.0"
+
+string-width@^4.1.0, string-width@^4.2.0:
+  version "4.2.3"
+  resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010"
+  integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==
+  dependencies:
+    emoji-regex "^8.0.0"
+    is-fullwidth-code-point "^3.0.0"
+    strip-ansi "^6.0.1"
+
+strip-ansi@^6.0.0, strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
+strip-json-comments@3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
+  integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
+
+supports-color@8.1.1, supports-color@^8.0.0:
+  version "8.1.1"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c"
+  integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==
+  dependencies:
+    has-flag "^4.0.0"
+
+supports-color@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
+  dependencies:
+    has-flag "^4.0.0"
+
+supports-preserve-symlinks-flag@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09"
+  integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==
+
+tapable@^2.1.1, tapable@^2.2.0:
+  version "2.2.1"
+  resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0"
+  integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==
+
+terser-webpack-plugin@^5.3.7:
+  version "5.3.10"
+  resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199"
+  integrity sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==
+  dependencies:
+    "@jridgewell/trace-mapping" "^0.3.20"
+    jest-worker "^27.4.5"
+    schema-utils "^3.1.1"
+    serialize-javascript "^6.0.1"
+    terser "^5.26.0"
+
+terser@^5.26.0:
+  version "5.28.1"
+  resolved "https://registry.yarnpkg.com/terser/-/terser-5.28.1.tgz#bf00f7537fd3a798c352c2d67d67d65c915d1b28"
+  integrity sha512-wM+bZp54v/E9eRRGXb5ZFDvinrJIOaTapx3WUokyVGZu5ucVCK55zEgGd5Dl2fSr3jUo5sDiERErUWLY6QPFyA==
+  dependencies:
+    "@jridgewell/source-map" "^0.3.3"
+    acorn "^8.8.2"
+    commander "^2.20.0"
+    source-map-support "~0.5.20"
+
+tmp@^0.2.1:
+  version "0.2.3"
+  resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae"
+  integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==
+
+to-regex-range@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4"
+  integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==
+  dependencies:
+    is-number "^7.0.0"
+
+toidentifier@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35"
+  integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==
+
+tr46@~0.0.3:
+  version "0.0.3"
+  resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a"
+  integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==
+
+type-is@~1.6.18:
+  version "1.6.18"
+  resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131"
+  integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==
+  dependencies:
+    media-typer "0.3.0"
+    mime-types "~2.1.24"
+
+typescript@5.0.4:
+  version "5.0.4"
+  resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b"
+  integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==
+
+ua-parser-js@^0.7.30:
+  version "0.7.37"
+  resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832"
+  integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==
+
+undici-types@~5.26.4:
+  version "5.26.5"
+  resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
+  integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
+
+universalify@^0.1.0:
+  version "0.1.2"
+  resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
+  integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
+
+unpipe@1.0.0, unpipe@~1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"
+  integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==
+
+update-browserslist-db@^1.0.13:
+  version "1.0.13"
+  resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4"
+  integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==
+  dependencies:
+    escalade "^3.1.1"
+    picocolors "^1.0.0"
+
+uri-js@^4.2.2:
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
+  integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
+  dependencies:
+    punycode "^2.1.0"
+
+utils-merge@1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
+  integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
+
+vary@^1:
+  version "1.1.2"
+  resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"
+  integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==
+
+void-elements@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec"
+  integrity sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung==
+
+watchpack@^2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d"
+  integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==
+  dependencies:
+    glob-to-regexp "^0.4.1"
+    graceful-fs "^4.1.2"
+
+webidl-conversions@^3.0.0:
+  version "3.0.1"
+  resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871"
+  integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==
+
+webpack-cli@5.1.0:
+  version "5.1.0"
+  resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.1.0.tgz#abc4b1f44b50250f2632d8b8b536cfe2f6257891"
+  integrity sha512-a7KRJnCxejFoDpYTOwzm5o21ZXMaNqtRlvS183XzGDUPRdVEzJNImcQokqYZ8BNTnk9DkKiuWxw75+DCCoZ26w==
+  dependencies:
+    "@discoveryjs/json-ext" "^0.5.0"
+    "@webpack-cli/configtest" "^2.1.0"
+    "@webpack-cli/info" "^2.0.1"
+    "@webpack-cli/serve" "^2.0.3"
+    colorette "^2.0.14"
+    commander "^10.0.1"
+    cross-spawn "^7.0.3"
+    envinfo "^7.7.3"
+    fastest-levenshtein "^1.0.12"
+    import-local "^3.0.2"
+    interpret "^3.1.1"
+    rechoir "^0.8.0"
+    webpack-merge "^5.7.3"
+
+webpack-merge@^4.1.5:
+  version "4.2.2"
+  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.2.2.tgz#a27c52ea783d1398afd2087f547d7b9d2f43634d"
+  integrity sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g==
+  dependencies:
+    lodash "^4.17.15"
+
+webpack-merge@^5.7.3:
+  version "5.10.0"
+  resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.10.0.tgz#a3ad5d773241e9c682803abf628d4cd62b8a4177"
+  integrity sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA==
+  dependencies:
+    clone-deep "^4.0.1"
+    flat "^5.0.2"
+    wildcard "^2.0.0"
+
+webpack-sources@^3.2.3:
+  version "3.2.3"
+  resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
+  integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
+
+webpack@5.82.0:
+  version "5.82.0"
+  resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.82.0.tgz#3c0d074dec79401db026b4ba0fb23d6333f88e7d"
+  integrity sha512-iGNA2fHhnDcV1bONdUu554eZx+XeldsaeQ8T67H6KKHl2nUSwX8Zm7cmzOA46ox/X1ARxf7Bjv8wQ/HsB5fxBg==
+  dependencies:
+    "@types/eslint-scope" "^3.7.3"
+    "@types/estree" "^1.0.0"
+    "@webassemblyjs/ast" "^1.11.5"
+    "@webassemblyjs/wasm-edit" "^1.11.5"
+    "@webassemblyjs/wasm-parser" "^1.11.5"
+    acorn "^8.7.1"
+    acorn-import-assertions "^1.7.6"
+    browserslist "^4.14.5"
+    chrome-trace-event "^1.0.2"
+    enhanced-resolve "^5.13.0"
+    es-module-lexer "^1.2.1"
+    eslint-scope "5.1.1"
+    events "^3.2.0"
+    glob-to-regexp "^0.4.1"
+    graceful-fs "^4.2.9"
+    json-parse-even-better-errors "^2.3.1"
+    loader-runner "^4.2.0"
+    mime-types "^2.1.27"
+    neo-async "^2.6.2"
+    schema-utils "^3.1.2"
+    tapable "^2.1.1"
+    terser-webpack-plugin "^5.3.7"
+    watchpack "^2.4.0"
+    webpack-sources "^3.2.3"
+
+whatwg-url@^5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d"
+  integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==
+  dependencies:
+    tr46 "~0.0.3"
+    webidl-conversions "^3.0.0"
+
+which@^1.2.1:
+  version "1.3.1"
+  resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a"
+  integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==
+  dependencies:
+    isexe "^2.0.0"
+
+which@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+  integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
+  dependencies:
+    isexe "^2.0.0"
+
+wildcard@^2.0.0:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67"
+  integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==
+
+workerpool@6.2.1:
+  version "6.2.1"
+  resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343"
+  integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw==
+
+wrap-ansi@^7.0.0:
+  version "7.0.0"
+  resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43"
+  integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==
+  dependencies:
+    ansi-styles "^4.0.0"
+    string-width "^4.1.0"
+    strip-ansi "^6.0.0"
+
+wrappy@1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==
+
+ws@8.5.0:
+  version "8.5.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f"
+  integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==
+
+ws@~8.11.0:
+  version "8.11.0"
+  resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143"
+  integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==
+
+y18n@^5.0.5:
+  version "5.0.8"
+  resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55"
+  integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==
+
+yargs-parser@20.2.4:
+  version "20.2.4"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54"
+  integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA==
+
+yargs-parser@^20.2.2:
+  version "20.2.9"
+  resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee"
+  integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==
+
+yargs-unparser@2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb"
+  integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA==
+  dependencies:
+    camelcase "^6.0.0"
+    decamelize "^4.0.0"
+    flat "^5.0.2"
+    is-plain-obj "^2.1.0"
+
+yargs@16.2.0, yargs@^16.1.1:
+  version "16.2.0"
+  resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66"
+  integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw==
+  dependencies:
+    cliui "^7.0.2"
+    escalade "^3.1.1"
+    get-caller-file "^2.0.5"
+    require-directory "^2.1.1"
+    string-width "^4.2.0"
+    y18n "^5.0.5"
+    yargs-parser "^20.2.2"
+
+yocto-queue@^0.1.0:
+  version "0.1.0"
+  resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"
+  integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==

From 4835376c0de47caa95cd6d0f362c140f1f6af7e8 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 6 Mar 2024 18:55:11 +0300
Subject: [PATCH 062/125] Add proper deviceName to in-memory property history

---
 .../space/kscience/controls/misc/PropertyHistory.kt    | 10 +++++++---
 1 file changed, 7 insertions(+), 3 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt
index 96bd8d5..092d9bb 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt
@@ -10,6 +10,7 @@ import space.kscience.controls.api.PropertyChangedMessage
 import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.name
 import space.kscience.dataforge.meta.MetaConverter
+import space.kscience.dataforge.names.Name
 
 /**
  * An interface for device property history.
@@ -34,6 +35,7 @@ public interface PropertyHistory<T> {
 public class CollectedPropertyHistory<T>(
     public val scope: CoroutineScope,
     eventFlow: Flow<DeviceMessage>,
+    public val deviceName: Name,
     public val propertyName: String,
     public val converter: MetaConverter<T>,
     maxSize: Int = 1000,
@@ -41,7 +43,7 @@ public class CollectedPropertyHistory<T>(
 
     private val store: SharedFlow<ValueWithTime<T>> = eventFlow
         .filterIsInstance<PropertyChangedMessage>()
-        .filter { it.property == propertyName }
+        .filter { it.sourceDevice == deviceName && it.property == propertyName }
         .map { ValueWithTime(converter.read(it.value), it.time) }
         .shareIn(scope, started = SharingStarted.Eagerly, replay = maxSize)
 
@@ -54,13 +56,15 @@ public class CollectedPropertyHistory<T>(
  */
 public fun <T> Device.collectPropertyHistory(
     scope: CoroutineScope = this,
+    deviceName: Name,
     propertyName: String,
     converter: MetaConverter<T>,
     maxSize: Int = 1000,
-): PropertyHistory<T> = CollectedPropertyHistory(scope, messageFlow, propertyName, converter, maxSize)
+): PropertyHistory<T> = CollectedPropertyHistory(scope, messageFlow, deviceName, propertyName, converter, maxSize)
 
 public fun <D : Device, T> D.collectPropertyHistory(
     scope: CoroutineScope = this,
+    deviceName: Name,
     spec: DevicePropertySpec<D, T>,
     maxSize: Int = 1000,
-): PropertyHistory<T> = collectPropertyHistory(scope, spec.name, spec.converter, maxSize)
\ No newline at end of file
+): PropertyHistory<T> = collectPropertyHistory(scope, deviceName, spec.name, spec.converter, maxSize)
\ No newline at end of file

From 29af4dfb2c68c90000c282f3c346b1c6e1f2597e Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 12 Mar 2024 22:26:43 +0300
Subject: [PATCH 063/125] Add heartbeat and watchdog

---
 magix/magix-utils/build.gradle.kts            | 26 ++++++
 .../kscience/magix/services/MagixRegistry.kt  |  6 +-
 .../magix/services/WatcherEndpointWrapper.kt  | 82 +++++++++++++++++++
 .../kscience/magix/services/converters.kt     |  0
 .../kscience/magix/services/magixPortal.kt    |  0
 settings.gradle.kts                           |  2 +
 6 files changed, 113 insertions(+), 3 deletions(-)
 create mode 100644 magix/magix-utils/build.gradle.kts
 rename magix/{magix-api => magix-utils}/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt (97%)
 create mode 100644 magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/WatcherEndpointWrapper.kt
 rename magix/{magix-api => magix-utils}/src/commonMain/kotlin/space/kscience/magix/services/converters.kt (100%)
 rename magix/{magix-api => magix-utils}/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt (100%)

diff --git a/magix/magix-utils/build.gradle.kts b/magix/magix-utils/build.gradle.kts
new file mode 100644
index 0000000..1f0ca20
--- /dev/null
+++ b/magix/magix-utils/build.gradle.kts
@@ -0,0 +1,26 @@
+import space.kscience.gradle.Maturity
+
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+description = """
+    Common utilities and services for Magix endpoints.   
+""".trimIndent()
+
+val dataforgeVersion: String by rootProject.extra
+
+kscience {
+    jvm()
+    js()
+    native()
+    commonMain {
+        api(projects.magix.magixApi)
+        api("space.kscience:dataforge-meta:$dataforgeVersion")
+    }
+}
+
+readme {
+    maturity = Maturity.EXPERIMENTAL
+}
\ No newline at end of file
diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt
similarity index 97%
rename from magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt
rename to magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt
index 3272131..c1a9466 100644
--- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt
+++ b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt
@@ -127,11 +127,11 @@ public fun CoroutineScope.launchMagixRegistry(
  *
  * If [registryEndpoint] field is provided, send request only to given endpoint.
  *
- * @param endpointName the name of endpoint requesting a property
+ * @param sourceEndpoint the name of endpoint requesting a property
  */
 public suspend fun MagixEndpoint.getProperty(
     propertyName: String,
-    endpointName: String,
+    sourceEndpoint: String,
     user: JsonElement? = null,
     registryEndpoint: String? = null,
 ): Flow<Pair<String, JsonElement>> = subscribe(
@@ -146,7 +146,7 @@ public suspend fun MagixEndpoint.getProperty(
     send(
         MagixRegistryMessage.format,
         MagixRegistryRequestMessage(propertyName),
-        source = endpointName,
+        source = sourceEndpoint,
         target = registryEndpoint,
         user = user
     )
diff --git a/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/WatcherEndpointWrapper.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/WatcherEndpointWrapper.kt
new file mode 100644
index 0000000..8560c79
--- /dev/null
+++ b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/WatcherEndpointWrapper.kt
@@ -0,0 +1,82 @@
+package space.kscience.magix.services
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.filter
+import kotlinx.coroutines.flow.onEach
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonPrimitive
+import kotlinx.serialization.json.jsonPrimitive
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.string
+import space.kscience.magix.api.MagixEndpoint
+import space.kscience.magix.api.MagixMessage
+import space.kscience.magix.api.MagixMessageFilter
+import space.kscience.magix.api.send
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+public class WatcherEndpointWrapper(
+    private val scope: CoroutineScope,
+    private val endpointName: String,
+    private val endpoint: MagixEndpoint,
+    private val meta: Meta,
+) : MagixEndpoint {
+
+    private val watchDogJob: Job = scope.launch {
+        val filter = MagixMessageFilter(
+            format = listOf(MAGIX_WATCHDOG_FORMAT),
+            target = listOf(null, endpointName)
+        )
+        endpoint.subscribe(filter).filter {
+            it.payload.jsonPrimitive.content == MAGIX_PING
+        }.onEach { request ->
+            endpoint.send(
+                MagixMessage(
+                    MAGIX_WATCHDOG_FORMAT,
+                    JsonPrimitive(MAGIX_PONG),
+                    sourceEndpoint = endpointName,
+                    targetEndpoint = request.sourceEndpoint,
+                    parentId = request.id
+                )
+            )
+        }.collect()
+    }
+
+    private val heartBeatDelay: Duration = meta["heartbeat.period"].string?.let { Duration.parse(it) } ?: 10.seconds
+    //TODO add update from registry
+
+    private val heartBeatJob = scope.launch {
+        while (isActive){
+            delay(heartBeatDelay)
+            endpoint.send(
+                MagixMessage(
+                    MAGIX_HEARTBEAT_FORMAT,
+                    JsonNull, //TODO consider adding timestamp
+                    endpointName
+                )
+            )
+        }
+    }
+
+    override fun subscribe(filter: MagixMessageFilter): Flow<MagixMessage> = endpoint.subscribe(filter)
+
+    override suspend fun broadcast(message: MagixMessage) {
+        endpoint.broadcast(message)
+    }
+
+    override fun close() {
+        endpoint.close()
+        watchDogJob.cancel()
+        heartBeatJob.cancel()
+    }
+
+    public companion object {
+        public const val MAGIX_WATCHDOG_FORMAT: String = "magix.watchdog"
+        public const val MAGIX_PING: String = "ping"
+        public const val MAGIX_PONG: String = "pong"
+        public const val MAGIX_HEARTBEAT_FORMAT: String = "magix.heartbeat"
+    }
+}
\ No newline at end of file
diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/converters.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/converters.kt
similarity index 100%
rename from magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/converters.kt
rename to magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/converters.kt
diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt
similarity index 100%
rename from magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt
rename to magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 7de4241..01bb379 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -47,6 +47,7 @@ include(
     ":controls-server",
     ":controls-opcua",
     ":controls-modbus",
+    ":controls-plc4x",
 //    ":controls-mongo",
     ":controls-storage",
     ":controls-storage:controls-xodus",
@@ -55,6 +56,7 @@ include(
     ":controls-jupyter",
     ":magix",
     ":magix:magix-api",
+    ":magix:magix-utils",
     ":magix:magix-server",
     ":magix:magix-rsocket",
     ":magix:magix-java-endpoint",

From f28e9dc226ea1856cae1575e1b7ae636777ad3a8 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 18 Mar 2024 09:30:41 +0300
Subject: [PATCH 064/125] Update constructor api

---
 build.gradle.kts                              |  4 +-
 .../controls/constructor/DeviceConstructor.kt | 44 +++---------
 .../controls/constructor/DeviceState.kt       | 16 +++++
 .../controls/opcua/server/DeviceNameSpace.kt  |  2 +-
 .../src/commonMain/kotlin/plotExtensions.kt   | 69 ++++++++++++++++---
 .../jsMain/kotlin/ControlsVisionPlugin.js.kt  | 14 +---
 demo/constructor/api/constructor.api          |  2 +-
 demo/constructor/build.gradle.kts             |  2 +-
 demo/constructor/src/jvmMain/kotlin/main.kt   |  5 +-
 gradle.properties                             |  2 +-
 magix/magix-utils/build.gradle.kts            |  1 +
 11 files changed, 98 insertions(+), 63 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index 99946f1..eeb4bfe 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,14 +6,14 @@ plugins {
 }
 
 val dataforgeVersion: String by extra("0.8.0")
-val visionforgeVersion by extra("0.4.0")
+val visionforgeVersion by extra("0.4.1")
 val ktorVersion: String by extra(space.kscience.gradle.KScienceVersions.ktorVersion)
 val rsocketVersion by extra("0.15.4")
 val xodusVersion by extra("2.0.1")
 
 allprojects {
     group = "space.kscience"
-    version = "0.3.0"
+    version = "0.3.1-dev-1"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 5c11f27..820f6ef 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -10,7 +10,6 @@ import space.kscience.dataforge.names.Name
 import space.kscience.dataforge.names.asName
 import kotlin.properties.PropertyDelegateProvider
 import kotlin.properties.ReadOnlyProperty
-import kotlin.properties.ReadWriteProperty
 import kotlin.reflect.KProperty
 import kotlin.time.Duration
 
@@ -55,17 +54,17 @@ public abstract class DeviceConstructor(
     /**
      * Register a property and provide a direct reader for it
      */
-    public fun <T : Any> property(
-        state: DeviceState<T>,
+    public fun <T : Any, S: DeviceState<T>> property(
+        state: S,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
         nameOverride: String? = null,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> =
+    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, S>> =
         PropertyDelegateProvider { _: DeviceConstructor, property ->
             val name = nameOverride ?: property.name
             val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
             registerProperty(descriptor, state)
             ReadOnlyProperty { _: DeviceConstructor, _ ->
-                state.value
+                state
             }
         }
 
@@ -79,37 +78,14 @@ public abstract class DeviceConstructor(
         initialState: T,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
         nameOverride: String? = null,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, T>> = property(
+    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, DeviceState<T>>> = property(
         DeviceState.external(this, metaConverter, readInterval, initialState, reader),
         descriptorBuilder,
         nameOverride,
     )
 
-
     /**
-     * Register a mutable property and provide a direct reader for it
-     */
-    public fun <T : Any> mutableProperty(
-        state: MutableDeviceState<T>,
-        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-        nameOverride: String? = null,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadWriteProperty<DeviceConstructor, T>> =
-        PropertyDelegateProvider { _: DeviceConstructor, property ->
-            val name = nameOverride ?: property.name
-            val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
-            registerProperty(descriptor, state)
-            object : ReadWriteProperty<DeviceConstructor, T> {
-                override fun getValue(thisRef: DeviceConstructor, property: KProperty<*>): T = state.value
-
-                override fun setValue(thisRef: DeviceConstructor, property: KProperty<*>, value: T) {
-                    state.value = value
-                }
-
-            }
-        }
-
-    /**
-     * Register external state as a property
+     * Register a mutable external state as a property
      */
     public fun <T : Any> mutableProperty(
         metaConverter: MetaConverter<T>,
@@ -119,22 +95,22 @@ public abstract class DeviceConstructor(
         initialState: T,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
         nameOverride: String? = null,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadWriteProperty<DeviceConstructor, T>> = mutableProperty(
+    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property(
         DeviceState.external(this, metaConverter, readInterval, initialState, reader, writer),
         descriptorBuilder,
         nameOverride,
     )
 
     /**
-     * Create and register a virtual property with optional [callback]
+     * Create and register a virtual mutable property with optional [callback]
      */
-    public fun <T : Any> state(
+    public fun <T : Any> virtualProperty(
         metaConverter: MetaConverter<T>,
         initialState: T,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
         nameOverride: String? = null,
         callback: (T) -> Unit = {},
-    ): PropertyDelegateProvider<DeviceConstructor, ReadWriteProperty<DeviceConstructor, T>> = mutableProperty(
+    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property(
         DeviceState.virtual(metaConverter, initialState, callback),
         descriptorBuilder,
         nameOverride,
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index 1828edd..1219210 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -1,6 +1,7 @@
 package space.kscience.controls.constructor
 
 import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.launch
@@ -11,6 +12,7 @@ import space.kscience.controls.spec.MutableDevicePropertySpec
 import space.kscience.controls.spec.name
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.MetaConverter
+import kotlin.reflect.KProperty
 import kotlin.time.Duration
 
 /**
@@ -29,6 +31,13 @@ public val <T> DeviceState<T>.metaFlow: Flow<Meta> get() = valueFlow.map(convert
 
 public val <T> DeviceState<T>.valueAsMeta: Meta get() = converter.convert(value)
 
+public operator fun <T> DeviceState<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value
+
+/**
+ * Collect values in a given [scope]
+ */
+public fun <T> DeviceState<T>.collectValuesIn(scope: CoroutineScope, block: suspend (T)->Unit): Job =
+    valueFlow.onEach(block).launchIn(scope)
 
 /**
  * A mutable state of a device
@@ -37,6 +46,10 @@ public interface MutableDeviceState<T> : DeviceState<T> {
     override var value: T
 }
 
+public operator fun <T> MutableDeviceState<T>.setValue(thisRef: Any?, property: KProperty<*>, value: T) {
+    this.value = value
+}
+
 public var <T : Any> MutableDeviceState<T>.valueAsMeta: Meta
     get() = converter.convert(value)
     set(arg) {
@@ -216,6 +229,9 @@ private class MutableExternalState<T>(
         }
 }
 
+/**
+ * Create a [DeviceState] that regularly reads and caches an external value
+ */
 public fun <T> DeviceState.Companion.external(
     scope: CoroutineScope,
     converter: MetaConverter<T>,
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
index e108b21..aa73890 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt
@@ -75,7 +75,7 @@ public class DeviceNameSpace(
 
                 browseName = newQualifiedName(propertyName)
                 displayName = LocalizedText.english(propertyName)
-                dataType = if (descriptor.metaDescriptor.children.isNotEmpty()) {
+                dataType = if (descriptor.metaDescriptor.nodes.isNotEmpty()) {
                     Identifiers.String
                 } else when (descriptor.metaDescriptor.valueTypes?.first()) {
                     null, ValueType.STRING, ValueType.NULL -> Identifiers.String
diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index 6e92165..4cfa49b 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -2,13 +2,8 @@
 
 package space.kscience.controls.vision
 
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.sample
-import kotlinx.coroutines.flow.transform
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.datetime.Clock
@@ -22,6 +17,7 @@ import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.name
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.*
+import space.kscience.dataforge.misc.DFExperimental
 import space.kscience.plotly.Plot
 import space.kscience.plotly.bar
 import space.kscience.plotly.models.Bar
@@ -183,4 +179,61 @@ public fun Plot.plotBooleanState(
     configuration: Bar.() -> Unit = {},
 ): Job = bar(configuration).run {
     updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, sampling)
-}
\ No newline at end of file
+}
+
+private fun <T> Flow<T>.chunkedByPeriod(duration: Duration): Flow<List<T>> {
+    val collector: ArrayDeque<T> = ArrayDeque<T>()
+    return channelFlow {
+        coroutineScope {
+            launch {
+                while (isActive) {
+                    delay(duration)
+                    send(ArrayList(collector))
+                    collector.clear()
+                }
+            }
+            this@chunkedByPeriod.collect {
+                collector.add(it)
+            }
+        }
+    }
+}
+
+private fun List<Instant>.averageTime(): Instant {
+    val min = min()
+    val max = max()
+    val duration = max - min
+    return min + duration / 2
+}
+
+/**
+ * Average property value by [averagingInterval]. Return [missingValue] on each sample interval if no events arrived.
+ */
+@DFExperimental
+public fun Plot.plotAveragedDeviceProperty(
+    device: Device,
+    propertyName: String,
+    missingValue: Double = 0.0,
+    extractValue: Meta.() -> Double = { value?.double ?: missingValue },
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    averagingInterval: Duration = defaultSampling,
+    coroutineScope: CoroutineScope = device.context,
+    configuration: Scatter.() -> Unit = {},
+): Job = scatter(configuration).run {
+    val data = TimeData()
+    device.propertyMessageFlow(propertyName).chunkedByPeriod(averagingInterval).transform { eventList ->
+        if(eventList.isEmpty()){
+            data.append(Clock.System.now(), missingValue.asValue())
+        } else {
+            val time = eventList.map { it.time }.averageTime()
+            val value = eventList.map { extractValue(it.value) }.average()
+            data.append(time, value.asValue())
+        }
+        data.trim(maxAge, maxPoints, minPoints)
+        emit(data)
+    }.onEach {
+        it.fillPlot(x, y)
+    }.launchIn(coroutineScope)
+}
diff --git a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
index 017989b..55074ae 100644
--- a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
+++ b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
@@ -1,29 +1,17 @@
 package space.kscience.controls.vision
 
 import kotlinx.serialization.modules.SerializersModule
-import org.w3c.dom.Element
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.PluginFactory
 import space.kscience.dataforge.context.PluginTag
 import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.names.Name
-import space.kscience.visionforge.Vision
 import space.kscience.visionforge.VisionPlugin
-import space.kscience.visionforge.html.ElementVisionRenderer
 
-public actual class ControlVisionPlugin : VisionPlugin(), ElementVisionRenderer {
+public actual class ControlVisionPlugin : VisionPlugin() {
     override val tag: PluginTag get() = Companion.tag
 
     override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule
 
-    override fun rateVision(vision: Vision): Int {
-        TODO("Not yet implemented")
-    }
-
-    override fun render(element: Element, name: Name, vision: Vision, meta: Meta) {
-        TODO("Not yet implemented")
-    }
-
     public actual companion object : PluginFactory<ControlVisionPlugin> {
         override val tag: PluginTag = PluginTag("controls.vision")
 
diff --git a/demo/constructor/api/constructor.api b/demo/constructor/api/constructor.api
index a6e5b7e..7206553 100644
--- a/demo/constructor/api/constructor.api
+++ b/demo/constructor/api/constructor.api
@@ -14,7 +14,7 @@ public final class space/kscience/controls/demo/constructor/LinearDrive : space/
 	public final fun getDrive ()Lspace/kscience/controls/constructor/Drive;
 	public final fun getEnd ()Lspace/kscience/controls/constructor/LimitSwitch;
 	public final fun getPid ()Lspace/kscience/controls/constructor/PidRegulator;
-	public final fun getPosition ()D
+	public final fun getPositionState ()Lspace/kscience/controls/constructor/DoubleRangeState;
 	public final fun getStart ()Lspace/kscience/controls/constructor/LimitSwitch;
 	public final fun getTarget ()D
 	public final fun setTarget (D)V
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
index 470533f..6909639 100644
--- a/demo/constructor/build.gradle.kts
+++ b/demo/constructor/build.gradle.kts
@@ -3,7 +3,7 @@ import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
 
 plugins {
     id("space.kscience.gradle.mpp")
-    id("org.jetbrains.compose") version "1.5.11"
+    alias(spclibs.plugins.compose)
 }
 
 kscience {
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 5cbef67..26ca10a 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -52,8 +52,9 @@ class LinearDrive(
     val end by device(LimitSwitch.factory(state.atEndState))
 
 
-    val position by property(state)
-    var target by mutableProperty(pid.mutablePropertyAsState(Regulator.target, 0.0))
+    val positionState: DoubleRangeState by property(state)
+    private val targetState: MutableDeviceState<Double> by property(pid.mutablePropertyAsState(Regulator.target, 0.0))
+    var target by targetState
 }
 
 
diff --git a/gradle.properties b/gradle.properties
index 717cec4..4f937f0 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -7,4 +7,4 @@ org.gradle.parallel=true
 org.gradle.configureondemand=true
 org.gradle.jvmargs=-Xmx4096m
 
-toolsVersion=0.15.2-kotlin-1.9.21
\ No newline at end of file
+toolsVersion=0.15.2-kotlin-1.9.22
\ No newline at end of file
diff --git a/magix/magix-utils/build.gradle.kts b/magix/magix-utils/build.gradle.kts
index 1f0ca20..b22b5f0 100644
--- a/magix/magix-utils/build.gradle.kts
+++ b/magix/magix-utils/build.gradle.kts
@@ -15,6 +15,7 @@ kscience {
     jvm()
     js()
     native()
+    useSerialization()
     commonMain {
         api(projects.magix.magixApi)
         api("space.kscience:dataforge-meta:$dataforgeVersion")

From 53cc4dc0df86908d09091a17104310a5b25d209b Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 18 Mar 2024 09:34:14 +0300
Subject: [PATCH 065/125] PLC4x bindings

---
 CHANGELOG.md | 3 +++
 1 file changed, 3 insertions(+)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30a9fee..760e00a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,8 +3,11 @@
 ## Unreleased
 
 ### Added
+- Value averaging plot extension
+- PLC4X bindings
 
 ### Changed
+- Constructor properties return `DeviceStat` in order to be able to subscribe to them
 
 ### Deprecated
 

From 70ab60f98c353c5952c2a3e41e12e6052a8a8be3 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 18 Mar 2024 17:15:39 +0300
Subject: [PATCH 066/125] fix plot extensions

---
 .../controls/constructor/DeviceConstructor.kt |  4 ++--
 .../controls/constructor/DeviceGroup.kt       |  4 ++--
 .../controls/constructor/DeviceState.kt       |  2 +-
 .../src/commonMain/kotlin/plotExtensions.kt   | 20 +++++++++----------
 4 files changed, 14 insertions(+), 16 deletions(-)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 820f6ef..63b5303 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -54,7 +54,7 @@ public abstract class DeviceConstructor(
     /**
      * Register a property and provide a direct reader for it
      */
-    public fun <T : Any, S: DeviceState<T>> property(
+    public fun <T, S: DeviceState<T>> property(
         state: S,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
         nameOverride: String? = null,
@@ -104,7 +104,7 @@ public abstract class DeviceConstructor(
     /**
      * Create and register a virtual mutable property with optional [callback]
      */
-    public fun <T : Any> virtualProperty(
+    public fun <T> virtualProperty(
         metaConverter: MetaConverter<T>,
         initialState: T,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index 6cdb784..ca05c27 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -28,7 +28,7 @@ public open class DeviceGroup(
 ) : DeviceHub, CachingDevice {
 
     internal class Property(
-        val state: DeviceState<out Any>,
+        val state: DeviceState<*>,
         val descriptor: PropertyDescriptor,
     )
 
@@ -82,7 +82,7 @@ public open class DeviceGroup(
     /**
      * Register a new property based on [DeviceState]. Properties could be modified dynamically
      */
-    public fun registerProperty(descriptor: PropertyDescriptor, state: DeviceState<out Any>) {
+    public fun registerProperty(descriptor: PropertyDescriptor, state: DeviceState<*>) {
         val name = descriptor.name.parseAsName()
         require(properties[name] == null) { "Can't add property with name $name. It already exists." }
         properties[name] = Property(state, descriptor)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index 1219210..faa7b2f 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -50,7 +50,7 @@ public operator fun <T> MutableDeviceState<T>.setValue(thisRef: Any?, property:
     this.value = value
 }
 
-public var <T : Any> MutableDeviceState<T>.valueAsMeta: Meta
+public var <T> MutableDeviceState<T>.valueAsMeta: Meta
     get() = converter.convert(value)
     set(arg) {
         value = converter.read(arg)
diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index 4cfa49b..804fe9d 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -184,18 +184,16 @@ public fun Plot.plotBooleanState(
 private fun <T> Flow<T>.chunkedByPeriod(duration: Duration): Flow<List<T>> {
     val collector: ArrayDeque<T> = ArrayDeque<T>()
     return channelFlow {
-        coroutineScope {
-            launch {
-                while (isActive) {
-                    delay(duration)
-                    send(ArrayList(collector))
-                    collector.clear()
-                }
-            }
-            this@chunkedByPeriod.collect {
-                collector.add(it)
+        launch {
+            while (isActive) {
+                delay(duration)
+                send(ArrayList(collector))
+                collector.clear()
             }
         }
+        this@chunkedByPeriod.collect {
+            collector.add(it)
+        }
     }
 }
 
@@ -224,7 +222,7 @@ public fun Plot.plotAveragedDeviceProperty(
 ): Job = scatter(configuration).run {
     val data = TimeData()
     device.propertyMessageFlow(propertyName).chunkedByPeriod(averagingInterval).transform { eventList ->
-        if(eventList.isEmpty()){
+        if (eventList.isEmpty()) {
             data.append(Clock.System.now(), missingValue.asValue())
         } else {
             val time = eventList.map { it.time }.averageTime()

From 78dade4b4969f64c4316e36fc02e84e9969614af Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 18 Mar 2024 17:18:31 +0300
Subject: [PATCH 067/125] PLC4x bindings

---
 controls-plc4x/build.gradle.kts               |  24 ++++
 .../kscience/controls/plc4x/Plc4XDevice.kt    |  28 ++++
 .../kscience/controls/plc4x/Plc4xProperty.kt  |  31 +++++
 .../kscience/controls/plc4x/plc4xConnector.kt | 123 ++++++++++++++++++
 4 files changed, 206 insertions(+)
 create mode 100644 controls-plc4x/build.gradle.kts
 create mode 100644 controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt
 create mode 100644 controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt
 create mode 100644 controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/plc4xConnector.kt

diff --git a/controls-plc4x/build.gradle.kts b/controls-plc4x/build.gradle.kts
new file mode 100644
index 0000000..0836476
--- /dev/null
+++ b/controls-plc4x/build.gradle.kts
@@ -0,0 +1,24 @@
+import space.kscience.gradle.Maturity
+
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+val plc4xVersion = "0.12.0"
+
+description = """
+    A plugin for Controls-kt device server on top of plc4x library
+""".trimIndent()
+
+kscience{
+    jvm()
+    jvmMain{
+        api(projects.controlsCore)
+        api("org.apache.plc4x:plc4j-spi:$plc4xVersion")
+    }
+}
+
+readme{
+    maturity = Maturity.EXPERIMENTAL
+}
\ No newline at end of file
diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt
new file mode 100644
index 0000000..996d194
--- /dev/null
+++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt
@@ -0,0 +1,28 @@
+package space.kscience.controls.plc4x
+
+import kotlinx.coroutines.future.await
+import org.apache.plc4x.java.api.PlcConnection
+import org.apache.plc4x.java.api.messages.PlcWriteRequest
+import space.kscience.controls.api.Device
+import space.kscience.dataforge.meta.Meta
+
+public interface Plc4XDevice: Device {
+    public val connection: PlcConnection
+
+    public suspend fun read(plc4xProperty: Plc4xProperty): Meta = with(plc4xProperty){
+        val request = connection.readRequestBuilder().request().build()
+        val response = request.execute().await()
+        response.readProperty()
+    }
+
+    public suspend fun write(plc4xProperty: Plc4xProperty, value: Meta): Unit = with(plc4xProperty){
+        val request: PlcWriteRequest = connection.writeRequestBuilder().writeProperty(value).build()
+       request.execute().await()
+    }
+
+    public suspend fun subscribe(propertyName: String, plc4xProperty: Plc4xProperty): Unit = with(plc4xProperty){
+
+    }
+
+}
+
diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt
new file mode 100644
index 0000000..5b16dec
--- /dev/null
+++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt
@@ -0,0 +1,31 @@
+package space.kscience.controls.plc4x
+
+import org.apache.plc4x.java.api.messages.PlcReadRequest
+import org.apache.plc4x.java.api.messages.PlcReadResponse
+import org.apache.plc4x.java.api.messages.PlcWriteRequest
+import org.apache.plc4x.java.api.types.PlcValueType
+import space.kscience.dataforge.meta.Meta
+
+public interface Plc4xProperty {
+
+    public fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder
+
+    public fun PlcReadResponse.readProperty(): Meta
+
+    public fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder
+}
+
+public class DefaultPlc4xProperty(
+    private val address: String,
+    private val plcValueType: PlcValueType,
+    private val name: String = "@default",
+) : Plc4xProperty {
+
+    override fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder =
+        addTagAddress(name, address)
+    override fun PlcReadResponse.readProperty(): Meta =
+        asPlcValue.toMeta()
+
+    override fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder =
+        addTagAddress(name, address, meta.toPlcValue(plcValueType))
+}
\ No newline at end of file
diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/plc4xConnector.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/plc4xConnector.kt
new file mode 100644
index 0000000..5d0b2de
--- /dev/null
+++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/plc4xConnector.kt
@@ -0,0 +1,123 @@
+package space.kscience.controls.plc4x
+
+import org.apache.plc4x.java.api.types.PlcValueType
+import org.apache.plc4x.java.api.value.PlcValue
+import org.apache.plc4x.java.spi.values.*
+import space.kscience.dataforge.meta.*
+import space.kscience.dataforge.names.asName
+import java.math.BigInteger
+
+internal fun PlcValue.toMeta(): Meta = Meta {
+    when (plcValueType) {
+        null, PlcValueType.NULL -> value = Null
+        PlcValueType.BOOL -> value = this@toMeta.boolean.asValue()
+        PlcValueType.BYTE -> this@toMeta.byte.asValue()
+        PlcValueType.WORD -> this@toMeta.short.asValue()
+        PlcValueType.DWORD -> this@toMeta.int.asValue()
+        PlcValueType.LWORD -> this@toMeta.long.asValue()
+        PlcValueType.USINT -> this@toMeta.short.asValue()
+        PlcValueType.UINT -> this@toMeta.int.asValue()
+        PlcValueType.UDINT -> this@toMeta.long.asValue()
+        PlcValueType.ULINT -> this@toMeta.bigInteger.asValue()
+        PlcValueType.SINT -> this@toMeta.byte.asValue()
+        PlcValueType.INT -> this@toMeta.short.asValue()
+        PlcValueType.DINT -> this@toMeta.int.asValue()
+        PlcValueType.LINT -> this@toMeta.long.asValue()
+        PlcValueType.REAL -> this@toMeta.float.asValue()
+        PlcValueType.LREAL -> this@toMeta.double.asValue()
+        PlcValueType.CHAR -> this@toMeta.int.asValue()
+        PlcValueType.WCHAR -> this@toMeta.short.asValue()
+        PlcValueType.STRING -> this@toMeta.string.asValue()
+        PlcValueType.WSTRING -> this@toMeta.string.asValue()
+        PlcValueType.TIME -> this@toMeta.duration.toString().asValue()
+        PlcValueType.LTIME -> this@toMeta.duration.toString().asValue()
+        PlcValueType.DATE -> this@toMeta.date.toString().asValue()
+        PlcValueType.LDATE -> this@toMeta.date.toString().asValue()
+        PlcValueType.TIME_OF_DAY -> this@toMeta.time.toString().asValue()
+        PlcValueType.LTIME_OF_DAY -> this@toMeta.time.toString().asValue()
+        PlcValueType.DATE_AND_TIME -> this@toMeta.dateTime.toString().asValue()
+        PlcValueType.DATE_AND_LTIME -> this@toMeta.dateTime.toString().asValue()
+        PlcValueType.LDATE_AND_TIME -> this@toMeta.dateTime.toString().asValue()
+        PlcValueType.Struct -> this@toMeta.struct.forEach { (name, item) ->
+            set(name, item.toMeta())
+        }
+
+        PlcValueType.List -> {
+            val listOfMeta = this@toMeta.list.map { it.toMeta() }
+            if (listOfMeta.all { it.items.isEmpty() }) {
+                value = listOfMeta.map { it.value ?: Null }.asValue()
+            } else {
+                setIndexed("@list".asName(), list.map { it.toMeta() })
+            }
+        }
+
+        PlcValueType.RAW_BYTE_ARRAY -> this@toMeta.raw.asValue()
+    }
+}
+
+private fun Value.toPlcValue(): PlcValue = when (type) {
+    ValueType.NUMBER -> when (val number = number) {
+        is Short -> PlcINT(number.toShort())
+        is Int -> PlcDINT(number.toInt())
+        is Long -> PlcLINT(number.toLong())
+        is Float -> PlcREAL(number.toFloat())
+        else -> PlcLREAL(number.toDouble())
+    }
+
+    ValueType.STRING -> PlcSTRING(string)
+    ValueType.BOOLEAN -> PlcBOOL(boolean)
+    ValueType.NULL -> PlcNull()
+    ValueType.LIST -> TODO()
+}
+
+internal fun Meta.toPlcValue(hint: PlcValueType): PlcValue = when (hint) {
+    PlcValueType.Struct -> PlcStruct(
+        items.entries.associate { (token, item) ->
+            token.toString() to item.toPlcValue(PlcValueType.Struct)
+        }
+    )
+
+    PlcValueType.NULL -> PlcNull()
+    PlcValueType.BOOL -> PlcBOOL(boolean)
+    PlcValueType.BYTE -> PlcBYTE(int)
+    PlcValueType.WORD -> PlcWORD(int)
+    PlcValueType.DWORD -> PlcDWORD(int)
+    PlcValueType.LWORD -> PlcLWORD(long)
+    PlcValueType.USINT -> PlcLWORD(short)
+    PlcValueType.UINT -> PlcUINT(int)
+    PlcValueType.UDINT -> PlcDINT(long)
+    PlcValueType.ULINT -> (number as? BigInteger)?.let { PlcULINT(it) } ?: PlcULINT(long)
+    PlcValueType.SINT -> PlcSINT(int)
+    PlcValueType.INT -> PlcINT(int)
+    PlcValueType.DINT -> PlcDINT(int)
+    PlcValueType.LINT -> PlcLINT(long)
+    PlcValueType.REAL -> PlcREAL(float)
+    PlcValueType.LREAL -> PlcLREAL(double)
+    PlcValueType.CHAR -> PlcCHAR(int)
+    PlcValueType.WCHAR -> PlcWCHAR(short)
+    PlcValueType.STRING -> PlcSTRING(string)
+    PlcValueType.WSTRING -> PlcWSTRING(string)
+    PlcValueType.TIME -> PlcTIME(string?.let { java.time.Duration.parse(it) })
+    PlcValueType.LTIME -> PlcLTIME(string?.let { java.time.Duration.parse(it) })
+    PlcValueType.DATE -> PlcDATE(string?.let { java.time.LocalDate.parse(it) })
+    PlcValueType.LDATE -> PlcLDATE(string?.let { java.time.LocalDate.parse(it) })
+    PlcValueType.TIME_OF_DAY -> PlcTIME_OF_DAY(string?.let { java.time.LocalTime.parse(it) })
+    PlcValueType.LTIME_OF_DAY -> PlcLTIME_OF_DAY(string?.let { java.time.LocalTime.parse(it) })
+    PlcValueType.DATE_AND_TIME -> PlcDATE_AND_TIME(string?.let { java.time.LocalDateTime.parse(it) })
+    PlcValueType.DATE_AND_LTIME -> PlcDATE_AND_LTIME(string?.let { java.time.LocalDateTime.parse(it) })
+    PlcValueType.LDATE_AND_TIME -> PlcLDATE_AND_TIME(string?.let { java.time.LocalDateTime.parse(it) })
+    PlcValueType.List -> PlcList().apply {
+        value?.list?.forEach { add(it.toPlcValue()) }
+        getIndexed("@list").forEach { (_, meta) ->
+            if (meta.items.isEmpty()) {
+                meta.value?.let { add(it.toPlcValue()) }
+            } else {
+                add(meta.toPlcValue(PlcValueType.Struct))
+            }
+        }
+    }
+
+    PlcValueType.RAW_BYTE_ARRAY -> PlcRawByteArray(
+        value?.list?.map { it.number.toByte() }?.toByteArray() ?: error("The meta content is not byte array")
+    )
+}
\ No newline at end of file

From 9a40d4f3404010b53b6ceacd17a346d42c98a6e0 Mon Sep 17 00:00:00 2001
From: InsanusMokrassar <ovsyannikov.alexey95@gmail.com>
Date: Wed, 20 Mar 2024 00:48:26 +0600
Subject: [PATCH 068/125] exclude ktor/rsocket/dataforge versions

---
 build.gradle.kts                                |  5 -----
 controls-core/build.gradle.kts                  |  2 +-
 controls-magix/build.gradle.kts                 |  2 +-
 controls-ports-ktor/build.gradle.kts            |  4 +---
 controls-server/build.gradle.kts                | 15 ++++++---------
 .../controls-xodus/build.gradle.kts             |  4 +---
 demo/all-things/build.gradle.kts                |  5 +----
 demo/car/build.gradle.kts                       |  5 +----
 demo/echo/build.gradle.kts                      |  5 +----
 demo/many-devices/build.gradle.kts              |  5 +----
 gradle/libs.versions.toml                       | 17 +++++++++++++++++
 magix/magix-rsocket/build.gradle.kts            |  9 ++++-----
 magix/magix-server/build.gradle.kts             |  5 ++---
 .../magix-storage-xodus/build.gradle.kts        |  4 +---
 settings.gradle.kts                             | 11 +++++++++++
 15 files changed, 49 insertions(+), 49 deletions(-)
 create mode 100644 gradle/libs.versions.toml

diff --git a/build.gradle.kts b/build.gradle.kts
index df7c664..02dde8d 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -6,11 +6,6 @@ plugins {
     id("space.kscience.gradle.project")
 }
 
-val dataforgeVersion: String by extra("0.6.2-dev-3")
-val ktorVersion: String by extra(space.kscience.gradle.KScienceVersions.ktorVersion)
-val rsocketVersion by extra("0.15.4")
-val xodusVersion by extra("2.0.1")
-
 allprojects {
     group = "space.kscience"
     version = "0.2.0"
diff --git a/controls-core/build.gradle.kts b/controls-core/build.gradle.kts
index bbe32eb..05bb084 100644
--- a/controls-core/build.gradle.kts
+++ b/controls-core/build.gradle.kts
@@ -21,7 +21,7 @@ kscience {
     }
     useContextReceivers()
     dependencies {
-        api("space.kscience:dataforge-io:$dataforgeVersion")
+        api(libs.dataforge.io)
         api(spclibs.kotlinx.datetime)
     }
 }
diff --git a/controls-magix/build.gradle.kts b/controls-magix/build.gradle.kts
index 0296b8c..f0a6339 100644
--- a/controls-magix/build.gradle.kts
+++ b/controls-magix/build.gradle.kts
@@ -18,7 +18,7 @@ kscience {
     dependencies {
         api(projects.magix.magixApi)
         api(projects.controlsCore)
-        api("com.benasher44:uuid:0.8.0")
+        api(libs.uuid)
     }
 }
 
diff --git a/controls-ports-ktor/build.gradle.kts b/controls-ports-ktor/build.gradle.kts
index 4c8ad9a..1f1efda 100644
--- a/controls-ports-ktor/build.gradle.kts
+++ b/controls-ports-ktor/build.gradle.kts
@@ -9,11 +9,9 @@ description = """
     Implementation of byte ports on top os ktor-io asynchronous API
 """.trimIndent()
 
-val ktorVersion: String by rootProject.extra
-
 dependencies {
     api(projects.controlsCore)
-    api("io.ktor:ktor-network:$ktorVersion")
+    api(spclibs.ktor.network)
 }
 
 readme{
diff --git a/controls-server/build.gradle.kts b/controls-server/build.gradle.kts
index 43a9d61..5528b63 100644
--- a/controls-server/build.gradle.kts
+++ b/controls-server/build.gradle.kts
@@ -9,19 +9,16 @@ description = """
    A combined Magix event loop server with web server for visualization.
 """.trimIndent()
 
-val dataforgeVersion: String by rootProject.extra
-val ktorVersion: String by rootProject.extra
-
 dependencies {
     implementation(projects.controlsCore)
     implementation(projects.controlsPortsKtor)
     implementation(projects.magix.magixServer)
-    implementation("io.ktor:ktor-server-cio:$ktorVersion")
-    implementation("io.ktor:ktor-server-websockets:$ktorVersion")
-    implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion")
-    implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
-    implementation("io.ktor:ktor-server-html-builder:$ktorVersion")
-    implementation("io.ktor:ktor-server-status-pages:$ktorVersion")
+    implementation(spclibs.ktor.server.cio)
+    implementation(spclibs.ktor.server.websockets)
+    implementation(spclibs.ktor.server.content.negotiation)
+    implementation(spclibs.ktor.serialization.kotlinx.json)
+    implementation(spclibs.ktor.server.html.builder)
+    implementation(spclibs.ktor.server.status.pages)
 }
 
 readme{
diff --git a/controls-storage/controls-xodus/build.gradle.kts b/controls-storage/controls-xodus/build.gradle.kts
index 329f3ce..8cebd37 100644
--- a/controls-storage/controls-xodus/build.gradle.kts
+++ b/controls-storage/controls-xodus/build.gradle.kts
@@ -3,15 +3,13 @@ plugins {
     `maven-publish`
 }
 
-val xodusVersion: String by rootProject.extra
-
 description = """
     An implementation of controls-storage on top of JetBrains Xodus.
 """.trimIndent()
 
 dependencies {
     api(projects.controlsStorage)
-    implementation("org.jetbrains.xodus:xodus-entity-store:$xodusVersion")
+    implementation(libs.xodus.entity.store)
 //    implementation("org.jetbrains.xodus:xodus-environment:$xodusVersion")
 //    implementation("org.jetbrains.xodus:xodus-vfs:$xodusVersion")
 
diff --git a/demo/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts
index 05d7395..9670921 100644
--- a/demo/all-things/build.gradle.kts
+++ b/demo/all-things/build.gradle.kts
@@ -10,9 +10,6 @@ repositories {
     maven("https://repo.kotlin.link")
 }
 
-val ktorVersion: String by rootProject.extra
-val rsocketVersion: String by rootProject.extra
-
 dependencies {
     implementation(projects.controlsCore)
     //implementation(projects.controlsServer)
@@ -22,7 +19,7 @@ dependencies {
     implementation(projects.magix.magixZmq)
     implementation(projects.controlsOpcua)
 
-    implementation("io.ktor:ktor-client-cio:$ktorVersion")
+    implementation(spclibs.ktor.client.cio)
     implementation("no.tornado:tornadofx:1.7.20")
     implementation("space.kscience:plotlykt-server:0.5.3")
 //    implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6")
diff --git a/demo/car/build.gradle.kts b/demo/car/build.gradle.kts
index 5d53f11..0d2de58 100644
--- a/demo/car/build.gradle.kts
+++ b/demo/car/build.gradle.kts
@@ -10,9 +10,6 @@ repositories {
     maven("https://repo.kotlin.link")
 }
 
-val ktorVersion: String by rootProject.extra
-val rsocketVersion: String by rootProject.extra
-
 dependencies {
     implementation(projects.controlsCore)
     implementation(projects.magix.magixApi)
@@ -24,7 +21,7 @@ dependencies {
     implementation(projects.magix.magixStorage.magixStorageXodus)
 //    implementation(projects.controlsMongo)
 
-    implementation("io.ktor:ktor-client-cio:$ktorVersion")
+    implementation(spclibs.ktor.client.cio)
     implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.3.1")
     implementation("no.tornado:tornadofx:1.7.20")
     implementation("space.kscience:plotlykt-server:0.5.0")
diff --git a/demo/echo/build.gradle.kts b/demo/echo/build.gradle.kts
index 5563ba3..d10aee5 100644
--- a/demo/echo/build.gradle.kts
+++ b/demo/echo/build.gradle.kts
@@ -8,14 +8,11 @@ repositories {
     maven("https://repo.kotlin.link")
 }
 
-val ktorVersion: String by rootProject.extra
-val rsocketVersion: String by rootProject.extra
-
 dependencies {
     implementation(projects.magix.magixServer)
     implementation(projects.magix.magixRsocket)
     implementation(projects.magix.magixZmq)
-    implementation("io.ktor:ktor-client-cio:$ktorVersion")
+    implementation(spclibs.ktor.client.cio)
 
     implementation("ch.qos.logback:logback-classic:1.2.11")
 }
diff --git a/demo/many-devices/build.gradle.kts b/demo/many-devices/build.gradle.kts
index 7248e42..250b5e7 100644
--- a/demo/many-devices/build.gradle.kts
+++ b/demo/many-devices/build.gradle.kts
@@ -9,16 +9,13 @@ repositories {
     maven("https://repo.kotlin.link")
 }
 
-val ktorVersion: String by rootProject.extra
-val rsocketVersion: String by rootProject.extra
-
 dependencies {
     implementation(projects.magix.magixServer)
     implementation(projects.controlsMagix)
     implementation(projects.magix.magixRsocket)
     implementation(projects.magix.magixZmq)
 
-    implementation("io.ktor:ktor-client-cio:$ktorVersion")
+    implementation(spclibs.ktor.client.cio)
     implementation("space.kscience:plotlykt-server:0.6.0")
     implementation(spclibs.logback.classic)
 }
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
new file mode 100644
index 0000000..502a400
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,17 @@
+[versions]
+
+dataforge = "0.6.2-dev-3"
+rsocket = "0.15.4"
+xodus = "2.0.1"
+
+uuid = "0.8.0"
+
+[libraries]
+
+dataforge-io = { module = "space.kscience:dataforge-io", version.ref = "dataforge" }
+uuid = { module = "com.benasher44:uuid", version.ref = "uuid" }
+xodus-entity-store = { module = "org.jetbrains.xodus:xodus-entity-store", version.ref = "xodus" }
+rsocket-ktor-client = { module = "io.rsocket.kotlin:rsocket-ktor-client", version.ref = "rsocket" }
+rsocket-ktor-server = { module = "io.rsocket.kotlin:rsocket-ktor-server", version.ref = "rsocket" }
+rsocket-transport-ktor-tcp = { module = "io.rsocket.kotlin:rsocket-transport-ktor-tcp", version.ref = "rsocket" }
+
diff --git a/magix/magix-rsocket/build.gradle.kts b/magix/magix-rsocket/build.gradle.kts
index b2046bb..ca016ce 100644
--- a/magix/magix-rsocket/build.gradle.kts
+++ b/magix/magix-rsocket/build.gradle.kts
@@ -10,7 +10,6 @@ description = """
 """.trimIndent()
 
 val ktorVersion: String by rootProject.extra
-val rsocketVersion: String by rootProject.extra
 
 kscience {
     jvm()
@@ -21,11 +20,11 @@ kscience {
     }
     dependencies {
         api(projects.magix.magixApi)
-        implementation("io.ktor:ktor-client-core:$ktorVersion")
-        implementation("io.rsocket.kotlin:rsocket-ktor-client:$rsocketVersion")
+        implementation(spclibs.ktor.client.core)
+        implementation(libs.rsocket.ktor.client)
     }
     dependencies(jvmMain) {
-        implementation("io.rsocket.kotlin:rsocket-transport-ktor-tcp:$rsocketVersion")
+        implementation(libs.rsocket.transport.ktor.tcp)
     }
 }
 
@@ -33,7 +32,7 @@ kotlin {
     sourceSets {
         getByName("linuxX64Main") {
             dependencies {
-                implementation("io.rsocket.kotlin:rsocket-transport-ktor-tcp:$rsocketVersion")
+                implementation(libs.rsocket.transport.ktor.tcp)
             }
         }
     }
diff --git a/magix/magix-server/build.gradle.kts b/magix/magix-server/build.gradle.kts
index cb63049..f00b81e 100644
--- a/magix/magix-server/build.gradle.kts
+++ b/magix/magix-server/build.gradle.kts
@@ -17,7 +17,6 @@ kscience {
 }
 
 val dataforgeVersion: String by rootProject.extra
-val rsocketVersion: String by rootProject.extra
 val ktorVersion: String  = space.kscience.gradle.KScienceVersions.ktorVersion
 
 dependencies{
@@ -28,8 +27,8 @@ dependencies{
     api("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
     api("io.ktor:ktor-server-html-builder:$ktorVersion")
 
-    api("io.rsocket.kotlin:rsocket-ktor-server:$rsocketVersion")
-    api("io.rsocket.kotlin:rsocket-transport-ktor-tcp:$rsocketVersion")
+    api(libs.rsocket.ktor.server)
+    api(libs.rsocket.transport.ktor.tcp)
 }
 
 readme{
diff --git a/magix/magix-storage/magix-storage-xodus/build.gradle.kts b/magix/magix-storage/magix-storage-xodus/build.gradle.kts
index ed1dc32..f50abe3 100644
--- a/magix/magix-storage/magix-storage-xodus/build.gradle.kts
+++ b/magix/magix-storage/magix-storage-xodus/build.gradle.kts
@@ -3,15 +3,13 @@ plugins {
     `maven-publish`
 }
 
-val xodusVersion: String by rootProject.extra
-
 kscience {
     useCoroutines()
 }
 
 dependencies {
     api(projects.magix.magixStorage)
-    implementation("org.jetbrains.xodus:xodus-entity-store:$xodusVersion")
+    implementation(libs.xodus.entity.store)
 //    implementation("org.jetbrains.xodus:dnq:2.0.0")
 
     testImplementation(spclibs.kotlinx.coroutines.test)
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 20ac44e..626da41 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -35,6 +35,17 @@ dependencyResolutionManagement {
     versionCatalogs {
         create("spclibs") {
             from("space.kscience:version-catalog:$toolsVersion")
+
+            library("ktor-client-core", "io.ktor", "ktor-client-core").versionRef("ktor")
+            library("ktor-client-cio", "io.ktor", "ktor-client-cio").versionRef("ktor")
+            library("ktor-network", "io.ktor", "ktor-network").versionRef("ktor")
+            library("ktor-serialization-kotlinx-json", "io.ktor", "ktor-serialization-kotlinx-json").versionRef("ktor")
+
+            library("ktor-server-cio", "io.ktor", "ktor-server-cio").versionRef("ktor")
+            library("ktor-server-websockets", "io.ktor", "ktor-server-websockets").versionRef("ktor")
+            library("ktor-server-content-negotiation", "io.ktor", "ktor-server-content-negotiation").versionRef("ktor")
+            library("ktor-server-html-builder", "io.ktor", "ktor-server-html-builder").versionRef("ktor")
+            library("ktor-server-status-pages", "io.ktor", "ktor-server-status-pages").versionRef("ktor")
         }
     }
 }

From 89656291517dfaccff3a32a15018caad93b7da68 Mon Sep 17 00:00:00 2001
From: InsanusMokrassar <ovsyannikov.alexey95@gmail.com>
Date: Wed, 20 Mar 2024 01:08:04 +0600
Subject: [PATCH 069/125] complete dependencies extraction

---
 controls-serial/build.gradle.kts              |  2 +-
 demo/all-things/build.gradle.kts              |  4 +--
 demo/car/build.gradle.kts                     | 14 ++++----
 demo/echo/build.gradle.kts                    |  2 +-
 demo/many-devices/build.gradle.kts            |  2 +-
 demo/motors/build.gradle.kts                  |  2 +-
 gradle/libs.versions.toml                     | 32 +++++++++++++++++++
 magix/magix-java-endpoint/build.gradle.kts    |  2 +-
 magix/magix-mqtt/build.gradle.kts             |  2 +-
 magix/magix-rabbit/build.gradle.kts           |  2 +-
 .../magix-storage-mongo/build.gradle.kts      |  4 +--
 settings.gradle.kts                           |  2 ++
 12 files changed, 51 insertions(+), 19 deletions(-)

diff --git a/controls-serial/build.gradle.kts b/controls-serial/build.gradle.kts
index a9afc41..d44a029 100644
--- a/controls-serial/build.gradle.kts
+++ b/controls-serial/build.gradle.kts
@@ -9,7 +9,7 @@ description = "Implementation of direct serial port communication with JSerialCo
 
 dependencies{
     api(project(":controls-core"))
-    implementation("com.fazecast:jSerialComm:2.10.3")
+    implementation(libs.jSerialComm)
 }
 
 readme{
diff --git a/demo/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts
index 9670921..b6074ed 100644
--- a/demo/all-things/build.gradle.kts
+++ b/demo/all-things/build.gradle.kts
@@ -20,8 +20,8 @@ dependencies {
     implementation(projects.controlsOpcua)
 
     implementation(spclibs.ktor.client.cio)
-    implementation("no.tornado:tornadofx:1.7.20")
-    implementation("space.kscience:plotlykt-server:0.5.3")
+    implementation(libs.tornadofx)
+    implementation(libs.plotlykt.server)
 //    implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6")
     implementation(spclibs.logback.classic)
 }
diff --git a/demo/car/build.gradle.kts b/demo/car/build.gradle.kts
index 0d2de58..b8a7fb6 100644
--- a/demo/car/build.gradle.kts
+++ b/demo/car/build.gradle.kts
@@ -22,13 +22,13 @@ dependencies {
 //    implementation(projects.controlsMongo)
 
     implementation(spclibs.ktor.client.cio)
-    implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.3.1")
-    implementation("no.tornado:tornadofx:1.7.20")
-    implementation("space.kscience:plotlykt-server:0.5.0")
-    implementation("ch.qos.logback:logback-classic:1.2.11")
-    implementation("org.jetbrains.xodus:xodus-entity-store:1.3.232")
-    implementation("org.jetbrains.xodus:xodus-environment:1.3.232")
-    implementation("org.jetbrains.xodus:xodus-vfs:1.3.232")
+    implementation(spclibs.kotlinx.datetime)
+    implementation(libs.tornadofx)
+    implementation(libs.plotlykt.server)
+    implementation(libs.logback.classic)
+    implementation(libs.xodus.entity.store)
+    implementation(libs.xodus.environment)
+    implementation(libs.xodus.vfs)
 //    implementation("org.litote.kmongo:kmongo-coroutine-serialization:4.4.0")
 }
 
diff --git a/demo/echo/build.gradle.kts b/demo/echo/build.gradle.kts
index d10aee5..873220e 100644
--- a/demo/echo/build.gradle.kts
+++ b/demo/echo/build.gradle.kts
@@ -14,7 +14,7 @@ dependencies {
     implementation(projects.magix.magixZmq)
     implementation(spclibs.ktor.client.cio)
 
-    implementation("ch.qos.logback:logback-classic:1.2.11")
+    implementation(libs.logback.classic)
 }
 kotlin{
     jvmToolchain(11)
diff --git a/demo/many-devices/build.gradle.kts b/demo/many-devices/build.gradle.kts
index 250b5e7..64fbfe2 100644
--- a/demo/many-devices/build.gradle.kts
+++ b/demo/many-devices/build.gradle.kts
@@ -16,7 +16,7 @@ dependencies {
     implementation(projects.magix.magixZmq)
 
     implementation(spclibs.ktor.client.cio)
-    implementation("space.kscience:plotlykt-server:0.6.0")
+    implementation(libs.plotlykt.server)
     implementation(spclibs.logback.classic)
 }
 
diff --git a/demo/motors/build.gradle.kts b/demo/motors/build.gradle.kts
index 8626c72..dd64935 100644
--- a/demo/motors/build.gradle.kts
+++ b/demo/motors/build.gradle.kts
@@ -25,5 +25,5 @@ val dataforgeVersion: String by extra
 dependencies {
     implementation(project(":controls-ports-ktor"))
     implementation(projects.controlsMagix)
-    implementation("no.tornado:tornadofx:1.7.20")
+    implementation(libs.tornadofx)
 }
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 502a400..e39b1d9 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -6,12 +6,44 @@ xodus = "2.0.1"
 
 uuid = "0.8.0"
 
+fazecast = "2.10.3"
+
+tornadofx = "1.7.20"
+
+plotlykt = "0.5.3"
+
+logback = "1.2.11"
+
+hivemq = "1.3.1"
+
+rabbitmq = "5.14.2"
+
+kmongo = "4.5.1"
+
 [libraries]
 
 dataforge-io = { module = "space.kscience:dataforge-io", version.ref = "dataforge" }
+
 uuid = { module = "com.benasher44:uuid", version.ref = "uuid" }
+
 xodus-entity-store = { module = "org.jetbrains.xodus:xodus-entity-store", version.ref = "xodus" }
+xodus-environment = { module = "org.jetbrains.xodus:xodus-environment", version.ref = "xodus" }
+xodus-vfs = { module = "org.jetbrains.xodus:xodus-vfs", version.ref = "xodus" }
+
 rsocket-ktor-client = { module = "io.rsocket.kotlin:rsocket-ktor-client", version.ref = "rsocket" }
 rsocket-ktor-server = { module = "io.rsocket.kotlin:rsocket-ktor-server", version.ref = "rsocket" }
 rsocket-transport-ktor-tcp = { module = "io.rsocket.kotlin:rsocket-transport-ktor-tcp", version.ref = "rsocket" }
 
+jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "fazecast" }
+
+tornadofx = { module = "no.tornado:tornadofx", version.ref = "tornadofx" }
+
+plotlykt-server = { module = "space.kscience:plotlykt-server", version.ref = "plotlykt" }
+
+logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" }
+
+hivemq-mqtt-client = { module = "com.hivemq:hivemq-mqtt-client", version.ref = "hivemq" }
+
+rabbitmq-amqp-client = { module = "com.rabbitmq:amqp-client", version.ref = "rabbitmq" }
+
+kmongo-coroutine-serialization = { module = "org.litote.kmongo:kmongo-coroutine-serialization", version.ref = "kmongo" }
diff --git a/magix/magix-java-endpoint/build.gradle.kts b/magix/magix-java-endpoint/build.gradle.kts
index ff51835..ce20b5d 100644
--- a/magix/magix-java-endpoint/build.gradle.kts
+++ b/magix/magix-java-endpoint/build.gradle.kts
@@ -14,7 +14,7 @@ description = """
 
 dependencies {
     implementation(project(":magix:magix-rsocket"))
-    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:${KScienceVersions.coroutinesVersion}")
+    implementation(spclibs.kotlinx.coroutines.jdk9)
 }
 
 //java {
diff --git a/magix/magix-mqtt/build.gradle.kts b/magix/magix-mqtt/build.gradle.kts
index e7037e6..5261a00 100644
--- a/magix/magix-mqtt/build.gradle.kts
+++ b/magix/magix-mqtt/build.gradle.kts
@@ -9,7 +9,7 @@ description = """
 
 dependencies {
     api(projects.magix.magixApi)
-    implementation("com.hivemq:hivemq-mqtt-client:1.3.1")
+    implementation(libs.hivemq.mqtt.client)
     implementation(spclibs.kotlinx.coroutines.jdk8)
 }
 
diff --git a/magix/magix-rabbit/build.gradle.kts b/magix/magix-rabbit/build.gradle.kts
index 3d7bc4d..d5472a5 100644
--- a/magix/magix-rabbit/build.gradle.kts
+++ b/magix/magix-rabbit/build.gradle.kts
@@ -9,7 +9,7 @@ description = """
 
 dependencies {
     api(projects.magix.magixApi)
-    implementation("com.rabbitmq:amqp-client:5.14.2")
+    implementation(libs.rabbitmq.amqp.client)
 }
 
 readme{
diff --git a/magix/magix-storage/magix-storage-mongo/build.gradle.kts b/magix/magix-storage/magix-storage-mongo/build.gradle.kts
index 51ed9f4..b72a2ec 100644
--- a/magix/magix-storage/magix-storage-mongo/build.gradle.kts
+++ b/magix/magix-storage/magix-storage-mongo/build.gradle.kts
@@ -3,11 +3,9 @@ plugins {
     `maven-publish`
 }
 
-val kmongoVersion = "4.5.1"
-
 dependencies {
     implementation(projects.controlsStorage)
-    implementation("org.litote.kmongo:kmongo-coroutine-serialization:$kmongoVersion")
+    implementation(libs.kmongo.coroutine.serialization)
 }
 
 readme{
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 626da41..0753e55 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -36,6 +36,8 @@ dependencyResolutionManagement {
         create("spclibs") {
             from("space.kscience:version-catalog:$toolsVersion")
 
+            library("kotlinx-coroutines-jdk9", "org.jetbrains.kotlinx", "kotlinx-coroutines-jdk9").versionRef("kotlinx-coroutines")
+
             library("ktor-client-core", "io.ktor", "ktor-client-core").versionRef("ktor")
             library("ktor-client-cio", "io.ktor", "ktor-client-cio").versionRef("ktor")
             library("ktor-network", "io.ktor", "ktor-network").versionRef("ktor")

From d91296c47d73358b3d5e0ebcb018240902168cf7 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 25 Mar 2024 15:48:23 +0300
Subject: [PATCH 070/125] Refactor load test

---
 .../space/kscience/controls/ports/Port.kt     |   2 +-
 .../space/kscience/controls/ports/phrases.kt  |  25 ++++
 .../kscience/controls/client/controlsMagix.kt |   5 +-
 demo/many-devices/build.gradle.kts            |   2 +-
 .../kscience/controls/demo/MassDevice.kt      | 109 ++++++++++--------
 gradle/wrapper/gradle-wrapper.properties      |   2 +-
 6 files changed, 91 insertions(+), 54 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
index b17de92..9366604 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
@@ -80,7 +80,7 @@ public abstract class AbstractPort(
 
     /**
      * Raw flow of incoming data chunks. The chunks are not guaranteed to be complete phrases.
-     * In order to form phrases, some condition should be used on top of it.
+     * To form phrases, some condition should be used on top of it.
      * For example [stringsDelimitedIncoming] generates phrases with fixed delimiter.
      */
     override fun receiving(): Flow<ByteArray> = incoming.receiveAsFlow()
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
index b86591f..03d21f5 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
@@ -42,6 +42,31 @@ public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray>
     }
 }
 
+private fun Flow<ByteArray>.withFixedMessageSize(messageSize: Int): Flow<ByteArray> {
+    require(messageSize > 0) { "Message size should be positive" }
+
+    val output = Buffer()
+
+    onCompletion {
+        output.close()
+    }
+
+    return transform { chunk ->
+        val remaining: Int = (messageSize - output.size).toInt()
+        if (chunk.size >= remaining) {
+            output.write(chunk, endIndex = remaining)
+            emit(output.readByteArray())
+            output.clear()
+            //write the remaining chunk fragment
+            if(chunk.size> remaining) {
+                output.write(chunk, startIndex = remaining)
+            }
+        } else {
+            output.write(chunk)
+        }
+    }
+}
+
 /**
  * Transform byte fragments into utf-8 phrases using utf-8 delimiter
  */
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
index 8aa4383..915d0a0 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
@@ -12,6 +12,8 @@ import space.kscience.controls.manager.respondHubMessage
 import space.kscience.dataforge.context.error
 import space.kscience.dataforge.context.logger
 import space.kscience.magix.api.*
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
 
 
 internal val controlsMagixFormat: MagixFormat<DeviceMessage> = MagixFormat(
@@ -38,7 +40,8 @@ internal fun generateId(request: MagixMessage): String = if (request.id != null)
 public fun DeviceManager.launchMagixService(
     endpoint: MagixEndpoint,
     endpointID: String = controlsMagixFormat.defaultFormat,
-): Job = context.launch {
+    coroutineContext: CoroutineContext = EmptyCoroutineContext,
+): Job = context.launch(coroutineContext) {
     endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID, null)).onEach { (request, payload) ->
         val responsePayload = respondHubMessage(payload)
         responsePayload.forEach {
diff --git a/demo/many-devices/build.gradle.kts b/demo/many-devices/build.gradle.kts
index 7248e42..f46b71f 100644
--- a/demo/many-devices/build.gradle.kts
+++ b/demo/many-devices/build.gradle.kts
@@ -19,7 +19,7 @@ dependencies {
     implementation(projects.magix.magixZmq)
 
     implementation("io.ktor:ktor-client-cio:$ktorVersion")
-    implementation("space.kscience:plotlykt-server:0.6.0")
+    implementation("space.kscience:plotlykt-server:0.7.1")
     implementation(spclibs.logback.classic)
 }
 
diff --git a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
index 22d7e1c..5eafabf 100644
--- a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
+++ b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
@@ -1,8 +1,11 @@
 package space.kscience.controls.demo
 
-import kotlinx.coroutines.*
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.datetime.Clock
@@ -19,15 +22,19 @@ import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.meta.int
 import space.kscience.magix.api.MagixEndpoint
 import space.kscience.magix.api.subscribe
-import space.kscience.magix.rsocket.rSocketWithTcp
-import space.kscience.magix.rsocket.rSocketWithWebSockets
+import space.kscience.magix.rsocket.rSocketStreamWithWebSockets
 import space.kscience.magix.server.RSocketMagixFlowPlugin
 import space.kscience.magix.server.startMagixServer
-import space.kscience.plotly.*
+import space.kscience.plotly.Plotly
+import space.kscience.plotly.PlotlyConfig
+import space.kscience.plotly.layout
+import space.kscience.plotly.models.Bar
+import space.kscience.plotly.plot
 import space.kscience.plotly.server.PlotlyUpdateMode
 import space.kscience.plotly.server.serve
 import space.kscience.plotly.server.show
 import space.kscince.magix.zmq.ZmqMagixFlowPlugin
+import space.kscince.magix.zmq.zmq
 import kotlin.random.Random
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.ZERO
@@ -46,14 +53,13 @@ class MassDevice(context: Context, meta: Meta) : DeviceBySpec<MassDevice>(MassDe
         val value by doubleProperty { randomValue }
 
         override suspend fun MassDevice.onOpen() {
-            doRecurring((meta["delay"].int ?: 10).milliseconds) {
+            doRecurring((meta["delay"].int ?: 5).milliseconds) {
                 read(value)
             }
         }
     }
 }
 
-@OptIn(DelicateCoroutinesApi::class)
 suspend fun main() {
     val context = Context("Mass")
 
@@ -62,28 +68,60 @@ suspend fun main() {
         ZmqMagixFlowPlugin()
     )
 
-    val numDevices = 100
+    val numDevices = 50
+
 
     repeat(numDevices) {
-        context.launch(newFixedThreadPoolContext(2, "Device${it}")) {
-            delay(1)
-            val deviceContext = Context("Device${it}") {
-                plugin(DeviceManager)
+        delay(1)
+        val deviceContext = Context("Device${it}") {
+            plugin(DeviceManager)
+        }
+
+        val deviceManager = deviceContext.request(DeviceManager)
+
+        deviceManager.install("device$it", MassDevice)
+
+        val endpointId = "device$it"
+        val deviceEndpoint = MagixEndpoint.rSocketStreamWithWebSockets("localhost")
+        deviceManager.launchMagixService(deviceEndpoint, endpointId, Dispatchers.IO)
+
+    }
+
+    val trace = Bar {
+        context.launch(Dispatchers.IO) {
+            val monitorEndpoint = MagixEndpoint.zmq("localhost")
+
+            val mutex = Mutex()
+
+            val latest = HashMap<String, Duration>()
+            val max = HashMap<String, Duration>()
+
+            monitorEndpoint.subscribe(DeviceManager.magixFormat).onEach { (magixMessage, payload) ->
+                mutex.withLock {
+                    val delay = Clock.System.now() - payload.time
+                    latest[magixMessage.sourceEndpoint] = Clock.System.now() - payload.time
+                    max[magixMessage.sourceEndpoint] =
+                        maxOf(delay, max[magixMessage.sourceEndpoint] ?: ZERO)
+                }
+            }.launchIn(this)
+
+            while (isActive) {
+                delay(200)
+                mutex.withLock {
+                    val sorted = max.mapKeys { it.key.substring(6).toInt() }.toSortedMap()
+                    latest.clear()
+                    max.clear()
+                    x.numbers = sorted.keys
+                    y.numbers = sorted.values.map { it.inWholeMicroseconds / 1000.0 + 0.0001 }
+                }
             }
-
-            val deviceManager = deviceContext.request(DeviceManager)
-
-            deviceManager.install("device$it", MassDevice)
-
-            val endpointId = "device$it"
-            val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost")
-            deviceManager.launchMagixService(deviceEndpoint, endpointId)
         }
     }
 
-    val application = Plotly.serve(port = 9091, scope = context) {
+    val application = Plotly.serve(port = 9091) {
         updateMode = PlotlyUpdateMode.PUSH
         updateInterval = 1000
+
         page { container ->
             plot(renderer = container, config = PlotlyConfig { saveAsSvg() }) {
                 layout {
@@ -92,36 +130,7 @@ suspend fun main() {
                     xaxis.title = "Device number"
                     yaxis.title = "Maximum latency in ms"
                 }
-                bar {
-                    launch(Dispatchers.IO) {
-                        val monitorEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
-
-                        val mutex = Mutex()
-
-                        val latest = HashMap<String, Duration>()
-                        val max = HashMap<String, Duration>()
-
-                        monitorEndpoint.subscribe(DeviceManager.magixFormat).onEach { (magixMessage, payload) ->
-                            mutex.withLock {
-                                val delay = Clock.System.now() - payload.time
-                                latest[magixMessage.sourceEndpoint] = Clock.System.now() - payload.time
-                                max[magixMessage.sourceEndpoint] =
-                                    maxOf(delay, max[magixMessage.sourceEndpoint] ?: ZERO)
-                            }
-                        }.launchIn(this)
-
-                        while (isActive) {
-                            delay(200)
-                            mutex.withLock {
-                                val sorted = max.mapKeys { it.key.substring(6).toInt() }.toSortedMap()
-                                latest.clear()
-                                max.clear()
-                                x.numbers = sorted.keys
-                                y.numbers = sorted.values.map { it.inWholeMicroseconds / 1000.0 + 0.0001 }
-                            }
-                        }
-                    }
-                }
+                traces(trace)
             }
         }
     }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index e411586..48c0a02 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists

From 85c2910ee937dd66f3add473833d4f9ded1079a1 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 31 Mar 2024 16:13:02 +0300
Subject: [PATCH 071/125] Refactor ports

---
 CHANGELOG.md                                  |   1 +
 .../controls/api/AsynchronousSocket.kt        |  40 +++++
 .../space/kscience/controls/api/Socket.kt     |  32 ----
 .../controls/ports/AsynchronousPort.kt        | 125 ++++++++++++++++
 .../space/kscience/controls/ports/Port.kt     | 100 -------------
 .../kscience/controls/ports/PortProxy.kt      |  64 --------
 .../space/kscience/controls/ports/Ports.kt    |  31 +++-
 .../controls/ports/SynchronousPort.kt         |  58 ++++++--
 .../space/kscience/controls/ports/phrases.kt  |   4 +-
 .../kscience/controls/ports/ChannelPort.kt    | 138 +++++++++++-------
 .../kscience/controls/ports/JvmPortsPlugin.kt |   8 +-
 .../kscience/controls/ports/UdpSocketPort.kt  |  44 +++---
 ...ortIOTest.kt => AsynchronousPortIOTest.kt} |   8 +-
 .../controls/pi/AsynchronousPiPort.kt         |  95 ++++++++++++
 .../space/kscience/controls/pi/PiPlugin.kt    |  11 +-
 .../kscience/controls/pi/PiSerialPort.kt      |  82 -----------
 .../kscience/controls/pi/SynchronousPiPort.kt |  98 +++++++++++++
 .../controls/ports/KtorPortsPlugin.kt         |   2 +-
 .../kscience/controls/ports/KtorTcpPort.kt    |  67 ++++++---
 .../kscience/controls/ports/KtorUdpPort.kt    |  91 ++++++++----
 .../controls/serial/AsynchronousSerialPort.kt | 134 +++++++++++++++++
 .../controls/serial/JSerialCommPort.kt        |  87 -----------
 .../controls/serial/SerialPortPlugin.kt       |  14 +-
 .../controls/serial/SynchronousSerialPort.kt  | 127 ++++++++++++++++
 .../sciprog/devices/mks/MksPdr900Device.kt    |   3 +-
 .../pimotionmaster/PiMotionMasterDevice.kt    |   8 +-
 .../PiMotionMasterVirtualDevice.kt            |  58 +++++---
 .../devices/pimotionmaster/piDebugServer.kt   |   2 +-
 28 files changed, 990 insertions(+), 542 deletions(-)
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt
 delete mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
 delete mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
 delete mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/ports/PortProxy.kt
 rename controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/{PortIOTest.kt => AsynchronousPortIOTest.kt} (85%)
 create mode 100644 controls-pi/src/main/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
 delete mode 100644 controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt
 create mode 100644 controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
 create mode 100644 controls-serial/src/main/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
 delete mode 100644 controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt
 create mode 100644 controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 760e00a..4a258d7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
 
 ### Changed
 - Constructor properties return `DeviceStat` in order to be able to subscribe to them
+- Refactored ports. Now we have `AsynchronousPort` as well as `SynchronousPort`
 
 ### Deprecated
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt
new file mode 100644
index 0000000..defefb2
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt
@@ -0,0 +1,40 @@
+package space.kscience.controls.api
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * A generic bidirectional asynchronous sender/receiver object
+ */
+public interface AsynchronousSocket<T> : AutoCloseable {
+    /**
+     * Send an object to the socket
+     */
+    public suspend fun send(data: T)
+
+    /**
+     * Flow of objects received from socket
+     */
+    public fun subscribe(): Flow<T>
+
+    /**
+     * Start socket operation
+     */
+    public fun open()
+
+    /**
+     * Check if this socket is open
+     */
+    public val isOpen: Boolean
+}
+
+/**
+ * Connect an input to this socket.
+ * Multiple inputs could be connected to the same [AsynchronousSocket].
+ *
+ * This method suspends indefinitely, so it should be started in a separate coroutine.
+ */
+public suspend fun <T> AsynchronousSocket<T>.sendFlow(flow: Flow<T>) {
+    flow.collect { send(it) }
+}
+
+
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt
deleted file mode 100644
index 700db2c..0000000
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package space.kscience.controls.api
-
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.launch
-
-/**
- * A generic bidirectional sender/receiver object
- */
-public interface Socket<T> : AutoCloseable {
-    /**
-     * Send an object to the socket
-     */
-    public suspend fun send(data: T)
-
-    /**
-     * Flow of objects received from socket
-     */
-    public fun receiving(): Flow<T>
-    public fun isOpen(): Boolean
-}
-
-/**
- * Connect an input to this socket using designated [scope] for it and return a handler [Job].
- * Multiple inputs could be connected to the same [Socket].
- */
-public fun <T> Socket<T>.connectInput(scope: CoroutineScope, flow: Flow<T>): Job = scope.launch {
-    flow.collect { send(it) }
-}
-
-
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
new file mode 100644
index 0000000..4462a42
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
@@ -0,0 +1,125 @@
+package space.kscience.controls.ports
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.receiveAsFlow
+import kotlinx.io.Buffer
+import kotlinx.io.Source
+import space.kscience.controls.api.AsynchronousSocket
+import space.kscience.dataforge.context.*
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.int
+import space.kscience.dataforge.meta.string
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * Raw [ByteArray] port
+ */
+public interface AsynchronousPort : ContextAware, AsynchronousSocket<ByteArray>
+
+/**
+ * Capture [AsynchronousPort] output as kotlinx-io [Source].
+ * [scope] controls the consummation.
+ * If the scope is canceled, the source stops producing.
+ */
+public fun AsynchronousPort.receiveAsSource(scope: CoroutineScope): Source {
+    val buffer = Buffer()
+
+    subscribe().onEach {
+        buffer.write(it)
+    }.launchIn(scope)
+
+    return buffer
+}
+
+
+/**
+ * Common abstraction for [AsynchronousPort] based on [Channel]
+ */
+public abstract class AbstractAsynchronousPort(
+    override val context: Context,
+    public val meta: Meta,
+    coroutineContext: CoroutineContext = context.coroutineContext,
+) : AsynchronousPort {
+
+
+    protected val scope: CoroutineScope by lazy {
+        CoroutineScope(
+            coroutineContext +
+                    SupervisorJob(coroutineContext[Job]) +
+                    CoroutineExceptionHandler { _, throwable -> logger.error(throwable) { throwable.stackTraceToString() } } +
+                    CoroutineName(toString())
+        )
+    }
+
+    private val outgoing = Channel<ByteArray>(meta["outgoing.capacity"].int?:100)
+    private val incoming = Channel<ByteArray>(meta["incoming.capacity"].int?:100)
+
+    /**
+     * Internal method to synchronously send data
+     */
+    protected abstract suspend fun write(data: ByteArray)
+
+    /**
+     * Internal method to receive data synchronously
+     */
+    protected suspend fun receive(data: ByteArray) {
+        logger.debug { "$this RECEIVED: ${data.decodeToString()}" }
+        incoming.send(data)
+    }
+
+    private var sendJob: Job? = null
+
+    protected abstract fun onOpen()
+
+    final override fun open() {
+        if (!isOpen) {
+            sendJob = scope.launch {
+                for (data in outgoing) {
+                    try {
+                        write(data)
+                        logger.debug { "${this@AbstractAsynchronousPort} SENT: ${data.decodeToString()}" }
+                    } catch (ex: Exception) {
+                        if (ex is CancellationException) throw ex
+                        logger.error(ex) { "Error while writing data to the port" }
+                    }
+                }
+            }
+            onOpen()
+        } else {
+            logger.warn { "$this already opened" }
+        }
+    }
+
+
+    /**
+     * Send a data packet via the port
+     */
+    override suspend fun send(data: ByteArray) {
+        outgoing.send(data)
+    }
+
+    /**
+     * Raw flow of incoming data chunks. The chunks are not guaranteed to be complete phrases.
+     * To form phrases, some condition should be used on top of it.
+     * For example [stringsDelimitedIncoming] generates phrases with fixed delimiter.
+     */
+    override fun subscribe(): Flow<ByteArray> = incoming.receiveAsFlow()
+
+    override fun close() {
+        outgoing.close()
+        incoming.close()
+        sendJob?.cancel()
+    }
+
+    override fun toString(): String = meta["name"].string?:"ChannelPort[${hashCode().toString(16)}]"
+}
+
+/**
+ * Send UTF-8 encoded string
+ */
+public suspend fun AsynchronousPort.send(string: String): Unit = send(string.encodeToByteArray())
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
deleted file mode 100644
index 9366604..0000000
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt
+++ /dev/null
@@ -1,100 +0,0 @@
-package space.kscience.controls.ports
-
-import kotlinx.coroutines.*
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.receiveAsFlow
-import space.kscience.controls.api.Socket
-import space.kscience.dataforge.context.*
-import space.kscience.dataforge.misc.DfType
-
-import kotlin.coroutines.CoroutineContext
-
-/**
- * Raw [ByteArray] port
- */
-public interface Port : ContextAware, Socket<ByteArray>
-
-/**
- * A specialized factory for [Port]
- */
-@DfType(PortFactory.TYPE)
-public interface PortFactory : Factory<Port> {
-    public val type: String
-
-    public companion object {
-        public const val TYPE: String = "controls.port"
-    }
-}
-
-/**
- * Common abstraction for [Port] based on [Channel]
- */
-public abstract class AbstractPort(
-    override val context: Context,
-    coroutineContext: CoroutineContext = context.coroutineContext,
-) : Port {
-
-    protected val scope: CoroutineScope = CoroutineScope(coroutineContext + SupervisorJob(coroutineContext[Job]))
-
-    private val outgoing = Channel<ByteArray>(100)
-    private val incoming = Channel<ByteArray>(100)
-
-    init {
-        scope.coroutineContext[Job]?.invokeOnCompletion {
-            close()
-        }
-    }
-
-    /**
-     * Internal method to synchronously send data
-     */
-    protected abstract suspend fun write(data: ByteArray)
-
-    /**
-     * Internal method to receive data synchronously
-     */
-    protected suspend fun receive(data: ByteArray) {
-        logger.debug { "${this@AbstractPort} RECEIVED: ${data.decodeToString()}" }
-        incoming.send(data)
-    }
-
-    private val sendJob = scope.launch {
-        for (data in outgoing) {
-            try {
-                write(data)
-                logger.debug { "${this@AbstractPort} SENT: ${data.decodeToString()}" }
-            } catch (ex: Exception) {
-                if (ex is CancellationException) throw ex
-                logger.error(ex) { "Error while writing data to the port" }
-            }
-        }
-    }
-
-    /**
-     * Send a data packet via the port
-     */
-    override suspend fun send(data: ByteArray) {
-        outgoing.send(data)
-    }
-
-    /**
-     * Raw flow of incoming data chunks. The chunks are not guaranteed to be complete phrases.
-     * To form phrases, some condition should be used on top of it.
-     * For example [stringsDelimitedIncoming] generates phrases with fixed delimiter.
-     */
-    override fun receiving(): Flow<ByteArray> = incoming.receiveAsFlow()
-
-    override fun close() {
-        outgoing.close()
-        incoming.close()
-        scope.cancel()
-    }
-
-    override fun isOpen(): Boolean = scope.isActive
-}
-
-/**
- * Send UTF-8 encoded string
- */
-public suspend fun Port.send(string: String): Unit = send(string.encodeToByteArray())
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/PortProxy.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/PortProxy.kt
deleted file mode 100644
index 4e51f6f..0000000
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/PortProxy.kt
+++ /dev/null
@@ -1,64 +0,0 @@
-package space.kscience.controls.ports
-
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import space.kscience.dataforge.context.*
-
-/**
- * A port that could be closed multiple times and opens automatically on request
- */
-public class PortProxy(override val context: Context = Global, public val factory: suspend () -> Port) : Port, ContextAware {
-
-    private var actualPort: Port? = null
-    private val mutex: Mutex = Mutex()
-
-    private suspend fun port(): Port {
-        return mutex.withLock {
-            if (actualPort?.isOpen() == true) {
-                actualPort!!
-            } else {
-                factory().also {
-                    actualPort = it
-                }
-            }
-        }
-    }
-
-    override suspend fun send(data: ByteArray) {
-        port().send(data)
-    }
-
-    @OptIn(ExperimentalCoroutinesApi::class)
-    override fun receiving(): Flow<ByteArray> = flow {
-        while (true) {
-            try {
-                //recreate port and Flow on connection problems
-                port().receiving().collect {
-                    emit(it)
-                }
-            } catch (t: Throwable) {
-                logger.warn{"Port read failed: ${t.message}. Reconnecting."}
-                mutex.withLock {
-                    actualPort?.close()
-                    actualPort = null
-                }
-            }
-        }
-    }
-
-    // open by default
-    override fun isOpen(): Boolean = true
-
-    override fun close() {
-        context.launch {
-            mutex.withLock {
-                actualPort?.close()
-                actualPort = null
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt
index 3d01e62..565caee 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt
@@ -11,26 +11,43 @@ public class Ports : AbstractPlugin() {
 
     override val tag: PluginTag get() = Companion.tag
 
-    private val portFactories by lazy {
-        context.gather<PortFactory>(PortFactory.TYPE)
+    private val synchronousPortFactories by lazy {
+        context.gather<Factory<SynchronousPort>>(SYNCHRONOUS_PORT_TYPE)
     }
 
-    private val portCache = mutableMapOf<Meta, Port>()
+    private val asynchronousPortFactories by lazy {
+        context.gather<Factory<AsynchronousPort>>(ASYNCHRONOUS_PORT_TYPE)
+    }
 
     /**
-     * Create a new [Port] according to specification
+     * Create a new [AsynchronousPort] according to specification
      */
-    public fun buildPort(meta: Meta): Port = portCache.getOrPut(meta) {
+    public fun buildAsynchronousPort(meta: Meta): AsynchronousPort {
         val type by meta.string { error("Port type is not defined") }
-        val factory = portFactories.values.firstOrNull { it.type == type }
+        val factory = asynchronousPortFactories.entries
+            .firstOrNull { it.key.toString() == type }?.value
             ?: error("Port factory for type $type not found")
-        factory.build(context, meta)
+        return factory.build(context, meta)
+    }
+
+    /**
+     * Create a [SynchronousPort] according to specification or wrap an asynchronous implementation
+     */
+    public fun buildSynchronousPort(meta: Meta): SynchronousPort {
+        val type by meta.string { error("Port type is not defined") }
+        val factory = synchronousPortFactories.entries
+            .firstOrNull { it.key.toString() == type }?.value
+            ?: return buildAsynchronousPort(meta).asSynchronousPort()
+        return factory.build(context, meta)
     }
 
     public companion object : PluginFactory<Ports> {
 
         override val tag: PluginTag = PluginTag("controls.ports", group = PluginTag.DATAFORGE_GROUP)
 
+        public const val ASYNCHRONOUS_PORT_TYPE: String = "controls.asynchronousPort"
+        public const val SYNCHRONOUS_PORT_TYPE: String = "controls.synchronousPort"
+
         override fun build(context: Context, meta: Meta): Ports = Ports()
 
     }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
index 0ed4764..8d95eb0 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
@@ -4,25 +4,65 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.ContextAware
 
 /**
- * A port handler for synchronous (request-response) communication with a port. Only one request could be active at a time (others are suspended.
- * The handler does not guarantee exclusive access to the port so the user mush ensure that no other controller handles port at the moment.
+ * A port handler for synchronous (request-response) communication with a port.
+ * Only one request could be active at a time (others are suspended).
  */
-public class SynchronousPort(public val port: Port, private val mutex: Mutex) : Port by port {
+public interface SynchronousPort : ContextAware, AutoCloseable {
+
+    public fun open()
+
+    public val isOpen: Boolean
+
     /**
-     * Send a single message and wait for the flow of respond messages.
+     * Send a single message and wait for the flow of response chunks.
+     * The consumer is responsible for calling a terminal operation on the flow.
      */
-    public suspend fun <R> respond(data: ByteArray, transform: suspend Flow<ByteArray>.() -> R): R = mutex.withLock {
-        port.send(data)
-        transform(port.receiving())
+    public suspend fun <R> respond(
+        request: ByteArray,
+        transform: suspend Flow<ByteArray>.() -> R,
+    ): R
+}
+
+private class SynchronousOverAsynchronousPort(
+    val port: AsynchronousPort,
+    val mutex: Mutex,
+) : SynchronousPort {
+
+    override val context: Context get() = port.context
+
+    override fun open() {
+        if (!port.isOpen) port.open()
+    }
+
+    override val isOpen: Boolean get() = port.isOpen
+
+    override fun close() {
+        if (port.isOpen) port.close()
+    }
+
+    override suspend fun <R> respond(
+        request: ByteArray,
+        transform: suspend Flow<ByteArray>.() -> R,
+    ): R = mutex.withLock {
+        port.send(request)
+        transform(port.subscribe())
     }
 }
 
+
 /**
- * Provide a synchronous wrapper for a port
+ * Provide a synchronous wrapper for an asynchronous port.
+ * Optionally provide external [mutex] for operation synchronization.
+ *
+ * If the [AsynchronousPort] is called directly, it could violate [SynchronousPort] contract
+ * of only one request running simultaneously.
  */
-public fun Port.synchronous(mutex: Mutex = Mutex()): SynchronousPort = SynchronousPort(this, mutex)
+public fun AsynchronousPort.asSynchronousPort(mutex: Mutex = Mutex()): SynchronousPort =
+    SynchronousOverAsynchronousPort(this, mutex)
 
 /**
  * Send request and read incoming data blocks until the delimiter is encountered
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
index 03d21f5..89bee8a 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt
@@ -77,9 +77,9 @@ public fun Flow<ByteArray>.withStringDelimiter(delimiter: String): Flow<String>
 /**
  * A flow of delimited phrases
  */
-public fun Port.delimitedIncoming(delimiter: ByteArray): Flow<ByteArray> = receiving().withDelimiter(delimiter)
+public fun AsynchronousPort.delimitedIncoming(delimiter: ByteArray): Flow<ByteArray> = subscribe().withDelimiter(delimiter)
 
 /**
  * A flow of delimited phrases with string content
  */
-public fun Port.stringsDelimitedIncoming(delimiter: String): Flow<String> = receiving().withStringDelimiter(delimiter)
+public fun AsynchronousPort.stringsDelimitedIncoming(delimiter: String): Flow<String> = subscribe().withStringDelimiter(delimiter)
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
index f450607..85c3d5c 100644
--- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
@@ -1,10 +1,7 @@
 package space.kscience.controls.ports
 
 import kotlinx.coroutines.*
-import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.context.error
-import space.kscience.dataforge.context.info
-import space.kscience.dataforge.context.logger
+import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.*
 import java.net.InetSocketAddress
 import java.nio.ByteBuffer
@@ -14,7 +11,10 @@ import java.nio.channels.DatagramChannel
 import java.nio.channels.SocketChannel
 import kotlin.coroutines.CoroutineContext
 
-public fun ByteBuffer.toArray(limit: Int = limit()): ByteArray {
+/**
+ * Copy the contents of this buffer to an array
+ */
+public fun ByteBuffer.copyToArray(limit: Int = limit()): ByteArray {
     rewind()
     val response = ByteArray(limit)
     get(response)
@@ -27,35 +27,40 @@ public fun ByteBuffer.toArray(limit: Int = limit()): ByteArray {
  */
 public class ChannelPort(
     context: Context,
+    meta: Meta,
     coroutineContext: CoroutineContext = context.coroutineContext,
     channelBuilder: suspend () -> ByteChannel,
-) : AbstractPort(context, coroutineContext), AutoCloseable {
-
-    private val futureChannel: Deferred<ByteChannel> = scope.async(Dispatchers.IO) {
-        channelBuilder()
-    }
+) : AbstractAsynchronousPort(context, meta, coroutineContext), AutoCloseable {
 
     /**
      * A handler to await port connection
      */
-    public val startJob: Job get() = futureChannel
+    private val futureChannel: Deferred<ByteChannel> = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
+        channelBuilder()
+    }
 
-    private val listenerJob = scope.launch(Dispatchers.IO) {
-        val channel = futureChannel.await()
-        val buffer = ByteBuffer.allocate(1024)
-        while (isActive && channel.isOpen) {
-            try {
-                val num = channel.read(buffer)
-                if (num > 0) {
-                    receive(buffer.toArray(num))
-                }
-                if (num < 0) cancel("The input channel is exhausted")
-            } catch (ex: Exception) {
-                if (ex is AsynchronousCloseException) {
-                    logger.info { "Channel $channel closed" }
-                } else {
-                    logger.error(ex) { "Channel read error, retrying in 1 second" }
-                    delay(1000)
+    private var listenerJob: Job? = null
+
+    override val isOpen: Boolean get() = listenerJob?.isActive == true
+
+    override fun onOpen() {
+        listenerJob = scope.launch(Dispatchers.IO) {
+            val channel = futureChannel.await()
+            val buffer = ByteBuffer.allocate(1024)
+            while (isActive && channel.isOpen) {
+                try {
+                    val num = channel.read(buffer)
+                    if (num > 0) {
+                        receive(buffer.copyToArray(num))
+                    }
+                    if (num < 0) cancel("The input channel is exhausted")
+                } catch (ex: Exception) {
+                    if (ex is AsynchronousCloseException) {
+                        logger.info { "Channel $channel closed" }
+                    } else {
+                        logger.error(ex) { "Channel read error, retrying in 1 second" }
+                        delay(1000)
+                    }
                 }
             }
         }
@@ -67,6 +72,7 @@ public class ChannelPort(
 
     @OptIn(ExperimentalCoroutinesApi::class)
     override fun close() {
+        listenerJob?.cancel()
         if (futureChannel.isCompleted) {
             futureChannel.getCompleted().close()
         }
@@ -75,61 +81,95 @@ public class ChannelPort(
 }
 
 /**
- * A [PortFactory] for TCP connections
+ * A [Factory] for TCP connections
  */
-public object TcpPort : PortFactory {
+public object TcpPort : Factory<AsynchronousPort> {
 
-    override val type: String = "tcp"
+    public fun build(
+        context: Context,
+        host: String,
+        port: Int,
+        coroutineContext: CoroutineContext = context.coroutineContext,
+    ): ChannelPort {
+        val meta = Meta {
+            "name" put "tcp://$host:$port"
+            "type" put "tcp"
+            "host" put host
+            "port" put port
+        }
+        return ChannelPort(context, meta, coroutineContext) {
+            SocketChannel.open(InetSocketAddress(host, port))
+        }
+    }
 
+    /**
+     * Create and open TCP port
+     */
     public fun open(
         context: Context,
         host: String,
         port: Int,
         coroutineContext: CoroutineContext = context.coroutineContext,
-    ): ChannelPort = ChannelPort(context, coroutineContext) {
-        SocketChannel.open(InetSocketAddress(host, port))
-    }
+    ): ChannelPort = build(context, host, port, coroutineContext).apply { open() }
 
     override fun build(context: Context, meta: Meta): ChannelPort {
         val host = meta["host"].string ?: "localhost"
         val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta")
-        return open(context, host, port)
+        return build(context, host, port)
     }
+
 }
 
 
 /**
- * A [PortFactory] for UDP connections
+ * A [Factory] for UDP connections
  */
-public object UdpPort : PortFactory {
+public object UdpPort : Factory<AsynchronousPort> {
 
-    override val type: String = "udp"
+    public fun build(
+        context: Context,
+        remoteHost: String,
+        remotePort: Int,
+        localPort: Int? = null,
+        localHost: String? = null,
+        coroutineContext: CoroutineContext = context.coroutineContext,
+    ): ChannelPort {
+        val meta = Meta {
+            "name" put "udp://$remoteHost:$remotePort"
+            "type" put "udp"
+            "remoteHost" put remoteHost
+            "remotePort" put remotePort
+            localHost?.let { "localHost" put it }
+            localPort?.let { "localPort" put it }
+        }
+        return ChannelPort(context, meta, coroutineContext) {
+            DatagramChannel.open().apply {
+                //bind the channel to a local port to receive messages
+                localPort?.let { bind(InetSocketAddress(localHost ?: "localhost", it)) }
+                //connect to remote port to send messages
+                connect(InetSocketAddress(remoteHost, remotePort.toInt()))
+                context.logger.info { "Connected to UDP $remotePort on $remoteHost" }
+            }
+        }
+    }
 
     /**
      * Connect a datagram channel to a remote host/port. If [localPort] is provided, it is used to bind local port for receiving messages.
      */
-    public fun openChannel(
+    public fun open(
         context: Context,
         remoteHost: String,
         remotePort: Int,
         localPort: Int? = null,
         localHost: String = "localhost",
-        coroutineContext: CoroutineContext = context.coroutineContext,
-    ): ChannelPort = ChannelPort(context, coroutineContext) {
-        DatagramChannel.open().apply {
-            //bind the channel to a local port to receive messages
-            localPort?.let { bind(InetSocketAddress(localHost, localPort)) }
-            //connect to remote port to send messages
-            connect(InetSocketAddress(remoteHost, remotePort))
-            context.logger.info { "Connected to UDP $remotePort on $remoteHost" }
-        }
-    }
+    ): ChannelPort = build(context, remoteHost, remotePort, localPort, localHost).apply { open() }
+
 
     override fun build(context: Context, meta: Meta): ChannelPort {
         val remoteHost by meta.string { error("Remote host is not specified") }
         val remotePort by meta.number { error("Remote port is not specified") }
         val localHost: String? by meta.string()
         val localPort: Int? by meta.int()
-        return openChannel(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost")
+        return build(context, remoteHost, remotePort.toInt(), localPort, localHost)
     }
 }
\ No newline at end of file
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt
index d9d87e2..82e1ac0 100644
--- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt
@@ -6,7 +6,7 @@ import space.kscience.dataforge.context.PluginFactory
 import space.kscience.dataforge.context.PluginTag
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.names.Name
-import space.kscience.dataforge.names.parseAsName
+import space.kscience.dataforge.names.asName
 
 /**
  * A plugin for loading JVM nio-based ports
@@ -17,9 +17,9 @@ public class JvmPortsPlugin : AbstractPlugin() {
     override val tag: PluginTag get() = Companion.tag
 
     override fun content(target: String): Map<Name, Any> = when(target){
-        PortFactory.TYPE -> mapOf(
-            TcpPort.type.parseAsName() to TcpPort,
-            UdpPort.type.parseAsName() to UdpPort
+        Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf(
+            "tcp".asName() to TcpPort,
+            "udp".asName() to UdpPort
         )
         else -> emptyMap()
     }
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt
index 27bb0d8..ae65c64 100644
--- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt
@@ -1,10 +1,8 @@
 package space.kscience.controls.ports
 
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import kotlinx.coroutines.*
 import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.Meta
 import java.net.DatagramPacket
 import java.net.DatagramSocket
 import kotlin.coroutines.CoroutineContext
@@ -14,33 +12,39 @@ import kotlin.coroutines.CoroutineContext
  */
 public class UdpSocketPort(
     override val context: Context,
+    meta: Meta,
     private val socket: DatagramSocket,
     coroutineContext: CoroutineContext = context.coroutineContext,
-) : AbstractPort(context, coroutineContext) {
+) : AbstractAsynchronousPort(context, meta, coroutineContext) {
 
-    private val listenerJob = context.launch(Dispatchers.IO) {
-        while (isActive) {
-            val buf = ByteArray(socket.receiveBufferSize)
+    private var listenerJob: Job? = null
 
-            val packet = DatagramPacket(
-                buf,
-                buf.size,
-            )
-            socket.receive(packet)
+    override fun onOpen() {
+        listenerJob = context.launch(Dispatchers.IO) {
+            while (isActive) {
+                val buf = ByteArray(socket.receiveBufferSize)
 
-            val bytes = packet.data.copyOfRange(
-                packet.offset,
-                packet.offset + packet.length
-            )
-            receive(bytes)
+                val packet = DatagramPacket(
+                    buf,
+                    buf.size,
+                )
+                socket.receive(packet)
+
+                val bytes = packet.data.copyOfRange(
+                    packet.offset,
+                    packet.offset + packet.length
+                )
+                receive(bytes)
+            }
         }
     }
 
     override fun close() {
-        listenerJob.cancel()
+        listenerJob?.cancel()
+        super.close()
     }
 
-    override fun isOpen(): Boolean = listenerJob.isActive
+    override val isOpen: Boolean get() = listenerJob?.isActive == true
 
 
     override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) {
diff --git a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt
similarity index 85%
rename from controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt
rename to controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt
index daa6173..b19cfad 100644
--- a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt
+++ b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt
@@ -12,7 +12,7 @@ import space.kscience.dataforge.context.Global
 import kotlin.test.assertEquals
 
 
-internal class PortIOTest {
+internal class AsynchronousPortIOTest {
 
     @Test
     fun testDelimiteredByteArrayFlow() {
@@ -29,8 +29,8 @@ internal class PortIOTest {
 
     @Test
     fun testUdpCommunication() = runTest {
-        val receiver = UdpPort.openChannel(Global, "localhost", 8811, localPort = 8812)
-        val sender = UdpPort.openChannel(Global, "localhost", 8812, localPort = 8811)
+        val receiver = UdpPort.open(Global, "localhost", 8811, localPort = 8812)
+        val sender = UdpPort.open(Global, "localhost", 8812, localPort = 8811)
 
         delay(30)
         repeat(10) {
@@ -38,7 +38,7 @@ internal class PortIOTest {
         }
 
         val res = receiver
-            .receiving()
+            .subscribe()
             .withStringDelimiter("\n")
             .take(10)
             .toList()
diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
new file mode 100644
index 0000000..1aee657
--- /dev/null
+++ b/controls-pi/src/main/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
@@ -0,0 +1,95 @@
+package space.kscience.controls.pi
+
+import com.pi4j.io.serial.Baud
+import com.pi4j.io.serial.Serial
+import com.pi4j.io.serial.SerialConfigBuilder
+import com.pi4j.ktx.io.serial
+import kotlinx.coroutines.*
+import space.kscience.controls.ports.AbstractAsynchronousPort
+import space.kscience.controls.ports.AsynchronousPort
+import space.kscience.controls.ports.copyToArray
+import space.kscience.dataforge.context.*
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.enum
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.string
+import java.nio.ByteBuffer
+import kotlin.coroutines.CoroutineContext
+
+public class AsynchronousPiPort(
+    context: Context,
+    meta: Meta,
+    private val serial: Serial,
+    coroutineContext: CoroutineContext = context.coroutineContext,
+) : AbstractAsynchronousPort(context, meta, coroutineContext) {
+
+
+    private var listenerJob: Job? = null
+    override fun onOpen() {
+        serial.open()
+        listenerJob = this.scope.launch(Dispatchers.IO) {
+            val buffer = ByteBuffer.allocate(1024)
+            while (isActive) {
+                try {
+                    val num = serial.read(buffer)
+                    if (num > 0) {
+                        receive(buffer.copyToArray(num))
+                    }
+                    if (num < 0) cancel("The input channel is exhausted")
+                } catch (ex: Exception) {
+                    logger.error(ex) { "Channel read error" }
+                    delay(1000)
+                }
+            }
+        }
+    }
+
+    override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) {
+        serial.write(data)
+    }
+
+
+    override val isOpen: Boolean get() = listenerJob?.isActive == true
+
+    override fun close() {
+        listenerJob?.cancel()
+        serial.close()
+    }
+
+    public companion object : Factory<AsynchronousPort> {
+
+
+        public fun build(
+            context: Context,
+            device: String,
+            block: SerialConfigBuilder.() -> Unit,
+        ): AsynchronousPiPort {
+            val meta = Meta {
+                "name" put "pi://$device"
+                "type" put "serial"
+            }
+            val pi = context.request(PiPlugin)
+
+            val serial = pi.piContext.serial(device, block)
+            return AsynchronousPiPort(context, meta, serial)
+        }
+
+        public fun open(
+            context: Context,
+            device: String,
+            block: SerialConfigBuilder.() -> Unit,
+        ): AsynchronousPiPort = build(context, device, block).apply { open() }
+
+        override fun build(context: Context, meta: Meta): AsynchronousPort {
+            val device: String = meta["device"].string ?: error("Device name not defined")
+            val baudRate: Baud = meta["baudRate"].enum<Baud>() ?: Baud._9600
+            val pi = context.request(PiPlugin)
+            val serial = pi.piContext.serial(device) {
+                baud8N1(baudRate)
+            }
+            return AsynchronousPiPort(context, meta, serial)
+        }
+
+    }
+}
+
diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt
index 20d4c80..0f2bb02 100644
--- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt
+++ b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt
@@ -2,7 +2,6 @@ package space.kscience.controls.pi
 
 import com.pi4j.Pi4J
 import space.kscience.controls.manager.DeviceManager
-import space.kscience.controls.ports.PortFactory
 import space.kscience.controls.ports.Ports
 import space.kscience.dataforge.context.AbstractPlugin
 import space.kscience.dataforge.context.Context
@@ -10,7 +9,7 @@ import space.kscience.dataforge.context.PluginFactory
 import space.kscience.dataforge.context.PluginTag
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.names.Name
-import space.kscience.dataforge.names.parseAsName
+import space.kscience.dataforge.names.asName
 import com.pi4j.context.Context as PiContext
 
 public class PiPlugin : AbstractPlugin() {
@@ -22,8 +21,11 @@ public class PiPlugin : AbstractPlugin() {
     public val piContext: PiContext by lazy { createPiContext(context, meta) }
 
     override fun content(target: String): Map<Name, Any> = when (target) {
-        PortFactory.TYPE -> mapOf(
-            PiSerialPort.type.parseAsName() to PiSerialPort,
+        Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf(
+            "serial".asName() to AsynchronousPiPort,
+        )
+        Ports.SYNCHRONOUS_PORT_TYPE -> mapOf(
+            "serial".asName() to SynchronousPiPort,
         )
 
         else -> super.content(target)
@@ -40,6 +42,7 @@ public class PiPlugin : AbstractPlugin() {
 
         override fun build(context: Context, meta: Meta): PiPlugin = PiPlugin()
 
+        @Suppress("UNUSED_PARAMETER")
         public fun createPiContext(context: Context, meta: Meta): PiContext = Pi4J.newAutoContext()
 
     }
diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt
deleted file mode 100644
index 6b9662d..0000000
--- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt
+++ /dev/null
@@ -1,82 +0,0 @@
-package space.kscience.controls.pi
-
-import com.pi4j.io.serial.Baud
-import com.pi4j.io.serial.Serial
-import com.pi4j.io.serial.SerialConfigBuilder
-import com.pi4j.ktx.io.serial
-import kotlinx.coroutines.*
-import space.kscience.controls.ports.AbstractPort
-import space.kscience.controls.ports.Port
-import space.kscience.controls.ports.PortFactory
-import space.kscience.controls.ports.toArray
-import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.context.error
-import space.kscience.dataforge.context.logger
-import space.kscience.dataforge.context.request
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.enum
-import space.kscience.dataforge.meta.get
-import space.kscience.dataforge.meta.string
-import java.nio.ByteBuffer
-import kotlin.coroutines.CoroutineContext
-import com.pi4j.context.Context as PiContext
-
-public class PiSerialPort(
-    context: Context,
-    coroutineContext: CoroutineContext = context.coroutineContext,
-    public val serialBuilder: PiContext.() -> Serial,
-) : AbstractPort(context, coroutineContext) {
-
-    private val serial: Serial by lazy {
-        val pi = context.request(PiPlugin)
-        pi.piContext.serialBuilder()
-    }
-
-
-    private val listenerJob = this.scope.launch(Dispatchers.IO) {
-        val buffer = ByteBuffer.allocate(1024)
-        while (isActive) {
-            try {
-                val num = serial.read(buffer)
-                if (num > 0) {
-                    receive(buffer.toArray(num))
-                }
-                if (num < 0) cancel("The input channel is exhausted")
-            } catch (ex: Exception) {
-                logger.error(ex) { "Channel read error" }
-                delay(1000)
-            }
-        }
-    }
-
-    override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) {
-        serial.write(data)
-    }
-
-    override fun close() {
-        listenerJob.cancel()
-        serial.close()
-    }
-
-    public companion object : PortFactory {
-        override val type: String get() = "pi"
-
-        public fun open(
-            context: Context,
-            device: String,
-            block: SerialConfigBuilder.() -> Unit,
-        ): PiSerialPort = PiSerialPort(context) {
-            serial(device, block)
-        }
-
-        override fun build(context: Context, meta: Meta): Port = PiSerialPort(context) {
-            val device: String = meta["device"].string ?: error("Device name not defined")
-            val baudRate: Baud = meta["baudRate"].enum<Baud>() ?: Baud._9600
-            serial(device) {
-                baud8N1(baudRate)
-            }
-        }
-
-    }
-}
-
diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
new file mode 100644
index 0000000..acefb1f
--- /dev/null
+++ b/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
@@ -0,0 +1,98 @@
+package space.kscience.controls.pi
+
+import com.pi4j.io.serial.Baud
+import com.pi4j.io.serial.Serial
+import com.pi4j.io.serial.SerialConfigBuilder
+import com.pi4j.ktx.io.serial
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import space.kscience.controls.ports.SynchronousPort
+import space.kscience.controls.ports.copyToArray
+import space.kscience.dataforge.context.*
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.enum
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.string
+import java.nio.ByteBuffer
+
+public class SynchronousPiPort(
+    override val context: Context,
+    public val meta: Meta,
+    private val serial: Serial,
+    private val mutex: Mutex = Mutex(),
+) : SynchronousPort {
+
+    private val pi = context.request(PiPlugin)
+    override fun open() {
+        serial.open()
+    }
+
+    override val isOpen: Boolean get() = serial.isOpen
+
+    override suspend fun <R> respond(
+        request: ByteArray,
+        transform: suspend Flow<ByteArray>.() -> R,
+    ): R = mutex.withLock {
+        serial.drain()
+        serial.write(request)
+        flow<ByteArray> {
+            val buffer = ByteBuffer.allocate(1024)
+            while (isOpen) {
+                try {
+                    val num = serial.read(buffer)
+                    if (num > 0) {
+                        emit(buffer.copyToArray(num))
+                    }
+                    if (num < 0) break
+                } catch (ex: Exception) {
+                    logger.error(ex) { "Channel read error" }
+                    delay(1000)
+                }
+            }
+        }.transform()
+    }
+
+    override fun close() {
+        serial.close()
+    }
+
+    public companion object : Factory<SynchronousPort> {
+
+
+        public fun build(
+            context: Context,
+            device: String,
+            block: SerialConfigBuilder.() -> Unit,
+        ): SynchronousPiPort {
+            val meta = Meta {
+                "name" put "pi://$device"
+                "type" put "serial"
+            }
+            val pi = context.request(PiPlugin)
+
+            val serial = pi.piContext.serial(device, block)
+            return SynchronousPiPort(context, meta, serial)
+        }
+
+        public fun open(
+            context: Context,
+            device: String,
+            block: SerialConfigBuilder.() -> Unit,
+        ): SynchronousPiPort = build(context, device, block).apply { open() }
+
+        override fun build(context: Context, meta: Meta): SynchronousPiPort {
+            val device: String = meta["device"].string ?: error("Device name not defined")
+            val baudRate: Baud = meta["baudRate"].enum<Baud>() ?: Baud._9600
+            val pi = context.request(PiPlugin)
+            val serial = pi.piContext.serial(device) {
+                baud8N1(baudRate)
+            }
+            return SynchronousPiPort(context, meta, serial)
+        }
+
+    }
+}
+
diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt
index 9c6dbba..6e3041c 100644
--- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt
+++ b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt
@@ -13,7 +13,7 @@ public class KtorPortsPlugin : AbstractPlugin() {
     override val tag: PluginTag get() = Companion.tag
 
     override fun content(target: String): Map<Name, Any> = when (target) {
-        PortFactory.TYPE -> mapOf("tcp".asName() to KtorTcpPort, "udp".asName() to KtorUdpPort)
+        Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf("tcp".asName() to KtorTcpPort, "udp".asName() to KtorUdpPort)
         else -> emptyMap()
     }
 
diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
index a7a698e..463e922 100644
--- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
+++ b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
@@ -8,42 +8,46 @@ import io.ktor.network.sockets.openWriteChannel
 import io.ktor.utils.io.consumeEachBufferRange
 import io.ktor.utils.io.core.Closeable
 import io.ktor.utils.io.writeAvailable
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
+import kotlinx.coroutines.*
 import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.meta.int
 import space.kscience.dataforge.meta.string
+import java.nio.ByteBuffer
 import kotlin.coroutines.CoroutineContext
 
 public class KtorTcpPort internal constructor(
     context: Context,
+    meta: Meta,
     public val host: String,
     public val port: Int,
     coroutineContext: CoroutineContext = context.coroutineContext,
-    socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {}
-) : AbstractPort(context, coroutineContext), Closeable {
+    socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {},
+) : AbstractAsynchronousPort(context, meta, coroutineContext), Closeable {
 
     override fun toString(): String = "port[tcp:$host:$port]"
 
-    private val futureSocket = scope.async {
+    private val futureSocket = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
         aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().connect(host, port, socketOptions)
     }
 
-    private val writeChannel = scope.async {
+    private val writeChannel = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
         futureSocket.await().openWriteChannel(true)
     }
 
-    private val listenerJob = scope.launch {
-        val input = futureSocket.await().openReadChannel()
-        input.consumeEachBufferRange { buffer, _ ->
-            val array = ByteArray(buffer.remaining())
-            buffer.get(array)
-            receive(array)
-            isActive
+    private var listenerJob: Job? = null
+
+    override fun onOpen() {
+        listenerJob = scope.launch {
+            val input = futureSocket.await().openReadChannel()
+            input.consumeEachBufferRange { buffer: ByteBuffer, last ->
+                val array = ByteArray(buffer.remaining())
+                buffer.get(array)
+                receive(array)
+                !last && isActive
+            }
         }
     }
 
@@ -51,30 +55,45 @@ public class KtorTcpPort internal constructor(
         writeChannel.await().writeAvailable(data)
     }
 
+    override val isOpen: Boolean
+        get() = listenerJob?.isActive == true
+
     override fun close() {
-        listenerJob.cancel()
+        listenerJob?.cancel()
         futureSocket.cancel()
         super.close()
     }
 
-    public companion object : PortFactory {
+    public companion object : Factory<AsynchronousPort> {
 
-        override val type: String = "tcp"
+        public fun build(
+            context: Context,
+            host: String,
+            port: Int,
+            coroutineContext: CoroutineContext = context.coroutineContext,
+            socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {},
+        ): KtorTcpPort {
+            val meta = Meta {
+                "name" put "tcp://$host:$port"
+                "type" put "tcp"
+                "host" put host
+                "port" put port
+            }
+            return KtorTcpPort(context, meta, host, port, coroutineContext, socketOptions)
+        }
 
         public fun open(
             context: Context,
             host: String,
             port: Int,
             coroutineContext: CoroutineContext = context.coroutineContext,
-            socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {}
-        ): KtorTcpPort {
-            return KtorTcpPort(context, host, port, coroutineContext, socketOptions)
-        }
+            socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {},
+        ): KtorTcpPort = build(context, host, port, coroutineContext, socketOptions).apply { open() }
 
-        override fun build(context: Context, meta: Meta): Port {
+        override fun build(context: Context, meta: Meta): AsynchronousPort {
             val host = meta["host"].string ?: "localhost"
             val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta")
-            return open(context, host, port)
+            return build(context, host, port)
         }
     }
 }
\ No newline at end of file
diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
index 1f30914..48096cf 100644
--- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
+++ b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
@@ -8,6 +8,7 @@ import io.ktor.utils.io.core.Closeable
 import io.ktor.utils.io.writeAvailable
 import kotlinx.coroutines.*
 import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.int
 import space.kscience.dataforge.meta.number
@@ -16,17 +17,18 @@ import kotlin.coroutines.CoroutineContext
 
 public class KtorUdpPort internal constructor(
     context: Context,
+    meta: Meta,
     public val remoteHost: String,
     public val remotePort: Int,
     public val localPort: Int? = null,
     public val localHost: String = "localhost",
     coroutineContext: CoroutineContext = context.coroutineContext,
-    socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {}
-) : AbstractPort(context, coroutineContext), Closeable {
+    socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {},
+) : AbstractAsynchronousPort(context, meta, coroutineContext), Closeable {
 
     override fun toString(): String = "port[udp:$remoteHost:$remotePort]"
 
-    private val futureSocket = scope.async {
+    private val futureSocket = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
         aSocket(ActorSelectorManager(Dispatchers.IO)).udp().connect(
             remoteAddress = InetSocketAddress(remoteHost, remotePort),
             localAddress = localPort?.let { InetSocketAddress(localHost, localPort) },
@@ -34,17 +36,21 @@ public class KtorUdpPort internal constructor(
         )
     }
 
-    private val writeChannel: Deferred<ByteWriteChannel> = scope.async {
+    private val writeChannel: Deferred<ByteWriteChannel> = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
         futureSocket.await().openWriteChannel(true)
     }
 
-    private val listenerJob = scope.launch {
-        val input = futureSocket.await().openReadChannel()
-        input.consumeEachBufferRange { buffer, _ ->
-            val array = ByteArray(buffer.remaining())
-            buffer.get(array)
-            receive(array)
-            isActive
+    private var listenerJob: Job? = null
+
+    override fun onOpen() {
+        listenerJob = scope.launch {
+            val input = futureSocket.await().openReadChannel()
+            input.consumeEachBufferRange { buffer, last ->
+                val array = ByteArray(buffer.remaining())
+                buffer.get(array)
+                receive(array)
+                !last && isActive
+            }
         }
     }
 
@@ -52,16 +58,49 @@ public class KtorUdpPort internal constructor(
         writeChannel.await().writeAvailable(data)
     }
 
+    override val isOpen: Boolean
+        get() = listenerJob?.isActive == true
+
     override fun close() {
-        listenerJob.cancel()
+        listenerJob?.cancel()
         futureSocket.cancel()
         super.close()
     }
 
-    public companion object : PortFactory {
+    public companion object : Factory<AsynchronousPort> {
 
-        override val type: String = "udp"
+        public fun build(
+            context: Context,
+            remoteHost: String,
+            remotePort: Int,
+            localPort: Int? = null,
+            localHost: String? = null,
+            coroutineContext: CoroutineContext = context.coroutineContext,
+            socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {},
+        ): KtorUdpPort {
+            val meta = Meta {
+                "name" put "udp://$remoteHost:$remotePort"
+                "type" put "udp"
+                "remoteHost" put remoteHost
+                "remotePort" put remotePort
+                localHost?.let { "localHost" put it }
+                localPort?.let { "localPort" put it }
+            }
+            return KtorUdpPort(
+                context = context,
+                meta = meta,
+                remoteHost = remoteHost,
+                remotePort = remotePort,
+                localPort = localPort,
+                localHost = localHost ?: "localhost",
+                coroutineContext = coroutineContext,
+                socketOptions = socketOptions
+            )
+        }
 
+        /**
+         * Create and open UDP port
+         */
         public fun open(
             context: Context,
             remoteHost: String,
@@ -69,23 +108,23 @@ public class KtorUdpPort internal constructor(
             localPort: Int? = null,
             localHost: String = "localhost",
             coroutineContext: CoroutineContext = context.coroutineContext,
-            socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {}
-        ): KtorUdpPort = KtorUdpPort(
-            context = context,
-            remoteHost = remoteHost,
-            remotePort = remotePort,
-            localPort = localPort,
-            localHost = localHost,
-            coroutineContext = coroutineContext,
-            socketOptions = socketOptions
-        )
+            socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {},
+        ): KtorUdpPort = build(
+            context,
+            remoteHost,
+            remotePort,
+            localPort,
+            localHost,
+            coroutineContext,
+            socketOptions
+        ).apply { open() }
 
-        override fun build(context: Context, meta: Meta): Port {
+        override fun build(context: Context, meta: Meta): AsynchronousPort {
             val remoteHost by meta.string { error("Remote host is not specified") }
             val remotePort by meta.number { error("Remote port is not specified") }
             val localHost: String? by meta.string()
             val localPort: Int? by meta.int()
-            return open(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost")
+            return build(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost")
         }
     }
 }
\ No newline at end of file
diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
new file mode 100644
index 0000000..b6e83bc
--- /dev/null
+++ b/controls-serial/src/main/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
@@ -0,0 +1,134 @@
+package space.kscience.controls.serial
+
+import com.fazecast.jSerialComm.SerialPort
+import com.fazecast.jSerialComm.SerialPortDataListener
+import com.fazecast.jSerialComm.SerialPortEvent
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import space.kscience.controls.ports.AbstractAsynchronousPort
+import space.kscience.controls.ports.AsynchronousPort
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.int
+import space.kscience.dataforge.meta.string
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * A port based on JSerialComm
+ */
+public class AsynchronousSerialPort(
+    context: Context,
+    meta: Meta,
+    private val comPort: SerialPort,
+    coroutineContext: CoroutineContext = context.coroutineContext,
+) : AbstractAsynchronousPort(context, meta, coroutineContext) {
+
+    override fun toString(): String = "port[${comPort.descriptivePortName}]"
+
+    private val serialPortListener = object : SerialPortDataListener {
+        override fun getListeningEvents(): Int =
+            SerialPort.LISTENING_EVENT_DATA_RECEIVED and SerialPort.LISTENING_EVENT_DATA_AVAILABLE
+
+        override fun serialEvent(event: SerialPortEvent) {
+            when (event.eventType) {
+                SerialPort.LISTENING_EVENT_DATA_RECEIVED -> {
+                    scope.launch { receive(event.receivedData) }
+                }
+
+                SerialPort.LISTENING_EVENT_DATA_AVAILABLE -> {
+                    scope.launch(Dispatchers.IO) {
+                        val available = comPort.bytesAvailable()
+                        if (available > 0) {
+                            val buffer = ByteArray(available)
+                            comPort.readBytes(buffer, available)
+                            receive(buffer)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    override fun onOpen() {
+        comPort.openPort()
+        comPort.addDataListener(serialPortListener)
+    }
+
+    override val isOpen: Boolean get() = comPort.isOpen
+
+    override suspend fun write(data: ByteArray) {
+        comPort.writeBytes(data, data.size)
+    }
+
+    override fun close() {
+        comPort.removeDataListener()
+        if (comPort.isOpen) {
+            comPort.closePort()
+        }
+        super.close()
+    }
+
+    public companion object : Factory<AsynchronousPort> {
+
+        public fun build(
+            context: Context,
+            portName: String,
+            baudRate: Int = 9600,
+            dataBits: Int = 8,
+            stopBits: Int = SerialPort.ONE_STOP_BIT,
+            parity: Int = SerialPort.NO_PARITY,
+            coroutineContext: CoroutineContext = context.coroutineContext,
+            additionalConfig: SerialPort.() -> Unit = {},
+        ): AsynchronousSerialPort {
+            val serialPort = SerialPort.getCommPort(portName).apply {
+                setComPortParameters(baudRate, dataBits, stopBits, parity)
+                additionalConfig()
+            }
+            val meta = Meta {
+                "name" put "com://$portName"
+                "type" put "serial"
+                "baudRate" put serialPort.baudRate
+                "dataBits" put serialPort.numDataBits
+                "stopBits" put serialPort.numStopBits
+                "parity" put serialPort.parity
+            }
+            return AsynchronousSerialPort(context, meta, serialPort, coroutineContext)
+        }
+
+
+        /**
+         * Construct ComPort with given parameters
+         */
+        public fun open(
+            context: Context,
+            portName: String,
+            baudRate: Int = 9600,
+            dataBits: Int = 8,
+            stopBits: Int = SerialPort.ONE_STOP_BIT,
+            parity: Int = SerialPort.NO_PARITY,
+            coroutineContext: CoroutineContext = context.coroutineContext,
+            additionalConfig: SerialPort.() -> Unit = {},
+        ): AsynchronousSerialPort = build(
+            context = context,
+            portName = portName,
+            baudRate = baudRate,
+            dataBits = dataBits,
+            stopBits = stopBits,
+            parity = parity,
+            coroutineContext = coroutineContext,
+            additionalConfig = additionalConfig
+        ).apply { open() }
+
+
+        override fun build(context: Context, meta: Meta): AsynchronousPort {
+            val name by meta.string { error("Serial port name not defined") }
+            val baudRate by meta.int(9600)
+            val dataBits by meta.int(8)
+            val stopBits by meta.int(SerialPort.ONE_STOP_BIT)
+            val parity by meta.int(SerialPort.NO_PARITY)
+            return build(context, name, baudRate, dataBits, stopBits, parity)
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt
deleted file mode 100644
index 8a2caab..0000000
--- a/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-package space.kscience.controls.serial
-
-import com.fazecast.jSerialComm.SerialPort
-import com.fazecast.jSerialComm.SerialPortDataListener
-import com.fazecast.jSerialComm.SerialPortEvent
-import kotlinx.coroutines.launch
-import space.kscience.controls.ports.AbstractPort
-import space.kscience.controls.ports.Port
-import space.kscience.controls.ports.PortFactory
-import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.int
-import space.kscience.dataforge.meta.string
-import kotlin.coroutines.CoroutineContext
-
-/**
- * A port based on JSerialComm
- */
-public class JSerialCommPort(
-    context: Context,
-    private val comPort: SerialPort,
-    coroutineContext: CoroutineContext = context.coroutineContext,
-) : AbstractPort(context, coroutineContext) {
-
-    override fun toString(): String = "port[${comPort.descriptivePortName}]"
-
-    private val serialPortListener = object : SerialPortDataListener {
-        override fun getListeningEvents(): Int = SerialPort.LISTENING_EVENT_DATA_AVAILABLE
-
-        override fun serialEvent(event: SerialPortEvent) {
-            if (event.eventType == SerialPort.LISTENING_EVENT_DATA_AVAILABLE && event.receivedData != null) {
-                scope.launch { receive(event.receivedData) }
-            }
-        }
-    }
-
-    init {
-        comPort.addDataListener(serialPortListener)
-    }
-
-    override suspend fun write(data: ByteArray) {
-        comPort.writeBytes(data, data.size)
-    }
-
-    override fun close() {
-        comPort.removeDataListener()
-        if (comPort.isOpen) {
-            comPort.closePort()
-        }
-        super.close()
-    }
-
-    public companion object : PortFactory {
-
-        override val type: String = "com"
-
-
-        /**
-         * Construct ComPort with given parameters
-         */
-        public fun open(
-            context: Context,
-            portName: String,
-            baudRate: Int = 9600,
-            dataBits: Int = 8,
-            stopBits: Int = SerialPort.ONE_STOP_BIT,
-            parity: Int = SerialPort.NO_PARITY,
-            coroutineContext: CoroutineContext = context.coroutineContext,
-        ): JSerialCommPort {
-            val serialPort = SerialPort.getCommPort(portName).apply {
-                setComPortParameters(baudRate, dataBits, stopBits, parity)
-                openPort()
-            }
-            return JSerialCommPort(context, serialPort, coroutineContext)
-        }
-
-        override fun build(context: Context, meta: Meta): Port {
-            val name by meta.string { error("Serial port name not defined") }
-            val baudRate by meta.int(9600)
-            val dataBits by meta.int(8)
-            val stopBits by meta.int(SerialPort.ONE_STOP_BIT)
-            val parity by meta.int(SerialPort.NO_PARITY)
-            return open(context, name, baudRate, dataBits, stopBits, parity)
-        }
-    }
-
-}
\ No newline at end of file
diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
index ae9d4fc..f0d099f 100644
--- a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
+++ b/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
@@ -1,19 +1,27 @@
 package space.kscience.controls.serial
 
-import space.kscience.controls.ports.PortFactory
+import space.kscience.controls.ports.Ports
 import space.kscience.dataforge.context.AbstractPlugin
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.PluginFactory
 import space.kscience.dataforge.context.PluginTag
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.asName
 
 public class SerialPortPlugin : AbstractPlugin() {
 
     override val tag: PluginTag get() = Companion.tag
 
-    override fun content(target: String): Map<Name, Any> = when(target){
-        PortFactory.TYPE -> mapOf(Name.EMPTY to JSerialCommPort)
+    override fun content(target: String): Map<Name, Any> = when (target) {
+        Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf(
+            "serial".asName() to AsynchronousSerialPort,
+        )
+
+        Ports.SYNCHRONOUS_PORT_TYPE -> mapOf(
+            "serial".asName() to SynchronousSerialPort,
+        )
+
         else -> emptyMap()
     }
 
diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
new file mode 100644
index 0000000..a67b3f3
--- /dev/null
+++ b/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
@@ -0,0 +1,127 @@
+package space.kscience.controls.serial
+
+import com.fazecast.jSerialComm.SerialPort
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import space.kscience.controls.ports.SynchronousPort
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.context.error
+import space.kscience.dataforge.context.logger
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.int
+import space.kscience.dataforge.meta.string
+
+/**
+ * A port based on JSerialComm
+ */
+public class SynchronousSerialPort(
+    override val context: Context,
+    public val meta: Meta,
+    private val comPort: SerialPort,
+) : SynchronousPort {
+
+    override fun toString(): String = "port[${comPort.descriptivePortName}]"
+
+
+    override fun open() {
+        if (!isOpen) {
+            comPort.openPort()
+        }
+    }
+
+    override val isOpen: Boolean get() = comPort.isOpen
+
+
+    override fun close() {
+        if (comPort.isOpen) {
+            comPort.closePort()
+        }
+    }
+
+    private val mutex = Mutex()
+
+    override suspend fun <R> respond(request: ByteArray, transform: suspend Flow<ByteArray>.() -> R): R =
+        mutex.withLock {
+            comPort.flushIOBuffers()
+            comPort.writeBytes(request, request.size)
+            flow<ByteArray> {
+                while (isOpen) {
+                    try {
+                        val available = comPort.bytesAvailable()
+                        if (available > 0) {
+                            val buffer = ByteArray(available)
+                            comPort.readBytes(buffer, available)
+                            emit(buffer)
+                        } else if (available < 0) break
+                    } catch (ex: Exception) {
+                        logger.error(ex) { "Channel read error" }
+                        delay(1000)
+                    }
+                }
+            }.transform()
+        }
+
+    public companion object : Factory<SynchronousPort> {
+
+        public fun build(
+            context: Context,
+            portName: String,
+            baudRate: Int = 9600,
+            dataBits: Int = 8,
+            stopBits: Int = SerialPort.ONE_STOP_BIT,
+            parity: Int = SerialPort.NO_PARITY,
+            additionalConfig: SerialPort.() -> Unit = {},
+        ): SynchronousSerialPort {
+            val serialPort = SerialPort.getCommPort(portName).apply {
+                setComPortParameters(baudRate, dataBits, stopBits, parity)
+                additionalConfig()
+            }
+            val meta = Meta {
+                "name" put "com://$portName"
+                "type" put "serial"
+                "baudRate" put serialPort.baudRate
+                "dataBits" put serialPort.numDataBits
+                "stopBits" put serialPort.numStopBits
+                "parity" put serialPort.parity
+            }
+            return SynchronousSerialPort(context, meta, serialPort)
+        }
+
+
+        /**
+         * Construct ComPort with given parameters
+         */
+        public fun open(
+            context: Context,
+            portName: String,
+            baudRate: Int = 9600,
+            dataBits: Int = 8,
+            stopBits: Int = SerialPort.ONE_STOP_BIT,
+            parity: Int = SerialPort.NO_PARITY,
+            additionalConfig: SerialPort.() -> Unit = {},
+        ): SynchronousSerialPort = build(
+            context = context,
+            portName = portName,
+            baudRate = baudRate,
+            dataBits = dataBits,
+            stopBits = stopBits,
+            parity = parity,
+            additionalConfig = additionalConfig
+        ).apply { open() }
+
+
+        override fun build(context: Context, meta: Meta): SynchronousPort {
+            val name by meta.string { error("Serial port name not defined") }
+            val baudRate by meta.int(9600)
+            val dataBits by meta.int(8)
+            val stopBits by meta.int(SerialPort.ONE_STOP_BIT)
+            val parity by meta.int(SerialPort.NO_PARITY)
+            return build(context, name, baudRate, dataBits, stopBits, parity)
+        }
+    }
+
+}
\ No newline at end of file
diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
index 1d48553..b0fd562 100644
--- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
+++ b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
@@ -4,7 +4,6 @@ import kotlinx.coroutines.withTimeoutOrNull
 import space.kscience.controls.ports.Ports
 import space.kscience.controls.ports.SynchronousPort
 import space.kscience.controls.ports.respondStringWithDelimiter
-import space.kscience.controls.ports.synchronous
 import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
@@ -22,7 +21,7 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi
 
     private val portDelegate = lazy {
         val ports = context.request(Ports)
-        ports.buildPort(meta["port"] ?: error("Port is not defined in device configuration")).synchronous()
+        ports.buildSynchronousPort(meta["port"] ?: error("Port is not defined in device configuration"))
     }
 
     private val port: SynchronousPort by portDelegate
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index 5d05993..69dfb79 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -25,10 +25,10 @@ import kotlin.time.Duration.Companion.milliseconds
 
 class PiMotionMasterDevice(
     context: Context,
-    private val portFactory: PortFactory = KtorTcpPort,
+    private val portFactory: Factory<AsynchronousPort> = KtorTcpPort,
 ) : DeviceBySpec<PiMotionMasterDevice>(PiMotionMasterDevice, context), DeviceHub {
 
-    private var port: Port? = null
+    private var port: AsynchronousPort? = null
     //TODO make proxy work
     //PortProxy { portFactory(address ?: error("The device is not connected"), context) }
 
@@ -83,7 +83,7 @@ class PiMotionMasterDevice(
     suspend fun getErrorCode(): Int = mutex.withLock {
         withTimeout(timeoutValue) {
             sendCommandInternal("ERR?")
-            val errorString = port?.receiving()?.withStringDelimiter("\n")?.first() ?: error("Not connected to device")
+            val errorString = port?.subscribe()?.withStringDelimiter("\n")?.first() ?: error("Not connected to device")
             errorString.trim().toInt()
         }
     }
@@ -96,7 +96,7 @@ class PiMotionMasterDevice(
         try {
             withTimeout(timeoutValue) {
                 sendCommandInternal(command, *arguments)
-                val phrases = port?.receiving()?.withStringDelimiter("\n") ?: error("Not connected to device")
+                val phrases = port?.subscribe()?.withStringDelimiter("\n") ?: error("Not connected to device")
                 phrases.transformWhile { line ->
                     emit(line)
                     line.endsWith(" \n")
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt
index 8efe4e9..f343d7c 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt
@@ -5,14 +5,15 @@ import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
-import space.kscience.controls.api.Socket
-import space.kscience.controls.ports.AbstractPort
+import space.kscience.controls.api.AsynchronousSocket
+import space.kscience.controls.ports.AbstractAsynchronousPort
 import space.kscience.controls.ports.withDelimiter
 import space.kscience.dataforge.context.*
+import space.kscience.dataforge.meta.Meta
 import kotlin.math.abs
 import kotlin.time.Duration
 
-abstract class VirtualDevice(val scope: CoroutineScope) : Socket<ByteArray> {
+abstract class VirtualDevice(val scope: CoroutineScope) : AsynchronousSocket<ByteArray> {
 
     protected abstract suspend fun evaluateRequest(request: ByteArray)
 
@@ -40,33 +41,42 @@ abstract class VirtualDevice(val scope: CoroutineScope) : Socket<ByteArray> {
         toRespond.send(response)
     }
 
-    override fun receiving(): Flow<ByteArray> = toRespond.receiveAsFlow()
+    override fun subscribe(): Flow<ByteArray> = toRespond.receiveAsFlow()
 
     protected fun respondInFuture(delay: Duration, response: suspend () -> ByteArray): Job = scope.launch {
         delay(delay)
         respond(response())
     }
 
-    override fun isOpen(): Boolean = scope.isActive
+    override val isOpen: Boolean
+        get() = scope.isActive
 
     override fun close() = scope.cancel()
 }
 
-class VirtualPort(private val device: VirtualDevice, context: Context) : AbstractPort(context) {
+class VirtualPort(private val device: VirtualDevice, context: Context) : AbstractAsynchronousPort(context, Meta.EMPTY) {
 
-    private val respondJob = device.receiving().onEach {
-        receive(it)
-    }.catch {
-        it.printStackTrace()
-    }.launchIn(scope)
+    private var respondJob: Job? = null
+
+    override fun onOpen() {
+        respondJob = device.subscribe().onEach {
+            receive(it)
+        }.catch {
+            it.printStackTrace()
+        }.launchIn(scope)
+
+    }
 
 
     override suspend fun write(data: ByteArray) {
         device.send(data)
     }
 
+    override val isOpen: Boolean
+        get() = respondJob?.isActive == true
+
     override fun close() {
-        respondJob.cancel()
+        respondJob?.cancel()
         super.close()
     }
 }
@@ -78,7 +88,7 @@ class PiMotionMasterVirtualDevice(
     scope: CoroutineScope = context,
 ) : VirtualDevice(scope), ContextAware {
 
-    init {
+    override fun open() {
         //add asynchronous send logic here
     }
 
@@ -102,9 +112,11 @@ class PiMotionMasterVirtualDevice(
                         abs(distance) < proposedStep -> {
                             position = targetPosition
                         }
+
                         targetPosition > position -> {
                             position += proposedStep
                         }
+
                         else -> {
                             position -= proposedStep
                         }
@@ -180,8 +192,10 @@ class PiMotionMasterVirtualDevice(
         when (command) {
             "XXX" -> {
             }
+
             "IDN?", "*IDN?" -> respond("(c)2015 Physik Instrumente(PI) Karlsruhe, C-885.M1 TCP-IP Master,0,1.0.0.1")
-            "VER?" -> respond("""
+            "VER?" -> respond(
+                """
                 2: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550039, 00.039 
                 3: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550040, 00.039 
                 4: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550041, 00.039 
@@ -195,8 +209,11 @@ class PiMotionMasterVirtualDevice(
                 12: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550049, 00.039 
                 13: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550051, 00.039 
                 FW_ARM: V1.0.0.1
-            """.trimIndent())
-            "HLP?" -> respond("""
+            """.trimIndent()
+            )
+
+            "HLP?" -> respond(
+                """
                 The following commands are valid: 
                 #4 Request Status Register 
                 #5 Request Motion Status 
@@ -235,11 +252,14 @@ class PiMotionMasterVirtualDevice(
                 VEL? [{<AxisID>}] Get Closed-Loop Velocity 
                 VER? Get Versions Of Firmware And Drivers 
                 end of help
-            """.trimIndent())
+            """.trimIndent()
+            )
+
             "ERR?" -> {
                 respond(errorCode.toString())
                 errorCode = 0
             }
+
             "SAI?" -> respond(axisState.keys.joinToString(separator = " \n"))
             "CST?" -> respondForAllAxis(axisIds) { "L-220.20SG" }
             "RON?" -> respondForAllAxis(axisIds) { referenceMode }
@@ -255,15 +275,19 @@ class PiMotionMasterVirtualDevice(
             "SVO" -> doForEachAxis(parts) { key, value ->
                 axisState[key]?.servoMode = value.toInt()
             }
+
             "MOV" -> doForEachAxis(parts) { key, value ->
                 axisState[key]?.targetPosition = value.toDouble()
             }
+
             "VEL" -> doForEachAxis(parts) { key, value ->
                 axisState[key]?.velocity = value.toDouble()
             }
+
             "INI" -> {
                 logger.info { "Axes initialized!" }
             }
+
             else -> {
                 logger.warn { "Unknown command: $command in message ${String(request)}" }
                 errorCode = 2
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
index 021dff5..c69b7fe 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
@@ -29,7 +29,7 @@ fun Context.launchPiDebugServer(port: Int, axes: List<String>): Job = launch(exc
             val output = socket.openWriteChannel()
 
             val sendJob = launch {
-                virtualDevice.receiving().collect {
+                virtualDevice.subscribe().collect {
                     //println("Sending: ${it.decodeToString()}")
                     output.writeAvailable(it)
                     output.flush()

From 58675f72f598aac1fbcdfbadfad0c61d286c5947 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 31 Mar 2024 16:33:22 +0300
Subject: [PATCH 072/125] Refactor ports

---
 .../controls/ports/SynchronousPort.kt         | 19 ++++++++
 .../kscience/controls/pi/SynchronousPiPort.kt |  9 ++++
 .../controls/serial/SynchronousSerialPort.kt  | 47 ++++++++++++-------
 3 files changed, 58 insertions(+), 17 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
index 8d95eb0..49c2b06 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
@@ -2,8 +2,11 @@ package space.kscience.controls.ports
 
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
+import kotlinx.io.Buffer
+import kotlinx.io.readByteArray
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.ContextAware
 
@@ -25,6 +28,22 @@ public interface SynchronousPort : ContextAware, AutoCloseable {
         request: ByteArray,
         transform: suspend Flow<ByteArray>.() -> R,
     ): R
+
+    /**
+     * Synchronously read fixed size response to a given [request]. Discard additional response bytes.
+     */
+    public suspend fun respondFixedMessageSize(
+        request: ByteArray,
+        responseSize: Int,
+    ): ByteArray = respond(request) {
+        val buffer = Buffer()
+        takeWhile {
+            buffer.size < responseSize
+        }.collect {
+            buffer.write(it)
+        }
+        buffer.readByteArray(responseSize)
+    }
 }
 
 private class SynchronousOverAsynchronousPort(
diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
index acefb1f..6bc590c 100644
--- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
+++ b/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
@@ -7,6 +7,7 @@ import com.pi4j.ktx.io.serial
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.runInterruptible
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import space.kscience.controls.ports.SynchronousPort
@@ -55,6 +56,14 @@ public class SynchronousPiPort(
         }.transform()
     }
 
+    override suspend fun respondFixedMessageSize(request: ByteArray, responseSize: Int): ByteArray = mutex.withLock {
+        runInterruptible {
+            serial.drain()
+            serial.write(request)
+            serial.readNBytes(responseSize)
+        }
+    }
+
     override fun close() {
         serial.close()
     }
diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
index a67b3f3..1a9b4a5 100644
--- a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
+++ b/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
@@ -4,6 +4,7 @@ import com.fazecast.jSerialComm.SerialPort
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.runInterruptible
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import space.kscience.controls.ports.SynchronousPort
@@ -44,26 +45,38 @@ public class SynchronousSerialPort(
 
     private val mutex = Mutex()
 
-    override suspend fun <R> respond(request: ByteArray, transform: suspend Flow<ByteArray>.() -> R): R =
-        mutex.withLock {
+    override suspend fun <R> respond(
+        request: ByteArray,
+        transform: suspend Flow<ByteArray>.() -> R,
+    ): R = mutex.withLock {
+        comPort.flushIOBuffers()
+        comPort.writeBytes(request, request.size)
+        flow<ByteArray> {
+            while (isOpen) {
+                try {
+                    val available = comPort.bytesAvailable()
+                    if (available > 0) {
+                        val buffer = ByteArray(available)
+                        comPort.readBytes(buffer, available)
+                        emit(buffer)
+                    } else if (available < 0) break
+                } catch (ex: Exception) {
+                    logger.error(ex) { "Channel read error" }
+                    delay(1000)
+                }
+            }
+        }.transform()
+    }
+
+    override suspend fun respondFixedMessageSize(request: ByteArray, responseSize: Int): ByteArray = mutex.withLock {
+        runInterruptible {
             comPort.flushIOBuffers()
             comPort.writeBytes(request, request.size)
-            flow<ByteArray> {
-                while (isOpen) {
-                    try {
-                        val available = comPort.bytesAvailable()
-                        if (available > 0) {
-                            val buffer = ByteArray(available)
-                            comPort.readBytes(buffer, available)
-                            emit(buffer)
-                        } else if (available < 0) break
-                    } catch (ex: Exception) {
-                        logger.error(ex) { "Channel read error" }
-                        delay(1000)
-                    }
-                }
-            }.transform()
+            val buffer = ByteArray(responseSize)
+            comPort.readBytes(buffer, responseSize)
+            buffer
         }
+    }
 
     public companion object : Factory<SynchronousPort> {
 

From 977500223d206eb878b52f0ed149a2faeca96433 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 7 Apr 2024 10:07:23 +0300
Subject: [PATCH 073/125] Plc4X refactor

---
 controls-plc4x/build.gradle.kts               |  6 +-
 .../kscience/controls/plc4x/Plc4XDevice.kt    | 80 +++++++++++++++----
 .../controls/plc4x/Plc4XDeviceBase.kt         | 22 +++++
 .../kscience/controls/plc4x/Plc4xProperty.kt  | 14 +++-
 demo/constructor/src/jvmMain/kotlin/main.kt   | 10 +--
 5 files changed, 105 insertions(+), 27 deletions(-)
 create mode 100644 controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDeviceBase.kt

diff --git a/controls-plc4x/build.gradle.kts b/controls-plc4x/build.gradle.kts
index 0836476..e7b063c 100644
--- a/controls-plc4x/build.gradle.kts
+++ b/controls-plc4x/build.gradle.kts
@@ -11,14 +11,14 @@ description = """
     A plugin for Controls-kt device server on top of plc4x library
 """.trimIndent()
 
-kscience{
+kscience {
     jvm()
-    jvmMain{
+    jvmMain {
         api(projects.controlsCore)
         api("org.apache.plc4x:plc4j-spi:$plc4xVersion")
     }
 }
 
-readme{
+readme {
     maturity = Maturity.EXPERIMENTAL
 }
\ No newline at end of file
diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt
index 996d194..9b08538 100644
--- a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt
+++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt
@@ -2,27 +2,75 @@ package space.kscience.controls.plc4x
 
 import kotlinx.coroutines.future.await
 import org.apache.plc4x.java.api.PlcConnection
+import org.apache.plc4x.java.api.messages.PlcBrowseItem
+import org.apache.plc4x.java.api.messages.PlcTagResponse
 import org.apache.plc4x.java.api.messages.PlcWriteRequest
+import org.apache.plc4x.java.api.messages.PlcWriteResponse
+import org.apache.plc4x.java.api.types.PlcResponseCode
 import space.kscience.controls.api.Device
 import space.kscience.dataforge.meta.Meta
 
-public interface Plc4XDevice: Device {
-    public val connection: PlcConnection
+private val PlcTagResponse.responseCodes: Map<String, PlcResponseCode>
+    get() = tagNames.associateWith { getResponseCode(it) }
 
-    public suspend fun read(plc4xProperty: Plc4xProperty): Meta = with(plc4xProperty){
-        val request = connection.readRequestBuilder().request().build()
-        val response = request.execute().await()
-        response.readProperty()
-    }
-
-    public suspend fun write(plc4xProperty: Plc4xProperty, value: Meta): Unit = with(plc4xProperty){
-        val request: PlcWriteRequest = connection.writeRequestBuilder().writeProperty(value).build()
-       request.execute().await()
-    }
-
-    public suspend fun subscribe(propertyName: String, plc4xProperty: Plc4xProperty): Unit = with(plc4xProperty){
-
-    }
+private val Map<String, PlcResponseCode>.isOK get() = values.all { it == PlcResponseCode.OK }
 
+public class PlcException(public val codes: Map<String, PlcResponseCode>) : Exception() {
+    override val message: String
+        get() = "Plc request unsuccessful:" + codes.entries.joinToString(prefix = "\n\t", separator = "\n\t") {
+            "${it.key}: ${it.value.name}"
+        }
 }
 
+private fun PlcTagResponse.throwOnFail() {
+    val codes = responseCodes
+    if (!codes.isOK) throw PlcException(codes)
+}
+
+
+public interface Plc4XDevice : Device {
+    public val connection: PlcConnection
+}
+
+
+/**
+ * Send ping request and suspend until it comes back
+ */
+public suspend fun Plc4XDevice.ping(): PlcResponseCode = connection.ping().await().responseCode
+
+/**
+ * Send browse request to list available tags
+ */
+public suspend fun Plc4XDevice.browse(): Map<String, MutableList<PlcBrowseItem>> {
+    require(connection.metadata.isBrowseSupported){"Browse actions are not supported on connection"}
+    val request = connection.browseRequestBuilder().build()
+    val response = request.execute().await()
+
+    return response.queryNames.associateWith { response.getValues(it) }
+}
+
+/**
+ * Send read request and suspend until it returns. Throw a [PlcException] if at least one tag read fails.
+ *
+ * @throws PlcException
+ */
+public suspend fun Plc4XDevice.read(plc4xProperty: Plc4xProperty): Meta = with(plc4xProperty) {
+    require(connection.metadata.isReadSupported) {"Read actions are not supported on connections"}
+    val request = connection.readRequestBuilder().request().build()
+    val response = request.execute().await()
+    response.throwOnFail()
+    response.readProperty()
+}
+
+
+/**
+ * Send write request and suspend until it finishes. Throw a [PlcException] if at least one tag write fails.
+ *
+ * @throws PlcException
+ */
+public suspend fun Plc4XDevice.write(plc4xProperty: Plc4xProperty, value: Meta): Unit = with(plc4xProperty) {
+    require(connection.metadata.isWriteSupported){"Write actions are not supported on connection"}
+    val request: PlcWriteRequest = connection.writeRequestBuilder().writeProperty(value).build()
+    val response: PlcWriteResponse = request.execute().await()
+    response.throwOnFail()
+}
\ No newline at end of file
diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDeviceBase.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDeviceBase.kt
new file mode 100644
index 0000000..e25a001
--- /dev/null
+++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDeviceBase.kt
@@ -0,0 +1,22 @@
+package space.kscience.controls.plc4x
+
+import org.apache.plc4x.java.api.PlcConnection
+import space.kscience.controls.spec.DeviceActionSpec
+import space.kscience.controls.spec.DeviceBase
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.Meta
+
+public class Plc4XDeviceBase(
+    context: Context,
+    meta: Meta,
+    override val connection: PlcConnection,
+) : Plc4XDevice, DeviceBase<Plc4XDevice>(context, meta) {
+    override val properties: Map<String, DevicePropertySpec<Plc4XDevice, *>>
+        get() = TODO("Not yet implemented")
+    override val actions: Map<String, DeviceActionSpec<Plc4XDevice, *, *>> = emptyMap()
+
+    override fun toString(): String {
+        TODO("Not yet implemented")
+    }
+}
\ No newline at end of file
diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt
index 5b16dec..cfeedd2 100644
--- a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt
+++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt
@@ -8,6 +8,8 @@ import space.kscience.dataforge.meta.Meta
 
 public interface Plc4xProperty {
 
+    public val keys: Set<String>
+
     public fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder
 
     public fun PlcReadResponse.readProperty(): Meta
@@ -15,17 +17,23 @@ public interface Plc4xProperty {
     public fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder
 }
 
-public class DefaultPlc4xProperty(
+private class DefaultPlc4xProperty(
     private val address: String,
     private val plcValueType: PlcValueType,
     private val name: String = "@default",
 ) : Plc4xProperty {
 
+    override val keys: Set<String> = setOf(name)
+
     override fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder =
         addTagAddress(name, address)
+
     override fun PlcReadResponse.readProperty(): Meta =
-        asPlcValue.toMeta()
+        getPlcValue(name).toMeta()
 
     override fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder =
         addTagAddress(name, address, meta.toPlcValue(plcValueType))
-}
\ No newline at end of file
+}
+
+public fun Plc4xProperty(address: String, plcValueType: PlcValueType, name: String = "@default"): Plc4xProperty =
+    DefaultPlc4xProperty(address, plcValueType, name)
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 26ca10a..9b06d2e 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -84,24 +84,24 @@ private fun Context.launchPidDevice(
 
     showDashboard {
         plot {
-            plotNumberState(context, state, maxAge = maxAge) {
+            plotNumberState(context, state, maxAge = maxAge, sampling = 50.milliseconds) {
                 name = "real position"
             }
-            plotDeviceProperty(device.pid, Regulator.position.name, maxAge = maxAge) {
+            plotDeviceProperty(device.pid, Regulator.position.name, maxAge = maxAge, sampling = 50.milliseconds) {
                 name = "read position"
             }
 
-            plotDeviceProperty(device.pid, Regulator.target.name, maxAge = maxAge) {
+            plotDeviceProperty(device.pid, Regulator.target.name, maxAge = maxAge, sampling = 50.milliseconds) {
                 name = "target"
             }
         }
 
         plot {
-            plotDeviceProperty(device.start, LimitSwitch.locked.name, maxAge = maxAge) {
+            plotDeviceProperty(device.start, LimitSwitch.locked.name, maxAge = maxAge, sampling = 50.milliseconds) {
                 name = "start measured"
                 mode = ScatterMode.markers
             }
-            plotDeviceProperty(device.end, LimitSwitch.locked.name, maxAge = maxAge) {
+            plotDeviceProperty(device.end, LimitSwitch.locked.name, maxAge = maxAge, sampling = 50.milliseconds) {
                 name = "end measured"
                 mode = ScatterMode.markers
             }

From 9eb583dfc6a832f6e855bd26d52d2ed930ccc4de Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 9 Apr 2024 15:13:41 +0300
Subject: [PATCH 074/125] Change plotAveragedDeviceProperty to show last value
 on miss.

---
 .../src/commonMain/kotlin/plotExtensions.kt            | 10 ++++++----
 1 file changed, 6 insertions(+), 4 deletions(-)

diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index 804fe9d..6dfdee0 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -205,14 +205,14 @@ private fun List<Instant>.averageTime(): Instant {
 }
 
 /**
- * Average property value by [averagingInterval]. Return [missingValue] on each sample interval if no events arrived.
+ * Average property value by [averagingInterval]. Return [startValue] on each sample interval if no events arrived.
  */
 @DFExperimental
 public fun Plot.plotAveragedDeviceProperty(
     device: Device,
     propertyName: String,
-    missingValue: Double = 0.0,
-    extractValue: Meta.() -> Double = { value?.double ?: missingValue },
+    startValue: Double = 0.0,
+    extractValue: Meta.() -> Double = { value?.double ?: startValue },
     maxAge: Duration = defaultMaxAge,
     maxPoints: Int = defaultMaxPoints,
     minPoints: Int = defaultMinPoints,
@@ -221,13 +221,15 @@ public fun Plot.plotAveragedDeviceProperty(
     configuration: Scatter.() -> Unit = {},
 ): Job = scatter(configuration).run {
     val data = TimeData()
+    var lastValue = startValue
     device.propertyMessageFlow(propertyName).chunkedByPeriod(averagingInterval).transform { eventList ->
         if (eventList.isEmpty()) {
-            data.append(Clock.System.now(), missingValue.asValue())
+            data.append(Clock.System.now(), lastValue.asValue())
         } else {
             val time = eventList.map { it.time }.averageTime()
             val value = eventList.map { extractValue(it.value) }.average()
             data.append(time, value.asValue())
+            lastValue = value
         }
         data.trim(maxAge, maxPoints, minPoints)
         emit(data)

From 23bceed89da647fd461dd74aa5ca243ee6346720 Mon Sep 17 00:00:00 2001
From: InsanusMokrassar <ovsyannikov.alexey95@gmail.com>
Date: Mon, 29 Apr 2024 17:22:56 +0600
Subject: [PATCH 075/125] update dependencies

---
 build.gradle.kts                         |  1 +
 controls-modbus/build.gradle.kts         |  2 +-
 controls-opcua/build.gradle.kts          |  8 +++-----
 controls-pi/build.gradle.kts             |  8 ++++----
 gradle/libs.versions.toml                | 26 ++++++++++++++++++++++++
 gradle/wrapper/gradle-wrapper.properties |  2 +-
 6 files changed, 36 insertions(+), 11 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index 02dde8d..2463418 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -4,6 +4,7 @@ import space.kscience.gradle.useSPCTeam
 
 plugins {
     id("space.kscience.gradle.project")
+    alias(libs.plugins.versions)
 }
 
 allprojects {
diff --git a/controls-modbus/build.gradle.kts b/controls-modbus/build.gradle.kts
index aee64d5..2f280b6 100644
--- a/controls-modbus/build.gradle.kts
+++ b/controls-modbus/build.gradle.kts
@@ -12,7 +12,7 @@ description = """
 
 dependencies {
     api(projects.controlsCore)
-    api("com.ghgande:j2mod:3.1.1")
+    api(libs.j2mod)
 }
 
 readme{
diff --git a/controls-opcua/build.gradle.kts b/controls-opcua/build.gradle.kts
index e7e1da8..686ae62 100644
--- a/controls-opcua/build.gradle.kts
+++ b/controls-opcua/build.gradle.kts
@@ -11,15 +11,13 @@ description = """
 
 val ktorVersion: String by rootProject.extra
 
-val miloVersion: String = "0.6.10"
-
 dependencies {
     api(projects.controlsCore)
     api(spclibs.kotlinx.coroutines.jdk8)
 
-    api("org.eclipse.milo:sdk-client:$miloVersion")
-    api("org.eclipse.milo:bsd-parser:$miloVersion")
-    api("org.eclipse.milo:sdk-server:$miloVersion")
+    api(libs.milo.client)
+    api(libs.milo.parser)
+    api(libs.milo.server)
 
     testImplementation(spclibs.kotlinx.coroutines.test)
 }
diff --git a/controls-pi/build.gradle.kts b/controls-pi/build.gradle.kts
index a763396..997fcf2 100644
--- a/controls-pi/build.gradle.kts
+++ b/controls-pi/build.gradle.kts
@@ -9,8 +9,8 @@ description = """
 
 dependencies{
     api(project(":controls-core"))
-    api("com.pi4j:pi4j-ktx:2.4.0") // Kotlin DSL
-    api("com.pi4j:pi4j-core:2.3.0")
-    api("com.pi4j:pi4j-plugin-raspberrypi:2.3.0")
-    api("com.pi4j:pi4j-plugin-pigpio:2.3.0")
+    api(libs.pi4j.ktx) // Kotlin DSL
+    api(libs.pi4j.core)
+    api(libs.pi4j.plugin.raspberrypi)
+    api(libs.pi4j.plugin.pigpio)
 }
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index e39b1d9..da20aff 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -20,6 +20,15 @@ rabbitmq = "5.14.2"
 
 kmongo = "4.5.1"
 
+j2mod = "3.2.1"
+
+milo = "0.6.12"
+
+pi4j = "2.3.0"
+pi4j-ktx = "2.4.0"
+
+versions = "0.51.0"
+
 [libraries]
 
 dataforge-io = { module = "space.kscience:dataforge-io", version.ref = "dataforge" }
@@ -46,4 +55,21 @@ hivemq-mqtt-client = { module = "com.hivemq:hivemq-mqtt-client", version.ref = "
 
 rabbitmq-amqp-client = { module = "com.rabbitmq:amqp-client", version.ref = "rabbitmq" }
 
+j2mod = { module = "com.ghgande:j2mod", version.ref = "j2mod" }
+
 kmongo-coroutine-serialization = { module = "org.litote.kmongo:kmongo-coroutine-serialization", version.ref = "kmongo" }
+
+milo-client = { module = "org.eclipse.milo:sdk-client", version.ref = "milo" }
+milo-parser = { module = "org.eclipse.milo:bsd-parser", version.ref = "milo" }
+milo-server = { module = "org.eclipse.milo:sdk-server", version.ref = "milo" }
+
+pi4j-ktx = { module = "com.pi4j:pi4j-ktx", version.ref = "pi4j-ktx" }
+pi4j-core = { module = "com.pi4j:pi4j-core", version.ref = "pi4j" }
+pi4j-plugin-raspberrypi = { module = "com.pi4j:pi4j-plugin-raspberrypi", version.ref = "pi4j" }
+pi4j-plugin-pigpio = { module = "com.pi4j:pi4j-plugin-pigpio", version.ref = "pi4j" }
+
+# Buildscript
+
+[plugins]
+
+versions = { id = "com.github.ben-manes.versions", version.ref = "versions" }
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 17655d0..48c0a02 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists

From e729cb1a794db44955d68b2ec8f676e4b65736ff Mon Sep 17 00:00:00 2001
From: InsanusMokrassar <ovsyannikov.alexey95@gmail.com>
Date: Mon, 29 Apr 2024 18:38:14 +0600
Subject: [PATCH 076/125] upfixes

---
 controls-core/build.gradle.kts     |  2 --
 controls-jupyter/build.gradle.kts  |  4 +---
 controls-plc4x/build.gradle.kts    |  4 +---
 controls-server/build.gradle.kts   | 24 ++++++++++++++----------
 controls-vision/build.gradle.kts   | 10 ++++------
 gradle/libs.versions.toml          | 14 +++++++++++++-
 magix/magix-utils/build.gradle.kts |  4 +---
 7 files changed, 34 insertions(+), 28 deletions(-)

diff --git a/controls-core/build.gradle.kts b/controls-core/build.gradle.kts
index 9b8c9f9..9624b35 100644
--- a/controls-core/build.gradle.kts
+++ b/controls-core/build.gradle.kts
@@ -9,8 +9,6 @@ description = """
     Core interfaces for building a device server
 """.trimIndent()
 
-val dataforgeVersion: String by rootProject.extra
-
 kscience {
     jvm()
     js()
diff --git a/controls-jupyter/build.gradle.kts b/controls-jupyter/build.gradle.kts
index c8486bd..21a3a82 100644
--- a/controls-jupyter/build.gradle.kts
+++ b/controls-jupyter/build.gradle.kts
@@ -3,8 +3,6 @@ plugins {
     `maven-publish`
 }
 
-val visionforgeVersion: String by rootProject.extra
-
 kscience {
     fullStack("js/controls-jupyter.js")
     useKtor()
@@ -12,7 +10,7 @@ kscience {
     jupyterLibrary("space.kscience.controls.jupyter.ControlsJupyter")
     dependencies {
         implementation(projects.controlsVision)
-        implementation("space.kscience:visionforge-jupyter:$visionforgeVersion")
+        implementation(libs.visionforge.jupiter)
     }
     jvmMain {
         implementation(spclibs.logback.classic)
diff --git a/controls-plc4x/build.gradle.kts b/controls-plc4x/build.gradle.kts
index e7b063c..01f7f68 100644
--- a/controls-plc4x/build.gradle.kts
+++ b/controls-plc4x/build.gradle.kts
@@ -5,8 +5,6 @@ plugins {
     `maven-publish`
 }
 
-val plc4xVersion = "0.12.0"
-
 description = """
     A plugin for Controls-kt device server on top of plc4x library
 """.trimIndent()
@@ -15,7 +13,7 @@ kscience {
     jvm()
     jvmMain {
         api(projects.controlsCore)
-        api("org.apache.plc4x:plc4j-spi:$plc4xVersion")
+        api(libs.plc4j.spi)
     }
 }
 
diff --git a/controls-server/build.gradle.kts b/controls-server/build.gradle.kts
index 3581a71..7a57d57 100644
--- a/controls-server/build.gradle.kts
+++ b/controls-server/build.gradle.kts
@@ -9,16 +9,20 @@ description = """
    A combined Magix event loop server with web server for visualization.
 """.trimIndent()
 
-dependencies {
-    implementation(projects.controlsCore)
-    implementation(projects.controlsPortsKtor)
-    implementation(projects.magix.magixServer)
-    implementation(spclibs.ktor.server.cio)
-    implementation(spclibs.ktor.server.websockets)
-    implementation(spclibs.ktor.server.content.negotiation)
-    implementation(spclibs.ktor.serialization.kotlinx.json)
-    implementation(spclibs.ktor.server.html.builder)
-    implementation(spclibs.ktor.server.status.pages)
+
+kscience {
+    jvm()
+    dependencies {
+        implementation(projects.controlsCore)
+        implementation(projects.controlsPortsKtor)
+        implementation(projects.magix.magixServer)
+        implementation(spclibs.ktor.server.cio)
+        implementation(spclibs.ktor.server.websockets)
+        implementation(spclibs.ktor.server.content.negotiation)
+        implementation(spclibs.ktor.serialization.kotlinx.json)
+        implementation(spclibs.ktor.server.html.builder)
+        implementation(spclibs.ktor.server.status.pages)
+    }
 }
 
 readme{
diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
index 9395b92..c36cdce 100644
--- a/controls-vision/build.gradle.kts
+++ b/controls-vision/build.gradle.kts
@@ -7,8 +7,6 @@ description = """
     Dashboard and visualization extensions for devices
 """.trimIndent()
 
-val visionforgeVersion: String by rootProject.extra
-
 kscience {
     fullStack("js/controls-vision.js")
     useKtor()
@@ -16,15 +14,15 @@ kscience {
     dependencies {
         api(projects.controlsCore)
         api(projects.controlsConstructor)
-        api("space.kscience:visionforge-plotly:$visionforgeVersion")
-        api("space.kscience:visionforge-markdown:$visionforgeVersion")
+        api(libs.visionforge.plotly)
+        api(libs.visionforge.markdown)
 //        api("space.kscience:tables-kt:0.2.1")
 //        api("space.kscience:visionforge-tables:$visionforgeVersion")
     }
 
     jvmMain{
-        api("space.kscience:visionforge-server:$visionforgeVersion")
-        api("io.ktor:ktor-server-cio")
+        api(libs.visionforge.server)
+        api(spclibs.ktor.server.cio)
     }
 }
 
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index da20aff..242a120 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,6 @@
 [versions]
 
-dataforge = "0.6.2-dev-3"
+dataforge = "0.8.0"
 rsocket = "0.15.4"
 xodus = "2.0.1"
 
@@ -27,11 +27,16 @@ milo = "0.6.12"
 pi4j = "2.3.0"
 pi4j-ktx = "2.4.0"
 
+plc4j = "0.12.0"
+
+visionforge = "0.4.1"
+
 versions = "0.51.0"
 
 [libraries]
 
 dataforge-io = { module = "space.kscience:dataforge-io", version.ref = "dataforge" }
+dataforge-meta = { module = "space.kscience:dataforge-meta", version.ref = "dataforge" }
 
 uuid = { module = "com.benasher44:uuid", version.ref = "uuid" }
 
@@ -68,6 +73,13 @@ pi4j-core = { module = "com.pi4j:pi4j-core", version.ref = "pi4j" }
 pi4j-plugin-raspberrypi = { module = "com.pi4j:pi4j-plugin-raspberrypi", version.ref = "pi4j" }
 pi4j-plugin-pigpio = { module = "com.pi4j:pi4j-plugin-pigpio", version.ref = "pi4j" }
 
+plc4j-spi = { module = "org.apache.plc4x:plc4j-spi", version.ref = "plc4j" }
+
+visionforge-jupiter = { module = "space.kscience:visionforge-jupyter", version.ref = "visionforge" }
+visionforge-plotly = { module = "space.kscience:visionforge-plotly", version.ref = "visionforge" }
+visionforge-markdown = { module = "space.kscience:visionforge-markdown", version.ref = "visionforge" }
+visionforge-server = { module = "space.kscience:visionforge-server", version.ref = "visionforge" }
+
 # Buildscript
 
 [plugins]
diff --git a/magix/magix-utils/build.gradle.kts b/magix/magix-utils/build.gradle.kts
index b22b5f0..9bec805 100644
--- a/magix/magix-utils/build.gradle.kts
+++ b/magix/magix-utils/build.gradle.kts
@@ -9,8 +9,6 @@ description = """
     Common utilities and services for Magix endpoints.   
 """.trimIndent()
 
-val dataforgeVersion: String by rootProject.extra
-
 kscience {
     jvm()
     js()
@@ -18,7 +16,7 @@ kscience {
     useSerialization()
     commonMain {
         api(projects.magix.magixApi)
-        api("space.kscience:dataforge-meta:$dataforgeVersion")
+        api(libs.dataforge.meta)
     }
 }
 

From f974483a41cf480e2ed366f688ac4e0326db9271 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 29 Apr 2024 18:28:14 +0300
Subject: [PATCH 077/125] Update plotly version

---
 .../kotlin/space/kscience/controls/spec/DeviceBase.kt           | 1 +
 gradle/libs.versions.toml                                       | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index dcb48b4..e8b9210 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -72,6 +72,7 @@ public abstract class DeviceBase<D : Device>(
         onBufferOverflow = BufferOverflow.DROP_OLDEST
     )
 
+    @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
     override val coroutineContext: CoroutineContext = context.newCoroutineContext(
         SupervisorJob(context.coroutineContext[Job]) +
                 CoroutineName("Device $this") +
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 242a120..601b9d1 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -10,7 +10,7 @@ fazecast = "2.10.3"
 
 tornadofx = "1.7.20"
 
-plotlykt = "0.5.3"
+plotlykt = "0.7.0"
 
 logback = "1.2.11"
 

From 381da970bf1e2fd7a8b288c1c2103017b5149ec8 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 10 May 2024 11:33:00 +0300
Subject: [PATCH 078/125] make device stop suspended to properly await for
 lifecycle event.

Add capabilities to Constructor
---
 build.gradle.kts                              |   2 +-
 controls-constructor/build.gradle.kts         |   7 +-
 .../constructor/ConstructorBinding.kt         |  26 ++
 .../controls/constructor/DeviceConstructor.kt | 230 +++++++++++-------
 .../controls/constructor/DeviceGroup.kt       |  51 ++--
 .../controls/constructor/DeviceState.kt       | 205 +++-------------
 .../controls/constructor/TimerState.kt        |  41 ++++
 .../controls/constructor/boundState.kt        | 103 ++++++++
 .../controls/constructor/customState.kt       |   4 +
 .../controls/constructor/externalState.kt     |  70 ++++++
 .../controls/constructor/flowState.kt         |  23 ++
 .../controls/constructor/internalState.kt     |  42 ++++
 .../constructor/{ => library}/Drive.kt        |   8 +-
 .../constructor/{ => library}/LimitSwitch.kt  |   5 +-
 .../constructor/{ => library}/PidRegulator.kt |  11 +-
 .../constructor/{ => library}/Regulator.kt    |   2 +-
 .../controls/constructor/DeviceGroupTest.kt   |  43 ++++
 .../controls/constructor/TimerTest.kt         |  22 ++
 .../space/kscience/controls/api/Device.kt     |   4 +-
 .../kscience/controls/api/descriptors.kt      |  19 +-
 .../kscience/controls/manager/ClockManager.kt |   8 +-
 .../controls/manager/DeviceManager.kt         |   7 +-
 .../kscience/controls/spec/DeviceBase.kt      |  39 ++-
 .../kscience/controls/spec/DeviceBySpec.kt    |   2 +-
 .../kscience/controls/spec/DeviceSpec.kt      |  25 ++
 .../kscience/controls/spec/converters.kt      |  41 ++++
 .../controls/spec/deviceExtensions.kt         |  50 ++--
 .../space/kscience/controls/spec/fromSpec.kt  |   2 -
 .../space/kscience/controls/spec/misc.kt      |  22 --
 .../kscience/controls/spec/fromSpec.jvm.kt    |   5 +-
 controls-modbus/build.gradle.kts              |  12 +-
 .../controls/modbus/DeviceProcessImage.kt     |   0
 .../kscience/controls/modbus/ModbusDevice.kt  |   0
 .../controls/modbus/ModbusDeviceBySpec.kt     |   2 +-
 .../controls/modbus/ModbusRegistryMap.kt      |   0
 .../opcua/client/OpcUaDeviceBySpec.kt         |   2 +-
 controls-pi/build.gradle.kts                  |  19 +-
 .../controls/pi/AsynchronousPiPort.kt         |   0
 .../space/kscience/controls/pi/PiPlugin.kt    |   0
 .../kscience/controls/pi/SynchronousPiPort.kt |   0
 controls-ports-ktor/build.gradle.kts          |  11 +-
 .../controls/ports/KtorPortsPlugin.kt         |   0
 .../kscience/controls/ports/KtorTcpPort.kt    |   0
 .../kscience/controls/ports/KtorUdpPort.kt    |   0
 controls-serial/build.gradle.kts              |  11 +-
 .../controls/serial/AsynchronousSerialPort.kt |   0
 .../controls/serial/SerialPortPlugin.kt       |   0
 .../controls/serial/SynchronousSerialPort.kt  |   0
 .../controls-xodus/build.gradle.kts           |  15 +-
 .../xodus/XodusDeviceMessageStorage.kt        |   0
 .../kotlin/PropertyHistoryTest.kt             |   0
 .../controls/demo/DemoControllerView.kt       |   7 +-
 .../controls/demo/car/VirtualCarController.kt |   7 +-
 demo/constructor/src/jvmMain/kotlin/main.kt   |  18 +-
 magix/magix-server/build.gradle.kts           |  36 +--
 .../magix/server/RSocketMagixFlowPlugin.kt    |   0
 .../kscience/magix/server/magixModule.kt      |   0
 .../space/kscience/magix/server/server.kt     |   0
 .../kotlin/space/kscience/magix/server/sse.kt |   0
 59 files changed, 818 insertions(+), 441 deletions(-)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorBinding.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/{ => library}/Drive.kt (91%)
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/{ => library}/LimitSwitch.kt (83%)
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/{ => library}/PidRegulator.kt (83%)
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/{ => library}/Regulator.kt (92%)
 create mode 100644 controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt
 create mode 100644 controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/spec/converters.kt
 delete mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt
 rename controls-modbus/src/{main => jvmMain}/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt (100%)
 rename controls-modbus/src/{main => jvmMain}/kotlin/space/kscience/controls/modbus/ModbusDevice.kt (100%)
 rename controls-modbus/src/{main => jvmMain}/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt (97%)
 rename controls-modbus/src/{main => jvmMain}/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt (100%)
 rename controls-pi/src/{main => jvmMain}/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt (100%)
 rename controls-pi/src/{main => jvmMain}/kotlin/space/kscience/controls/pi/PiPlugin.kt (100%)
 rename controls-pi/src/{main => jvmMain}/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt (100%)
 rename controls-ports-ktor/src/{main => jvmMain}/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt (100%)
 rename controls-ports-ktor/src/{main => jvmMain}/kotlin/space/kscience/controls/ports/KtorTcpPort.kt (100%)
 rename controls-ports-ktor/src/{main => jvmMain}/kotlin/space/kscience/controls/ports/KtorUdpPort.kt (100%)
 rename controls-serial/src/{main => jvmMain}/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt (100%)
 rename controls-serial/src/{main => jvmMain}/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt (100%)
 rename controls-serial/src/{main => jvmMain}/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt (100%)
 rename controls-storage/controls-xodus/src/{main => jvmMain}/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt (100%)
 rename controls-storage/controls-xodus/src/{test => jvmTest}/kotlin/PropertyHistoryTest.kt (100%)
 rename magix/magix-server/src/{main => jvmMain}/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt (100%)
 rename magix/magix-server/src/{main => jvmMain}/kotlin/space/kscience/magix/server/magixModule.kt (100%)
 rename magix/magix-server/src/{main => jvmMain}/kotlin/space/kscience/magix/server/server.kt (100%)
 rename magix/magix-server/src/{main => jvmMain}/kotlin/space/kscience/magix/server/sse.kt (100%)

diff --git a/build.gradle.kts b/build.gradle.kts
index 57cb978..aed37b5 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,7 @@ plugins {
 
 allprojects {
     group = "space.kscience"
-    version = "0.3.1-dev-1"
+    version = "0.4.0-dev-1"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-constructor/build.gradle.kts b/controls-constructor/build.gradle.kts
index e62dd8e..e69c4d4 100644
--- a/controls-constructor/build.gradle.kts
+++ b/controls-constructor/build.gradle.kts
@@ -10,9 +10,14 @@ description = """
 kscience{
     jvm()
     js()
-    dependencies {
+    useCoroutines()
+    commonMain {
         api(projects.controlsCore)
     }
+
+    commonTest{
+        implementation(spclibs.logback.classic)
+    }
 }
 
 readme{
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorBinding.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorBinding.kt
new file mode 100644
index 0000000..7ad370e
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorBinding.kt
@@ -0,0 +1,26 @@
+package space.kscience.controls.constructor
+
+import space.kscience.controls.api.Device
+
+public sealed interface ConstructorBinding
+
+/**
+ * A binding that exposes device property as read-only state
+ */
+public class PropertyBinding<T>(
+    public val device: Device,
+    public val propertyName: String,
+    public val state: DeviceState<T>,
+) : ConstructorBinding
+
+/**
+ * A binding for independent state like a timer
+ */
+public class StateBinding<T>(
+    public val state: DeviceState<T>
+) : ConstructorBinding
+
+public class ActionBinding(
+    public val reads: Collection<DeviceState<*>>,
+    public val writes: Collection<DeviceState<*>>
+): ConstructorBinding
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 63b5303..7824e3c 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -1,9 +1,16 @@
 package space.kscience.controls.constructor
 
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.PropertyDescriptor
+import space.kscience.controls.manager.ClockManager
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.MutableDevicePropertySpec
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.context.request
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.names.Name
@@ -14,105 +21,154 @@ import kotlin.reflect.KProperty
 import kotlin.time.Duration
 
 /**
- * A base for strongly typed device constructor blocks. Has additional delegates for type-safe devices
+ * A base for strongly typed device constructor block. Has additional delegates for type-safe devices
  */
 public abstract class DeviceConstructor(
     context: Context,
-    meta: Meta,
+    meta: Meta = Meta.EMPTY,
 ) : DeviceGroup(context, meta) {
+    private val _bindings: MutableList<ConstructorBinding> = mutableListOf()
+    public val bindings: List<ConstructorBinding> get() = _bindings
+
+    public fun registerBinding(binding: ConstructorBinding) {
+        _bindings.add(binding)
+    }
+
+    override fun registerProperty(descriptor: PropertyDescriptor, state: DeviceState<*>) {
+        super.registerProperty(descriptor, state)
+        registerBinding(PropertyBinding(this, descriptor.name, state))
+    }
 
     /**
-     * Register a device, provided by a given [factory] and
+     * Create and register a timer. Timer is not counted as a device property.
      */
-    public fun <D : Device> device(
-        factory: Factory<D>,
-        meta: Meta? = null,
-        nameOverride: Name? = null,
-        metaLocation: Name? = null,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> =
-        PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> ->
-            val name = nameOverride ?: property.name.asName()
-            val device = install(name, factory, meta, metaLocation ?: name)
-            ReadOnlyProperty { _: DeviceConstructor, _ ->
-                device
-            }
+    public fun timer(tick: Duration): TimerState = TimerState(context.request(ClockManager), tick)
+        .also { registerBinding(StateBinding(it)) }
+
+    /**
+     * Launch action that is performed on each [DeviceState] value change.
+     *
+     * Optionally provide [writes] - a set of states that this change affects.
+     */
+    public fun <T> DeviceState<T>.onChange(
+        vararg writes: DeviceState<*>,
+        reads: Collection<DeviceState<*>>,
+        onChange: suspend (T) -> Unit,
+    ): Job = valueFlow.onEach(onChange).launchIn(this@DeviceConstructor).also {
+        registerBinding(ActionBinding(setOf(this, *reads.toTypedArray()), setOf(*writes)))
+    }
+}
+
+/**
+ * Register a device, provided by a given [factory] and
+ */
+public fun <D : Device> DeviceConstructor.device(
+    factory: Factory<D>,
+    meta: Meta? = null,
+    nameOverride: Name? = null,
+    metaLocation: Name? = null,
+): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> =
+    PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> ->
+        val name = nameOverride ?: property.name.asName()
+        val device = install(name, factory, meta, metaLocation ?: name)
+        ReadOnlyProperty { _: DeviceConstructor, _ ->
+            device
         }
+    }
 
-    public fun <D : Device> device(
-        device: D,
-        nameOverride: Name? = null,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> =
-        PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> ->
-            val name = nameOverride ?: property.name.asName()
-            install(name, device)
-            ReadOnlyProperty { _: DeviceConstructor, _ ->
-                device
-            }
+public fun <D : Device> DeviceConstructor.device(
+    device: D,
+    nameOverride: Name? = null,
+): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> =
+    PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> ->
+        val name = nameOverride ?: property.name.asName()
+        install(name, device)
+        ReadOnlyProperty { _: DeviceConstructor, _ ->
+            device
         }
+    }
 
-
-    /**
-     * Register a property and provide a direct reader for it
-     */
-    public fun <T, S: DeviceState<T>> property(
-        state: S,
-        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-        nameOverride: String? = null,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, S>> =
-        PropertyDelegateProvider { _: DeviceConstructor, property ->
-            val name = nameOverride ?: property.name
-            val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
-            registerProperty(descriptor, state)
-            ReadOnlyProperty { _: DeviceConstructor, _ ->
-                state
-            }
+/**
+ * Register a property and provide a direct reader for it
+ */
+public fun <T, S : DeviceState<T>> DeviceConstructor.property(
+    state: S,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    nameOverride: String? = null,
+): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, S>> =
+    PropertyDelegateProvider { _: DeviceConstructor, property ->
+        val name = nameOverride ?: property.name
+        val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
+        registerProperty(descriptor, state)
+        ReadOnlyProperty { _: DeviceConstructor, _ ->
+            state
         }
+    }
 
-    /**
-     * Register external state as a property
-     */
-    public fun <T : Any> property(
-        metaConverter: MetaConverter<T>,
-        reader: suspend () -> T,
-        readInterval: Duration,
-        initialState: T,
-        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-        nameOverride: String? = null,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, DeviceState<T>>> = property(
-        DeviceState.external(this, metaConverter, readInterval, initialState, reader),
-        descriptorBuilder,
-        nameOverride,
-    )
+/**
+ * Register external state as a property
+ */
+public fun <T : Any> DeviceConstructor.property(
+    metaConverter: MetaConverter<T>,
+    reader: suspend () -> T,
+    readInterval: Duration,
+    initialState: T,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    nameOverride: String? = null,
+): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, DeviceState<T>>> = property(
+    DeviceState.external(this, metaConverter, readInterval, initialState, reader),
+    descriptorBuilder,
+    nameOverride,
+)
 
-    /**
-     * Register a mutable external state as a property
-     */
-    public fun <T : Any> mutableProperty(
-        metaConverter: MetaConverter<T>,
-        reader: suspend () -> T,
-        writer: suspend (T) -> Unit,
-        readInterval: Duration,
-        initialState: T,
-        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-        nameOverride: String? = null,
-    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property(
-        DeviceState.external(this, metaConverter, readInterval, initialState, reader, writer),
-        descriptorBuilder,
-        nameOverride,
-    )
+/**
+ * Register a mutable external state as a property
+ */
+public fun <T : Any> DeviceConstructor.mutableProperty(
+    metaConverter: MetaConverter<T>,
+    reader: suspend () -> T,
+    writer: suspend (T) -> Unit,
+    readInterval: Duration,
+    initialState: T,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    nameOverride: String? = null,
+): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property(
+    DeviceState.external(this, metaConverter, readInterval, initialState, reader, writer),
+    descriptorBuilder,
+    nameOverride,
+)
 
-    /**
-     * Create and register a virtual mutable property with optional [callback]
-     */
-    public fun <T> virtualProperty(
-        metaConverter: MetaConverter<T>,
-        initialState: T,
-        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-        nameOverride: String? = null,
-        callback: (T) -> Unit = {},
-    ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property(
-        DeviceState.virtual(metaConverter, initialState, callback),
-        descriptorBuilder,
-        nameOverride,
-    )
-}
\ No newline at end of file
+/**
+ * Create and register a virtual mutable property with optional [callback]
+ */
+public fun <T> DeviceConstructor.virtualProperty(
+    metaConverter: MetaConverter<T>,
+    initialState: T,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    nameOverride: String? = null,
+    callback: (T) -> Unit = {},
+): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property(
+    DeviceState.internal(metaConverter, initialState, callback),
+    descriptorBuilder,
+    nameOverride,
+)
+
+/**
+ * Bind existing property provided by specification to this device
+ */
+public fun <T, D : Device> DeviceConstructor.deviceProperty(
+    device: D,
+    property: DevicePropertySpec<D, T>,
+    initialValue: T,
+): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, DeviceState<T>>> =
+    property(device.propertyAsState(property, initialValue))
+
+/**
+ * Bind existing property provided by specification to this device
+ */
+public fun <T, D : Device> DeviceConstructor.deviceProperty(
+    device: D,
+    property: MutableDevicePropertySpec<D, T>,
+    initialValue: T,
+): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> =
+    property(device.mutablePropertyAsState(property, initialValue))
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index ca05c27..6785b4b 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -9,9 +9,7 @@ import space.kscience.controls.api.*
 import space.kscience.controls.api.DeviceLifecycleState.*
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
-import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.context.Factory
-import space.kscience.dataforge.context.request
+import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.*
 import space.kscience.dataforge.misc.DFExperimental
 import space.kscience.dataforge.names.*
@@ -57,6 +55,7 @@ public open class DeviceGroup(
                             )
                         )
                     }
+                    logger.error(throwable) { "Exception in device $id" }
                 }
     )
 
@@ -69,7 +68,7 @@ public open class DeviceGroup(
      * Register and initialize (synchronize child's lifecycle state with group state) a new device in this group
      */
     @OptIn(DFExperimental::class)
-    public fun <D : Device> install(token: NameToken, device: D): D {
+    public open fun <D : Device> install(token: NameToken, device: D): D {
         require(_devices[token] == null) { "A child device with name $token already exists" }
         //start the child device if needed
         if (lifecycleState == STARTED || lifecycleState == STARTING) launch { device.start() }
@@ -82,7 +81,7 @@ public open class DeviceGroup(
     /**
      * Register a new property based on [DeviceState]. Properties could be modified dynamically
      */
-    public fun registerProperty(descriptor: PropertyDescriptor, state: DeviceState<*>) {
+    public open fun registerProperty(descriptor: PropertyDescriptor, state: DeviceState<*>) {
         val name = descriptor.name.parseAsName()
         require(properties[name] == null) { "Can't add property with name $name. It already exists." }
         properties[name] = Property(state, descriptor)
@@ -126,37 +125,33 @@ public open class DeviceGroup(
         return action.invoke(argument)
     }
 
-    @DFExperimental
-    override var lifecycleState: DeviceLifecycleState = STOPPED
-        protected set(value) {
-            if (field != value) {
-                launch {
-                    sharedMessageFlow.emit(
-                        DeviceLifeCycleMessage(value)
-                    )
-                }
-            }
-            field = value
-        }
+    final override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
+        private set
+
+
+    private suspend fun setLifecycleState(lifecycleState: DeviceLifecycleState) {
+        this.lifecycleState = lifecycleState
+        sharedMessageFlow.emit(
+            DeviceLifeCycleMessage(lifecycleState)
+        )
+    }
 
 
-    @OptIn(DFExperimental::class)
     override suspend fun start() {
-        lifecycleState = STARTING
+        setLifecycleState(STARTING)
         super.start()
         devices.values.forEach {
             it.start()
         }
-        lifecycleState = STARTED
+        setLifecycleState(STARTED)
     }
 
-    @OptIn(DFExperimental::class)
-    override fun stop() {
+    override suspend fun stop() {
         devices.values.forEach {
             it.stop()
         }
+        setLifecycleState(STOPPED)
         super.stop()
-        lifecycleState = STOPPED
     }
 
     public companion object {
@@ -210,13 +205,9 @@ public fun <D : Device> DeviceGroup.install(name: Name, device: D): D {
     }
 }
 
-public fun <D : Device> DeviceGroup.install(name: String, device: D): D =
-    install(name.parseAsName(), device)
+public fun <D : Device> DeviceGroup.install(name: String, device: D): D = install(name.parseAsName(), device)
 
-public fun <D : Device> DeviceGroup.install(device: D): D =
-    install(device.id, device)
-
-public fun <D : Device> Context.install(name: String, device: D): D = request(DeviceManager).install(name, device)
+public fun <D : Device> DeviceGroup.install(device: D): D = install(device.id, device)
 
 /**
  * Add a device creating intermediate groups if necessary. If device with given [name] already exists, throws an error.
@@ -292,7 +283,7 @@ public fun <T : Any> DeviceGroup.registerVirtualProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     callback: (T) -> Unit = {},
 ): MutableDeviceState<T> {
-    val state = DeviceState.virtual<T>(converter, initialValue, callback)
+    val state = DeviceState.internal<T>(converter, initialValue, callback)
     registerMutableProperty(name, state, descriptorBuilder)
     return state
 }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index faa7b2f..9a25f00 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -2,18 +2,13 @@ package space.kscience.controls.constructor
 
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.launch
-import space.kscience.controls.api.Device
-import space.kscience.controls.api.PropertyChangedMessage
-import space.kscience.controls.spec.DevicePropertySpec
-import space.kscience.controls.spec.MutableDevicePropertySpec
-import space.kscience.controls.spec.name
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.MetaConverter
 import kotlin.reflect.KProperty
-import kotlin.time.Duration
 
 /**
  * An observable state of a device
@@ -24,6 +19,8 @@ public interface DeviceState<T> {
 
     public val valueFlow: Flow<T>
 
+    override fun toString(): String
+
     public companion object
 }
 
@@ -36,7 +33,7 @@ public operator fun <T> DeviceState<T>.getValue(thisRef: Any?, property: KProper
 /**
  * Collect values in a given [scope]
  */
-public fun <T> DeviceState<T>.collectValuesIn(scope: CoroutineScope, block: suspend (T)->Unit): Job =
+public fun <T> DeviceState<T>.collectValuesIn(scope: CoroutineScope, block: suspend (T) -> Unit): Job =
     valueFlow.onEach(block).launchIn(scope)
 
 /**
@@ -57,186 +54,46 @@ public var <T> MutableDeviceState<T>.valueAsMeta: Meta
     }
 
 /**
- * A [MutableDeviceState] that does not correspond to a physical state
- *
- * @param callback a synchronous callback that could be used without a scope
+ * Device state with a value that depends on other device states
  */
-private class VirtualDeviceState<T>(
-    override val converter: MetaConverter<T>,
-    initialValue: T,
-    private val callback: (T) -> Unit = {},
-) : MutableDeviceState<T> {
-    private val flow = MutableStateFlow(initialValue)
-    override val valueFlow: Flow<T> get() = flow
-
-    override var value: T
-        get() = flow.value
-        set(value) {
-            flow.value = value
-            callback(value)
-        }
-}
-
-
-/**
- * A [MutableDeviceState] that does not correspond to a physical state
- *
- * @param callback a synchronous callback that could be used without a scope
- */
-public fun <T> DeviceState.Companion.virtual(
-    converter: MetaConverter<T>,
-    initialValue: T,
-    callback: (T) -> Unit = {},
-): MutableDeviceState<T> = VirtualDeviceState(converter, initialValue, callback)
-
-private class StateFlowAsState<T>(
-    override val converter: MetaConverter<T>,
-    val flow: MutableStateFlow<T>,
-) : MutableDeviceState<T> {
-    override var value: T by flow::value
-    override val valueFlow: Flow<T> get() = flow
-}
-
-public fun <T> MutableStateFlow<T>.asDeviceState(converter: MetaConverter<T>): DeviceState<T> =
-    StateFlowAsState(converter, this)
-
-
-private open class BoundDeviceState<T>(
-    override val converter: MetaConverter<T>,
-    val device: Device,
-    val propertyName: String,
-    initialValue: T,
-) : DeviceState<T> {
-
-    override val valueFlow: StateFlow<T> = device.messageFlow.filterIsInstance<PropertyChangedMessage>().filter {
-        it.property == propertyName
-    }.mapNotNull {
-        converter.read(it.value)
-    }.stateIn(device.context, SharingStarted.Eagerly, initialValue)
-
-    override val value: T get() = valueFlow.value
+public interface DeviceStateWithDependencies<T> : DeviceState<T> {
+    public val dependencies: Collection<DeviceState<*>>
 }
 
 /**
- * Bind a read-only [DeviceState] to a [Device] property
+ * Create a new read-only [DeviceState] that mirrors receiver state by mapping the value with [mapper].
  */
-public suspend fun <T> Device.propertyAsState(
-    propertyName: String,
-    metaConverter: MetaConverter<T>,
-): DeviceState<T> {
-    val initialValue = metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed")
-    return BoundDeviceState(metaConverter, this, propertyName, initialValue)
-}
-
-public suspend fun <D : Device, T> D.propertyAsState(
-    propertySpec: DevicePropertySpec<D, T>,
-): DeviceState<T> = propertyAsState(propertySpec.name, propertySpec.converter)
-
 public fun <T, R> DeviceState<T>.map(
     converter: MetaConverter<R>, mapper: (T) -> R,
-): DeviceState<R> = object : DeviceState<R> {
+): DeviceStateWithDependencies<R> = object : DeviceStateWithDependencies<R> {
+    override val dependencies = listOf(this)
+
     override val converter: MetaConverter<R> = converter
+
     override val value: R
         get() = mapper(this@map.value)
 
     override val valueFlow: Flow<R> = this@map.valueFlow.map(mapper)
-}
 
-private class MutableBoundDeviceState<T>(
-    converter: MetaConverter<T>,
-    device: Device,
-    propertyName: String,
-    initialValue: T,
-) : BoundDeviceState<T>(converter, device, propertyName, initialValue), MutableDeviceState<T> {
-
-    override var value: T
-        get() = valueFlow.value
-        set(newValue) {
-            device.launch {
-                device.writeProperty(propertyName, converter.convert(newValue))
-            }
-        }
-}
-
-public fun <T> Device.mutablePropertyAsState(
-    propertyName: String,
-    metaConverter: MetaConverter<T>,
-    initialValue: T,
-): MutableDeviceState<T> = MutableBoundDeviceState(metaConverter, this, propertyName, initialValue)
-
-public suspend fun <T> Device.mutablePropertyAsState(
-    propertyName: String,
-    metaConverter: MetaConverter<T>,
-): MutableDeviceState<T> {
-    val initialValue = metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed")
-    return mutablePropertyAsState(propertyName, metaConverter, initialValue)
-}
-
-public suspend fun <D : Device, T> D.mutablePropertyAsState(
-    propertySpec: MutableDevicePropertySpec<D, T>,
-): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter)
-
-public fun <D : Device, T> D.mutablePropertyAsState(
-    propertySpec: MutableDevicePropertySpec<D, T>,
-    initialValue: T,
-): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue)
-
-
-private open class ExternalState<T>(
-    val scope: CoroutineScope,
-    override val converter: MetaConverter<T>,
-    val readInterval: Duration,
-    initialValue: T,
-    val reader: suspend () -> T,
-) : DeviceState<T> {
-
-    protected val flow: StateFlow<T> = flow {
-        while (true) {
-            delay(readInterval)
-            emit(reader())
-        }
-    }.stateIn(scope, SharingStarted.Eagerly, initialValue)
-
-    override val value: T get() = flow.value
-    override val valueFlow: Flow<T> get() = flow
+    override fun toString(): String = "DeviceState.map(arg=${this@map}, converter=$converter)"
 }
 
 /**
- * Create a [DeviceState] which is constructed by periodically reading external value
+ * Combine two device states into one read-only [DeviceState]. Only the latest value of each state is used.
  */
-public fun <T> DeviceState.Companion.external(
-    scope: CoroutineScope,
-    converter: MetaConverter<T>,
-    readInterval: Duration,
-    initialValue: T,
-    reader: suspend () -> T,
-): DeviceState<T> = ExternalState(scope, converter, readInterval, initialValue, reader)
+public fun <T1, T2, R> combine(
+    state1: DeviceState<T1>,
+    state2: DeviceState<T2>,
+    converter: MetaConverter<R>,
+    mapper: (T1, T2) -> R,
+): DeviceStateWithDependencies<R> = object : DeviceStateWithDependencies<R> {
+    override val dependencies = listOf(state1, state2)
 
-private class MutableExternalState<T>(
-    scope: CoroutineScope,
-    converter: MetaConverter<T>,
-    readInterval: Duration,
-    initialValue: T,
-    reader: suspend () -> T,
-    val writer: suspend (T) -> Unit,
-) : ExternalState<T>(scope, converter, readInterval, initialValue, reader), MutableDeviceState<T> {
-    override var value: T
-        get() = super.value
-        set(value) {
-            scope.launch {
-                writer(value)
-            }
-        }
+    override val converter: MetaConverter<R> = converter
+
+    override val value: R get() = mapper(state1.value, state2.value)
+
+    override val valueFlow: Flow<R> = kotlinx.coroutines.flow.combine(state1.valueFlow, state2.valueFlow, mapper)
+
+    override fun toString(): String = "DeviceState.combine(state1=$state1, state2=$state2)"
 }
-
-/**
- * Create a [DeviceState] that regularly reads and caches an external value
- */
-public fun <T> DeviceState.Companion.external(
-    scope: CoroutineScope,
-    converter: MetaConverter<T>,
-    readInterval: Duration,
-    initialValue: T,
-    reader: suspend () -> T,
-    writer: suspend (T) -> Unit,
-): MutableDeviceState<T> = MutableExternalState(scope, converter, readInterval, initialValue, reader, writer)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
new file mode 100644
index 0000000..7b10cb9
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
@@ -0,0 +1,41 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Instant
+import space.kscience.controls.manager.ClockManager
+import space.kscience.controls.spec.instant
+import space.kscience.dataforge.meta.MetaConverter
+import kotlin.time.Duration
+
+/**
+ * A dedicated [DeviceState] that operates with time.
+ * The state changes with [tick] interval and always shows the time of the last update.
+ *
+ * Both [tick] and current time are computed by [clockManager] enabling time manipulation.
+ *
+ * The timer runs indefinitely until the parent context is closed
+ */
+public class TimerState(
+    public val clockManager: ClockManager,
+    public val tick: Duration,
+) : DeviceState<Instant> {
+    override val converter: MetaConverter<Instant> get() = MetaConverter.instant
+
+    private val clock = MutableStateFlow(clockManager.clock.now())
+
+    private val updateJob = clockManager.context.launch {
+        while (isActive) {
+            clockManager.delay(tick)
+            clock.value = clockManager.clock.now()
+        }
+    }
+
+    override val valueFlow: Flow<Instant> get() = clock
+
+    override val value: Instant get() = clock.value
+
+    override fun toString(): String = "TimerState(tick=$tick)"
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt
new file mode 100644
index 0000000..37be770
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt
@@ -0,0 +1,103 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import space.kscience.controls.api.Device
+import space.kscience.controls.api.PropertyChangedMessage
+import space.kscience.controls.api.id
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.MutableDevicePropertySpec
+import space.kscience.controls.spec.name
+import space.kscience.dataforge.meta.MetaConverter
+
+
+/**
+ * A copy-free [DeviceState] bound to a device property
+ */
+private open class BoundDeviceState<T>(
+    override val converter: MetaConverter<T>,
+    val device: Device,
+    val propertyName: String,
+    initialValue: T,
+) : DeviceState<T> {
+
+    override val valueFlow: StateFlow<T> = device.messageFlow.filterIsInstance<PropertyChangedMessage>().filter {
+        it.property == propertyName
+    }.mapNotNull {
+        converter.read(it.value)
+    }.stateIn(device.context, SharingStarted.Eagerly, initialValue)
+
+    override val value: T get() = valueFlow.value
+    override fun toString(): String =
+        "BoundDeviceState(converter=$converter, device=${device.id}, propertyName='$propertyName')"
+
+
+}
+
+public fun <T> Device.propertyAsState(
+    propertyName: String,
+    metaConverter: MetaConverter<T>,
+    initialValue: T,
+): DeviceState<T> = BoundDeviceState(metaConverter, this, propertyName, initialValue)
+
+/**
+ * Bind a read-only [DeviceState] to a [Device] property
+ */
+public suspend fun <T> Device.propertyAsState(
+    propertyName: String,
+    metaConverter: MetaConverter<T>,
+): DeviceState<T> = propertyAsState(
+    propertyName,
+    metaConverter,
+    metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed")
+)
+
+public suspend fun <D : Device, T> D.propertyAsState(
+    propertySpec: DevicePropertySpec<D, T>,
+): DeviceState<T> = propertyAsState(propertySpec.name, propertySpec.converter)
+
+public fun <D : Device, T> D.propertyAsState(
+    propertySpec: DevicePropertySpec<D, T>,
+    initialValue: T,
+): DeviceState<T> = propertyAsState(propertySpec.name, propertySpec.converter, initialValue)
+
+
+private class MutableBoundDeviceState<T>(
+    converter: MetaConverter<T>,
+    device: Device,
+    propertyName: String,
+    initialValue: T,
+) : BoundDeviceState<T>(converter, device, propertyName, initialValue), MutableDeviceState<T> {
+
+    override var value: T
+        get() = valueFlow.value
+        set(newValue) {
+            device.launch {
+                device.writeProperty(propertyName, converter.convert(newValue))
+            }
+        }
+}
+
+public fun <T> Device.mutablePropertyAsState(
+    propertyName: String,
+    metaConverter: MetaConverter<T>,
+    initialValue: T,
+): MutableDeviceState<T> = MutableBoundDeviceState(metaConverter, this, propertyName, initialValue)
+
+public suspend fun <T> Device.mutablePropertyAsState(
+    propertyName: String,
+    metaConverter: MetaConverter<T>,
+): MutableDeviceState<T> {
+    val initialValue = metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed")
+    return mutablePropertyAsState(propertyName, metaConverter, initialValue)
+}
+
+public suspend fun <D : Device, T> D.mutablePropertyAsState(
+    propertySpec: MutableDevicePropertySpec<D, T>,
+): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter)
+
+public fun <D : Device, T> D.mutablePropertyAsState(
+    propertySpec: MutableDevicePropertySpec<D, T>,
+    initialValue: T,
+): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue)
+
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
index 100f755..881bdea 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
@@ -38,6 +38,10 @@ public class DoubleRangeState(
      * A state showing that the range is on its higher boundary
      */
     public val atEndState: DeviceState<Boolean> = map(MetaConverter.boolean) { it >= range.endInclusive }
+
+    override fun toString(): String = "DoubleRangeState(range=$range, converter=$converter)"
+
+
 }
 
 @Suppress("UnusedReceiverParameter")
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt
new file mode 100644
index 0000000..c670a23
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt
@@ -0,0 +1,70 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import space.kscience.dataforge.meta.MetaConverter
+import kotlin.time.Duration
+
+
+private open class ExternalState<T>(
+    val scope: CoroutineScope,
+    override val converter: MetaConverter<T>,
+    val readInterval: Duration,
+    initialValue: T,
+    val reader: suspend () -> T,
+) : DeviceState<T> {
+
+    protected val flow: StateFlow<T> = flow {
+        while (true) {
+            delay(readInterval)
+            emit(reader())
+        }
+    }.stateIn(scope, SharingStarted.Eagerly, initialValue)
+
+    override val value: T get() = flow.value
+    override val valueFlow: Flow<T> get() = flow
+
+    override fun toString(): String  = "ExternalState(converter=$converter)"
+}
+
+/**
+ * Create a [DeviceState] which is constructed by regularly reading external value
+ */
+public fun <T> DeviceState.Companion.external(
+    scope: CoroutineScope,
+    converter: MetaConverter<T>,
+    readInterval: Duration,
+    initialValue: T,
+    reader: suspend () -> T,
+): DeviceState<T> = ExternalState(scope, converter, readInterval, initialValue, reader)
+
+private class MutableExternalState<T>(
+    scope: CoroutineScope,
+    converter: MetaConverter<T>,
+    readInterval: Duration,
+    initialValue: T,
+    reader: suspend () -> T,
+    val writer: suspend (T) -> Unit,
+) : ExternalState<T>(scope, converter, readInterval, initialValue, reader), MutableDeviceState<T> {
+    override var value: T
+        get() = super.value
+        set(value) {
+            scope.launch {
+                writer(value)
+            }
+        }
+}
+
+/**
+ * Create a [MutableDeviceState] which is constructed by regularly reading external value and allows writing
+ */
+public fun <T> DeviceState.Companion.external(
+    scope: CoroutineScope,
+    converter: MetaConverter<T>,
+    readInterval: Duration,
+    initialValue: T,
+    reader: suspend () -> T,
+    writer: suspend (T) -> Unit,
+): MutableDeviceState<T> = MutableExternalState(scope, converter, readInterval, initialValue, reader, writer)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt
new file mode 100644
index 0000000..434c074
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt
@@ -0,0 +1,23 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import space.kscience.dataforge.meta.MetaConverter
+
+
+private class StateFlowAsState<T>(
+    override val converter: MetaConverter<T>,
+    val flow: MutableStateFlow<T>,
+) : MutableDeviceState<T> {
+    override var value: T by flow::value
+    override val valueFlow: Flow<T> get() = flow
+
+    override fun toString(): String = "FlowAsState(converter=$converter)"
+}
+
+/**
+ * Create a read-only [DeviceState] that wraps [MutableStateFlow].
+ * No data copy is performed.
+ */
+public fun <T> MutableStateFlow<T>.asDeviceState(converter: MetaConverter<T>): DeviceState<T> =
+    StateFlowAsState(converter, this)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
new file mode 100644
index 0000000..6f9d086
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
@@ -0,0 +1,42 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import space.kscience.dataforge.meta.MetaConverter
+
+/**
+ * A [MutableDeviceState] that does not correspond to a physical state
+ *
+ * @param callback a synchronous callback that could be used without a scope
+ */
+private class VirtualDeviceState<T>(
+    override val converter: MetaConverter<T>,
+    initialValue: T,
+    private val callback: (T) -> Unit = {},
+) : MutableDeviceState<T> {
+    private val flow = MutableStateFlow(initialValue)
+    override val valueFlow: Flow<T> get() = flow
+
+    override var value: T
+        get() = flow.value
+        set(value) {
+            flow.value = value
+            callback(value)
+        }
+
+    override fun toString(): String = "VirtualDeviceState(converter=$converter)"
+
+
+}
+
+
+/**
+ * A [MutableDeviceState] that does not correspond to a physical state
+ *
+ * @param callback a synchronous callback that could be used without a scope
+ */
+public fun <T> DeviceState.Companion.internal(
+    converter: MetaConverter<T>,
+    initialValue: T,
+    callback: (T) -> Unit = {},
+): MutableDeviceState<T> = VirtualDeviceState(converter, initialValue, callback)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
similarity index 91%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
index 8301622..5678b34 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
@@ -1,10 +1,12 @@
-package space.kscience.controls.constructor
+package space.kscience.controls.constructor.library
 
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import space.kscience.controls.api.Device
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.mutablePropertyAsState
 import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.Context
@@ -49,7 +51,7 @@ public class VirtualDrive(
     public val positionState: MutableDeviceState<Double>,
 ) : Drive, DeviceBySpec<Drive>(Drive, context) {
 
-    private val dt = meta["time.step"].double?.milliseconds ?: 1.milliseconds
+    private val dt = meta["time.step"].double?.milliseconds ?: 5.milliseconds
     private val clock = context.clock
 
     override var force: Double = 0.0
@@ -82,7 +84,7 @@ public class VirtualDrive(
         }
     }
 
-    override fun onStop() {
+    override suspend fun onStop() {
         updateJob?.cancel()
     }
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
similarity index 83%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
index 977930d..895cee8 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
@@ -1,8 +1,9 @@
-package space.kscience.controls.constructor
+package space.kscience.controls.constructor.library
 
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import space.kscience.controls.api.Device
+import space.kscience.controls.constructor.DeviceState
 import space.kscience.controls.spec.DeviceBySpec
 import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.DeviceSpec
@@ -20,7 +21,7 @@ public interface LimitSwitch : Device {
 
     public companion object : DeviceSpec<LimitSwitch>() {
         public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { locked }
-        public fun factory(lockedState: DeviceState<Boolean>): Factory<LimitSwitch> = Factory { context, _ ->
+        public operator fun invoke(lockedState: DeviceState<Boolean>): Factory<LimitSwitch> = Factory { context, _ ->
             VirtualLimitSwitch(context, lockedState)
         }
     }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
similarity index 83%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
index d782e05..7ce2054 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
@@ -1,4 +1,4 @@
-package space.kscience.controls.constructor
+package space.kscience.controls.constructor.library
 
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
@@ -7,8 +7,11 @@ import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.datetime.Instant
+import space.kscience.controls.constructor.DeviceGroup
+import space.kscience.controls.constructor.install
 import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.DeviceBySpec
+import space.kscience.controls.spec.write
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.DurationUnit
@@ -71,15 +74,17 @@ public class PidRegulator(
                     lastTime = realTime
                     lastPosition = drive.position
 
-                    drive.force = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative
+                    drive.write(Drive.force,pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative)
+                    //drive.force = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative
                     propertyChanged(Regulator.position, drive.position)
                 }
             }
         }
     }
 
-    override fun onStop() {
+    override suspend fun onStop() {
         updateJob?.cancel()
+        drive.stop()
     }
 
     override val position: Double get() = drive.position
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
similarity index 92%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
index cbe6967..adf319b 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
@@ -1,4 +1,4 @@
-package space.kscience.controls.constructor
+package space.kscience.controls.constructor.library
 
 import space.kscience.controls.api.Device
 import space.kscience.controls.spec.*
diff --git a/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt
new file mode 100644
index 0000000..40f00dc
--- /dev/null
+++ b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt
@@ -0,0 +1,43 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import space.kscience.controls.api.Device
+import space.kscience.controls.api.DeviceLifeCycleMessage
+import space.kscience.controls.api.DeviceLifecycleState
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.controls.manager.install
+import space.kscience.controls.spec.doRecurring
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.context.Global
+import space.kscience.dataforge.context.request
+import space.kscience.dataforge.meta.Meta
+import kotlin.test.Test
+import kotlin.time.Duration.Companion.milliseconds
+
+class DeviceGroupTest {
+
+    class TestDevice(context: Context) : DeviceConstructor(context) {
+
+        companion object : Factory<Device> {
+            override fun build(context: Context, meta: Meta): Device = TestDevice(context)
+        }
+    }
+
+    @Test
+    fun testRecurringRead() = runTest {
+        var counter = 10
+        val testDevice = Global.request(DeviceManager).install("test", TestDevice)
+        testDevice.doRecurring(1.milliseconds) {
+            counter--
+            println(counter)
+            if (counter <= 0) {
+                testDevice.stop()
+            }
+            error("Error!")
+        }
+        testDevice.messageFlow.first { it is DeviceLifeCycleMessage && it.state == DeviceLifecycleState.STOPPED }
+        println("stopped")
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt
new file mode 100644
index 0000000..323f8df
--- /dev/null
+++ b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt
@@ -0,0 +1,22 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.test.runTest
+import space.kscience.controls.manager.ClockManager
+import space.kscience.dataforge.context.Global
+import space.kscience.dataforge.context.request
+import kotlin.test.Test
+import kotlin.time.Duration.Companion.milliseconds
+
+class TimerTest {
+
+    @Test
+    fun timer() = runTest {
+        val timer = TimerState(Global.request(ClockManager), 10.milliseconds)
+        timer.valueFlow.take(10).onEach {
+            println(it)
+        }.collect()
+    }
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index 3226422..fc68bf6 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -12,7 +12,6 @@ import space.kscience.dataforge.context.logger
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.meta.string
-import space.kscience.dataforge.misc.DFExperimental
 import space.kscience.dataforge.misc.DfType
 import space.kscience.dataforge.names.parseAsName
 
@@ -100,12 +99,11 @@ public interface Device : ContextAware, CoroutineScope {
     /**
      * Close and terminate the device. This function does not wait for the device to be closed.
      */
-    public fun stop() {
+    public suspend fun stop() {
         logger.info { "Device $this is closed" }
         cancel("The device is closed")
     }
 
-    @DFExperimental
     public val lifecycleState: DeviceLifecycleState
 
     public companion object {
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt
index 2adb89a..f3ffa93 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt
@@ -15,18 +15,23 @@ public class PropertyDescriptor(
     public var description: String? = null,
     public var metaDescriptor: MetaDescriptor = MetaDescriptor(),
     public var readable: Boolean = true,
-    public var mutable: Boolean = false
+    public var mutable: Boolean = false,
 )
 
-public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Unit){
-    metaDescriptor = MetaDescriptor(block)
+public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.() -> Unit) {
+    metaDescriptor = MetaDescriptor {
+        from(metaDescriptor)
+        block()
+    }
 }
 
 /**
  * A descriptor for property
  */
 @Serializable
-public class ActionDescriptor(public val name: String) {
-    public var description: String? = null
-}
-
+public class ActionDescriptor(
+    public val name: String,
+    public var description: String? = null,
+    public var inputMetaDescriptor: MetaDescriptor = MetaDescriptor(),
+    public var outputMetaDescriptor: MetaDescriptor = MetaDescriptor()
+)
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
index ca69b91..2f852e1 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
@@ -6,15 +6,21 @@ import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.PluginFactory
 import space.kscience.dataforge.context.PluginTag
 import space.kscience.dataforge.meta.Meta
+import kotlin.time.Duration
 
 public class ClockManager : AbstractPlugin() {
-    override val tag: PluginTag get() = DeviceManager.tag
+    override val tag: PluginTag get() = Companion.tag
 
     public val clock: Clock by lazy {
         //TODO add clock customization
         Clock.System
     }
 
+    public suspend fun delay(duration: Duration) {
+        //TODO add time compression
+        kotlinx.coroutines.delay(duration)
+    }
+
     public companion object : PluginFactory<ClockManager> {
         override val tag: PluginTag = PluginTag("clock", group = PluginTag.DATAFORGE_GROUP)
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
index c8c7ffc..93b0696 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
@@ -49,6 +49,10 @@ public fun <D : Device> DeviceManager.install(name: String, device: D): D {
 public fun <D : Device> DeviceManager.install(device: D): D = install(device.id, device)
 
 
+public fun <D : Device> Context.install(name: String, device: D): D = request(DeviceManager).install(name, device)
+
+public fun <D : Device> Context.install(device: D): D = request(DeviceManager).install(device.id, device)
+
 /**
  * Register and start a device built by [factory] with current [Context] and [meta].
  */
@@ -75,5 +79,4 @@ public inline fun <D : Device> DeviceManager.installing(
             current as D
         }
     }
-}
-
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index e8b9210..941eda9 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -9,11 +9,11 @@ import kotlinx.coroutines.sync.withLock
 import space.kscience.controls.api.*
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.debug
+import space.kscience.dataforge.context.error
 import space.kscience.dataforge.context.logger
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.meta.int
-import space.kscience.dataforge.misc.DFExperimental
 import kotlin.coroutines.CoroutineContext
 
 /**
@@ -72,10 +72,10 @@ public abstract class DeviceBase<D : Device>(
         onBufferOverflow = BufferOverflow.DROP_OLDEST
     )
 
-    @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+    @OptIn(ExperimentalCoroutinesApi::class)
     override val coroutineContext: CoroutineContext = context.newCoroutineContext(
         SupervisorJob(context.coroutineContext[Job]) +
-                CoroutineName("Device $this") +
+                CoroutineName("Device $id") +
                 CoroutineExceptionHandler { _, throwable ->
                     launch {
                         sharedMessageFlow.emit(
@@ -86,6 +86,7 @@ public abstract class DeviceBase<D : Device>(
                             )
                         )
                     }
+                    logger.error(throwable) { "Exception in device $id" }
                 }
     )
 
@@ -187,43 +188,39 @@ public abstract class DeviceBase<D : Device>(
         return spec.executeWithMeta(self, argument ?: Meta.EMPTY)
     }
 
-    @DFExperimental
     final override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
-        private set(value) {
-            if (field != value) {
-                launch {
-                    sharedMessageFlow.emit(
-                        DeviceLifeCycleMessage(value)
-                    )
-                }
-            }
-            field = value
-        }
+        private set
+
+
+    private suspend fun setLifecycleState(lifecycleState: DeviceLifecycleState) {
+        this.lifecycleState = lifecycleState
+        sharedMessageFlow.emit(
+            DeviceLifeCycleMessage(lifecycleState)
+        )
+    }
 
     protected open suspend fun onStart() {
 
     }
 
-    @OptIn(DFExperimental::class)
     final override suspend fun start() {
         if (lifecycleState == DeviceLifecycleState.STOPPED) {
             super.start()
-            lifecycleState = DeviceLifecycleState.STARTING
+            setLifecycleState(DeviceLifecycleState.STARTING)
             onStart()
-            lifecycleState = DeviceLifecycleState.STARTED
+            setLifecycleState(DeviceLifecycleState.STARTED)
         } else {
             logger.debug { "Device $this is already started" }
         }
     }
 
-    protected open fun onStop() {
+    protected open suspend fun onStop() {
 
     }
 
-    @OptIn(DFExperimental::class)
-    final override fun stop() {
+    final override suspend fun stop() {
         onStop()
-        lifecycleState = DeviceLifecycleState.STOPPED
+        setLifecycleState(DeviceLifecycleState.STOPPED)
         super.stop()
     }
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt
index 31f4c09..f639fc7 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt
@@ -20,7 +20,7 @@ public open class DeviceBySpec<D : Device>(
         self.onOpen()
     }
 
-    override fun onStop(): Unit = with(spec){
+    override suspend fun onStop(): Unit = with(spec){
         self.onClose()
     }
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index 2f90cb8..07d831f 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -4,8 +4,10 @@ import kotlinx.coroutines.withContext
 import space.kscience.controls.api.ActionDescriptor
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.PropertyDescriptor
+import space.kscience.controls.api.metaDescriptor
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.MetaConverter
+import space.kscience.dataforge.meta.descriptors.MetaDescriptor
 import kotlin.properties.PropertyDelegateProvider
 import kotlin.properties.ReadOnlyProperty
 import kotlin.reflect.KProperty
@@ -54,6 +56,11 @@ public abstract class DeviceSpec<D : Device> {
             val deviceProperty = object : DevicePropertySpec<D, T> {
 
                 override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
+                    converter.descriptor?.let { converterDescriptor ->
+                        metaDescriptor {
+                            from(converterDescriptor)
+                        }
+                    }
                     fromSpec(property)
                     descriptorBuilder()
                 }
@@ -83,6 +90,11 @@ public abstract class DeviceSpec<D : Device> {
                     propertyName,
                     mutable = true
                 ).apply {
+                    converter.descriptor?.let { converterDescriptor ->
+                        metaDescriptor {
+                            from(converterDescriptor)
+                        }
+                    }
                     fromSpec(property)
                     descriptorBuilder()
                 }
@@ -118,6 +130,19 @@ public abstract class DeviceSpec<D : Device> {
             val actionName = name ?: property.name
             val deviceAction = object : DeviceActionSpec<D, I, O> {
                 override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply {
+                    inputConverter.descriptor?.let { converterDescriptor ->
+                        inputMetaDescriptor = MetaDescriptor {
+                            from(converterDescriptor)
+                            from(inputMetaDescriptor)
+                        }
+                    }
+                    outputConverter.descriptor?.let { converterDescriptor ->
+                        outputMetaDescriptor = MetaDescriptor {
+                            from(converterDescriptor)
+                            from(outputMetaDescriptor)
+                        }
+                    }
+
                     fromSpec(property)
                     descriptorBuilder()
                 }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/converters.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/converters.kt
new file mode 100644
index 0000000..89a28da
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/converters.kt
@@ -0,0 +1,41 @@
+package space.kscience.controls.spec
+
+import kotlinx.datetime.Instant
+import space.kscience.dataforge.meta.*
+import kotlin.time.Duration
+import kotlin.time.DurationUnit
+import kotlin.time.toDuration
+
+public fun Double.asMeta(): Meta = Meta(asValue())
+
+/**
+ * Generate a nullable [MetaConverter] from non-nullable one
+ */
+public fun <T : Any> MetaConverter<T>.nullable(): MetaConverter<T?> = object : MetaConverter<T?> {
+    override fun convert(obj: T?): Meta  = obj?.let { this@nullable.convert(it) }?: Meta(Null)
+
+    override fun readOrNull(source: Meta): T? = if(source.value == Null) null else this@nullable.readOrNull(source)
+
+}
+
+//TODO to be moved to DF
+private object DurationConverter : MetaConverter<Duration> {
+    override fun readOrNull(source: Meta): Duration = source.value?.double?.toDuration(DurationUnit.SECONDS)
+        ?: run {
+            val unit: DurationUnit = source["unit"].enum<DurationUnit>() ?: DurationUnit.SECONDS
+            val value = source[Meta.VALUE_KEY].double ?: error("No value present for Duration")
+            return@run value.toDuration(unit)
+        }
+
+    override fun convert(obj: Duration): Meta = obj.toDouble(DurationUnit.SECONDS).asMeta()
+}
+
+public val MetaConverter.Companion.duration: MetaConverter<Duration> get() = DurationConverter
+
+
+private object InstantConverter : MetaConverter<Instant> {
+    override fun readOrNull(source: Meta): Instant? = source.string?.let { Instant.parse(it) }
+    override fun convert(obj: Instant): Meta = Meta(obj.toString())
+}
+
+public val MetaConverter.Companion.instant: MetaConverter<Instant> get() = InstantConverter
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt
index fefb65e..ee47ea0 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt
@@ -1,14 +1,31 @@
 package space.kscience.controls.spec
 
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
+import kotlinx.coroutines.*
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flow
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
 import space.kscience.controls.api.Device
 import kotlin.time.Duration
 
+/**
+ * Do a recurring (with a fixed delay) task on a device.
+ */
+public fun <D : Device> D.doRecurring(
+    interval: Duration,
+    debugTaskName: String? = null,
+    task: suspend D.() -> Unit,
+): Job {
+    val taskName = debugTaskName ?: "task[${task.hashCode().toString(16)}]"
+    return launch(CoroutineName(taskName)) {
+        while (isActive) {
+            delay(interval)
+            //launch in parent scope to properly evaluate exceptions
+            this@doRecurring.launch {
+                task()
+            }
+        }
+    }
+}
+
 /**
  * Perform a recurring asynchronous read action and return a flow of results.
  * The flow is lazy, so action is not performed unless flow is consumed.
@@ -16,23 +33,12 @@ import kotlin.time.Duration
  *
  * The flow is canceled when the device scope is canceled
  */
-public fun <D : Device, R> D.readRecurring(interval: Duration, reader: suspend D.() -> R): Flow<R> = flow {
-    while (isActive) {
-        delay(interval)
-        launch {
-            emit(reader())
-        }
-    }
-}
-
-/**
- * Do a recurring (with a fixed delay) task on a device.
- */
-public fun <D : Device> D.doRecurring(interval: Duration, task: suspend D.() -> Unit): Job = launch {
-    while (isActive) {
-        delay(interval)
-        launch {
-            task()
-        }
+public fun <D : Device, R> D.readRecurring(
+    interval: Duration,
+    debugTaskName: String? = null,
+    reader: suspend D.() -> R,
+): Flow<R> = flow {
+    doRecurring(interval, debugTaskName) {
+        emit(reader())
     }
 }
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt
index 000458e..ad5862b 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt
@@ -4,8 +4,6 @@ import space.kscience.controls.api.ActionDescriptor
 import space.kscience.controls.api.PropertyDescriptor
 import kotlin.reflect.KProperty
 
-@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FIELD)
-public annotation class Description(val content: String)
 
 internal expect fun PropertyDescriptor.fromSpec(property: KProperty<*>)
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt
deleted file mode 100644
index 1fc4649..0000000
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package space.kscience.controls.spec
-
-import space.kscience.dataforge.meta.*
-import kotlin.time.Duration
-import kotlin.time.DurationUnit
-import kotlin.time.toDuration
-
-public fun Double.asMeta(): Meta = Meta(asValue())
-
-//TODO to be moved to DF
-public object DurationConverter : MetaConverter<Duration> {
-    override fun readOrNull(source: Meta): Duration = source.value?.double?.toDuration(DurationUnit.SECONDS)
-        ?: run {
-            val unit: DurationUnit = source["unit"].enum<DurationUnit>() ?: DurationUnit.SECONDS
-            val value = source[Meta.VALUE_KEY].double ?: error("No value present for Duration")
-            return@run value.toDuration(unit)
-        }
-
-    override fun convert(obj: Duration): Meta = obj.toDouble(DurationUnit.SECONDS).asMeta()
-}
-
-public val MetaConverter.Companion.duration: MetaConverter<Duration> get() = DurationConverter
\ No newline at end of file
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt
index 7ae572f..cb64fc3 100644
--- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt
@@ -2,17 +2,18 @@ package space.kscience.controls.spec
 
 import space.kscience.controls.api.ActionDescriptor
 import space.kscience.controls.api.PropertyDescriptor
+import space.kscience.dataforge.descriptors.Description
 import kotlin.reflect.KProperty
 import kotlin.reflect.full.findAnnotation
 
 internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) {
     property.findAnnotation<Description>()?.let {
-        description = it.content
+        description = it.value
     }
 }
 
 internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){
     property.findAnnotation<Description>()?.let {
-        description = it.content
+        description = it.value
     }
 }
\ No newline at end of file
diff --git a/controls-modbus/build.gradle.kts b/controls-modbus/build.gradle.kts
index 2f280b6..8ef9ca8 100644
--- a/controls-modbus/build.gradle.kts
+++ b/controls-modbus/build.gradle.kts
@@ -1,7 +1,7 @@
 import space.kscience.gradle.Maturity
 
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
 }
 
@@ -9,10 +9,12 @@ description = """
     A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols
 """.trimIndent()
 
-
-dependencies {
-    api(projects.controlsCore)
-    api(libs.j2mod)
+kscience {
+    jvm()
+    jvmMain {
+        api(projects.controlsCore)
+        api(libs.j2mod)
+    }
 }
 
 readme{
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
similarity index 100%
rename from controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
rename to controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
similarity index 100%
rename from controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
rename to controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
similarity index 97%
rename from controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
rename to controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
index 995e0df..e0bd47a 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
+++ b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
@@ -24,7 +24,7 @@ public open class ModbusDeviceBySpec<D: Device>(
         master.connect()
     }
 
-    override fun onStop() {
+    override suspend fun onStop() {
         if(disposeMasterOnClose){
             master.disconnect()
         }
diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
similarity index 100%
rename from controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
rename to controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt
index 7debc0e..ea85719 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt
@@ -63,7 +63,7 @@ public open class OpcUaDeviceBySpec<D : Device>(
         }
     }
 
-    override fun onStop() {
+    override suspend fun onStop() {
         client.disconnect()
     }
 }
diff --git a/controls-pi/build.gradle.kts b/controls-pi/build.gradle.kts
index 997fcf2..850173d 100644
--- a/controls-pi/build.gradle.kts
+++ b/controls-pi/build.gradle.kts
@@ -1,5 +1,5 @@
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
 }
 
@@ -7,10 +7,15 @@ description = """
     Utils to work with controls-kt on Raspberry pi
 """.trimIndent()
 
-dependencies{
-    api(project(":controls-core"))
-    api(libs.pi4j.ktx) // Kotlin DSL
-    api(libs.pi4j.core)
-    api(libs.pi4j.plugin.raspberrypi)
-    api(libs.pi4j.plugin.pigpio)
+kscience {
+    jvm()
+
+
+    jvmMain {
+        api(project(":controls-core"))
+        api(libs.pi4j.ktx) // Kotlin DSL
+        api(libs.pi4j.core)
+        api(libs.pi4j.plugin.raspberrypi)
+        api(libs.pi4j.plugin.pigpio)
+    }
 }
\ No newline at end of file
diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
similarity index 100%
rename from controls-pi/src/main/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
rename to controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/PiPlugin.kt
similarity index 100%
rename from controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt
rename to controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/PiPlugin.kt
diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
similarity index 100%
rename from controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
rename to controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
diff --git a/controls-ports-ktor/build.gradle.kts b/controls-ports-ktor/build.gradle.kts
index 1f1efda..c225097 100644
--- a/controls-ports-ktor/build.gradle.kts
+++ b/controls-ports-ktor/build.gradle.kts
@@ -1,7 +1,7 @@
 import space.kscience.gradle.Maturity
 
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
 }
 
@@ -9,9 +9,12 @@ description = """
     Implementation of byte ports on top os ktor-io asynchronous API
 """.trimIndent()
 
-dependencies {
-    api(projects.controlsCore)
-    api(spclibs.ktor.network)
+kscience {
+    jvm()
+    jvmMain {
+        api(projects.controlsCore)
+        api(spclibs.ktor.network)
+    }
 }
 
 readme{
diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt
similarity index 100%
rename from controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt
rename to controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt
diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
similarity index 100%
rename from controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
rename to controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
similarity index 100%
rename from controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
rename to controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
diff --git a/controls-serial/build.gradle.kts b/controls-serial/build.gradle.kts
index d44a029..75bf017 100644
--- a/controls-serial/build.gradle.kts
+++ b/controls-serial/build.gradle.kts
@@ -1,15 +1,18 @@
 import space.kscience.gradle.Maturity
 
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
 }
 
 description = "Implementation of direct serial port communication with JSerialComm"
 
-dependencies{
-    api(project(":controls-core"))
-    implementation(libs.jSerialComm)
+kscience {
+    jvm()
+    jvmMain {
+        api(project(":controls-core"))
+        implementation(libs.jSerialComm)
+    }
 }
 
 readme{
diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
similarity index 100%
rename from controls-serial/src/main/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
rename to controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
similarity index 100%
rename from controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
rename to controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
similarity index 100%
rename from controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
rename to controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
diff --git a/controls-storage/controls-xodus/build.gradle.kts b/controls-storage/controls-xodus/build.gradle.kts
index 8cebd37..04be46a 100644
--- a/controls-storage/controls-xodus/build.gradle.kts
+++ b/controls-storage/controls-xodus/build.gradle.kts
@@ -1,5 +1,5 @@
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
 }
 
@@ -7,13 +7,18 @@ description = """
     An implementation of controls-storage on top of JetBrains Xodus.
 """.trimIndent()
 
-dependencies {
-    api(projects.controlsStorage)
-    implementation(libs.xodus.entity.store)
+kscience {
+    jvm()
+    jvmMain {
+        api(projects.controlsStorage)
+        implementation(libs.xodus.entity.store)
 //    implementation("org.jetbrains.xodus:xodus-environment:$xodusVersion")
 //    implementation("org.jetbrains.xodus:xodus-vfs:$xodusVersion")
 
-    testImplementation(spclibs.kotlinx.coroutines.test)
+    }
+    jvmTest{
+        implementation(spclibs.kotlinx.coroutines.test)
+    }
 }
 
 readme{
diff --git a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt b/controls-storage/controls-xodus/src/jvmMain/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
similarity index 100%
rename from controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
rename to controls-storage/controls-xodus/src/jvmMain/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt
diff --git a/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt b/controls-storage/controls-xodus/src/jvmTest/kotlin/PropertyHistoryTest.kt
similarity index 100%
rename from controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt
rename to controls-storage/controls-xodus/src/jvmTest/kotlin/PropertyHistoryTest.kt
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
index 2d2086b..f341256 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
@@ -8,6 +8,7 @@ import javafx.stage.Stage
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 import kotlinx.serialization.json.Json
 import org.eclipse.milo.opcua.sdk.server.OpcUaServer
 import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText
@@ -91,7 +92,7 @@ class DemoController : Controller(), ContextAware {
         }
     }
 
-    fun shutdown() {
+    suspend fun shutdown() {
         logger.info { "Shutting down..." }
         opcUaServer.shutdown()
         logger.info { "OpcUa server stopped" }
@@ -179,7 +180,9 @@ class DemoControllerApp : App(DemoControllerView::class) {
     }
 
     override fun stop() {
-        controller.shutdown()
+        runBlocking {
+            controller.shutdown()
+        }
         super.stop()
     }
 }
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
index 3a63003..e76cfe0 100644
--- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
+++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
@@ -8,6 +8,7 @@ import javafx.scene.layout.Priority
 import javafx.stage.Stage
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
 import space.kscience.controls.client.launchMagixService
 import space.kscience.controls.demo.car.IVirtualCar.Companion.acceleration
 import space.kscience.controls.manager.DeviceManager
@@ -67,7 +68,7 @@ class VirtualCarController : Controller(), ContextAware {
         }
     }
 
-    fun shutdown() {
+    suspend fun shutdown() {
         logger.info { "Shutting down..." }
         magixServer?.stop(1000, 5000)
         logger.info { "Magix server stopped" }
@@ -137,7 +138,9 @@ class VirtualCarControllerApp : App(VirtualCarControllerView::class) {
     }
 
     override fun stop() {
-        controller.shutdown()
+        runBlocking {
+            controller.shutdown()
+        }
         super.stop()
     }
 }
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt
index 9b06d2e..e426e1b 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/main.kt
@@ -16,9 +16,11 @@ import androidx.compose.ui.window.Window
 import androidx.compose.ui.window.application
 import kotlinx.coroutines.launch
 import space.kscience.controls.constructor.*
+import space.kscience.controls.constructor.library.*
 import space.kscience.controls.manager.ClockManager
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.clock
+import space.kscience.controls.manager.install
 import space.kscience.controls.spec.doRecurring
 import space.kscience.controls.spec.name
 import space.kscience.controls.vision.plot
@@ -48,13 +50,14 @@ class LinearDrive(
     val drive by device(VirtualDrive.factory(mass, state))
     val pid by device(PidRegulator(drive, pidParameters))
 
-    val start by device(LimitSwitch.factory(state.atStartState))
-    val end by device(LimitSwitch.factory(state.atEndState))
+    val start by device(LimitSwitch(state.atStartState))
+    val end by device(LimitSwitch(state.atEndState))
 
 
     val positionState: DoubleRangeState by property(state)
-    private val targetState: MutableDeviceState<Double> by property(pid.mutablePropertyAsState(Regulator.target, 0.0))
-    var target by targetState
+
+    private val targetState: MutableDeviceState<Double> by deviceProperty(pid, Regulator.target, 0.0)
+    var target: Double by targetState
 }
 
 
@@ -73,7 +76,6 @@ private fun Context.launchPidDevice(
             val timeFromStart = clock.now() - clockStart
             val t = timeFromStart.toDouble(DurationUnit.SECONDS)
             val freq = 0.1
-
             target = 5 * sin(2.0 * PI * freq * t) +
                     sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))
         }
@@ -150,7 +152,7 @@ fun main() = application {
                 Row {
                     Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
                     TextField(
-                        String.format("%.2f",pidParameters.kp),
+                        String.format("%.2f", pidParameters.kp),
                         { pidParameters.kp = it.toDouble() },
                         Modifier.width(100.dp),
                         enabled = false
@@ -165,7 +167,7 @@ fun main() = application {
                 Row {
                     Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
                     TextField(
-                        String.format("%.2f",pidParameters.ki),
+                        String.format("%.2f", pidParameters.ki),
                         { pidParameters.ki = it.toDouble() },
                         Modifier.width(100.dp),
                         enabled = false
@@ -181,7 +183,7 @@ fun main() = application {
                 Row {
                     Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
                     TextField(
-                        String.format("%.2f",pidParameters.kd),
+                        String.format("%.2f", pidParameters.kd),
                         { pidParameters.kd = it.toDouble() },
                         Modifier.width(100.dp),
                         enabled = false
diff --git a/magix/magix-server/build.gradle.kts b/magix/magix-server/build.gradle.kts
index f00b81e..28fe492 100644
--- a/magix/magix-server/build.gradle.kts
+++ b/magix/magix-server/build.gradle.kts
@@ -1,36 +1,38 @@
 import space.kscience.gradle.Maturity
 
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
-    application
 }
 
 description = """
     A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes.
 """.trimIndent()
 
-kscience {
-    useSerialization{
-        json()
-    }
-}
-
 val dataforgeVersion: String by rootProject.extra
 val ktorVersion: String  = space.kscience.gradle.KScienceVersions.ktorVersion
 
-dependencies{
-    api(projects.magix.magixApi)
-    api("io.ktor:ktor-server-cio:$ktorVersion")
-    api("io.ktor:ktor-server-websockets:$ktorVersion")
-    api("io.ktor:ktor-server-content-negotiation:$ktorVersion")
-    api("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
-    api("io.ktor:ktor-server-html-builder:$ktorVersion")
+kscience {
+    jvm()
+    useSerialization{
+        json()
+    }
+
+    jvmMain{
+        api(projects.magix.magixApi)
+        api("io.ktor:ktor-server-cio:$ktorVersion")
+        api("io.ktor:ktor-server-websockets:$ktorVersion")
+        api("io.ktor:ktor-server-content-negotiation:$ktorVersion")
+        api("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
+        api("io.ktor:ktor-server-html-builder:$ktorVersion")
+
+        api(libs.rsocket.ktor.server)
+        api(libs.rsocket.transport.ktor.tcp)
+    }
 
-    api(libs.rsocket.ktor.server)
-    api(libs.rsocket.transport.ktor.tcp)
 }
 
+
 readme{
     maturity = Maturity.EXPERIMENTAL
 }
\ No newline at end of file
diff --git a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt
similarity index 100%
rename from magix/magix-server/src/main/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt
rename to magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt
diff --git a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/magixModule.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/magixModule.kt
similarity index 100%
rename from magix/magix-server/src/main/kotlin/space/kscience/magix/server/magixModule.kt
rename to magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/magixModule.kt
diff --git a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/server.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/server.kt
similarity index 100%
rename from magix/magix-server/src/main/kotlin/space/kscience/magix/server/server.kt
rename to magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/server.kt
diff --git a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/sse.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/sse.kt
similarity index 100%
rename from magix/magix-server/src/main/kotlin/space/kscience/magix/server/sse.kt
rename to magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/sse.kt

From 24b6856f153a81ecef7ab9559707da129bb6e479 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 11 May 2024 17:56:28 +0300
Subject: [PATCH 079/125] Move all-things to Compose

---
 build.gradle.kts                              |   1 -
 .../constructor/library/LimitSwitch.kt        |   2 +-
 demo/all-things/build.gradle.kts              |  51 ++--
 .../controls/demo/DemoControllerView.kt       | 225 +++++++++---------
 .../controls/demo/generateMessageSchema.kt    |  10 -
 demo/motors/build.gradle.kts                  |  17 +-
 settings.gradle.kts                           |   1 -
 7 files changed, 146 insertions(+), 161 deletions(-)
 delete mode 100644 demo/all-things/src/main/kotlin/space/kscience/controls/demo/generateMessageSchema.kt

diff --git a/build.gradle.kts b/build.gradle.kts
index aed37b5..38d5c6a 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,7 +3,6 @@ import space.kscience.gradle.useSPCTeam
 
 plugins {
     id("space.kscience.gradle.project")
-    alias(libs.plugins.versions)
 }
 
 allprojects {
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
index 895cee8..af6436f 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
@@ -35,7 +35,7 @@ public class VirtualLimitSwitch(
     public val lockedState: DeviceState<Boolean>,
 ) : DeviceBySpec<LimitSwitch>(LimitSwitch, context), LimitSwitch {
 
-    init {
+    override suspend fun onStart() {
         lockedState.valueFlow.onEach {
             propertyChanged(LimitSwitch.locked, it)
         }.launchIn(this)
diff --git a/demo/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts
index b6074ed..2abde80 100644
--- a/demo/all-things/build.gradle.kts
+++ b/demo/all-things/build.gradle.kts
@@ -1,7 +1,6 @@
 plugins {
     kotlin("jvm")
-    id("org.openjfx.javafxplugin") version "0.0.13"
-    application
+    alias(spclibs.plugins.compose)
 }
 
 
@@ -20,28 +19,46 @@ dependencies {
     implementation(projects.controlsOpcua)
 
     implementation(spclibs.ktor.client.cio)
-    implementation(libs.tornadofx)
     implementation(libs.plotlykt.server)
 //    implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6")
+
+    implementation(compose.runtime)
+    implementation(compose.desktop.currentOs)
+    implementation(compose.material3)
+//    implementation("org.pushing-pixels:aurora-window:1.3.0")
+//    implementation("org.pushing-pixels:aurora-component:1.3.0")
+//    implementation("org.pushing-pixels:aurora-theming:1.3.0")
+
     implementation(spclibs.logback.classic)
 }
 
 kotlin{
-    jvmToolchain(11)
-}
-
-
-tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
-    kotlinOptions {
-        freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn")
+    jvmToolchain(17)
+    compilerOptions {
+        freeCompilerArgs.addAll("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn")
     }
 }
 
-javafx {
-    version = "17"
-    modules("javafx.controls")
+compose{
+    desktop{
+        application{
+            mainClass = "space.kscience.controls.demo.DemoControllerViewKt"
+        }
+    }
 }
-
-application {
-    mainClass.set("space.kscience.controls.demo.DemoControllerViewKt")
-}
\ No newline at end of file
+//
+//
+//tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
+//    kotlinOptions {
+//        freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn")
+//    }
+//}
+//
+//javafx {
+//    version = "17"
+//    modules("javafx.controls")
+//}
+//
+//application {
+//    mainClass.set("space.kscience.controls.demo.DemoControllerViewKt")
+//}
\ No newline at end of file
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
index f341256..cc9d445 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
@@ -1,14 +1,19 @@
 package space.kscience.controls.demo
 
+import androidx.compose.foundation.layout.*
+import androidx.compose.material.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
 import io.ktor.server.engine.ApplicationEngine
-import javafx.scene.Parent
-import javafx.scene.control.Slider
-import javafx.scene.layout.Priority
-import javafx.stage.Stage
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
 import kotlinx.serialization.json.Json
 import org.eclipse.milo.opcua.sdk.server.OpcUaServer
 import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText
@@ -17,9 +22,6 @@ import space.kscience.controls.api.GetDescriptionMessage
 import space.kscience.controls.api.PropertyChangedMessage
 import space.kscience.controls.client.launchMagixService
 import space.kscience.controls.client.magixFormat
-import space.kscience.controls.demo.DemoDevice.Companion.cosScale
-import space.kscience.controls.demo.DemoDevice.Companion.sinScale
-import space.kscience.controls.demo.DemoDevice.Companion.timeScale
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
 import space.kscience.controls.opcua.server.OpcUaServer
@@ -35,16 +37,15 @@ import space.kscience.magix.rsocket.rSocketWithWebSockets
 import space.kscience.magix.server.RSocketMagixFlowPlugin
 import space.kscience.magix.server.startMagixServer
 import space.kscince.magix.zmq.ZmqMagixFlowPlugin
-import tornadofx.*
 import java.awt.Desktop
 import java.net.URI
 
-class DemoController : Controller(), ContextAware {
+class DemoController : ContextAware {
 
     var device: DemoDevice? = null
     var magixServer: ApplicationEngine? = null
     var visualizer: ApplicationEngine? = null
-    var opcUaServer: OpcUaServer = OpcUaServer {
+    val opcUaServer: OpcUaServer = OpcUaServer {
         setApplicationName(LocalizedText.english("space.kscience.controls.opcua"))
 
         endpoint {
@@ -60,39 +61,37 @@ class DemoController : Controller(), ContextAware {
     private val deviceManager = context.request(DeviceManager)
 
 
-    fun init() {
-        context.launch {
-            device = deviceManager.install("demo", DemoDevice)
-            //starting magix event loop
-            magixServer = startMagixServer(
-                RSocketMagixFlowPlugin(), //TCP rsocket support
-                ZmqMagixFlowPlugin() //ZMQ support
-            )
-            //Launch a device client and connect it to the server
-            val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost")
-            deviceManager.launchMagixService(deviceEndpoint)
-            //connect visualization to a magix endpoint
-            val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
-            visualizer = startDemoDeviceServer(visualEndpoint)
+    fun start(): Job = context.launch {
+        device = deviceManager.install("demo", DemoDevice)
+        //starting magix event loop
+        magixServer = startMagixServer(
+            RSocketMagixFlowPlugin(), //TCP rsocket support
+            ZmqMagixFlowPlugin() //ZMQ support
+        )
+        //Launch a device client and connect it to the server
+        val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost")
+        deviceManager.launchMagixService(deviceEndpoint)
+        //connect visualization to a magix endpoint
+        val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+        visualizer = startDemoDeviceServer(visualEndpoint)
 
-            //serve devices as OPC-UA namespace
-            opcUaServer.startup()
-            opcUaServer.serveDevices(deviceManager)
+        //serve devices as OPC-UA namespace
+        opcUaServer.startup()
+        opcUaServer.serveDevices(deviceManager)
 
 
-            val listenerEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
-            listenerEndpoint.subscribe(DeviceManager.magixFormat).onEach { (_, deviceMessage)->
-                // print all messages that are not property change message
-                if(deviceMessage !is PropertyChangedMessage){
-                    println(">> ${Json.encodeToString(DeviceMessage.serializer(), deviceMessage)}")
-                }
-            }.launchIn(this)
-            listenerEndpoint.send(DeviceManager.magixFormat, GetDescriptionMessage(), "listener", "controls-kt")
+        val listenerEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+        listenerEndpoint.subscribe(DeviceManager.magixFormat).onEach { (_, deviceMessage) ->
+            // print all messages that are not property change message
+            if (deviceMessage !is PropertyChangedMessage) {
+                println(">> ${Json.encodeToString(DeviceMessage.serializer(), deviceMessage)}")
+            }
+        }.launchIn(this)
+        listenerEndpoint.send(DeviceManager.magixFormat, GetDescriptionMessage(), "listener", "controls-kt")
 
-        }
     }
 
-    suspend fun shutdown() {
+    fun shutdown(): Job = context.launch {
         logger.info { "Shutting down..." }
         opcUaServer.shutdown()
         logger.info { "OpcUa server stopped" }
@@ -102,92 +101,82 @@ class DemoController : Controller(), ContextAware {
         logger.info { "Magix server stopped" }
         device?.stop()
         logger.info { "Device server stopped" }
-        context.close()
+    }
+}
+
+@Composable
+fun DemoControls(controller: DemoController) {
+    var timeScale by remember { mutableStateOf(5000f) }
+    var xScale by remember { mutableStateOf(1f) }
+    var yScale by remember { mutableStateOf(1f) }
+
+    Surface(Modifier.padding(5.dp)) {
+        Column {
+            Row(Modifier.fillMaxWidth()) {
+                Text("Time Scale", modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
+                TextField(String.format("%.2f", timeScale),{}, enabled = false, modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
+                Slider(timeScale, onValueChange = { timeScale = it }, steps = 20, valueRange = 1000f..5000f)
+            }
+            Row(Modifier.fillMaxWidth()) {
+                Text("X scale", modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
+                TextField(String.format("%.2f", xScale),{}, enabled = false, modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
+                Slider(xScale, onValueChange = { xScale = it }, steps = 20, valueRange = 0.1f..2.0f)
+            }
+            Row(Modifier.fillMaxWidth()) {
+                Text("Y scale", modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
+                TextField(String.format("%.2f", yScale),{}, enabled = false, modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
+                Slider(yScale, onValueChange = { yScale = it }, steps = 20, valueRange = 0.1f..2.0f)
+            }
+            Row(Modifier.fillMaxWidth()) {
+                Button(
+                    onClick = {
+                        controller.device?.run {
+                            launch {
+                                write(DemoDevice.timeScale, timeScale.toDouble())
+                                write(DemoDevice.sinScale, xScale.toDouble())
+                                write(DemoDevice.cosScale, yScale.toDouble())
+                            }
+                        }
+                    },
+                    Modifier.fillMaxWidth()
+                ) {
+                    Text("Submit")
+                }
+            }
+            Row(Modifier.fillMaxWidth()) {
+                Button(
+                    onClick = {
+                        controller.visualizer?.run {
+                            val host = "localhost"//environment.connectors.first().host
+                            val port = environment.connectors.first().port
+                            val uri = URI("http", null, host, port, "/", null, null)
+                            Desktop.getDesktop().browse(uri)
+                        }
+                    },
+                    Modifier.fillMaxWidth()
+                ) {
+                    Text("Show plots")
+                }
+            }
+        }
+
     }
 }
 
 
-class DemoControllerView : View(title = " Demo controller remote") {
-    private val controller: DemoController by inject()
-    private var timeScaleSlider: Slider by singleAssign()
-    private var xScaleSlider: Slider by singleAssign()
-    private var yScaleSlider: Slider by singleAssign()
+fun main() = application {
+    val controller = remember { DemoController().apply { start() } }
 
-    override val root: Parent = vbox {
-        hbox {
-            label("Time scale")
-            pane {
-                hgrow = Priority.ALWAYS
-            }
-            timeScaleSlider = slider(1000..10000, 5000) {
-                isShowTickLabels = true
-                isShowTickMarks = true
-            }
-        }
-        hbox {
-            label("X scale")
-            pane {
-                hgrow = Priority.ALWAYS
-            }
-            xScaleSlider = slider(0.1..2.0, 1.0) {
-                isShowTickLabels = true
-                isShowTickMarks = true
-            }
-        }
-        hbox {
-            label("Y scale")
-            pane {
-                hgrow = Priority.ALWAYS
-            }
-            yScaleSlider = slider(0.1..2.0, 1.0) {
-                isShowTickLabels = true
-                isShowTickMarks = true
-            }
-        }
-        button("Submit") {
-            useMaxWidth = true
-            action {
-                controller.device?.run {
-                    launch {
-                        write(timeScale, timeScaleSlider.value)
-                        write(sinScale, xScaleSlider.value)
-                        write(cosScale, yScaleSlider.value)
-                    }
-                }
-            }
-        }
-        button("Show plots") {
-            useMaxWidth = true
-            action {
-                controller.visualizer?.run {
-                    val host = "localhost"//environment.connectors.first().host
-                    val port = environment.connectors.first().port
-                    val uri = URI("http", null, host, port, "/", null, null)
-                    Desktop.getDesktop().browse(uri)
-                }
-            }
-        }
-    }
-}
-
-
-class DemoControllerApp : App(DemoControllerView::class) {
-    private val controller: DemoController by inject()
-
-    override fun start(stage: Stage) {
-        super.start(stage)
-        controller.init()
-    }
-
-    override fun stop() {
-        runBlocking {
+    Window(
+        title = "All things control",
+        onCloseRequest = {
             controller.shutdown()
+            exitApplication()
+        },
+        state = rememberWindowState(width = 400.dp, height = 300.dp)
+    ) {
+        MaterialTheme {
+            DemoControls(controller)
         }
-        super.stop()
     }
-}
-
-
-fun main() {
-    launch<DemoControllerApp>()
 }
\ No newline at end of file
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/generateMessageSchema.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/generateMessageSchema.kt
deleted file mode 100644
index d50ec2c..0000000
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/generateMessageSchema.kt
+++ /dev/null
@@ -1,10 +0,0 @@
-package space.kscience.controls.demo
-
-//import com.github.ricky12awesome.jss.encodeToSchema
-//import com.github.ricky12awesome.jss.globalJson
-//import space.kscience.controls.api.DeviceMessage
-
-//fun main() {
-//    val schema = globalJson.encodeToSchema(DeviceMessage.serializer(), generateDefinitions = false)
-//    println(schema)
-//}
\ No newline at end of file
diff --git a/demo/motors/build.gradle.kts b/demo/motors/build.gradle.kts
index dd64935..faca2b0 100644
--- a/demo/motors/build.gradle.kts
+++ b/demo/motors/build.gradle.kts
@@ -1,19 +1,11 @@
 plugins {
     id("space.kscience.gradle.jvm")
-    application
-    id("org.openjfx.javafxplugin")
+    alias(spclibs.plugins.compose)
 }
 
-//TODO to be moved to a separate project
-
-javafx {
-    version = "17"
-    modules = listOf("javafx.controls")
-}
-
-application{
-    mainClass.set("ru.mipt.npm.devices.pimotionmaster.PiMotionMasterAppKt")
-}
+//application{
+//    mainClass.set("ru.mipt.npm.devices.pimotionmaster.PiMotionMasterAppKt")
+//}
 
 kotlin{
     explicitApi = null
@@ -25,5 +17,4 @@ val dataforgeVersion: String by extra
 dependencies {
     implementation(project(":controls-ports-ktor"))
     implementation(projects.controlsMagix)
-    implementation(libs.tornadofx)
 }
diff --git a/settings.gradle.kts b/settings.gradle.kts
index ed33e53..39adc72 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -18,7 +18,6 @@ pluginManagement {
         id("space.kscience.gradle.mpp") version toolsVersion
         id("space.kscience.gradle.jvm") version toolsVersion
         id("space.kscience.gradle.js") version toolsVersion
-        id("org.openjfx.javafxplugin") version "0.0.13"
     }
 }
 

From 44514cd47772d494aee71683a5851836ab0dbf65 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 12 May 2024 13:52:00 +0300
Subject: [PATCH 080/125] [WIP] moving from java fx to compose in examples

---
 .../space/kscience/controls/api/DeviceHub.kt  |   2 +-
 .../controls/demo/DemoControllerView.kt       |   4 +-
 demo/motors/build.gradle.kts                  |  17 +-
 .../pimotionmaster/PiMotionMasterApp.kt       | 327 ++++++++++--------
 .../pimotionmaster/PiMotionMasterDevice.kt    |  26 +-
 .../pimotionmaster/deviceProperties.kt        |  15 +
 .../pimotionmaster/fxDeviceProperties.kt      |  58 ----
 .../devices/pimotionmaster/piDebugServer.kt   |  60 ++--
 8 files changed, 259 insertions(+), 250 deletions(-)
 create mode 100644 demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/deviceProperties.kt
 delete mode 100644 demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
index 72b0dc3..f6ca4ec 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
@@ -48,7 +48,7 @@ public operator fun DeviceHub.get(nameToken: NameToken): Device =
 
 public fun DeviceHub.getOrNull(name: Name): Device? = when {
     name.isEmpty() -> this as? Device
-    name.length == 1 -> get(name.firstOrNull()!!)
+    name.length == 1 -> devices[name.firstOrNull()!!]
     else -> (get(name.firstOrNull()!!) as? DeviceHub)?.getOrNull(name.cutFirst())
 }
 
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
index cc9d445..ff99f32 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
@@ -1,7 +1,7 @@
 package space.kscience.controls.demo
 
 import androidx.compose.foundation.layout.*
-import androidx.compose.material.*
+import androidx.compose.material3.*
 import androidx.compose.runtime.*
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
@@ -173,7 +173,7 @@ fun main() = application {
             controller.shutdown()
             exitApplication()
         },
-        state = rememberWindowState(width = 400.dp, height = 300.dp)
+        state = rememberWindowState(width = 400.dp, height = 320.dp)
     ) {
         MaterialTheme {
             DemoControls(controller)
diff --git a/demo/motors/build.gradle.kts b/demo/motors/build.gradle.kts
index faca2b0..9241764 100644
--- a/demo/motors/build.gradle.kts
+++ b/demo/motors/build.gradle.kts
@@ -3,10 +3,6 @@ plugins {
     alias(spclibs.plugins.compose)
 }
 
-//application{
-//    mainClass.set("ru.mipt.npm.devices.pimotionmaster.PiMotionMasterAppKt")
-//}
-
 kotlin{
     explicitApi = null
 }
@@ -17,4 +13,17 @@ val dataforgeVersion: String by extra
 dependencies {
     implementation(project(":controls-ports-ktor"))
     implementation(projects.controlsMagix)
+
+    implementation(compose.runtime)
+    implementation(compose.desktop.currentOs)
+    implementation(compose.material3)
+    implementation(spclibs.logback.classic)
+}
+
+compose{
+    desktop{
+        application{
+            mainClass = "ru.mipt.npm.devices.pimotionmaster.PiMotionMasterAppKt"
+        }
+    }
 }
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
index 840b1d9..0762e9f 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
@@ -1,31 +1,193 @@
 package ru.mipt.npm.devices.pimotionmaster
 
-import javafx.beans.property.ReadOnlyProperty
-import javafx.beans.property.SimpleIntegerProperty
-import javafx.beans.property.SimpleObjectProperty
-import javafx.beans.property.SimpleStringProperty
-import javafx.geometry.Pos
-import javafx.scene.Parent
-import javafx.scene.layout.Priority
-import javafx.scene.layout.VBox
-import kotlinx.coroutines.CoroutineScope
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material.Button
+import androidx.compose.material.OutlinedTextField
+import androidx.compose.material.Slider
+import androidx.compose.material.Text
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import androidx.compose.ui.window.rememberWindowState
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.launch
-import ru.mipt.npm.devices.pimotionmaster.PiMotionMasterDevice.Axis.Companion.maxPosition
-import ru.mipt.npm.devices.pimotionmaster.PiMotionMasterDevice.Axis.Companion.minPosition
-import ru.mipt.npm.devices.pimotionmaster.PiMotionMasterDevice.Axis.Companion.position
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.installing
 import space.kscience.controls.spec.read
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.request
-import tornadofx.*
 
-class PiMotionMasterApp : App(PiMotionMasterView::class)
+//class PiMotionMasterApp : App(PiMotionMasterView::class)
+//
+//class PiMotionMasterController : Controller() {
+//    //initialize context
+//    val context = Context("piMotionMaster") {
+//        plugin(DeviceManager)
+//    }
+//
+//    //initialize deviceManager plugin
+//    val deviceManager: DeviceManager = context.request(DeviceManager)
+//
+//    // install device
+//    val motionMaster: PiMotionMasterDevice by deviceManager.installing(PiMotionMasterDevice)
+//}
 
-class PiMotionMasterController : Controller() {
-    //initialize context
-    val context = Context("piMotionMaster"){
+@Composable
+fun ColumnScope.piMotionMasterAxis(
+    axisName: String,
+    axis: PiMotionMasterDevice.Axis,
+) {
+    Row {
+        Text(axisName)
+        var min by remember { mutableStateOf(0f) }
+        var max by remember { mutableStateOf(0f) }
+        var targetPosition by remember { mutableStateOf(0f) }
+        val position: Double by axis.composeState(PiMotionMasterDevice.Axis.position, 0.0)
+
+        val scope = rememberCoroutineScope()
+
+        LaunchedEffect(axis) {
+            min = axis.read(PiMotionMasterDevice.Axis.minPosition).toFloat()
+            max = axis.read(PiMotionMasterDevice.Axis.maxPosition).toFloat()
+            targetPosition = axis.read(PiMotionMasterDevice.Axis.position).toFloat()
+        }
+
+        Column {
+            Slider(
+                value = position.toFloat(),
+                enabled = false,
+                onValueChange = { },
+                valueRange = min..max
+            )
+            Slider(
+                value = targetPosition,
+                onValueChange = { newPosition ->
+                    scope.launch {
+                        axis.move(newPosition.toDouble())
+                    }
+                    targetPosition = newPosition
+                },
+                valueRange = min..max
+            )
+
+        }
+    }
+}
+
+@Composable
+fun AxisPane(axes: Map<String, PiMotionMasterDevice.Axis>) {
+    Column {
+        axes.forEach { (name, axis) ->
+            this.piMotionMasterAxis(name, axis)
+        }
+    }
+}
+
+
+@Composable
+fun PiMotionMasterApp(device: PiMotionMasterDevice) {
+
+    val scope = rememberCoroutineScope()
+    val connected by device.composeState(PiMotionMasterDevice.connected, false)
+    var debugServerJob by remember { mutableStateOf<Job?>(null) }
+    var axes by remember { mutableStateOf<Map<String, PiMotionMasterDevice.Axis>?>(null) }
+    //private val axisList = FXCollections.observableArrayList<Map.Entry<String, PiMotionMasterDevice.Axis>>()
+    var host by remember { mutableStateOf("127.0.0.1") }
+    var port by remember { mutableStateOf(10024) }
+
+    Scaffold {
+        Column {
+
+
+            Text("Address:")
+            Row {
+                OutlinedTextField(
+                    value = host,
+                    onValueChange = { host = it },
+                    label = { Text("Host") },
+                    enabled = debugServerJob == null,
+                    modifier = Modifier.weight(1f)
+                )
+                var portError by remember { mutableStateOf(false) }
+                OutlinedTextField(
+                    value = port.toString(),
+                    onValueChange = {
+                        it.toIntOrNull()?.let { value ->
+                            port = value
+                            portError = false
+                        } ?: run {
+                            portError = true
+                        }
+                    },
+                    label = { Text("Port") },
+                    enabled = debugServerJob == null,
+                    isError = portError,
+                    modifier = Modifier.weight(1f),
+                )
+            }
+            Row {
+                Button(
+                    onClick = {
+                        if (debugServerJob == null) {
+                            debugServerJob = device.context.launchPiDebugServer(port, listOf("1", "2", "3", "4"))
+                        } else {
+                            debugServerJob?.cancel()
+                            debugServerJob = null
+                        }
+                    },
+                    modifier = Modifier.fillMaxWidth()
+                ) {
+                    if (debugServerJob == null) {
+                        Text("Start debug server")
+                    } else {
+                        Text("Stop debug server")
+                    }
+                }
+            }
+            Row {
+                Button(
+                    onClick = {
+                        if (!connected) {
+                            device.launch {
+                                device.connect(host, port)
+                            }
+                            axes = device.axes
+                        } else {
+                            device.launch {
+                                device.disconnect()
+                            }
+                            axes = null
+                        }
+                    },
+                    modifier = Modifier.fillMaxWidth()
+                ) {
+                    if (!connected) {
+                        Text("Connect")
+                    } else {
+                        Text("Disconnect")
+                    }
+                }
+            }
+
+            axes?.let { axes ->
+                AxisPane(axes)
+            }
+        }
+    }
+}
+
+
+fun main() = application {
+
+    val context = Context("piMotionMaster") {
         plugin(DeviceManager)
     }
 
@@ -34,131 +196,14 @@ class PiMotionMasterController : Controller() {
 
     // install device
     val motionMaster: PiMotionMasterDevice by deviceManager.installing(PiMotionMasterDevice)
-}
 
-fun VBox.piMotionMasterAxis(
-    axisName: String,
-    axis: PiMotionMasterDevice.Axis,
-    coroutineScope: CoroutineScope,
-) = hbox {
-    alignment = Pos.CENTER
-    label(axisName)
-    coroutineScope.launch {
-        with(axis) {
-            val min: Double = read(minPosition)
-            val max: Double = read(maxPosition)
-            val positionProperty = fxProperty(position)
-            val startPosition = read(position)
-            runLater {
-                vbox {
-                    hgrow = Priority.ALWAYS
-                    slider(min..max, startPosition) {
-                        minWidth = 300.0
-                        isShowTickLabels = true
-                        isShowTickMarks = true
-                        minorTickCount = 10
-                        majorTickUnit = 1.0
-                        valueProperty().onChange {
-                            coroutineScope.launch {
-                                axis.move(value)
-                            }
-                        }
-                    }
-                    slider(min..max) {
-                        isDisable = true
-                        valueProperty().bind(positionProperty)
-                    }
-                }
-            }
+    Window(
+        title = "Pi motion master demo",
+        onCloseRequest = { exitApplication() },
+        state = rememberWindowState(width = 400.dp, height = 300.dp)
+    ) {
+        MaterialTheme {
+            PiMotionMasterApp(motionMaster)
         }
     }
-}
-
-fun Parent.axisPane(axes: Map<String, PiMotionMasterDevice.Axis>, coroutineScope: CoroutineScope) {
-    vbox {
-        axes.forEach { (name, axis) ->
-            this.piMotionMasterAxis(name, axis, coroutineScope)
-        }
-    }
-}
-
-
-class PiMotionMasterView : View() {
-
-    private val controller: PiMotionMasterController by inject()
-    val device = controller.motionMaster
-
-    private val connectedProperty: ReadOnlyProperty<Boolean> = device.fxProperty(PiMotionMasterDevice.connected)
-    private val debugServerJobProperty = SimpleObjectProperty<Job>()
-    private val debugServerStarted = debugServerJobProperty.booleanBinding { it != null }
-    //private val axisList = FXCollections.observableArrayList<Map.Entry<String, PiMotionMasterDevice.Axis>>()
-
-    override val root: Parent = borderpane {
-        top {
-            form {
-                val host = SimpleStringProperty("127.0.0.1")
-                val port = SimpleIntegerProperty(10024)
-                fieldset("Address:") {
-                    field("Host:") {
-                        textfield(host) {
-                            enableWhen(debugServerStarted.not())
-                        }
-                    }
-                    field("Port:") {
-                        textfield(port) {
-                            stripNonNumeric()
-                        }
-                        button {
-                            hgrow = Priority.ALWAYS
-                            textProperty().bind(debugServerStarted.stringBinding {
-                                if (it != true) {
-                                    "Start debug server"
-                                } else {
-                                    "Stop debug server"
-                                }
-                            })
-                            action {
-                                if (!debugServerStarted.get()) {
-                                    debugServerJobProperty.value =
-                                        controller.context.launchPiDebugServer(port.get(), listOf("1", "2", "3", "4"))
-                                } else {
-                                    debugServerJobProperty.get().cancel()
-                                    debugServerJobProperty.value = null
-                                }
-                            }
-                        }
-                    }
-                }
-
-                button {
-                    hgrow = Priority.ALWAYS
-                    textProperty().bind(connectedProperty.stringBinding {
-                        if (it == false) {
-                            "Connect"
-                        } else {
-                            "Disconnect"
-                        }
-                    })
-                    action {
-                        if (!connectedProperty.value) {
-                            device.connect(host.get(), port.get())
-                            center {
-                                axisPane(device.axes,controller.context)
-                            }
-                        } else {
-                            this@borderpane.center = null
-                            device.disconnect()
-                        }
-                    }
-                }
-
-
-            }
-        }
-
-    }
-}
-
-fun main() {
-    launch<PiMotionMasterApp>()
 }
\ No newline at end of file
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index 69dfb79..4bde0b6 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -7,13 +7,15 @@ import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.flow.toList
 import kotlinx.coroutines.flow.transformWhile
-import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withTimeout
 import space.kscience.controls.api.DeviceHub
 import space.kscience.controls.api.PropertyDescriptor
-import space.kscience.controls.ports.*
+import space.kscience.controls.ports.AsynchronousPort
+import space.kscience.controls.ports.KtorTcpPort
+import space.kscience.controls.ports.send
+import space.kscience.controls.ports.withStringDelimiter
 import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.*
@@ -33,10 +35,8 @@ class PiMotionMasterDevice(
     //PortProxy { portFactory(address ?: error("The device is not connected"), context) }
 
 
-    fun disconnect() {
-        runBlocking {
-            execute(disconnect)
-        }
+    suspend fun disconnect() {
+        execute(disconnect)
     }
 
     var timeoutValue: Duration = 200.milliseconds
@@ -54,13 +54,11 @@ class PiMotionMasterDevice(
         if (errorCode != 0) error(message(errorCode))
     }
 
-    fun connect(host: String, port: Int) {
-        runBlocking {
-            execute(connect, Meta {
-                "host" put host
-                "port" put port
-            })
-        }
+    suspend fun connect(host: String, port: Int) {
+        execute(connect, Meta {
+            "host" put host
+            "port" put port
+        })
     }
 
     private val mutex = Mutex()
@@ -103,7 +101,7 @@ class PiMotionMasterDevice(
                 }.toList()
             }
         } catch (ex: Throwable) {
-            logger.warn { "Error during PIMotionMaster request. Requesting error code." }
+            logger.error(ex) { "Error during PIMotionMaster request. Requesting error code." }
             val errorCode = getErrorCode()
             dispatchError(errorCode)
             logger.warn { "Error code $errorCode" }
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/deviceProperties.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/deviceProperties.kt
new file mode 100644
index 0000000..00a4c55
--- /dev/null
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/deviceProperties.kt
@@ -0,0 +1,15 @@
+package ru.mipt.npm.devices.pimotionmaster
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import space.kscience.controls.api.Device
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.propertyFlow
+
+
+@Composable
+fun <D : Device, T : Any> D.composeState(
+    spec: DevicePropertySpec<D, T>,
+    initialState: T,
+): State<T> = propertyFlow(spec).collectAsState(initialState)
\ No newline at end of file
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt
deleted file mode 100644
index adef540..0000000
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package ru.mipt.npm.devices.pimotionmaster
-
-import javafx.beans.property.ObjectPropertyBase
-import javafx.beans.property.Property
-import javafx.beans.property.ReadOnlyProperty
-import space.kscience.controls.api.Device
-import space.kscience.controls.spec.*
-import space.kscience.dataforge.context.info
-import space.kscience.dataforge.context.logger
-import tornadofx.*
-
-/**
- * Bind a FX property to a device property with a given [spec]
- */
-fun <D : Device, T : Any> D.fxProperty(
-    spec: DevicePropertySpec<D, T>,
-): ReadOnlyProperty<T> = object : ObjectPropertyBase<T>() {
-    override fun getBean(): Any = this
-    override fun getName(): String = spec.name
-
-    init {
-        //Read incoming changes
-        onPropertyChange(spec) {
-            runLater {
-                try {
-                    set(it)
-                } catch (ex: Throwable) {
-                    logger.info { "Failed to set property $name to $it" }
-                }
-            }
-        }
-    }
-}
-
-fun <D : Device, T : Any> D.fxProperty(spec: MutableDevicePropertySpec<D, T>): Property<T> =
-    object : ObjectPropertyBase<T>() {
-        override fun getBean(): Any = this
-        override fun getName(): String = spec.name
-
-        init {
-            //Read incoming changes
-            onPropertyChange(spec) {
-                runLater {
-                    try {
-                        set(it)
-                    } catch (ex: Throwable) {
-                        logger.info { "Failed to set property $name to $it" }
-                    }
-                }
-            }
-
-            onChange { newValue ->
-                if (newValue != null) {
-                    writeAsync(spec, newValue)
-                }
-            }
-        }
-    }
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
index c69b7fe..2de548d 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
@@ -18,41 +18,41 @@ val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
 @OptIn(InternalAPI::class)
 fun Context.launchPiDebugServer(port: Int, axes: List<String>): Job = launch(exceptionHandler) {
     val virtualDevice = PiMotionMasterVirtualDevice(this@launchPiDebugServer, axes)
-    val server = aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().bind("localhost", port)
-    println("Started virtual port server at ${server.localAddress}")
+    aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().bind("localhost", port).use { server ->
+        println("Started virtual port server at ${server.localAddress}")
 
-    while (isActive) {
-        val socket = server.accept()
-        launch(SupervisorJob(coroutineContext[Job])) {
-            println("Socket accepted: ${socket.remoteAddress}")
-            val input = socket.openReadChannel()
-            val output = socket.openWriteChannel()
+        while (isActive) {
+            val socket = server.accept()
+            launch(SupervisorJob(coroutineContext[Job])) {
+                println("Socket accepted: ${socket.remoteAddress}")
+                val input = socket.openReadChannel()
+                val output = socket.openWriteChannel()
 
-            val sendJob = launch {
-                virtualDevice.subscribe().collect {
-                    //println("Sending: ${it.decodeToString()}")
-                    output.writeAvailable(it)
-                    output.flush()
-                }
-            }
-
-            try {
-                while (isActive) {
-                    input.read { buffer ->
-                        val array = buffer.moveToByteArray()
-                        launch {
-                            virtualDevice.send(array)
-                        }
+                val sendJob = launch {
+                    virtualDevice.subscribe().collect {
+                        //println("Sending: ${it.decodeToString()}")
+                        output.writeAvailable(it)
+                        output.flush()
                     }
                 }
-            } catch (e: Throwable) {
-                e.printStackTrace()
-                sendJob.cancel()
-                socket.close()
-            } finally {
-                println("Socket closed")
-            }
 
+                try {
+                    while (isActive) {
+                        input.read { buffer ->
+                            val array = buffer.moveToByteArray()
+                            launch {
+                                virtualDevice.send(array)
+                            }
+                        }
+                    }
+                } catch (e: Throwable) {
+                    e.printStackTrace()
+                    sendJob.cancel()
+                    socket.close()
+                } finally {
+                    println("Client socket closed")
+                }
+            }
         }
     }
 }

From a9592d0372e07a32f9461a8493d2fb6573fca5ca Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 12 May 2024 14:14:31 +0300
Subject: [PATCH 081/125] Fixed part of motionMaster

---
 .../controls/ports/AsynchronousPort.kt        |  1 +
 .../pimotionmaster/PiMotionMasterApp.kt       | 32 ++++++++++---------
 .../pimotionmaster/PiMotionMasterDevice.kt    |  4 +--
 .../devices/pimotionmaster/piDebugServer.kt   | 15 +++++----
 4 files changed, 28 insertions(+), 24 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
index 4462a42..3f89e75 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
@@ -100,6 +100,7 @@ public abstract class AbstractAsynchronousPort(
      * Send a data packet via the port
      */
     override suspend fun send(data: ByteArray) {
+        check(isOpen){"The port is not opened"}
         outgoing.send(data)
     }
 
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
index 0762e9f..ab1c47a 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
@@ -45,20 +45,22 @@ fun ColumnScope.piMotionMasterAxis(
     axisName: String,
     axis: PiMotionMasterDevice.Axis,
 ) {
+    var min by remember { mutableStateOf(0f) }
+    var max by remember { mutableStateOf(1f) }
+    var targetPosition by remember { mutableStateOf(0f) }
+    val position: Double by axis.composeState(PiMotionMasterDevice.Axis.position, 0.0)
+
+    val scope = rememberCoroutineScope()
+
+    LaunchedEffect(axis) {
+        min = axis.read(PiMotionMasterDevice.Axis.minPosition).toFloat()
+        max = axis.read(PiMotionMasterDevice.Axis.maxPosition).toFloat()
+        targetPosition = axis.read(PiMotionMasterDevice.Axis.position).toFloat()
+    }
+
+
     Row {
         Text(axisName)
-        var min by remember { mutableStateOf(0f) }
-        var max by remember { mutableStateOf(0f) }
-        var targetPosition by remember { mutableStateOf(0f) }
-        val position: Double by axis.composeState(PiMotionMasterDevice.Axis.position, 0.0)
-
-        val scope = rememberCoroutineScope()
-
-        LaunchedEffect(axis) {
-            min = axis.read(PiMotionMasterDevice.Axis.minPosition).toFloat()
-            max = axis.read(PiMotionMasterDevice.Axis.maxPosition).toFloat()
-            targetPosition = axis.read(PiMotionMasterDevice.Axis.position).toFloat()
-        }
 
         Column {
             Slider(
@@ -70,10 +72,10 @@ fun ColumnScope.piMotionMasterAxis(
             Slider(
                 value = targetPosition,
                 onValueChange = { newPosition ->
+                    targetPosition = newPosition
                     scope.launch {
                         axis.move(newPosition.toDouble())
                     }
-                    targetPosition = newPosition
                 },
                 valueRange = min..max
             )
@@ -158,13 +160,13 @@ fun PiMotionMasterApp(device: PiMotionMasterDevice) {
                         if (!connected) {
                             device.launch {
                                 device.connect(host, port)
+                                axes = device.axes
                             }
-                            axes = device.axes
                         } else {
                             device.launch {
                                 device.disconnect()
+                                axes = null
                             }
-                            axes = null
                         }
                     },
                     modifier = Modifier.fillMaxWidth()
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index 4bde0b6..6c271d7 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -165,12 +165,12 @@ class PiMotionMasterDevice(
             }
             //Update port
             //address = portSpec.node
-            port = portFactory(portSpec, context)
-            propertyChanged(connected, true)
+            port = portFactory(portSpec, context).apply { open() }
 //        connector.open()
             //Initialize axes
             val idn = read(identity)
             failIfError { "Can't connect to $portSpec. Error code: $it" }
+            propertyChanged(connected, true)
             logger.info { "Connected to $idn on $portSpec" }
             val ids = request("SAI?").map { it.trim() }
             if (ids != axes.keys.toList()) {
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
index 2de548d..4c2b350 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
@@ -8,6 +8,8 @@ import io.ktor.util.InternalAPI
 import io.ktor.util.moveToByteArray
 import io.ktor.utils.io.writeAvailable
 import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Global
 
@@ -28,13 +30,12 @@ fun Context.launchPiDebugServer(port: Int, axes: List<String>): Job = launch(exc
                 val input = socket.openReadChannel()
                 val output = socket.openWriteChannel()
 
-                val sendJob = launch {
-                    virtualDevice.subscribe().collect {
-                        //println("Sending: ${it.decodeToString()}")
-                        output.writeAvailable(it)
-                        output.flush()
-                    }
-                }
+                val sendJob = virtualDevice.subscribe().onEach {
+                    //println("Sending: ${it.decodeToString()}")
+                    output.writeAvailable(it)
+                    output.flush()
+                }.launchIn(this)
+
 
                 try {
                     while (isActive) {

From 5921978122b501e04e6b744f7d4e7dbfdba79302 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 15 May 2024 22:49:08 +0300
Subject: [PATCH 082/125] [WIP] Refactor constructor

---
 README.md                                     |  10 ++
 controls-constructor/README.md                |   4 +-
 .../{ConstructorBinding.kt => Binding.kt}     |  17 +-
 .../controls/constructor/DeviceConstructor.kt |  10 +-
 .../controls/constructor/DeviceModel.kt       |  12 ++
 .../controls/constructor/DeviceState.kt       |  16 +-
 .../controls/constructor/TimerState.kt        |   5 +-
 .../controls/constructor/customState.kt       |  20 ++-
 .../controls/constructor/internalState.kt     |   2 -
 controls-core/README.md                       |   4 +-
 .../kscience/controls/manager/ClockManager.kt |  90 ++++++++--
 .../controls/spec/deviceExtensions.kt         |   6 +-
 controls-jupyter/README.md                    |   4 +-
 controls-magix/README.md                      |   4 +-
 controls-magix/build.gradle.kts               |   3 +-
 controls-modbus/README.md                     |   4 +-
 controls-opcua/README.md                      |   4 +-
 controls-pi/README.md                         |   4 +-
 controls-ports-ktor/README.md                 |   4 +-
 controls-serial/README.md                     |   4 +-
 controls-server/README.md                     |   4 +-
 controls-storage/README.md                    |   4 +-
 controls-storage/controls-xodus/README.md     |   4 +-
 controls-vision/README.md                     |   6 +-
 controls-vision/build.gradle.kts              |   1 +
 controls-vision/docs/README-TEMPLATE.md       |  15 ++
 .../kotlin/BooleanIndicatorVision.kt          |  24 +++
 .../commonMain/kotlin/ControlVisionPlugin.kt  |   3 +-
 .../src/commonMain/kotlin/IndicatorVision.kt  |  20 ---
 .../src/commonMain/kotlin/plotExtensions.kt   |   2 +-
 .../jsMain/kotlin/ControlsVisionPlugin.js.kt  |  16 ++
 .../src/jvmMain/kotlin/dashboard.kt           |  47 ++++--
 .../kotlin/{main.kt => LinearDrive.kt}        | 155 ++++++++++--------
 .../pimotionmaster/PiMotionMasterApp.kt       |   2 +-
 docs/templates/ARTIFACT-TEMPLATE.md           |  30 ----
 magix/magix-api/README.md                     |   4 +-
 magix/magix-java-endpoint/README.md           |   4 +-
 magix/magix-mqtt/README.md                    |   4 +-
 magix/magix-rabbit/README.md                  |   4 +-
 magix/magix-rsocket/README.md                 |   4 +-
 magix/magix-server/README.md                  |   4 +-
 magix/magix-storage/README.md                 |   4 +-
 .../magix-storage-xodus/README.md             |   4 +-
 magix/magix-zmq/README.md                     |   4 +-
 44 files changed, 367 insertions(+), 229 deletions(-)
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/{ConstructorBinding.kt => Binding.kt} (67%)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
 create mode 100644 controls-vision/docs/README-TEMPLATE.md
 create mode 100644 controls-vision/src/commonMain/kotlin/BooleanIndicatorVision.kt
 delete mode 100644 controls-vision/src/commonMain/kotlin/IndicatorVision.kt
 rename demo/constructor/src/jvmMain/kotlin/{main.kt => LinearDrive.kt} (74%)
 delete mode 100644 docs/templates/ARTIFACT-TEMPLATE.md

diff --git a/README.md b/README.md
index d6a602b..d5baaf9 100644
--- a/README.md
+++ b/README.md
@@ -104,6 +104,11 @@ Automatically checks consistency.
 >
 > **Maturity**: EXPERIMENTAL
 
+### [controls-plc4x](controls-plc4x)
+> A plugin for Controls-kt device server on top of plc4x library
+>
+> **Maturity**: EXPERIMENTAL
+
 ### [controls-ports-ktor](controls-ports-ktor)
 > Implementation of byte ports on top os ktor-io asynchronous API
 >
@@ -209,6 +214,11 @@ Automatically checks consistency.
 >
 > **Maturity**: PROTOTYPE
 
+### [magix/magix-utils](magix/magix-utils)
+> Common utilities and services for Magix endpoints.   
+>
+> **Maturity**: EXPERIMENTAL
+
 ### [magix/magix-zmq](magix/magix-zmq)
 > ZMQ client endpoint for Magix
 >
diff --git a/controls-constructor/README.md b/controls-constructor/README.md
index 9a8b27c..d388b01 100644
--- a/controls-constructor/README.md
+++ b/controls-constructor/README.md
@@ -6,7 +6,7 @@ A low-code constructor for composite devices simulation
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-constructor:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-constructor:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-constructor:0.3.0")
+    implementation("space.kscience:controls-constructor:0.4.0-dev-1")
 }
 ```
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorBinding.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Binding.kt
similarity index 67%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorBinding.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Binding.kt
index 7ad370e..d712d62 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorBinding.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Binding.kt
@@ -2,7 +2,10 @@ package space.kscience.controls.constructor
 
 import space.kscience.controls.api.Device
 
-public sealed interface ConstructorBinding
+/**
+ * A binding that is used to describe device functionality
+ */
+public sealed interface Binding
 
 /**
  * A binding that exposes device property as read-only state
@@ -11,16 +14,22 @@ public class PropertyBinding<T>(
     public val device: Device,
     public val propertyName: String,
     public val state: DeviceState<T>,
-) : ConstructorBinding
+) : Binding
 
 /**
  * A binding for independent state like a timer
  */
 public class StateBinding<T>(
     public val state: DeviceState<T>
-) : ConstructorBinding
+) : Binding
 
 public class ActionBinding(
     public val reads: Collection<DeviceState<*>>,
     public val writes: Collection<DeviceState<*>>
-): ConstructorBinding
\ No newline at end of file
+): Binding
+
+
+public interface BindingsContainer{
+    public val bindings: List<Binding>
+    public fun registerBinding(binding: Binding)
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 7824e3c..5b6ff9f 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -26,11 +26,11 @@ import kotlin.time.Duration
 public abstract class DeviceConstructor(
     context: Context,
     meta: Meta = Meta.EMPTY,
-) : DeviceGroup(context, meta) {
-    private val _bindings: MutableList<ConstructorBinding> = mutableListOf()
-    public val bindings: List<ConstructorBinding> get() = _bindings
+) : DeviceGroup(context, meta), BindingsContainer {
+    private val _bindings: MutableList<Binding> = mutableListOf()
+    override val bindings: List<Binding> get() = _bindings
 
-    public fun registerBinding(binding: ConstructorBinding) {
+    override fun registerBinding(binding: Binding) {
         _bindings.add(binding)
     }
 
@@ -46,7 +46,7 @@ public abstract class DeviceConstructor(
         .also { registerBinding(StateBinding(it)) }
 
     /**
-     * Launch action that is performed on each [DeviceState] value change.
+     * Bind an action to a [DeviceState]. [onChange] block is performed on each state change
      *
      * Optionally provide [writes] - a set of states that this change affects.
      */
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
new file mode 100644
index 0000000..08913a2
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
@@ -0,0 +1,12 @@
+package space.kscience.controls.constructor
+
+public abstract class DeviceModel : BindingsContainer {
+
+    private val _bindings: MutableList<Binding> = mutableListOf()
+
+    override val bindings: List<Binding> get() = _bindings
+
+    override fun registerBinding(binding: Binding) {
+        _bindings.add(binding)
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index 9a25f00..8a2914b 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -63,25 +63,25 @@ public interface DeviceStateWithDependencies<T> : DeviceState<T> {
 /**
  * Create a new read-only [DeviceState] that mirrors receiver state by mapping the value with [mapper].
  */
-public fun <T, R> DeviceState<T>.map(
+public fun <T, R> DeviceState.Companion.map(
+    state: DeviceState<T>,
     converter: MetaConverter<R>, mapper: (T) -> R,
 ): DeviceStateWithDependencies<R> = object : DeviceStateWithDependencies<R> {
-    override val dependencies = listOf(this)
+    override val dependencies = listOf(state)
 
     override val converter: MetaConverter<R> = converter
 
-    override val value: R
-        get() = mapper(this@map.value)
+    override val value: R get() = mapper(state.value)
 
-    override val valueFlow: Flow<R> = this@map.valueFlow.map(mapper)
+    override val valueFlow: Flow<R> = state.valueFlow.map(mapper)
 
-    override fun toString(): String = "DeviceState.map(arg=${this@map}, converter=$converter)"
+    override fun toString(): String = "DeviceState.map(arg=${state}, converter=$converter)"
 }
 
 /**
  * Combine two device states into one read-only [DeviceState]. Only the latest value of each state is used.
  */
-public fun <T1, T2, R> combine(
+public fun <T1, T2, R> DeviceState.Companion.combine(
     state1: DeviceState<T1>,
     state2: DeviceState<T2>,
     converter: MetaConverter<R>,
@@ -96,4 +96,4 @@ public fun <T1, T2, R> combine(
     override val valueFlow: Flow<R> = kotlinx.coroutines.flow.combine(state1.valueFlow, state2.valueFlow, mapper)
 
     override fun toString(): String = "DeviceState.combine(state1=$state1, state2=$state2)"
-}
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
index 7b10cb9..8f36079 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
@@ -1,5 +1,6 @@
 package space.kscience.controls.constructor
 
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.isActive
@@ -26,9 +27,9 @@ public class TimerState(
 
     private val clock = MutableStateFlow(clockManager.clock.now())
 
-    private val updateJob = clockManager.context.launch {
+    private val updateJob = clockManager.context.launch(clockManager.asDispatcher()) {
         while (isActive) {
-            clockManager.delay(tick)
+            delay(tick)
             clock.value = clockManager.clock.now()
         }
     }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
index 881bdea..277271a 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
@@ -8,7 +8,7 @@ import space.kscience.dataforge.meta.MetaConverter
 /**
  *  A state describing a [Double] value in the [range]
  */
-public class DoubleRangeState(
+public class DoubleInRangeState(
     initialValue: Double,
     public val range: ClosedFloatingPointRange<Double>,
 ) : MutableDeviceState<Double> {
@@ -32,20 +32,28 @@ public class DoubleRangeState(
     /**
      * A state showing that the range is on its lower boundary
      */
-    public val atStartState: DeviceState<Boolean> = map(MetaConverter.boolean) { it <= range.start }
+    public val atStart: DeviceState<Boolean> = DeviceState.map(this, MetaConverter.boolean) {
+        it <= range.start
+    }
 
     /**
      * A state showing that the range is on its higher boundary
      */
-    public val atEndState: DeviceState<Boolean> = map(MetaConverter.boolean) { it >= range.endInclusive }
+    public val atEnd: DeviceState<Boolean> = DeviceState.map(this, MetaConverter.boolean) {
+        it >= range.endInclusive
+    }
 
     override fun toString(): String = "DoubleRangeState(range=$range, converter=$converter)"
 
 
 }
 
-@Suppress("UnusedReceiverParameter")
-public fun DeviceGroup.rangeState(
+/**
+ * Create and register a [DoubleInRangeState]
+ */
+public fun BindingsContainer.doubleInRangeState(
     initialValue: Double,
     range: ClosedFloatingPointRange<Double>,
-): DoubleRangeState = DoubleRangeState(initialValue, range)
\ No newline at end of file
+): DoubleInRangeState = DoubleInRangeState(initialValue, range).also {
+    registerBinding(StateBinding(it))
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
index 6f9d086..ed2d5d1 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
@@ -25,8 +25,6 @@ private class VirtualDeviceState<T>(
         }
 
     override fun toString(): String = "VirtualDeviceState(converter=$converter)"
-
-
 }
 
 
diff --git a/controls-core/README.md b/controls-core/README.md
index b1edbdc..71caf53 100644
--- a/controls-core/README.md
+++ b/controls-core/README.md
@@ -16,7 +16,7 @@ Core interfaces for building a device server
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-core:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-core:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -26,6 +26,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-core:0.3.0")
+    implementation("space.kscience:controls-core:0.4.0-dev-1")
 }
 ```
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
index 2f852e1..20ba47e 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
@@ -1,24 +1,80 @@
 package space.kscience.controls.manager
 
+import kotlinx.coroutines.*
 import kotlinx.datetime.Clock
-import space.kscience.dataforge.context.AbstractPlugin
-import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.context.PluginFactory
-import space.kscience.dataforge.context.PluginTag
+import kotlinx.datetime.Instant
+import space.kscience.controls.api.Device
+import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.Meta
-import kotlin.time.Duration
+import space.kscience.dataforge.meta.double
+import kotlin.coroutines.CoroutineContext
+import kotlin.math.roundToLong
+
+@OptIn(InternalCoroutinesApi::class)
+private class CompressedTimeDispatcher(
+    val dispatcher: CoroutineDispatcher,
+    val compression: Double,
+) : CoroutineDispatcher(), Delay {
+
+    @InternalCoroutinesApi
+    override fun dispatchYield(context: CoroutineContext, block: Runnable) {
+        dispatcher.dispatchYield(context, block)
+    }
+
+    override fun isDispatchNeeded(context: CoroutineContext): Boolean = dispatcher.isDispatchNeeded(context)
+
+    @ExperimentalCoroutinesApi
+    override fun limitedParallelism(parallelism: Int): CoroutineDispatcher = dispatcher.limitedParallelism(parallelism)
+
+    override fun dispatch(context: CoroutineContext, block: Runnable) {
+        dispatcher.dispatch(context, block)
+    }
+
+    private val delay = ((dispatcher as? Delay) ?: (Dispatchers.Default as Delay))
+
+    override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) {
+        delay.scheduleResumeAfterDelay((timeMillis / compression).roundToLong(), continuation)
+    }
+
+
+    override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle {
+        return delay.invokeOnTimeout((timeMillis / compression).roundToLong(), block, context)
+    }
+}
+
+private class CompressedClock(
+    val start: Instant,
+    val compression: Double,
+    val baseClock: Clock = Clock.System,
+) : Clock {
+    override fun now(): Instant {
+        val elapsed = (baseClock.now() - start)
+        return start + elapsed / compression
+    }
+}
 
 public class ClockManager : AbstractPlugin() {
     override val tag: PluginTag get() = Companion.tag
 
+    public val timeCompression: Double by meta.double(1.0)
+
     public val clock: Clock by lazy {
-        //TODO add clock customization
-        Clock.System
+        if (timeCompression == 1.0) {
+            Clock.System
+        } else {
+            CompressedClock(Clock.System.now(), timeCompression)
+        }
     }
 
-    public suspend fun delay(duration: Duration) {
-        //TODO add time compression
-        kotlinx.coroutines.delay(duration)
+    /**
+     * Provide a [CoroutineDispatcher] with compressed time based on given [dispatcher]
+     */
+    public fun asDispatcher(
+        dispatcher: CoroutineDispatcher = Dispatchers.Default,
+    ): CoroutineDispatcher = if (timeCompression == 1.0) {
+        dispatcher
+    } else {
+        CompressedTimeDispatcher(dispatcher, timeCompression)
     }
 
     public companion object : PluginFactory<ClockManager> {
@@ -28,4 +84,16 @@ public class ClockManager : AbstractPlugin() {
     }
 }
 
-public val Context.clock: Clock get() = plugins[ClockManager]?.clock ?: Clock.System
\ No newline at end of file
+public val Context.clock: Clock get() = plugins[ClockManager]?.clock ?: Clock.System
+
+public val Device.clock: Clock get() = context.clock
+
+public fun Device.getCoroutineDispatcher(dispatcher: CoroutineDispatcher = Dispatchers.Default): CoroutineDispatcher =
+    context.plugins[ClockManager]?.asDispatcher(dispatcher) ?: dispatcher
+
+public fun ContextBuilder.withTimeCompression(compression: Double) {
+    require(compression > 0.0) { "Time compression must be greater than zero." }
+    plugin(ClockManager) {
+        "timeCompression" put compression
+    }
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt
index ee47ea0..3344407 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt
@@ -4,6 +4,7 @@ import kotlinx.coroutines.*
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flow
 import space.kscience.controls.api.Device
+import space.kscience.controls.manager.getCoroutineDispatcher
 import kotlin.time.Duration
 
 /**
@@ -15,11 +16,12 @@ public fun <D : Device> D.doRecurring(
     task: suspend D.() -> Unit,
 ): Job {
     val taskName = debugTaskName ?: "task[${task.hashCode().toString(16)}]"
-    return launch(CoroutineName(taskName)) {
+    val dispatcher = getCoroutineDispatcher()
+    return launch(CoroutineName(taskName) + dispatcher) {
         while (isActive) {
             delay(interval)
             //launch in parent scope to properly evaluate exceptions
-            this@doRecurring.launch {
+            this@doRecurring.launch(CoroutineName("$taskName-recurring") + dispatcher) {
                 task()
             }
         }
diff --git a/controls-jupyter/README.md b/controls-jupyter/README.md
index 15d8e2d..7d0fc4f 100644
--- a/controls-jupyter/README.md
+++ b/controls-jupyter/README.md
@@ -6,7 +6,7 @@
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-jupyter:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-jupyter:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-jupyter:0.3.0")
+    implementation("space.kscience:controls-jupyter:0.4.0-dev-1")
 }
 ```
diff --git a/controls-magix/README.md b/controls-magix/README.md
index 4048990..8c16ffd 100644
--- a/controls-magix/README.md
+++ b/controls-magix/README.md
@@ -12,7 +12,7 @@ Magix service for binding controls devices (both as RPC client and server)
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-magix:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-magix:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -22,6 +22,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-magix:0.3.0")
+    implementation("space.kscience:controls-magix:0.4.0-dev-1")
 }
 ```
diff --git a/controls-magix/build.gradle.kts b/controls-magix/build.gradle.kts
index 055ec98..040c040 100644
--- a/controls-magix/build.gradle.kts
+++ b/controls-magix/build.gradle.kts
@@ -12,7 +12,8 @@ description = """
 kscience {
     jvm()
     js()
-    useCoroutines("1.8.0")
+    native()
+    useCoroutines()
     useSerialization {
         json()
     }
diff --git a/controls-modbus/README.md b/controls-modbus/README.md
index 18c37ee..61e4b60 100644
--- a/controls-modbus/README.md
+++ b/controls-modbus/README.md
@@ -14,7 +14,7 @@ Automatically checks consistency.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-modbus:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-modbus:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -24,6 +24,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-modbus:0.3.0")
+    implementation("space.kscience:controls-modbus:0.4.0-dev-1")
 }
 ```
diff --git a/controls-opcua/README.md b/controls-opcua/README.md
index 3befbb9..03dc32b 100644
--- a/controls-opcua/README.md
+++ b/controls-opcua/README.md
@@ -12,7 +12,7 @@ A client and server connectors for OPC-UA via Eclipse Milo
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-opcua:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-opcua:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -22,6 +22,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-opcua:0.3.0")
+    implementation("space.kscience:controls-opcua:0.4.0-dev-1")
 }
 ```
diff --git a/controls-pi/README.md b/controls-pi/README.md
index 6235995..2873ac2 100644
--- a/controls-pi/README.md
+++ b/controls-pi/README.md
@@ -6,7 +6,7 @@ Utils to work with controls-kt on Raspberry pi
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-pi:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-pi:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-pi:0.3.0")
+    implementation("space.kscience:controls-pi:0.4.0-dev-1")
 }
 ```
diff --git a/controls-ports-ktor/README.md b/controls-ports-ktor/README.md
index b703521..6b23d80 100644
--- a/controls-ports-ktor/README.md
+++ b/controls-ports-ktor/README.md
@@ -6,7 +6,7 @@ Implementation of byte ports on top os ktor-io asynchronous API
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-ports-ktor:0.3.0")
+    implementation("space.kscience:controls-ports-ktor:0.4.0-dev-1")
 }
 ```
diff --git a/controls-serial/README.md b/controls-serial/README.md
index 861b6d3..a961f55 100644
--- a/controls-serial/README.md
+++ b/controls-serial/README.md
@@ -6,7 +6,7 @@ Implementation of direct serial port communication with JSerialComm
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-serial:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-serial:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-serial:0.3.0")
+    implementation("space.kscience:controls-serial:0.4.0-dev-1")
 }
 ```
diff --git a/controls-server/README.md b/controls-server/README.md
index 86a5bf5..114f618 100644
--- a/controls-server/README.md
+++ b/controls-server/README.md
@@ -6,7 +6,7 @@ A combined Magix event loop server with web server for visualization.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-server:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-server:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-server:0.3.0")
+    implementation("space.kscience:controls-server:0.4.0-dev-1")
 }
 ```
diff --git a/controls-storage/README.md b/controls-storage/README.md
index 3242de2..64751e8 100644
--- a/controls-storage/README.md
+++ b/controls-storage/README.md
@@ -6,7 +6,7 @@ An API for stand-alone Controls-kt device or a hub.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-storage:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-storage:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-storage:0.3.0")
+    implementation("space.kscience:controls-storage:0.4.0-dev-1")
 }
 ```
diff --git a/controls-storage/controls-xodus/README.md b/controls-storage/controls-xodus/README.md
index 2a6c247..e423c3f 100644
--- a/controls-storage/controls-xodus/README.md
+++ b/controls-storage/controls-xodus/README.md
@@ -6,7 +6,7 @@ An implementation of controls-storage on top of JetBrains Xodus.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-xodus:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-xodus:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-xodus:0.3.0")
+    implementation("space.kscience:controls-xodus:0.4.0-dev-1")
 }
 ```
diff --git a/controls-vision/README.md b/controls-vision/README.md
index 41483ce..cbfc917 100644
--- a/controls-vision/README.md
+++ b/controls-vision/README.md
@@ -2,11 +2,13 @@
 
 Dashboard and visualization extensions for devices
 
+Hello world!
+
 ## Usage
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-vision:0.3.0`.
+The Maven coordinates of this project are `space.kscience:controls-vision:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +18,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-vision:0.3.0")
+    implementation("space.kscience:controls-vision:0.4.0-dev-1")
 }
 ```
diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
index c36cdce..5c75242 100644
--- a/controls-vision/build.gradle.kts
+++ b/controls-vision/build.gradle.kts
@@ -10,6 +10,7 @@ description = """
 kscience {
     fullStack("js/controls-vision.js")
     useKtor()
+    useSerialization()
     useContextReceivers()
     dependencies {
         api(projects.controlsCore)
diff --git a/controls-vision/docs/README-TEMPLATE.md b/controls-vision/docs/README-TEMPLATE.md
new file mode 100644
index 0000000..63e6d31
--- /dev/null
+++ b/controls-vision/docs/README-TEMPLATE.md
@@ -0,0 +1,15 @@
+# Module ${name}
+
+${description}
+
+<#if features?has_content>
+## Features
+
+${features}
+
+</#if>
+<#if published>
+## Usage
+
+${artifact}
+</#if>
diff --git a/controls-vision/src/commonMain/kotlin/BooleanIndicatorVision.kt b/controls-vision/src/commonMain/kotlin/BooleanIndicatorVision.kt
new file mode 100644
index 0000000..8783228
--- /dev/null
+++ b/controls-vision/src/commonMain/kotlin/BooleanIndicatorVision.kt
@@ -0,0 +1,24 @@
+package space.kscience.controls.vision
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import space.kscience.dataforge.meta.boolean
+import space.kscience.visionforge.AbstractVision
+import space.kscience.visionforge.Vision
+import space.kscience.visionforge.html.VisionOfHtml
+
+/**
+ * A [Vision] that shows an indicator
+ */
+@Serializable
+@SerialName("controls.indicator")
+public class BooleanIndicatorVision : AbstractVision(), VisionOfHtml {
+    public val isOn: Boolean by properties.boolean(false)
+}
+
+///**
+// * A [Vision] that allows both showing the value and changing it
+// */
+//public interface RegulatorVision: IndicatorVision{
+//
+//}
\ No newline at end of file
diff --git a/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
index d587250..6395d53 100644
--- a/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
+++ b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
@@ -6,7 +6,6 @@ import kotlinx.serialization.modules.subclass
 import space.kscience.dataforge.context.PluginFactory
 import space.kscience.visionforge.Vision
 import space.kscience.visionforge.VisionPlugin
-import space.kscience.visionforge.plotly.VisionOfPlotly
 
 public expect class ControlVisionPlugin: VisionPlugin{
     public companion object: PluginFactory<ControlVisionPlugin>
@@ -14,6 +13,6 @@ public expect class ControlVisionPlugin: VisionPlugin{
 
 internal val controlsVisionSerializersModule = SerializersModule {
     polymorphic(Vision::class) {
-        subclass(VisionOfPlotly.serializer())
+        subclass(BooleanIndicatorVision.serializer())
     }
 }
\ No newline at end of file
diff --git a/controls-vision/src/commonMain/kotlin/IndicatorVision.kt b/controls-vision/src/commonMain/kotlin/IndicatorVision.kt
deleted file mode 100644
index 9ec2319..0000000
--- a/controls-vision/src/commonMain/kotlin/IndicatorVision.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package space.kscience.controls.vision
-
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.node
-import space.kscience.visionforge.AbstractVision
-import space.kscience.visionforge.Vision
-
-/**
- * A [Vision] that shows an indicator
- */
-public class IndicatorVision: AbstractVision() {
-    public val value: Meta? by properties.node()
-}
-
-///**
-// * A [Vision] that allows both showing the value and changing it
-// */
-//public interface RegulatorVision: IndicatorVision{
-//
-//}
\ No newline at end of file
diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index 6dfdee0..88b870e 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -1,4 +1,4 @@
-@file:OptIn(FlowPreview::class)
+@file:OptIn(FlowPreview::class, FlowPreview::class)
 
 package space.kscience.controls.vision
 
diff --git a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
index 55074ae..d08772c 100644
--- a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
+++ b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
@@ -5,13 +5,29 @@ import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.PluginFactory
 import space.kscience.dataforge.context.PluginTag
 import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.asName
 import space.kscience.visionforge.VisionPlugin
+import space.kscience.visionforge.html.ElementVisionRenderer
+
+private val indicatorRenderer = ElementVisionRenderer<BooleanIndicatorVision> { name, vision: BooleanIndicatorVision, meta ->
+
+}
+
 
 public actual class ControlVisionPlugin : VisionPlugin() {
     override val tag: PluginTag get() = Companion.tag
 
     override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule
 
+    override fun content(target: String): Map<Name, Any> = when (target) {
+        ElementVisionRenderer.TYPE -> mapOf(
+            "indicator".asName() to indicatorRenderer
+        )
+
+        else -> super.content(target)
+    }
+
     public actual companion object : PluginFactory<ControlVisionPlugin> {
         override val tag: PluginTag = PluginTag("controls.vision")
 
diff --git a/controls-vision/src/jvmMain/kotlin/dashboard.kt b/controls-vision/src/jvmMain/kotlin/dashboard.kt
index 29c4740..1b64b15 100644
--- a/controls-vision/src/jvmMain/kotlin/dashboard.kt
+++ b/controls-vision/src/jvmMain/kotlin/dashboard.kt
@@ -13,6 +13,8 @@ import space.kscience.plotly.PlotlyConfig
 import space.kscience.visionforge.html.HtmlVisionFragment
 import space.kscience.visionforge.html.VisionPage
 import space.kscience.visionforge.html.VisionTagConsumer
+import space.kscience.visionforge.markup.MarkupPlugin
+import space.kscience.visionforge.plotly.PlotlyPlugin
 import space.kscience.visionforge.plotly.plotly
 import space.kscience.visionforge.server.VisionRoute
 import space.kscience.visionforge.server.close
@@ -25,29 +27,38 @@ public fun Context.showDashboard(
     routes: Routing.() -> Unit = {},
     configurationBuilder: VisionRoute.() -> Unit = {},
     visionFragment: HtmlVisionFragment,
-): ApplicationEngine = embeddedServer(CIO, port = port) {
-    routing {
-        staticResources("", null, null)
-        routes()
+): ApplicationEngine {
+    //create a sub-context for visualization
+    val visualisationContext = buildContext {
+        plugin(PlotlyPlugin)
+        plugin(ControlVisionPlugin)
+        plugin(MarkupPlugin)
     }
 
-    visionPage(
-        visionManager,
-        VisionPage.scriptHeader("js/controls-vision.js"),
-        configurationBuilder = configurationBuilder,
-        visionFragment = visionFragment
-    )
-}.also {
-    it.start(false)
-    it.openInBrowser()
+    return visualisationContext.embeddedServer(CIO, port = port) {
+        routing {
+            staticResources("", null, null)
+            routes()
+        }
+
+        visionPage(
+            visualisationContext.visionManager,
+            VisionPage.scriptHeader("js/controls-vision.js"),
+            configurationBuilder = configurationBuilder,
+            visionFragment = visionFragment
+        )
+    }.also {
+        it.start(false)
+        it.openInBrowser()
 
 
-    println("Enter 'exit' to close server")
-    while (readlnOrNull() != "exit") {
-        //
+        println("Enter 'exit' to close server")
+        while (readlnOrNull() != "exit") {
+            //
+        }
+
+        it.close()
     }
-
-    it.close()
 }
 
 context(VisionTagConsumer<*>)
diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt
similarity index 74%
rename from demo/constructor/src/jvmMain/kotlin/main.kt
rename to demo/constructor/src/jvmMain/kotlin/LinearDrive.kt
index e426e1b..95df7ad 100644
--- a/demo/constructor/src/jvmMain/kotlin/main.kt
+++ b/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt
@@ -14,8 +14,10 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.Window
 import androidx.compose.ui.window.application
-import kotlinx.coroutines.launch
-import space.kscience.controls.constructor.*
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.DoubleInRangeState
+import space.kscience.controls.constructor.device
+import space.kscience.controls.constructor.deviceProperty
 import space.kscience.controls.constructor.library.*
 import space.kscience.controls.manager.ClockManager
 import space.kscience.controls.manager.DeviceManager
@@ -40,77 +42,41 @@ import kotlin.time.DurationUnit
 
 
 class LinearDrive(
+    drive: Drive,
+    start: LimitSwitch,
+    end: LimitSwitch,
+    pidParameters: PidParameters,
+    meta: Meta = Meta.EMPTY,
+) : DeviceConstructor(drive.context, meta) {
+
+    val drive: Drive by device(drive)
+    val pid by device(PidRegulator(drive, pidParameters))
+
+    val start by device(start)
+    val end by device(end)
+
+    val position by deviceProperty(drive, Drive.position, Double.NaN)
+
+    val target by deviceProperty(pid, Regulator.target, 0.0)
+}
+
+/**
+ * A shortcut to create a virtual [LimitSwitch] from [DoubleInRangeState]
+ */
+fun LinearDrive(
     context: Context,
-    state: DoubleRangeState,
+    positionState: DoubleInRangeState,
     mass: Double,
     pidParameters: PidParameters,
     meta: Meta = Meta.EMPTY,
-) : DeviceConstructor(context, meta) {
+): LinearDrive = LinearDrive(
+    drive = VirtualDrive(context, mass, positionState),
+    start = VirtualLimitSwitch(context, positionState.atStart),
+    end = VirtualLimitSwitch(context, positionState.atEnd),
+    pidParameters = pidParameters,
+    meta = meta
+)
 
-    val drive by device(VirtualDrive.factory(mass, state))
-    val pid by device(PidRegulator(drive, pidParameters))
-
-    val start by device(LimitSwitch(state.atStartState))
-    val end by device(LimitSwitch(state.atEndState))
-
-
-    val positionState: DoubleRangeState by property(state)
-
-    private val targetState: MutableDeviceState<Double> by deviceProperty(pid, Regulator.target, 0.0)
-    var target: Double by targetState
-}
-
-
-private fun Context.launchPidDevice(
-    state: DoubleRangeState,
-    pidParameters: PidParameters,
-    mass: Double,
-) = launch {
-    val device = install(
-        "device",
-        LinearDrive(this@launchPidDevice, state, mass, pidParameters)
-    ).apply {
-        val clock = context.clock
-        val clockStart = clock.now()
-        doRecurring(10.milliseconds) {
-            val timeFromStart = clock.now() - clockStart
-            val t = timeFromStart.toDouble(DurationUnit.SECONDS)
-            val freq = 0.1
-            target = 5 * sin(2.0 * PI * freq * t) +
-                    sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))
-        }
-    }
-
-
-    val maxAge = 10.seconds
-
-    showDashboard {
-        plot {
-            plotNumberState(context, state, maxAge = maxAge, sampling = 50.milliseconds) {
-                name = "real position"
-            }
-            plotDeviceProperty(device.pid, Regulator.position.name, maxAge = maxAge, sampling = 50.milliseconds) {
-                name = "read position"
-            }
-
-            plotDeviceProperty(device.pid, Regulator.target.name, maxAge = maxAge, sampling = 50.milliseconds) {
-                name = "target"
-            }
-        }
-
-        plot {
-            plotDeviceProperty(device.start, LimitSwitch.locked.name, maxAge = maxAge, sampling = 50.milliseconds) {
-                name = "start measured"
-                mode = ScatterMode.markers
-            }
-            plotDeviceProperty(device.end, LimitSwitch.locked.name, maxAge = maxAge, sampling = 50.milliseconds) {
-                name = "end measured"
-                mode = ScatterMode.markers
-            }
-        }
-
-    }
-}
 
 fun main() = application {
     val context = Context {
@@ -140,12 +106,57 @@ fun main() = application {
         )
     }
 
-    context.launchPidDevice(
-        DoubleRangeState(0.0, -6.0..6.0),
-        pidParameters,
-        mass = 0.05
+    val state = DoubleInRangeState(0.0, -6.0..6.0)
+
+    val linearDrive = context.install(
+        "linearDrive",
+        LinearDrive(context, state, 0.05, pidParameters)
     )
 
+    val clockStart = context.clock.now()
+    linearDrive.doRecurring(10.milliseconds) {
+        val timeFromStart = clock.now() - clockStart
+        val t = timeFromStart.toDouble(DurationUnit.SECONDS)
+        val freq = 0.1
+        target.value = 5 * sin(2.0 * PI * freq * t) +
+                sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))
+    }
+
+
+    val maxAge = 10.seconds
+
+    context.showDashboard {
+        plot {
+            plotNumberState(context, state, maxAge = maxAge, sampling = 50.milliseconds) {
+                name = "real position"
+            }
+            plotDeviceProperty(linearDrive.pid, Regulator.position.name, maxAge = maxAge, sampling = 50.milliseconds) {
+                name = "read position"
+            }
+
+            plotDeviceProperty(linearDrive.pid, Regulator.target.name, maxAge = maxAge, sampling = 50.milliseconds) {
+                name = "target"
+            }
+        }
+
+        plot {
+            plotDeviceProperty(
+                linearDrive.start,
+                LimitSwitch.locked.name,
+                maxAge = maxAge,
+                sampling = 50.milliseconds
+            ) {
+                name = "start measured"
+                mode = ScatterMode.markers
+            }
+            plotDeviceProperty(linearDrive.end, LimitSwitch.locked.name, maxAge = maxAge, sampling = 50.milliseconds) {
+                name = "end measured"
+                mode = ScatterMode.markers
+            }
+        }
+
+    }
+
     Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
         MaterialTheme {
             Column {
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
index ab1c47a..b64cd2d 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
@@ -97,7 +97,7 @@ fun AxisPane(axes: Map<String, PiMotionMasterDevice.Axis>) {
 @Composable
 fun PiMotionMasterApp(device: PiMotionMasterDevice) {
 
-    val scope = rememberCoroutineScope()
+//    val scope = rememberCoroutineScope()
     val connected by device.composeState(PiMotionMasterDevice.connected, false)
     var debugServerJob by remember { mutableStateOf<Job?>(null) }
     var axes by remember { mutableStateOf<Map<String, PiMotionMasterDevice.Axis>?>(null) }
diff --git a/docs/templates/ARTIFACT-TEMPLATE.md b/docs/templates/ARTIFACT-TEMPLATE.md
deleted file mode 100644
index a3e47e6..0000000
--- a/docs/templates/ARTIFACT-TEMPLATE.md
+++ /dev/null
@@ -1,30 +0,0 @@
-## Artifact:
-
-The Maven coordinates of this project are `${group}:${name}:${version}`.
-
-**Gradle:**
-```groovy
-repositories {
-    maven { url 'https://repo.kotlin.link' }
-    mavenCentral()
-    // development and snapshot versions
-    maven { url 'https://maven.pkg.jetbrains.space/spc/p/sci/dev' }
-}
-
-dependencies {
-    implementation '${group}:${name}:${version}'
-}
-```
-**Gradle Kotlin DSL:**
-```kotlin
-repositories {
-    maven("https://repo.kotlin.link")
-    mavenCentral()
-    // development and snapshot versions
-    maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
-}
-
-dependencies {
-    implementation("${group}:${name}:${version}")
-}
-```
\ No newline at end of file
diff --git a/magix/magix-api/README.md b/magix/magix-api/README.md
index d624d84..6228a7e 100644
--- a/magix/magix-api/README.md
+++ b/magix/magix-api/README.md
@@ -6,7 +6,7 @@ A kotlin API for magix standard and some zero-dependency magix services
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-api:0.3.0`.
+The Maven coordinates of this project are `space.kscience:magix-api:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-api:0.3.0")
+    implementation("space.kscience:magix-api:0.4.0-dev-1")
 }
 ```
diff --git a/magix/magix-java-endpoint/README.md b/magix/magix-java-endpoint/README.md
index 40f4c8e..2db1d42 100644
--- a/magix/magix-java-endpoint/README.md
+++ b/magix/magix-java-endpoint/README.md
@@ -6,7 +6,7 @@ Java API to work with magix endpoints without Kotlin
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.3.0`.
+The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-java-endpoint:0.3.0")
+    implementation("space.kscience:magix-java-endpoint:0.4.0-dev-1")
 }
 ```
diff --git a/magix/magix-mqtt/README.md b/magix/magix-mqtt/README.md
index 35165a0..7896472 100644
--- a/magix/magix-mqtt/README.md
+++ b/magix/magix-mqtt/README.md
@@ -6,7 +6,7 @@ MQTT client magix endpoint
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-mqtt:0.3.0`.
+The Maven coordinates of this project are `space.kscience:magix-mqtt:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-mqtt:0.3.0")
+    implementation("space.kscience:magix-mqtt:0.4.0-dev-1")
 }
 ```
diff --git a/magix/magix-rabbit/README.md b/magix/magix-rabbit/README.md
index 609303d..eee4a21 100644
--- a/magix/magix-rabbit/README.md
+++ b/magix/magix-rabbit/README.md
@@ -6,7 +6,7 @@ RabbitMQ client magix endpoint
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-rabbit:0.3.0`.
+The Maven coordinates of this project are `space.kscience:magix-rabbit:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-rabbit:0.3.0")
+    implementation("space.kscience:magix-rabbit:0.4.0-dev-1")
 }
 ```
diff --git a/magix/magix-rsocket/README.md b/magix/magix-rsocket/README.md
index 0a1b34b..ef16b66 100644
--- a/magix/magix-rsocket/README.md
+++ b/magix/magix-rsocket/README.md
@@ -6,7 +6,7 @@ Magix endpoint (client) based on RSocket
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-rsocket:0.3.0`.
+The Maven coordinates of this project are `space.kscience:magix-rsocket:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-rsocket:0.3.0")
+    implementation("space.kscience:magix-rsocket:0.4.0-dev-1")
 }
 ```
diff --git a/magix/magix-server/README.md b/magix/magix-server/README.md
index 4175dcd..17bdbdf 100644
--- a/magix/magix-server/README.md
+++ b/magix/magix-server/README.md
@@ -6,7 +6,7 @@ A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket route
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-server:0.3.0`.
+The Maven coordinates of this project are `space.kscience:magix-server:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-server:0.3.0")
+    implementation("space.kscience:magix-server:0.4.0-dev-1")
 }
 ```
diff --git a/magix/magix-storage/README.md b/magix/magix-storage/README.md
index db1d340..dbb2729 100644
--- a/magix/magix-storage/README.md
+++ b/magix/magix-storage/README.md
@@ -6,7 +6,7 @@ Magix history database API
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-storage:0.3.0`.
+The Maven coordinates of this project are `space.kscience:magix-storage:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-storage:0.3.0")
+    implementation("space.kscience:magix-storage:0.4.0-dev-1")
 }
 ```
diff --git a/magix/magix-storage/magix-storage-xodus/README.md b/magix/magix-storage/magix-storage-xodus/README.md
index 2d9e202..90a0050 100644
--- a/magix/magix-storage/magix-storage-xodus/README.md
+++ b/magix/magix-storage/magix-storage-xodus/README.md
@@ -6,7 +6,7 @@
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.3.0`.
+The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-storage-xodus:0.3.0")
+    implementation("space.kscience:magix-storage-xodus:0.4.0-dev-1")
 }
 ```
diff --git a/magix/magix-zmq/README.md b/magix/magix-zmq/README.md
index b016e6b..6e924db 100644
--- a/magix/magix-zmq/README.md
+++ b/magix/magix-zmq/README.md
@@ -6,7 +6,7 @@ ZMQ client endpoint for Magix
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-zmq:0.3.0`.
+The Maven coordinates of this project are `space.kscience:magix-zmq:0.4.0-dev-1`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-zmq:0.3.0")
+    implementation("space.kscience:magix-zmq:0.4.0-dev-1")
 }
 ```

From ee83f81a049038ce9188211a4f49d68894b20f4e Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Thu, 16 May 2024 21:22:34 +0300
Subject: [PATCH 083/125] Fix small typo in event id generation

---
 .../kscience/controls/client/controlsMagix.kt |  4 +-
 .../controls/demo/DemoControllerView.kt       | 40 +++++++++++++++----
 2 files changed, 34 insertions(+), 10 deletions(-)

diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
index 915d0a0..8db9351 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
@@ -29,7 +29,7 @@ public val DeviceManager.Companion.magixFormat: MagixFormat<DeviceMessage> get()
 internal fun generateId(request: MagixMessage): String = if (request.id != null) {
     "${request.id}.response"
 } else {
-    "controls[${request.payload.hashCode().toString(16)}"
+    "controls[${request.payload.hashCode().toUInt().toString(16)}]"
 }
 
 /**
@@ -43,7 +43,7 @@ public fun DeviceManager.launchMagixService(
     coroutineContext: CoroutineContext = EmptyCoroutineContext,
 ): Job = context.launch(coroutineContext) {
     endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID, null)).onEach { (request, payload) ->
-        val responsePayload = respondHubMessage(payload)
+        val responsePayload: List<DeviceMessage> = respondHubMessage(payload)
         responsePayload.forEach {
             endpoint.send(
                 format = controlsMagixFormat,
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
index ff99f32..f107f56 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
@@ -17,7 +17,6 @@ import kotlinx.coroutines.launch
 import kotlinx.serialization.json.Json
 import org.eclipse.milo.opcua.sdk.server.OpcUaServer
 import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText
-import space.kscience.controls.api.DeviceMessage
 import space.kscience.controls.api.GetDescriptionMessage
 import space.kscience.controls.api.PropertyChangedMessage
 import space.kscience.controls.client.launchMagixService
@@ -30,6 +29,7 @@ import space.kscience.controls.opcua.server.serveDevices
 import space.kscience.controls.spec.write
 import space.kscience.dataforge.context.*
 import space.kscience.magix.api.MagixEndpoint
+import space.kscience.magix.api.MagixMessage
 import space.kscience.magix.api.send
 import space.kscience.magix.api.subscribe
 import space.kscience.magix.rsocket.rSocketWithTcp
@@ -40,6 +40,9 @@ import space.kscince.magix.zmq.ZmqMagixFlowPlugin
 import java.awt.Desktop
 import java.net.URI
 
+
+private val json = Json { prettyPrint = true }
+
 class DemoController : ContextAware {
 
     var device: DemoDevice? = null
@@ -70,7 +73,7 @@ class DemoController : ContextAware {
         )
         //Launch a device client and connect it to the server
         val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost")
-        deviceManager.launchMagixService(deviceEndpoint)
+        deviceManager.launchMagixService(deviceEndpoint, "demoDevice")
         //connect visualization to a magix endpoint
         val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
         visualizer = startDemoDeviceServer(visualEndpoint)
@@ -81,13 +84,19 @@ class DemoController : ContextAware {
 
 
         val listenerEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
-        listenerEndpoint.subscribe(DeviceManager.magixFormat).onEach { (_, deviceMessage) ->
+
+        listenerEndpoint.subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) ->
             // print all messages that are not property change message
             if (deviceMessage !is PropertyChangedMessage) {
-                println(">> ${Json.encodeToString(DeviceMessage.serializer(), deviceMessage)}")
+                println(">> ${json.encodeToString(MagixMessage.serializer(), magixMessage)}")
             }
         }.launchIn(this)
-        listenerEndpoint.send(DeviceManager.magixFormat, GetDescriptionMessage(), "listener", "controls-kt")
+        listenerEndpoint.send(
+            format = DeviceManager.magixFormat,
+            payload = GetDescriptionMessage(),
+            source = "listener",
+//            target = "demoDevice"
+        )
 
     }
 
@@ -114,17 +123,32 @@ fun DemoControls(controller: DemoController) {
         Column {
             Row(Modifier.fillMaxWidth()) {
                 Text("Time Scale", modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
-                TextField(String.format("%.2f", timeScale),{}, enabled = false, modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
+                TextField(
+                    String.format("%.2f", timeScale),
+                    {},
+                    enabled = false,
+                    modifier = Modifier.align(Alignment.CenterVertically).width(100.dp)
+                )
                 Slider(timeScale, onValueChange = { timeScale = it }, steps = 20, valueRange = 1000f..5000f)
             }
             Row(Modifier.fillMaxWidth()) {
                 Text("X scale", modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
-                TextField(String.format("%.2f", xScale),{}, enabled = false, modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
+                TextField(
+                    String.format("%.2f", xScale),
+                    {},
+                    enabled = false,
+                    modifier = Modifier.align(Alignment.CenterVertically).width(100.dp)
+                )
                 Slider(xScale, onValueChange = { xScale = it }, steps = 20, valueRange = 0.1f..2.0f)
             }
             Row(Modifier.fillMaxWidth()) {
                 Text("Y scale", modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
-                TextField(String.format("%.2f", yScale),{}, enabled = false, modifier = Modifier.align(Alignment.CenterVertically).width(100.dp))
+                TextField(
+                    String.format("%.2f", yScale),
+                    {},
+                    enabled = false,
+                    modifier = Modifier.align(Alignment.CenterVertically).width(100.dp)
+                )
                 Slider(yScale, onValueChange = { yScale = it }, steps = 20, valueRange = 0.1f..2.0f)
             }
             Row(Modifier.fillMaxWidth()) {

From e5088ac8e466ddb6a48023c84ba79c8fbd033ef9 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 17 May 2024 23:01:20 +0300
Subject: [PATCH 084/125] Make remote device connection ask for descriptors
 before start

---
 .../space/kscience/controls/api/Device.kt     |  2 +-
 controls-magix/build.gradle.kts               |  6 ++-
 .../kscience/controls/client/DeviceClient.kt  | 50 +++++++++++++------
 .../kscience/controls/client/controlsMagix.kt |  3 +-
 .../controls/client/RemoteDeviceConnect.kt    | 21 +++++---
 .../space/kscience/magix/api/MagixFormat.kt   |  2 +-
 6 files changed, 59 insertions(+), 25 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index fc68bf6..240eaa0 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -100,8 +100,8 @@ public interface Device : ContextAware, CoroutineScope {
      * Close and terminate the device. This function does not wait for the device to be closed.
      */
     public suspend fun stop() {
+        coroutineContext[Job]?.cancel("The device is closed")
         logger.info { "Device $this is closed" }
-        cancel("The device is closed")
     }
 
     public val lifecycleState: DeviceLifecycleState
diff --git a/controls-magix/build.gradle.kts b/controls-magix/build.gradle.kts
index 040c040..ef4a3af 100644
--- a/controls-magix/build.gradle.kts
+++ b/controls-magix/build.gradle.kts
@@ -17,11 +17,15 @@ kscience {
     useSerialization {
         json()
     }
-    dependencies {
+    commonMain {
         api(projects.magix.magixApi)
         api(projects.controlsCore)
         api(libs.uuid)
     }
+
+    jvmTest{
+        implementation(spclibs.logback.classic)
+    }
 }
 
 readme {
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index 51836b9..4285c44 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
@@ -1,8 +1,11 @@
 package space.kscience.controls.client
 
 import com.benasher44.uuid.uuid4
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import space.kscience.controls.api.*
@@ -23,9 +26,11 @@ private fun stringUID() = uuid4().leastSignificantBits.toString(16)
 /**
  * A remote accessible device that relies on connection via Magix
  */
-public class DeviceClient(
+public class DeviceClient internal constructor(
     override val context: Context,
     private val deviceName: Name,
+    override val propertyDescriptors: Collection<PropertyDescriptor>,
+    override val actionDescriptors: Collection<ActionDescriptor>,
     incomingFlow: Flow<DeviceMessage>,
     private val send: suspend (DeviceMessage) -> Unit,
 ) : CachingDevice {
@@ -37,12 +42,6 @@ public class DeviceClient(
 
     private val propertyCache = HashMap<String, Meta>()
 
-    override var propertyDescriptors: Collection<PropertyDescriptor> = emptyList()
-        private set
-
-    override var actionDescriptors: Collection<ActionDescriptor> = emptyList()
-        private set
-
     private val flowInternal = incomingFlow.filter {
         it.sourceDevice == deviceName
     }.shareIn(this, started = SharingStarted.Eagerly).also {
@@ -52,11 +51,6 @@ public class DeviceClient(
                     propertyCache[message.property] = message.value
                 }
 
-                is DescriptionMessage -> mutex.withLock {
-                    propertyDescriptors = message.properties
-                    actionDescriptors = message.actions
-                }
-
                 else -> {
                     //ignore
                 }
@@ -112,14 +106,38 @@ public class DeviceClient(
  * @param targetEndpointName the name of endpoint in Magix to connect to
  * @param deviceName the name of device within endpoint
  */
-public fun MagixEndpoint.remoteDevice(
+public suspend fun MagixEndpoint.remoteDevice(
     context: Context,
     sourceEndpointName: String,
     targetEndpointName: String,
     deviceName: Name,
-): DeviceClient {
+): DeviceClient = coroutineScope{
     val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(targetEndpointName)).map { it.second }
-    return DeviceClient(context, deviceName, subscription) {
+
+    val deferredDescriptorMessage = CompletableDeferred<DescriptionMessage>()
+
+    launch {
+        deferredDescriptorMessage.complete(subscription.filterIsInstance<DescriptionMessage>().first())
+    }
+
+    send(
+        format = DeviceManager.magixFormat,
+        payload = GetDescriptionMessage(targetDevice = deviceName),
+        source = sourceEndpointName,
+        target = targetEndpointName,
+        id = stringUID()
+    )
+
+
+    val descriptionMessage = deferredDescriptorMessage.await()
+
+    DeviceClient(
+        context = context,
+        deviceName = deviceName,
+        propertyDescriptors = descriptionMessage.properties,
+        actionDescriptors = descriptionMessage.actions,
+        incomingFlow = subscription
+    ) {
         send(
             format = DeviceManager.magixFormat,
             payload = it,
@@ -130,6 +148,8 @@ public fun MagixEndpoint.remoteDevice(
     }
 }
 
+//public fun MagixEndpoint.remoteDeviceHub()
+
 /**
  * Subscribe on specific property of a device without creating a device
  */
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
index 8db9351..1c6f556 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
@@ -1,5 +1,6 @@
 package space.kscience.controls.client
 
+import com.benasher44.uuid.uuid4
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.launchIn
@@ -29,7 +30,7 @@ public val DeviceManager.Companion.magixFormat: MagixFormat<DeviceMessage> get()
 internal fun generateId(request: MagixMessage): String = if (request.id != null) {
     "${request.id}.response"
 } else {
-    "controls[${request.payload.hashCode().toUInt().toString(16)}]"
+    uuid4().leastSignificantBits.toULong().toString(16)
 }
 
 /**
diff --git a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
index 1478899..ff82486 100644
--- a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
+++ b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
@@ -1,9 +1,12 @@
 package space.kscience.controls.client
 
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.test.runTest
 import kotlinx.serialization.json.Json
+import space.kscience.controls.api.DeviceMessage
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
 import space.kscience.controls.manager.respondMessage
@@ -46,7 +49,7 @@ internal class RemoteDeviceConnect {
     }
 
     @Test
-    fun wrapper() = runTest {
+    fun deviceClient() = runTest {
         val context = Context {
             plugin(DeviceManager)
         }
@@ -56,11 +59,15 @@ internal class RemoteDeviceConnect {
         val virtualMagixEndpoint = object : MagixEndpoint {
 
 
-            override fun subscribe(filter: MagixMessageFilter): Flow<MagixMessage> = device.messageFlow.map {
+            private val additionalMessages = MutableSharedFlow<DeviceMessage>(10)
+
+            override fun subscribe(
+                filter: MagixMessageFilter,
+            ): Flow<MagixMessage> = merge(device.messageFlow, additionalMessages).map {
                 MagixMessage(
                     format = DeviceManager.magixFormat.defaultFormat,
                     payload = MagixEndpoint.magixJson.encodeToJsonElement(DeviceManager.magixFormat.serializer, it),
-                    sourceEndpoint = "source",
+                    sourceEndpoint = "device",
                 )
             }
 
@@ -68,16 +75,18 @@ internal class RemoteDeviceConnect {
                 device.respondMessage(
                     Name.EMPTY,
                     Json.decodeFromJsonElement(DeviceManager.magixFormat.serializer, message.payload)
-                )
+                )?.let {
+                    additionalMessages.emit(it)
+                }
             }
 
             override fun close() {
                 //
             }
         }
-
-        val remoteDevice = virtualMagixEndpoint.remoteDevice(context, "source", "target", Name.EMPTY)
+        val remoteDevice = virtualMagixEndpoint.remoteDevice(context, "client", "device", Name.EMPTY)
 
         assertContains(0.0..1.0, remoteDevice.read(TestDevice.value))
+
     }
 }
\ No newline at end of file
diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt
index 42d55b7..b0316e3 100644
--- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt
+++ b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt
@@ -56,6 +56,6 @@ public suspend fun <T> MagixEndpoint.send(
         parentId = parentId,
         user = user
     )
-    broadcast(message)
+    send(message)
 }
 

From 207064cd45fed2eab863e683a5f213f1593a8730 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 19 May 2024 10:44:34 +0300
Subject: [PATCH 085/125] Remove hierarchical device structure in Hubs

---
 CHANGELOG.md                                  |  6 +-
 build.gradle.kts                              |  2 +-
 .../controls/constructor/DeviceGroup.kt       | 56 +++++-------
 .../constructor/library/PidRegulator.kt       |  4 +-
 .../space/kscience/controls/api/DeviceHub.kt  | 73 ++++++---------
 .../controls/manager/DeviceManager.kt         | 16 ++--
 .../controls/manager/respondMessage.kt        |  4 +-
 .../kscience/controls/client/DeviceClient.kt  | 88 +++++++++++++------
 .../kscience/controls/client/tangoMagix.kt    |  7 +-
 .../controls/client/RemoteDeviceConnect.kt    |  2 +-
 .../controls/modbus/ModbusDeviceBySpec.kt     |  6 +-
 .../controls/server/deviceWebServer.kt        | 10 +--
 .../pimotionmaster/PiMotionMasterDevice.kt    |  5 +-
 13 files changed, 140 insertions(+), 139 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4a258d7..2c7ea2f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,10 +5,14 @@
 ### Added
 - Value averaging plot extension
 - PLC4X bindings
+- Shortcuts to access all Controls devices in a magix network.
+- `DeviceClient` properly evaluates lifecycle and logs
 
 ### Changed
-- Constructor properties return `DeviceStat` in order to be able to subscribe to them
+- Constructor properties return `DeviceState` in order to be able to subscribe to them
 - Refactored ports. Now we have `AsynchronousPort` as well as `SynchronousPort`
+- `DeviceClient` now initializes property and action descriptors eagerly.
+- `DeviceHub` now works with `Name` instead of `NameToken`. Tree-like structure is made using `Path`. Device messages no longer have access to sub-devices.
 
 ### Deprecated
 
diff --git a/build.gradle.kts b/build.gradle.kts
index 38d5c6a..0f00407 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -7,7 +7,7 @@ plugins {
 
 allprojects {
     group = "space.kscience"
-    version = "0.4.0-dev-1"
+    version = "0.4.0-dev-2"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index 6785b4b..ef886dc 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -10,9 +10,14 @@ import space.kscience.controls.api.DeviceLifecycleState.*
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
 import space.kscience.dataforge.context.*
-import space.kscience.dataforge.meta.*
+import space.kscience.dataforge.meta.Laminate
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaConverter
+import space.kscience.dataforge.meta.MutableMeta
 import space.kscience.dataforge.misc.DFExperimental
-import space.kscience.dataforge.names.*
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.get
+import space.kscience.dataforge.names.parseAsName
 import kotlin.collections.set
 import kotlin.coroutines.CoroutineContext
 
@@ -60,15 +65,15 @@ public open class DeviceGroup(
     )
 
 
-    private val _devices = hashMapOf<NameToken, Device>()
+    private val _devices = hashMapOf<Name, Device>()
 
-    override val devices: Map<NameToken, Device> = _devices
+    override val devices: Map<Name, Device> = _devices
 
     /**
      * Register and initialize (synchronize child's lifecycle state with group state) a new device in this group
      */
     @OptIn(DFExperimental::class)
-    public open fun <D : Device> install(token: NameToken, device: D): D {
+    public open fun <D : Device> install(token: Name, device: D): D {
         require(_devices[token] == null) { "A child device with name $token already exists" }
         //start the child device if needed
         if (lifecycleState == STARTED || lifecycleState == STARTING) launch { device.start() }
@@ -175,35 +180,16 @@ public fun Context.registerDeviceGroup(
     block: DeviceGroup.() -> Unit,
 ): DeviceGroup = request(DeviceManager).registerDeviceGroup(name, meta, block)
 
-private fun DeviceGroup.getOrCreateGroup(name: Name): DeviceGroup {
-    return when (name.length) {
-        0 -> this
-        1 -> {
-            val token = name.first()
-            when (val d = devices[token]) {
-                null -> install(
-                    token,
-                    DeviceGroup(context, meta[token] ?: Meta.EMPTY)
-                )
-
-                else -> (d as? DeviceGroup) ?: error("Device $name is not a DeviceGroup")
-            }
-        }
-
-        else -> getOrCreateGroup(name.first().asName()).getOrCreateGroup(name.cutFirst())
-    }
-}
-
-/**
- * Register a device at given [name] path
- */
-public fun <D : Device> DeviceGroup.install(name: Name, device: D): D {
-    return when (name.length) {
-        0 -> error("Can't use empty name for a child device")
-        1 -> install(name.first(), device)
-        else -> getOrCreateGroup(name.cutLast()).install(name.tokens.last(), device)
-    }
-}
+///**
+// * Register a device at given [path] path
+// */
+//public fun <D : Device> DeviceGroup.install(path: Path, device: D): D {
+//    return when (path.length) {
+//        0 -> error("Can't use empty path for a child device")
+//        1 -> install(path.first().name, device)
+//        else -> getOrCreateGroup(path.cutLast()).install(path.tokens.last(), device)
+//    }
+//}
 
 public fun <D : Device> DeviceGroup.install(name: String, device: D): D = install(name.parseAsName(), device)
 
@@ -238,7 +224,7 @@ public fun <D : Device> DeviceGroup.install(
  * Create or edit a group with a given [name].
  */
 public fun DeviceGroup.registerDeviceGroup(name: Name, block: DeviceGroup.() -> Unit): DeviceGroup =
-    getOrCreateGroup(name).apply(block)
+    install(name, DeviceGroup(context, meta).apply(block))
 
 public fun DeviceGroup.registerDeviceGroup(name: String, block: DeviceGroup.() -> Unit): DeviceGroup =
     registerDeviceGroup(name.parseAsName(), block)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
index 7ce2054..afb2cbd 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
@@ -8,10 +8,10 @@ import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.datetime.Instant
 import space.kscience.controls.constructor.DeviceGroup
-import space.kscience.controls.constructor.install
 import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.DeviceBySpec
 import space.kscience.controls.spec.write
+import space.kscience.dataforge.names.parseAsName
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.DurationUnit
@@ -94,4 +94,4 @@ public fun DeviceGroup.pid(
     name: String,
     drive: Drive,
     pidParameters: PidParameters,
-): PidRegulator = install(name, PidRegulator(drive, pidParameters))
\ No newline at end of file
+): PidRegulator = install(name.parseAsName(), PidRegulator(drive, pidParameters))
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
index f6ca4ec..077585b 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
@@ -1,40 +1,24 @@
 package space.kscience.controls.api
 
 import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.names.*
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.provider.Path
 import space.kscience.dataforge.provider.Provider
+import space.kscience.dataforge.provider.asPath
+import space.kscience.dataforge.provider.plus
 
 /**
  * A hub that could locate multiple devices and redirect actions to them
  */
 public interface DeviceHub : Provider {
-    public val devices: Map<NameToken, Device>
+    public val devices: Map<Name, Device>
 
     override val defaultTarget: String get() = Device.DEVICE_TARGET
 
     override val defaultChainTarget: String get() = Device.DEVICE_TARGET
 
-    /**
-     * List all devices, including sub-devices
-     */
-    public fun buildDeviceTree(): Map<Name, Device> = buildMap {
-        fun putAll(prefix: Name, hub: DeviceHub) {
-            hub.devices.forEach {
-                put(prefix + it.key, it.value)
-            }
-        }
-
-        devices.forEach {
-            val name = it.key.asName()
-            put(name, it.value)
-            (it.value as? DeviceHub)?.let { hub ->
-                putAll(name, hub)
-            }
-        }
-    }
-
     override fun content(target: String): Map<Name, Any> = if (target == Device.DEVICE_TARGET) {
-        buildDeviceTree()
+        devices
     } else {
         emptyMap()
     }
@@ -42,38 +26,31 @@ public interface DeviceHub : Provider {
     public companion object
 }
 
+/**
+ * List all devices, including sub-devices
+ */
+public fun DeviceHub.provideAllDevices(): Map<Path, Device> = buildMap {
+    fun putAll(prefix: Path, hub: DeviceHub) {
+        hub.devices.forEach {
+            put(prefix + it.key.asPath(), it.value)
+        }
+    }
 
-public operator fun DeviceHub.get(nameToken: NameToken): Device =
-    devices[nameToken] ?: error("Device with name $nameToken not found in $this")
-
-public fun DeviceHub.getOrNull(name: Name): Device? = when {
-    name.isEmpty() -> this as? Device
-    name.length == 1 -> devices[name.firstOrNull()!!]
-    else -> (get(name.firstOrNull()!!) as? DeviceHub)?.getOrNull(name.cutFirst())
+    devices.forEach {
+        val name: Name = it.key
+        put(name.asPath(), it.value)
+        (it.value as? DeviceHub)?.let { hub ->
+            putAll(name.asPath(), hub)
+        }
+    }
 }
 
-public operator fun DeviceHub.get(name: Name): Device =
-    getOrNull(name) ?: error("Device with name $name not found in $this")
-
-public fun DeviceHub.getOrNull(nameString: String): Device? = getOrNull(Name.parse(nameString))
-
-public operator fun DeviceHub.get(nameString: String): Device =
-    getOrNull(nameString) ?: error("Device with name $nameString not found in $this")
-
 public suspend fun DeviceHub.readProperty(deviceName: Name, propertyName: String): Meta =
-    this[deviceName].readProperty(propertyName)
+    (devices[deviceName] ?: error("Device with name $deviceName not found in $this")).readProperty(propertyName)
 
 public suspend fun DeviceHub.writeProperty(deviceName: Name, propertyName: String, value: Meta) {
-    this[deviceName].writeProperty(propertyName, value)
+    (devices[deviceName] ?: error("Device with name $deviceName not found in $this")).writeProperty(propertyName, value)
 }
 
 public suspend fun DeviceHub.execute(deviceName: Name, command: String, argument: Meta?): Meta? =
-    this[deviceName].execute(command, argument)
-
-
-//suspend fun DeviceHub.respond(request: Envelope): EnvelopeBuilder {
-//    val target = request.meta[DeviceMessage.TARGET_KEY].string ?: defaultTarget
-//    val device = this[target.toName()]
-//
-//    return device.respond(device, target, request)
-//}
\ No newline at end of file
+    (devices[deviceName] ?: error("Device with name $deviceName not found in $this")).execute(command, argument)
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
index 93b0696..689cb90 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
@@ -3,13 +3,13 @@ package space.kscience.controls.manager
 import kotlinx.coroutines.launch
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.DeviceHub
-import space.kscience.controls.api.getOrNull
 import space.kscience.controls.api.id
 import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.MutableMeta
 import space.kscience.dataforge.names.Name
-import space.kscience.dataforge.names.NameToken
+import space.kscience.dataforge.names.get
+import space.kscience.dataforge.names.parseAsName
 import kotlin.collections.set
 import kotlin.properties.ReadOnlyProperty
 
@@ -22,11 +22,11 @@ public class DeviceManager : AbstractPlugin(), DeviceHub {
     /**
      * Actual list of connected devices
      */
-    private val top = HashMap<NameToken, Device>()
-    override val devices: Map<NameToken, Device> get() = top
+    private val _devices = HashMap<Name, Device>()
+    override val devices: Map<Name, Device> get() = _devices
 
-    public fun registerDevice(name: NameToken, device: Device) {
-        top[name] = device
+    public fun registerDevice(name: Name, device: Device) {
+        _devices[name] = device
     }
 
     override fun content(target: String): Map<Name, Any> = super<DeviceHub>.content(target)
@@ -39,7 +39,7 @@ public class DeviceManager : AbstractPlugin(), DeviceHub {
 }
 
 public fun <D : Device> DeviceManager.install(name: String, device: D): D {
-    registerDevice(NameToken(name), device)
+    registerDevice(name.parseAsName(), device)
     device.launch {
         device.start()
     }
@@ -69,7 +69,7 @@ public inline fun <D : Device> DeviceManager.installing(
     val meta = Meta(builder)
     return ReadOnlyProperty { _, property ->
         val name = property.name
-        val current = getOrNull(name)
+        val current = devices[name]
         if (current == null) {
             install(name, factory, meta)
         } else if (current.meta != meta) {
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 fc6fb5d..a15bcef 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
@@ -74,11 +74,11 @@ public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List<Dev
     return try {
         val targetName = request.targetDevice
         if (targetName == null) {
-            buildDeviceTree().mapNotNull {
+            devices.mapNotNull {
                 it.value.respondMessage(it.key, request)
             }
         } else {
-            val device = getOrNull(targetName) ?: error("The device with name $targetName not found in $this")
+            val device = devices[targetName] ?: error("The device with name $targetName not found in $this")
             listOfNotNull(device.respondMessage(targetName, request))
         }
     } catch (ex: Exception) {
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index 4285c44..9418791 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
@@ -16,6 +16,8 @@ import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.misc.DFExperimental
 import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.length
+import space.kscience.dataforge.names.startsWith
 import space.kscience.magix.api.MagixEndpoint
 import space.kscience.magix.api.send
 import space.kscience.magix.api.subscribe
@@ -29,13 +31,19 @@ private fun stringUID() = uuid4().leastSignificantBits.toString(16)
 public class DeviceClient internal constructor(
     override val context: Context,
     private val deviceName: Name,
-    override val propertyDescriptors: Collection<PropertyDescriptor>,
-    override val actionDescriptors: Collection<ActionDescriptor>,
+    propertyDescriptors: Collection<PropertyDescriptor>,
+    actionDescriptors: Collection<ActionDescriptor>,
     incomingFlow: Flow<DeviceMessage>,
     private val send: suspend (DeviceMessage) -> Unit,
 ) : CachingDevice {
 
 
+    override var actionDescriptors: Collection<ActionDescriptor> = actionDescriptors
+        internal set
+
+    override var propertyDescriptors: Collection<PropertyDescriptor> = propertyDescriptors
+        internal set
+
     override val coroutineContext: CoroutineContext = context.coroutineContext + Job(context.coroutineContext[Job])
 
     private val mutex = Mutex()
@@ -44,19 +52,17 @@ public class DeviceClient internal constructor(
 
     private val flowInternal = incomingFlow.filter {
         it.sourceDevice == deviceName
-    }.shareIn(this, started = SharingStarted.Eagerly).also {
-        it.onEach { message ->
-            when (message) {
-                is PropertyChangedMessage -> mutex.withLock {
-                    propertyCache[message.property] = message.value
-                }
-
-                else -> {
-                    //ignore
-                }
+    }.onEach { message ->
+        when (message) {
+            is PropertyChangedMessage -> mutex.withLock {
+                propertyCache[message.property] = message.value
             }
-        }.launchIn(this)
-    }
+
+            else -> {
+                //ignore
+            }
+        }
+    }.shareIn(this, started = SharingStarted.Eagerly)
 
     override val messageFlow: Flow<DeviceMessage> get() = flowInternal
 
@@ -65,7 +71,7 @@ public class DeviceClient internal constructor(
         send(
             PropertyGetMessage(propertyName, targetDevice = deviceName)
         )
-        return flowInternal.filterIsInstance<PropertyChangedMessage>().first {
+        return messageFlow.filterIsInstance<PropertyChangedMessage>().first {
             it.property == propertyName
         }.value
     }
@@ -89,30 +95,33 @@ public class DeviceClient internal constructor(
         send(
             ActionExecuteMessage(actionName, argument, id, targetDevice = deviceName)
         )
-        return flowInternal.filterIsInstance<ActionResultMessage>().first {
+        return messageFlow.filterIsInstance<ActionResultMessage>().first {
             it.action == actionName && it.requestId == id
         }.result
     }
 
+    private val lifecycleStateFlow = messageFlow.filterIsInstance<DeviceLifeCycleMessage>()
+        .map { it.state }.stateIn(this, started = SharingStarted.Eagerly, DeviceLifecycleState.STARTED)
+
     @DFExperimental
-    override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STARTED
+    override val lifecycleState: DeviceLifecycleState get() = lifecycleStateFlow.value
 }
 
 /**
  * Connect to a remote device via this endpoint.
  *
  * @param context a [Context] to run device in
- * @param sourceEndpointName the name of this endpoint
- * @param targetEndpointName the name of endpoint in Magix to connect to
+ * @param thisEndpoint the name of this endpoint
+ * @param deviceEndpoint the name of endpoint in Magix to connect to
  * @param deviceName the name of device within endpoint
  */
 public suspend fun MagixEndpoint.remoteDevice(
     context: Context,
-    sourceEndpointName: String,
-    targetEndpointName: String,
+    thisEndpoint: String,
+    deviceEndpoint: String,
     deviceName: Name,
-): DeviceClient = coroutineScope{
-    val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(targetEndpointName)).map { it.second }
+): DeviceClient = coroutineScope {
+    val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(deviceEndpoint)).map { it.second }
 
     val deferredDescriptorMessage = CompletableDeferred<DescriptionMessage>()
 
@@ -123,8 +132,8 @@ public suspend fun MagixEndpoint.remoteDevice(
     send(
         format = DeviceManager.magixFormat,
         payload = GetDescriptionMessage(targetDevice = deviceName),
-        source = sourceEndpointName,
-        target = targetEndpointName,
+        source = thisEndpoint,
+        target = deviceEndpoint,
         id = stringUID()
     )
 
@@ -141,14 +150,37 @@ public suspend fun MagixEndpoint.remoteDevice(
         send(
             format = DeviceManager.magixFormat,
             payload = it,
-            source = sourceEndpointName,
-            target = targetEndpointName,
+            source = thisEndpoint,
+            target = deviceEndpoint,
             id = stringUID()
         )
     }
 }
 
-//public fun MagixEndpoint.remoteDeviceHub()
+private class MapBasedDeviceHub(val deviceMap: Map<Name, Device>, val prefix: Name) : DeviceHub {
+    override val devices: Map<Name, Device>
+        get() = deviceMap.filterKeys { name: Name -> name == prefix || (name.startsWith(prefix) && name.length == prefix.length + 1) }
+
+}
+
+public fun MagixEndpoint.remoteDeviceHub(
+    context: Context,
+    thisEndpoint: String,
+    deviceEndpoint: String,
+): DeviceHub {
+    val devices = mutableMapOf<Name, DeviceClient>()
+    val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(deviceEndpoint)).map { it.second }
+    subscription.filterIsInstance<DescriptionMessage>().onEach {
+
+    }.launchIn(context)
+
+
+    return object : DeviceHub {
+        override val devices: Map<Name, Device>
+            get() = TODO("Not yet implemented")
+
+    }
+}
 
 /**
  * Subscribe on specific property of a device without creating a device
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt
index d0bdda4..8f3e742 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt
@@ -5,12 +5,12 @@ import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.launch
 import kotlinx.serialization.Serializable
-import space.kscience.controls.api.get
 import space.kscience.controls.api.getOrReadProperty
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.dataforge.context.error
 import space.kscience.dataforge.context.logger
 import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.names.get
 import space.kscience.magix.api.*
 
 public const val TANGO_MAGIX_FORMAT: String = "tango"
@@ -88,7 +88,7 @@ public fun DeviceManager.launchTangoMagix(
     return context.launch {
         endpoint.subscribe(tangoMagixFormat).onEach { (request, payload) ->
             try {
-                val device = get(payload.device)
+                val device = devices[payload.device] ?: error("Device ${payload.device} not found")
                 when (payload.action) {
                     TangoAction.read -> {
                         val value = device.getOrReadProperty(payload.name)
@@ -99,6 +99,7 @@ public fun DeviceManager.launchTangoMagix(
                             )
                         }
                     }
+
                     TangoAction.write -> {
                         payload.value?.let { value ->
                             device.writeProperty(payload.name, value)
@@ -112,6 +113,7 @@ public fun DeviceManager.launchTangoMagix(
                             )
                         }
                     }
+
                     TangoAction.exec -> {
                         val result = device.execute(payload.name, payload.argin)
                         respond(request, payload) { requestPayload ->
@@ -121,6 +123,7 @@ public fun DeviceManager.launchTangoMagix(
                             )
                         }
                     }
+
                     TangoAction.pipe -> TODO("Pipe not implemented")
                 }
             } catch (ex: Exception) {
diff --git a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
index ff82486..db80848 100644
--- a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
+++ b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
@@ -59,7 +59,7 @@ internal class RemoteDeviceConnect {
         val virtualMagixEndpoint = object : MagixEndpoint {
 
 
-            private val additionalMessages = MutableSharedFlow<DeviceMessage>(10)
+            private val additionalMessages = MutableSharedFlow<DeviceMessage>(1)
 
             override fun subscribe(
                 filter: MagixMessageFilter,
diff --git a/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
index e0bd47a..8557f9d 100644
--- a/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
+++ b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt
@@ -7,7 +7,7 @@ import space.kscience.controls.spec.DeviceBySpec
 import space.kscience.controls.spec.DeviceSpec
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.names.NameToken
+import space.kscience.dataforge.names.Name
 
 /**
  * A variant of [DeviceBySpec] that includes Modbus RTU/TCP/UDP client
@@ -35,12 +35,12 @@ public open class ModbusDeviceBySpec<D: Device>(
 public class ModbusHub(
     public val context: Context,
     public val masterBuilder: () -> AbstractModbusMaster,
-    public val specs: Map<NameToken, Pair<Int, DeviceSpec<*>>>,
+    public val specs: Map<Name, Pair<Int, DeviceSpec<*>>>,
 ) : DeviceHub, AutoCloseable {
 
     public val master: AbstractModbusMaster by lazy(masterBuilder)
 
-    override val devices: Map<NameToken, ModbusDevice> by lazy {
+    override val devices: Map<Name, ModbusDevice> by lazy {
         specs.mapValues { (_, pair) ->
             ModbusDeviceBySpec(
                 context,
diff --git a/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
index 04bb46d..4f37322 100644
--- a/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
+++ b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
@@ -29,19 +29,18 @@ import kotlinx.serialization.json.put
 import space.kscience.controls.api.DeviceMessage
 import space.kscience.controls.api.PropertyGetMessage
 import space.kscience.controls.api.PropertySetMessage
-import space.kscience.controls.api.getOrNull
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.respondHubMessage
 import space.kscience.dataforge.meta.toMeta
 import space.kscience.dataforge.names.Name
 import space.kscience.dataforge.names.asName
+import space.kscience.dataforge.names.get
 import space.kscience.magix.api.MagixEndpoint
 import space.kscience.magix.api.MagixFlowPlugin
 import space.kscience.magix.api.MagixMessage
 import space.kscience.magix.server.magixModule
 
 
-
 private fun Application.deviceServerModule(manager: DeviceManager) {
     install(StatusPages) {
         exception<IllegalArgumentException> { call, cause ->
@@ -100,10 +99,9 @@ public fun Application.deviceManagerModule(
                         h1 {
                             +"Device server dashboard"
                         }
-                        deviceNames.forEach { deviceName ->
-                            val device =
-                                manager.getOrNull(deviceName)
-                                    ?: error("The device with name $deviceName not found in $manager")
+                        deviceNames.forEach { deviceName: String ->
+                            val device = manager.devices[deviceName]
+                                ?: error("The device with name $deviceName not found in $manager")
                             div {
                                 id = deviceName
                                 h2 { +deviceName }
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index 6c271d7..106b077 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -19,7 +19,8 @@ import space.kscience.controls.ports.withStringDelimiter
 import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.*
-import space.kscience.dataforge.names.NameToken
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.parseAsName
 import kotlin.collections.component1
 import kotlin.collections.component2
 import kotlin.time.Duration
@@ -47,7 +48,7 @@ class PiMotionMasterDevice(
     var axes: Map<String, Axis> = emptyMap()
         private set
 
-    override val devices: Map<NameToken, Axis> = axes.mapKeys { (key, _) -> NameToken(key) }
+    override val devices: Map<Name, Axis> = axes.mapKeys { (key, _) -> key.parseAsName() }
 
     private suspend fun failIfError(message: (Int) -> String = { "Failed with error code $it" }) {
         val errorCode = getErrorCode()

From 4a10c3c44345b115d33612a4b3c6f44fcaa0223d Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 19 May 2024 18:50:56 +0300
Subject: [PATCH 086/125] Add test for remote hub

---
 .../space/kscience/controls/api/DeviceHub.kt  |   5 +
 .../kscience/controls/client/DeviceClient.kt  |  55 ++++++++--
 .../controls/client/RemoteDeviceConnect.kt    | 100 ++++++++++++------
 3 files changed, 122 insertions(+), 38 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
index 077585b..e2f6331 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
@@ -26,6 +26,11 @@ public interface DeviceHub : Provider {
     public companion object
 }
 
+public fun DeviceHub(deviceMap: Map<Name, Device>): DeviceHub = object : DeviceHub {
+    override val devices: Map<Name, Device>
+        get() = deviceMap
+}
+
 /**
  * List all devices, including sub-devices
  */
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index 9418791..859bce2 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
@@ -163,23 +163,64 @@ private class MapBasedDeviceHub(val deviceMap: Map<Name, Device>, val prefix: Na
 
 }
 
-public fun MagixEndpoint.remoteDeviceHub(
+/**
+ * Create a dynamic [DeviceHub] from incoming messages
+ */
+public suspend fun MagixEndpoint.remoteDeviceHub(
     context: Context,
     thisEndpoint: String,
     deviceEndpoint: String,
 ): DeviceHub {
     val devices = mutableMapOf<Name, DeviceClient>()
     val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(deviceEndpoint)).map { it.second }
-    subscription.filterIsInstance<DescriptionMessage>().onEach {
-
+    subscription.filterIsInstance<DescriptionMessage>().onEach { descriptionMessage ->
+        devices.getOrPut(descriptionMessage.sourceDevice) {
+            DeviceClient(
+                context = context,
+                deviceName = descriptionMessage.sourceDevice,
+                propertyDescriptors = descriptionMessage.properties,
+                actionDescriptors = descriptionMessage.actions,
+                incomingFlow = subscription
+            ) {
+                send(
+                    format = DeviceManager.magixFormat,
+                    payload = it,
+                    source = thisEndpoint,
+                    target = deviceEndpoint,
+                    id = stringUID()
+                )
+            }
+        }.run {
+            propertyDescriptors = descriptionMessage.properties
+        }
     }.launchIn(context)
 
 
-    return object : DeviceHub {
-        override val devices: Map<Name, Device>
-            get() = TODO("Not yet implemented")
+    send(
+        format = DeviceManager.magixFormat,
+        payload = GetDescriptionMessage(targetDevice = null),
+        source = thisEndpoint,
+        target = deviceEndpoint,
+        id = stringUID()
+    )
 
-    }
+    return DeviceHub(devices)
+}
+
+/**
+ * Request a description update for all devices on an endpoint
+ */
+public suspend fun MagixEndpoint.requestDeviceUpdate(
+    thisEndpoint: String,
+    deviceEndpoint: String,
+) {
+    send(
+        format = DeviceManager.magixFormat,
+        payload = GetDescriptionMessage(),
+        source = thisEndpoint,
+        target = deviceEndpoint,
+        id = stringUID()
+    )
 }
 
 /**
diff --git a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
index db80848..3c707b2 100644
--- a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
+++ b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
@@ -1,15 +1,21 @@
 package space.kscience.controls.client
 
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.launch
 import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
 import kotlinx.serialization.json.Json
+import space.kscience.controls.api.DeviceHub
 import space.kscience.controls.api.DeviceMessage
 import space.kscience.controls.manager.DeviceManager
+import space.kscience.controls.manager.hubMessageFlow
 import space.kscience.controls.manager.install
-import space.kscience.controls.manager.respondMessage
+import space.kscience.controls.manager.respondHubMessage
 import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
@@ -17,15 +23,43 @@ import space.kscience.dataforge.context.request
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.get
 import space.kscience.dataforge.meta.int
-import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.asName
 import space.kscience.magix.api.MagixEndpoint
 import space.kscience.magix.api.MagixMessage
 import space.kscience.magix.api.MagixMessageFilter
 import kotlin.random.Random
 import kotlin.test.Test
 import kotlin.test.assertContains
+import kotlin.test.assertEquals
 import kotlin.time.Duration.Companion.milliseconds
 
+class VirtualMagixEndpoint(val hub: DeviceHub) : MagixEndpoint {
+
+    private val additionalMessages = MutableSharedFlow<DeviceMessage>(1)
+
+    override fun subscribe(
+        filter: MagixMessageFilter,
+    ): Flow<MagixMessage> = merge(hub.hubMessageFlow(), additionalMessages).map {
+        MagixMessage(
+            format = DeviceManager.magixFormat.defaultFormat,
+            payload = MagixEndpoint.magixJson.encodeToJsonElement(DeviceManager.magixFormat.serializer, it),
+            sourceEndpoint = "device",
+        )
+    }
+
+    override suspend fun broadcast(message: MagixMessage) {
+        hub.respondHubMessage(
+            Json.decodeFromJsonElement(DeviceManager.magixFormat.serializer, message.payload)
+        ).forEach {
+            additionalMessages.emit(it)
+        }
+    }
+
+    override fun close() {
+        //
+    }
+}
+
 
 internal class RemoteDeviceConnect {
 
@@ -53,40 +87,44 @@ internal class RemoteDeviceConnect {
         val context = Context {
             plugin(DeviceManager)
         }
+        val deviceManager = context.request(DeviceManager)
 
-        val device = context.request(DeviceManager).install("test", TestDevice)
+        deviceManager.install("test", TestDevice)
 
-        val virtualMagixEndpoint = object : MagixEndpoint {
+        val virtualMagixEndpoint = VirtualMagixEndpoint(deviceManager)
 
-
-            private val additionalMessages = MutableSharedFlow<DeviceMessage>(1)
-
-            override fun subscribe(
-                filter: MagixMessageFilter,
-            ): Flow<MagixMessage> = merge(device.messageFlow, additionalMessages).map {
-                MagixMessage(
-                    format = DeviceManager.magixFormat.defaultFormat,
-                    payload = MagixEndpoint.magixJson.encodeToJsonElement(DeviceManager.magixFormat.serializer, it),
-                    sourceEndpoint = "device",
-                )
-            }
-
-            override suspend fun broadcast(message: MagixMessage) {
-                device.respondMessage(
-                    Name.EMPTY,
-                    Json.decodeFromJsonElement(DeviceManager.magixFormat.serializer, message.payload)
-                )?.let {
-                    additionalMessages.emit(it)
-                }
-            }
-
-            override fun close() {
-                //
-            }
-        }
-        val remoteDevice = virtualMagixEndpoint.remoteDevice(context, "client", "device", Name.EMPTY)
+        val remoteDevice = virtualMagixEndpoint.remoteDevice(context, "client", "device", "test".asName())
 
         assertContains(0.0..1.0, remoteDevice.read(TestDevice.value))
 
     }
+
+    @Test
+    fun deviceHub() = runTest {
+        val context = Context {
+            plugin(DeviceManager)
+        }
+        val deviceManager = context.request(DeviceManager)
+
+        launch {
+            delay(50)
+            repeat(10) {
+                deviceManager.install("test[$it]", TestDevice)
+            }
+        }
+
+        val virtualMagixEndpoint = VirtualMagixEndpoint(deviceManager)
+
+        val remoteHub = virtualMagixEndpoint.remoteDeviceHub(context, "client", "device")
+
+        assertEquals(0, remoteHub.devices.size)
+
+        delay(60)
+        //switch context to use actual delay
+        withContext(Dispatchers.Default) {
+            virtualMagixEndpoint.requestDeviceUpdate("client", "device")
+            delay(30)
+            assertEquals(10, remoteHub.devices.size)
+        }
+    }
 }
\ No newline at end of file

From a66e411848663c9c652250aa241d40c4befe19d7 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 21 May 2024 09:44:45 +0300
Subject: [PATCH 087/125] Fix Rsocket endpoint without filter. Add integration
 test with loop

---
 CHANGELOG.md                                  |  1 +
 build.gradle.kts                              |  2 +-
 controls-magix/build.gradle.kts               |  6 ++
 .../kscience/controls/client/MagixLoopTest.kt | 60 +++++++++++++++++++
 .../magix/server/RSocketMagixFlowPlugin.kt    | 15 +++--
 5 files changed, 77 insertions(+), 7 deletions(-)
 create mode 100644 controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 2c7ea2f..5a1dad9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -19,6 +19,7 @@
 ### Removed
 
 ### Fixed
+- Fix a problem with rsocket endpoint with no filter.
 
 ### Security
 
diff --git a/build.gradle.kts b/build.gradle.kts
index 0f00407..6423c3c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -7,7 +7,7 @@ plugins {
 
 allprojects {
     group = "space.kscience"
-    version = "0.4.0-dev-2"
+    version = "0.4.0-dev-3"
     repositories{
         maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
     }
diff --git a/controls-magix/build.gradle.kts b/controls-magix/build.gradle.kts
index ef4a3af..ea03a7e 100644
--- a/controls-magix/build.gradle.kts
+++ b/controls-magix/build.gradle.kts
@@ -17,6 +17,7 @@ kscience {
     useSerialization {
         json()
     }
+
     commonMain {
         api(projects.magix.magixApi)
         api(projects.controlsCore)
@@ -25,6 +26,11 @@ kscience {
 
     jvmTest{
         implementation(spclibs.logback.classic)
+        implementation(projects.magix.magixServer)
+        implementation(projects.magix.magixRsocket)
+        implementation(spclibs.ktor.server.cio)
+        implementation(spclibs.ktor.server.websockets)
+        implementation(spclibs.ktor.client.cio)
     }
 }
 
diff --git a/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt
new file mode 100644
index 0000000..764dcad
--- /dev/null
+++ b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt
@@ -0,0 +1,60 @@
+package space.kscience.controls.client
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.withContext
+import space.kscience.controls.client.RemoteDeviceConnect.TestDevice
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.controls.manager.install
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.request
+import space.kscience.magix.api.MagixEndpoint
+import space.kscience.magix.rsocket.rSocketWithWebSockets
+import space.kscience.magix.server.startMagixServer
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class MagixLoopTest {
+
+    @Test
+    fun deviceHub() = runTest {
+        val context = Context {
+            plugin(DeviceManager)
+        }
+
+        val server = context.startMagixServer()
+
+        val deviceManager = context.request(DeviceManager)
+
+        val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+
+//        deviceEndpoint.subscribe().onEach {
+//            println(it)
+//        }.launchIn(this)
+
+        deviceManager.launchMagixService(deviceEndpoint, "device")
+
+        launch {
+            delay(50)
+            repeat(10) {
+                deviceManager.install("test[$it]", TestDevice)
+            }
+        }
+
+        val clientEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+
+        val remoteHub = clientEndpoint.remoteDeviceHub(context, "client", "device")
+
+        assertEquals(0, remoteHub.devices.size)
+
+        delay(60)
+        //switch context to use actual delay
+        withContext(Dispatchers.Default) {
+            clientEndpoint.requestDeviceUpdate("client", "device")
+            delay(60)
+            assertEquals(10, remoteHub.devices.size)
+        }
+    }
+}
\ No newline at end of file
diff --git a/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt
index a00c33f..699a281 100644
--- a/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt
+++ b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt
@@ -59,9 +59,12 @@ public class RSocketMagixFlowPlugin(
             RSocketRequestHandler(coroutineScope.coroutineContext) {
                 //handler for request/stream
                 requestStream { request: Payload ->
-                    val filter = MagixEndpoint.magixJson.decodeFromString(
+                    val requestText = request.data.readText()
+                    val filter = if(requestText.isBlank()) {
+                        MagixMessageFilter.ALL
+                    } else  MagixEndpoint.magixJson.decodeFromString(
                         MagixMessageFilter.serializer(),
-                        request.data.readText()
+                        requestText
                     )
 
                     receive.filter(filter).map { message ->
@@ -89,12 +92,12 @@ public class RSocketMagixFlowPlugin(
                         )
                     }.launchIn(this)
 
-                    val filterText = request.use { it.data.readText() }
+                    val filterText = request.data.readText()
 
-                    val filter = if (filterText.isNotBlank()) {
-                        MagixEndpoint.magixJson.decodeFromString(MagixMessageFilter.serializer(), filterText)
+                    val filter = if (filterText.isBlank()) {
+                        MagixMessageFilter.ALL
                     } else {
-                        MagixMessageFilter()
+                        MagixEndpoint.magixJson.decodeFromString(MagixMessageFilter.serializer(), filterText)
                     }
 
                     receive.filter(filter).map { message ->

From 673a7c89a6e6e06a089da89c60492f559284c745 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 21 May 2024 13:31:01 +0300
Subject: [PATCH 088/125] [WIP] working on constructor

---
 .../kscience/controls/constructor/Binding.kt  | 35 ---------
 .../controls/constructor/DeviceConstructor.kt | 24 ++----
 .../controls/constructor/DeviceModel.kt       | 12 +--
 .../controls/constructor/StateDescriptor.kt   | 74 +++++++++++++++++++
 .../{customState.kt => exoticState.kt}        |  4 +-
 controls-vision/build.gradle.kts              |  2 +-
 6 files changed, 92 insertions(+), 59 deletions(-)
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Binding.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/{customState.kt => exoticState.kt} (94%)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Binding.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Binding.kt
deleted file mode 100644
index d712d62..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Binding.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-package space.kscience.controls.constructor
-
-import space.kscience.controls.api.Device
-
-/**
- * A binding that is used to describe device functionality
- */
-public sealed interface Binding
-
-/**
- * A binding that exposes device property as read-only state
- */
-public class PropertyBinding<T>(
-    public val device: Device,
-    public val propertyName: String,
-    public val state: DeviceState<T>,
-) : Binding
-
-/**
- * A binding for independent state like a timer
- */
-public class StateBinding<T>(
-    public val state: DeviceState<T>
-) : Binding
-
-public class ActionBinding(
-    public val reads: Collection<DeviceState<*>>,
-    public val writes: Collection<DeviceState<*>>
-): Binding
-
-
-public interface BindingsContainer{
-    public val bindings: List<Binding>
-    public fun registerBinding(binding: Binding)
-}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 5b6ff9f..03a1e0e 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -5,12 +5,10 @@ import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.PropertyDescriptor
-import space.kscience.controls.manager.ClockManager
 import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.MutableDevicePropertySpec
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
-import space.kscience.dataforge.context.request
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.names.Name
@@ -26,25 +24,19 @@ import kotlin.time.Duration
 public abstract class DeviceConstructor(
     context: Context,
     meta: Meta = Meta.EMPTY,
-) : DeviceGroup(context, meta), BindingsContainer {
-    private val _bindings: MutableList<Binding> = mutableListOf()
-    override val bindings: List<Binding> get() = _bindings
+) : DeviceGroup(context, meta), StateContainer {
+    private val _stateDescriptors: MutableList<StateDescriptor> = mutableListOf()
+    override val stateDescriptors: List<StateDescriptor> get() = _stateDescriptors
 
-    override fun registerBinding(binding: Binding) {
-        _bindings.add(binding)
+    override fun registerState(stateDescriptor: StateDescriptor) {
+        _stateDescriptors.add(stateDescriptor)
     }
 
     override fun registerProperty(descriptor: PropertyDescriptor, state: DeviceState<*>) {
         super.registerProperty(descriptor, state)
-        registerBinding(PropertyBinding(this, descriptor.name, state))
+        registerState(PropertyStateDescriptor(this, descriptor.name, state))
     }
 
-    /**
-     * Create and register a timer. Timer is not counted as a device property.
-     */
-    public fun timer(tick: Duration): TimerState = TimerState(context.request(ClockManager), tick)
-        .also { registerBinding(StateBinding(it)) }
-
     /**
      * Bind an action to a [DeviceState]. [onChange] block is performed on each state change
      *
@@ -55,7 +47,7 @@ public abstract class DeviceConstructor(
         reads: Collection<DeviceState<*>>,
         onChange: suspend (T) -> Unit,
     ): Job = valueFlow.onEach(onChange).launchIn(this@DeviceConstructor).also {
-        registerBinding(ActionBinding(setOf(this, *reads.toTypedArray()), setOf(*writes)))
+        registerState(ConnectionStateDescriptor(setOf(this, *reads.toTypedArray()), setOf(*writes)))
     }
 }
 
@@ -171,4 +163,4 @@ public fun <T, D : Device> DeviceConstructor.deviceProperty(
     property: MutableDevicePropertySpec<D, T>,
     initialValue: T,
 ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> =
-    property(device.mutablePropertyAsState(property, initialValue))
\ No newline at end of file
+    property(device.mutablePropertyAsState(property, initialValue))
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
index 08913a2..6d44422 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
@@ -1,12 +1,14 @@
 package space.kscience.controls.constructor
 
-public abstract class DeviceModel : BindingsContainer {
+import space.kscience.dataforge.context.Context
 
-    private val _bindings: MutableList<Binding> = mutableListOf()
+public abstract class DeviceModel(override val context: Context) : StateContainer {
 
-    override val bindings: List<Binding> get() = _bindings
+    private val _stateDescriptors: MutableList<StateDescriptor> = mutableListOf()
 
-    override fun registerBinding(binding: Binding) {
-        _bindings.add(binding)
+    override val stateDescriptors: List<StateDescriptor> get() = _stateDescriptors
+
+    override fun registerState(stateDescriptor: StateDescriptor) {
+        _stateDescriptors.add(stateDescriptor)
     }
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt
new file mode 100644
index 0000000..b8f40b5
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt
@@ -0,0 +1,74 @@
+package space.kscience.controls.constructor
+
+import space.kscience.controls.api.Device
+import space.kscience.controls.manager.ClockManager
+import space.kscience.dataforge.context.ContextAware
+import space.kscience.dataforge.context.request
+import space.kscience.dataforge.meta.MetaConverter
+import kotlin.time.Duration
+
+/**
+ * A binding that is used to describe device functionality
+ */
+public sealed interface StateDescriptor
+
+/**
+ * A binding that exposes device property as read-only state
+ */
+public class PropertyStateDescriptor<T>(
+    public val device: Device,
+    public val propertyName: String,
+    public val state: DeviceState<T>,
+) : StateDescriptor
+
+/**
+ * A binding for independent state like a timer
+ */
+public class StateBinding<T>(
+    public val state: DeviceState<T>,
+) : StateDescriptor
+
+public class ConnectionStateDescriptor(
+    public val reads: Collection<DeviceState<*>>,
+    public val writes: Collection<DeviceState<*>>,
+) : StateDescriptor
+
+
+public interface StateContainer : ContextAware {
+    public val stateDescriptors: List<StateDescriptor>
+    public fun registerState(stateDescriptor: StateDescriptor)
+}
+
+/**
+ * Register a [state] in this container. The state is not registered as a device property if [this] is a [DeviceConstructor]
+ */
+public fun <T, D : DeviceState<T>> StateContainer.state(state: D): D {
+    registerState(StateBinding(state))
+    return state
+}
+
+/**
+ * Create a register a [MutableDeviceState] with a given [converter]
+ */
+public fun <T> StateContainer.state(converter: MetaConverter<T>, initialValue: T): MutableDeviceState<T> = state(
+    DeviceState.internal(converter, initialValue)
+)
+
+/**
+ * Create a register a mutable [Double] state
+ */
+public fun StateContainer.doubleState(initialValue: Double): MutableDeviceState<Double> = state(
+    MetaConverter.double, initialValue
+)
+
+/**
+ * Create a register a mutable [String] state
+ */
+public fun StateContainer.stringState(initialValue: String): MutableDeviceState<String> = state(
+    MetaConverter.string, initialValue
+)
+
+/**
+ * Create and register a timer state.
+ */
+public fun StateContainer.timer(tick: Duration): TimerState = state(TimerState(context.request(ClockManager), tick))
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
similarity index 94%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
index 277271a..d1d557f 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
@@ -51,9 +51,9 @@ public class DoubleInRangeState(
 /**
  * Create and register a [DoubleInRangeState]
  */
-public fun BindingsContainer.doubleInRangeState(
+public fun StateContainer.doubleInRangeState(
     initialValue: Double,
     range: ClosedFloatingPointRange<Double>,
 ): DoubleInRangeState = DoubleInRangeState(initialValue, range).also {
-    registerBinding(StateBinding(it))
+    registerState(StateBinding(it))
 }
\ No newline at end of file
diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
index 5c75242..09c644b 100644
--- a/controls-vision/build.gradle.kts
+++ b/controls-vision/build.gradle.kts
@@ -12,7 +12,7 @@ kscience {
     useKtor()
     useSerialization()
     useContextReceivers()
-    dependencies {
+    commonMain {
         api(projects.controlsCore)
         api(projects.controlsConstructor)
         api(libs.visionforge.plotly)

From 55bcb0866814bdbbf17d5eb74e2182ca408cea4a Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Thu, 23 May 2024 16:16:22 +0300
Subject: [PATCH 089/125] Force to use endpoint ID in `launchMagixService`

---
 .../kotlin/space/kscience/controls/client/controlsMagix.kt      | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
index 1c6f556..5fe99c3 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
@@ -40,7 +40,7 @@ internal fun generateId(request: MagixMessage): String = if (request.id != null)
  */
 public fun DeviceManager.launchMagixService(
     endpoint: MagixEndpoint,
-    endpointID: String = controlsMagixFormat.defaultFormat,
+    endpointID: String,
     coroutineContext: CoroutineContext = EmptyCoroutineContext,
 ): Job = context.launch(coroutineContext) {
     endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID, null)).onEach { (request, payload) ->

From 05757aefdc1afbe674c67be6f0602ebf0ee37125 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 24 May 2024 13:56:54 +0300
Subject: [PATCH 090/125] First draft of model binding

---
 .space/CODEOWNERS                             |   1 -
 build.gradle.kts                              |   5 +-
 .../controls/constructor/DeviceConstructor.kt |  46 ++---
 .../controls/constructor/DeviceGroup.kt       |  56 ++++--
 .../controls/constructor/DeviceModel.kt       |  24 ++-
 .../controls/constructor/DeviceState.kt       |  21 +-
 .../controls/constructor/StateDescriptor.kt   | 169 +++++++++++++---
 .../controls/constructor/TimerState.kt        |   3 -
 .../controls/constructor/boundState.kt        |   2 +-
 .../controls/constructor/exoticState.kt       |  10 +-
 .../controls/constructor/externalState.kt     |  13 +-
 .../controls/constructor/flowState.kt         |   7 +-
 .../controls/constructor/internalState.kt     |   9 +-
 .../space/kscience/controls/api/DeviceHub.kt  |   1 +
 .../controls/{spec => misc}/converters.kt     |  20 +-
 .../kscience/controls/api/MessageTest.kt      |   3 +-
 .../kscience/controls/client/DeviceClient.kt  |  18 +-
 .../kscience/controls/client/controlsMagix.kt |   3 +-
 .../kotlin/BooleanIndicatorVision.kt          |  24 ---
 .../commonMain/kotlin/ControlVisionPlugin.kt  |   3 +-
 .../src/commonMain/kotlin/controlsVisions.kt  |  35 ++++
 .../src/commonMain/kotlin/plotExtensions.kt   |   2 +-
 .../jsMain/kotlin/ControlsVisionPlugin.js.kt  |  32 ++-
 .../controls/demo/car/VirtualCarController.kt |   2 +-
 demo/constructor/build.gradle.kts             |  12 +-
 .../src/jvmMain/kotlin/BodyOnSprings.kt       | 185 ++++++++++++++++++
 .../pimotionmaster/PiMotionMasterDevice.kt    |   2 +
 gradle/libs.versions.toml                     |   1 +
 28 files changed, 537 insertions(+), 172 deletions(-)
 delete mode 100644 .space/CODEOWNERS
 rename controls-core/src/commonMain/kotlin/space/kscience/controls/{spec => misc}/converters.kt (62%)
 delete mode 100644 controls-vision/src/commonMain/kotlin/BooleanIndicatorVision.kt
 create mode 100644 controls-vision/src/commonMain/kotlin/controlsVisions.kt
 create mode 100644 demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt

diff --git a/.space/CODEOWNERS b/.space/CODEOWNERS
deleted file mode 100644
index 9f836ea..0000000
--- a/.space/CODEOWNERS
+++ /dev/null
@@ -1 +0,0 @@
-./space/* "Project Admin"
diff --git a/build.gradle.kts b/build.gradle.kts
index 6423c3c..50a61d8 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -7,10 +7,7 @@ plugins {
 
 allprojects {
     group = "space.kscience"
-    version = "0.4.0-dev-3"
-    repositories{
-        maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
-    }
+    version = "0.4.0-dev-4"
 }
 
 ksciencePublish {
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 03a1e0e..17294f1 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -1,8 +1,5 @@
 package space.kscience.controls.constructor
 
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.PropertyDescriptor
 import space.kscience.controls.spec.DevicePropertySpec
@@ -25,29 +22,24 @@ public abstract class DeviceConstructor(
     context: Context,
     meta: Meta = Meta.EMPTY,
 ) : DeviceGroup(context, meta), StateContainer {
-    private val _stateDescriptors: MutableList<StateDescriptor> = mutableListOf()
-    override val stateDescriptors: List<StateDescriptor> get() = _stateDescriptors
+    private val _stateDescriptors: MutableSet<StateDescriptor> = mutableSetOf()
+    override val stateDescriptors: Set<StateDescriptor> get() = _stateDescriptors
 
     override fun registerState(stateDescriptor: StateDescriptor) {
         _stateDescriptors.add(stateDescriptor)
     }
 
-    override fun registerProperty(descriptor: PropertyDescriptor, state: DeviceState<*>) {
-        super.registerProperty(descriptor, state)
-        registerState(PropertyStateDescriptor(this, descriptor.name, state))
+    override fun unregisterState(stateDescriptor: StateDescriptor) {
+        _stateDescriptors.remove(stateDescriptor)
     }
 
-    /**
-     * Bind an action to a [DeviceState]. [onChange] block is performed on each state change
-     *
-     * Optionally provide [writes] - a set of states that this change affects.
-     */
-    public fun <T> DeviceState<T>.onChange(
-        vararg writes: DeviceState<*>,
-        reads: Collection<DeviceState<*>>,
-        onChange: suspend (T) -> Unit,
-    ): Job = valueFlow.onEach(onChange).launchIn(this@DeviceConstructor).also {
-        registerState(ConnectionStateDescriptor(setOf(this, *reads.toTypedArray()), setOf(*writes)))
+    override fun <T> registerProperty(
+        converter: MetaConverter<T>,
+        descriptor: PropertyDescriptor,
+        state: DeviceState<T>,
+    ) {
+        super.registerProperty(converter, descriptor, state)
+        registerState(StatePropertyDescriptor(this, descriptor.name, state))
     }
 }
 
@@ -84,6 +76,7 @@ public fun <D : Device> DeviceConstructor.device(
  * Register a property and provide a direct reader for it
  */
 public fun <T, S : DeviceState<T>> DeviceConstructor.property(
+    converter: MetaConverter<T>,
     state: S,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     nameOverride: String? = null,
@@ -91,7 +84,7 @@ public fun <T, S : DeviceState<T>> DeviceConstructor.property(
     PropertyDelegateProvider { _: DeviceConstructor, property ->
         val name = nameOverride ?: property.name
         val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
-        registerProperty(descriptor, state)
+        registerProperty(converter, descriptor, state)
         ReadOnlyProperty { _: DeviceConstructor, _ ->
             state
         }
@@ -108,7 +101,8 @@ public fun <T : Any> DeviceConstructor.property(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     nameOverride: String? = null,
 ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, DeviceState<T>>> = property(
-    DeviceState.external(this, metaConverter, readInterval, initialState, reader),
+    metaConverter,
+    DeviceState.external(this, readInterval, initialState, reader),
     descriptorBuilder,
     nameOverride,
 )
@@ -125,7 +119,8 @@ public fun <T : Any> DeviceConstructor.mutableProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     nameOverride: String? = null,
 ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property(
-    DeviceState.external(this, metaConverter, readInterval, initialState, reader, writer),
+    metaConverter,
+    DeviceState.external(this, readInterval, initialState, reader, writer),
     descriptorBuilder,
     nameOverride,
 )
@@ -140,7 +135,8 @@ public fun <T> DeviceConstructor.virtualProperty(
     nameOverride: String? = null,
     callback: (T) -> Unit = {},
 ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property(
-    DeviceState.internal(metaConverter, initialState, callback),
+    metaConverter,
+    MutableDeviceState(initialState, callback),
     descriptorBuilder,
     nameOverride,
 )
@@ -153,7 +149,7 @@ public fun <T, D : Device> DeviceConstructor.deviceProperty(
     property: DevicePropertySpec<D, T>,
     initialValue: T,
 ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, DeviceState<T>>> =
-    property(device.propertyAsState(property, initialValue))
+    property(property.converter, device.propertyAsState(property, initialValue))
 
 /**
  * Bind existing property provided by specification to this device
@@ -163,4 +159,4 @@ public fun <T, D : Device> DeviceConstructor.deviceProperty(
     property: MutableDevicePropertySpec<D, T>,
     initialValue: T,
 ): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> =
-    property(device.mutablePropertyAsState(property, initialValue))
+    property(property.converter, device.mutablePropertyAsState(property, initialValue))
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index ef886dc..ddac115 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -1,14 +1,12 @@
 package space.kscience.controls.constructor
 
 import kotlinx.coroutines.*
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableSharedFlow
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.*
 import space.kscience.controls.api.*
 import space.kscience.controls.api.DeviceLifecycleState.*
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
+import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.Laminate
 import space.kscience.dataforge.meta.Meta
@@ -30,12 +28,21 @@ public open class DeviceGroup(
     override val meta: Meta,
 ) : DeviceHub, CachingDevice {
 
-    internal class Property(
-        val state: DeviceState<*>,
+    private class Property<T>(
+        val state: DeviceState<T>,
+        val converter: MetaConverter<T>,
         val descriptor: PropertyDescriptor,
-    )
+    ) {
+        val valueAsMeta get() = converter.convert(state.value)
 
-    internal class Action(
+        fun setMeta(meta: Meta) {
+            check(state is MutableDeviceState) { "Can't write to read-only property" }
+
+            state.value = converter.read(meta)
+        }
+    }
+
+    private class Action(
         val invoke: suspend (Meta?) -> Meta?,
         val descriptor: ActionDescriptor,
     )
@@ -81,16 +88,20 @@ public open class DeviceGroup(
         return device
     }
 
-    private val properties: MutableMap<Name, Property> = hashMapOf()
+    private val properties: MutableMap<Name, Property<*>> = hashMapOf()
 
     /**
      * Register a new property based on [DeviceState]. Properties could be modified dynamically
      */
-    public open fun registerProperty(descriptor: PropertyDescriptor, state: DeviceState<*>) {
+    public open fun <T> registerProperty(
+        converter: MetaConverter<T>,
+        descriptor: PropertyDescriptor,
+        state: DeviceState<T>,
+    ) {
         val name = descriptor.name.parseAsName()
         require(properties[name] == null) { "Can't add property with name $name. It already exists." }
-        properties[name] = Property(state, descriptor)
-        state.metaFlow.onEach {
+        properties[name] = Property(state, converter, descriptor)
+        state.valueFlow.map(converter::convert).onEach {
             sharedMessageFlow.emit(
                 PropertyChangedMessage(
                     descriptor.name,
@@ -109,19 +120,18 @@ public open class DeviceGroup(
         get() = actions.values.map { it.descriptor }
 
     override suspend fun readProperty(propertyName: String): Meta =
-        properties[propertyName.parseAsName()]?.state?.valueAsMeta
+        properties[propertyName.parseAsName()]?.valueAsMeta
             ?: error("Property with name $propertyName not found")
 
-    override fun getProperty(propertyName: String): Meta? = properties[propertyName.parseAsName()]?.state?.valueAsMeta
+    override fun getProperty(propertyName: String): Meta? = properties[propertyName.parseAsName()]?.valueAsMeta
 
     override suspend fun invalidate(propertyName: String) {
         //does nothing for this implementation
     }
 
     override suspend fun writeProperty(propertyName: String, value: Meta) {
-        val property = (properties[propertyName.parseAsName()]?.state as? MutableDeviceState)
-            ?: error("Property with name $propertyName not found")
-        property.valueAsMeta = value
+        val property = properties[propertyName.parseAsName()] ?: error("Property with name $propertyName not found")
+        property.setMeta(value)
     }
 
 
@@ -164,6 +174,10 @@ public open class DeviceGroup(
     }
 }
 
+public fun <T> DeviceGroup.registerProperty(propertySpec: DevicePropertySpec<*, T>, state: DeviceState<T>) {
+    registerProperty(propertySpec.converter, propertySpec.descriptor, state)
+}
+
 public fun DeviceManager.registerDeviceGroup(
     name: String = "@group",
     meta: Meta = Meta.EMPTY,
@@ -234,10 +248,12 @@ public fun DeviceGroup.registerDeviceGroup(name: String, block: DeviceGroup.() -
  */
 public fun <T : Any> DeviceGroup.registerProperty(
     name: String,
+    converter: MetaConverter<T>,
     state: DeviceState<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
 ) {
     registerProperty(
+        converter,
         PropertyDescriptor(name).apply(descriptorBuilder),
         state
     )
@@ -248,10 +264,12 @@ public fun <T : Any> DeviceGroup.registerProperty(
  */
 public fun <T : Any> DeviceGroup.registerMutableProperty(
     name: String,
+    converter: MetaConverter<T>,
     state: MutableDeviceState<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
 ) {
     registerProperty(
+        converter,
         PropertyDescriptor(name).apply(descriptorBuilder),
         state
     )
@@ -269,7 +287,7 @@ public fun <T : Any> DeviceGroup.registerVirtualProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     callback: (T) -> Unit = {},
 ): MutableDeviceState<T> {
-    val state = DeviceState.internal<T>(converter, initialValue, callback)
-    registerMutableProperty(name, state, descriptorBuilder)
+    val state = MutableDeviceState<T>(initialValue, callback)
+    registerMutableProperty(name, converter, state, descriptorBuilder)
     return state
 }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
index 6d44422..484b471 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
@@ -1,14 +1,32 @@
 package space.kscience.controls.constructor
 
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.newCoroutineContext
 import space.kscience.dataforge.context.Context
+import kotlin.coroutines.CoroutineContext
 
-public abstract class DeviceModel(override val context: Context) : StateContainer {
+public abstract class DeviceModel(
+    final override val context: Context,
+    vararg dependencies: DeviceState<*>,
+) : StateContainer, CoroutineScope {
 
-    private val _stateDescriptors: MutableList<StateDescriptor> = mutableListOf()
+    override val coroutineContext: CoroutineContext = context.newCoroutineContext(SupervisorJob())
 
-    override val stateDescriptors: List<StateDescriptor> get() = _stateDescriptors
+
+    private val _stateDescriptors: MutableSet<StateDescriptor> = mutableSetOf<StateDescriptor>().apply {
+        dependencies.forEach {
+            add(StateNodeDescriptor(it))
+        }
+    }
+
+    override val stateDescriptors: Set<StateDescriptor> get() = _stateDescriptors
 
     override fun registerState(stateDescriptor: StateDescriptor) {
         _stateDescriptors.add(stateDescriptor)
     }
+
+    override fun unregisterState(stateDescriptor: StateDescriptor) {
+        _stateDescriptors.remove(stateDescriptor)
+    }
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index 8a2914b..e74c4c0 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -6,15 +6,12 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.MetaConverter
 import kotlin.reflect.KProperty
 
 /**
  * An observable state of a device
  */
 public interface DeviceState<T> {
-    public val converter: MetaConverter<T>
     public val value: T
 
     public val valueFlow: Flow<T>
@@ -24,9 +21,6 @@ public interface DeviceState<T> {
     public companion object
 }
 
-public val <T> DeviceState<T>.metaFlow: Flow<Meta> get() = valueFlow.map(converter::convert)
-
-public val <T> DeviceState<T>.valueAsMeta: Meta get() = converter.convert(value)
 
 public operator fun <T> DeviceState<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value
 
@@ -47,12 +41,6 @@ public operator fun <T> MutableDeviceState<T>.setValue(thisRef: Any?, property:
     this.value = value
 }
 
-public var <T> MutableDeviceState<T>.valueAsMeta: Meta
-    get() = converter.convert(value)
-    set(arg) {
-        value = converter.read(arg)
-    }
-
 /**
  * Device state with a value that depends on other device states
  */
@@ -65,17 +53,15 @@ public interface DeviceStateWithDependencies<T> : DeviceState<T> {
  */
 public fun <T, R> DeviceState.Companion.map(
     state: DeviceState<T>,
-    converter: MetaConverter<R>, mapper: (T) -> R,
+    mapper: (T) -> R,
 ): DeviceStateWithDependencies<R> = object : DeviceStateWithDependencies<R> {
     override val dependencies = listOf(state)
 
-    override val converter: MetaConverter<R> = converter
-
     override val value: R get() = mapper(state.value)
 
     override val valueFlow: Flow<R> = state.valueFlow.map(mapper)
 
-    override fun toString(): String = "DeviceState.map(arg=${state}, converter=$converter)"
+    override fun toString(): String = "DeviceState.map(arg=${state})"
 }
 
 /**
@@ -84,13 +70,10 @@ public fun <T, R> DeviceState.Companion.map(
 public fun <T1, T2, R> DeviceState.Companion.combine(
     state1: DeviceState<T1>,
     state2: DeviceState<T2>,
-    converter: MetaConverter<R>,
     mapper: (T1, T2) -> R,
 ): DeviceStateWithDependencies<R> = object : DeviceStateWithDependencies<R> {
     override val dependencies = listOf(state1, state2)
 
-    override val converter: MetaConverter<R> = converter
-
     override val value: R get() = mapper(state1.value, state2.value)
 
     override val valueFlow: Flow<R> = kotlinx.coroutines.flow.combine(state1.valueFlow, state2.valueFlow, mapper)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt
index b8f40b5..30a0299 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt
@@ -1,10 +1,14 @@
 package space.kscience.controls.constructor
 
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.runningFold
 import space.kscience.controls.api.Device
 import space.kscience.controls.manager.ClockManager
 import space.kscience.dataforge.context.ContextAware
 import space.kscience.dataforge.context.request
-import space.kscience.dataforge.meta.MetaConverter
 import kotlin.time.Duration
 
 /**
@@ -15,7 +19,7 @@ public sealed interface StateDescriptor
 /**
  * A binding that exposes device property as read-only state
  */
-public class PropertyStateDescriptor<T>(
+public class StatePropertyDescriptor<T>(
     public val device: Device,
     public val propertyName: String,
     public val state: DeviceState<T>,
@@ -24,51 +28,172 @@ public class PropertyStateDescriptor<T>(
 /**
  * A binding for independent state like a timer
  */
-public class StateBinding<T>(
+public class StateNodeDescriptor<T>(
     public val state: DeviceState<T>,
 ) : StateDescriptor
 
-public class ConnectionStateDescriptor(
+public class StateConnectionDescriptor(
     public val reads: Collection<DeviceState<*>>,
     public val writes: Collection<DeviceState<*>>,
 ) : StateDescriptor
 
 
-public interface StateContainer : ContextAware {
-    public val stateDescriptors: List<StateDescriptor>
+public interface StateContainer : ContextAware, CoroutineScope {
+    public val stateDescriptors: Set<StateDescriptor>
     public fun registerState(stateDescriptor: StateDescriptor)
+    public fun unregisterState(stateDescriptor: StateDescriptor)
+
+
+    /**
+     * Bind an action to a [DeviceState]. [onChange] block is performed on each state change
+     *
+     * Optionally provide [writes] - a set of states that this change affects.
+     */
+    public fun <T> DeviceState<T>.onNext(
+        vararg writes: DeviceState<*>,
+        alsoReads: Collection<DeviceState<*>> = emptySet(),
+        onChange: suspend (T) -> Unit,
+    ): Job = valueFlow.onEach(onChange).launchIn(this@StateContainer).also {
+        registerState(StateConnectionDescriptor(setOf(this, *alsoReads.toTypedArray()), setOf(*writes)))
+    }
+
+    public fun <T> DeviceState<T>.onChange(
+        vararg writes: DeviceState<*>,
+        alsoReads: Collection<DeviceState<*>> = emptySet(),
+        onChange: suspend (prev: T, next: T) -> Unit,
+    ): Job = valueFlow.runningFold(Pair(value, value)) { pair, next ->
+        Pair(pair.second, next)
+    }.onEach { pair ->
+        if (pair.first != pair.second) {
+            onChange(pair.first, pair.second)
+        }
+    }.launchIn(this@StateContainer).also {
+        registerState(StateConnectionDescriptor(setOf(this, *alsoReads.toTypedArray()), setOf(*writes)))
+    }
 }
 
 /**
  * Register a [state] in this container. The state is not registered as a device property if [this] is a [DeviceConstructor]
  */
 public fun <T, D : DeviceState<T>> StateContainer.state(state: D): D {
-    registerState(StateBinding(state))
+    registerState(StateNodeDescriptor(state))
     return state
 }
 
 /**
  * Create a register a [MutableDeviceState] with a given [converter]
  */
-public fun <T> StateContainer.state(converter: MetaConverter<T>, initialValue: T): MutableDeviceState<T> = state(
-    DeviceState.internal(converter, initialValue)
+public fun <T> StateContainer.mutableState(initialValue: T): MutableDeviceState<T> = state(
+    MutableDeviceState(initialValue)
 )
 
-/**
- * Create a register a mutable [Double] state
- */
-public fun StateContainer.doubleState(initialValue: Double): MutableDeviceState<Double> = state(
-    MetaConverter.double, initialValue
-)
-
-/**
- * Create a register a mutable [String] state
- */
-public fun StateContainer.stringState(initialValue: String): MutableDeviceState<String> = state(
-    MetaConverter.string, initialValue
-)
+public fun <T : DeviceModel> StateContainer.model(model: T): T {
+    model.stateDescriptors.forEach {
+        registerState(it)
+    }
+    return model
+}
 
 /**
  * Create and register a timer state.
  */
 public fun StateContainer.timer(tick: Duration): TimerState = state(TimerState(context.request(ClockManager), tick))
+
+
+public fun <T, R> StateContainer.mapState(
+    state: DeviceState<T>,
+    transformation: (T) -> R,
+): DeviceStateWithDependencies<R> = state(DeviceState.map(state, transformation))
+
+/**
+ * Create a new state by combining two existing ones
+ */
+public fun <T1, T2, R> StateContainer.combineState(
+    first: DeviceState<T1>,
+    second: DeviceState<T2>,
+    transformation: (T1, T2) -> R,
+): DeviceState<R> = state(DeviceState.combine(first, second, transformation))
+
+
+/**
+ * Create and start binding between [sourceState] and [targetState]. Changes made to [sourceState] are automatically
+ * transferred onto [targetState], but not vise versa.
+ *
+ * On resulting [Job] cancel the binding is unregistered
+ */
+public fun <T> StateContainer.bindTo(sourceState: DeviceState<T>, targetState: MutableDeviceState<T>): Job {
+    val descriptor = StateConnectionDescriptor(setOf(sourceState), setOf(targetState))
+    registerState(descriptor)
+    return sourceState.valueFlow.onEach {
+        targetState.value = it
+    }.launchIn(this).apply {
+        invokeOnCompletion {
+            unregisterState(descriptor)
+        }
+    }
+}
+
+/**
+ * Create and start binding between [sourceState] and [targetState]. Changes made to [sourceState] are automatically
+ * transferred onto [targetState] via [transformation], but not vise versa.
+ *
+ * On resulting [Job] cancel the binding is unregistered
+ */
+public fun <T, R> StateContainer.transformTo(
+    sourceState: DeviceState<T>,
+    targetState: MutableDeviceState<R>,
+    transformation: suspend (T) -> R,
+): Job {
+    val descriptor = StateConnectionDescriptor(setOf(sourceState), setOf(targetState))
+    registerState(descriptor)
+    return sourceState.valueFlow.onEach {
+        targetState.value = transformation(it)
+    }.launchIn(this).apply {
+        invokeOnCompletion {
+            unregisterState(descriptor)
+        }
+    }
+}
+
+/**
+ * Register [StateDescriptor] that combines values from [sourceState1] and [sourceState2] using [transformation].
+ *
+ * On resulting [Job] cancel the binding is unregistered
+ */
+public fun <T1, T2, R> StateContainer.combineTo(
+    sourceState1: DeviceState<T1>,
+    sourceState2: DeviceState<T2>,
+    targetState: MutableDeviceState<R>,
+    transformation: suspend (T1, T2) -> R,
+): Job {
+    val descriptor = StateConnectionDescriptor(setOf(sourceState1, sourceState2), setOf(targetState))
+    registerState(descriptor)
+    return kotlinx.coroutines.flow.combine(sourceState1.valueFlow, sourceState2.valueFlow, transformation).onEach {
+        targetState.value = it
+    }.launchIn(this).apply {
+        invokeOnCompletion {
+            unregisterState(descriptor)
+        }
+    }
+}
+
+/**
+ * Register [StateDescriptor] that combines values from [sourceStates] using [transformation].
+ *
+ * On resulting [Job] cancel the binding is unregistered
+ */
+public inline fun <reified T, R> StateContainer.combineTo(
+    sourceStates: Collection<DeviceState<T>>,
+    targetState: MutableDeviceState<R>,
+    noinline transformation: suspend (Array<T>) -> R,
+): Job {
+    val descriptor = StateConnectionDescriptor(sourceStates, setOf(targetState))
+    registerState(descriptor)
+    return kotlinx.coroutines.flow.combine(sourceStates.map { it.valueFlow }, transformation).onEach {
+        targetState.value = it
+    }.launchIn(this).apply {
+        invokeOnCompletion {
+            unregisterState(descriptor)
+        }
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
index 8f36079..baf9646 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
@@ -7,8 +7,6 @@ import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.datetime.Instant
 import space.kscience.controls.manager.ClockManager
-import space.kscience.controls.spec.instant
-import space.kscience.dataforge.meta.MetaConverter
 import kotlin.time.Duration
 
 /**
@@ -23,7 +21,6 @@ public class TimerState(
     public val clockManager: ClockManager,
     public val tick: Duration,
 ) : DeviceState<Instant> {
-    override val converter: MetaConverter<Instant> get() = MetaConverter.instant
 
     private val clock = MutableStateFlow(clockManager.clock.now())
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt
index 37be770..f5c4480 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt
@@ -15,7 +15,7 @@ import space.kscience.dataforge.meta.MetaConverter
  * A copy-free [DeviceState] bound to a device property
  */
 private open class BoundDeviceState<T>(
-    override val converter: MetaConverter<T>,
+    val converter: MetaConverter<T>,
     val device: Device,
     val propertyName: String,
     initialValue: T,
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
index d1d557f..aabacfd 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
@@ -2,7 +2,6 @@ package space.kscience.controls.constructor
 
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
-import space.kscience.dataforge.meta.MetaConverter
 
 
 /**
@@ -17,7 +16,6 @@ public class DoubleInRangeState(
         require(initialValue in range) { "Initial value should be in range" }
     }
 
-    override val converter: MetaConverter<Double> = MetaConverter.double
 
     private val _valueFlow = MutableStateFlow(initialValue)
 
@@ -32,18 +30,18 @@ public class DoubleInRangeState(
     /**
      * A state showing that the range is on its lower boundary
      */
-    public val atStart: DeviceState<Boolean> = DeviceState.map(this, MetaConverter.boolean) {
+    public val atStart: DeviceState<Boolean> = DeviceState.map(this) {
         it <= range.start
     }
 
     /**
      * A state showing that the range is on its higher boundary
      */
-    public val atEnd: DeviceState<Boolean> = DeviceState.map(this, MetaConverter.boolean) {
+    public val atEnd: DeviceState<Boolean> = DeviceState.map(this) {
         it >= range.endInclusive
     }
 
-    override fun toString(): String = "DoubleRangeState(range=$range, converter=$converter)"
+    override fun toString(): String = "DoubleRangeState(range=$range)"
 
 
 }
@@ -55,5 +53,5 @@ public fun StateContainer.doubleInRangeState(
     initialValue: Double,
     range: ClosedFloatingPointRange<Double>,
 ): DoubleInRangeState = DoubleInRangeState(initialValue, range).also {
-    registerState(StateBinding(it))
+    registerState(StateNodeDescriptor(it))
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt
index c670a23..9b4a15f 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt
@@ -4,13 +4,11 @@ import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.launch
-import space.kscience.dataforge.meta.MetaConverter
 import kotlin.time.Duration
 
 
 private open class ExternalState<T>(
     val scope: CoroutineScope,
-    override val converter: MetaConverter<T>,
     val readInterval: Duration,
     initialValue: T,
     val reader: suspend () -> T,
@@ -26,7 +24,7 @@ private open class ExternalState<T>(
     override val value: T get() = flow.value
     override val valueFlow: Flow<T> get() = flow
 
-    override fun toString(): String  = "ExternalState(converter=$converter)"
+    override fun toString(): String  = "ExternalState()"
 }
 
 /**
@@ -34,20 +32,18 @@ private open class ExternalState<T>(
  */
 public fun <T> DeviceState.Companion.external(
     scope: CoroutineScope,
-    converter: MetaConverter<T>,
     readInterval: Duration,
     initialValue: T,
     reader: suspend () -> T,
-): DeviceState<T> = ExternalState(scope, converter, readInterval, initialValue, reader)
+): DeviceState<T> = ExternalState(scope,  readInterval, initialValue, reader)
 
 private class MutableExternalState<T>(
     scope: CoroutineScope,
-    converter: MetaConverter<T>,
     readInterval: Duration,
     initialValue: T,
     reader: suspend () -> T,
     val writer: suspend (T) -> Unit,
-) : ExternalState<T>(scope, converter, readInterval, initialValue, reader), MutableDeviceState<T> {
+) : ExternalState<T>(scope, readInterval, initialValue, reader), MutableDeviceState<T> {
     override var value: T
         get() = super.value
         set(value) {
@@ -62,9 +58,8 @@ private class MutableExternalState<T>(
  */
 public fun <T> DeviceState.Companion.external(
     scope: CoroutineScope,
-    converter: MetaConverter<T>,
     readInterval: Duration,
     initialValue: T,
     reader: suspend () -> T,
     writer: suspend (T) -> Unit,
-): MutableDeviceState<T> = MutableExternalState(scope, converter, readInterval, initialValue, reader, writer)
\ No newline at end of file
+): MutableDeviceState<T> = MutableExternalState(scope, readInterval, initialValue, reader, writer)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt
index 434c074..2bd9322 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt
@@ -2,22 +2,19 @@ package space.kscience.controls.constructor
 
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
-import space.kscience.dataforge.meta.MetaConverter
 
 
 private class StateFlowAsState<T>(
-    override val converter: MetaConverter<T>,
     val flow: MutableStateFlow<T>,
 ) : MutableDeviceState<T> {
     override var value: T by flow::value
     override val valueFlow: Flow<T> get() = flow
 
-    override fun toString(): String = "FlowAsState(converter=$converter)"
+    override fun toString(): String = "FlowAsState()"
 }
 
 /**
  * Create a read-only [DeviceState] that wraps [MutableStateFlow].
  * No data copy is performed.
  */
-public fun <T> MutableStateFlow<T>.asDeviceState(converter: MetaConverter<T>): DeviceState<T> =
-    StateFlowAsState(converter, this)
\ No newline at end of file
+public fun <T> MutableStateFlow<T>.asDeviceState(): MutableDeviceState<T> = StateFlowAsState(this)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
index ed2d5d1..11c778d 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
@@ -2,7 +2,6 @@ package space.kscience.controls.constructor
 
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
-import space.kscience.dataforge.meta.MetaConverter
 
 /**
  * A [MutableDeviceState] that does not correspond to a physical state
@@ -10,7 +9,6 @@ import space.kscience.dataforge.meta.MetaConverter
  * @param callback a synchronous callback that could be used without a scope
  */
 private class VirtualDeviceState<T>(
-    override val converter: MetaConverter<T>,
     initialValue: T,
     private val callback: (T) -> Unit = {},
 ) : MutableDeviceState<T> {
@@ -24,7 +22,7 @@ private class VirtualDeviceState<T>(
             callback(value)
         }
 
-    override fun toString(): String = "VirtualDeviceState(converter=$converter)"
+    override fun toString(): String = "VirtualDeviceState()"
 }
 
 
@@ -33,8 +31,7 @@ private class VirtualDeviceState<T>(
  *
  * @param callback a synchronous callback that could be used without a scope
  */
-public fun <T> DeviceState.Companion.internal(
-    converter: MetaConverter<T>,
+public fun <T> MutableDeviceState(
     initialValue: T,
     callback: (T) -> Unit = {},
-): MutableDeviceState<T> = VirtualDeviceState(converter, initialValue, callback)
+): MutableDeviceState<T> = VirtualDeviceState(initialValue, callback)
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
index e2f6331..4aef3ff 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
@@ -22,6 +22,7 @@ public interface DeviceHub : Provider {
     } else {
         emptyMap()
     }
+    //TODO send message on device change
 
     public companion object
 }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/converters.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt
similarity index 62%
rename from controls-core/src/commonMain/kotlin/space/kscience/controls/spec/converters.kt
rename to controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt
index 89a28da..4297d20 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/converters.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt
@@ -1,4 +1,4 @@
-package space.kscience.controls.spec
+package space.kscience.controls.misc
 
 import kotlinx.datetime.Instant
 import space.kscience.dataforge.meta.*
@@ -12,9 +12,9 @@ public fun Double.asMeta(): Meta = Meta(asValue())
  * Generate a nullable [MetaConverter] from non-nullable one
  */
 public fun <T : Any> MetaConverter<T>.nullable(): MetaConverter<T?> = object : MetaConverter<T?> {
-    override fun convert(obj: T?): Meta  = obj?.let { this@nullable.convert(it) }?: Meta(Null)
+    override fun convert(obj: T?): Meta = obj?.let { this@nullable.convert(it) } ?: Meta(Null)
 
-    override fun readOrNull(source: Meta): T? = if(source.value == Null) null else this@nullable.readOrNull(source)
+    override fun readOrNull(source: Meta): T? = if (source.value == Null) null else this@nullable.readOrNull(source)
 
 }
 
@@ -38,4 +38,16 @@ private object InstantConverter : MetaConverter<Instant> {
     override fun convert(obj: Instant): Meta = Meta(obj.toString())
 }
 
-public val MetaConverter.Companion.instant: MetaConverter<Instant> get() = InstantConverter
\ No newline at end of file
+public val MetaConverter.Companion.instant: MetaConverter<Instant> get() = InstantConverter
+
+private object DoubleRangeConverter : MetaConverter<ClosedFloatingPointRange<Double>> {
+    override fun readOrNull(source: Meta): ClosedFloatingPointRange<Double>? = source.value?.doubleArray?.let { (start, end)->
+        start..end
+    }
+
+    override fun convert(
+        obj: ClosedFloatingPointRange<Double>,
+    ): Meta = Meta(doubleArrayOf(obj.start, obj.endInclusive).asValue())
+}
+
+public val MetaConverter.Companion.doubleRange: MetaConverter<ClosedFloatingPointRange<Double>> get() = DoubleRangeConverter
\ No newline at end of file
diff --git a/controls-core/src/commonTest/kotlin/space/kscience/controls/api/MessageTest.kt b/controls-core/src/commonTest/kotlin/space/kscience/controls/api/MessageTest.kt
index 719738a..269b140 100644
--- a/controls-core/src/commonTest/kotlin/space/kscience/controls/api/MessageTest.kt
+++ b/controls-core/src/commonTest/kotlin/space/kscience/controls/api/MessageTest.kt
@@ -1,9 +1,8 @@
 package space.kscience.controls.api
 
-import kotlinx.serialization.decodeFromString
 import kotlinx.serialization.encodeToString
 import kotlinx.serialization.json.Json
-import space.kscience.controls.spec.asMeta
+import space.kscience.controls.misc.asMeta
 import kotlin.test.Test
 import kotlin.test.assertEquals
 
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index 859bce2..f4a232e 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
@@ -16,8 +16,6 @@ import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.misc.DFExperimental
 import space.kscience.dataforge.names.Name
-import space.kscience.dataforge.names.length
-import space.kscience.dataforge.names.startsWith
 import space.kscience.magix.api.MagixEndpoint
 import space.kscience.magix.api.send
 import space.kscience.magix.api.subscribe
@@ -121,12 +119,18 @@ public suspend fun MagixEndpoint.remoteDevice(
     deviceEndpoint: String,
     deviceName: Name,
 ): DeviceClient = coroutineScope {
-    val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(deviceEndpoint)).map { it.second }
+    val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(deviceEndpoint))
+        .map { it.second }
+        .filter {
+            it.sourceDevice == null || it.sourceDevice == deviceName
+        }
 
     val deferredDescriptorMessage = CompletableDeferred<DescriptionMessage>()
 
     launch {
-        deferredDescriptorMessage.complete(subscription.filterIsInstance<DescriptionMessage>().first())
+        deferredDescriptorMessage.complete(
+            subscription.filterIsInstance<DescriptionMessage>().first()
+        )
     }
 
     send(
@@ -157,12 +161,6 @@ public suspend fun MagixEndpoint.remoteDevice(
     }
 }
 
-private class MapBasedDeviceHub(val deviceMap: Map<Name, Device>, val prefix: Name) : DeviceHub {
-    override val devices: Map<Name, Device>
-        get() = deviceMap.filterKeys { name: Name -> name == prefix || (name.startsWith(prefix) && name.length == prefix.length + 1) }
-
-}
-
 /**
  * Create a dynamic [DeviceHub] from incoming messages
  */
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
index 5fe99c3..2ffad2a 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt
@@ -1,6 +1,7 @@
 package space.kscience.controls.client
 
 import com.benasher44.uuid.uuid4
+import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.catch
 import kotlinx.coroutines.flow.launchIn
@@ -56,7 +57,7 @@ public fun DeviceManager.launchMagixService(
             )
         }
     }.catch { error ->
-        logger.error(error) { "Error while responding to message: ${error.message}" }
+        if (error !is CancellationException) logger.error(error) { "Error while responding to message: ${error.message}" }
     }.launchIn(this)
 
     hubMessageFlow().onEach { payload ->
diff --git a/controls-vision/src/commonMain/kotlin/BooleanIndicatorVision.kt b/controls-vision/src/commonMain/kotlin/BooleanIndicatorVision.kt
deleted file mode 100644
index 8783228..0000000
--- a/controls-vision/src/commonMain/kotlin/BooleanIndicatorVision.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package space.kscience.controls.vision
-
-import kotlinx.serialization.SerialName
-import kotlinx.serialization.Serializable
-import space.kscience.dataforge.meta.boolean
-import space.kscience.visionforge.AbstractVision
-import space.kscience.visionforge.Vision
-import space.kscience.visionforge.html.VisionOfHtml
-
-/**
- * A [Vision] that shows an indicator
- */
-@Serializable
-@SerialName("controls.indicator")
-public class BooleanIndicatorVision : AbstractVision(), VisionOfHtml {
-    public val isOn: Boolean by properties.boolean(false)
-}
-
-///**
-// * A [Vision] that allows both showing the value and changing it
-// */
-//public interface RegulatorVision: IndicatorVision{
-//
-//}
\ No newline at end of file
diff --git a/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
index 6395d53..d9ec746 100644
--- a/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
+++ b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
@@ -13,6 +13,7 @@ public expect class ControlVisionPlugin: VisionPlugin{
 
 internal val controlsVisionSerializersModule = SerializersModule {
     polymorphic(Vision::class) {
-        subclass(BooleanIndicatorVision.serializer())
+        subclass(IndicatorVision.serializer())
+        subclass(SliderVision.serializer())
     }
 }
\ No newline at end of file
diff --git a/controls-vision/src/commonMain/kotlin/controlsVisions.kt b/controls-vision/src/commonMain/kotlin/controlsVisions.kt
new file mode 100644
index 0000000..1b633ed
--- /dev/null
+++ b/controls-vision/src/commonMain/kotlin/controlsVisions.kt
@@ -0,0 +1,35 @@
+package space.kscience.controls.vision
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import space.kscience.controls.misc.doubleRange
+import space.kscience.dataforge.meta.MetaConverter
+import space.kscience.dataforge.meta.convertable
+import space.kscience.dataforge.meta.double
+import space.kscience.dataforge.meta.string
+import space.kscience.visionforge.AbstractControlVision
+import space.kscience.visionforge.AbstractVision
+import space.kscience.visionforge.Vision
+
+/**
+ * A [Vision] that shows a colored indicator
+ */
+@Serializable
+@SerialName("controls.indicator")
+public class IndicatorVision : AbstractVision() {
+    public val color: String? by properties.string()
+}
+
+@Serializable
+@SerialName("controls.slider")
+public class SliderVision : AbstractControlVision() {
+    public var position: Double? by properties.double()
+    public var range: ClosedFloatingPointRange<Double>? by properties.convertable(MetaConverter.doubleRange)
+}
+
+///**
+// * A [Vision] that allows both showing the value and changing it
+// */
+//public interface RegulatorVision: IndicatorVision{
+//
+//}
\ No newline at end of file
diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
index 88b870e..8eeb9a4 100644
--- a/controls-vision/src/commonMain/kotlin/plotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt
@@ -145,7 +145,7 @@ private fun <T> Trace.updateFromState(
 public fun <T> Plot.plotDeviceState(
     context: Context,
     state: DeviceState<T>,
-    extractValue: T.() -> Value = { state.converter.convert(this).value ?: Null },
+    extractValue: (T) -> Value = { Value.of(it) },
     maxAge: Duration = defaultMaxAge,
     maxPoints: Int = defaultMaxPoints,
     minPoints: Int = defaultMinPoints,
diff --git a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
index d08772c..b966791 100644
--- a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
+++ b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
@@ -10,7 +10,34 @@ import space.kscience.dataforge.names.asName
 import space.kscience.visionforge.VisionPlugin
 import space.kscience.visionforge.html.ElementVisionRenderer
 
-private val indicatorRenderer = ElementVisionRenderer<BooleanIndicatorVision> { name, vision: BooleanIndicatorVision, meta ->
+
+private val indicatorRenderer = ElementVisionRenderer<IndicatorVision> { name, vision: IndicatorVision, meta ->
+//    val ledSize = vision.properties["size"].int ?: 15
+//    val color = vision.color ?: "LightGray"
+//    div("controls-indicator") {
+//        style = """
+//
+//            @keyframes blink {
+//              0%   { box-shadow: 0 0 10px; }
+//              50%  { box-shadow: 0 0 30px; }
+//              100% { box-shadow: 0 0 10px; }
+//            }
+//
+//            display: inline-block;
+//            margin: ${ledSize}px;
+//            width: ${ledSize}px;
+//            height: ${ledSize}px;
+//            border-radius: 50%;
+//
+//            background: $color;
+//            border: 1px solid darken($color,5%);
+//            color: $color;
+//            animation: blink 3s infinite;
+//        """.trimIndent()
+//    }
+}
+
+private val sliderRenderer = ElementVisionRenderer<SliderVision> { name, vision: SliderVision, meta ->
 
 }
 
@@ -22,7 +49,8 @@ public actual class ControlVisionPlugin : VisionPlugin() {
 
     override fun content(target: String): Map<Name, Any> = when (target) {
         ElementVisionRenderer.TYPE -> mapOf(
-            "indicator".asName() to indicatorRenderer
+            "indicator".asName() to indicatorRenderer,
+            "slider".asName() to sliderRenderer
         )
 
         else -> super.content(target)
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
index e76cfe0..a598d4b 100644
--- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
+++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
@@ -64,7 +64,7 @@ class VirtualCarController : Controller(), ContextAware {
             //mongoStorageJob = deviceManager.storeMessages(DefaultAsynchronousMongoClientFactory)
             //Launch device client and connect it to the server
             val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost")
-            deviceManager.launchMagixService(deviceEndpoint)
+            deviceManager.launchMagixService(deviceEndpoint, "car")
         }
     }
 
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
index 6909639..8628dd1 100644
--- a/demo/constructor/build.gradle.kts
+++ b/demo/constructor/build.gradle.kts
@@ -1,3 +1,4 @@
+import org.jetbrains.compose.ExperimentalComposeLibrary
 import org.jetbrains.compose.desktop.application.dsl.TargetFormat
 import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
 
@@ -11,12 +12,15 @@ kscience {
         withJava()
     }
     useKtor()
+    useSerialization()
     useContextReceivers()
-    dependencies {
-        api(projects.controlsVision)
+    commonMain {
+        implementation(projects.controlsVision)
+        implementation(projects.controlsConstructor)
+//        implementation("io.github.koalaplot:koalaplot-core:0.6.0")
     }
     jvmMain {
-        implementation("io.ktor:ktor-server-cio")
+//        implementation("io.ktor:ktor-server-cio")
         implementation(spclibs.logback.classic)
     }
 }
@@ -26,6 +30,8 @@ kotlin {
         jvmMain {
             dependencies {
                 implementation(compose.desktop.currentOs)
+                @OptIn(ExperimentalComposeLibrary::class)
+                implementation(compose.desktop.components.splitPane)
             }
         }
     }
diff --git a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
new file mode 100644
index 0000000..bb15c49
--- /dev/null
+++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
@@ -0,0 +1,185 @@
+package space.kscience.controls.demo.constructor
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import kotlinx.serialization.Serializable
+import space.kscience.controls.constructor.*
+import space.kscience.dataforge.context.Context
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+import kotlin.math.pow
+import kotlin.math.sqrt
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.DurationUnit
+
+@Serializable
+data class XY(val x: Double, val y: Double) {
+    companion object {
+        val ZERO = XY(0.0, 0.0)
+    }
+}
+
+operator fun XY.plus(other: XY): XY = XY(x + other.x, y + other.y)
+operator fun XY.times(c: Double): XY = XY(x * c, y * c)
+operator fun XY.div(c: Double): XY = XY(x / c, y / c)
+//
+//class XYPosition(context: Context, x0: Double, y0: Double) : DeviceModel(context) {
+//    val x: MutableDeviceState<Double> = mutableState(x0)
+//    val y: MutableDeviceState<Double> = mutableState(y0)
+//
+//    val xy = combineState(x, y) { x, y -> XY(x, y) }
+//}
+
+class Spring(
+    context: Context,
+    val k: Double,
+    val l0: Double,
+    val begin: DeviceState<XY>,
+    val end: DeviceState<XY>,
+) : DeviceConstructor(context) {
+
+    val length = combineState(begin, end) { begin, end ->
+        sqrt((end.y - begin.y).pow(2) + (end.x - begin.x).pow(2))
+    }
+
+    val tension: DeviceState<Double> = mapState(length) { l ->
+        val delta = l - l0
+        k * delta
+    }
+
+    /**
+     * direction from start to end
+     */
+    val direction = combineState(begin, end) { begin, end ->
+        val dx = end.x - begin.x
+        val dy = end.y - begin.y
+        val l = sqrt((end.y - begin.y).pow(2) + (end.x - begin.x).pow(2))
+        XY(dx / l, dy / l)
+    }
+
+    val beginForce = combineState(direction, tension) { direction: XY, tension: Double ->
+        direction * (tension)
+    }
+
+
+    val endForce = combineState(direction, tension) { direction: XY, tension: Double ->
+        direction * (-tension)
+    }
+}
+
+class MaterialPoint(
+    context: Context,
+    val mass: Double,
+    val force: DeviceState<XY>,
+    val position: MutableDeviceState<XY>,
+    val velocity: MutableDeviceState<XY> = MutableDeviceState(XY.ZERO),
+) : DeviceModel(context, force) {
+
+    private val timer: TimerState = timer(2.milliseconds)
+
+    private val movement = timer.onChange(
+        position, velocity,
+        alsoReads = setOf(force, velocity, position)
+    ) { prev, next ->
+        val dt = (next - prev).toDouble(DurationUnit.SECONDS)
+        val a = force.value / mass
+        position.value += a * (dt * dt / 2) + velocity.value * dt
+        velocity.value += a * dt
+    }
+}
+
+
+class BodyOnSprings(
+    context: Context,
+    mass: Double,
+    k: Double,
+    startPosition: XY,
+    l0: Double = 1.0,
+    val xLeft: Double = 0.0,
+    val xRight: Double = 2.0,
+    val yBottom: Double = 0.0,
+    val yTop: Double = 2.0,
+) : DeviceConstructor(context) {
+
+    val width = xRight - xLeft
+    val height = yTop - yBottom
+
+    val position = mutableState(startPosition)
+
+    private val leftAnchor = mutableState(XY(xLeft, yTop + yBottom / 2))
+
+    val leftSpring by device(
+        Spring(context, k, l0, leftAnchor, position)
+    )
+
+    private val rightAnchor = mutableState(XY(xRight, yTop + yBottom / 2))
+
+    val rightSpring by device(
+        Spring(context, k, l0, rightAnchor, position)
+    )
+
+    val force: DeviceState<XY> = combineState(leftSpring.endForce, rightSpring.endForce) { left, rignt ->
+        left + rignt
+    }
+
+
+    val body = model(
+        MaterialPoint(
+            context = context,
+            mass = mass,
+            force = force,
+            position = position
+        )
+    )
+}
+
+@Composable
+fun <T> DeviceState<T>.collect(
+    coroutineContext: CoroutineContext = EmptyCoroutineContext,
+): State<T> = valueFlow.collectAsState(value, coroutineContext)
+
+fun main() = application {
+    val initialState = XY(1.1, 1.1)
+
+    Window(title = "Ball on springs", onCloseRequest = ::exitApplication) {
+        MaterialTheme {
+            val context = remember {
+                Context("simulation")
+            }
+
+            val model = remember {
+                BodyOnSprings(context, 100.0, 1000.0, initialState)
+            }
+
+            val position: XY by model.body.position.collect()
+            Box(Modifier.size(400.dp)) {
+                Canvas(modifier = Modifier.fillMaxSize()) {
+                    fun XY.toOffset() = Offset(
+                        (x / model.width * size.width).toFloat(),
+                        (y / model.height * size.height).toFloat()
+                    )
+
+                    drawCircle(
+                        Color.Red, 10f, center = position.toOffset()
+                    )
+                    drawLine(Color.Blue, model.leftSpring.begin.value.toOffset(), model.leftSpring.end.value.toOffset())
+                    drawLine(
+                        Color.Blue,
+                        model.rightSpring.begin.value.toOffset(),
+                        model.rightSpring.end.value.toOffset()
+                    )
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index 106b077..b0282d0 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -12,6 +12,8 @@ import kotlinx.coroutines.sync.withLock
 import kotlinx.coroutines.withTimeout
 import space.kscience.controls.api.DeviceHub
 import space.kscience.controls.api.PropertyDescriptor
+import space.kscience.controls.misc.asMeta
+import space.kscience.controls.misc.duration
 import space.kscience.controls.ports.AsynchronousPort
 import space.kscience.controls.ports.KtorTcpPort
 import space.kscience.controls.ports.send
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 601b9d1..617933a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -79,6 +79,7 @@ visionforge-jupiter = { module = "space.kscience:visionforge-jupyter", version.r
 visionforge-plotly = { module = "space.kscience:visionforge-plotly", version.ref = "visionforge" }
 visionforge-markdown = { module = "space.kscience:visionforge-markdown", version.ref = "visionforge" }
 visionforge-server = { module = "space.kscience:visionforge-server", version.ref = "visionforge" }
+visionforge-compose-html = { module = "space.kscience:visionforge-compose-html", version.ref = "visionforge" }
 
 # Buildscript
 

From f72d7aa3fa46b2b45e5ccbe508568cc3563af4e0 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 28 May 2024 09:51:30 +0300
Subject: [PATCH 091/125] Synchronous port response consumeAsSource

---
 CHANGELOG.md                                  |  1 +
 .../controls/ports/AsynchronousPort.kt        | 23 +++++-------------
 .../controls/ports/SynchronousPort.kt         | 24 ++++++++++++++++---
 .../kscience/controls/ports/ioExtensions.kt   | 21 +++++++++++++++-
 4 files changed, 48 insertions(+), 21 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5a1dad9..92b2765 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -13,6 +13,7 @@
 - Refactored ports. Now we have `AsynchronousPort` as well as `SynchronousPort`
 - `DeviceClient` now initializes property and action descriptors eagerly.
 - `DeviceHub` now works with `Name` instead of `NameToken`. Tree-like structure is made using `Path`. Device messages no longer have access to sub-devices.
+- Add some utility methods to ports. Synchronous port response could be now consumed as `Source`.
 
 ### Deprecated
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
index 3f89e75..9b37fd3 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
@@ -3,10 +3,7 @@ package space.kscience.controls.ports
 import kotlinx.coroutines.*
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.receiveAsFlow
-import kotlinx.io.Buffer
 import kotlinx.io.Source
 import space.kscience.controls.api.AsynchronousSocket
 import space.kscience.dataforge.context.*
@@ -26,15 +23,7 @@ public interface AsynchronousPort : ContextAware, AsynchronousSocket<ByteArray>
  * [scope] controls the consummation.
  * If the scope is canceled, the source stops producing.
  */
-public fun AsynchronousPort.receiveAsSource(scope: CoroutineScope): Source {
-    val buffer = Buffer()
-
-    subscribe().onEach {
-        buffer.write(it)
-    }.launchIn(scope)
-
-    return buffer
-}
+public fun AsynchronousPort.receiveAsSource(scope: CoroutineScope): Source = subscribe().consumeAsSource(scope)
 
 
 /**
@@ -51,13 +40,13 @@ public abstract class AbstractAsynchronousPort(
         CoroutineScope(
             coroutineContext +
                     SupervisorJob(coroutineContext[Job]) +
-                    CoroutineExceptionHandler { _, throwable -> logger.error(throwable) { throwable.stackTraceToString() } } +
+                    CoroutineExceptionHandler { _, throwable -> logger.error(throwable) { "Asynchronous port error: " + throwable.stackTraceToString() } } +
                     CoroutineName(toString())
         )
     }
 
-    private val outgoing = Channel<ByteArray>(meta["outgoing.capacity"].int?:100)
-    private val incoming = Channel<ByteArray>(meta["incoming.capacity"].int?:100)
+    private val outgoing = Channel<ByteArray>(meta["outgoing.capacity"].int ?: 100)
+    private val incoming = Channel<ByteArray>(meta["incoming.capacity"].int ?: 100)
 
     /**
      * Internal method to synchronously send data
@@ -100,7 +89,7 @@ public abstract class AbstractAsynchronousPort(
      * Send a data packet via the port
      */
     override suspend fun send(data: ByteArray) {
-        check(isOpen){"The port is not opened"}
+        check(isOpen) { "The port is not opened" }
         outgoing.send(data)
     }
 
@@ -117,7 +106,7 @@ public abstract class AbstractAsynchronousPort(
         sendJob?.cancel()
     }
 
-    override fun toString(): String = meta["name"].string?:"ChannelPort[${hashCode().toString(16)}]"
+    override fun toString(): String = meta["name"].string ?: "ChannelPort[${hashCode().toString(16)}]"
 }
 
 /**
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
index 49c2b06..ac368c5 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
@@ -1,11 +1,11 @@
 package space.kscience.controls.ports
 
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.first
-import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import kotlinx.io.Buffer
+import kotlinx.io.Source
 import kotlinx.io.readByteArray
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.ContextAware
@@ -46,6 +46,24 @@ public interface SynchronousPort : ContextAware, AutoCloseable {
     }
 }
 
+/**
+ * Read response to a given message using [Source] abstraction
+ */
+public suspend fun <R> SynchronousPort.respondAsSource(
+    request: ByteArray,
+    transform: suspend Source.() -> R,
+): R = respond(request) {
+    //suspend until the response is fully read
+    coroutineScope {
+        val buffer = Buffer()
+        val collectJob = onEach { buffer.write(it) }.launchIn(this)
+        val res = transform(buffer)
+        //cancel collection when the result is achieved
+        collectJob.cancel()
+        res
+    }
+}
+
 private class SynchronousOverAsynchronousPort(
     val port: AsynchronousPort,
     val mutex: Mutex,
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt
index dbe7256..5d7d3bf 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt
@@ -1,5 +1,24 @@
 package space.kscience.controls.ports
 
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.io.Buffer
+import kotlinx.io.Source
 import space.kscience.dataforge.io.Binary
 
-public fun Binary.readShort(position: Int): Short = read(position) { readShort() }
\ No newline at end of file
+public fun Binary.readShort(position: Int): Short = read(position) { readShort() }
+
+/**
+ * Consume given flow of [ByteArray] as [Source]. The subscription is canceled when [scope] is closed.
+ */
+public fun Flow<ByteArray>.consumeAsSource(scope: CoroutineScope): Source {
+    val buffer = Buffer()
+    //subscription is canceled when the scope is canceled
+    onEach {
+        buffer.write(it)
+    }.launchIn(scope)
+
+    return buffer
+}
\ No newline at end of file

From 9edde7bdbd47688f208cb9f2a8fbe73519a8261a Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 29 May 2024 22:20:22 +0300
Subject: [PATCH 092/125] Major constructor refactoring

---
 build.gradle.kts                              |   3 +
 controls-constructor/build.gradle.kts         |   1 +
 ...ateDescriptor.kt => ConstructorElement.kt} |  93 +++--
 .../controls/constructor/ConstructorModel.kt  |  33 ++
 .../controls/constructor/DeviceConstructor.kt |  40 +-
 .../controls/constructor/DeviceModel.kt       |  32 --
 .../controls/constructor/DeviceState.kt       |   6 +
 .../controls/constructor/boundState.kt        |   4 +-
 .../controls/constructor/exoticState.kt       |   2 +-
 .../controls/constructor/library/Converter.kt |  20 +
 .../controls/constructor/library/Drive.kt     |   4 +-
 .../constructor/library/LimitSwitch.kt        |  26 +-
 .../constructor/library/PidRegulator.kt       |  30 +-
 .../controls/constructor/library/Regulator.kt |   4 +-
 .../constructor/units/NumericalValue.kt       |  10 +
 .../constructor/units/UnitsOfMeasurement.kt   |  30 ++
 .../kscience/controls/manager/ClockManager.kt |   3 +-
 .../kscience/controls/spec/DeviceSpec.kt      |  19 +-
 .../kscience/controls/client/MagixLoopTest.kt |  59 ++-
 .../controls/opcua/client/OpcUaDevice.kt      |   2 +-
 ...otExtensions.kt => koalaPlotExtensions.kt} |   0
 .../build.gradle.kts                          |  45 +++
 .../src/commonMain/kotlin/TimeAxisModel.kt    |  45 +++
 .../src/commonMain/kotlin/composeState.kt     |  31 ++
 .../src/commonMain/kotlin/indicators.kt       |   2 +
 .../src/commonMain/kotlin/koalaPlots.kt       | 230 +++++++++++
 .../src/commonMain/kotlin/sliders.kt          |  51 +++
 demo/constructor/build.gradle.kts             |  10 +-
 .../src/jvmMain/kotlin/BodyOnSprings.kt       |  92 +++--
 .../src/jvmMain/kotlin/LinearDrive.kt         | 372 ++++++++++--------
 .../constructor/src/jvmMain/kotlin/Plotter.kt |   2 +
 settings.gradle.kts                           |   1 +
 32 files changed, 900 insertions(+), 402 deletions(-)
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/{StateDescriptor.kt => ConstructorElement.kt} (63%)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorModel.kt
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt
 rename controls-vision/src/commonMain/kotlin/{plotExtensions.kt => koalaPlotExtensions.kt} (100%)
 create mode 100644 controls-visualisation-compose/build.gradle.kts
 create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt
 create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/composeState.kt
 create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/indicators.kt
 create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt
 create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/sliders.kt
 create mode 100644 demo/constructor/src/jvmMain/kotlin/Plotter.kt

diff --git a/build.gradle.kts b/build.gradle.kts
index 50a61d8..773272c 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,6 +8,9 @@ plugins {
 allprojects {
     group = "space.kscience"
     version = "0.4.0-dev-4"
+    repositories{
+        google()
+    }
 }
 
 ksciencePublish {
diff --git a/controls-constructor/build.gradle.kts b/controls-constructor/build.gradle.kts
index e69c4d4..008a242 100644
--- a/controls-constructor/build.gradle.kts
+++ b/controls-constructor/build.gradle.kts
@@ -11,6 +11,7 @@ kscience{
     jvm()
     js()
     useCoroutines()
+    useSerialization()
     commonMain {
         api(projects.controlsCore)
     }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
similarity index 63%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
index 30a0299..911a682 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/StateDescriptor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
@@ -2,9 +2,7 @@ package space.kscience.controls.constructor
 
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.runningFold
+import kotlinx.coroutines.flow.*
 import space.kscience.controls.api.Device
 import space.kscience.controls.manager.ClockManager
 import space.kscience.dataforge.context.ContextAware
@@ -14,34 +12,38 @@ import kotlin.time.Duration
 /**
  * A binding that is used to describe device functionality
  */
-public sealed interface StateDescriptor
+public sealed interface ConstructorElement
 
 /**
  * A binding that exposes device property as read-only state
  */
-public class StatePropertyDescriptor<T>(
+public class PropertyConstructorElement<T>(
     public val device: Device,
     public val propertyName: String,
     public val state: DeviceState<T>,
-) : StateDescriptor
+) : ConstructorElement
 
 /**
  * A binding for independent state like a timer
  */
-public class StateNodeDescriptor<T>(
+public class StateConstructorElement<T>(
     public val state: DeviceState<T>,
-) : StateDescriptor
+) : ConstructorElement
 
-public class StateConnectionDescriptor(
+public class ConnectionConstrucorElement(
     public val reads: Collection<DeviceState<*>>,
     public val writes: Collection<DeviceState<*>>,
-) : StateDescriptor
+) : ConstructorElement
+
+public class ModelConstructorElement(
+    public val model: ConstructorModel
+) : ConstructorElement
 
 
 public interface StateContainer : ContextAware, CoroutineScope {
-    public val stateDescriptors: Set<StateDescriptor>
-    public fun registerState(stateDescriptor: StateDescriptor)
-    public fun unregisterState(stateDescriptor: StateDescriptor)
+    public val constructorElements: Set<ConstructorElement>
+    public fun registerElement(constructorElement: ConstructorElement)
+    public fun unregisterElement(constructorElement: ConstructorElement)
 
 
     /**
@@ -50,16 +52,16 @@ public interface StateContainer : ContextAware, CoroutineScope {
      * Optionally provide [writes] - a set of states that this change affects.
      */
     public fun <T> DeviceState<T>.onNext(
-        vararg writes: DeviceState<*>,
-        alsoReads: Collection<DeviceState<*>> = emptySet(),
+        writes: Collection<DeviceState<*>> = emptySet(),
+        reads: Collection<DeviceState<*>> = emptySet(),
         onChange: suspend (T) -> Unit,
     ): Job = valueFlow.onEach(onChange).launchIn(this@StateContainer).also {
-        registerState(StateConnectionDescriptor(setOf(this, *alsoReads.toTypedArray()), setOf(*writes)))
+        registerElement(ConnectionConstrucorElement(reads + this, writes))
     }
 
     public fun <T> DeviceState<T>.onChange(
-        vararg writes: DeviceState<*>,
-        alsoReads: Collection<DeviceState<*>> = emptySet(),
+        writes: Collection<DeviceState<*>> = emptySet(),
+        reads: Collection<DeviceState<*>> = emptySet(),
         onChange: suspend (prev: T, next: T) -> Unit,
     ): Job = valueFlow.runningFold(Pair(value, value)) { pair, next ->
         Pair(pair.second, next)
@@ -68,7 +70,7 @@ public interface StateContainer : ContextAware, CoroutineScope {
             onChange(pair.first, pair.second)
         }
     }.launchIn(this@StateContainer).also {
-        registerState(StateConnectionDescriptor(setOf(this, *alsoReads.toTypedArray()), setOf(*writes)))
+        registerElement(ConnectionConstrucorElement(reads + this, writes))
     }
 }
 
@@ -76,21 +78,19 @@ public interface StateContainer : ContextAware, CoroutineScope {
  * Register a [state] in this container. The state is not registered as a device property if [this] is a [DeviceConstructor]
  */
 public fun <T, D : DeviceState<T>> StateContainer.state(state: D): D {
-    registerState(StateNodeDescriptor(state))
+    registerElement(StateConstructorElement(state))
     return state
 }
 
 /**
  * Create a register a [MutableDeviceState] with a given [converter]
  */
-public fun <T> StateContainer.mutableState(initialValue: T): MutableDeviceState<T> = state(
+public fun <T> StateContainer.stateOf(initialValue: T): MutableDeviceState<T> = state(
     MutableDeviceState(initialValue)
 )
 
-public fun <T : DeviceModel> StateContainer.model(model: T): T {
-    model.stateDescriptors.forEach {
-        registerState(it)
-    }
+public fun <T : ConstructorModel> StateContainer.model(model: T): T {
+    registerElement(ModelConstructorElement(model))
     return model
 }
 
@@ -101,9 +101,20 @@ public fun StateContainer.timer(tick: Duration): TimerState = state(TimerState(c
 
 
 public fun <T, R> StateContainer.mapState(
-    state: DeviceState<T>,
+    origin: DeviceState<T>,
     transformation: (T) -> R,
-): DeviceStateWithDependencies<R> = state(DeviceState.map(state, transformation))
+): DeviceStateWithDependencies<R> = state(DeviceState.map(origin, transformation))
+
+
+public fun <T, R> StateContainer.flowState(
+    origin: DeviceState<T>,
+    initialValue: R,
+    transformation: suspend FlowCollector<R>.(T) -> Unit
+): DeviceStateWithDependencies<R> {
+    val state = MutableDeviceState(initialValue)
+    origin.valueFlow.transform(transformation).onEach { state.value = it }.launchIn(this)
+    return state(state.withDependencies(setOf(origin)))
+}
 
 /**
  * Create a new state by combining two existing ones
@@ -122,13 +133,13 @@ public fun <T1, T2, R> StateContainer.combineState(
  * On resulting [Job] cancel the binding is unregistered
  */
 public fun <T> StateContainer.bindTo(sourceState: DeviceState<T>, targetState: MutableDeviceState<T>): Job {
-    val descriptor = StateConnectionDescriptor(setOf(sourceState), setOf(targetState))
-    registerState(descriptor)
+    val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
+    registerElement(descriptor)
     return sourceState.valueFlow.onEach {
         targetState.value = it
     }.launchIn(this).apply {
         invokeOnCompletion {
-            unregisterState(descriptor)
+            unregisterElement(descriptor)
         }
     }
 }
@@ -144,19 +155,19 @@ public fun <T, R> StateContainer.transformTo(
     targetState: MutableDeviceState<R>,
     transformation: suspend (T) -> R,
 ): Job {
-    val descriptor = StateConnectionDescriptor(setOf(sourceState), setOf(targetState))
-    registerState(descriptor)
+    val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
+    registerElement(descriptor)
     return sourceState.valueFlow.onEach {
         targetState.value = transformation(it)
     }.launchIn(this).apply {
         invokeOnCompletion {
-            unregisterState(descriptor)
+            unregisterElement(descriptor)
         }
     }
 }
 
 /**
- * Register [StateDescriptor] that combines values from [sourceState1] and [sourceState2] using [transformation].
+ * Register [ConstructorElement] that combines values from [sourceState1] and [sourceState2] using [transformation].
  *
  * On resulting [Job] cancel the binding is unregistered
  */
@@ -166,19 +177,19 @@ public fun <T1, T2, R> StateContainer.combineTo(
     targetState: MutableDeviceState<R>,
     transformation: suspend (T1, T2) -> R,
 ): Job {
-    val descriptor = StateConnectionDescriptor(setOf(sourceState1, sourceState2), setOf(targetState))
-    registerState(descriptor)
+    val descriptor = ConnectionConstrucorElement(setOf(sourceState1, sourceState2), setOf(targetState))
+    registerElement(descriptor)
     return kotlinx.coroutines.flow.combine(sourceState1.valueFlow, sourceState2.valueFlow, transformation).onEach {
         targetState.value = it
     }.launchIn(this).apply {
         invokeOnCompletion {
-            unregisterState(descriptor)
+            unregisterElement(descriptor)
         }
     }
 }
 
 /**
- * Register [StateDescriptor] that combines values from [sourceStates] using [transformation].
+ * Register [ConstructorElement] that combines values from [sourceStates] using [transformation].
  *
  * On resulting [Job] cancel the binding is unregistered
  */
@@ -187,13 +198,13 @@ public inline fun <reified T, R> StateContainer.combineTo(
     targetState: MutableDeviceState<R>,
     noinline transformation: suspend (Array<T>) -> R,
 ): Job {
-    val descriptor = StateConnectionDescriptor(sourceStates, setOf(targetState))
-    registerState(descriptor)
+    val descriptor = ConnectionConstrucorElement(sourceStates, setOf(targetState))
+    registerElement(descriptor)
     return kotlinx.coroutines.flow.combine(sourceStates.map { it.valueFlow }, transformation).onEach {
         targetState.value = it
     }.launchIn(this).apply {
         invokeOnCompletion {
-            unregisterState(descriptor)
+            unregisterElement(descriptor)
         }
     }
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorModel.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorModel.kt
new file mode 100644
index 0000000..35a1e31
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorModel.kt
@@ -0,0 +1,33 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.newCoroutineContext
+import space.kscience.dataforge.context.Context
+import kotlin.coroutines.CoroutineContext
+
+public abstract class ConstructorModel(
+    final override val context: Context,
+    vararg dependencies: DeviceState<*>,
+) : StateContainer, CoroutineScope {
+
+    @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
+    override val coroutineContext: CoroutineContext = context.newCoroutineContext(SupervisorJob())
+
+
+    private val _constructorElements: MutableSet<ConstructorElement> = mutableSetOf<ConstructorElement>().apply {
+        dependencies.forEach {
+            add(StateConstructorElement(it))
+        }
+    }
+
+    override val constructorElements: Set<ConstructorElement> get() = _constructorElements
+
+    override fun registerElement(constructorElement: ConstructorElement) {
+        _constructorElements.add(constructorElement)
+    }
+
+    override fun unregisterElement(constructorElement: ConstructorElement) {
+        _constructorElements.remove(constructorElement)
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 17294f1..8854247 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -3,7 +3,6 @@ package space.kscience.controls.constructor
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.PropertyDescriptor
 import space.kscience.controls.spec.DevicePropertySpec
-import space.kscience.controls.spec.MutableDevicePropertySpec
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.meta.Meta
@@ -22,15 +21,15 @@ public abstract class DeviceConstructor(
     context: Context,
     meta: Meta = Meta.EMPTY,
 ) : DeviceGroup(context, meta), StateContainer {
-    private val _stateDescriptors: MutableSet<StateDescriptor> = mutableSetOf()
-    override val stateDescriptors: Set<StateDescriptor> get() = _stateDescriptors
+    private val _constructorElements: MutableSet<ConstructorElement> = mutableSetOf()
+    override val constructorElements: Set<ConstructorElement> get() = _constructorElements
 
-    override fun registerState(stateDescriptor: StateDescriptor) {
-        _stateDescriptors.add(stateDescriptor)
+    override fun registerElement(constructorElement: ConstructorElement) {
+        _constructorElements.add(constructorElement)
     }
 
-    override fun unregisterState(stateDescriptor: StateDescriptor) {
-        _stateDescriptors.remove(stateDescriptor)
+    override fun unregisterElement(constructorElement: ConstructorElement) {
+        _constructorElements.remove(constructorElement)
     }
 
     override fun <T> registerProperty(
@@ -39,7 +38,7 @@ public abstract class DeviceConstructor(
         state: DeviceState<T>,
     ) {
         super.registerProperty(converter, descriptor, state)
-        registerState(StatePropertyDescriptor(this, descriptor.name, state))
+        registerElement(PropertyConstructorElement(this, descriptor.name, state))
     }
 }
 
@@ -108,7 +107,7 @@ public fun <T : Any> DeviceConstructor.property(
 )
 
 /**
- * Register a mutable external state as a property
+ * Create and register a mutable external state as a property
  */
 public fun <T : Any> DeviceConstructor.mutableProperty(
     metaConverter: MetaConverter<T>,
@@ -141,22 +140,7 @@ public fun <T> DeviceConstructor.virtualProperty(
     nameOverride,
 )
 
-/**
- * Bind existing property provided by specification to this device
- */
-public fun <T, D : Device> DeviceConstructor.deviceProperty(
-    device: D,
-    property: DevicePropertySpec<D, T>,
-    initialValue: T,
-): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, DeviceState<T>>> =
-    property(property.converter, device.propertyAsState(property, initialValue))
-
-/**
- * Bind existing property provided by specification to this device
- */
-public fun <T, D : Device> DeviceConstructor.deviceProperty(
-    device: D,
-    property: MutableDevicePropertySpec<D, T>,
-    initialValue: T,
-): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> =
-    property(property.converter, device.mutablePropertyAsState(property, initialValue))
+public fun <T, S : DeviceState<T>> DeviceConstructor.property(
+    spec: DevicePropertySpec<*, T>,
+    state: S,
+): Unit = registerProperty(spec.converter, spec.descriptor, state)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
deleted file mode 100644
index 484b471..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceModel.kt
+++ /dev/null
@@ -1,32 +0,0 @@
-package space.kscience.controls.constructor
-
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.SupervisorJob
-import kotlinx.coroutines.newCoroutineContext
-import space.kscience.dataforge.context.Context
-import kotlin.coroutines.CoroutineContext
-
-public abstract class DeviceModel(
-    final override val context: Context,
-    vararg dependencies: DeviceState<*>,
-) : StateContainer, CoroutineScope {
-
-    override val coroutineContext: CoroutineContext = context.newCoroutineContext(SupervisorJob())
-
-
-    private val _stateDescriptors: MutableSet<StateDescriptor> = mutableSetOf<StateDescriptor>().apply {
-        dependencies.forEach {
-            add(StateNodeDescriptor(it))
-        }
-    }
-
-    override val stateDescriptors: Set<StateDescriptor> get() = _stateDescriptors
-
-    override fun registerState(stateDescriptor: StateDescriptor) {
-        _stateDescriptors.add(stateDescriptor)
-    }
-
-    override fun unregisterState(stateDescriptor: StateDescriptor) {
-        _stateDescriptors.remove(stateDescriptor)
-    }
-}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index e74c4c0..db388d3 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -48,6 +48,12 @@ public interface DeviceStateWithDependencies<T> : DeviceState<T> {
     public val dependencies: Collection<DeviceState<*>>
 }
 
+public fun <T> DeviceState<T>.withDependencies(
+    dependencies: Collection<DeviceState<*>>
+): DeviceStateWithDependencies<T> = object : DeviceStateWithDependencies<T>, DeviceState<T> by this {
+    override val dependencies: Collection<DeviceState<*>> = dependencies
+}
+
 /**
  * Create a new read-only [DeviceState] that mirrors receiver state by mapping the value with [mapper].
  */
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt
index f5c4480..1c128fa 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt
@@ -92,11 +92,11 @@ public suspend fun <T> Device.mutablePropertyAsState(
     return mutablePropertyAsState(propertyName, metaConverter, initialValue)
 }
 
-public suspend fun <D : Device, T> D.mutablePropertyAsState(
+public suspend fun <D : Device, T> D.propertyAsState(
     propertySpec: MutableDevicePropertySpec<D, T>,
 ): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter)
 
-public fun <D : Device, T> D.mutablePropertyAsState(
+public fun <D : Device, T> D.propertyAsState(
     propertySpec: MutableDevicePropertySpec<D, T>,
     initialValue: T,
 ): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
index aabacfd..96ea4fe 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
@@ -53,5 +53,5 @@ public fun StateContainer.doubleInRangeState(
     initialValue: Double,
     range: ClosedFloatingPointRange<Double>,
 ): DoubleInRangeState = DoubleInRangeState(initialValue, range).also {
-    registerState(StateNodeDescriptor(it))
+    registerElement(StateConstructorElement(it))
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt
new file mode 100644
index 0000000..45cd4db
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt
@@ -0,0 +1,20 @@
+package space.kscience.controls.constructor.library
+
+import kotlinx.coroutines.flow.FlowCollector
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.DeviceStateWithDependencies
+import space.kscience.controls.constructor.flowState
+import space.kscience.dataforge.context.Context
+
+/**
+ * A device that converts one type of physical quantity to another type
+ */
+public class Converter<T, R>(
+    context: Context,
+    input: DeviceState<T>,
+    initialValue: R,
+    transform: suspend FlowCollector<R>.(T) -> Unit,
+) : DeviceConstructor(context) {
+    public val output: DeviceStateWithDependencies<R> = flowState(input, initialValue, transform)
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
index 5678b34..6a07db0 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
@@ -6,7 +6,7 @@ import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import space.kscience.controls.api.Device
 import space.kscience.controls.constructor.MutableDeviceState
-import space.kscience.controls.constructor.mutablePropertyAsState
+import space.kscience.controls.constructor.propertyAsState
 import space.kscience.controls.manager.clock
 import space.kscience.controls.spec.*
 import space.kscience.dataforge.context.Context
@@ -98,4 +98,4 @@ public class VirtualDrive(
     }
 }
 
-public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = mutablePropertyAsState(Drive.force)
+public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = propertyAsState(Drive.force)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
index af6436f..876d2dc 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
@@ -1,15 +1,14 @@
 package space.kscience.controls.constructor.library
 
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
 import space.kscience.controls.api.Device
+import space.kscience.controls.constructor.DeviceConstructor
 import space.kscience.controls.constructor.DeviceState
-import space.kscience.controls.spec.DeviceBySpec
+import space.kscience.controls.constructor.property
 import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.DeviceSpec
 import space.kscience.controls.spec.booleanProperty
 import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.meta.MetaConverter
 
 
 /**
@@ -17,13 +16,10 @@ import space.kscience.dataforge.context.Factory
  */
 public interface LimitSwitch : Device {
 
-    public val locked: Boolean
+    public fun isLocked(): Boolean
 
     public companion object : DeviceSpec<LimitSwitch>() {
-        public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { locked }
-        public operator fun invoke(lockedState: DeviceState<Boolean>): Factory<LimitSwitch> = Factory { context, _ ->
-            VirtualLimitSwitch(context, lockedState)
-        }
+        public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { isLocked() }
     }
 }
 
@@ -32,14 +28,10 @@ public interface LimitSwitch : Device {
  */
 public class VirtualLimitSwitch(
     context: Context,
-    public val lockedState: DeviceState<Boolean>,
-) : DeviceBySpec<LimitSwitch>(LimitSwitch, context), LimitSwitch {
+    locked: DeviceState<Boolean>,
+) : DeviceConstructor(context), LimitSwitch {
 
-    override suspend fun onStart() {
-        lockedState.valueFlow.onEach {
-            propertyChanged(LimitSwitch.locked, it)
-        }.launchIn(this)
-    }
+    public val locked: DeviceState<Boolean> by property(MetaConverter.boolean, locked)
 
-    override val locked: Boolean get() = lockedState.value
+    override fun isLocked(): Boolean = locked.value
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
index afb2cbd..9edf91e 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
@@ -16,32 +16,22 @@ import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.DurationUnit
 
+
 /**
  * Pid regulator parameters
  */
-public interface PidParameters {
-    public val kp: Double
-    public val ki: Double
-    public val kd: Double
-    public val timeStep: Duration
-}
-
-private data class PidParametersImpl(
-    override val kp: Double,
-    override val ki: Double,
-    override val kd: Double,
-    override val timeStep: Duration,
-) : PidParameters
-
-public fun PidParameters(kp: Double, ki: Double, kd: Double, timeStep: Duration = 1.milliseconds): PidParameters =
-    PidParametersImpl(kp, ki, kd, timeStep)
-
+public data class PidParameters(
+    val kp: Double,
+    val ki: Double,
+    val kd: Double,
+    val timeStep: Duration  = 1.milliseconds,
+)
 /**
  * A drive with PID regulator
  */
 public class PidRegulator(
     public val drive: Drive,
-    public val pidParameters: PidParameters,
+    public var pidParameters: PidParameters, // TODO expose as property
 ) : DeviceBySpec<Regulator>(Regulator, drive.context), Regulator {
 
     private val clock = drive.context.clock
@@ -65,7 +55,7 @@ public class PidRegulator(
                 delay(pidParameters.timeStep)
                 mutex.withLock {
                     val realTime = clock.now()
-                    val delta = target - position
+                    val delta = target - getPosition()
                     val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
                     integral += delta * dtSeconds
                     val derivative = (drive.position - lastPosition) / dtSeconds
@@ -87,7 +77,7 @@ public class PidRegulator(
         drive.stop()
     }
 
-    override val position: Double get() = drive.position
+    override suspend fun getPosition(): Double = drive.position
 }
 
 public fun DeviceGroup.pid(
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
index adf319b..eb9a333 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
@@ -17,11 +17,11 @@ public interface Regulator : Device {
     /**
      * Current position value
      */
-    public val position: Double
+    public suspend fun getPosition(): Double
 
     public companion object : DeviceSpec<Regulator>() {
         public val target: MutableDevicePropertySpec<Regulator, Double> by mutableProperty(MetaConverter.double, Regulator::target)
 
-        public val position: DevicePropertySpec<Regulator, Double> by doubleProperty { position }
+        public val position: DevicePropertySpec<Regulator, Double> by doubleProperty { getPosition() }
     }
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
new file mode 100644
index 0000000..c97784d
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
@@ -0,0 +1,10 @@
+package space.kscience.controls.constructor.units
+
+import kotlin.jvm.JvmInline
+
+
+/**
+ * A value without identity coupled to units of measurements.
+ */
+@JvmInline
+public value class NumericalValue<T: UnitsOfMeasurement>(public val value: Double)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt
new file mode 100644
index 0000000..c29c1be
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt
@@ -0,0 +1,30 @@
+package space.kscience.controls.constructor.units
+
+
+public interface UnitsOfMeasurement
+
+/**/
+
+public interface UnitsOfLength : UnitsOfMeasurement
+
+public data object Meters: UnitsOfLength
+
+/**/
+
+public interface UnitsOfTime: UnitsOfMeasurement
+
+public data object Seconds: UnitsOfTime
+
+/**/
+
+public interface UnitsOfVelocity: UnitsOfMeasurement
+
+public data object MetersPerSecond: UnitsOfVelocity
+
+/**/
+
+public interface UnitsAngularOfVelocity: UnitsOfMeasurement
+
+public data object RadiansPerSecond: UnitsAngularOfVelocity
+
+public data object DegreesPerSecond: UnitsAngularOfVelocity
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
index 20ba47e..a245d29 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
@@ -12,6 +12,7 @@ import kotlin.math.roundToLong
 
 @OptIn(InternalCoroutinesApi::class)
 private class CompressedTimeDispatcher(
+    val clockManager: ClockManager,
     val dispatcher: CoroutineDispatcher,
     val compression: Double,
 ) : CoroutineDispatcher(), Delay {
@@ -74,7 +75,7 @@ public class ClockManager : AbstractPlugin() {
     ): CoroutineDispatcher = if (timeCompression == 1.0) {
         dispatcher
     } else {
-        CompressedTimeDispatcher(dispatcher, timeCompression)
+        CompressedTimeDispatcher(this, dispatcher, timeCompression)
     }
 
     public companion object : PluginFactory<ClockManager> {
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index 07d831f..503df83 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -1,10 +1,7 @@
 package space.kscience.controls.spec
 
 import kotlinx.coroutines.withContext
-import space.kscience.controls.api.ActionDescriptor
-import space.kscience.controls.api.Device
-import space.kscience.controls.api.PropertyDescriptor
-import space.kscience.controls.api.metaDescriptor
+import space.kscience.controls.api.*
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.meta.descriptors.MetaDescriptor
@@ -159,7 +156,6 @@ public abstract class DeviceSpec<D : Device> {
                 deviceAction
             }
         }
-
 }
 
 /**
@@ -196,3 +192,16 @@ public fun <D : Device> DeviceSpec<D>.metaAction(
         execute(it)
     }
 
+
+/**
+ * Throw an exception if device does not have all properties and actions defined by this specification
+ */
+public fun DeviceSpec<*>.validate(device: Device) {
+    properties.map { it.value.descriptor }.forEach { specProperty ->
+        check(specProperty in device.propertyDescriptors) { "Property ${specProperty.name} not registered in ${device.id}" }
+    }
+
+    actions.map { it.value.descriptor }.forEach { specAction ->
+        check(specAction in device.actionDescriptors) { "Action ${specAction.name} not registered in ${device.id}" }
+    }
+}
diff --git a/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt
index 764dcad..b08066b 100644
--- a/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt
+++ b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt
@@ -19,42 +19,37 @@ import kotlin.test.assertEquals
 class MagixLoopTest {
 
     @Test
-    fun deviceHub() = runTest {
-        val context = Context {
-            plugin(DeviceManager)
-        }
-
-        val server = context.startMagixServer()
-
-        val deviceManager = context.request(DeviceManager)
-
-        val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
-
-//        deviceEndpoint.subscribe().onEach {
-//            println(it)
-//        }.launchIn(this)
-
-        deviceManager.launchMagixService(deviceEndpoint, "device")
-
-        launch {
-            delay(50)
-            repeat(10) {
-                deviceManager.install("test[$it]", TestDevice)
-            }
-        }
-
-        val clientEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
-
-        val remoteHub = clientEndpoint.remoteDeviceHub(context, "client", "device")
-
-        assertEquals(0, remoteHub.devices.size)
-
-        delay(60)
-        //switch context to use actual delay
+    fun realDeviceHub() = runTest {
         withContext(Dispatchers.Default) {
+            val context = Context {
+                plugin(DeviceManager)
+            }
+
+            val server = context.startMagixServer()
+
+            val deviceManager = context.request(DeviceManager)
+
+            val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+
+            deviceManager.launchMagixService(deviceEndpoint, "device")
+
+            launch {
+                delay(50)
+                repeat(10) {
+                    deviceManager.install("test[$it]", TestDevice)
+                }
+            }
+
+            val clientEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+
+            val remoteHub = clientEndpoint.remoteDeviceHub(context, "client", "device")
+
+            assertEquals(0, remoteHub.devices.size)
+            delay(60)
             clientEndpoint.requestDeviceUpdate("client", "device")
             delay(60)
             assertEquals(10, remoteHub.devices.size)
+            server.stop()
         }
     }
 }
\ No newline at end of file
diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt
index 08a84b0..41369dc 100644
--- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt
+++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt
@@ -34,7 +34,7 @@ public suspend inline fun <reified T: Any> OpcUaDevice.readOpcWithTime(
     converter: MetaConverter<T>,
     magAge: Double = 500.0
 ): Pair<T, DateTime> {
-    val data = client.readValue(magAge, TimestampsToReturn.Server, nodeId).await()
+    val data: DataValue = client.readValue(magAge, TimestampsToReturn.Server, nodeId).await()
     val time = data.serverTime ?: error("No server time provided")
     val meta: Meta = when (val content = data.value.value) {
         is T -> return content to time
diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt
similarity index 100%
rename from controls-vision/src/commonMain/kotlin/plotExtensions.kt
rename to controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt
diff --git a/controls-visualisation-compose/build.gradle.kts b/controls-visualisation-compose/build.gradle.kts
new file mode 100644
index 0000000..48ae9cd
--- /dev/null
+++ b/controls-visualisation-compose/build.gradle.kts
@@ -0,0 +1,45 @@
+import org.jetbrains.compose.ExperimentalComposeLibrary
+
+plugins {
+    id("space.kscience.gradle.mpp")
+    alias(spclibs.plugins.compose)
+    `maven-publish`
+}
+
+description = """
+    Visualisation extension using compose-multiplatform
+""".trimIndent()
+
+kscience {
+    jvm()
+    useKtor()
+    useSerialization()
+    useContextReceivers()
+    commonMain {
+        api(projects.controlsConstructor)
+        api("io.github.koalaplot:koalaplot-core:0.6.0")
+    }
+}
+
+kotlin {
+    sourceSets {
+        commonMain {
+            dependencies {
+                api(compose.foundation)
+                api(compose.material3)
+                @OptIn(ExperimentalComposeLibrary::class)
+                api(compose.desktop.components.splitPane)
+            }
+        }
+//        jvmMain {
+//            dependencies {
+//                implementation(compose.desktop.currentOs)
+//            }
+//        }
+    }
+}
+
+
+readme {
+    maturity = space.kscience.gradle.Maturity.PROTOTYPE
+}
\ No newline at end of file
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt b/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt
new file mode 100644
index 0000000..2c82802
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt
@@ -0,0 +1,45 @@
+package space.kscience.controls.compose
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import io.github.koalaplot.core.xygraph.AxisModel
+import io.github.koalaplot.core.xygraph.TickValues
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlin.math.floor
+import kotlin.time.Duration
+import kotlin.time.times
+
+public class TimeAxisModel(
+    override val minimumMajorTickSpacing: Dp = 50.dp,
+    private val rangeProvider: () -> ClosedRange<Instant>,
+) : AxisModel<Instant> {
+
+    override fun computeTickValues(axisLength: Dp): TickValues<Instant> {
+        val currentRange = rangeProvider()
+        val rangeLength = currentRange.endInclusive - currentRange.start
+        val numTicks = floor(axisLength / minimumMajorTickSpacing).toInt()
+        val numMinorTicks = numTicks * 2
+        return object : TickValues<Instant> {
+            override val majorTickValues: List<Instant> = List(numTicks) {
+                currentRange.start + it.toDouble() / (numTicks - 1) * rangeLength
+            }
+
+            override val minorTickValues: List<Instant> = List(numMinorTicks) {
+                currentRange.start + it.toDouble() / (numMinorTicks - 1) * rangeLength
+            }
+        }
+    }
+
+    override fun computeOffset(point: Instant): Float {
+        val currentRange = rangeProvider()
+        return ((point - currentRange.start) / (currentRange.endInclusive - currentRange.start)).toFloat()
+    }
+
+    public companion object {
+        public fun recent(duration: Duration, clock: Clock = Clock.System): TimeAxisModel = TimeAxisModel {
+            val now = clock.now()
+            (now - duration)..now
+        }
+    }
+}
\ No newline at end of file
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/composeState.kt b/controls-visualisation-compose/src/commonMain/kotlin/composeState.kt
new file mode 100644
index 0000000..5b793d6
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/composeState.kt
@@ -0,0 +1,31 @@
+package space.kscience.controls.compose
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.snapshotFlow
+import kotlinx.coroutines.flow.Flow
+import space.kscience.controls.constructor.DeviceState
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+
+/**
+ * Represent this [DeviceState] as Compose multiplatform [State]
+ */
+@Composable
+public fun <T> DeviceState<T>.asComposeState(
+    coroutineContext: CoroutineContext = EmptyCoroutineContext,
+): State<T> = valueFlow.collectAsState(value, coroutineContext)
+
+
+/**
+ * Represent this Compose [State] as [DeviceState]
+ */
+public fun <T> State<T>.asDeviceState(): DeviceState<T> = object : DeviceState<T> {
+    override val value: T get() = this@asDeviceState.value
+
+    override val valueFlow: Flow<T> get() = snapshotFlow { this@asDeviceState.value }
+
+    override fun toString(): String = "ComposeState(value=$value)"
+}
\ No newline at end of file
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/indicators.kt b/controls-visualisation-compose/src/commonMain/kotlin/indicators.kt
new file mode 100644
index 0000000..ada0f10
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/indicators.kt
@@ -0,0 +1,2 @@
+package space.kscience.controls.compose
+
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt
new file mode 100644
index 0000000..d721f0a
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt
@@ -0,0 +1,230 @@
+package space.kscience.controls.compose
+
+import androidx.compose.runtime.*
+import androidx.compose.ui.graphics.SolidColor
+import io.github.koalaplot.core.line.LinePlot
+import io.github.koalaplot.core.style.LineStyle
+import io.github.koalaplot.core.xygraph.DefaultPoint
+import io.github.koalaplot.core.xygraph.XYGraphScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import space.kscience.controls.api.Device
+import space.kscience.controls.api.PropertyChangedMessage
+import space.kscience.controls.api.propertyMessageFlow
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.manager.clock
+import space.kscience.controls.misc.ValueWithTime
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.name
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.double
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.minutes
+import kotlin.time.Duration.Companion.seconds
+
+
+private val defaultMaxAge get() = 10.minutes
+private val defaultMaxPoints get() = 800
+private val defaultMinPoints get() = 400
+private val defaultSampling get() = 1.seconds
+
+
+internal fun <T> Flow<ValueWithTime<T>>.collectAndTrim(
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    clock: Clock = Clock.System,
+): Flow<List<ValueWithTime<T>>> {
+    require(maxPoints > 2)
+    require(minPoints > 0)
+    require(maxPoints > minPoints)
+    val points = mutableListOf<ValueWithTime<T>>()
+    return transform { newPoint ->
+        points.add(newPoint)
+        val now = clock.now()
+        // filter old points
+        points.removeAll { now - it.time > maxAge }
+
+        if (points.size > maxPoints) {
+            val durationBetweenPoints = maxAge / minPoints
+            val markedForRemoval = buildList {
+                var lastTime: Instant? = null
+                points.forEach { point ->
+                    if (lastTime?.let { point.time - it < durationBetweenPoints } == true) {
+                        add(point)
+                    } else {
+                        lastTime = point.time
+                    }
+                }
+            }
+
+            points.removeAll(markedForRemoval)
+        }
+        //return a protective copy
+        emit(ArrayList(points))
+    }
+}
+
+private val defaultLineStyle: LineStyle = LineStyle(SolidColor(androidx.compose.ui.graphics.Color.Black))
+
+
+@Composable
+private fun <T> XYGraphScope<Instant, T>.PlotTimeSeries(
+    data: List<ValueWithTime<T>>,
+    lineStyle: LineStyle = defaultLineStyle,
+) {
+    LinePlot(
+        data = data.map { DefaultPoint(it.time, it.value) },
+        lineStyle = lineStyle
+    )
+}
+
+
+/**
+ * Add a trace that shows a [Device] property change over time. Show only latest [maxPoints] .
+ * @return a [Job] that handles the listener
+ */
+@Composable
+public fun XYGraphScope<Instant, Double>.PlotDeviceProperty(
+    device: Device,
+    propertyName: String,
+    extractValue: Meta.() -> Double = { value?.double ?: Double.NaN },
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    sampling: Duration = defaultSampling,
+    lineStyle: LineStyle = defaultLineStyle,
+) {
+    var points by remember { mutableStateOf<List<ValueWithTime<Double>>>(emptyList()) }
+
+    LaunchedEffect(device, propertyName, maxAge, maxPoints, minPoints, sampling) {
+        device.propertyMessageFlow(propertyName)
+            .sample(sampling)
+            .map { ValueWithTime(it.value.extractValue(), it.time) }
+            .collectAndTrim(maxAge, maxPoints, minPoints, device.clock)
+            .onEach { points = it }
+            .launchIn(this)
+    }
+
+
+    PlotTimeSeries(points, lineStyle)
+}
+
+@Composable
+public fun XYGraphScope<Instant, Double>.PlotDeviceProperty(
+    device: Device,
+    property: DevicePropertySpec<*, out Number>,
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    sampling: Duration = defaultSampling,
+    lineStyle: LineStyle = LineStyle(SolidColor(androidx.compose.ui.graphics.Color.Black)),
+): Unit = PlotDeviceProperty(
+    device = device,
+    propertyName = property.name,
+    extractValue = { property.converter.readOrNull(this)?.toDouble() ?: Double.NaN },
+    maxAge = maxAge,
+    maxPoints = maxPoints,
+    minPoints = minPoints,
+    sampling = sampling,
+    lineStyle = lineStyle
+)
+
+@Composable
+public fun XYGraphScope<Instant, Double>.PlotNumberState(
+    context: Context,
+    state: DeviceState<out Number>,
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    sampling: Duration = defaultSampling,
+    lineStyle: LineStyle = defaultLineStyle,
+): Unit {
+    var points by remember { mutableStateOf<List<ValueWithTime<Double>>>(emptyList()) }
+
+
+    LaunchedEffect(context, state, maxAge, maxPoints, minPoints, sampling) {
+        val clock = context.clock
+
+        state.valueFlow.sample(sampling)
+            .map { ValueWithTime(it.toDouble(), clock.now()) }
+            .collectAndTrim(maxAge, maxPoints, minPoints, clock)
+            .onEach { points = it }
+            .launchIn(this)
+    }
+
+
+    PlotTimeSeries(points, lineStyle)
+}
+
+
+private fun List<Instant>.averageTime(): Instant {
+    val min = min()
+    val max = max()
+    val duration = max - min
+    return min + duration / 2
+}
+
+private fun <T> Flow<T>.chunkedByPeriod(duration: Duration): Flow<List<T>> {
+    val collector: ArrayDeque<T> = ArrayDeque<T>()
+    return channelFlow {
+        launch {
+            while (isActive) {
+                delay(duration)
+                send(ArrayList(collector))
+                collector.clear()
+            }
+        }
+        this@chunkedByPeriod.collect {
+            collector.add(it)
+        }
+    }
+}
+
+
+/**
+ * Average property value by [averagingInterval]. Return [startValue] on each sample interval if no events arrived.
+ */
+@Composable
+public fun XYGraphScope<Instant, Double>.PlotAveragedDeviceProperty(
+    device: Device,
+    propertyName: String,
+    startValue: Double = 0.0,
+    extractValue: Meta.() -> Double = { value?.double ?: startValue },
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    averagingInterval: Duration = defaultSampling,
+    lineStyle: LineStyle = defaultLineStyle,
+) {
+
+    var points by remember { mutableStateOf<List<ValueWithTime<Double>>>(emptyList()) }
+
+    LaunchedEffect(device, propertyName, startValue, maxAge, maxPoints, minPoints, averagingInterval) {
+        val clock = device.clock
+        var lastValue = startValue
+        device.propertyMessageFlow(propertyName)
+            .chunkedByPeriod(averagingInterval)
+            .transform<List<PropertyChangedMessage>, ValueWithTime<Double>> { eventList ->
+                if (eventList.isEmpty()) {
+                    ValueWithTime(lastValue, clock.now())
+                } else {
+                    val time = eventList.map { it.time }.averageTime()
+                    val value = eventList.map { extractValue(it.value) }.average()
+                    ValueWithTime(value, time).also {
+                        lastValue = value
+                    }
+                }
+            }.collectAndTrim(maxAge, maxPoints, minPoints, clock)
+            .onEach { points = it }
+            .launchIn(this)
+    }
+
+    PlotTimeSeries(points, lineStyle)
+}
\ No newline at end of file
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/sliders.kt b/controls-visualisation-compose/src/commonMain/kotlin/sliders.kt
new file mode 100644
index 0000000..4adf952
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/sliders.kt
@@ -0,0 +1,51 @@
+package space.kscience.controls.compose
+
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.material3.SliderColors
+import androidx.compose.material3.SliderDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.MutableDeviceState
+
+@Composable
+public fun Slider(
+    deviceState: MutableDeviceState<Number>,
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
+    steps: Int = 0,
+    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+    colors: SliderColors = SliderDefaults.colors(),
+) {
+    androidx.compose.material3.Slider(
+        value = deviceState.value.toFloat(),
+        onValueChange = { deviceState.value = it },
+        modifier = modifier,
+        enabled = enabled,
+        valueRange = valueRange,
+        steps = steps,
+        interactionSource = interactionSource,
+        colors = colors,
+    )
+}
+
+@Composable
+public fun SliderIndicator(
+    deviceState: DeviceState<Number>,
+    modifier: Modifier = Modifier,
+    valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
+    steps: Int = 0,
+    colors: SliderColors = SliderDefaults.colors(),
+) {
+    androidx.compose.material3.Slider(
+        value = deviceState.value.toFloat(),
+        onValueChange = { /*do nothing*/ },
+        modifier = modifier,
+        enabled = false,
+        valueRange = valueRange,
+        steps = steps,
+        colors = colors,
+    )
+}
\ No newline at end of file
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
index 8628dd1..6b6f461 100644
--- a/demo/constructor/build.gradle.kts
+++ b/demo/constructor/build.gradle.kts
@@ -1,4 +1,3 @@
-import org.jetbrains.compose.ExperimentalComposeLibrary
 import org.jetbrains.compose.desktop.application.dsl.TargetFormat
 import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
 
@@ -8,14 +7,13 @@ plugins {
 }
 
 kscience {
-    jvm {
-        withJava()
-    }
+    jvm()
     useKtor()
     useSerialization()
     useContextReceivers()
     commonMain {
-        implementation(projects.controlsVision)
+        implementation(projects.controlsVisualisationCompose)
+//        implementation(projects.controlsVision)
         implementation(projects.controlsConstructor)
 //        implementation("io.github.koalaplot:koalaplot-core:0.6.0")
     }
@@ -30,8 +28,6 @@ kotlin {
         jvmMain {
             dependencies {
                 implementation(compose.desktop.currentOs)
-                @OptIn(ExperimentalComposeLibrary::class)
-                implementation(compose.desktop.components.splitPane)
             }
         }
     }
diff --git a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
index bb15c49..9f84cb7 100644
--- a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
+++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
@@ -5,7 +5,8 @@ import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.size
 import androidx.compose.material.MaterialTheme
-import androidx.compose.runtime.*
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
@@ -13,10 +14,9 @@ import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.Window
 import androidx.compose.ui.window.application
 import kotlinx.serialization.Serializable
+import space.kscience.controls.compose.asComposeState
 import space.kscience.controls.constructor.*
 import space.kscience.dataforge.context.Context
-import kotlin.coroutines.CoroutineContext
-import kotlin.coroutines.EmptyCoroutineContext
 import kotlin.math.pow
 import kotlin.math.sqrt
 import kotlin.time.Duration.Companion.milliseconds
@@ -29,16 +29,11 @@ data class XY(val x: Double, val y: Double) {
     }
 }
 
+val XY.length: Double get() = sqrt(x.pow(2) + y.pow(2))
+
 operator fun XY.plus(other: XY): XY = XY(x + other.x, y + other.y)
 operator fun XY.times(c: Double): XY = XY(x * c, y * c)
 operator fun XY.div(c: Double): XY = XY(x / c, y / c)
-//
-//class XYPosition(context: Context, x0: Double, y0: Double) : DeviceModel(context) {
-//    val x: MutableDeviceState<Double> = mutableState(x0)
-//    val y: MutableDeviceState<Double> = mutableState(y0)
-//
-//    val xy = combineState(x, y) { x, y -> XY(x, y) }
-//}
 
 class Spring(
     context: Context,
@@ -46,27 +41,25 @@ class Spring(
     val l0: Double,
     val begin: DeviceState<XY>,
     val end: DeviceState<XY>,
-) : DeviceConstructor(context) {
-
-    val length = combineState(begin, end) { begin, end ->
-        sqrt((end.y - begin.y).pow(2) + (end.x - begin.x).pow(2))
-    }
-
-    val tension: DeviceState<Double> = mapState(length) { l ->
-        val delta = l - l0
-        k * delta
-    }
+) : ConstructorModel(context) {
 
     /**
-     * direction from start to end
+     * vector from start to end
      */
-    val direction = combineState(begin, end) { begin, end ->
+    val direction = combineState(begin, end) { begin: XY, end: XY ->
         val dx = end.x - begin.x
         val dy = end.y - begin.y
-        val l = sqrt((end.y - begin.y).pow(2) + (end.x - begin.x).pow(2))
+        val l = sqrt(dx.pow(2) + dy.pow(2))
         XY(dx / l, dy / l)
     }
 
+    val tension: DeviceState<Double> = combineState(begin, end) { begin: XY, end: XY ->
+        val dx = end.x - begin.x
+        val dy = end.y - begin.y
+        k * sqrt(dx.pow(2) + dy.pow(2))
+    }
+
+
     val beginForce = combineState(direction, tension) { direction: XY, tension: Double ->
         direction * (tension)
     }
@@ -83,13 +76,15 @@ class MaterialPoint(
     val force: DeviceState<XY>,
     val position: MutableDeviceState<XY>,
     val velocity: MutableDeviceState<XY> = MutableDeviceState(XY.ZERO),
-) : DeviceModel(context, force) {
+) : ConstructorModel(context, force, position, velocity) {
 
     private val timer: TimerState = timer(2.milliseconds)
 
+    //TODO synchronize force change
+
     private val movement = timer.onChange(
-        position, velocity,
-        alsoReads = setOf(force, velocity, position)
+        writes = setOf(position, velocity),
+        reads = setOf(force, velocity, position)
     ) { prev, next ->
         val dt = (next - prev).toDouble(DurationUnit.SECONDS)
         val a = force.value / mass
@@ -105,31 +100,31 @@ class BodyOnSprings(
     k: Double,
     startPosition: XY,
     l0: Double = 1.0,
-    val xLeft: Double = 0.0,
-    val xRight: Double = 2.0,
-    val yBottom: Double = 0.0,
-    val yTop: Double = 2.0,
+    val xLeft: Double = -1.0,
+    val xRight: Double = 1.0,
+    val yBottom: Double = -1.0,
+    val yTop: Double = 1.0,
 ) : DeviceConstructor(context) {
 
     val width = xRight - xLeft
     val height = yTop - yBottom
 
-    val position = mutableState(startPosition)
+    val position = stateOf(startPosition)
 
-    private val leftAnchor = mutableState(XY(xLeft, yTop + yBottom / 2))
+    private val leftAnchor = stateOf(XY(xLeft, (yTop + yBottom) / 2))
 
-    val leftSpring by device(
+    val leftSpring = model(
         Spring(context, k, l0, leftAnchor, position)
     )
 
-    private val rightAnchor = mutableState(XY(xRight, yTop + yBottom / 2))
+    private val rightAnchor = stateOf(XY(xRight, (yTop + yBottom) / 2))
 
-    val rightSpring by device(
+    val rightSpring = model(
         Spring(context, k, l0, rightAnchor, position)
     )
 
-    val force: DeviceState<XY> = combineState(leftSpring.endForce, rightSpring.endForce) { left, rignt ->
-        left + rignt
+    val force: DeviceState<XY> = combineState(leftSpring.endForce, rightSpring.endForce) { left, right ->
+        left + right
     }
 
 
@@ -138,18 +133,13 @@ class BodyOnSprings(
             context = context,
             mass = mass,
             force = force,
-            position = position
+            position = position,
         )
     )
 }
 
-@Composable
-fun <T> DeviceState<T>.collect(
-    coroutineContext: CoroutineContext = EmptyCoroutineContext,
-): State<T> = valueFlow.collectAsState(value, coroutineContext)
-
 fun main() = application {
-    val initialState = XY(1.1, 1.1)
+    val initialState = XY(0.1, 0.2)
 
     Window(title = "Ball on springs", onCloseRequest = ::exitApplication) {
         MaterialTheme {
@@ -161,12 +151,20 @@ fun main() = application {
                 BodyOnSprings(context, 100.0, 1000.0, initialState)
             }
 
-            val position: XY by model.body.position.collect()
+            //TODO add ability to freeze model
+
+//            LaunchedEffect(Unit){
+//                model.position.valueFlow.onEach {
+//                    model.position.value = it.copy(y = model.position.value.y.coerceIn(-1.0..1.0))
+//                }.collect()
+//            }
+
+            val position: XY by model.body.position.asComposeState()
             Box(Modifier.size(400.dp)) {
                 Canvas(modifier = Modifier.fillMaxSize()) {
                     fun XY.toOffset() = Offset(
-                        (x / model.width * size.width).toFloat(),
-                        (y / model.height * size.height).toFloat()
+                        center.x + (x / model.width * size.width).toFloat(),
+                        center.y - (y / model.height * size.height).toFloat()
                     )
 
                     drawCircle(
diff --git a/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt b/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt
index 95df7ad..f64ef72 100644
--- a/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt
+++ b/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt
@@ -1,38 +1,40 @@
 package space.kscience.controls.demo.constructor
 
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.*
 import androidx.compose.material.*
-import androidx.compose.runtime.getValue
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.runtime.setValue
+import androidx.compose.runtime.*
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.SolidColor
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.Window
 import androidx.compose.ui.window.application
-import space.kscience.controls.constructor.DeviceConstructor
-import space.kscience.controls.constructor.DoubleInRangeState
-import space.kscience.controls.constructor.device
-import space.kscience.controls.constructor.deviceProperty
+import io.github.koalaplot.core.ChartLayout
+import io.github.koalaplot.core.legend.FlowLegend
+import io.github.koalaplot.core.style.LineStyle
+import io.github.koalaplot.core.util.ExperimentalKoalaPlotApi
+import io.github.koalaplot.core.util.toString
+import io.github.koalaplot.core.xygraph.XYGraph
+import io.github.koalaplot.core.xygraph.rememberDoubleLinearAxisModel
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.onEach
+import kotlinx.datetime.Instant
+import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
+import org.jetbrains.compose.splitpane.HorizontalSplitPane
+import space.kscience.controls.compose.PlotDeviceProperty
+import space.kscience.controls.compose.PlotNumberState
+import space.kscience.controls.compose.TimeAxisModel
+import space.kscience.controls.constructor.*
 import space.kscience.controls.constructor.library.*
 import space.kscience.controls.manager.ClockManager
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.clock
 import space.kscience.controls.manager.install
-import space.kscience.controls.spec.doRecurring
-import space.kscience.controls.spec.name
-import space.kscience.controls.vision.plot
-import space.kscience.controls.vision.plotDeviceProperty
-import space.kscience.controls.vision.plotNumberState
-import space.kscience.controls.vision.showDashboard
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.Meta
-import space.kscience.plotly.models.ScatterMode
-import space.kscience.visionforge.plotly.PlotlyPlugin
+import java.awt.Dimension
 import kotlin.math.PI
 import kotlin.math.sin
 import kotlin.time.Duration
@@ -49,15 +51,15 @@ class LinearDrive(
     meta: Meta = Meta.EMPTY,
 ) : DeviceConstructor(drive.context, meta) {
 
-    val drive: Drive by device(drive)
+    val drive by device(drive)
     val pid by device(PidRegulator(drive, pidParameters))
 
     val start by device(start)
     val end by device(end)
 
-    val position by deviceProperty(drive, Drive.position, Double.NaN)
+    val position = drive.propertyAsState(Drive.position, Double.NaN)
 
-    val target by deviceProperty(pid, Regulator.target, 0.0)
+    val target = pid.propertyAsState(Regulator.target, 0.0)
 }
 
 /**
@@ -77,163 +79,205 @@ fun LinearDrive(
     meta = meta
 )
 
+class Modulator(
+    context: Context,
+    target: MutableDeviceState<Double>,
+    var freq: Double = 0.1,
+    var timeStep: Duration = 5.milliseconds,
+) : DeviceConstructor(context) {
+    private val clockStart = clock.now()
 
+    val timer = timer(10.milliseconds)
+
+    private val modulation = timer.onNext {
+        val timeFromStart = clock.now() - clockStart
+        val t = timeFromStart.toDouble(DurationUnit.SECONDS)
+        target.value = 5 * sin(2.0 * PI * freq * t) +
+                sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / timeStep))
+    }
+}
+
+
+private val maxAge = 10.seconds
+
+@OptIn(ExperimentalSplitPaneApi::class, ExperimentalKoalaPlotApi::class)
 fun main() = application {
-    val context = Context {
-        plugin(DeviceManager)
-        plugin(PlotlyPlugin)
-        plugin(ClockManager)
+    val context = remember {
+        Context {
+            plugin(DeviceManager)
+            plugin(ClockManager)
+        }
     }
 
-    class MutablePidParameters(
-        kp: Double,
-        ki: Double,
-        kd: Double,
-        timeStep: Duration,
-    ) : PidParameters {
-        override var kp by mutableStateOf(kp)
-        override var ki by mutableStateOf(ki)
-        override var kd by mutableStateOf(kd)
-        override var timeStep by mutableStateOf(timeStep)
+    val clock = remember { context.clock }
+
+
+    var pidParameters by remember {
+        mutableStateOf(PidParameters(kp = 2.5, ki = 0.0, kd = -0.1, timeStep = 0.005.seconds))
     }
 
-    val pidParameters = remember {
-        MutablePidParameters(
-            kp = 2.5,
-            ki = 0.0,
-            kd = -0.1,
-            timeStep = 0.005.seconds
+    val state = remember { DoubleInRangeState(0.0, -6.0..6.0) }
+
+    val linearDrive = remember {
+        context.install(
+            "linearDrive",
+            LinearDrive(context, state, 0.05, pidParameters)
         )
     }
 
-    val state = DoubleInRangeState(0.0, -6.0..6.0)
-
-    val linearDrive = context.install(
-        "linearDrive",
-        LinearDrive(context, state, 0.05, pidParameters)
-    )
-
-    val clockStart = context.clock.now()
-    linearDrive.doRecurring(10.milliseconds) {
-        val timeFromStart = clock.now() - clockStart
-        val t = timeFromStart.toDouble(DurationUnit.SECONDS)
-        val freq = 0.1
-        target.value = 5 * sin(2.0 * PI * freq * t) +
-                sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))
+    val modulator = remember {
+        context.install(
+            "modulator",
+            Modulator(context, linearDrive.target)
+        )
     }
 
-
-    val maxAge = 10.seconds
-
-    context.showDashboard {
-        plot {
-            plotNumberState(context, state, maxAge = maxAge, sampling = 50.milliseconds) {
-                name = "real position"
-            }
-            plotDeviceProperty(linearDrive.pid, Regulator.position.name, maxAge = maxAge, sampling = 50.milliseconds) {
-                name = "read position"
-            }
-
-            plotDeviceProperty(linearDrive.pid, Regulator.target.name, maxAge = maxAge, sampling = 50.milliseconds) {
-                name = "target"
-            }
-        }
-
-        plot {
-            plotDeviceProperty(
-                linearDrive.start,
-                LimitSwitch.locked.name,
-                maxAge = maxAge,
-                sampling = 50.milliseconds
-            ) {
-                name = "start measured"
-                mode = ScatterMode.markers
-            }
-            plotDeviceProperty(linearDrive.end, LimitSwitch.locked.name, maxAge = maxAge, sampling = 50.milliseconds) {
-                name = "end measured"
-                mode = ScatterMode.markers
-            }
-        }
-
+    //bind pid parameters
+    LaunchedEffect(Unit) {
+        snapshotFlow {
+            pidParameters
+        }.onEach {
+            linearDrive.pid.pidParameters = pidParameters
+        }.collect()
     }
 
     Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
+        window.minimumSize = Dimension(800, 400)
         MaterialTheme {
-            Column {
-                Row {
-                    Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
-                    TextField(
-                        String.format("%.2f", pidParameters.kp),
-                        { pidParameters.kp = it.toDouble() },
-                        Modifier.width(100.dp),
-                        enabled = false
-                    )
-                    Slider(
-                        pidParameters.kp.toFloat(),
-                        { pidParameters.kp = it.toDouble() },
-                        valueRange = 0f..20f,
-                        steps = 100
-                    )
-                }
-                Row {
-                    Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
-                    TextField(
-                        String.format("%.2f", pidParameters.ki),
-                        { pidParameters.ki = it.toDouble() },
-                        Modifier.width(100.dp),
-                        enabled = false
-                    )
-
-                    Slider(
-                        pidParameters.ki.toFloat(),
-                        { pidParameters.ki = it.toDouble() },
-                        valueRange = -10f..10f,
-                        steps = 100
-                    )
-                }
-                Row {
-                    Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
-                    TextField(
-                        String.format("%.2f", pidParameters.kd),
-                        { pidParameters.kd = it.toDouble() },
-                        Modifier.width(100.dp),
-                        enabled = false
-                    )
-
-                    Slider(
-                        pidParameters.kd.toFloat(),
-                        { pidParameters.kd = it.toDouble() },
-                        valueRange = -10f..10f,
-                        steps = 100
-                    )
-                }
-
-                Row {
-                    Text("dt:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
-                    TextField(
-                        pidParameters.timeStep.toString(DurationUnit.MILLISECONDS),
-                        { pidParameters.timeStep = it.toDouble().milliseconds },
-                        Modifier.width(100.dp),
-                        enabled = false
-                    )
-
-                    Slider(
-                        pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(),
-                        { pidParameters.timeStep = it.toDouble().milliseconds },
-                        valueRange = 0f..100f,
-                        steps = 100
-                    )
-                }
-                Row {
-                    Button({
-                        pidParameters.run {
-                            kp = 2.5
-                            ki = 0.0
-                            kd = -0.1
-                            timeStep = 0.005.seconds
+            HorizontalSplitPane {
+                first(400.dp) {
+                    Column(modifier = Modifier.background(color = Color.LightGray).fillMaxHeight()) {
+                        Row {
+                            Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
+                            TextField(
+                                String.format("%.2f", pidParameters.kp),
+                                { pidParameters = pidParameters.copy(kp = it.toDouble()) },
+                                Modifier.width(100.dp),
+                                enabled = false
+                            )
+                            Slider(
+                                pidParameters.kp.toFloat(),
+                                { pidParameters = pidParameters.copy(kp = it.toDouble()) },
+                                valueRange = 0f..20f,
+                                steps = 100
+                            )
+                        }
+                        Row {
+                            Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
+                            TextField(
+                                String.format("%.2f", pidParameters.ki),
+                                { pidParameters = pidParameters.copy(ki = it.toDouble()) },
+                                Modifier.width(100.dp),
+                                enabled = false
+                            )
+
+                            Slider(
+                                pidParameters.ki.toFloat(),
+                                { pidParameters = pidParameters.copy(ki = it.toDouble()) },
+                                valueRange = -10f..10f,
+                                steps = 100
+                            )
+                        }
+                        Row {
+                            Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
+                            TextField(
+                                String.format("%.2f", pidParameters.kd),
+                                { pidParameters = pidParameters.copy(kd = it.toDouble()) },
+                                Modifier.width(100.dp),
+                                enabled = false
+                            )
+
+                            Slider(
+                                pidParameters.kd.toFloat(),
+                                { pidParameters = pidParameters.copy(kd = it.toDouble()) },
+                                valueRange = -10f..10f,
+                                steps = 100
+                            )
+                        }
+
+                        Row {
+                            Text("dt:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
+                            TextField(
+                                pidParameters.timeStep.toString(DurationUnit.MILLISECONDS),
+                                { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) },
+                                Modifier.width(100.dp),
+                                enabled = false
+                            )
+
+                            Slider(
+                                pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(),
+                                { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) },
+                                valueRange = 0f..100f,
+                                steps = 100
+                            )
+                        }
+                        Row {
+                            Button({
+                                pidParameters = PidParameters(
+                                    kp = 2.5,
+                                    ki = 0.0,
+                                    kd = -0.1,
+                                    timeStep = 0.005.seconds
+                                )
+                            }) {
+                                Text("Reset")
+                            }
+                        }
+                    }
+                }
+                second(400.dp) {
+                    ChartLayout {
+                        XYGraph<Instant, Double>(
+                            xAxisModel = remember { TimeAxisModel.recent(maxAge, clock) },
+                            yAxisModel = rememberDoubleLinearAxisModel(state.range),
+                            xAxisTitle = { Text("Time in seconds relative to current") },
+                            xAxisLabels = { it: Instant ->
+                                androidx.compose.material3.Text(
+                                    (clock.now() - it).toDouble(
+                                        DurationUnit.SECONDS
+                                    ).toString(2)
+                                )
+                            },
+                            yAxisLabels = { it: Double -> Text(it.toString(2)) }
+                        ) {
+                            PlotNumberState(
+                                context = context,
+                                state = state,
+                                maxAge = maxAge,
+                                sampling = 50.milliseconds,
+                                lineStyle = LineStyle(SolidColor(Color.Blue))
+                            )
+                            PlotDeviceProperty(
+                                linearDrive.pid,
+                                Regulator.position,
+                                maxAge = maxAge,
+                                sampling = 50.milliseconds,
+                            )
+                            PlotDeviceProperty(
+                                linearDrive.pid,
+                                Regulator.target,
+                                maxAge = maxAge,
+                                sampling = 50.milliseconds,
+                                lineStyle = LineStyle(SolidColor(Color.Red))
+                            )
+                        }
+                        Surface {
+                            FlowLegend(3, label = {
+                                when (it) {
+                                    0 -> {
+                                        Text("Body position", color = Color.Blue)
+                                    }
+
+                                    1 -> {
+                                        Text("Regulator position", color = Color.Black)
+                                    }
+
+                                    2 -> {
+                                        Text("Regulator target", color = Color.Red)
+                                    }
+                                }
+                            })
                         }
-                    }) {
-                        Text("Reset")
                     }
                 }
             }
diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
new file mode 100644
index 0000000..bd0d366
--- /dev/null
+++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
@@ -0,0 +1,2 @@
+package space.kscience.controls.demo.constructor
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 39adc72..366c39e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -64,6 +64,7 @@ include(
     ":controls-storage",
     ":controls-storage:controls-xodus",
     ":controls-constructor",
+    ":controls-visualisation-compose",
     ":controls-vision",
     ":controls-jupyter",
     ":magix",

From 54e915ef108b37f89092113334117f75db488f71 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 1 Jun 2024 09:35:03 +0300
Subject: [PATCH 093/125] WIP Constructor update

---
 .../constructor/ConstructorElement.kt         |  7 +-
 .../controls/constructor/DeviceConstructor.kt | 18 ++--
 .../controls/constructor/DeviceGroup.kt       | 17 ++--
 .../controls/constructor/DeviceState.kt       |  4 +-
 ...onstructorModel.kt => ModelConstructor.kt} |  2 +-
 .../controls/constructor/exoticState.kt       | 57 -------------
 .../controls/constructor/internalState.kt     | 16 ++++
 .../controls/constructor/library/Converter.kt | 20 -----
 .../constructor/library/DoubleInRangeState.kt | 57 +++++++++++++
 .../controls/constructor/library/Drive.kt     |  3 +-
 .../constructor/library/LimitSwitch.kt        | 48 ++++++-----
 .../constructor/library/PidRegulator.kt       | 83 ++++++++-----------
 .../controls/constructor/library/Regulator.kt | 24 ++----
 .../controls/constructor/library/StepDrive.kt | 33 ++++++++
 .../constructor/library/Transmission.kt       | 33 ++++++++
 .../controls/constructor/units/Direction.kt   |  6 ++
 .../constructor/units/NumericalValue.kt       | 29 ++++++-
 .../constructor/units/UnitsOfMeasurement.kt   | 23 +++--
 .../controls/constructor/TimerTest.kt         |  3 +-
 .../kscience/controls/manager/ClockManager.kt |  9 ++
 .../src/jvmMain/kotlin/BodyOnSprings.kt       |  4 +-
 .../kotlin/{LinearDrive.kt => PidDemo.kt}     | 36 ++++----
 22 files changed, 320 insertions(+), 212 deletions(-)
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/{ConstructorModel.kt => ModelConstructor.kt} (96%)
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/DoubleInRangeState.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/StepDrive.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Transmission.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/Direction.kt
 rename demo/constructor/src/jvmMain/kotlin/{LinearDrive.kt => PidDemo.kt} (92%)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
index 911a682..0aa72a0 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
@@ -36,7 +36,7 @@ public class ConnectionConstrucorElement(
 ) : ConstructorElement
 
 public class ModelConstructorElement(
-    public val model: ConstructorModel
+    public val model: ModelConstructor
 ) : ConstructorElement
 
 
@@ -89,7 +89,7 @@ public fun <T> StateContainer.stateOf(initialValue: T): MutableDeviceState<T> =
     MutableDeviceState(initialValue)
 )
 
-public fun <T : ConstructorModel> StateContainer.model(model: T): T {
+public fun <T : ModelConstructor> StateContainer.model(model: T): T {
     registerElement(ModelConstructorElement(model))
     return model
 }
@@ -125,14 +125,13 @@ public fun <T1, T2, R> StateContainer.combineState(
     transformation: (T1, T2) -> R,
 ): DeviceState<R> = state(DeviceState.combine(first, second, transformation))
 
-
 /**
  * Create and start binding between [sourceState] and [targetState]. Changes made to [sourceState] are automatically
  * transferred onto [targetState], but not vise versa.
  *
  * On resulting [Job] cancel the binding is unregistered
  */
-public fun <T> StateContainer.bindTo(sourceState: DeviceState<T>, targetState: MutableDeviceState<T>): Job {
+public fun <T> StateContainer.bind(sourceState: DeviceState<T>, targetState: MutableDeviceState<T>): Job {
     val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
     registerElement(descriptor)
     return sourceState.valueFlow.onEach {
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 8854247..00d408e 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -32,13 +32,14 @@ public abstract class DeviceConstructor(
         _constructorElements.remove(constructorElement)
     }
 
-    override fun <T> registerProperty(
+    override fun <T, S: DeviceState<T>> registerAsProperty(
         converter: MetaConverter<T>,
         descriptor: PropertyDescriptor,
-        state: DeviceState<T>,
-    ) {
-        super.registerProperty(converter, descriptor, state)
+        state: S,
+    ): S {
+        val res = super.registerAsProperty(converter, descriptor, state)
         registerElement(PropertyConstructorElement(this, descriptor.name, state))
+        return res
     }
 }
 
@@ -83,7 +84,7 @@ public fun <T, S : DeviceState<T>> DeviceConstructor.property(
     PropertyDelegateProvider { _: DeviceConstructor, property ->
         val name = nameOverride ?: property.name
         val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
-        registerProperty(converter, descriptor, state)
+        registerAsProperty(converter, descriptor, state)
         ReadOnlyProperty { _: DeviceConstructor, _ ->
             state
         }
@@ -140,7 +141,10 @@ public fun <T> DeviceConstructor.virtualProperty(
     nameOverride,
 )
 
-public fun <T, S : DeviceState<T>> DeviceConstructor.property(
+public fun <T, S : DeviceState<T>> DeviceConstructor.registerAsProperty(
     spec: DevicePropertySpec<*, T>,
     state: S,
-): Unit = registerProperty(spec.converter, spec.descriptor, state)
+): S {
+    registerAsProperty(spec.converter, spec.descriptor, state)
+    return state
+}
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index ddac115..182d10b 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -93,11 +93,11 @@ public open class DeviceGroup(
     /**
      * Register a new property based on [DeviceState]. Properties could be modified dynamically
      */
-    public open fun <T> registerProperty(
+    public open fun <T, S : DeviceState<T>> registerAsProperty(
         converter: MetaConverter<T>,
         descriptor: PropertyDescriptor,
-        state: DeviceState<T>,
-    ) {
+        state: S,
+    ): S {
         val name = descriptor.name.parseAsName()
         require(properties[name] == null) { "Can't add property with name $name. It already exists." }
         properties[name] = Property(state, converter, descriptor)
@@ -109,6 +109,7 @@ public open class DeviceGroup(
                 )
             )
         }.launchIn(this)
+        return state
     }
 
     private val actions: MutableMap<Name, Action> = hashMapOf()
@@ -174,8 +175,8 @@ public open class DeviceGroup(
     }
 }
 
-public fun <T> DeviceGroup.registerProperty(propertySpec: DevicePropertySpec<*, T>, state: DeviceState<T>) {
-    registerProperty(propertySpec.converter, propertySpec.descriptor, state)
+public fun <T> DeviceGroup.registerAsProperty(propertySpec: DevicePropertySpec<*, T>, state: DeviceState<T>) {
+    registerAsProperty(propertySpec.converter, propertySpec.descriptor, state)
 }
 
 public fun DeviceManager.registerDeviceGroup(
@@ -246,13 +247,13 @@ public fun DeviceGroup.registerDeviceGroup(name: String, block: DeviceGroup.() -
 /**
  * Register read-only property based on [state]
  */
-public fun <T : Any> DeviceGroup.registerProperty(
+public fun <T : Any> DeviceGroup.registerAsProperty(
     name: String,
     converter: MetaConverter<T>,
     state: DeviceState<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
 ) {
-    registerProperty(
+    registerAsProperty(
         converter,
         PropertyDescriptor(name).apply(descriptorBuilder),
         state
@@ -268,7 +269,7 @@ public fun <T : Any> DeviceGroup.registerMutableProperty(
     state: MutableDeviceState<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
 ) {
-    registerProperty(
+    registerAsProperty(
         converter,
         PropertyDescriptor(name).apply(descriptorBuilder),
         state
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index db388d3..5083347 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -67,9 +67,11 @@ public fun <T, R> DeviceState.Companion.map(
 
     override val valueFlow: Flow<R> = state.valueFlow.map(mapper)
 
-    override fun toString(): String = "DeviceState.map(arg=${state})"
+    override fun toString(): String = "DeviceState.map(state=${state})"
 }
 
+public fun <T, R> DeviceState<T>.map(mapper: (T) -> R): DeviceStateWithDependencies<R> = DeviceState.map(this, mapper)
+
 /**
  * Combine two device states into one read-only [DeviceState]. Only the latest value of each state is used.
  */
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorModel.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ModelConstructor.kt
similarity index 96%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorModel.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ModelConstructor.kt
index 35a1e31..8fdc708 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorModel.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ModelConstructor.kt
@@ -6,7 +6,7 @@ import kotlinx.coroutines.newCoroutineContext
 import space.kscience.dataforge.context.Context
 import kotlin.coroutines.CoroutineContext
 
-public abstract class ConstructorModel(
+public abstract class ModelConstructor(
     final override val context: Context,
     vararg dependencies: DeviceState<*>,
 ) : StateContainer, CoroutineScope {
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
deleted file mode 100644
index 96ea4fe..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/exoticState.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package space.kscience.controls.constructor
-
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-
-
-/**
- *  A state describing a [Double] value in the [range]
- */
-public class DoubleInRangeState(
-    initialValue: Double,
-    public val range: ClosedFloatingPointRange<Double>,
-) : MutableDeviceState<Double> {
-
-    init {
-        require(initialValue in range) { "Initial value should be in range" }
-    }
-
-
-    private val _valueFlow = MutableStateFlow(initialValue)
-
-    override var value: Double
-        get() = _valueFlow.value
-        set(newValue) {
-            _valueFlow.value = newValue.coerceIn(range)
-        }
-
-    override val valueFlow: StateFlow<Double> get() = _valueFlow
-
-    /**
-     * A state showing that the range is on its lower boundary
-     */
-    public val atStart: DeviceState<Boolean> = DeviceState.map(this) {
-        it <= range.start
-    }
-
-    /**
-     * A state showing that the range is on its higher boundary
-     */
-    public val atEnd: DeviceState<Boolean> = DeviceState.map(this) {
-        it >= range.endInclusive
-    }
-
-    override fun toString(): String = "DoubleRangeState(range=$range)"
-
-
-}
-
-/**
- * Create and register a [DoubleInRangeState]
- */
-public fun StateContainer.doubleInRangeState(
-    initialValue: Double,
-    range: ClosedFloatingPointRange<Double>,
-): DoubleInRangeState = DoubleInRangeState(initialValue, range).also {
-    registerElement(StateConstructorElement(it))
-}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
index 11c778d..8bdfce1 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
@@ -2,6 +2,7 @@ package space.kscience.controls.constructor
 
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.emptyFlow
 
 /**
  * A [MutableDeviceState] that does not correspond to a physical state
@@ -35,3 +36,18 @@ public fun <T> MutableDeviceState(
     initialValue: T,
     callback: (T) -> Unit = {},
 ): MutableDeviceState<T> = VirtualDeviceState(initialValue, callback)
+
+
+/**
+ * Create a [DeviceState] with constant value
+ */
+public fun <T> DeviceState(
+    value: T
+): DeviceState<T> = object : DeviceState<T> {
+    override val value: T get() = value
+    override val valueFlow: Flow<T>
+        get() = emptyFlow()
+
+    override fun toString(): String = "ConstDeviceState($value)"
+
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt
deleted file mode 100644
index 45cd4db..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Converter.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package space.kscience.controls.constructor.library
-
-import kotlinx.coroutines.flow.FlowCollector
-import space.kscience.controls.constructor.DeviceConstructor
-import space.kscience.controls.constructor.DeviceState
-import space.kscience.controls.constructor.DeviceStateWithDependencies
-import space.kscience.controls.constructor.flowState
-import space.kscience.dataforge.context.Context
-
-/**
- * A device that converts one type of physical quantity to another type
- */
-public class Converter<T, R>(
-    context: Context,
-    input: DeviceState<T>,
-    initialValue: R,
-    transform: suspend FlowCollector<R>.(T) -> Unit,
-) : DeviceConstructor(context) {
-    public val output: DeviceStateWithDependencies<R> = flowState(input, initialValue, transform)
-}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/DoubleInRangeState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/DoubleInRangeState.kt
new file mode 100644
index 0000000..10773da
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/DoubleInRangeState.kt
@@ -0,0 +1,57 @@
+package space.kscience.controls.constructor.library
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.map
+
+/**
+ *  A state describing a [Double] value in the [range]
+ */
+public open class DoubleInRangeState(
+    private val input: DeviceState<Double>,
+    public val range: ClosedFloatingPointRange<Double>,
+) : DeviceState<Double> {
+
+    override val valueFlow: Flow<Double> get() = input.valueFlow.map { it.coerceIn(range) }
+
+    override val value: Double get() = input.value.coerceIn(range)
+
+    /**
+     * A state showing that the range is on its lower boundary
+     */
+    public val atStart: DeviceState<Boolean> = input.map { it <= range.start }
+
+    /**
+     * A state showing that the range is on its higher boundary
+     */
+    public val atEnd: DeviceState<Boolean> = input.map { it >= range.endInclusive }
+
+    override fun toString(): String = "DoubleRangeState(value=${value},range=$range)"
+}
+
+public class MutableDoubleInRangeState(
+    private val mutableInput: MutableDeviceState<Double>,
+    range: ClosedFloatingPointRange<Double>
+) : DoubleInRangeState(mutableInput, range), MutableDeviceState<Double> {
+    override var value: Double
+        get() = super.value
+        set(value) {
+            mutableInput.value = value.coerceIn(range)
+        }
+}
+
+public fun MutableDoubleInRangeState(
+    initialValue: Double,
+    range: ClosedFloatingPointRange<Double>
+): MutableDoubleInRangeState = MutableDoubleInRangeState(MutableDeviceState(initialValue),range)
+
+
+public fun DeviceState<Double>.coerceIn(
+    range: ClosedFloatingPointRange<Double>
+): DoubleInRangeState = DoubleInRangeState(this, range)
+
+public fun MutableDeviceState<Double>.coerceIn(
+    range: ClosedFloatingPointRange<Double>
+): MutableDoubleInRangeState = MutableDoubleInRangeState(this, range)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
index 6a07db0..77a16ca 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
@@ -98,4 +98,5 @@ public class VirtualDrive(
     }
 }
 
-public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = propertyAsState(Drive.force)
+public fun Drive.stateOfForce(initialForce: Double = 0.0): MutableDeviceState<Double> =
+    propertyAsState(Drive.force, initialForce)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
index 876d2dc..919d026 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
@@ -1,37 +1,45 @@
 package space.kscience.controls.constructor.library
 
-import space.kscience.controls.api.Device
 import space.kscience.controls.constructor.DeviceConstructor
 import space.kscience.controls.constructor.DeviceState
-import space.kscience.controls.constructor.property
+import space.kscience.controls.constructor.map
+import space.kscience.controls.constructor.registerAsProperty
+import space.kscience.controls.constructor.units.Direction
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.units.UnitsOfMeasurement
+import space.kscience.controls.constructor.units.compareTo
 import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.DeviceSpec
 import space.kscience.controls.spec.booleanProperty
 import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.meta.MetaConverter
 
 
-/**
- * A limit switch device
- */
-public interface LimitSwitch : Device {
-
-    public fun isLocked(): Boolean
-
-    public companion object : DeviceSpec<LimitSwitch>() {
-        public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { isLocked() }
-    }
-}
-
 /**
  * Virtual [LimitSwitch]
  */
-public class VirtualLimitSwitch(
+public class LimitSwitch(
     context: Context,
     locked: DeviceState<Boolean>,
-) : DeviceConstructor(context), LimitSwitch {
+) : DeviceConstructor(context) {
 
-    public val locked: DeviceState<Boolean> by property(MetaConverter.boolean, locked)
+    public val locked: DeviceState<Boolean> = registerAsProperty(LimitSwitch.locked, locked)
 
-    override fun isLocked(): Boolean = locked.value
-}
\ No newline at end of file
+    public companion object : DeviceSpec<LimitSwitch>() {
+        public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { locked.value }
+    }
+}
+
+public fun <U : UnitsOfMeasurement, T : NumericalValue<U>> LimitSwitch(
+    context: Context,
+    limit: T,
+    boundary: Direction,
+    position: DeviceState<T>,
+): LimitSwitch = LimitSwitch(
+    context,
+    DeviceState.map(position) {
+        when (boundary) {
+            Direction.UP -> it >= limit
+            Direction.DOWN -> it <= limit
+        }
+    }
+)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
index 9edf91e..27c3f95 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
@@ -1,19 +1,17 @@
 package space.kscience.controls.constructor.library
 
-import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
-import kotlinx.datetime.Instant
-import space.kscience.controls.constructor.DeviceGroup
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.state
+import space.kscience.controls.constructor.stateOf
 import space.kscience.controls.manager.clock
-import space.kscience.controls.spec.DeviceBySpec
-import space.kscience.controls.spec.write
-import space.kscience.dataforge.names.parseAsName
+import space.kscience.dataforge.context.Context
 import kotlin.time.Duration
-import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.DurationUnit
 
 
@@ -24,64 +22,53 @@ public data class PidParameters(
     val kp: Double,
     val ki: Double,
     val kd: Double,
-    val timeStep: Duration  = 1.milliseconds,
+    val timeStep: Duration,
 )
+
 /**
- * A drive with PID regulator
+ * A PID regulator
  */
 public class PidRegulator(
-    public val drive: Drive,
+    context: Context,
+    private val position: DeviceState<Double>,
     public var pidParameters: PidParameters, // TODO expose as property
-) : DeviceBySpec<Regulator>(Regulator, drive.context), Regulator {
+    output: MutableDeviceState<Double> = MutableDeviceState(0.0),
+) : Regulator<Double>(context) {
 
-    private val clock = drive.context.clock
+    override val target: MutableDeviceState<Double> = stateOf(0.0)
+    override val output: MutableDeviceState<Double> = state(output)
 
-    override var target: Double = drive.position
-
-    private var lastTime: Instant = clock.now()
-    private var lastPosition: Double = target
+    private var lastPosition: Double = target.value
 
     private var integral: Double = 0.0
 
-
-    private var updateJob: Job? = null
     private val mutex = Mutex()
 
+    private var lastTime = clock.now()
 
-    override suspend fun onStart() {
-        drive.start()
-        updateJob = launch {
-            while (isActive) {
-                delay(pidParameters.timeStep)
-                mutex.withLock {
-                    val realTime = clock.now()
-                    val delta = target - getPosition()
-                    val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
-                    integral += delta * dtSeconds
-                    val derivative = (drive.position - lastPosition) / dtSeconds
+    private val updateJob = launch {
+        while (isActive) {
+            delay(pidParameters.timeStep)
+            mutex.withLock {
+                val realTime = clock.now()
+                val delta = target.value - position.value
+                val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
+                integral += delta * dtSeconds
+                val derivative = (position.value - lastPosition) / dtSeconds
 
-                    //set last time and value to new values
-                    lastTime = realTime
-                    lastPosition = drive.position
+                //set last time and value to new values
+                lastTime = realTime
+                lastPosition = position.value
 
-                    drive.write(Drive.force,pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative)
-                    //drive.force = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative
-                    propertyChanged(Regulator.position, drive.position)
-                }
+                output.value = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative
             }
         }
     }
-
-    override suspend fun onStop() {
-        updateJob?.cancel()
-        drive.stop()
-    }
-
-    override suspend fun getPosition(): Double = drive.position
 }
 
-public fun DeviceGroup.pid(
-    name: String,
-    drive: Drive,
-    pidParameters: PidParameters,
-): PidRegulator = install(name.parseAsName(), PidRegulator(drive, pidParameters))
\ No newline at end of file
+//
+//public fun DeviceGroup.pid(
+//    name: String,
+//    drive: Drive,
+//    pidParameters: PidParameters,
+//): PidRegulator = install(name.parseAsName(), PidRegulator(drive, pidParameters))
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
index eb9a333..628fb0e 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
@@ -1,27 +1,17 @@
 package space.kscience.controls.constructor.library
 
-import space.kscience.controls.api.Device
-import space.kscience.controls.spec.*
-import space.kscience.dataforge.meta.MetaConverter
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.dataforge.context.Context
 
 
 /**
  * A regulator with target value and current position
  */
-public interface Regulator : Device {
-    /**
-     * Get or set target value
-     */
-    public var target: Double
+public abstract class Regulator<T>(context: Context) : DeviceConstructor(context) {
 
-    /**
-     * Current position value
-     */
-    public suspend fun getPosition(): Double
+    public abstract val target: MutableDeviceState<T>
 
-    public companion object : DeviceSpec<Regulator>() {
-        public val target: MutableDevicePropertySpec<Regulator, Double> by mutableProperty(MetaConverter.double, Regulator::target)
-
-        public val position: DevicePropertySpec<Regulator, Double> by doubleProperty { getPosition() }
-    }
+    public abstract val output: DeviceState<T>
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/StepDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/StepDrive.kt
new file mode 100644
index 0000000..47a9742
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/StepDrive.kt
@@ -0,0 +1,33 @@
+package space.kscience.controls.constructor.library
+
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.combineState
+import space.kscience.controls.constructor.property
+import space.kscience.controls.constructor.units.*
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.MetaConverter
+
+/**
+ * A step drive regulated by [input]
+ */
+public class StepDrive(
+    context: Context,
+    public val step: NumericalValue<Degrees>,
+    public val zero: NumericalValue<Degrees> = NumericalValue(0.0),
+    direction: Direction = Direction.UP,
+    input: MutableDeviceState<Int> = MutableDeviceState(0),
+    hold: MutableDeviceState<Boolean> = MutableDeviceState(false)
+) : Transmission<Int, NumericalValue<Degrees>>(context) {
+
+    override val input: MutableDeviceState<Int> by property(MetaConverter.int, input)
+
+    public val hold: MutableDeviceState<Boolean> by property(MetaConverter.boolean, hold)
+
+    override val output: DeviceState<NumericalValue<Degrees>> = combineState(
+        input, hold
+    ) { input, hold ->
+        //TODO use hold parameter
+        zero + input * direction.coef * step
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Transmission.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Transmission.kt
new file mode 100644
index 0000000..5974a2f
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Transmission.kt
@@ -0,0 +1,33 @@
+package space.kscience.controls.constructor.library
+
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.flowState
+import space.kscience.dataforge.context.Context
+
+
+/**
+ * A model for a device that converts one type of physical quantity to another type
+ */
+public abstract class Transmission<T, R>(context: Context) : DeviceConstructor(context) {
+    public abstract val input: MutableDeviceState<T>
+    public abstract val output: DeviceState<R>
+
+    public companion object {
+        /**
+         * Create a device that is a hard connection between two physical quantities
+         */
+        public suspend fun <T, R> direct(
+            context: Context,
+            input: MutableDeviceState<T>,
+            transform: suspend (T) -> R
+        ): Transmission<T, R> {
+            val initialValue = transform(input.value)
+            return object : Transmission<T, R>(context) {
+                override val input: MutableDeviceState<T> = input
+                override val output: DeviceState<R> = flowState(input, initialValue) { emit(transform(it)) }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/Direction.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/Direction.kt
new file mode 100644
index 0000000..3685c7e
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/Direction.kt
@@ -0,0 +1,6 @@
+package space.kscience.controls.constructor.units
+
+public enum class Direction(public val coef: Int) {
+    UP(1),
+    DOWN(-1)
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
index c97784d..98fe418 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
@@ -7,4 +7,31 @@ import kotlin.jvm.JvmInline
  * A value without identity coupled to units of measurements.
  */
 @JvmInline
-public value class NumericalValue<T: UnitsOfMeasurement>(public val value: Double)
\ No newline at end of file
+public value class NumericalValue<U : UnitsOfMeasurement>(public val value: Double)
+
+public operator fun <U: UnitsOfMeasurement> NumericalValue<U>.compareTo(other: NumericalValue<U>): Int =
+    value.compareTo(other.value)
+
+public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.plus(
+    other: NumericalValue<U>
+): NumericalValue<U> = NumericalValue(this.value + other.value)
+
+public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.minus(
+    other: NumericalValue<U>
+): NumericalValue<U> = NumericalValue(this.value - other.value)
+
+public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.times(
+    c: Number
+): NumericalValue<U> = NumericalValue(this.value * c.toDouble())
+
+public operator fun <U : UnitsOfMeasurement> Number.times(
+    numericalValue: NumericalValue<U>
+): NumericalValue<U> = NumericalValue(numericalValue.value * toDouble())
+
+public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.times(
+    c: Double
+): NumericalValue<U> = NumericalValue(this.value * c)
+
+public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.div(
+    c: Number
+): NumericalValue<U> = NumericalValue(this.value / c.toDouble())
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt
index c29c1be..ed295ef 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt
@@ -7,24 +7,31 @@ public interface UnitsOfMeasurement
 
 public interface UnitsOfLength : UnitsOfMeasurement
 
-public data object Meters: UnitsOfLength
+public data object Meters : UnitsOfLength
 
 /**/
 
-public interface UnitsOfTime: UnitsOfMeasurement
+public interface UnitsOfTime : UnitsOfMeasurement
 
-public data object Seconds: UnitsOfTime
+public data object Seconds : UnitsOfTime
 
 /**/
 
-public interface UnitsOfVelocity: UnitsOfMeasurement
+public interface UnitsOfVelocity : UnitsOfMeasurement
 
-public data object MetersPerSecond: UnitsOfVelocity
+public data object MetersPerSecond : UnitsOfVelocity
 
 /**/
 
-public interface UnitsAngularOfVelocity: UnitsOfMeasurement
+public sealed interface UnitsOfAngles : UnitsOfMeasurement
 
-public data object RadiansPerSecond: UnitsAngularOfVelocity
+public data object Radians : UnitsOfAngles
+public data object Degrees : UnitsOfAngles
 
-public data object DegreesPerSecond: UnitsAngularOfVelocity
\ No newline at end of file
+/**/
+
+public interface UnitsAngularOfVelocity : UnitsOfMeasurement
+
+public data object RadiansPerSecond : UnitsAngularOfVelocity
+
+public data object DegreesPerSecond : UnitsAngularOfVelocity
\ No newline at end of file
diff --git a/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt
index 323f8df..38f7d83 100644
--- a/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt
+++ b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt
@@ -15,8 +15,9 @@ class TimerTest {
     @Test
     fun timer() = runTest {
         val timer = TimerState(Global.request(ClockManager), 10.milliseconds)
-        timer.valueFlow.take(10).onEach {
+        timer.valueFlow.take(100).onEach {
             println(it)
         }.collect()
+
     }
 }
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
index a245d29..4204730 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
@@ -9,6 +9,7 @@ import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.double
 import kotlin.coroutines.CoroutineContext
 import kotlin.math.roundToLong
+import kotlin.time.Duration
 
 @OptIn(InternalCoroutinesApi::class)
 private class CompressedTimeDispatcher(
@@ -78,6 +79,14 @@ public class ClockManager : AbstractPlugin() {
         CompressedTimeDispatcher(this, dispatcher, timeCompression)
     }
 
+    public fun scheduleWithFixedDelay(tick: Duration, block: suspend () -> Unit): Job = context.launch(asDispatcher()) {
+        while (isActive) {
+            delay(tick)
+            block()
+        }
+    }
+
+
     public companion object : PluginFactory<ClockManager> {
         override val tag: PluginTag = PluginTag("clock", group = PluginTag.DATAFORGE_GROUP)
 
diff --git a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
index 9f84cb7..7fcb5ab 100644
--- a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
+++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
@@ -41,7 +41,7 @@ class Spring(
     val l0: Double,
     val begin: DeviceState<XY>,
     val end: DeviceState<XY>,
-) : ConstructorModel(context) {
+) : ModelConstructor(context) {
 
     /**
      * vector from start to end
@@ -76,7 +76,7 @@ class MaterialPoint(
     val force: DeviceState<XY>,
     val position: MutableDeviceState<XY>,
     val velocity: MutableDeviceState<XY> = MutableDeviceState(XY.ZERO),
-) : ConstructorModel(context, force, position, velocity) {
+) : ModelConstructor(context, force, position, velocity) {
 
     private val timer: TimerState = timer(2.milliseconds)
 
diff --git a/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
similarity index 92%
rename from demo/constructor/src/jvmMain/kotlin/LinearDrive.kt
rename to demo/constructor/src/jvmMain/kotlin/PidDemo.kt
index f64ef72..fb0ab64 100644
--- a/demo/constructor/src/jvmMain/kotlin/LinearDrive.kt
+++ b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
@@ -52,14 +52,18 @@ class LinearDrive(
 ) : DeviceConstructor(drive.context, meta) {
 
     val drive by device(drive)
-    val pid by device(PidRegulator(drive, pidParameters))
+    val pid by device(
+        PidRegulator(
+            context = context,
+            position = drive.propertyAsState(Drive.position, 0.0),
+            pidParameters = pidParameters
+        )
+    )
+
+    private val binding = bind(pid.output, drive.stateOfForce())
 
     val start by device(start)
     val end by device(end)
-
-    val position = drive.propertyAsState(Drive.position, Double.NaN)
-
-    val target = pid.propertyAsState(Regulator.target, 0.0)
 }
 
 /**
@@ -67,14 +71,14 @@ class LinearDrive(
  */
 fun LinearDrive(
     context: Context,
-    positionState: DoubleInRangeState,
+    positionState: MutableDoubleInRangeState,
     mass: Double,
     pidParameters: PidParameters,
     meta: Meta = Meta.EMPTY,
 ): LinearDrive = LinearDrive(
     drive = VirtualDrive(context, mass, positionState),
-    start = VirtualLimitSwitch(context, positionState.atStart),
-    end = VirtualLimitSwitch(context, positionState.atEnd),
+    start = LimitSwitch(context, positionState.atStart),
+    end = LimitSwitch(context, positionState.atEnd),
     pidParameters = pidParameters,
     meta = meta
 )
@@ -116,7 +120,7 @@ fun main() = application {
         mutableStateOf(PidParameters(kp = 2.5, ki = 0.0, kd = -0.1, timeStep = 0.005.seconds))
     }
 
-    val state = remember { DoubleInRangeState(0.0, -6.0..6.0) }
+    val state = remember { MutableDoubleInRangeState(0.0, -6.0..6.0) }
 
     val linearDrive = remember {
         context.install(
@@ -128,7 +132,7 @@ fun main() = application {
     val modulator = remember {
         context.install(
             "modulator",
-            Modulator(context, linearDrive.target)
+            Modulator(context, linearDrive.pid.target)
         )
     }
 
@@ -207,7 +211,7 @@ fun main() = application {
                             Slider(
                                 pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(),
                                 { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) },
-                                valueRange = 0f..100f,
+                                valueRange = 1f..100f,
                                 steps = 100
                             )
                         }
@@ -248,14 +252,14 @@ fun main() = application {
                                 lineStyle = LineStyle(SolidColor(Color.Blue))
                             )
                             PlotDeviceProperty(
-                                linearDrive.pid,
-                                Regulator.position,
+                                linearDrive.drive,
+                                Drive.position,
                                 maxAge = maxAge,
                                 sampling = 50.milliseconds,
                             )
-                            PlotDeviceProperty(
-                                linearDrive.pid,
-                                Regulator.target,
+                            PlotNumberState(
+                                context = context,
+                                state = linearDrive.pid.target,
                                 maxAge = maxAge,
                                 sampling = 50.milliseconds,
                                 lineStyle = LineStyle(SolidColor(Color.Red))

From d0e3faea8818d27cb80529f0a596d472bd702f42 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 2 Jun 2024 17:58:38 +0300
Subject: [PATCH 094/125] Complete rework of PID demo and underlying devices

---
 .../constructor/ConstructorElement.kt         |  50 ++++-
 .../controls/constructor/DeviceState.kt       |  16 +-
 .../controls/constructor/devices/Drive.kt     |  18 ++
 .../constructor/devices/EncoderDevice.kt      |  20 ++
 .../{library => devices}/LimitSwitch.kt       |   3 +-
 .../constructor/devices/LinearDrive.kt        |  38 ++++
 .../controls/constructor/devices/StepDrive.kt |  61 ++++++
 .../constructor/library/DoubleInRangeState.kt |  57 -----
 .../controls/constructor/library/Drive.kt     | 102 ---------
 .../constructor/library/PidRegulator.kt       |  74 -------
 .../controls/constructor/library/Regulator.kt |  17 --
 .../controls/constructor/library/StepDrive.kt |  33 ---
 .../constructor/library/Transmission.kt       |  33 ---
 .../controls/constructor/models/Inertia.kt    | 104 +++++++++
 .../constructor/models/PidRegulator.kt        |  73 +++++++
 .../controls/constructor/models/RangeState.kt |  68 ++++++
 .../controls/constructor/models/Reducer.kt    |  25 +++
 .../controls/constructor/models/ScrewDrive.kt |  22 ++
 .../constructor/units/NumericalValue.kt       |  38 +++-
 .../constructor/units/UnitsOfMeasurement.kt   |  27 ++-
 .../src/commonMain/kotlin/NumberTextField.kt  |  59 +++++
 .../src/commonMain/kotlin/TimeAxisModel.kt    |   5 +-
 .../src/commonMain/kotlin/koalaPlots.kt       |  17 +-
 .../constructor/src/jvmMain/kotlin/PidDemo.kt | 204 +++++++++---------
 24 files changed, 719 insertions(+), 445 deletions(-)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/EncoderDevice.kt
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/{library => devices}/LimitSwitch.kt (92%)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LinearDrive.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/DoubleInRangeState.kt
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/StepDrive.kt
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Transmission.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/PidRegulator.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Reducer.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
 create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
index 0aa72a0..8073689 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
@@ -3,11 +3,13 @@ package space.kscience.controls.constructor
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.flow.*
+import kotlinx.datetime.Instant
 import space.kscience.controls.api.Device
 import space.kscience.controls.manager.ClockManager
 import space.kscience.dataforge.context.ContextAware
 import space.kscience.dataforge.context.request
 import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
 
 /**
  * A binding that is used to describe device functionality
@@ -36,7 +38,7 @@ public class ConnectionConstrucorElement(
 ) : ConstructorElement
 
 public class ModelConstructorElement(
-    public val model: ModelConstructor
+    public val model: ModelConstructor,
 ) : ConstructorElement
 
 
@@ -77,15 +79,15 @@ public interface StateContainer : ContextAware, CoroutineScope {
 /**
  * Register a [state] in this container. The state is not registered as a device property if [this] is a [DeviceConstructor]
  */
-public fun <T, D : DeviceState<T>> StateContainer.state(state: D): D {
+public fun <T, D : DeviceState<T>> StateContainer.registerState(state: D): D {
     registerElement(StateConstructorElement(state))
     return state
 }
 
 /**
- * Create a register a [MutableDeviceState] with a given [converter]
+ * Create a register a [MutableDeviceState]
  */
-public fun <T> StateContainer.stateOf(initialValue: T): MutableDeviceState<T> = state(
+public fun <T> StateContainer.stateOf(initialValue: T): MutableDeviceState<T> = registerState(
     MutableDeviceState(initialValue)
 )
 
@@ -97,23 +99,53 @@ public fun <T : ModelConstructor> StateContainer.model(model: T): T {
 /**
  * Create and register a timer state.
  */
-public fun StateContainer.timer(tick: Duration): TimerState = state(TimerState(context.request(ClockManager), tick))
+public fun StateContainer.timer(tick: Duration): TimerState =
+    registerState(TimerState(context.request(ClockManager), tick))
 
+/**
+ * Register a new timer and perform [block] on its change
+ */
+public fun StateContainer.onTimer(
+    tick: Duration,
+    writes: Collection<DeviceState<*>> = emptySet(),
+    reads: Collection<DeviceState<*>> = emptySet(),
+    block: suspend (prev: Instant, next: Instant) -> Unit,
+): Job = timer(tick).onChange(writes = writes, reads = reads, onChange = block)
+
+public enum class DefaultTimer(public val duration: Duration){
+    REALTIME(5.milliseconds),
+    VERY_FAST(10.milliseconds),
+    FAST(20.milliseconds),
+    MEDIUM(50.milliseconds),
+    SLOW(100.milliseconds),
+    VERY_SLOW(500.milliseconds),
+}
+
+/**
+ * Perform an action on default timer
+ */
+public fun StateContainer.onTimer(
+    defaultTimer: DefaultTimer = DefaultTimer.FAST,
+    writes: Collection<DeviceState<*>> = emptySet(),
+    reads: Collection<DeviceState<*>> = emptySet(),
+    block: suspend (prev: Instant, next: Instant) -> Unit,
+): Job = timer(defaultTimer.duration).onChange(writes = writes, reads = reads, onChange = block)
+//TODO implement timer pooling
 
 public fun <T, R> StateContainer.mapState(
     origin: DeviceState<T>,
     transformation: (T) -> R,
-): DeviceStateWithDependencies<R> = state(DeviceState.map(origin, transformation))
+): DeviceStateWithDependencies<R> = registerState(DeviceState.map(origin, transformation))
 
 
 public fun <T, R> StateContainer.flowState(
     origin: DeviceState<T>,
     initialValue: R,
-    transformation: suspend FlowCollector<R>.(T) -> Unit
+    transformation: suspend FlowCollector<R>.(T) -> Unit,
 ): DeviceStateWithDependencies<R> {
     val state = MutableDeviceState(initialValue)
     origin.valueFlow.transform(transformation).onEach { state.value = it }.launchIn(this)
-    return state(state.withDependencies(setOf(origin)))
+    return registerState(state.withDependencies(setOf(origin)))
 }
 
 /**
@@ -123,7 +155,7 @@ public fun <T1, T2, R> StateContainer.combineState(
     first: DeviceState<T1>,
     second: DeviceState<T2>,
     transformation: (T1, T2) -> R,
-): DeviceState<R> = state(DeviceState.combine(first, second, transformation))
+): DeviceState<R> = registerState(DeviceState.combine(first, second, transformation))
 
 /**
  * Create and start binding between [sourceState] and [targetState]. Changes made to [sourceState] are automatically
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index 5083347..c326565 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -6,12 +6,14 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.map
 import kotlinx.coroutines.flow.onEach
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.units.UnitsOfMeasurement
 import kotlin.reflect.KProperty
 
 /**
  * An observable state of a device
  */
-public interface DeviceState<T> {
+public interface DeviceState<out T> {
     public val value: T
 
     public val valueFlow: Flow<T>
@@ -49,7 +51,7 @@ public interface DeviceStateWithDependencies<T> : DeviceState<T> {
 }
 
 public fun <T> DeviceState<T>.withDependencies(
-    dependencies: Collection<DeviceState<*>>
+    dependencies: Collection<DeviceState<*>>,
 ): DeviceStateWithDependencies<T> = object : DeviceStateWithDependencies<T>, DeviceState<T> by this {
     override val dependencies: Collection<DeviceState<*>> = dependencies
 }
@@ -72,6 +74,16 @@ public fun <T, R> DeviceState.Companion.map(
 
 public fun <T, R> DeviceState<T>.map(mapper: (T) -> R): DeviceStateWithDependencies<R> = DeviceState.map(this, mapper)
 
+public fun DeviceState<out NumericalValue<out UnitsOfMeasurement>>.values(): DeviceState<Double> = object : DeviceState<Double> {
+    override val value: Double
+        get() = this@values.value.value
+
+    override val valueFlow: Flow<Double>
+        get() = this@values.valueFlow.map { it.value }
+
+    override fun toString(): String = this@values.toString()
+}
+
 /**
  * Combine two device states into one read-only [DeviceState]. Only the latest value of each state is used.
  */
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt
new file mode 100644
index 0000000..3436856
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt
@@ -0,0 +1,18 @@
+package space.kscience.controls.constructor.devices
+
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.property
+import space.kscience.controls.constructor.units.NewtonsMeters
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.units.numerical
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.MetaConverter
+
+
+public class Drive(
+    context: Context,
+    force: MutableDeviceState<NumericalValue<NewtonsMeters>> = MutableDeviceState(NumericalValue(0)),
+) : DeviceConstructor(context) {
+    public val force: MutableDeviceState<NumericalValue<NewtonsMeters>> by property(MetaConverter.numerical(), force)
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/EncoderDevice.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/EncoderDevice.kt
new file mode 100644
index 0000000..cb73cff
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/EncoderDevice.kt
@@ -0,0 +1,20 @@
+package space.kscience.controls.constructor.devices
+
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.property
+import space.kscience.controls.constructor.units.Degrees
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.units.numerical
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.MetaConverter
+
+/**
+ * An encoder that can read an angle
+ */
+public class EncoderDevice(
+    context: Context,
+    position: DeviceState<NumericalValue<Degrees>>
+) : DeviceConstructor(context) {
+    public val position: DeviceState<NumericalValue<Degrees>> by property(MetaConverter.numerical<Degrees>(), position)
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt
similarity index 92%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt
index 919d026..cba6b83 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/LimitSwitch.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt
@@ -1,4 +1,4 @@
-package space.kscience.controls.constructor.library
+package space.kscience.controls.constructor.devices
 
 import space.kscience.controls.constructor.DeviceConstructor
 import space.kscience.controls.constructor.DeviceState
@@ -7,7 +7,6 @@ import space.kscience.controls.constructor.registerAsProperty
 import space.kscience.controls.constructor.units.Direction
 import space.kscience.controls.constructor.units.NumericalValue
 import space.kscience.controls.constructor.units.UnitsOfMeasurement
-import space.kscience.controls.constructor.units.compareTo
 import space.kscience.controls.spec.DevicePropertySpec
 import space.kscience.controls.spec.DeviceSpec
 import space.kscience.controls.spec.booleanProperty
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LinearDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LinearDrive.kt
new file mode 100644
index 0000000..51039d1
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LinearDrive.kt
@@ -0,0 +1,38 @@
+package space.kscience.controls.constructor.devices
+
+import space.kscience.controls.constructor.*
+import space.kscience.controls.constructor.models.PidParameters
+import space.kscience.controls.constructor.models.PidRegulator
+import space.kscience.controls.constructor.units.Meters
+import space.kscience.controls.constructor.units.NewtonsMeters
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.units.numerical
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaConverter
+
+public class LinearDrive(
+    drive: Drive,
+    start: LimitSwitch,
+    end: LimitSwitch,
+    position: DeviceState<NumericalValue<Meters>>,
+    pidParameters: PidParameters,
+    context: Context = drive.context,
+    meta: Meta = Meta.EMPTY,
+) : DeviceConstructor(context, meta) {
+
+    public val position: DeviceState<NumericalValue<Meters>> by property(MetaConverter.numerical(), position)
+
+    public val drive: Drive by device(drive)
+    public val pid: PidRegulator<Meters, NewtonsMeters>  = model(
+        PidRegulator(
+            context = context,
+            position = position,
+            output = drive.force,
+            pidParameters = pidParameters
+        )
+    )
+
+    public val startLimit: LimitSwitch by device(start)
+    public val endLimit: LimitSwitch by device(end)
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
new file mode 100644
index 0000000..072789b
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
@@ -0,0 +1,61 @@
+package space.kscience.controls.constructor.devices
+
+import kotlinx.coroutines.launch
+import space.kscience.controls.constructor.*
+import space.kscience.controls.constructor.units.Degrees
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.units.plus
+import space.kscience.controls.constructor.units.times
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.MetaConverter
+import kotlin.math.max
+import kotlin.math.min
+import kotlin.math.roundToInt
+import kotlin.time.DurationUnit
+
+/**
+ * A step drive
+ *
+ * @param speed ticks per second
+ * @param target target ticks state
+ * @param writeTicks a hardware callback
+ */
+public class StepDrive(
+    context: Context,
+    speed: MutableDeviceState<Double>,
+    target: MutableDeviceState<Int> = MutableDeviceState(0),
+    private val writeTicks: suspend (ticks: Int, speed: Double) -> Unit,
+) : DeviceConstructor(context) {
+
+    public val target: MutableDeviceState<Int> by property(MetaConverter.int, target)
+
+    public val speed: MutableDeviceState<Double> by property(MetaConverter.double, speed)
+
+    private val positionState = stateOf(target.value)
+
+    public val position: DeviceState<Int> by property(MetaConverter.int, positionState)
+
+    private val ticker = onTimer { prev, next ->
+        val tickSpeed = speed.value
+        val timeDelta = (next - prev).toDouble(DurationUnit.SECONDS)
+        val ticksDelta: Int = target.value - position.value
+        val steps: Int = when {
+            ticksDelta > 0 -> min(ticksDelta, (timeDelta * tickSpeed).roundToInt())
+            ticksDelta < 0 -> max(ticksDelta, (timeDelta * tickSpeed).roundToInt())
+            else -> return@onTimer
+        }
+        launch {
+            writeTicks(steps, tickSpeed)
+            positionState.value += steps
+        }
+    }
+}
+
+/**
+ * Compute a state using given tick-to-angle transformation
+ */
+public fun StepDrive.angle(
+    zero: NumericalValue<Degrees>,
+    step: NumericalValue<Degrees>,
+): DeviceState<NumericalValue<Degrees>> = position.map { zero + it * step }
+
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/DoubleInRangeState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/DoubleInRangeState.kt
deleted file mode 100644
index 10773da..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/DoubleInRangeState.kt
+++ /dev/null
@@ -1,57 +0,0 @@
-package space.kscience.controls.constructor.library
-
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.map
-import space.kscience.controls.constructor.DeviceState
-import space.kscience.controls.constructor.MutableDeviceState
-import space.kscience.controls.constructor.map
-
-/**
- *  A state describing a [Double] value in the [range]
- */
-public open class DoubleInRangeState(
-    private val input: DeviceState<Double>,
-    public val range: ClosedFloatingPointRange<Double>,
-) : DeviceState<Double> {
-
-    override val valueFlow: Flow<Double> get() = input.valueFlow.map { it.coerceIn(range) }
-
-    override val value: Double get() = input.value.coerceIn(range)
-
-    /**
-     * A state showing that the range is on its lower boundary
-     */
-    public val atStart: DeviceState<Boolean> = input.map { it <= range.start }
-
-    /**
-     * A state showing that the range is on its higher boundary
-     */
-    public val atEnd: DeviceState<Boolean> = input.map { it >= range.endInclusive }
-
-    override fun toString(): String = "DoubleRangeState(value=${value},range=$range)"
-}
-
-public class MutableDoubleInRangeState(
-    private val mutableInput: MutableDeviceState<Double>,
-    range: ClosedFloatingPointRange<Double>
-) : DoubleInRangeState(mutableInput, range), MutableDeviceState<Double> {
-    override var value: Double
-        get() = super.value
-        set(value) {
-            mutableInput.value = value.coerceIn(range)
-        }
-}
-
-public fun MutableDoubleInRangeState(
-    initialValue: Double,
-    range: ClosedFloatingPointRange<Double>
-): MutableDoubleInRangeState = MutableDoubleInRangeState(MutableDeviceState(initialValue),range)
-
-
-public fun DeviceState<Double>.coerceIn(
-    range: ClosedFloatingPointRange<Double>
-): DoubleInRangeState = DoubleInRangeState(this, range)
-
-public fun MutableDeviceState<Double>.coerceIn(
-    range: ClosedFloatingPointRange<Double>
-): MutableDoubleInRangeState = MutableDoubleInRangeState(this, range)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
deleted file mode 100644
index 77a16ca..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Drive.kt
+++ /dev/null
@@ -1,102 +0,0 @@
-package space.kscience.controls.constructor.library
-
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import space.kscience.controls.api.Device
-import space.kscience.controls.constructor.MutableDeviceState
-import space.kscience.controls.constructor.propertyAsState
-import space.kscience.controls.manager.clock
-import space.kscience.controls.spec.*
-import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.context.Factory
-import space.kscience.dataforge.meta.MetaConverter
-import space.kscience.dataforge.meta.double
-import space.kscience.dataforge.meta.get
-import kotlin.math.pow
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.DurationUnit
-
-/**
- * A classic drive regulated by force with encoder
- */
-public interface Drive : Device {
-    /**
-     * Get or set drive force or momentum
-     */
-    public var force: Double
-
-    /**
-     * Current position value
-     */
-    public val position: Double
-
-    public companion object : DeviceSpec<Drive>() {
-        public val force: MutableDevicePropertySpec<Drive, Double> by Drive.mutableProperty(
-            MetaConverter.double,
-            Drive::force
-        )
-
-        public val position: DevicePropertySpec<Drive, Double> by doubleProperty { position }
-    }
-}
-
-/**
- * A virtual drive
- */
-public class VirtualDrive(
-    context: Context,
-    private val mass: Double,
-    public val positionState: MutableDeviceState<Double>,
-) : Drive, DeviceBySpec<Drive>(Drive, context) {
-
-    private val dt = meta["time.step"].double?.milliseconds ?: 5.milliseconds
-    private val clock = context.clock
-
-    override var force: Double = 0.0
-
-    override val position: Double get() = positionState.value
-
-    public var velocity: Double = 0.0
-        private set
-
-    private var updateJob: Job? = null
-
-    override suspend fun onStart() {
-        updateJob = launch {
-            var lastTime = clock.now()
-            while (isActive) {
-                delay(dt)
-                val realTime = clock.now()
-                val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
-
-                //set last time and value to new values
-                lastTime = realTime
-
-                // compute new value based on velocity and acceleration from the previous step
-                positionState.value += velocity * dtSeconds + force / mass * dtSeconds.pow(2) / 2
-                propertyChanged(Drive.position, positionState.value)
-
-                // compute new velocity based on acceleration on the previous step
-                velocity += force / mass * dtSeconds
-            }
-        }
-    }
-
-    override suspend fun onStop() {
-        updateJob?.cancel()
-    }
-
-    public companion object {
-        public fun factory(
-            mass: Double,
-            positionState: MutableDeviceState<Double>,
-        ): Factory<Drive> = Factory { context, _ ->
-            VirtualDrive(context, mass, positionState)
-        }
-    }
-}
-
-public fun Drive.stateOfForce(initialForce: Double = 0.0): MutableDeviceState<Double> =
-    propertyAsState(Drive.force, initialForce)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
deleted file mode 100644
index 27c3f95..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/PidRegulator.kt
+++ /dev/null
@@ -1,74 +0,0 @@
-package space.kscience.controls.constructor.library
-
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import space.kscience.controls.constructor.DeviceState
-import space.kscience.controls.constructor.MutableDeviceState
-import space.kscience.controls.constructor.state
-import space.kscience.controls.constructor.stateOf
-import space.kscience.controls.manager.clock
-import space.kscience.dataforge.context.Context
-import kotlin.time.Duration
-import kotlin.time.DurationUnit
-
-
-/**
- * Pid regulator parameters
- */
-public data class PidParameters(
-    val kp: Double,
-    val ki: Double,
-    val kd: Double,
-    val timeStep: Duration,
-)
-
-/**
- * A PID regulator
- */
-public class PidRegulator(
-    context: Context,
-    private val position: DeviceState<Double>,
-    public var pidParameters: PidParameters, // TODO expose as property
-    output: MutableDeviceState<Double> = MutableDeviceState(0.0),
-) : Regulator<Double>(context) {
-
-    override val target: MutableDeviceState<Double> = stateOf(0.0)
-    override val output: MutableDeviceState<Double> = state(output)
-
-    private var lastPosition: Double = target.value
-
-    private var integral: Double = 0.0
-
-    private val mutex = Mutex()
-
-    private var lastTime = clock.now()
-
-    private val updateJob = launch {
-        while (isActive) {
-            delay(pidParameters.timeStep)
-            mutex.withLock {
-                val realTime = clock.now()
-                val delta = target.value - position.value
-                val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
-                integral += delta * dtSeconds
-                val derivative = (position.value - lastPosition) / dtSeconds
-
-                //set last time and value to new values
-                lastTime = realTime
-                lastPosition = position.value
-
-                output.value = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative
-            }
-        }
-    }
-}
-
-//
-//public fun DeviceGroup.pid(
-//    name: String,
-//    drive: Drive,
-//    pidParameters: PidParameters,
-//): PidRegulator = install(name.parseAsName(), PidRegulator(drive, pidParameters))
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
deleted file mode 100644
index 628fb0e..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Regulator.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package space.kscience.controls.constructor.library
-
-import space.kscience.controls.constructor.DeviceConstructor
-import space.kscience.controls.constructor.DeviceState
-import space.kscience.controls.constructor.MutableDeviceState
-import space.kscience.dataforge.context.Context
-
-
-/**
- * A regulator with target value and current position
- */
-public abstract class Regulator<T>(context: Context) : DeviceConstructor(context) {
-
-    public abstract val target: MutableDeviceState<T>
-
-    public abstract val output: DeviceState<T>
-}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/StepDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/StepDrive.kt
deleted file mode 100644
index 47a9742..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/StepDrive.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package space.kscience.controls.constructor.library
-
-import space.kscience.controls.constructor.DeviceState
-import space.kscience.controls.constructor.MutableDeviceState
-import space.kscience.controls.constructor.combineState
-import space.kscience.controls.constructor.property
-import space.kscience.controls.constructor.units.*
-import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.meta.MetaConverter
-
-/**
- * A step drive regulated by [input]
- */
-public class StepDrive(
-    context: Context,
-    public val step: NumericalValue<Degrees>,
-    public val zero: NumericalValue<Degrees> = NumericalValue(0.0),
-    direction: Direction = Direction.UP,
-    input: MutableDeviceState<Int> = MutableDeviceState(0),
-    hold: MutableDeviceState<Boolean> = MutableDeviceState(false)
-) : Transmission<Int, NumericalValue<Degrees>>(context) {
-
-    override val input: MutableDeviceState<Int> by property(MetaConverter.int, input)
-
-    public val hold: MutableDeviceState<Boolean> by property(MetaConverter.boolean, hold)
-
-    override val output: DeviceState<NumericalValue<Degrees>> = combineState(
-        input, hold
-    ) { input, hold ->
-        //TODO use hold parameter
-        zero + input * direction.coef * step
-    }
-}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Transmission.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Transmission.kt
deleted file mode 100644
index 5974a2f..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/library/Transmission.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package space.kscience.controls.constructor.library
-
-import space.kscience.controls.constructor.DeviceConstructor
-import space.kscience.controls.constructor.DeviceState
-import space.kscience.controls.constructor.MutableDeviceState
-import space.kscience.controls.constructor.flowState
-import space.kscience.dataforge.context.Context
-
-
-/**
- * A model for a device that converts one type of physical quantity to another type
- */
-public abstract class Transmission<T, R>(context: Context) : DeviceConstructor(context) {
-    public abstract val input: MutableDeviceState<T>
-    public abstract val output: DeviceState<R>
-
-    public companion object {
-        /**
-         * Create a device that is a hard connection between two physical quantities
-         */
-        public suspend fun <T, R> direct(
-            context: Context,
-            input: MutableDeviceState<T>,
-            transform: suspend (T) -> R
-        ): Transmission<T, R> {
-            val initialValue = transform(input.value)
-            return object : Transmission<T, R>(context) {
-                override val input: MutableDeviceState<T> = input
-                override val output: DeviceState<R> = flowState(input, initialValue) { emit(transform(it)) }
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
new file mode 100644
index 0000000..d6c3fc7
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
@@ -0,0 +1,104 @@
+package space.kscience.controls.constructor.models
+
+import space.kscience.controls.constructor.*
+import space.kscience.controls.constructor.units.*
+import space.kscience.dataforge.context.Context
+import kotlin.math.pow
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.DurationUnit
+
+/**
+ * A model for inertial movement. Both linear and angular
+ */
+public class Inertia<U : UnitsOfMeasurement, V : UnitsOfMeasurement>(
+    context: Context,
+    force: DeviceState<Double>, //TODO add system unit sets
+    inertia: Double,
+    public val position: MutableDeviceState<NumericalValue<U>>,
+    public val velocity: MutableDeviceState<NumericalValue<V>>,
+    timerPrecision: Duration = 10.milliseconds,
+) : ModelConstructor(context) {
+
+    init {
+        registerState(position)
+        registerState(velocity)
+    }
+
+    private val movementTimer = timer(timerPrecision)
+
+    private var currentForce = force.value
+
+    private val movement = movementTimer.onChange { prev, next ->
+        val dtSeconds = (next - prev).toDouble(DurationUnit.SECONDS)
+
+        // compute new value based on velocity and acceleration from the previous step
+        position.value += NumericalValue(velocity.value.value * dtSeconds + currentForce / inertia * dtSeconds.pow(2) / 2)
+
+        // compute new velocity based on acceleration on the previous step
+        velocity.value += NumericalValue(currentForce / inertia * dtSeconds)
+        currentForce = force.value
+    }
+
+    public companion object {
+        /**
+         * Linear inertial model with [force] in newtons and [mass] in kilograms
+         */
+        public fun linear(
+            context: Context,
+            force: DeviceState<NumericalValue<Newtons>>,
+            mass: NumericalValue<Kilograms>,
+            position: MutableDeviceState<NumericalValue<Meters>>,
+            velocity: MutableDeviceState<NumericalValue<MetersPerSecond>> = MutableDeviceState(NumericalValue(0.0)),
+        ): Inertia<Meters, MetersPerSecond> = Inertia(
+            context = context,
+            force = force.values(),
+            inertia = mass.value,
+            position = position,
+            velocity = velocity
+        )
+//
+//
+//        public fun linear(
+//            context: Context,
+//            force: DeviceState<NumericalValue<Newtons>>,
+//            mass: NumericalValue<Kilograms>,
+//            initialPosition: NumericalValue<Meters>,
+//            initialVelocity: NumericalValue<MetersPerSecond> = NumericalValue(0),
+//        ): Inertia<Meters, MetersPerSecond> = Inertia(
+//            context = context,
+//            force = force.values(),
+//            inertia = mass.value,
+//            position = MutableDeviceState(initialPosition),
+//            velocity = MutableDeviceState(initialVelocity)
+//        )
+
+        public fun circular(
+            context: Context,
+            force: DeviceState<NumericalValue<NewtonsMeters>>,
+            momentOfInertia: NumericalValue<KgM2>,
+            position: MutableDeviceState<NumericalValue<Degrees>>,
+            velocity: MutableDeviceState<NumericalValue<DegreesPerSecond>> = MutableDeviceState(NumericalValue(0.0)),
+        ): Inertia<Degrees, DegreesPerSecond> = Inertia(
+            context = context,
+            force = force.values(),
+            inertia = momentOfInertia.value,
+            position = position,
+            velocity = velocity
+        )
+//
+//        public fun circular(
+//            context: Context,
+//            force: DeviceState<NumericalValue<NewtonsMeters>>,
+//            momentOfInertia: NumericalValue<KgM2>,
+//            initialPosition: NumericalValue<Degrees>,
+//            initialVelocity: NumericalValue<DegreesPerSecond> = NumericalValue(0),
+//        ): Inertia<Degrees, DegreesPerSecond> = Inertia(
+//            context = context,
+//            force = force.values(),
+//            inertia = momentOfInertia.value,
+//            position = MutableDeviceState(initialPosition),
+//            velocity = MutableDeviceState(initialVelocity)
+//        )
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/PidRegulator.kt
new file mode 100644
index 0000000..8c97594
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/PidRegulator.kt
@@ -0,0 +1,73 @@
+package space.kscience.controls.constructor.models
+
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import space.kscience.controls.constructor.*
+import space.kscience.controls.constructor.units.*
+import space.kscience.controls.manager.clock
+import space.kscience.dataforge.context.Context
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
+import kotlin.time.DurationUnit
+
+
+/**
+ * Pid regulator parameters
+ */
+public data class PidParameters(
+    val kp: Double,
+    val ki: Double,
+    val kd: Double,
+    val timeStep: Duration = 10.milliseconds,
+)
+
+/**
+ * A PID regulator
+ *
+ * @param P units of position values
+ * @param O units of output values
+ */
+public class PidRegulator<P : UnitsOfMeasurement, O : UnitsOfMeasurement>(
+    context: Context,
+    private val position: DeviceState<NumericalValue<P>>,
+    public var pidParameters: PidParameters, // TODO expose as property
+    output: MutableDeviceState<NumericalValue<O>> = MutableDeviceState(NumericalValue(0.0)),
+    private val convertOutput: (NumericalValue<P>) -> NumericalValue<O> = { NumericalValue(it.value) },
+) : ModelConstructor(context) {
+
+    public val target: MutableDeviceState<NumericalValue<P>> = stateOf(NumericalValue(0.0))
+    public val output: MutableDeviceState<NumericalValue<O>> = registerState(output)
+
+    private val updateJob = launch {
+        var lastPosition: NumericalValue<P> = target.value
+
+        var integral: NumericalValue<P> = NumericalValue(0.0)
+
+        val mutex = Mutex()
+
+        val clock = context.clock
+
+        var lastTime = clock.now()
+
+        while (isActive) {
+            delay(pidParameters.timeStep)
+            mutex.withLock {
+                val realTime = clock.now()
+                val delta: NumericalValue<P> = target.value - position.value
+                val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS)
+                integral += delta * dtSeconds
+                val derivative = (position.value - lastPosition) / dtSeconds
+
+                //set last time and value to new values
+                lastTime = realTime
+                lastPosition = position.value
+
+                output.value =
+                    convertOutput(pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative)
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt
new file mode 100644
index 0000000..c760699
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt
@@ -0,0 +1,68 @@
+package space.kscience.controls.constructor.models
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.map
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.units.UnitsOfMeasurement
+
+/**
+ *  A state describing a [T] value in the [range]
+ */
+public open class RangeState<T : Comparable<T>>(
+    private val input: DeviceState<T>,
+    public val range: ClosedRange<T>,
+) : DeviceState<T> {
+
+    override val valueFlow: Flow<T> get() = input.valueFlow.map { it.coerceIn(range) }
+
+    override val value: T get() = input.value.coerceIn(range)
+
+    /**
+     * A state showing that the range is on its lower boundary
+     */
+    public val atStart: DeviceState<Boolean> = input.map { it <= range.start }
+
+    /**
+     * A state showing that the range is on its higher boundary
+     */
+    public val atEnd: DeviceState<Boolean> = input.map { it >= range.endInclusive }
+
+    override fun toString(): String = "DoubleRangeState(value=${value},range=$range)"
+}
+
+public class MutableRangeState<T : Comparable<T>>(
+    private val mutableInput: MutableDeviceState<T>,
+    range: ClosedRange<T>,
+) : RangeState<T>(mutableInput, range), MutableDeviceState<T> {
+    override var value: T
+        get() = super.value
+        set(value) {
+            mutableInput.value = value.coerceIn(range)
+        }
+}
+
+public fun <T : Comparable<T>> MutableRangeState(
+    initialValue: T,
+    range: ClosedRange<T>,
+): MutableRangeState<T> = MutableRangeState<T>(MutableDeviceState(initialValue), range)
+
+public fun <U : UnitsOfMeasurement> MutableRangeState(
+    initialValue: Double,
+    range: ClosedRange<Double>,
+): MutableRangeState<NumericalValue<U>> = MutableRangeState(
+    initialValue = NumericalValue(initialValue),
+    range = NumericalValue<U>(range.start)..NumericalValue<U>(range.endInclusive)
+)
+
+
+public fun <T : Comparable<T>> DeviceState<T>.coerceIn(
+    range: ClosedFloatingPointRange<T>,
+): RangeState<T> = RangeState(this, range)
+
+
+public fun <T : Comparable<T>> MutableDeviceState<T>.coerceIn(
+    range: ClosedFloatingPointRange<T>,
+): MutableRangeState<T> = MutableRangeState(this, range)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Reducer.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Reducer.kt
new file mode 100644
index 0000000..caba5ff
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Reducer.kt
@@ -0,0 +1,25 @@
+package space.kscience.controls.constructor.models
+
+import space.kscience.controls.constructor.*
+import space.kscience.controls.constructor.units.Degrees
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.units.times
+import space.kscience.dataforge.context.Context
+
+/**
+ * A reducer device used for simulations only (no public properties)
+ */
+public class Reducer(
+    context: Context,
+    public val ratio: Double,
+    public val input: DeviceState<NumericalValue<Degrees>>,
+    public val output: MutableDeviceState<NumericalValue<Degrees>>,
+) : ModelConstructor(context) {
+    init {
+        registerState(input)
+        registerState(output)
+        transformTo(input, output) {
+            it * ratio
+        }
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
new file mode 100644
index 0000000..6e2f86c
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
@@ -0,0 +1,22 @@
+package space.kscience.controls.constructor.models
+
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.ModelConstructor
+import space.kscience.controls.constructor.map
+import space.kscience.controls.constructor.units.Meters
+import space.kscience.controls.constructor.units.Newtons
+import space.kscience.controls.constructor.units.NewtonsMeters
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.dataforge.context.Context
+
+public class ScrewDrive(
+    context: Context,
+    public val leverage: NumericalValue<Meters>,
+) : ModelConstructor(context) {
+    public fun transformForce(
+        stateOfForce: DeviceState<NumericalValue<NewtonsMeters>>,
+    ): DeviceState<NumericalValue<Newtons>> = DeviceState.map(stateOfForce) {
+        NumericalValue(it.value * leverage.value)
+    }
+
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
index 98fe418..60de195 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
@@ -1,5 +1,8 @@
 package space.kscience.controls.constructor.units
 
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaConverter
+import space.kscience.dataforge.meta.double
 import kotlin.jvm.JvmInline
 
 
@@ -7,31 +10,46 @@ import kotlin.jvm.JvmInline
  * A value without identity coupled to units of measurements.
  */
 @JvmInline
-public value class NumericalValue<U : UnitsOfMeasurement>(public val value: Double)
+public value class NumericalValue<U : UnitsOfMeasurement>(public val value: Double): Comparable<NumericalValue<U>> {
+    override fun compareTo(other: NumericalValue<U>): Int = value.compareTo(other.value)
 
-public operator fun <U: UnitsOfMeasurement> NumericalValue<U>.compareTo(other: NumericalValue<U>): Int =
-    value.compareTo(other.value)
+}
+
+public fun <U : UnitsOfMeasurement> NumericalValue(
+    number: Number,
+): NumericalValue<U> = NumericalValue(number.toDouble())
 
 public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.plus(
-    other: NumericalValue<U>
+    other: NumericalValue<U>,
 ): NumericalValue<U> = NumericalValue(this.value + other.value)
 
 public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.minus(
-    other: NumericalValue<U>
+    other: NumericalValue<U>,
 ): NumericalValue<U> = NumericalValue(this.value - other.value)
 
 public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.times(
-    c: Number
+    c: Number,
 ): NumericalValue<U> = NumericalValue(this.value * c.toDouble())
 
 public operator fun <U : UnitsOfMeasurement> Number.times(
-    numericalValue: NumericalValue<U>
+    numericalValue: NumericalValue<U>,
 ): NumericalValue<U> = NumericalValue(numericalValue.value * toDouble())
 
 public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.times(
-    c: Double
+    c: Double,
 ): NumericalValue<U> = NumericalValue(this.value * c)
 
 public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.div(
-    c: Number
-): NumericalValue<U> = NumericalValue(this.value / c.toDouble())
\ No newline at end of file
+    c: Number,
+): NumericalValue<U> = NumericalValue(this.value / c.toDouble())
+
+
+private object NumericalValueMetaConverter : MetaConverter<NumericalValue<*>> {
+    override fun convert(obj: NumericalValue<*>): Meta = Meta(obj.value)
+
+    override fun readOrNull(source: Meta): NumericalValue<*>? = source.double?.let { NumericalValue<Nothing>(it) }
+}
+
+@Suppress("UNCHECKED_CAST")
+public fun <U : UnitsOfMeasurement> MetaConverter.Companion.numerical(): MetaConverter<NumericalValue<U>> =
+    NumericalValueMetaConverter as MetaConverter<NumericalValue<U>>
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt
index ed295ef..1264423 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt
@@ -30,8 +30,31 @@ public data object Degrees : UnitsOfAngles
 
 /**/
 
-public interface UnitsAngularOfVelocity : UnitsOfMeasurement
+public sealed interface UnitsAngularOfVelocity : UnitsOfMeasurement
 
 public data object RadiansPerSecond : UnitsAngularOfVelocity
 
-public data object DegreesPerSecond : UnitsAngularOfVelocity
\ No newline at end of file
+public data object DegreesPerSecond : UnitsAngularOfVelocity
+
+/**/
+public interface UnitsOfForce: UnitsOfMeasurement
+
+public data object Newtons: UnitsOfForce
+
+/**/
+
+public interface UnitsOfTorque: UnitsOfMeasurement
+
+public data object NewtonsMeters: UnitsOfTorque
+
+/**/
+
+public interface UnitsOfMass: UnitsOfMeasurement
+
+public data object Kilograms : UnitsOfMass
+
+/**/
+
+public interface UnitsOfMomentOfInertia: UnitsOfMeasurement
+
+public data object KgM2: UnitsOfMomentOfInertia
\ No newline at end of file
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt b/controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt
new file mode 100644
index 0000000..68e61ca
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt
@@ -0,0 +1,59 @@
+package space.kscience.controls.compose
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.filled.KeyboardArrowRight
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.text.TextStyle
+
+@Composable
+public fun NumberTextField(
+    value: Number,
+    onValueChange: (Number) -> Unit,
+    step: Double = 0.0,
+    formatter: (Number) -> String = { it.toString() },
+    modifier: Modifier = Modifier,
+    enabled: Boolean = true,
+    textStyle: TextStyle = LocalTextStyle.current,
+    label: @Composable (() -> Unit)? = null,
+    supportingText: @Composable (() -> Unit)? = null,
+    shape: Shape = TextFieldDefaults.shape,
+    colors: TextFieldColors = TextFieldDefaults.colors(),
+) {
+    var isError by remember { mutableStateOf(false) }
+
+    Row (verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
+        step.takeIf { it > 0.0 }?.let {
+            IconButton({ onValueChange(value.toDouble() - step) }, enabled = enabled) {
+                Icon(Icons.Default.KeyboardArrowLeft, "decrease value")
+            }
+        }
+        TextField(
+            value = formatter(value),
+            onValueChange = { stringValue: String ->
+                val number = stringValue.toDoubleOrNull()
+                number?.let { onValueChange(number) }
+                isError = number == null
+            },
+            isError = isError,
+            enabled = enabled,
+            textStyle = textStyle,
+            label = label,
+            supportingText = supportingText,
+            singleLine = true,
+            shape = shape,
+            colors = colors,
+            modifier = Modifier.weight(1f)
+        )
+        step.takeIf { it > 0.0 }?.let {
+            IconButton({ onValueChange(value.toDouble() + step) }, enabled = enabled) {
+                Icon(Icons.Default.KeyboardArrowRight, "increase value")
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt b/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt
index 2c82802..65ce8d9 100644
--- a/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt
+++ b/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt
@@ -19,15 +19,12 @@ public class TimeAxisModel(
         val currentRange = rangeProvider()
         val rangeLength = currentRange.endInclusive - currentRange.start
         val numTicks = floor(axisLength / minimumMajorTickSpacing).toInt()
-        val numMinorTicks = numTicks * 2
         return object : TickValues<Instant> {
             override val majorTickValues: List<Instant> = List(numTicks) {
                 currentRange.start + it.toDouble() / (numTicks - 1) * rangeLength
             }
 
-            override val minorTickValues: List<Instant> = List(numMinorTicks) {
-                currentRange.start + it.toDouble() / (numMinorTicks - 1) * rangeLength
-            }
+            override val minorTickValues: List<Instant> = emptyList()
         }
     }
 
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt
index d721f0a..855de10 100644
--- a/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt
+++ b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt
@@ -17,6 +17,8 @@ import space.kscience.controls.api.Device
 import space.kscience.controls.api.PropertyChangedMessage
 import space.kscience.controls.api.propertyMessageFlow
 import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.values
 import space.kscience.controls.manager.clock
 import space.kscience.controls.misc.ValueWithTime
 import space.kscience.controls.spec.DevicePropertySpec
@@ -139,7 +141,7 @@ public fun XYGraphScope<Instant, Double>.PlotDeviceProperty(
 @Composable
 public fun XYGraphScope<Instant, Double>.PlotNumberState(
     context: Context,
-    state: DeviceState<out Number>,
+    state: DeviceState<Number>,
     maxAge: Duration = defaultMaxAge,
     maxPoints: Int = defaultMaxPoints,
     minPoints: Int = defaultMinPoints,
@@ -163,6 +165,19 @@ public fun XYGraphScope<Instant, Double>.PlotNumberState(
     PlotTimeSeries(points, lineStyle)
 }
 
+@Composable
+public fun XYGraphScope<Instant, Double>.PlotNumericState(
+    context: Context,
+    state: DeviceState<NumericalValue<*>>,
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    sampling: Duration = defaultSampling,
+    lineStyle: LineStyle = defaultLineStyle,
+): Unit {
+    PlotNumberState(context, state.values(), maxAge, maxPoints, minPoints, sampling, lineStyle)
+}
+
 
 private fun List<Instant>.averageTime(): Instant {
     val min = min()
diff --git a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
index fb0ab64..9129a1c 100644
--- a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
+++ b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
@@ -23,17 +23,27 @@ import kotlinx.coroutines.flow.onEach
 import kotlinx.datetime.Instant
 import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
 import org.jetbrains.compose.splitpane.HorizontalSplitPane
-import space.kscience.controls.compose.PlotDeviceProperty
-import space.kscience.controls.compose.PlotNumberState
+import space.kscience.controls.compose.NumberTextField
+import space.kscience.controls.compose.PlotNumericState
 import space.kscience.controls.compose.TimeAxisModel
-import space.kscience.controls.constructor.*
-import space.kscience.controls.constructor.library.*
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.devices.Drive
+import space.kscience.controls.constructor.devices.LimitSwitch
+import space.kscience.controls.constructor.devices.LinearDrive
+import space.kscience.controls.constructor.models.Inertia
+import space.kscience.controls.constructor.models.MutableRangeState
+import space.kscience.controls.constructor.models.PidParameters
+import space.kscience.controls.constructor.models.ScrewDrive
+import space.kscience.controls.constructor.timer
+import space.kscience.controls.constructor.units.Kilograms
+import space.kscience.controls.constructor.units.Meters
+import space.kscience.controls.constructor.units.NumericalValue
 import space.kscience.controls.manager.ClockManager
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.clock
 import space.kscience.controls.manager.install
 import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.meta.Meta
 import java.awt.Dimension
 import kotlin.math.PI
 import kotlin.math.sin
@@ -43,67 +53,79 @@ import kotlin.time.Duration.Companion.seconds
 import kotlin.time.DurationUnit
 
 
-class LinearDrive(
-    drive: Drive,
-    start: LimitSwitch,
-    end: LimitSwitch,
-    pidParameters: PidParameters,
-    meta: Meta = Meta.EMPTY,
-) : DeviceConstructor(drive.context, meta) {
-
-    val drive by device(drive)
-    val pid by device(
-        PidRegulator(
-            context = context,
-            position = drive.propertyAsState(Drive.position, 0.0),
-            pidParameters = pidParameters
-        )
-    )
-
-    private val binding = bind(pid.output, drive.stateOfForce())
-
-    val start by device(start)
-    val end by device(end)
-}
-
-/**
- * A shortcut to create a virtual [LimitSwitch] from [DoubleInRangeState]
- */
-fun LinearDrive(
-    context: Context,
-    positionState: MutableDoubleInRangeState,
-    mass: Double,
-    pidParameters: PidParameters,
-    meta: Meta = Meta.EMPTY,
-): LinearDrive = LinearDrive(
-    drive = VirtualDrive(context, mass, positionState),
-    start = LimitSwitch(context, positionState.atStart),
-    end = LimitSwitch(context, positionState.atEnd),
-    pidParameters = pidParameters,
-    meta = meta
-)
-
 class Modulator(
     context: Context,
-    target: MutableDeviceState<Double>,
+    target: MutableDeviceState<NumericalValue<Meters>>,
     var freq: Double = 0.1,
     var timeStep: Duration = 5.milliseconds,
 ) : DeviceConstructor(context) {
     private val clockStart = clock.now()
 
-    val timer = timer(10.milliseconds)
-
-    private val modulation = timer.onNext {
+    private val modulation = timer(10.milliseconds).onNext {
         val timeFromStart = clock.now() - clockStart
         val t = timeFromStart.toDouble(DurationUnit.SECONDS)
-        target.value = 5 * sin(2.0 * PI * freq * t) +
-                sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / timeStep))
+        target.value = NumericalValue(
+            5 * sin(2.0 * PI * freq * t) +
+                    sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / timeStep))
+        )
     }
 }
 
 
+private val inertia = NumericalValue<Kilograms>(0.1)
+
+private val leverage = NumericalValue<Meters>(0.05)
+
 private val maxAge = 10.seconds
 
+private val range = -6.0..6.0
+
+/**
+ * The whole physical model is here
+ */
+private fun createLinearDriveModel(context: Context, pidParameters: PidParameters): LinearDrive {
+
+    //create a drive model with zero starting force
+    val drive = Drive(context)
+
+    //a screw drive to converse a rotational moment into a linear one
+    val screwDrive = ScrewDrive(context, leverage)
+
+    // Create a physical position coerced in a given range
+    val position = MutableRangeState<Meters>(0.0, range)
+
+    /**
+     * Create an inertia model.
+     * The inertia uses drive force as input. Position is used as both input and output
+     *
+     * Force is the input parameter, position is output parameter
+     *
+     */
+    val inertia = Inertia.linear(
+        context = context,
+        force = screwDrive.transformForce(drive.force),
+        mass = inertia,
+        position = position
+    )
+
+    /**
+     * Create a limit switches from physical position
+     */
+    val startLimitSwitch = LimitSwitch(context, position.atStart)
+    val endLimitSwitch = LimitSwitch(context, position.atEnd)
+
+    return context.install(
+        "linearDrive",
+        LinearDrive(drive, startLimitSwitch, endLimitSwitch, position, pidParameters)
+    )
+}
+
+
+private fun createModulator(linearDrive: LinearDrive): Modulator = linearDrive.context.install(
+    "modulator",
+    Modulator(linearDrive.context, linearDrive.pid.target)
+)
+
 @OptIn(ExperimentalSplitPaneApi::class, ExperimentalKoalaPlotApi::class)
 fun main() = application {
     val context = remember {
@@ -113,27 +135,16 @@ fun main() = application {
         }
     }
 
-    val clock = remember { context.clock }
-
-
     var pidParameters by remember {
-        mutableStateOf(PidParameters(kp = 2.5, ki = 0.0, kd = -0.1, timeStep = 0.005.seconds))
+        mutableStateOf(PidParameters(kp = 900.0, ki = 20.0, kd = -50.0, timeStep = 0.005.seconds))
     }
 
-    val state = remember { MutableDoubleInRangeState(0.0, -6.0..6.0) }
-
-    val linearDrive = remember {
-        context.install(
-            "linearDrive",
-            LinearDrive(context, state, 0.05, pidParameters)
-        )
+    val linearDrive: LinearDrive = remember {
+        createLinearDriveModel(context, pidParameters)
     }
 
     val modulator = remember {
-        context.install(
-            "modulator",
-            Modulator(context, linearDrive.pid.target)
-        )
+        createModulator(linearDrive)
     }
 
     //bind pid parameters
@@ -145,6 +156,8 @@ fun main() = application {
         }.collect()
     }
 
+    val clock = remember { context.clock }
+
     Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
         window.minimumSize = Dimension(800, 400)
         MaterialTheme {
@@ -153,48 +166,51 @@ fun main() = application {
                     Column(modifier = Modifier.background(color = Color.LightGray).fillMaxHeight()) {
                         Row {
                             Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
-                            TextField(
-                                String.format("%.2f", pidParameters.kp),
-                                { pidParameters = pidParameters.copy(kp = it.toDouble()) },
-                                Modifier.width(100.dp),
-                                enabled = false
+                            NumberTextField(
+                                value = pidParameters.kp,
+                                onValueChange = { pidParameters = pidParameters.copy(kp = it.toDouble()) },
+                                formatter = { String.format("%.2f", it.toDouble()) },
+                                step = 1.0,
+                                modifier = Modifier.width(200.dp),
                             )
                             Slider(
                                 pidParameters.kp.toFloat(),
                                 { pidParameters = pidParameters.copy(kp = it.toDouble()) },
-                                valueRange = 0f..20f,
+                                valueRange = 0f..1000f,
                                 steps = 100
                             )
                         }
                         Row {
                             Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
-                            TextField(
-                                String.format("%.2f", pidParameters.ki),
-                                { pidParameters = pidParameters.copy(ki = it.toDouble()) },
-                                Modifier.width(100.dp),
-                                enabled = false
+                            NumberTextField(
+                                value = pidParameters.ki,
+                                onValueChange = { pidParameters = pidParameters.copy(ki = it.toDouble()) },
+                                formatter = { String.format("%.2f", it.toDouble()) },
+                                step = 0.1,
+                                modifier = Modifier.width(200.dp),
                             )
 
                             Slider(
                                 pidParameters.ki.toFloat(),
                                 { pidParameters = pidParameters.copy(ki = it.toDouble()) },
-                                valueRange = -10f..10f,
+                                valueRange = -100f..100f,
                                 steps = 100
                             )
                         }
                         Row {
                             Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
-                            TextField(
-                                String.format("%.2f", pidParameters.kd),
-                                { pidParameters = pidParameters.copy(kd = it.toDouble()) },
-                                Modifier.width(100.dp),
-                                enabled = false
+                            NumberTextField(
+                                value = pidParameters.kd,
+                                onValueChange = { pidParameters = pidParameters.copy(kd = it.toDouble()) },
+                                formatter = { String.format("%.2f", it.toDouble()) },
+                                step = 0.1,
+                                modifier = Modifier.width(200.dp),
                             )
 
                             Slider(
                                 pidParameters.kd.toFloat(),
                                 { pidParameters = pidParameters.copy(kd = it.toDouble()) },
-                                valueRange = -10f..10f,
+                                valueRange = -100f..100f,
                                 steps = 100
                             )
                         }
@@ -204,7 +220,7 @@ fun main() = application {
                             TextField(
                                 pidParameters.timeStep.toString(DurationUnit.MILLISECONDS),
                                 { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) },
-                                Modifier.width(100.dp),
+                                Modifier.width(200.dp),
                                 enabled = false
                             )
 
@@ -233,7 +249,7 @@ fun main() = application {
                     ChartLayout {
                         XYGraph<Instant, Double>(
                             xAxisModel = remember { TimeAxisModel.recent(maxAge, clock) },
-                            yAxisModel = rememberDoubleLinearAxisModel(state.range),
+                            yAxisModel = rememberDoubleLinearAxisModel((range.start - 1.0)..(range.endInclusive + 1.0)),
                             xAxisTitle = { Text("Time in seconds relative to current") },
                             xAxisLabels = { it: Instant ->
                                 androidx.compose.material3.Text(
@@ -244,20 +260,14 @@ fun main() = application {
                             },
                             yAxisLabels = { it: Double -> Text(it.toString(2)) }
                         ) {
-                            PlotNumberState(
+                            PlotNumericState(
                                 context = context,
-                                state = state,
+                                state = linearDrive.position,
                                 maxAge = maxAge,
                                 sampling = 50.milliseconds,
                                 lineStyle = LineStyle(SolidColor(Color.Blue))
                             )
-                            PlotDeviceProperty(
-                                linearDrive.drive,
-                                Drive.position,
-                                maxAge = maxAge,
-                                sampling = 50.milliseconds,
-                            )
-                            PlotNumberState(
+                            PlotNumericState(
                                 context = context,
                                 state = linearDrive.pid.target,
                                 maxAge = maxAge,
@@ -273,10 +283,6 @@ fun main() = application {
                                     }
 
                                     1 -> {
-                                        Text("Regulator position", color = Color.Black)
-                                    }
-
-                                    2 -> {
                                         Text("Regulator target", color = Color.Red)
                                     }
                                 }

From a2b7d1ecb096e66c4ca4eea6e9fb5cbd8bdd4911 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 3 Jun 2024 13:44:20 +0300
Subject: [PATCH 095/125] Fix step drive demo

---
 .../controls/constructor/DeviceConstructor.kt |   8 +-
 .../controls/constructor/DeviceGroup.kt       |  44 ++++-
 .../controls/constructor/DeviceState.kt       |   2 +-
 .../controls/constructor/devices/StepDrive.kt |  27 ++-
 .../controls/constructor/flowState.kt         |   2 +-
 .../controls/constructor/internalState.kt     |   2 +-
 .../controls/constructor/models/RangeState.kt |   8 +-
 .../controls/constructor/models/ScrewDrive.kt |  16 +-
 .../controls/constructor/models/XYPosition.kt |  17 ++
 .../constructor/units/NumericalValue.kt       |   5 +-
 .../src/jvmMain/kotlin/BodyOnSprings.kt       |  16 +-
 .../constructor/src/jvmMain/kotlin/PidDemo.kt |  40 ++--
 .../constructor/src/jvmMain/kotlin/Plotter.kt | 172 ++++++++++++++++++
 13 files changed, 298 insertions(+), 61 deletions(-)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
index 00d408e..371e94f 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -32,12 +32,12 @@ public abstract class DeviceConstructor(
         _constructorElements.remove(constructorElement)
     }
 
-    override fun <T, S: DeviceState<T>> registerAsProperty(
+    override fun <T, S: DeviceState<T>> registerProperty(
         converter: MetaConverter<T>,
         descriptor: PropertyDescriptor,
         state: S,
     ): S {
-        val res = super.registerAsProperty(converter, descriptor, state)
+        val res = super.registerProperty(converter, descriptor, state)
         registerElement(PropertyConstructorElement(this, descriptor.name, state))
         return res
     }
@@ -84,7 +84,7 @@ public fun <T, S : DeviceState<T>> DeviceConstructor.property(
     PropertyDelegateProvider { _: DeviceConstructor, property ->
         val name = nameOverride ?: property.name
         val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
-        registerAsProperty(converter, descriptor, state)
+        registerProperty(converter, descriptor, state)
         ReadOnlyProperty { _: DeviceConstructor, _ ->
             state
         }
@@ -145,6 +145,6 @@ public fun <T, S : DeviceState<T>> DeviceConstructor.registerAsProperty(
     spec: DevicePropertySpec<*, T>,
     state: S,
 ): S {
-    registerAsProperty(spec.converter, spec.descriptor, state)
+    registerProperty(spec.converter, spec.descriptor, state)
     return state
 }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index 182d10b..a41899a 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -42,10 +42,15 @@ public open class DeviceGroup(
         }
     }
 
-    private class Action(
-        val invoke: suspend (Meta?) -> Meta?,
+    private class Action<T, R>(
+        val inputConverter: MetaConverter<T>,
+        val outputConverter: MetaConverter<R>,
         val descriptor: ActionDescriptor,
-    )
+        val action: suspend (T) -> R,
+    ) {
+        suspend operator fun invoke(argument: Meta?): Meta? = argument?.let { inputConverter.readOrNull(it) }
+            ?.let { action(it)?.let { outputConverter.convert(it) } }
+    }
 
 
     private val sharedMessageFlow = MutableSharedFlow<DeviceMessage>()
@@ -93,7 +98,7 @@ public open class DeviceGroup(
     /**
      * Register a new property based on [DeviceState]. Properties could be modified dynamically
      */
-    public open fun <T, S : DeviceState<T>> registerAsProperty(
+    public open fun <T, S : DeviceState<T>> registerProperty(
         converter: MetaConverter<T>,
         descriptor: PropertyDescriptor,
         state: S,
@@ -112,7 +117,26 @@ public open class DeviceGroup(
         return state
     }
 
-    private val actions: MutableMap<Name, Action> = hashMapOf()
+    private val actions: MutableMap<Name, Action<*, *>> = hashMapOf()
+
+    public fun <T, R> registerAction(
+        inputConverter: MetaConverter<T>,
+        outputConverter: MetaConverter<R>,
+        descriptor: ActionDescriptor,
+        action: suspend (T) -> R,
+    ): suspend (T) -> R {
+        val name = descriptor.name.parseAsName()
+        require(actions[name] == null) { "Can't add action with name $name. It already exists." }
+        actions[name] = Action(
+            inputConverter = inputConverter,
+            outputConverter = outputConverter,
+            descriptor = descriptor,
+            action = action
+        )
+        return {
+            action(it)
+        }
+    }
 
     override val propertyDescriptors: Collection<PropertyDescriptor>
         get() = properties.values.map { it.descriptor }
@@ -137,8 +161,8 @@ public open class DeviceGroup(
 
 
     override suspend fun execute(actionName: String, argument: Meta?): Meta? {
-        val action = actions[actionName] ?: error("Action with name $actionName not found")
-        return action.invoke(argument)
+        val action: Action<*, *> = actions[actionName] ?: error("Action with name $actionName not found")
+        return action(argument)
     }
 
     final override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
@@ -176,7 +200,7 @@ public open class DeviceGroup(
 }
 
 public fun <T> DeviceGroup.registerAsProperty(propertySpec: DevicePropertySpec<*, T>, state: DeviceState<T>) {
-    registerAsProperty(propertySpec.converter, propertySpec.descriptor, state)
+    registerProperty(propertySpec.converter, propertySpec.descriptor, state)
 }
 
 public fun DeviceManager.registerDeviceGroup(
@@ -253,7 +277,7 @@ public fun <T : Any> DeviceGroup.registerAsProperty(
     state: DeviceState<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
 ) {
-    registerAsProperty(
+    registerProperty(
         converter,
         PropertyDescriptor(name).apply(descriptorBuilder),
         state
@@ -269,7 +293,7 @@ public fun <T : Any> DeviceGroup.registerMutableProperty(
     state: MutableDeviceState<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
 ) {
-    registerAsProperty(
+    registerProperty(
         converter,
         PropertyDescriptor(name).apply(descriptorBuilder),
         state
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index c326565..846a37b 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -74,7 +74,7 @@ public fun <T, R> DeviceState.Companion.map(
 
 public fun <T, R> DeviceState<T>.map(mapper: (T) -> R): DeviceStateWithDependencies<R> = DeviceState.map(this, mapper)
 
-public fun DeviceState<out NumericalValue<out UnitsOfMeasurement>>.values(): DeviceState<Double> = object : DeviceState<Double> {
+public fun DeviceState<NumericalValue<out UnitsOfMeasurement>>.values(): DeviceState<Double> = object : DeviceState<Double> {
     override val value: Double
         get() = this@values.value.value
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
index 072789b..a8d9894 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
@@ -1,6 +1,5 @@
 package space.kscience.controls.constructor.devices
 
-import kotlinx.coroutines.launch
 import space.kscience.controls.constructor.*
 import space.kscience.controls.constructor.units.Degrees
 import space.kscience.controls.constructor.units.NumericalValue
@@ -16,38 +15,36 @@ import kotlin.time.DurationUnit
 /**
  * A step drive
  *
- * @param speed ticks per second
+ * @param ticksPerSecond ticks per second
  * @param target target ticks state
  * @param writeTicks a hardware callback
  */
 public class StepDrive(
     context: Context,
-    speed: MutableDeviceState<Double>,
+    ticksPerSecond: MutableDeviceState<Double>,
     target: MutableDeviceState<Int> = MutableDeviceState(0),
-    private val writeTicks: suspend (ticks: Int, speed: Double) -> Unit,
+    private val writeTicks: suspend (ticks: Int, speed: Double) -> Unit = { _, _ -> },
 ) : DeviceConstructor(context) {
 
     public val target: MutableDeviceState<Int> by property(MetaConverter.int, target)
 
-    public val speed: MutableDeviceState<Double> by property(MetaConverter.double, speed)
+    public val speed: MutableDeviceState<Double> by property(MetaConverter.double, ticksPerSecond)
 
     private val positionState = stateOf(target.value)
 
     public val position: DeviceState<Int> by property(MetaConverter.int, positionState)
 
-    private val ticker = onTimer { prev, next ->
-        val tickSpeed = speed.value
+    private val ticker = onTimer(reads = setOf(target, position), writes = setOf(position)) { prev, next ->
+        val tickSpeed = ticksPerSecond.value
         val timeDelta = (next - prev).toDouble(DurationUnit.SECONDS)
         val ticksDelta: Int = target.value - position.value
         val steps: Int = when {
             ticksDelta > 0 -> min(ticksDelta, (timeDelta * tickSpeed).roundToInt())
-            ticksDelta < 0 -> max(ticksDelta, (timeDelta * tickSpeed).roundToInt())
+            ticksDelta < 0 -> max(ticksDelta, -(timeDelta * tickSpeed).roundToInt())
             else -> return@onTimer
         }
-        launch {
-            writeTicks(steps, tickSpeed)
-            positionState.value += steps
-        }
+        writeTicks(steps, tickSpeed)
+        positionState.value += steps
     }
 }
 
@@ -55,7 +52,9 @@ public class StepDrive(
  * Compute a state using given tick-to-angle transformation
  */
 public fun StepDrive.angle(
-    zero: NumericalValue<Degrees>,
     step: NumericalValue<Degrees>,
-): DeviceState<NumericalValue<Degrees>> = position.map { zero + it * step }
+    zero: NumericalValue<Degrees> = NumericalValue(0),
+): DeviceState<NumericalValue<Degrees>> = position.map {
+    zero + it.toDouble() * step
+}
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt
index 2bd9322..34a4910 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt
@@ -10,7 +10,7 @@ private class StateFlowAsState<T>(
     override var value: T by flow::value
     override val valueFlow: Flow<T> get() = flow
 
-    override fun toString(): String = "FlowAsState()"
+    override fun toString(): String = "FlowAsState($value)"
 }
 
 /**
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
index 8bdfce1..687804e 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
@@ -23,7 +23,7 @@ private class VirtualDeviceState<T>(
             callback(value)
         }
 
-    override fun toString(): String = "VirtualDeviceState()"
+    override fun toString(): String = "VirtualDeviceState($value)"
 }
 
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt
index c760699..202fa71 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt
@@ -16,7 +16,9 @@ public open class RangeState<T : Comparable<T>>(
     public val range: ClosedRange<T>,
 ) : DeviceState<T> {
 
-    override val valueFlow: Flow<T> get() = input.valueFlow.map { it.coerceIn(range) }
+    override val valueFlow: Flow<T> get() = input.valueFlow.map {
+        it.coerceIn(range)
+    }
 
     override val value: T get() = input.value.coerceIn(range)
 
@@ -59,10 +61,10 @@ public fun <U : UnitsOfMeasurement> MutableRangeState(
 
 
 public fun <T : Comparable<T>> DeviceState<T>.coerceIn(
-    range: ClosedFloatingPointRange<T>,
+    range: ClosedRange<T>,
 ): RangeState<T> = RangeState(this, range)
 
 
 public fun <T : Comparable<T>> MutableDeviceState<T>.coerceIn(
-    range: ClosedFloatingPointRange<T>,
+    range: ClosedRange<T>,
 ): MutableRangeState<T> = MutableRangeState(this, range)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
index 6e2f86c..5a6b6bc 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
@@ -3,20 +3,26 @@ package space.kscience.controls.constructor.models
 import space.kscience.controls.constructor.DeviceState
 import space.kscience.controls.constructor.ModelConstructor
 import space.kscience.controls.constructor.map
-import space.kscience.controls.constructor.units.Meters
-import space.kscience.controls.constructor.units.Newtons
-import space.kscience.controls.constructor.units.NewtonsMeters
-import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.units.*
 import space.kscience.dataforge.context.Context
+import kotlin.math.PI
 
 public class ScrewDrive(
     context: Context,
     public val leverage: NumericalValue<Meters>,
 ) : ModelConstructor(context) {
+
     public fun transformForce(
         stateOfForce: DeviceState<NumericalValue<NewtonsMeters>>,
     ): DeviceState<NumericalValue<Newtons>> = DeviceState.map(stateOfForce) {
-        NumericalValue(it.value * leverage.value)
+        NumericalValue(it.value * leverage.value/2/ PI)
+    }
+
+    public fun transformOffset(
+        stateOfAngle: DeviceState<NumericalValue<Degrees>>,
+        offset: NumericalValue<Meters> = NumericalValue(0),
+    ): DeviceState<NumericalValue<Meters>> = DeviceState.map(stateOfAngle) {
+        offset + NumericalValue(it.value * leverage.value/2/ PI)
     }
 
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt
new file mode 100644
index 0000000..15411f8
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt
@@ -0,0 +1,17 @@
+package space.kscience.controls.constructor.models
+
+import space.kscience.controls.constructor.ModelConstructor
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.stateOf
+import space.kscience.controls.constructor.units.Meters
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.dataforge.context.Context
+
+public class XYPosition(
+    context: Context,
+    initialX: NumericalValue<Meters> = NumericalValue(0.0),
+    initialY: NumericalValue<Meters> = NumericalValue(0.0),
+) : ModelConstructor(context) {
+    public val x: MutableDeviceState<NumericalValue<Meters>> = stateOf(initialX)
+    public val y: MutableDeviceState<NumericalValue<Meters>> = stateOf(initialY)
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
index 60de195..e503623 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
@@ -10,7 +10,7 @@ import kotlin.jvm.JvmInline
  * A value without identity coupled to units of measurements.
  */
 @JvmInline
-public value class NumericalValue<U : UnitsOfMeasurement>(public val value: Double): Comparable<NumericalValue<U>> {
+public value class NumericalValue<U : UnitsOfMeasurement>(public val value: Double) : Comparable<NumericalValue<U>> {
     override fun compareTo(other: NumericalValue<U>): Int = value.compareTo(other.value)
 
 }
@@ -43,6 +43,9 @@ public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.div(
     c: Number,
 ): NumericalValue<U> = NumericalValue(this.value / c.toDouble())
 
+public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.div(other: NumericalValue<U>): Double =
+    value / other.value
+
 
 private object NumericalValueMetaConverter : MetaConverter<NumericalValue<*>> {
     override fun convert(obj: NumericalValue<*>): Meta = Meta(obj.value)
diff --git a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
index 7fcb5ab..e4de468 100644
--- a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
+++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
@@ -23,19 +23,19 @@ import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.DurationUnit
 
 @Serializable
-data class XY(val x: Double, val y: Double) {
+private data class XY(val x: Double, val y: Double) {
     companion object {
         val ZERO = XY(0.0, 0.0)
     }
 }
 
-val XY.length: Double get() = sqrt(x.pow(2) + y.pow(2))
+private val XY.length: Double get() = sqrt(x.pow(2) + y.pow(2))
 
-operator fun XY.plus(other: XY): XY = XY(x + other.x, y + other.y)
-operator fun XY.times(c: Double): XY = XY(x * c, y * c)
-operator fun XY.div(c: Double): XY = XY(x / c, y / c)
+private operator fun XY.plus(other: XY): XY = XY(x + other.x, y + other.y)
+private operator fun XY.times(c: Double): XY = XY(x * c, y * c)
+private operator fun XY.div(c: Double): XY = XY(x / c, y / c)
 
-class Spring(
+private class Spring(
     context: Context,
     val k: Double,
     val l0: Double,
@@ -70,7 +70,7 @@ class Spring(
     }
 }
 
-class MaterialPoint(
+private class MaterialPoint(
     context: Context,
     val mass: Double,
     val force: DeviceState<XY>,
@@ -94,7 +94,7 @@ class MaterialPoint(
 }
 
 
-class BodyOnSprings(
+private class BodyOnSprings(
     context: Context,
     mass: Double,
     k: Double,
diff --git a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
index 9129a1c..a77544f 100644
--- a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
+++ b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
@@ -72,7 +72,7 @@ class Modulator(
 }
 
 
-private val inertia = NumericalValue<Kilograms>(0.1)
+private val mass = NumericalValue<Kilograms>(0.1)
 
 private val leverage = NumericalValue<Meters>(0.05)
 
@@ -83,7 +83,13 @@ private val range = -6.0..6.0
 /**
  * The whole physical model is here
  */
-private fun createLinearDriveModel(context: Context, pidParameters: PidParameters): LinearDrive {
+internal fun createLinearDriveModel(
+    context: Context,
+    pidParameters: PidParameters,
+    mass: NumericalValue<Kilograms>,
+    leverage: NumericalValue<Meters>,
+    position: MutableRangeState<NumericalValue<Meters>>,
+): LinearDrive {
 
     //create a drive model with zero starting force
     val drive = Drive(context)
@@ -91,8 +97,6 @@ private fun createLinearDriveModel(context: Context, pidParameters: PidParameter
     //a screw drive to converse a rotational moment into a linear one
     val screwDrive = ScrewDrive(context, leverage)
 
-    // Create a physical position coerced in a given range
-    val position = MutableRangeState<Meters>(0.0, range)
 
     /**
      * Create an inertia model.
@@ -101,10 +105,10 @@ private fun createLinearDriveModel(context: Context, pidParameters: PidParameter
      * Force is the input parameter, position is output parameter
      *
      */
-    val inertia = Inertia.linear(
+    val inertiaModel = Inertia.linear(
         context = context,
         force = screwDrive.transformForce(drive.force),
-        mass = inertia,
+        mass = mass,
         position = position
     )
 
@@ -114,12 +118,12 @@ private fun createLinearDriveModel(context: Context, pidParameters: PidParameter
     val startLimitSwitch = LimitSwitch(context, position.atStart)
     val endLimitSwitch = LimitSwitch(context, position.atEnd)
 
-    return context.install(
-        "linearDrive",
-        LinearDrive(drive, startLimitSwitch, endLimitSwitch, position, pidParameters)
-    )
-}
+    /**
+     * Install the resulting device
+     */
+    return LinearDrive(drive, startLimitSwitch, endLimitSwitch, position, pidParameters)
 
+}
 
 private fun createModulator(linearDrive: LinearDrive): Modulator = linearDrive.context.install(
     "modulator",
@@ -140,11 +144,21 @@ fun main() = application {
     }
 
     val linearDrive: LinearDrive = remember {
-        createLinearDriveModel(context, pidParameters)
+        context.install(
+            "linearDrive",
+            createLinearDriveModel(
+                context = context,
+                pidParameters = pidParameters,
+                mass = mass,
+                leverage = leverage,
+                // Create a physical position coerced in a given range
+                position = MutableRangeState<Meters>(0.0, range)
+            )
+        )
     }
 
     val modulator = remember {
-        createModulator(linearDrive)
+        context.install("modulator", createModulator(linearDrive))
     }
 
     //bind pid parameters
diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
index bd0d366..9d48965 100644
--- a/demo/constructor/src/jvmMain/kotlin/Plotter.kt
+++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
@@ -1,2 +1,174 @@
 package space.kscience.controls.demo.constructor
 
+import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material.MaterialTheme
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.window.Window
+import androidx.compose.ui.window.application
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.device
+import space.kscience.controls.constructor.devices.StepDrive
+import space.kscience.controls.constructor.devices.angle
+import space.kscience.controls.constructor.models.RangeState
+import space.kscience.controls.constructor.models.ScrewDrive
+import space.kscience.controls.constructor.models.coerceIn
+import space.kscience.controls.constructor.units.*
+import space.kscience.controls.manager.ClockManager
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.dataforge.context.Context
+import java.awt.Dimension
+import kotlin.random.Random
+
+
+class Plotter(
+    context: Context,
+    xDrive: StepDrive,
+    yDrive: StepDrive,
+    val paint: suspend (Color) -> Unit,
+) : DeviceConstructor(context) {
+    val xDrive by device(xDrive)
+    val yDrive by device(yDrive)
+
+    public fun moveToXY(x: Int, y: Int) {
+        xDrive.target.value = x
+        yDrive.target.value = y
+    }
+
+    //TODO add calibration
+
+    // TODO add draw as action
+}
+
+suspend fun Plotter.modernArt(xRange: IntRange, yRange: IntRange) {
+    while (isActive){
+        val randomX = Random.nextInt(xRange.first, xRange.last)
+        val randomY = Random.nextInt(xRange.first, xRange.last)
+        moveToXY(randomX, randomY)
+        delay(500)
+        paint(Color(Random.nextInt()))
+    }
+}
+
+suspend fun Plotter.square(xRange: IntRange, yRange: IntRange) {
+    while (isActive) {
+        moveToXY(xRange.first, yRange.first)
+        delay(1000)
+        paint(Color.Red)
+
+        moveToXY(xRange.first, yRange.last)
+        delay(1000)
+        paint(Color.Red)
+
+        moveToXY(xRange.last, yRange.last)
+        delay(1000)
+        paint(Color.Red)
+
+        moveToXY(xRange.last, yRange.first)
+        delay(1000)
+        paint(Color.Red)
+    }
+}
+
+private val xRange = NumericalValue<Meters>(-0.5)..NumericalValue<Meters>(0.5)
+private val yRange = NumericalValue<Meters>(-0.5)..NumericalValue<Meters>(0.5)
+private val ticksPerSecond = MutableDeviceState(250.0)
+private val step = NumericalValue<Degrees>(1.2)
+
+
+private data class PlotterPoint(
+    val x: NumericalValue<Meters>,
+    val y: NumericalValue<Meters>,
+    val color: Color = Color.Black,
+)
+
+suspend fun main() = application {
+    Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
+        window.minimumSize = Dimension(400, 400)
+
+        val points = remember { mutableStateListOf<PlotterPoint>() }
+        var position by remember { mutableStateOf(PlotterPoint(NumericalValue(0), NumericalValue(0))) }
+
+        LaunchedEffect(Unit) {
+            val context = Context {
+                plugin(DeviceManager)
+                plugin(ClockManager)
+            }
+
+            /* Here goes the device definition block */
+
+
+            val xScrewDrive = ScrewDrive(context, NumericalValue(0.01))
+            val xDrive = StepDrive(context, ticksPerSecond)
+            val x: RangeState<NumericalValue<Meters>> = xScrewDrive.transformOffset(xDrive.angle(step)).coerceIn(xRange)
+
+            val yScrewDrive = ScrewDrive(context, NumericalValue(0.01))
+            val yDrive = StepDrive(context, ticksPerSecond)
+            val y: RangeState<NumericalValue<Meters>> = yScrewDrive.transformOffset(yDrive.angle(step)).coerceIn(yRange)
+
+            val plotter = Plotter(context, xDrive, yDrive) { color ->
+                println("Point X: ${x.value.value}, Y: ${y.value.value}, color: $color")
+                points.add(PlotterPoint(x.value, y.value, color))
+            }
+
+
+            /* Start visualization program */
+
+            launch {
+                x.valueFlow.collect {
+                    position = position.copy(x = it)
+                }
+            }
+
+            launch {
+                y.valueFlow.collect {
+                    position = position.copy(y = it)
+                }
+            }
+
+            launch {
+                val range = -100..100
+                plotter.modernArt(range, range)
+                //plotter.square(range, range)
+            }
+        }
+
+
+        /* Here goes the visualization block */
+
+        MaterialTheme {
+            Canvas(modifier = Modifier.fillMaxSize()) {
+                fun toOffset(x: NumericalValue<Meters>, y: NumericalValue<Meters>): Offset {
+                    val canvasX = (x - xRange.start) / (xRange.endInclusive - xRange.start) * size.width
+                    val canvasY = (y - yRange.start) / (yRange.endInclusive - yRange.start) * size.height
+                    return Offset(canvasX.toFloat(), canvasY.toFloat())
+                }
+
+                val center = toOffset(position.x, position.y)
+
+
+                drawRect(
+                    Color.LightGray,
+                    topLeft = Offset(0f, center.y - 5f),
+                    size = Size(size.width, 10f)
+                )
+
+                drawCircle(Color.Black, radius = 10f, center = center)
+
+
+                points.forEach {
+                    drawCircle(it.color, radius = 2f, center = toOffset(it.x, it.y))
+                }
+            }
+        }
+    }
+
+}
\ No newline at end of file

From 4a5f5fab8c953bb288749ef17f6a359ee20a8a78 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 3 Jun 2024 15:38:39 +0300
Subject: [PATCH 096/125] Finish plotter demo

---
 .../controls/constructor/devices/Drive.kt     |  1 +
 .../controls/constructor/devices/StepDrive.kt | 22 ++++++------
 .../controls/constructor/models/Inertia.kt    | 33 +----------------
 .../controls/constructor/models/ScrewDrive.kt | 14 ++++----
 .../controls/constructor/models/XYPosition.kt | 17 ---------
 .../constructor/models/coordinates.kt         |  8 +++++
 .../constructor/src/jvmMain/kotlin/PidDemo.kt |  6 ++--
 .../constructor/src/jvmMain/kotlin/Plotter.kt | 36 +++++++++++--------
 8 files changed, 53 insertions(+), 84 deletions(-)
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/coordinates.kt

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt
index 3436856..9880508 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt
@@ -9,6 +9,7 @@ import space.kscience.controls.constructor.units.numerical
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.MetaConverter
 
+//TODO use current as input
 
 public class Drive(
     context: Context,
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
index a8d9894..be32f07 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
@@ -9,7 +9,7 @@ import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.MetaConverter
 import kotlin.math.max
 import kotlin.math.min
-import kotlin.math.roundToInt
+import kotlin.math.roundToLong
 import kotlin.time.DurationUnit
 
 /**
@@ -22,25 +22,27 @@ import kotlin.time.DurationUnit
 public class StepDrive(
     context: Context,
     ticksPerSecond: MutableDeviceState<Double>,
-    target: MutableDeviceState<Int> = MutableDeviceState(0),
-    private val writeTicks: suspend (ticks: Int, speed: Double) -> Unit = { _, _ -> },
+    target: MutableDeviceState<Long> = MutableDeviceState(0),
+    private val writeTicks: suspend (ticks: Long, speed: Double) -> Unit = { _, _ -> },
 ) : DeviceConstructor(context) {
 
-    public val target: MutableDeviceState<Int> by property(MetaConverter.int, target)
+    public val target: MutableDeviceState<Long> by property(MetaConverter.long, target)
 
     public val speed: MutableDeviceState<Double> by property(MetaConverter.double, ticksPerSecond)
 
     private val positionState = stateOf(target.value)
 
-    public val position: DeviceState<Int> by property(MetaConverter.int, positionState)
+    public val position: DeviceState<Long> by property(MetaConverter.long, positionState)
 
+
+    //FIXME round to zero problem
     private val ticker = onTimer(reads = setOf(target, position), writes = setOf(position)) { prev, next ->
         val tickSpeed = ticksPerSecond.value
         val timeDelta = (next - prev).toDouble(DurationUnit.SECONDS)
-        val ticksDelta: Int = target.value - position.value
-        val steps: Int = when {
-            ticksDelta > 0 -> min(ticksDelta, (timeDelta * tickSpeed).roundToInt())
-            ticksDelta < 0 -> max(ticksDelta, -(timeDelta * tickSpeed).roundToInt())
+        val ticksDelta: Long = target.value - position.value
+        val steps: Long = when {
+            ticksDelta > 0 -> min(ticksDelta, (timeDelta * tickSpeed).roundToLong())
+            ticksDelta < 0 -> max(ticksDelta, -(timeDelta * tickSpeed).roundToLong())
             else -> return@onTimer
         }
         writeTicks(steps, tickSpeed)
@@ -55,6 +57,6 @@ public fun StepDrive.angle(
     step: NumericalValue<Degrees>,
     zero: NumericalValue<Degrees> = NumericalValue(0),
 ): DeviceState<NumericalValue<Degrees>> = position.map {
-    zero + it.toDouble() * step
+    zero + it * step
 }
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
index d6c3fc7..88a377e 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
@@ -25,11 +25,9 @@ public class Inertia<U : UnitsOfMeasurement, V : UnitsOfMeasurement>(
         registerState(velocity)
     }
 
-    private val movementTimer = timer(timerPrecision)
-
     private var currentForce = force.value
 
-    private val movement = movementTimer.onChange { prev, next ->
+    private val movement = onTimer (timerPrecision) { prev, next ->
         val dtSeconds = (next - prev).toDouble(DurationUnit.SECONDS)
 
         // compute new value based on velocity and acceleration from the previous step
@@ -57,21 +55,6 @@ public class Inertia<U : UnitsOfMeasurement, V : UnitsOfMeasurement>(
             position = position,
             velocity = velocity
         )
-//
-//
-//        public fun linear(
-//            context: Context,
-//            force: DeviceState<NumericalValue<Newtons>>,
-//            mass: NumericalValue<Kilograms>,
-//            initialPosition: NumericalValue<Meters>,
-//            initialVelocity: NumericalValue<MetersPerSecond> = NumericalValue(0),
-//        ): Inertia<Meters, MetersPerSecond> = Inertia(
-//            context = context,
-//            force = force.values(),
-//            inertia = mass.value,
-//            position = MutableDeviceState(initialPosition),
-//            velocity = MutableDeviceState(initialVelocity)
-//        )
 
         public fun circular(
             context: Context,
@@ -86,19 +69,5 @@ public class Inertia<U : UnitsOfMeasurement, V : UnitsOfMeasurement>(
             position = position,
             velocity = velocity
         )
-//
-//        public fun circular(
-//            context: Context,
-//            force: DeviceState<NumericalValue<NewtonsMeters>>,
-//            momentOfInertia: NumericalValue<KgM2>,
-//            initialPosition: NumericalValue<Degrees>,
-//            initialVelocity: NumericalValue<DegreesPerSecond> = NumericalValue(0),
-//        ): Inertia<Degrees, DegreesPerSecond> = Inertia(
-//            context = context,
-//            force = force.values(),
-//            inertia = momentOfInertia.value,
-//            position = MutableDeviceState(initialPosition),
-//            velocity = MutableDeviceState(initialVelocity)
-//        )
     }
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
index 5a6b6bc..573a648 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
@@ -12,17 +12,17 @@ public class ScrewDrive(
     public val leverage: NumericalValue<Meters>,
 ) : ModelConstructor(context) {
 
-    public fun transformForce(
-        stateOfForce: DeviceState<NumericalValue<NewtonsMeters>>,
-    ): DeviceState<NumericalValue<Newtons>> = DeviceState.map(stateOfForce) {
-        NumericalValue(it.value * leverage.value/2/ PI)
+    public fun torqueToForce(
+        stateOfMomentum: DeviceState<NumericalValue<NewtonsMeters>>,
+    ): DeviceState<NumericalValue<Newtons>> = DeviceState.map(stateOfMomentum) { momentum ->
+        NumericalValue(momentum.value / leverage.value )
     }
 
-    public fun transformOffset(
+    public fun degreesToMeters(
         stateOfAngle: DeviceState<NumericalValue<Degrees>>,
         offset: NumericalValue<Meters> = NumericalValue(0),
-    ): DeviceState<NumericalValue<Meters>> = DeviceState.map(stateOfAngle) {
-        offset + NumericalValue(it.value * leverage.value/2/ PI)
+    ): DeviceState<NumericalValue<Meters>> = DeviceState.map(stateOfAngle) { degrees ->
+        offset + NumericalValue(degrees.value * 2 * PI / 360 *leverage.value )
     }
 
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt
deleted file mode 100644
index 15411f8..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/XYPosition.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package space.kscience.controls.constructor.models
-
-import space.kscience.controls.constructor.ModelConstructor
-import space.kscience.controls.constructor.MutableDeviceState
-import space.kscience.controls.constructor.stateOf
-import space.kscience.controls.constructor.units.Meters
-import space.kscience.controls.constructor.units.NumericalValue
-import space.kscience.dataforge.context.Context
-
-public class XYPosition(
-    context: Context,
-    initialX: NumericalValue<Meters> = NumericalValue(0.0),
-    initialY: NumericalValue<Meters> = NumericalValue(0.0),
-) : ModelConstructor(context) {
-    public val x: MutableDeviceState<NumericalValue<Meters>> = stateOf(initialX)
-    public val y: MutableDeviceState<NumericalValue<Meters>> = stateOf(initialY)
-}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/coordinates.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/coordinates.kt
new file mode 100644
index 0000000..50db92b
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/coordinates.kt
@@ -0,0 +1,8 @@
+package space.kscience.controls.constructor.models
+
+import space.kscience.controls.constructor.units.NumericalValue
+import space.kscience.controls.constructor.units.UnitsOfMeasurement
+
+public data class XY<U: UnitsOfMeasurement>(val x: NumericalValue<U>, val y: NumericalValue<U>)
+
+public data class XYZ<U: UnitsOfMeasurement>(val x: NumericalValue<U>, val y: NumericalValue<U>, val z: NumericalValue<U>)
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
index a77544f..b19e179 100644
--- a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
+++ b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
@@ -94,7 +94,7 @@ internal fun createLinearDriveModel(
     //create a drive model with zero starting force
     val drive = Drive(context)
 
-    //a screw drive to converse a rotational moment into a linear one
+    //a screw drive to convert a rotational moment into a force
     val screwDrive = ScrewDrive(context, leverage)
 
 
@@ -107,7 +107,7 @@ internal fun createLinearDriveModel(
      */
     val inertiaModel = Inertia.linear(
         context = context,
-        force = screwDrive.transformForce(drive.force),
+        force = screwDrive.torqueToForce(drive.force),
         mass = mass,
         position = position
     )
@@ -266,7 +266,7 @@ fun main() = application {
                             yAxisModel = rememberDoubleLinearAxisModel((range.start - 1.0)..(range.endInclusive + 1.0)),
                             xAxisTitle = { Text("Time in seconds relative to current") },
                             xAxisLabels = { it: Instant ->
-                                androidx.compose.material3.Text(
+                                Text(
                                     (clock.now() - it).toDouble(
                                         DurationUnit.SECONDS
                                     ).toString(2)
diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
index 9d48965..b605dd4 100644
--- a/demo/constructor/src/jvmMain/kotlin/Plotter.kt
+++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
@@ -18,7 +18,6 @@ import space.kscience.controls.constructor.MutableDeviceState
 import space.kscience.controls.constructor.device
 import space.kscience.controls.constructor.devices.StepDrive
 import space.kscience.controls.constructor.devices.angle
-import space.kscience.controls.constructor.models.RangeState
 import space.kscience.controls.constructor.models.ScrewDrive
 import space.kscience.controls.constructor.models.coerceIn
 import space.kscience.controls.constructor.units.*
@@ -38,21 +37,26 @@ class Plotter(
     val xDrive by device(xDrive)
     val yDrive by device(yDrive)
 
-    public fun moveToXY(x: Int, y: Int) {
-        xDrive.target.value = x
-        yDrive.target.value = y
+    public fun moveToXY(x: Number, y: Number) {
+        xDrive.target.value = x.toLong()
+        yDrive.target.value = y.toLong()
     }
 
+//    val position = combineState(xDrive.position, yDrive.position) { x, y ->
+//        space.kscience.controls.constructor.models.XY(x, y)
+//    }
+
     //TODO add calibration
 
     // TODO add draw as action
 }
 
 suspend fun Plotter.modernArt(xRange: IntRange, yRange: IntRange) {
-    while (isActive){
+    while (isActive) {
         val randomX = Random.nextInt(xRange.first, xRange.last)
-        val randomY = Random.nextInt(xRange.first, xRange.last)
+        val randomY = Random.nextInt(yRange.first, yRange.last)
         moveToXY(randomX, randomY)
+        //TODO wait for position instead of custom delay
         delay(500)
         paint(Color(Random.nextInt()))
     }
@@ -80,8 +84,8 @@ suspend fun Plotter.square(xRange: IntRange, yRange: IntRange) {
 
 private val xRange = NumericalValue<Meters>(-0.5)..NumericalValue<Meters>(0.5)
 private val yRange = NumericalValue<Meters>(-0.5)..NumericalValue<Meters>(0.5)
-private val ticksPerSecond = MutableDeviceState(250.0)
-private val step = NumericalValue<Degrees>(1.2)
+private val ticksPerSecond = MutableDeviceState(3000.0)
+private val step = NumericalValue<Degrees>(1.8)
 
 
 private data class PlotterPoint(
@@ -106,13 +110,13 @@ suspend fun main() = application {
             /* Here goes the device definition block */
 
 
-            val xScrewDrive = ScrewDrive(context, NumericalValue(0.01))
             val xDrive = StepDrive(context, ticksPerSecond)
-            val x: RangeState<NumericalValue<Meters>> = xScrewDrive.transformOffset(xDrive.angle(step)).coerceIn(xRange)
+            val xTransmission = ScrewDrive(context, NumericalValue(0.01))
+            val x = xTransmission.degreesToMeters(xDrive.angle(step)).coerceIn(xRange)
 
-            val yScrewDrive = ScrewDrive(context, NumericalValue(0.01))
             val yDrive = StepDrive(context, ticksPerSecond)
-            val y: RangeState<NumericalValue<Meters>> = yScrewDrive.transformOffset(yDrive.angle(step)).coerceIn(yRange)
+            val yTransmission = ScrewDrive(context, NumericalValue(0.01))
+            val y = yTransmission.degreesToMeters(yDrive.angle(step)).coerceIn(yRange)
 
             val plotter = Plotter(context, xDrive, yDrive) { color ->
                 println("Point X: ${x.value.value}, Y: ${y.value.value}, color: $color")
@@ -134,10 +138,12 @@ suspend fun main() = application {
                 }
             }
 
+            /* run program */
+
             launch {
-                val range = -100..100
-                plotter.modernArt(range, range)
-                //plotter.square(range, range)
+                val range = -1000..1000
+//                plotter.modernArt(range, range)
+                plotter.square(range, range)
             }
         }
 

From 9f21a14f96fae6588f4802903ca8dfb829db4fc7 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 3 Jun 2024 18:02:57 +0300
Subject: [PATCH 097/125] Fix ball on springs demo

---
 .../controls/constructor/models/Inertia.kt    |   5 +-
 .../constructor/models/MaterialPoint.kt       |  42 ++++++
 .../constructor/models/coordinates.kt         |   8 --
 .../constructor/units/NumericalValue.kt       |   2 +
 .../controls/constructor/units/coordinates.kt |  36 +++++
 .../src/jvmMain/kotlin/BodyOnSprings.kt       | 134 ++++++------------
 6 files changed, 122 insertions(+), 105 deletions(-)
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt
 delete mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/coordinates.kt
 create mode 100644 controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
index 88a377e..1c6a507 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
@@ -4,8 +4,6 @@ import space.kscience.controls.constructor.*
 import space.kscience.controls.constructor.units.*
 import space.kscience.dataforge.context.Context
 import kotlin.math.pow
-import kotlin.time.Duration
-import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.DurationUnit
 
 /**
@@ -17,7 +15,6 @@ public class Inertia<U : UnitsOfMeasurement, V : UnitsOfMeasurement>(
     inertia: Double,
     public val position: MutableDeviceState<NumericalValue<U>>,
     public val velocity: MutableDeviceState<NumericalValue<V>>,
-    timerPrecision: Duration = 10.milliseconds,
 ) : ModelConstructor(context) {
 
     init {
@@ -27,7 +24,7 @@ public class Inertia<U : UnitsOfMeasurement, V : UnitsOfMeasurement>(
 
     private var currentForce = force.value
 
-    private val movement = onTimer (timerPrecision) { prev, next ->
+    private val movement = onTimer { prev, next ->
         val dtSeconds = (next - prev).toDouble(DurationUnit.SECONDS)
 
         // compute new value based on velocity and acceleration from the previous step
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt
new file mode 100644
index 0000000..c03841c
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt
@@ -0,0 +1,42 @@
+package space.kscience.controls.constructor.models
+
+import space.kscience.controls.constructor.*
+import space.kscience.controls.constructor.units.*
+import space.kscience.dataforge.context.Context
+import kotlin.math.pow
+import kotlin.time.DurationUnit
+
+
+/**
+ * 3D material point
+ */
+public class MaterialPoint(
+    context: Context,
+    force: DeviceState<XYZ<Newtons>>,
+    mass: NumericalValue<Kilograms>,
+    public val position: MutableDeviceState<XYZ<Meters>>,
+    public val velocity: MutableDeviceState<XYZ<MetersPerSecond>>,
+) : ModelConstructor(context) {
+
+    init {
+        registerState(position)
+        registerState(velocity)
+    }
+
+    private var currentForce = force.value
+
+    private val movement = onTimer(
+        reads = setOf(velocity, position),
+        writes = setOf(velocity, position)
+    ) { prev, next ->
+        val dtSeconds = (next - prev).toDouble(DurationUnit.SECONDS)
+
+        // compute new value based on velocity and acceleration from the previous step
+        position.value += (velocity.value * dtSeconds).cast(Meters) +
+                (currentForce / mass.value * dtSeconds.pow(2) / 2).cast(Meters)
+
+        // compute new velocity based on acceleration on the previous step
+        velocity.value += (currentForce / mass.value * dtSeconds).cast(MetersPerSecond)
+        currentForce = force.value
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/coordinates.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/coordinates.kt
deleted file mode 100644
index 50db92b..0000000
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/coordinates.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package space.kscience.controls.constructor.models
-
-import space.kscience.controls.constructor.units.NumericalValue
-import space.kscience.controls.constructor.units.UnitsOfMeasurement
-
-public data class XY<U: UnitsOfMeasurement>(val x: NumericalValue<U>, val y: NumericalValue<U>)
-
-public data class XYZ<U: UnitsOfMeasurement>(val x: NumericalValue<U>, val y: NumericalValue<U>, val z: NumericalValue<U>)
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
index e503623..8e77001 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
@@ -46,6 +46,8 @@ public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.div(
 public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.div(other: NumericalValue<U>): Double =
     value / other.value
 
+public operator fun <U: UnitsOfMeasurement> NumericalValue<U>.unaryMinus(): NumericalValue<U> = NumericalValue(-value)
+
 
 private object NumericalValueMetaConverter : MetaConverter<NumericalValue<*>> {
     override fun convert(obj: NumericalValue<*>): Meta = Meta(obj.value)
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt
new file mode 100644
index 0000000..88d7bd6
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt
@@ -0,0 +1,36 @@
+package space.kscience.controls.constructor.units
+
+public data class XY<U : UnitsOfMeasurement>(val x: NumericalValue<U>, val y: NumericalValue<U>)
+
+public fun <U : UnitsOfMeasurement> XY(x: Number, y: Number): XY<U> = XY(NumericalValue(x), NumericalValue((y)))
+
+public operator fun <U : UnitsOfMeasurement> XY<U>.plus(other: XY<U>): XY<U> =
+    XY(x + other.x, y + other.y)
+
+public operator fun <U : UnitsOfMeasurement> XY<U>.times(c: Number): XY<U> = XY(x * c, y * c)
+public operator fun <U : UnitsOfMeasurement> XY<U>.div(c: Number): XY<U> = XY(x / c, y / c)
+
+public operator fun <U : UnitsOfMeasurement> XY<U>.unaryMinus(): XY<U> = XY(-x, -y)
+
+public data class XYZ<U : UnitsOfMeasurement>(
+    val x: NumericalValue<U>,
+    val y: NumericalValue<U>,
+    val z: NumericalValue<U>,
+)
+
+public fun <U : UnitsOfMeasurement> XYZ(x: Number, y: Number, z: Number): XYZ<U> =
+    XYZ(NumericalValue(x), NumericalValue((y)), NumericalValue(z))
+
+@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER")
+public fun <U : UnitsOfMeasurement, R : UnitsOfMeasurement> XYZ<U>.cast(units: R): XYZ<R> = this as XYZ<R>
+
+public operator fun <U : UnitsOfMeasurement> XYZ<U>.plus(other: XYZ<U>): XYZ<U> =
+    XYZ(x + other.x, y + other.y, z + other.z)
+
+public operator fun <U : UnitsOfMeasurement> XYZ<U>.minus(other: XYZ<U>): XYZ<U> =
+    XYZ(x - other.x, y - other.y, z - other.z)
+
+public operator fun <U : UnitsOfMeasurement> XYZ<U>.times(c: Number): XYZ<U> = XYZ(x * c, y * c, z * c)
+public operator fun <U : UnitsOfMeasurement> XYZ<U>.div(c: Number): XYZ<U> = XYZ(x / c, y / c, z / c)
+
+public operator fun <U : UnitsOfMeasurement> XYZ<U>.unaryMinus(): XYZ<U> = XYZ(-x, -y, -z)
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
index e4de468..e937d1b 100644
--- a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
+++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
@@ -1,105 +1,50 @@
 package space.kscience.controls.demo.constructor
 
 import androidx.compose.foundation.Canvas
-import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.size
 import androidx.compose.material.MaterialTheme
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.remember
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.Window
 import androidx.compose.ui.window.application
-import kotlinx.serialization.Serializable
 import space.kscience.controls.compose.asComposeState
 import space.kscience.controls.constructor.*
+import space.kscience.controls.constructor.models.MaterialPoint
+import space.kscience.controls.constructor.units.*
 import space.kscience.dataforge.context.Context
+import java.awt.Dimension
 import kotlin.math.pow
 import kotlin.math.sqrt
-import kotlin.time.Duration.Companion.milliseconds
-import kotlin.time.DurationUnit
 
-@Serializable
-private data class XY(val x: Double, val y: Double) {
-    companion object {
-        val ZERO = XY(0.0, 0.0)
-    }
-}
-
-private val XY.length: Double get() = sqrt(x.pow(2) + y.pow(2))
-
-private operator fun XY.plus(other: XY): XY = XY(x + other.x, y + other.y)
-private operator fun XY.times(c: Double): XY = XY(x * c, y * c)
-private operator fun XY.div(c: Double): XY = XY(x / c, y / c)
 
 private class Spring(
     context: Context,
     val k: Double,
-    val l0: Double,
-    val begin: DeviceState<XY>,
-    val end: DeviceState<XY>,
+    val l0: NumericalValue<Meters>,
+    val begin: DeviceState<XYZ<Meters>>,
+    val end: DeviceState<XYZ<Meters>>,
 ) : ModelConstructor(context) {
 
     /**
-     * vector from start to end
+     * Tension at the beginning point
      */
-    val direction = combineState(begin, end) { begin: XY, end: XY ->
-        val dx = end.x - begin.x
-        val dy = end.y - begin.y
-        val l = sqrt(dx.pow(2) + dy.pow(2))
-        XY(dx / l, dy / l)
-    }
-
-    val tension: DeviceState<Double> = combineState(begin, end) { begin: XY, end: XY ->
-        val dx = end.x - begin.x
-        val dy = end.y - begin.y
-        k * sqrt(dx.pow(2) + dy.pow(2))
-    }
-
-
-    val beginForce = combineState(direction, tension) { direction: XY, tension: Double ->
-        direction * (tension)
-    }
-
-
-    val endForce = combineState(direction, tension) { direction: XY, tension: Double ->
-        direction * (-tension)
-    }
-}
-
-private class MaterialPoint(
-    context: Context,
-    val mass: Double,
-    val force: DeviceState<XY>,
-    val position: MutableDeviceState<XY>,
-    val velocity: MutableDeviceState<XY> = MutableDeviceState(XY.ZERO),
-) : ModelConstructor(context, force, position, velocity) {
-
-    private val timer: TimerState = timer(2.milliseconds)
-
-    //TODO synchronize force change
-
-    private val movement = timer.onChange(
-        writes = setOf(position, velocity),
-        reads = setOf(force, velocity, position)
-    ) { prev, next ->
-        val dt = (next - prev).toDouble(DurationUnit.SECONDS)
-        val a = force.value / mass
-        position.value += a * (dt * dt / 2) + velocity.value * dt
-        velocity.value += a * dt
+    val tension: DeviceState<XYZ<Newtons>> = combineState(begin, end) { begin: XYZ<Meters>, end: XYZ<Meters> ->
+        val delta = end - begin
+        val l = sqrt(delta.x.value.pow(2) + delta.y.value.pow(2) + delta.z.value.pow(2))
+        ((delta / l) * k * (l - l0.value)).cast(Newtons)
     }
 }
 
 
 private class BodyOnSprings(
     context: Context,
-    mass: Double,
+    mass: NumericalValue<Kilograms>,
     k: Double,
-    startPosition: XY,
-    l0: Double = 1.0,
+    startPosition: XYZ<Meters>,
+    l0: NumericalValue<Meters> = NumericalValue(1.0),
     val xLeft: Double = -1.0,
     val xRight: Double = 1.0,
     val yBottom: Double = -1.0,
@@ -110,22 +55,24 @@ private class BodyOnSprings(
     val height = yTop - yBottom
 
     val position = stateOf(startPosition)
+    val velocity: MutableDeviceState<XYZ<MetersPerSecond>> = stateOf(XYZ(0, 0, 0))
 
-    private val leftAnchor = stateOf(XY(xLeft, (yTop + yBottom) / 2))
+    private val leftAnchor = stateOf(XYZ<Meters>(xLeft, (yTop + yBottom) / 2, 0.0))
 
     val leftSpring = model(
         Spring(context, k, l0, leftAnchor, position)
     )
 
-    private val rightAnchor = stateOf(XY(xRight, (yTop + yBottom) / 2))
+    private val rightAnchor = stateOf(XYZ<Meters>(xRight, (yTop + yBottom) / 2, 0.0))
 
     val rightSpring = model(
         Spring(context, k, l0, rightAnchor, position)
     )
 
-    val force: DeviceState<XY> = combineState(leftSpring.endForce, rightSpring.endForce) { left, right ->
-        left + right
-    }
+    val force: DeviceState<XYZ<Newtons>> =
+        combineState(leftSpring.tension, rightSpring.tension) { left: XYZ<Newtons>, right ->
+            -left - right
+        }
 
 
     val body = model(
@@ -134,21 +81,23 @@ private class BodyOnSprings(
             mass = mass,
             force = force,
             position = position,
+            velocity = velocity
         )
     )
 }
 
 fun main() = application {
-    val initialState = XY(0.1, 0.2)
+    val initialState = XYZ<Meters>(0.01, 0.1, 0)
 
     Window(title = "Ball on springs", onCloseRequest = ::exitApplication) {
+        window.minimumSize = Dimension(400, 400)
         MaterialTheme {
             val context = remember {
                 Context("simulation")
             }
 
             val model = remember {
-                BodyOnSprings(context, 100.0, 1000.0, initialState)
+                BodyOnSprings(context, NumericalValue(10.0), 100.0, initialState)
             }
 
             //TODO add ability to freeze model
@@ -159,24 +108,23 @@ fun main() = application {
 //                }.collect()
 //            }
 
-            val position: XY by model.body.position.asComposeState()
-            Box(Modifier.size(400.dp)) {
-                Canvas(modifier = Modifier.fillMaxSize()) {
-                    fun XY.toOffset() = Offset(
-                        center.x + (x / model.width * size.width).toFloat(),
-                        center.y - (y / model.height * size.height).toFloat()
-                    )
+            val position: XYZ<Meters> by model.body.position.asComposeState()
+            Canvas(modifier = Modifier.fillMaxSize()) {
+                fun XYZ<Meters>.toOffset() = Offset(
+                    ((x.value - model.xLeft) / model.width * size.width).toFloat(),
+                    ((y.value - model.yBottom) / model.height * size.height).toFloat()
 
-                    drawCircle(
-                        Color.Red, 10f, center = position.toOffset()
-                    )
-                    drawLine(Color.Blue, model.leftSpring.begin.value.toOffset(), model.leftSpring.end.value.toOffset())
-                    drawLine(
-                        Color.Blue,
-                        model.rightSpring.begin.value.toOffset(),
-                        model.rightSpring.end.value.toOffset()
-                    )
-                }
+                )
+
+                drawCircle(
+                    Color.Red, 10f, center = position.toOffset()
+                )
+                drawLine(Color.Blue, model.leftSpring.begin.value.toOffset(), model.leftSpring.end.value.toOffset())
+                drawLine(
+                    Color.Blue,
+                    model.rightSpring.begin.value.toOffset(),
+                    model.rightSpring.end.value.toOffset()
+                )
             }
         }
     }

From 1799a9a9096b945d9354000234a517e9b41790fb Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 3 Jun 2024 18:34:08 +0300
Subject: [PATCH 098/125] Fix ball on springs demo

---
 .../controls/constructor/models/MaterialPoint.kt       | 10 ++++++++--
 .../kscience/controls/constructor/units/coordinates.kt |  8 ++++++++
 demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt   |  6 ++----
 3 files changed, 18 insertions(+), 6 deletions(-)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt
index c03841c..cc44a85 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt
@@ -26,17 +26,23 @@ public class MaterialPoint(
     private var currentForce = force.value
 
     private val movement = onTimer(
+        DefaultTimer.REALTIME,
         reads = setOf(velocity, position),
         writes = setOf(velocity, position)
     ) { prev, next ->
         val dtSeconds = (next - prev).toDouble(DurationUnit.SECONDS)
 
         // compute new value based on velocity and acceleration from the previous step
-        position.value += (velocity.value * dtSeconds).cast(Meters) +
+        val deltaR = (velocity.value * dtSeconds).cast(Meters) +
                 (currentForce / mass.value * dtSeconds.pow(2) / 2).cast(Meters)
+        position.value += deltaR
 
         // compute new velocity based on acceleration on the previous step
-        velocity.value += (currentForce / mass.value * dtSeconds).cast(MetersPerSecond)
+        val deltaV = (currentForce / mass.value * dtSeconds).cast(MetersPerSecond)
+        //TODO apply energy correction
+        //val work = deltaR.length.value * currentForce.length.value
+        velocity.value += deltaV
+
         currentForce = force.value
     }
 }
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt
index 88d7bd6..027dbbb 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt
@@ -1,5 +1,8 @@
 package space.kscience.controls.constructor.units
 
+import kotlin.math.pow
+import kotlin.math.sqrt
+
 public data class XY<U : UnitsOfMeasurement>(val x: NumericalValue<U>, val y: NumericalValue<U>)
 
 public fun <U : UnitsOfMeasurement> XY(x: Number, y: Number): XY<U> = XY(NumericalValue(x), NumericalValue((y)))
@@ -18,6 +21,11 @@ public data class XYZ<U : UnitsOfMeasurement>(
     val z: NumericalValue<U>,
 )
 
+public val <U : UnitsOfMeasurement> XYZ<U>.length: NumericalValue<U>
+    get() = NumericalValue(
+        sqrt(x.value.pow(2) + y.value.pow(2) + z.value.pow(2))
+    )
+
 public fun <U : UnitsOfMeasurement> XYZ(x: Number, y: Number, z: Number): XYZ<U> =
     XYZ(NumericalValue(x), NumericalValue((y)), NumericalValue(z))
 
diff --git a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
index e937d1b..57bc426 100644
--- a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
+++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
@@ -16,8 +16,6 @@ import space.kscience.controls.constructor.models.MaterialPoint
 import space.kscience.controls.constructor.units.*
 import space.kscience.dataforge.context.Context
 import java.awt.Dimension
-import kotlin.math.pow
-import kotlin.math.sqrt
 
 
 private class Spring(
@@ -33,7 +31,7 @@ private class Spring(
      */
     val tension: DeviceState<XYZ<Newtons>> = combineState(begin, end) { begin: XYZ<Meters>, end: XYZ<Meters> ->
         val delta = end - begin
-        val l = sqrt(delta.x.value.pow(2) + delta.y.value.pow(2) + delta.z.value.pow(2))
+        val l = delta.length.value
         ((delta / l) * k * (l - l0.value)).cast(Newtons)
     }
 }
@@ -87,7 +85,7 @@ private class BodyOnSprings(
 }
 
 fun main() = application {
-    val initialState = XYZ<Meters>(0.01, 0.1, 0)
+    val initialState = XYZ<Meters>(0, 0.4, 0)
 
     Window(title = "Ball on springs", onCloseRequest = ::exitApplication) {
         window.minimumSize = Dimension(400, 400)

From 91f860adf61a3d0ee49372a99c3dc385a30bed27 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 3 Jun 2024 18:48:12 +0300
Subject: [PATCH 099/125] Move plotter model out of visualization

---
 .../constructor/src/jvmMain/kotlin/Plotter.kt | 79 ++++++++++---------
 1 file changed, 40 insertions(+), 39 deletions(-)

diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
index b605dd4..af2c4dd 100644
--- a/demo/constructor/src/jvmMain/kotlin/Plotter.kt
+++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
@@ -11,11 +11,10 @@ import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.window.Window
 import androidx.compose.ui.window.application
 import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import space.kscience.controls.constructor.DeviceConstructor
-import space.kscience.controls.constructor.MutableDeviceState
-import space.kscience.controls.constructor.device
+import space.kscience.controls.constructor.*
 import space.kscience.controls.constructor.devices.StepDrive
 import space.kscience.controls.constructor.devices.angle
 import space.kscience.controls.constructor.models.ScrewDrive
@@ -28,7 +27,7 @@ import java.awt.Dimension
 import kotlin.random.Random
 
 
-class Plotter(
+private class Plotter(
     context: Context,
     xDrive: StepDrive,
     yDrive: StepDrive,
@@ -42,16 +41,16 @@ class Plotter(
         yDrive.target.value = y.toLong()
     }
 
-//    val position = combineState(xDrive.position, yDrive.position) { x, y ->
-//        space.kscience.controls.constructor.models.XY(x, y)
-//    }
+    val ticks = combineState(xDrive.position, yDrive.position) { x, y ->
+        x to y
+    }
 
     //TODO add calibration
 
     // TODO add draw as action
 }
 
-suspend fun Plotter.modernArt(xRange: IntRange, yRange: IntRange) {
+private suspend fun Plotter.modernArt(xRange: IntRange, yRange: IntRange) {
     while (isActive) {
         val randomX = Random.nextInt(xRange.first, xRange.last)
         val randomY = Random.nextInt(yRange.first, yRange.last)
@@ -62,7 +61,7 @@ suspend fun Plotter.modernArt(xRange: IntRange, yRange: IntRange) {
     }
 }
 
-suspend fun Plotter.square(xRange: IntRange, yRange: IntRange) {
+private suspend fun Plotter.square(xRange: IntRange, yRange: IntRange) {
     while (isActive) {
         moveToXY(xRange.first, yRange.first)
         delay(1000)
@@ -94,12 +93,33 @@ private data class PlotterPoint(
     val color: Color = Color.Black,
 )
 
+private class PlotterModel(
+    context: Context,
+    val callback: (PlotterPoint) -> Unit,
+) : ModelConstructor(context) {
+
+    private val xDrive = StepDrive(context, ticksPerSecond)
+    private val xTransmission = ScrewDrive(context, NumericalValue(0.01))
+    val x = xTransmission.degreesToMeters(xDrive.angle(step)).coerceIn(xRange)
+
+    private val yDrive = StepDrive(context, ticksPerSecond)
+    private val yTransmission = ScrewDrive(context, NumericalValue(0.01))
+    val y = yTransmission.degreesToMeters(yDrive.angle(step)).coerceIn(yRange)
+
+    val xy: DeviceState<XY<Meters>> = combineState(x, y) { x, y -> XY(x, y) }
+
+    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))
+    }
+}
+
 suspend fun main() = application {
     Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
         window.minimumSize = Dimension(400, 400)
 
         val points = remember { mutableStateListOf<PlotterPoint>() }
-        var position by remember { mutableStateOf(PlotterPoint(NumericalValue(0), NumericalValue(0))) }
+        var position by remember { mutableStateOf(XY<Meters>(0, 0)) }
 
         LaunchedEffect(Unit) {
             val context = Context {
@@ -109,42 +129,23 @@ suspend fun main() = application {
 
             /* Here goes the device definition block */
 
-
-            val xDrive = StepDrive(context, ticksPerSecond)
-            val xTransmission = ScrewDrive(context, NumericalValue(0.01))
-            val x = xTransmission.degreesToMeters(xDrive.angle(step)).coerceIn(xRange)
-
-            val yDrive = StepDrive(context, ticksPerSecond)
-            val yTransmission = ScrewDrive(context, NumericalValue(0.01))
-            val y = yTransmission.degreesToMeters(yDrive.angle(step)).coerceIn(yRange)
-
-            val plotter = Plotter(context, xDrive, yDrive) { color ->
-                println("Point X: ${x.value.value}, Y: ${y.value.value}, color: $color")
-                points.add(PlotterPoint(x.value, y.value, color))
+            val plotterModel = PlotterModel(context) { plotterPoint ->
+                points.add(plotterPoint)
             }
 
-
             /* Start visualization program */
 
-            launch {
-                x.valueFlow.collect {
-                    position = position.copy(x = it)
-                }
-            }
-
-            launch {
-                y.valueFlow.collect {
-                    position = position.copy(y = it)
-                }
-            }
+            plotterModel.xy.valueFlow.onEach {
+                position = it
+            }.launchIn(this)
 
             /* run program */
 
-            launch {
-                val range = -1000..1000
+
+            val range = -1000..1000
 //                plotter.modernArt(range, range)
-                plotter.square(range, range)
-            }
+            plotterModel.plotter.square(range, range)
+
         }
 
 

From c63c2db6517ef05a4fe6e22700f6fcbc0991de91 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 5 Jun 2024 17:19:20 +0300
Subject: [PATCH 100/125] Fix PID demo

---
 .gitignore                                    |  1 +
 .../controls/constructor/models/Inertia.kt    |  2 +-
 .../models/{ScrewDrive.kt => Leadscrew.kt}    | 13 +++--
 .../commonMain/kotlin/ControlVisionPlugin.kt  | 10 +++-
 .../commonMain/kotlin/koalaPlotExtensions.kt  |  2 +-
 .../jsMain/kotlin/ControlsVisionPlugin.js.kt  |  8 ++--
 .../kotlin/ControlsVisionPlugin.jvm.kt        |  8 ++--
 .../build.gradle.kts                          |  3 +-
 .../src/commonMain/kotlin/NumberTextField.kt  |  8 ++--
 demo/all-things/build.gradle.kts              |  3 +-
 demo/constructor/build.gradle.kts             |  3 +-
 .../src/jvmMain/kotlin/BodyOnSprings.kt       |  2 +-
 .../constructor/src/jvmMain/kotlin/PidDemo.kt | 47 +++++++++----------
 .../constructor/src/jvmMain/kotlin/Plotter.kt |  8 ++--
 demo/devices-on-map/build.gradle.kts          | 41 ++++++++++++++++
 .../devices-on-map/src/jvmMain/kotlin/main.kt |  8 ++++
 demo/motors/build.gradle.kts                  |  3 +-
 gradle.properties                             |  2 +-
 gradle/libs.versions.toml                     |  2 +
 settings.gradle.kts                           |  3 +-
 20 files changed, 121 insertions(+), 56 deletions(-)
 rename controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/{ScrewDrive.kt => Leadscrew.kt} (72%)
 create mode 100644 demo/devices-on-map/build.gradle.kts
 create mode 100644 demo/devices-on-map/src/jvmMain/kotlin/main.kt

diff --git a/.gitignore b/.gitignore
index 3bf252e..e688053 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 # Created by .ignore support plugin (hsz.mobi)
 .idea/
 .gradle
+.kotlin
 
 *.iws
 *.iml
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
index 1c6a507..1259151 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
@@ -24,7 +24,7 @@ public class Inertia<U : UnitsOfMeasurement, V : UnitsOfMeasurement>(
 
     private var currentForce = force.value
 
-    private val movement = onTimer { prev, next ->
+    private val movement = onTimer(DefaultTimer.REALTIME) { prev, next ->
         val dtSeconds = (next - prev).toDouble(DurationUnit.SECONDS)
 
         // compute new value based on velocity and acceleration from the previous step
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Leadscrew.kt
similarity index 72%
rename from controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
rename to controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Leadscrew.kt
index 573a648..ebe0d30 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/ScrewDrive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Leadscrew.kt
@@ -7,22 +7,25 @@ import space.kscience.controls.constructor.units.*
 import space.kscience.dataforge.context.Context
 import kotlin.math.PI
 
-public class ScrewDrive(
+/**
+ * https://en.wikipedia.org/wiki/Leadscrew
+ */
+public class Leadscrew(
     context: Context,
     public val leverage: NumericalValue<Meters>,
 ) : ModelConstructor(context) {
 
     public fun torqueToForce(
-        stateOfMomentum: DeviceState<NumericalValue<NewtonsMeters>>,
-    ): DeviceState<NumericalValue<Newtons>> = DeviceState.map(stateOfMomentum) { momentum ->
-        NumericalValue(momentum.value / leverage.value )
+        stateOfTorque: DeviceState<NumericalValue<NewtonsMeters>>,
+    ): DeviceState<NumericalValue<Newtons>> = DeviceState.map(stateOfTorque) { torque ->
+        NumericalValue(torque.value / leverage.value )
     }
 
     public fun degreesToMeters(
         stateOfAngle: DeviceState<NumericalValue<Degrees>>,
         offset: NumericalValue<Meters> = NumericalValue(0),
     ): DeviceState<NumericalValue<Meters>> = DeviceState.map(stateOfAngle) { degrees ->
-        offset + NumericalValue(degrees.value * 2 * PI / 360 *leverage.value )
+        offset + NumericalValue(degrees.value * 2 * PI / 360 * leverage.value )
     }
 
 }
\ No newline at end of file
diff --git a/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
index d9ec746..b51d15c 100644
--- a/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
+++ b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
@@ -3,12 +3,20 @@ package space.kscience.controls.vision
 import kotlinx.serialization.modules.SerializersModule
 import kotlinx.serialization.modules.polymorphic
 import kotlinx.serialization.modules.subclass
+import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.PluginFactory
+import space.kscience.dataforge.context.PluginTag
+import space.kscience.dataforge.meta.Meta
 import space.kscience.visionforge.Vision
 import space.kscience.visionforge.VisionPlugin
 
 public expect class ControlVisionPlugin: VisionPlugin{
-    public companion object: PluginFactory<ControlVisionPlugin>
+    override val tag: PluginTag
+    override val visionSerializersModule: SerializersModule
+    public companion object: PluginFactory<ControlVisionPlugin>{
+        override val tag: PluginTag
+        override fun build(context: Context, meta: Meta): ControlVisionPlugin
+    }
 }
 
 internal val controlsVisionSerializersModule = SerializersModule {
diff --git a/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt b/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt
index 8eeb9a4..8be10df 100644
--- a/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt
+++ b/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt
@@ -158,7 +158,7 @@ public fun <T> Plot.plotDeviceState(
 
 public fun Plot.plotNumberState(
     context: Context,
-    state: DeviceState<out Number>,
+    state: DeviceState<Number>,
     maxAge: Duration = defaultMaxAge,
     maxPoints: Int = defaultMaxPoints,
     minPoints: Int = defaultMinPoints,
diff --git a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
index b966791..f75630b 100644
--- a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
+++ b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
@@ -43,9 +43,9 @@ private val sliderRenderer = ElementVisionRenderer<SliderVision> { name, vision:
 
 
 public actual class ControlVisionPlugin : VisionPlugin() {
-    override val tag: PluginTag get() = Companion.tag
+    actual override val tag: PluginTag get() = Companion.tag
 
-    override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule
+    actual override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule
 
     override fun content(target: String): Map<Name, Any> = when (target) {
         ElementVisionRenderer.TYPE -> mapOf(
@@ -57,9 +57,9 @@ public actual class ControlVisionPlugin : VisionPlugin() {
     }
 
     public actual companion object : PluginFactory<ControlVisionPlugin> {
-        override val tag: PluginTag = PluginTag("controls.vision")
+        actual override val tag: PluginTag = PluginTag("controls.vision")
 
-        override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin()
+        actual override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin()
 
     }
 }
\ No newline at end of file
diff --git a/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt b/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt
index 55074ae..53ada2e 100644
--- a/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt
+++ b/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt
@@ -8,14 +8,14 @@ import space.kscience.dataforge.meta.Meta
 import space.kscience.visionforge.VisionPlugin
 
 public actual class ControlVisionPlugin : VisionPlugin() {
-    override val tag: PluginTag get() = Companion.tag
+    actual override val tag: PluginTag get() = Companion.tag
 
-    override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule
+    actual override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule
 
     public actual companion object : PluginFactory<ControlVisionPlugin> {
-        override val tag: PluginTag = PluginTag("controls.vision")
+        actual override val tag: PluginTag = PluginTag("controls.vision")
 
-        override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin()
+        actual override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin()
 
     }
 }
\ No newline at end of file
diff --git a/controls-visualisation-compose/build.gradle.kts b/controls-visualisation-compose/build.gradle.kts
index 48ae9cd..d9d8215 100644
--- a/controls-visualisation-compose/build.gradle.kts
+++ b/controls-visualisation-compose/build.gradle.kts
@@ -2,7 +2,8 @@ import org.jetbrains.compose.ExperimentalComposeLibrary
 
 plugins {
     id("space.kscience.gradle.mpp")
-    alias(spclibs.plugins.compose)
+    alias(spclibs.plugins.compose.compiler)
+    alias(spclibs.plugins.compose.jb)
     `maven-publish`
 }
 
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt b/controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt
index 68e61ca..3ab10c6 100644
--- a/controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt
+++ b/controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt
@@ -2,8 +2,8 @@ package space.kscience.controls.compose
 
 import androidx.compose.foundation.layout.Row
 import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.KeyboardArrowLeft
-import androidx.compose.material.icons.filled.KeyboardArrowRight
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
 import androidx.compose.material3.*
 import androidx.compose.runtime.*
 import androidx.compose.ui.Alignment
@@ -30,7 +30,7 @@ public fun NumberTextField(
     Row (verticalAlignment = Alignment.CenterVertically, modifier = modifier) {
         step.takeIf { it > 0.0 }?.let {
             IconButton({ onValueChange(value.toDouble() - step) }, enabled = enabled) {
-                Icon(Icons.Default.KeyboardArrowLeft, "decrease value")
+                Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "decrease value")
             }
         }
         TextField(
@@ -52,7 +52,7 @@ public fun NumberTextField(
         )
         step.takeIf { it > 0.0 }?.let {
             IconButton({ onValueChange(value.toDouble() + step) }, enabled = enabled) {
-                Icon(Icons.Default.KeyboardArrowRight, "increase value")
+                Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "increase value")
             }
         }
     }
diff --git a/demo/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts
index 2abde80..077e55d 100644
--- a/demo/all-things/build.gradle.kts
+++ b/demo/all-things/build.gradle.kts
@@ -1,6 +1,7 @@
 plugins {
     kotlin("jvm")
-    alias(spclibs.plugins.compose)
+    alias(spclibs.plugins.compose.compiler)
+    alias(spclibs.plugins.compose.jb)
 }
 
 
diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
index 6b6f461..6d6d7f0 100644
--- a/demo/constructor/build.gradle.kts
+++ b/demo/constructor/build.gradle.kts
@@ -3,7 +3,8 @@ import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
 
 plugins {
     id("space.kscience.gradle.mpp")
-    alias(spclibs.plugins.compose)
+    alias(spclibs.plugins.compose.compiler)
+    alias(spclibs.plugins.compose.jb)
 }
 
 kscience {
diff --git a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
index 57bc426..af6da52 100644
--- a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
+++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
@@ -85,7 +85,7 @@ private class BodyOnSprings(
 }
 
 fun main() = application {
-    val initialState = XYZ<Meters>(0, 0.4, 0)
+    val initialState = XYZ<Meters>(0.05, 0.4, 0)
 
     Window(title = "Ball on springs", onCloseRequest = ::exitApplication) {
         window.minimumSize = Dimension(400, 400)
diff --git a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
index b19e179..81a5923 100644
--- a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
+++ b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
@@ -32,10 +32,10 @@ import space.kscience.controls.constructor.devices.Drive
 import space.kscience.controls.constructor.devices.LimitSwitch
 import space.kscience.controls.constructor.devices.LinearDrive
 import space.kscience.controls.constructor.models.Inertia
+import space.kscience.controls.constructor.models.Leadscrew
 import space.kscience.controls.constructor.models.MutableRangeState
 import space.kscience.controls.constructor.models.PidParameters
-import space.kscience.controls.constructor.models.ScrewDrive
-import space.kscience.controls.constructor.timer
+import space.kscience.controls.constructor.onTimer
 import space.kscience.controls.constructor.units.Kilograms
 import space.kscience.controls.constructor.units.Meters
 import space.kscience.controls.constructor.units.NumericalValue
@@ -56,13 +56,13 @@ import kotlin.time.DurationUnit
 class Modulator(
     context: Context,
     target: MutableDeviceState<NumericalValue<Meters>>,
-    var freq: Double = 0.1,
     var timeStep: Duration = 5.milliseconds,
+    var freq: Double = 0.1,
 ) : DeviceConstructor(context) {
     private val clockStart = clock.now()
 
-    private val modulation = timer(10.milliseconds).onNext {
-        val timeFromStart = clock.now() - clockStart
+    private val modulation = onTimer(timeStep) { _, next ->
+        val timeFromStart = next - clockStart
         val t = timeFromStart.toDouble(DurationUnit.SECONDS)
         target.value = NumericalValue(
             5 * sin(2.0 * PI * freq * t) +
@@ -72,9 +72,9 @@ class Modulator(
 }
 
 
-private val mass = NumericalValue<Kilograms>(0.1)
+private val mass = NumericalValue<Kilograms>(1)
 
-private val leverage = NumericalValue<Meters>(0.05)
+private val leverage = NumericalValue<Meters>(1.0)
 
 private val maxAge = 10.seconds
 
@@ -95,7 +95,7 @@ internal fun createLinearDriveModel(
     val drive = Drive(context)
 
     //a screw drive to convert a rotational moment into a force
-    val screwDrive = ScrewDrive(context, leverage)
+    val leadscrew = Leadscrew(context, leverage)
 
 
     /**
@@ -107,7 +107,7 @@ internal fun createLinearDriveModel(
      */
     val inertiaModel = Inertia.linear(
         context = context,
-        force = screwDrive.torqueToForce(drive.force),
+        force = leadscrew.torqueToForce(drive.force),
         mass = mass,
         position = position
     )
@@ -130,6 +130,8 @@ private fun createModulator(linearDrive: LinearDrive): Modulator = linearDrive.c
     Modulator(linearDrive.context, linearDrive.pid.target)
 )
 
+private val startPid = PidParameters(kp = 250.0, ki = 0.0, kd = -20.0, timeStep = 20.milliseconds)
+
 @OptIn(ExperimentalSplitPaneApi::class, ExperimentalKoalaPlotApi::class)
 fun main() = application {
     val context = remember {
@@ -140,7 +142,7 @@ fun main() = application {
     }
 
     var pidParameters by remember {
-        mutableStateOf(PidParameters(kp = 900.0, ki = 20.0, kd = -50.0, timeStep = 0.005.seconds))
+        mutableStateOf(startPid)
     }
 
     val linearDrive: LinearDrive = remember {
@@ -183,14 +185,14 @@ fun main() = application {
                             NumberTextField(
                                 value = pidParameters.kp,
                                 onValueChange = { pidParameters = pidParameters.copy(kp = it.toDouble()) },
-                                formatter = { String.format("%.2f", it.toDouble()) },
-                                step = 1.0,
+                                formatter = { String.format("%.3f", it.toDouble()) },
+                                step = 0.01,
                                 modifier = Modifier.width(200.dp),
                             )
                             Slider(
                                 pidParameters.kp.toFloat(),
                                 { pidParameters = pidParameters.copy(kp = it.toDouble()) },
-                                valueRange = 0f..1000f,
+                                valueRange = 0f..100f,
                                 steps = 100
                             )
                         }
@@ -199,15 +201,15 @@ fun main() = application {
                             NumberTextField(
                                 value = pidParameters.ki,
                                 onValueChange = { pidParameters = pidParameters.copy(ki = it.toDouble()) },
-                                formatter = { String.format("%.2f", it.toDouble()) },
-                                step = 0.1,
+                                formatter = { String.format("%.3f", it.toDouble()) },
+                                step = 0.01,
                                 modifier = Modifier.width(200.dp),
                             )
 
                             Slider(
                                 pidParameters.ki.toFloat(),
                                 { pidParameters = pidParameters.copy(ki = it.toDouble()) },
-                                valueRange = -100f..100f,
+                                valueRange = -10f..10f,
                                 steps = 100
                             )
                         }
@@ -216,15 +218,15 @@ fun main() = application {
                             NumberTextField(
                                 value = pidParameters.kd,
                                 onValueChange = { pidParameters = pidParameters.copy(kd = it.toDouble()) },
-                                formatter = { String.format("%.2f", it.toDouble()) },
-                                step = 0.1,
+                                formatter = { String.format("%.3f", it.toDouble()) },
+                                step = 0.01,
                                 modifier = Modifier.width(200.dp),
                             )
 
                             Slider(
                                 pidParameters.kd.toFloat(),
                                 { pidParameters = pidParameters.copy(kd = it.toDouble()) },
-                                valueRange = -100f..100f,
+                                valueRange = -10f..10f,
                                 steps = 100
                             )
                         }
@@ -247,12 +249,7 @@ fun main() = application {
                         }
                         Row {
                             Button({
-                                pidParameters = PidParameters(
-                                    kp = 2.5,
-                                    ki = 0.0,
-                                    kd = -0.1,
-                                    timeStep = 0.005.seconds
-                                )
+                                pidParameters = startPid
                             }) {
                                 Text("Reset")
                             }
diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
index af2c4dd..dfded59 100644
--- a/demo/constructor/src/jvmMain/kotlin/Plotter.kt
+++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
@@ -17,7 +17,7 @@ import kotlinx.coroutines.isActive
 import space.kscience.controls.constructor.*
 import space.kscience.controls.constructor.devices.StepDrive
 import space.kscience.controls.constructor.devices.angle
-import space.kscience.controls.constructor.models.ScrewDrive
+import space.kscience.controls.constructor.models.Leadscrew
 import space.kscience.controls.constructor.models.coerceIn
 import space.kscience.controls.constructor.units.*
 import space.kscience.controls.manager.ClockManager
@@ -99,11 +99,11 @@ private class PlotterModel(
 ) : ModelConstructor(context) {
 
     private val xDrive = StepDrive(context, ticksPerSecond)
-    private val xTransmission = ScrewDrive(context, NumericalValue(0.01))
+    private val xTransmission = Leadscrew(context, NumericalValue(0.01))
     val x = xTransmission.degreesToMeters(xDrive.angle(step)).coerceIn(xRange)
 
     private val yDrive = StepDrive(context, ticksPerSecond)
-    private val yTransmission = ScrewDrive(context, NumericalValue(0.01))
+    private val yTransmission = Leadscrew(context, NumericalValue(0.01))
     val y = yTransmission.degreesToMeters(yDrive.angle(step)).coerceIn(yRange)
 
     val xy: DeviceState<XY<Meters>> = combineState(x, y) { x, y -> XY(x, y) }
@@ -143,7 +143,7 @@ suspend fun main() = application {
 
 
             val range = -1000..1000
-//                plotter.modernArt(range, range)
+//            plotterModel.plotter.modernArt(range, range)
             plotterModel.plotter.square(range, range)
 
         }
diff --git a/demo/devices-on-map/build.gradle.kts b/demo/devices-on-map/build.gradle.kts
new file mode 100644
index 0000000..b81dc12
--- /dev/null
+++ b/demo/devices-on-map/build.gradle.kts
@@ -0,0 +1,41 @@
+import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode
+
+plugins {
+    id("space.kscience.gradle.mpp")
+    alias(spclibs.plugins.compose.compiler)
+    alias(spclibs.plugins.compose.jb)
+}
+
+kscience {
+    jvm()
+    useSerialization()
+    useContextReceivers()
+    commonMain {
+        implementation(projects.controlsVisualisationCompose)
+        implementation(projects.controlsConstructor)
+    }
+    jvmMain {
+//        implementation("io.ktor:ktor-server-cio")
+        implementation(spclibs.logback.classic)
+        implementation(libs.sciprog.maps.compose)
+    }
+}
+
+kotlin {
+    sourceSets {
+        jvmMain {
+            dependencies {
+                implementation(compose.desktop.currentOs)
+            }
+        }
+    }
+}
+
+kotlin.explicitApi = ExplicitApiMode.Disabled
+
+
+compose.desktop {
+    application {
+        mainClass = "space.kscience.controls.demo.map.MainKt"
+    }
+}
\ No newline at end of file
diff --git a/demo/devices-on-map/src/jvmMain/kotlin/main.kt b/demo/devices-on-map/src/jvmMain/kotlin/main.kt
new file mode 100644
index 0000000..648610e
--- /dev/null
+++ b/demo/devices-on-map/src/jvmMain/kotlin/main.kt
@@ -0,0 +1,8 @@
+package space.kscience.controls.demo.map
+
+import androidx.compose.ui.window.application
+
+
+fun main() = application {
+
+}
\ No newline at end of file
diff --git a/demo/motors/build.gradle.kts b/demo/motors/build.gradle.kts
index 9241764..774df46 100644
--- a/demo/motors/build.gradle.kts
+++ b/demo/motors/build.gradle.kts
@@ -1,6 +1,7 @@
 plugins {
     id("space.kscience.gradle.jvm")
-    alias(spclibs.plugins.compose)
+    alias(spclibs.plugins.compose.compiler)
+    alias(spclibs.plugins.compose.jb)
 }
 
 kotlin{
diff --git a/gradle.properties b/gradle.properties
index 4f937f0..99c39d0 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -7,4 +7,4 @@ org.gradle.parallel=true
 org.gradle.configureondemand=true
 org.gradle.jvmargs=-Xmx4096m
 
-toolsVersion=0.15.2-kotlin-1.9.22
\ No newline at end of file
+toolsVersion=0.15.4-kotlin-2.0.0
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 617933a..102c441 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -81,6 +81,8 @@ visionforge-markdown = { module = "space.kscience:visionforge-markdown", version
 visionforge-server = { module = "space.kscience:visionforge-server", version.ref = "visionforge" }
 visionforge-compose-html = { module = "space.kscience:visionforge-compose-html", version.ref = "visionforge" }
 
+sciprog-maps-compose = "space.kscience:maps-kt-compose:0.3.0"
+
 # Buildscript
 
 [plugins]
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 366c39e..7aebf36 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -86,5 +86,6 @@ include(
     ":demo:motors",
     ":demo:echo",
     ":demo:mks-pdr900",
-    ":demo:constructor"
+    ":demo:constructor",
+    ":demo:devices-on-map"
 )

From a2b5880da99f6718afcff5a54a7f3cd9e11db767 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Thu, 6 Jun 2024 16:54:17 +0300
Subject: [PATCH 101/125] Add device collective demo

---
 .../controls/client/RemoteDeviceConnect.kt    |   2 +-
 .../kscience/controls/client/MagixLoopTest.kt |  63 ++++++------
 .../src/commonMain/kotlin/koalaPlots.kt       |   7 +-
 .../build.gradle.kts                          |   1 +
 .../src/jvmMain/kotlin/DebounceDeviceState.kt |  20 ++++
 .../jvmMain/kotlin/DeviceCollectiveModel.kt   |  43 +++++++++
 .../src/jvmMain/kotlin/GmcVelocity.kt         |  24 +++++
 .../src/jvmMain/kotlin/RemoteDevice.kt        |  64 ++++++++++++
 .../src/jvmMain/kotlin/debugModel.kt          |  50 ++++++++++
 .../src/jvmMain/kotlin/main.kt                |  91 ++++++++++++++++++
 .../src/jvmMain/resources/SPC-logo.png        | Bin 0 -> 5166 bytes
 .../devices-on-map/src/jvmMain/kotlin/main.kt |   8 --
 gradle/libs.versions.toml                     |   6 +-
 settings.gradle.kts                           |   2 +-
 14 files changed, 333 insertions(+), 48 deletions(-)
 rename demo/{devices-on-map => device-collective}/build.gradle.kts (94%)
 create mode 100644 demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt
 create mode 100644 demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
 create mode 100644 demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt
 create mode 100644 demo/device-collective/src/jvmMain/kotlin/RemoteDevice.kt
 create mode 100644 demo/device-collective/src/jvmMain/kotlin/debugModel.kt
 create mode 100644 demo/device-collective/src/jvmMain/kotlin/main.kt
 create mode 100644 demo/device-collective/src/jvmMain/resources/SPC-logo.png
 delete mode 100644 demo/devices-on-map/src/jvmMain/kotlin/main.kt

diff --git a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
index 3c707b2..18a3dfc 100644
--- a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
+++ b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
@@ -93,7 +93,7 @@ internal class RemoteDeviceConnect {
 
         val virtualMagixEndpoint = VirtualMagixEndpoint(deviceManager)
 
-        val remoteDevice = virtualMagixEndpoint.remoteDevice(context, "client", "device", "test".asName())
+        val remoteDevice: DeviceClient = virtualMagixEndpoint.remoteDevice(context, "client", "device", "test".asName())
 
         assertContains(0.0..1.0, remoteDevice.read(TestDevice.value))
 
diff --git a/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt
index b08066b..31f73fc 100644
--- a/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt
+++ b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt
@@ -1,10 +1,10 @@
 package space.kscience.controls.client
 
+import kotlinx.coroutines.CompletableDeferred
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.test.runTest
-import kotlinx.coroutines.withContext
 import space.kscience.controls.client.RemoteDeviceConnect.TestDevice
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
@@ -20,36 +20,37 @@ class MagixLoopTest {
 
     @Test
     fun realDeviceHub() = runTest {
-        withContext(Dispatchers.Default) {
-            val context = Context {
-                plugin(DeviceManager)
-            }
-
-            val server = context.startMagixServer()
-
-            val deviceManager = context.request(DeviceManager)
-
-            val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
-
-            deviceManager.launchMagixService(deviceEndpoint, "device")
-
-            launch {
-                delay(50)
-                repeat(10) {
-                    deviceManager.install("test[$it]", TestDevice)
-                }
-            }
-
-            val clientEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
-
-            val remoteHub = clientEndpoint.remoteDeviceHub(context, "client", "device")
-
-            assertEquals(0, remoteHub.devices.size)
-            delay(60)
-            clientEndpoint.requestDeviceUpdate("client", "device")
-            delay(60)
-            assertEquals(10, remoteHub.devices.size)
-            server.stop()
+        val context = Context {
+            coroutineContext(Dispatchers.Default)
+            plugin(DeviceManager)
         }
+
+        val server = context.startMagixServer()
+
+        val deviceManager = context.request(DeviceManager)
+
+        val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+
+        deviceManager.launchMagixService(deviceEndpoint, "device")
+
+        val trigger = CompletableDeferred<Unit>()
+
+        context.launch {
+            repeat(10) {
+                deviceManager.install("test[$it]", TestDevice)
+            }
+            delay(100)
+            trigger.complete(Unit)
+        }
+
+        val clientEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+
+        val remoteHub = clientEndpoint.remoteDeviceHub(context, "client", "device")
+
+        assertEquals(0, remoteHub.devices.size)
+        clientEndpoint.requestDeviceUpdate("client", "device")
+        trigger.join()
+        assertEquals(10, remoteHub.devices.size)
+        server.stop()
     }
 }
\ No newline at end of file
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt
index 855de10..87a9fca 100644
--- a/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt
+++ b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt
@@ -1,3 +1,5 @@
+@file:OptIn(FlowPreview::class)
+
 package space.kscience.controls.compose
 
 import androidx.compose.runtime.*
@@ -6,11 +8,8 @@ import io.github.koalaplot.core.line.LinePlot
 import io.github.koalaplot.core.style.LineStyle
 import io.github.koalaplot.core.xygraph.DefaultPoint
 import io.github.koalaplot.core.xygraph.XYGraphScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
+import kotlinx.coroutines.*
 import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
 import kotlinx.datetime.Clock
 import kotlinx.datetime.Instant
 import space.kscience.controls.api.Device
diff --git a/demo/devices-on-map/build.gradle.kts b/demo/device-collective/build.gradle.kts
similarity index 94%
rename from demo/devices-on-map/build.gradle.kts
rename to demo/device-collective/build.gradle.kts
index b81dc12..5520265 100644
--- a/demo/devices-on-map/build.gradle.kts
+++ b/demo/device-collective/build.gradle.kts
@@ -13,6 +13,7 @@ kscience {
     commonMain {
         implementation(projects.controlsVisualisationCompose)
         implementation(projects.controlsConstructor)
+        implementation(projects.magix.magixRsocket)
     }
     jvmMain {
 //        implementation("io.ktor:ktor-server-cio")
diff --git a/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt b/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt
new file mode 100644
index 0000000..b507cd6
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt
@@ -0,0 +1,20 @@
+package space.kscience.controls.demo.map
+
+import kotlinx.coroutines.FlowPreview
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.sample
+import space.kscience.controls.constructor.DeviceState
+import kotlin.time.Duration
+
+@OptIn(FlowPreview::class)
+class DebounceDeviceState<T>(
+    val origin: DeviceState<T>,
+    val interval: Duration,
+) : DeviceState<T> {
+    override val value: T get() = origin.value
+    override val valueFlow: Flow<T> get() = origin.valueFlow.sample(interval)
+
+    override fun toString(): String = "DebounceDeviceState($value, interval=$interval)"
+}
+
+fun <T> DeviceState<T>.debounce(interval: Duration) = DebounceDeviceState(this, interval)
\ 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
new file mode 100644
index 0000000..fdf13a1
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
@@ -0,0 +1,43 @@
+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.Gmc
+
+
+typealias RemoteDeviceId = String
+
+
+data class RemoteDeviceState(
+    val id: RemoteDeviceId,
+    val configuration: RemoteDeviceConfiguration,
+    val position: MutableDeviceState<Gmc>,
+    val velocity: MutableDeviceState<GmcVelocity>,
+)
+
+public fun RemoteDeviceState(
+    id: RemoteDeviceId,
+    position: Gmc,
+    configuration: RemoteDeviceConfiguration.() -> Unit = {},
+) = RemoteDeviceState(
+    id,
+    RemoteDeviceConfiguration(configuration),
+    MutableDeviceState(position),
+    MutableDeviceState(GmcVelocity.zero)
+)
+
+
+class DeviceCollectiveModel(
+    context: Context,
+    val deviceStates: Collection<RemoteDeviceState>,
+) : ModelConstructor(context) {
+
+    private val movement = onTimer { prev, next ->
+        val delta = (next - prev)
+        deviceStates.forEach { state ->
+            state.position.value = state.position.value.moveWith(state.velocity.value, delta)
+        }
+    }
+}
\ No newline at end of file
diff --git a/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt b/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt
new file mode 100644
index 0000000..bea840d
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt
@@ -0,0 +1,24 @@
+package space.kscience.controls.demo.map
+
+import kotlinx.serialization.Serializable
+import space.kscience.kmath.geometry.Angle
+import space.kscience.maps.coordinates.*
+import kotlin.time.Duration
+import kotlin.time.DurationUnit
+
+@Serializable
+data class GmcVelocity(val bearing: Angle, val velocity: Distance, val elevation: Distance = 0.kilometers){
+    companion object{
+        val zero = GmcVelocity(Angle.zero, 0.kilometers)
+    }
+}
+
+
+fun Gmc.moveWith(velocity: GmcVelocity, duration: Duration): Gmc {
+    val seconds = duration.toDouble(DurationUnit.SECONDS)
+
+    return GeoEllipsoid.WGS84.curveInDirection(
+        GmcPose(this, velocity.bearing),
+        velocity.velocity * seconds,
+    ).backward.coordinates
+}
\ No newline at end of file
diff --git a/demo/device-collective/src/jvmMain/kotlin/RemoteDevice.kt b/demo/device-collective/src/jvmMain/kotlin/RemoteDevice.kt
new file mode 100644
index 0000000..19a4e5b
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/RemoteDevice.kt
@@ -0,0 +1,64 @@
+@file:OptIn(DFExperimental::class)
+
+package space.kscience.controls.demo.map
+
+import space.kscience.controls.api.Device
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.registerAsProperty
+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.misc.DFExperimental
+import space.kscience.maps.coordinates.Gmc
+import kotlin.time.Duration.Companion.milliseconds
+
+class RemoteDeviceConfiguration : Scheme() {
+    companion object : SchemeSpec<RemoteDeviceConfiguration>(::RemoteDeviceConfiguration)
+}
+
+
+interface RemoteDevice : Device {
+
+    suspend fun getPosition(): Gmc
+
+    suspend fun getVelocity(): GmcVelocity
+
+    suspend fun setVelocity(value: GmcVelocity)
+
+
+    companion object : DeviceSpec<RemoteDevice>() {
+        val position by property<Gmc>(
+            converter = MetaConverter.serializable(),
+            read = { getPosition() }
+        )
+
+        val velocity by mutableProperty<GmcVelocity>(
+            converter = MetaConverter.serializable(),
+            read = { getVelocity() },
+            write = { _, value -> setVelocity(value) }
+        )
+    }
+}
+
+
+class RemoteDeviceConstructor(
+    context: Context,
+    val configuration: RemoteDeviceConfiguration,
+    position: MutableDeviceState<Gmc>,
+    velocity: MutableDeviceState<GmcVelocity>,
+) : DeviceConstructor(context, configuration.meta), RemoteDevice {
+
+    val position = registerAsProperty(RemoteDevice.position, position.debounce(500.milliseconds))
+    val velocity = registerAsProperty(RemoteDevice.velocity, velocity)
+
+    override suspend fun getPosition(): Gmc = position.value
+
+    override suspend fun getVelocity(): GmcVelocity = velocity.value
+
+    override suspend fun setVelocity(value: GmcVelocity) {
+        velocity.value = value
+    }
+}
\ 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
new file mode 100644
index 0000000..e352fcc
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
@@ -0,0 +1,50 @@
+package space.kscience.controls.demo.map
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import space.kscience.controls.spec.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
+
+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): DeviceCollectiveModel {
+    val devices: List<RemoteDeviceState> = buildList {
+        repeat(100) {
+            add(
+                RemoteDeviceState(
+                    "device[$it]",
+                    Gmc(
+                        center.latitude + radius * Random.nextDouble(),
+                        center.longitude + radius * Random.nextDouble()
+                    )
+                )
+            )
+        }
+    }
+
+    val model = DeviceCollectiveModel(context, devices)
+
+    return model
+}
+
+fun RemoteDevice.moveInCircles(): Job = launch {
+    var bearing = Random.nextDouble(-PI, PI).radians
+    write(RemoteDevice.velocity, GmcVelocity(bearing, deviceVelocity))
+    while (isActive) {
+        delay(500)
+        bearing += 5.degrees
+        write(RemoteDevice.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
new file mode 100644
index 0000000..0c7f7ad
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/main.kt
@@ -0,0 +1,91 @@
+package space.kscience.controls.demo.map
+
+import androidx.compose.material3.MaterialTheme
+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.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 space.kscience.controls.manager.DeviceManager
+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.ViewConfig
+import space.kscience.maps.features.circle
+import space.kscience.maps.features.color
+import space.kscience.maps.features.rectangle
+import java.nio.file.Path
+
+
+@Composable
+fun rememberDeviceManager(): DeviceManager = remember {
+    val context = Context {
+        plugin(DeviceManager)
+    }
+
+    context.request(DeviceManager)
+}
+
+
+@Composable
+fun App() {
+    val scope = rememberCoroutineScope()
+
+
+    val deviceManager = rememberDeviceManager()
+
+
+    val collectiveModel = remember {
+        generateModel(deviceManager.context)
+    }
+
+    val devices: Map<RemoteDeviceId, RemoteDevice> = remember {
+        collectiveModel.deviceStates.associate {
+            val device = RemoteDeviceConstructor(deviceManager.context, it.configuration, it.position, it.velocity)
+            device.moveInCircles()
+            it.id to device
+        }
+    }
+
+    val mapTileProvider = remember {
+        OpenStreetMapTileProvider(
+            client = HttpClient(CIO),
+            cacheDirectory = Path.of("mapCache")
+        )
+    }
+
+    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").color(Color.Red)
+            }.launchIn(scope)
+        }
+
+        devices.forEach { (id, device) ->
+            device.propertyFlow(RemoteDevice.position).onEach {  position ->
+                rectangle(position, id = id).color(Color.Blue)
+            }.launchIn(scope)
+        }
+    }
+}
+
+
+fun main() = application {
+    Window(onCloseRequest = ::exitApplication, title = "Maps-kt demo", icon = painterResource("SPC-logo.png")) {
+        MaterialTheme {
+            App()
+        }
+    }
+}
\ No newline at end of file
diff --git a/demo/device-collective/src/jvmMain/resources/SPC-logo.png b/demo/device-collective/src/jvmMain/resources/SPC-logo.png
new file mode 100644
index 0000000000000000000000000000000000000000..953de16d4876765010935ffc20cbd1d37351dbb6
GIT binary patch
literal 5166
zcmeHLdpML^7ax~0lFVx=62pw5QfYE6*WP)N%B7S`H#Lr1i6&$ya-6ANxji~v6pdRK
zCATA82z8E2DWpUZP9{x_>tI|GzP+dDdA|R?|Gz)xnf0!{e!sQXdiUOI?Kj%d-bQ|&
z$~*#rAaA=F+d&{mq{AO^F3>#7lKUI{$sXP87Dynh)r3C@_6>{UK)EEy$~DL-z$+-^
z;4x1^NJz+9U;o2_9tV$lt_?Wmo!n=tLLiV!Y_Ux{*(tqk6ZP%E;nl*B){=W&vFm>O
zJ91vs!s|CI69ty82WVs^gNEgin?5{YYp_;4ti`nt>{(PuT~$Y2*EnB+vFXazxe}V0
zuV`J%gpny$_G{+sPU|gIA3o1;=r1YlpO`c=rJQ;aT-x`kZ0IFFa9_s<^38fvrZFMy
zNrgayFnpOP;b_evtk6UW7Z56;1}70bF=FI;nzZE-L^3V*|HJ>CmMiH3iFKiajy;dx
zs8<i~JAQ6oNIU&_TWIyWgA={Xmd2F@m7kL<ZzkKv_&VLH`1G719R90ISn>K6b9}I?
zz;9en_{AfJ*LOl4FX=0@=qnqY8WWj~R-f1A-knN}T!t_|9GUnlhxg=tUi5}Sce0FH
zen3ybL@wR8^NU)Ide~S)Qu)7^lFA3b@4)u&oi3vl0|NtDrRM9xw6>*&a?0(iK5+A{
zQmP*d^Nl+P9u-UsU#k|Cs=Gg4mRst->16*|m&&5%H8`KUho0N7aQ7OK?sYt@@&&Kf
zMs0Dx2Eo8PkI{YEd<?7TT)vejGaYDmavDJ7joK8moySFIx6mrGS|{F?RF@=#A66Z@
z`Hzk0>XCbG;r&vq1|!{=yi?uh*q^U>XY(y}qe+>cBPU0q|8!v6s75^7^w!DxQ=*M}
z!q&B=rfVO5DiK!fa6c3&T!#AHdp}W!>)D@j@6qG!dh29Z1APy<KVGr+(eR+_qhp>T
z)h}}p<{w82-(C{_{%3p~uVegDk}(SvrEDL1QPLOZF!d&s)f+5#=awZunxtvy+gZVG
zcAxZ28Eoe<V``1Exq4b>&YtBB>g2`OpB#B1Y_*|ebEU2=M`NEIZ}lgqP7d32Iw-W4
zht)h}pA?=q7I_SH-Z`9?$CbRM@0+Z4+$1whx_97Pb6vb#Ior9Xi0}W_sjBwgZ6{t*
z=7N}<2QBL()=%GbEjqMIwCu}5ZEDlu!B6R9&tx28L)0gllGdf0q)x^*X0-OjDsYc`
zeF*IM<E>Mg-qi_`OkDogd$(Crd<#UIGJ1c%%nBN_qjWTOy7<$Fcc+@#p&5o3wRmN(
z;dSaxd#HXZmdd`RU)8kd2%;4Jwa2)}p;fecq5j2sj%((%{ldYqDi^*;FqC@tIfEfo
z3OLF5KJw9qci$vpyP3>xJC1AZt5=RE`JJbtzn1juG`mrtWuHVkb^O_3-tAkLEA3+X
zvba(?%h9`DZ}044z38<_$V@QRH+=uwF;Pm@)pZ9^)%By1t_4ib5BUR^1RGv~<)C6V
z_Ua$jWRw+pVO>bwYHF1HGW706QkN{^r;ISKaaK6s2R>oANHN2d8kLApcR-DX17|ZC
zDpV0BCN`PGaAYaxi=7RHhILc1e1BTSTm)Jak*S7BknZjkV!?K?Z7{9ET8vgAQ>{<K
zF;fSQvH^NQ8yheI3lzGNM40o5+qtBM5L!hN2`Dn^F`TZ6g`${yG|6KNKFGn%6{%Gz
z5FW0H#>!D^$h2=5N7)GS44)>_bD^^@QeaEULZV<ACJSwfw{cd8d0e7NH#jQ-`cm8q
zj&;=}>0&`RaeLp>vy}MmfQ1>cf1JkS5vs~2=xHqw7*NP1QB|zO8X(v};XawFVhaUB
zAZ!(D6san9Vhs=i1}wW9Y$&U+Zq`7GoA9MxeN~)AX-?R@>o_UrU+ltYadX{qT806J
zH-RW!0`HwBHmrgX-20u_un`LS*N6>U#KIL~!)~!~k=Squy%3UrD%M6Snv|oBg-$Va
z>@?A!D3}d98X9y)EKt#)b7DaQ4T^`tTMV~{+08glY;c2AI}z$e@m%N8Fg{EZy&c2%
z!+bMMqe-PyknajMLJbqgEYv`U#WWsvoG;5^E~@~~9>^2B2+3%M`Bs*rVf|vF7L17E
z)+nxQN0TlGQ$#C#5Gq>&!mR%wKHUo6Zb1P?Y_f#8ild_Wiy@QW3M49+l=1jcpjTy}
zj&p^)TtE&NjloHG&yu=ouN9_}=ro$Ntk@}sxam9$%Z(<jsR5V98RAnSbY5UilU^$3
z4MwQWLwz0{Dt~I(os)?eY89-E8~K-^P%V;MT>0b0&_n7X3jE#=x*i?see2|-jC5MH
z2Yzqm^-qsnH~v<y5O><BgEDG1;3&Fo9Z0J=G5K`D1SwEKIu(AHnGYKw2p!ZE)!+&$
zdI3x%_cMDXLZj_hO@rZrWOkm$aD%x%a7Hjkq;&2cm&mzL2B8xXLD9CEC-87PQF_Mm
zn;j?2hassCg9EvlT#lL*(j`>j`vJTWis_UkaRhmo$u8OL)3~EUtk(=tmOGXuQ$@3s
zg1}I;QJvPv@>nu6A%P4c$QKe{MM5B#6r4&7Ze-6+u-J~XG3ys}AFYvoan`H9V)_DK
zbk@xYEKBq1!dc$Mh#*qy7ZHQAJNNl7C@FpMVa6<U63e>LG7CLqgd9pYoQ1Y*<?Oy$
zI}1Ion7;VyFR$TP*7D+CP$lHh?`wZS861_|7r&sS^ts)?ypCg8>EC`q4Ut1>=CjbN
zTRAE>>t~@}is^HaH1Wm&c^bkU8N^qDAFnOT4a<^{_UZlkv_Z>R;Y-PtiJp~~-36c|
z2*(R8RGe;YyUl&gmQ!k@3{8om1WR&`3ML$IVC80(If`Yyt9{VEB$YdQL$D-n1oRoK
z8ei|KL2oVu6?)g<P8LT|QLJ-PNZ%aRP)>+*{TH)lmi*B-879oHe>zlB0~NBkuZ<ma
z_fWLUrqw&Y&55~lOTWG6ieBeo{<S5^0r!1H7pjsWy1hI+Gi)vujiX}O<TO)>8giN!
z0%FVH%Q|uiU9yfKwtt9;gg6d-(2I7I5^caFFKAnaN;dALH9CgRPlN+pf<ZFTz%><J
z1_r=rtu`v(e3tZ+!vuGf3z~&dXpM^dW?)4z#G=iYXJEaW=$bdDXJOHfaz(SS{j|m%
zM`mHk#3I+^S(rAuruoba%m{as(VvB>(HarE8CY-(G58rMhPZZWSTQ)g!rl+E>t)Qf
zEM?)O+cZ(NHyo0@<nSq@_PzJh*GR8$h%B}ROMd-K$Y8~9Uqn^>ktlz1`hunPGj2zt
z9joJPaEKX{&Qgb)Y!(Lg7}*1%!R(q!2$T>(V#CaoL<vOEOCQc=00PKwwqRd-%)|w^
zbrE&OT?9Fuvefpc18^KX+V)ch2L-K3?I%0al7r(0XMggsso*r)ul#eY^TwGwGOt#<
zmyWg4A0IOIFYB#y9eC4vhyP=3{5@26uGTQw+3bd}&YZ8qnmT>ixjyY6>_01xiikd%
z+G(cR8ckkI5y3|j#G=t;#W}!%({@!yq7V{@jmF?;*n&{-)ku^yG_giG2h5;}^2&PI
zryJP|fcTDN56*O$ad@U1KpfKpCq0HsjI?ws=ifPEfIKV@U^&6^K$K9Cm0;?F%JV}*
zriR%;HwB>bT!JtUD^Pi$Ls)rCAa|?*lHnpIQlMc0M6g8Z`(X)#-arYvCMIH&L79U@
zpv-y0)w&(<EQ36&fQcsXjsY4cNaG`kWax>7c}NCXEGQxwqLI|12ZZIaBv57VwM-R`
z#OH;7+$OPwq;YjK$7c;>y1IqqBc9sTZ5$spvBsX`vj_?;mK+~>vB1m<BmAbC-(fB+
z(mknV*tRpWiQf}grpR|^`-<joW+`R}fv1qYM{^0DFHJ@zB{FtlxOL#1%lvuqhM=9;
z3WK;01abCZ%V8td0Kp0hYe8GWfC6k(Hi<O|wFXAqU_irG!Z{Rg2K^6=81z5OVSHcK
z!xjmefX3`0T=W?V7|s&gxZ@-q3Tey{&}+dr0DG<5@AbTij^_ytHlR7XVist<m)-6r
zc|pT+_#%<6CFayan2lmi(7$CtPSDUTgo>yAI4KiS61m!_pFW%sn~pFu;hTFZz;Jmj
zFgNMLTBsihj=7p3%nHbOpbd1G2q20Qk*TktM#F})S*$Qcn5ANq-!MEF`ie><Q=7Ix
zABM5S0&BQT@;|~oVai}I=>1OWi_=8P6iU3FMM%U+QD=aQ!|zSU6qyD(&UbO1gSlCR
zsCxpOq)jy!b$FSnrZ$lWG^0s}gF2)XDqA~(x2)b9bOIc#q2O2<>vl4yOpmweWZ$yv
z1~x_P=dX>YuD{_4)H@u_ZqOI&x~WvBo($VN$r}fE0kYPS!hig|cP6Ag;_fM+<&HHN
z&^>Fk=qEnMCV}o;bbY-=X_vP!cQiu3@B8>Zczc-HmMsVSHceFW%>#G)mAAdPb1OFc
z<HwH=2bNy0bmOO}*PA`a2wM<zj!37qmicujjowzMPR}#RhZmwA;c}Lsdg{GsX)Myr
zihcH%=L_D1%l<_Py8BfMz55n9-SpdG=DTgaf<m>Y#aM^&I3pErJv9;o?gwu6s9!#~
z9?{I%uUV`QCJE8D<dPw^%dP$2JyTdCN}W~7{yE^11>WR6P}P>GTl9w0yAASkxqoe^
zfIF*sNV}D=Hees3<C1fSD^;q2#$J^gY1Otj|ER_qqsr`ZSdw^FJ#=;1RE|z($28BR
zVEFm1JABJLutxl=xFwa*)un6VHwf~R^nG`TQi_Tv+GRR30`@Pyyf-Y=bfPElZD87E
za4#4-r*=QVs9Y$|dYQZ+p>%}pDN4b|+Ag*#L<HHL?61j&6OH+JuzbqVmZPkc7uK~_
z_!->%G~16Jh1YZ5w^!Yo3FegM@1Lq3elO39Kb*HmS;r+;^}*=eCOeLDNUL9wr>gOZ
z)TF>CWpwY6vVvyH8yitepOJ!@VzaL6!Xc&A=f>p%qTSnr`?E?7*A-*<F|gKz+9rLZ
zlD@|t+d4d}-v#s4JSHq!3=La@4*5N*{7gU6(wJlzyk#5Lbi*Z3#2r?Luj1~$zxvXe
zJ(L8qvSktB^^>g8QiY0rqaV~mI));3l`KIcOz<oo;!p`W|NrRozkKX^`Kqw~ZcA>M
dG+}ahdgIi>+tx>p41sTb1Y0Y6EEhcx^&il<db$7r

literal 0
HcmV?d00001

diff --git a/demo/devices-on-map/src/jvmMain/kotlin/main.kt b/demo/devices-on-map/src/jvmMain/kotlin/main.kt
deleted file mode 100644
index 648610e..0000000
--- a/demo/devices-on-map/src/jvmMain/kotlin/main.kt
+++ /dev/null
@@ -1,8 +0,0 @@
-package space.kscience.controls.demo.map
-
-import androidx.compose.ui.window.application
-
-
-fun main() = application {
-
-}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 102c441..7c5a861 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,6 +1,6 @@
 [versions]
 
-dataforge = "0.8.0"
+dataforge = "0.9.0"
 rsocket = "0.15.4"
 xodus = "2.0.1"
 
@@ -10,7 +10,7 @@ fazecast = "2.10.3"
 
 tornadofx = "1.7.20"
 
-plotlykt = "0.7.0"
+plotlykt = "0.7.2"
 
 logback = "1.2.11"
 
@@ -29,7 +29,7 @@ pi4j-ktx = "2.4.0"
 
 plc4j = "0.12.0"
 
-visionforge = "0.4.1"
+visionforge = "0.4.2"
 
 versions = "0.51.0"
 
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 7aebf36..7e84c71 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -87,5 +87,5 @@ include(
     ":demo:echo",
     ":demo:mks-pdr900",
     ":demo:constructor",
-    ":demo:devices-on-map"
+    ":demo:device-collective"
 )

From 5c7d3d8a7a3513ede88320a895ef8d89cf6dc440 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 7 Jun 2024 10:52:28 +0300
Subject: [PATCH 102/125] Add PeerConnection

---
 CHANGELOG.md                                  |  1 +
 .../kscience/controls/api/DeviceMessage.kt    | 10 ++++-
 .../kscience/controls/peer/PeerConnection.kt  | 42 +++++++++++++++++++
 3 files changed, 51 insertions(+), 2 deletions(-)
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 92b2765..ae8c47f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
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 dda89de..f91beb5 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
@@ -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,
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt
new file mode 100644
index 0000000..14b49ef
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt
@@ -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,
+    )
+}
\ No newline at end of file

From 13b80be8841761568ed17db6b23a2e9116591411 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 7 Jun 2024 20:20:39 +0300
Subject: [PATCH 103/125] Implement visibility range for collective device

---
 .gitignore                                    |   5 +-
 .../src/commonMain/kotlin/misc.kt             |  12 ++
 .../constructor/src/jvmMain/kotlin/Plotter.kt |  19 ++-
 demo/device-collective/build.gradle.kts       |   2 +-
 .../{RemoteDevice.kt => CollectiveDevice.kt}  |  32 ++--
 .../jvmMain/kotlin/DeviceCollectiveModel.kt   |  42 +++--
 .../src/jvmMain/kotlin/GmcVelocity.kt         |   2 +-
 ...nceDeviceState.kt => SampleDeviceState.kt} |   6 +-
 .../src/jvmMain/kotlin/debugModel.kt          |  31 ++--
 .../src/jvmMain/kotlin/main.kt                | 146 +++++++++++++++---
 10 files changed, 226 insertions(+), 71 deletions(-)
 create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/misc.kt
 rename demo/device-collective/src/jvmMain/kotlin/{RemoteDevice.kt => CollectiveDevice.kt} (60%)
 rename demo/device-collective/src/jvmMain/kotlin/{DebounceDeviceState.kt => SampleDeviceState.kt} (76%)

diff --git a/.gitignore b/.gitignore
index e688053..5fab474 100644
--- a/.gitignore
+++ b/.gitignore
@@ -9,4 +9,7 @@
 
 out/
 build/
-!gradle-wrapper.jar
\ No newline at end of file
+
+!gradle-wrapper.jar
+
+/demo/device-collective/mapCache/
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/misc.kt b/controls-visualisation-compose/src/commonMain/kotlin/misc.kt
new file mode 100644
index 0000000..caf21e3
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/misc.kt
@@ -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
+}
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
index dfded59..40f81f9 100644
--- a/demo/constructor/src/jvmMain/kotlin/Plotter.kt
+++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
@@ -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))
     }
diff --git a/demo/device-collective/build.gradle.kts b/demo/device-collective/build.gradle.kts
index 5520265..8bb0597 100644
--- a/demo/device-collective/build.gradle.kts
+++ b/demo/device-collective/build.gradle.kts
@@ -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"
     }
 }
\ No newline at end of file
diff --git a/demo/device-collective/src/jvmMain/kotlin/RemoteDevice.kt b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
similarity index 60%
rename from demo/device-collective/src/jvmMain/kotlin/RemoteDevice.kt
rename to demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
index 19a4e5b..050794c 100644
--- a/demo/device-collective/src/jvmMain/kotlin/RemoteDevice.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
@@ -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()
 }
\ 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 fdf13a1..4abedea 100644
--- a/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
@@ -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 }
+    }
 }
\ No newline at end of file
diff --git a/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt b/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt
index bea840d..9d356c6 100644
--- a/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt
@@ -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
diff --git a/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt b/demo/device-collective/src/jvmMain/kotlin/SampleDeviceState.kt
similarity index 76%
rename from demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt
rename to demo/device-collective/src/jvmMain/kotlin/SampleDeviceState.kt
index b507cd6..a2ef06c 100644
--- a/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/SampleDeviceState.kt
@@ -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)
\ No newline at end of file
+fun <T> DeviceState<T>.sample(interval: Duration) = SampleDeviceState(this, interval)
\ 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
index e352fcc..96c9ca8 100644
--- a/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
@@ -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))
     }
 }
\ 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 0c7f7ad..1db8621 100644
--- a/demo/device-collective/src/jvmMain/kotlin/main.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/main.kt
@@ -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()
+                            )
+                        }
+                    }
+                }
+            }
         }
     }
 }

From e9bde6867466d4fcea57b20a11c46d25856abd60 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 9 Jun 2024 15:09:43 +0300
Subject: [PATCH 104/125] [WIP] remote communication for CollectiveDevice

---
 .../kscience/controls/peer/PeerConnection.kt  |   7 +-
 .../controls/server/deviceWebServer.kt        |   2 +
 demo/device-collective/build.gradle.kts       |   2 +
 .../src/jvmMain/kotlin/CollectiveDevice.kt    |  33 ++-
 .../jvmMain/kotlin/DeviceCollectiveModel.kt   |  86 ++++++-
 .../src/jvmMain/kotlin/SampleDeviceState.kt   |  19 +-
 .../src/jvmMain/kotlin/debugModel.kt          |  13 +-
 .../src/jvmMain/kotlin/main.kt                | 218 +++++++++++-------
 .../kscience/controls/demo/MassDevice.kt      |   3 +-
 .../kscience/magix/api/MagixFlowPlugin.kt     |  13 +-
 .../kscience/magix/server/magixModule.kt      |  57 +++--
 .../space/kscience/magix/server/server.kt     |   2 +-
 12 files changed, 312 insertions(+), 143 deletions(-)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt
index 14b49ef..a831207 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt
@@ -2,14 +2,13 @@ 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].
+     * Receive an [Envelope] from a device 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.
@@ -20,13 +19,12 @@ public interface PeerConnection {
      */
     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]
+     * Send an [envelope] to a device 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.
@@ -35,7 +33,6 @@ public interface PeerConnection {
      */
     public suspend fun send(
         address: String,
-        deviceName: Name,
         envelope: Envelope,
         requestMeta: Meta = Meta.EMPTY,
     )
diff --git a/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
index 4f37322..8fd104e 100644
--- a/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
+++ b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
@@ -38,6 +38,7 @@ import space.kscience.dataforge.names.get
 import space.kscience.magix.api.MagixEndpoint
 import space.kscience.magix.api.MagixFlowPlugin
 import space.kscience.magix.api.MagixMessage
+import space.kscience.magix.api.start
 import space.kscience.magix.server.magixModule
 
 
@@ -215,5 +216,6 @@ public fun Application.deviceManagerModule(
     plugins.forEach {
         it.start(this, magixFlow)
     }
+
     magixModule(magixFlow)
 }
\ No newline at end of file
diff --git a/demo/device-collective/build.gradle.kts b/demo/device-collective/build.gradle.kts
index 8bb0597..3290a0d 100644
--- a/demo/device-collective/build.gradle.kts
+++ b/demo/device-collective/build.gradle.kts
@@ -13,7 +13,9 @@ kscience {
     commonMain {
         implementation(projects.controlsVisualisationCompose)
         implementation(projects.controlsConstructor)
+        implementation(projects.magix.magixServer)
         implementation(projects.magix.magixRsocket)
+        implementation(projects.controlsMagix)
     }
     jvmMain {
 //        implementation("io.ktor:ktor-server-cio")
diff --git a/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
index 050794c..9fce04d 100644
--- a/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
@@ -6,24 +6,35 @@ import space.kscience.controls.api.Device
 import space.kscience.controls.constructor.DeviceConstructor
 import space.kscience.controls.constructor.MutableDeviceState
 import space.kscience.controls.constructor.registerAsProperty
+import space.kscience.controls.peer.PeerConnection
 import space.kscience.controls.spec.DeviceSpec
+import space.kscience.controls.spec.unit
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.meta.Scheme
+import space.kscience.dataforge.meta.int
 import space.kscience.dataforge.meta.string
 import space.kscience.dataforge.misc.DFExperimental
 import space.kscience.maps.coordinates.Gmc
+import space.kscience.maps.coordinates.GmcCurve
 import kotlin.time.Duration.Companion.milliseconds
 
-class CollectiveDeviceConfiguration(deviceId: DeviceId) : Scheme() {
+typealias CollectiveDeviceId = String
+
+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")
 }
 
+typealias CollectiveDeviceRoster = Map<CollectiveDeviceId, CollectiveDeviceConfiguration>
 
 interface CollectiveDevice : Device {
 
-    public val id: DeviceId
+    public val id: CollectiveDeviceId
+
+    public val peerConnection: PeerConnection
 
     suspend fun getPosition(): Gmc
 
@@ -31,8 +42,7 @@ interface CollectiveDevice : Device {
 
     suspend fun setVelocity(value: GmcVelocity)
 
-    suspend fun listVisible(): Collection<DeviceId>
-
+    suspend fun listVisible(): Collection<CollectiveDeviceId>
 
     companion object : DeviceSpec<CollectiveDevice>() {
         val position by property<Gmc>(
@@ -45,6 +55,10 @@ interface CollectiveDevice : Device {
             read = { getVelocity() },
             write = { _, value -> setVelocity(value) }
         )
+
+        val listVisible by action(MetaConverter.unit, MetaConverter.valueList<String> { it.string }) {
+            listVisible().toList()
+        }
     }
 }
 
@@ -54,13 +68,14 @@ class CollectiveDeviceConstructor(
     val configuration: CollectiveDeviceConfiguration,
     position: MutableDeviceState<Gmc>,
     velocity: MutableDeviceState<GmcVelocity>,
-    private val listVisible: suspend () -> Collection<DeviceId>,
+    override val peerConnection: PeerConnection,
+    private val observation: suspend () -> Map<CollectiveDeviceId, GmcCurve>,
 ) : DeviceConstructor(context, configuration.meta), CollectiveDevice {
 
-    override val id: DeviceId get() = configuration.deviceId
+    override val id: CollectiveDeviceId get() = configuration.deviceId
 
-    val position = registerAsProperty(CollectiveDevice.position, position.sample(500.milliseconds))
-    val velocity = registerAsProperty(CollectiveDevice.velocity, velocity)
+    val position = registerAsProperty(CollectiveDevice.position, position.sample(configuration.reportInterval.milliseconds))
+    val velocity = registerAsProperty(CollectiveDevice.velocity, velocity.sample(configuration.reportInterval.milliseconds))
 
     override suspend fun getPosition(): Gmc = position.value
 
@@ -70,5 +85,5 @@ class CollectiveDeviceConstructor(
         velocity.value = value
     }
 
-    override suspend fun listVisible(): Collection<DeviceId> = listVisible.invoke()
+    override suspend fun listVisible(): Collection<CollectiveDeviceId> = 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 4abedea..2c325b4 100644
--- a/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
@@ -1,27 +1,41 @@
 package space.kscience.controls.demo.collective
 
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import space.kscience.controls.client.launchMagixService
 import space.kscience.controls.constructor.ModelConstructor
 import space.kscience.controls.constructor.MutableDeviceState
 import space.kscience.controls.constructor.onTimer
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.controls.manager.install
+import space.kscience.controls.peer.PeerConnection
 import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.request
+import space.kscience.dataforge.io.Envelope
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.names.parseAsName
+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.time.Duration
+import kotlin.time.Duration.Companion.milliseconds
 
 
-typealias DeviceId = String
-
-
-internal data class VirtualDeviceState(
-    val id: DeviceId,
+internal data class CollectiveDeviceState(
+    val id: CollectiveDeviceId,
     val configuration: CollectiveDeviceConfiguration,
     val position: MutableDeviceState<Gmc>,
     val velocity: MutableDeviceState<GmcVelocity>,
 )
 
 internal fun VirtualDeviceState(
-    id: DeviceId,
+    id: CollectiveDeviceId,
     position: Gmc,
     configuration: CollectiveDeviceConfiguration.() -> Unit = {},
-) = VirtualDeviceState(
+) = CollectiveDeviceState(
     id,
     CollectiveDeviceConfiguration(id).apply(configuration),
     MutableDeviceState(position),
@@ -31,9 +45,11 @@ internal fun VirtualDeviceState(
 
 internal class DeviceCollectiveModel(
     context: Context,
-    val deviceStates: Collection<VirtualDeviceState>,
-    val visibilityRange: Distance,
-) : ModelConstructor(context) {
+    val deviceStates: Collection<CollectiveDeviceState>,
+    val visibilityRange: Distance = 0.4.kilometers,
+    val radioRange: Distance = 5.kilometers,
+    val reportInterval: Duration = 1000.milliseconds
+) : ModelConstructor(context), PeerConnection {
 
     /**
      * Propagate movement
@@ -45,7 +61,7 @@ internal class DeviceCollectiveModel(
         }
     }
 
-    suspend fun locateVisible(id: DeviceId): Map<DeviceId, GmcCurve> {
+    private fun locateVisible(id: CollectiveDeviceId): Map<CollectiveDeviceId, GmcCurve> {
         val coordinatesSnapshot = deviceStates.associate { it.id to it.position.value }
 
         val selected = coordinatesSnapshot[id] ?: error("Can't find device with id $id")
@@ -56,4 +72,52 @@ internal class DeviceCollectiveModel(
 
         return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange }
     }
+
+    val devices = deviceStates.associate {
+        val device = CollectiveDeviceConstructor(
+            context = context,
+            configuration = it.configuration,
+            position = it.position,
+            velocity = it.velocity,
+            peerConnection = this,
+        ) {
+            locateVisible(it.id)
+        }
+        //start movement program
+        device.moveInCircles()
+        it.id to device
+    }
+
+    val roster = deviceStates.associate { it.id to it.configuration }
+
+    override suspend fun receive(address: String, contentId: String, requestMeta: Meta): Envelope {
+        TODO("Not yet implemented")
+    }
+
+    override suspend fun send(address: String, envelope: Envelope, requestMeta: Meta) {
+//        devices.values.filter { it.configuration.radioFrequency == address }.forEach { device ->
+//            ```
+//        }
+    }
+}
+
+internal fun CoroutineScope.launchCollectiveMagixServer(
+    collectiveModel: DeviceCollectiveModel,
+): Job = launch(Dispatchers.IO) {
+    val server = startMagixServer(
+//        RSocketMagixFlowPlugin()
+    )
+
+    collectiveModel.devices.forEach { (id, device) ->
+        val deviceContext = collectiveModel.context.buildContext(id.parseAsName()) {
+            coroutineContext(coroutineContext)
+            plugin(DeviceManager)
+        }
+
+        deviceContext.install(id, device)
+
+        val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+
+        deviceContext.request(DeviceManager).launchMagixService(deviceEndpoint, id)
+    }
 }
\ No newline at end of file
diff --git a/demo/device-collective/src/jvmMain/kotlin/SampleDeviceState.kt b/demo/device-collective/src/jvmMain/kotlin/SampleDeviceState.kt
index a2ef06c..499c903 100644
--- a/demo/device-collective/src/jvmMain/kotlin/SampleDeviceState.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/SampleDeviceState.kt
@@ -4,6 +4,7 @@ import kotlinx.coroutines.FlowPreview
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.sample
 import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.constructor.MutableDeviceState
 import kotlin.time.Duration
 
 @OptIn(FlowPreview::class)
@@ -11,10 +12,24 @@ class SampleDeviceState<T>(
     val origin: DeviceState<T>,
     val interval: Duration,
 ) : DeviceState<T> {
-    override val value: T get() = origin.value
+    override val value: T by origin::value
     override val valueFlow: Flow<T> get() = origin.valueFlow.sample(interval)
 
     override fun toString(): String = "DebounceDeviceState($value, interval=$interval)"
 }
 
-fun <T> DeviceState<T>.sample(interval: Duration) = SampleDeviceState(this, interval)
\ No newline at end of file
+
+fun <T> DeviceState<T>.sample(interval: Duration) = SampleDeviceState(this, interval)
+
+@OptIn(FlowPreview::class)
+class MutableSampleDeviceState<T>(
+    val origin: MutableDeviceState<T>,
+    val interval: Duration,
+) : MutableDeviceState<T> {
+    override var value: T by origin::value
+    override val valueFlow: Flow<T> get() = origin.valueFlow.sample(interval)
+
+    override fun toString(): String = "DebounceDeviceState($value, interval=$interval)"
+}
+
+fun <T> MutableDeviceState<T>.sample(interval: Duration) = MutableSampleDeviceState(this, interval)
\ 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
index 96c9ca8..d0ec384 100644
--- a/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
@@ -12,6 +12,8 @@ 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
 
@@ -19,8 +21,13 @@ private val center = Gmc.ofDegrees(55.925, 37.514)
 private val radius = 0.01.degrees
 
 
-internal fun generateModel(context: Context): DeviceCollectiveModel {
-    val devices: List<VirtualDeviceState> = List(100) { index ->
+internal fun generateModel(
+    context: Context,
+    size: Int = 50,
+    reportInterval: Duration = 500.milliseconds,
+    additionalConfiguration: CollectiveDeviceConfiguration.() -> Unit = {},
+): DeviceCollectiveModel {
+    val devices: List<CollectiveDeviceState> = List(size) { index ->
         val id = "device[$index]"
 
         VirtualDeviceState(
@@ -32,6 +39,8 @@ internal fun generateModel(context: Context): DeviceCollectiveModel {
         ) {
             deviceId = id
             description = "Virtual remote device $id"
+            this.reportInterval = reportInterval.inWholeMilliseconds.toInt()
+            additionalConfiguration()
         }
     }
 
diff --git a/demo/device-collective/src/jvmMain/kotlin/main.kt b/demo/device-collective/src/jvmMain/kotlin/main.kt
index 1db8621..9aff323 100644
--- a/demo/device-collective/src/jvmMain/kotlin/main.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/main.kt
@@ -24,74 +24,95 @@ 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.*
 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.api.PropertyChangedMessage
+import space.kscience.controls.client.*
 import space.kscience.controls.compose.conditional
 import space.kscience.controls.manager.DeviceManager
-import space.kscience.controls.spec.useProperty
 import space.kscience.dataforge.context.Context
-import space.kscience.dataforge.context.request
+import space.kscience.dataforge.context.ContextBuilder
+import space.kscience.dataforge.meta.MetaConverter
+import space.kscience.dataforge.names.parseAsName
+import space.kscience.magix.api.MagixEndpoint
+import space.kscience.magix.api.subscribe
+import space.kscience.magix.rsocket.rSocketWithWebSockets
 import space.kscience.maps.compose.MapView
 import space.kscience.maps.compose.OpenStreetMapTileProvider
+import space.kscience.maps.coordinates.Gmc
+import space.kscience.maps.coordinates.meters
 import space.kscience.maps.features.*
 import java.nio.file.Path
+import kotlin.time.Duration.Companion.seconds
 
 
 @Composable
-fun rememberDeviceManager(): DeviceManager = remember {
-    val context = Context {
-        plugin(DeviceManager)
-    }
-
-    context.request(DeviceManager)
+fun rememberContext(name: String, contextBuilder: ContextBuilder.() -> Unit = {}): Context = remember {
+    Context(name, contextBuilder)
 }
 
 @Composable
 fun App() {
     val scope = rememberCoroutineScope()
 
-
-    val deviceManager = rememberDeviceManager()
-
-
-    val collectiveModel = remember {
-        generateModel(deviceManager.context)
+    val parentContext = rememberContext("Parent") {
+        plugin(DeviceManager)
     }
 
-    val devices: Map<DeviceId, CollectiveDevice> = 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 collectiveModel = remember {
+        generateModel(parentContext, 60)
+    }
+
+    val roster = remember {
+        collectiveModel.roster
+    }
+
+    val client = remember { CompletableDeferred<MagixEndpoint>() }
+
+    val devices = remember { mutableStateMapOf<CollectiveDeviceId, DeviceClient>() }
+
+
+    LaunchedEffect(collectiveModel) {
+        launchCollectiveMagixServer(collectiveModel)
+        withContext(Dispatchers.IO) {
+            val magixClient = MagixEndpoint.rSocketWithWebSockets("localhost")
+
+            client.complete(magixClient)
+
+            collectiveModel.roster.forEach { (id, config) ->
+                devices[id] = magixClient.remoteDevice(parentContext, "listener", id, id.parseAsName())
             }
-            device.moveInCircles()
-            it.id to device
+        }
+
+    }
+
+    var selectedDeviceId by remember { mutableStateOf<CollectiveDeviceId?>(null) }
+
+    var currentPosition by remember { mutableStateOf<Gmc?>(null) }
+
+    LaunchedEffect(selectedDeviceId, devices) {
+        selectedDeviceId?.let { devices[it] }?.propertyFlow(CollectiveDevice.position)?.collect {
+            currentPosition = it
         }
     }
 
-    var selectedDeviceId by remember { mutableStateOf<DeviceId?>(null) }
     var showOnlyVisible by remember { mutableStateOf(false) }
 
-    val mapTileProvider = remember {
-        OpenStreetMapTileProvider(
-            client = HttpClient(CIO),
-            cacheDirectory = Path.of("mapCache")
-        )
-    }
-
     HorizontalSplitPane(
         splitPaneState = rememberSplitPaneState(0.9f)
     ) {
         first(400.dp) {
             MapView(
-                mapTileProvider = mapTileProvider,
+                mapTileProvider = remember {
+                    OpenStreetMapTileProvider(
+                        client = HttpClient(CIO),
+                        cacheDirectory = Path.of("mapCache")
+                    )
+                },
                 config = ViewConfig()
             ) {
                 collectiveModel.deviceStates.forEach { device ->
@@ -103,65 +124,61 @@ fun App() {
                     }.launchIn(scope)
                 }
 
-                devices.forEach { (id, device) ->
-                    device.useProperty(CollectiveDevice.position, scope = scope) { position ->
+                scope.launch {
 
-                        val activeDevice = selectedDeviceId?.let { devices[it] }
+                    client.await().subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) ->
+                        if (deviceMessage is PropertyChangedMessage && deviceMessage.property == "position") {
+                            val id = magixMessage.sourceEndpoint
+                            val position = MetaConverter.serializable<Gmc>().read(deviceMessage.value)
+                            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)
+                            suspend fun DeviceClient.idIsVisible() = try {
+                                withTimeout(1.seconds) {
+                                    id in execute(CollectiveDevice.listVisible)
+                                }
+                            } catch (ex: Exception) {
+                                ex.printStackTrace()
+                                true
+                            }
+
+                            if (
+                                activeDevice == null ||
+                                id == selectedDeviceId ||
+                                !showOnlyVisible ||
+                                activeDevice.idIsVisible()
+                            ) {
+                                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)
+                            }
                         }
+                    }.launchIn(scope)
 
-                    }
                 }
             }
         }
         second(200.dp) {
-            Column {
-                selectedDeviceId?.let { id ->
-                    Column(
-                        modifier = Modifier
-                            .padding(8.dp)
-                            .border(2.dp, Color.DarkGray)
+
+            Column(
+                modifier = Modifier.verticalScroll(rememberScrollState())
+            ) {
+                devices.forEach { (id, _) ->
+                    Card(
+                        elevation = 16.dp,
+                        modifier = Modifier.padding(8.dp).onClick {
+                            selectedDeviceId = id
+                        }.conditional(id == selectedDeviceId) {
+                            border(2.dp, Color.Blue)
+                        }
                     ) {
-                        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)
-                            }
+                        Column(
+                            modifier = Modifier.padding(8.dp)
                         ) {
                             Text(
                                 text = id,
@@ -169,16 +186,49 @@ fun App() {
                                 fontWeight = FontWeight.Bold,
                                 modifier = Modifier.padding(10.dp).fillMaxWidth()
                             )
+                            if (id == selectedDeviceId) {
+                                roster[id]?.let {
+                                    Text("Meta:", color = Color.Blue, fontWeight = FontWeight.Bold)
+                                    Card(elevation = 16.dp, modifier = Modifier.fillMaxWidth().padding(8.dp)) {
+                                        Text(it.toString())
+                                    }
+                                }
+
+                                currentPosition?.let { currentPosition ->
+                                    Text("Широта: ${String.format("%.3f", currentPosition.latitude.toDegrees().value)}")
+                                    Text(
+                                        "Долгота: ${
+                                            String.format(
+                                                "%.3f",
+                                                currentPosition.longitude.toDegrees().value
+                                            )
+                                        }"
+                                    )
+                                    currentPosition.elevation?.let {
+                                        Text("Высота: ${String.format("%.1f", it.meters)} м")
+                                    }
+                                }
+
+                                Row(
+                                    verticalAlignment = Alignment.CenterVertically,
+                                    modifier = Modifier.fillMaxWidth()
+                                ) {
+                                    Text("Показать только видимые")
+                                    Checkbox(showOnlyVisible, { showOnlyVisible = it })
+                                }
+                            }
                         }
                     }
                 }
             }
+
         }
     }
 }
 
 
 fun main() = application {
+//    System.setProperty(IO_PARALLELISM_PROPERTY_NAME, 300.toString())
     Window(onCloseRequest = ::exitApplication, title = "Maps-kt demo", icon = painterResource("SPC-logo.png")) {
         MaterialTheme {
             App()
diff --git a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
index 5eafabf..53c4c7f 100644
--- a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
+++ b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt
@@ -83,8 +83,7 @@ suspend fun main() {
 
         val endpointId = "device$it"
         val deviceEndpoint = MagixEndpoint.rSocketStreamWithWebSockets("localhost")
-        deviceManager.launchMagixService(deviceEndpoint, endpointId, Dispatchers.IO)
-
+        deviceManager.launchMagixService(deviceEndpoint, endpointId)
     }
 
     val trace = Bar {
diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFlowPlugin.kt b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFlowPlugin.kt
index 83c95cc..1a3dd75 100644
--- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFlowPlugin.kt
+++ b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFlowPlugin.kt
@@ -21,9 +21,10 @@ public fun interface MagixFlowPlugin {
         sendMessage: suspend (MagixMessage) -> Unit,
     ): Job
 
-    /**
-     * Use the same [MutableSharedFlow] to send and receive messages. Could be a bottleneck in case of many plugins.
-     */
-    public fun start(scope: CoroutineScope, magixFlow: MutableSharedFlow<MagixMessage>): Job =
-        start(scope, magixFlow) { magixFlow.emit(it) }
-}
\ No newline at end of file
+}
+
+/**
+ * Use the same [MutableSharedFlow] to send and receive messages. Could be a bottleneck in case of many plugins.
+ */
+public fun MagixFlowPlugin.start(scope: CoroutineScope, magixFlow: MutableSharedFlow<MagixMessage>): Job =
+    start(scope, magixFlow) { magixFlow.emit(it) }
\ No newline at end of file
diff --git a/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/magixModule.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/magixModule.kt
index dc197ad..a1edec5 100644
--- a/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/magixModule.kt
+++ b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/magixModule.kt
@@ -10,7 +10,9 @@ import io.ktor.server.util.getValue
 import io.ktor.server.websocket.WebSockets
 import io.rsocket.kotlin.ktor.server.RSocketSupport
 import io.rsocket.kotlin.ktor.server.rSocket
+import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
 import kotlinx.coroutines.flow.map
 import kotlinx.html.*
 import kotlinx.serialization.encodeToString
@@ -42,7 +44,11 @@ private fun ApplicationCall.buildFilter(): MagixMessageFilter {
 /**
  * Attach magix http/sse and websocket-based rsocket event loop + statistics page to existing [MutableSharedFlow]
  */
-public fun Application.magixModule(magixFlow: MutableSharedFlow<MagixMessage>, route: String = "/") {
+public fun Application.magixModule(
+    magixFlow: Flow<MagixMessage>,
+    send: suspend (MagixMessage) -> Unit,
+    route: String = "/",
+) {
     if (pluginOrNull(WebSockets) == null) {
         install(WebSockets)
     }
@@ -62,27 +68,31 @@ public fun Application.magixModule(magixFlow: MutableSharedFlow<MagixMessage>, r
 
     routing {
         route(route) {
-            install(ContentNegotiation){
+            install(ContentNegotiation) {
                 json()
             }
-            get("state") {
-                call.respondHtml {
-                    head {
-                        meta {
-                            httpEquiv = "refresh"
-                            content = "2"
+            if (magixFlow is SharedFlow) {
+                get("state") {
+                    call.respondHtml {
+                        head {
+                            meta {
+                                httpEquiv = "refresh"
+                                content = "2"
+                            }
                         }
-                    }
-                    body {
-                        h1 { +"Magix loop statistics" }
-                        h2 { +"Number of subscribers: ${magixFlow.subscriptionCount.value}" }
-                        h3 { +"Replay cache size: ${magixFlow.replayCache.size}" }
-                        h3 { +"Replay cache:" }
-                        ol {
-                            magixFlow.replayCache.forEach { message ->
-                                li {
-                                    code {
-                                        +magixJson.encodeToString(message)
+                        body {
+                            h1 { +"Magix loop statistics" }
+                            if (magixFlow is MutableSharedFlow) {
+                                h2 { +"Number of subscribers: ${magixFlow.subscriptionCount.value}" }
+                            }
+                            h3 { +"Replay cache size: ${magixFlow.replayCache.size}" }
+                            h3 { +"Replay cache:" }
+                            ol {
+                                magixFlow.replayCache.forEach { message ->
+                                    li {
+                                        code {
+                                            +magixJson.encodeToString(message)
+                                        }
                                     }
                                 }
                             }
@@ -102,17 +112,22 @@ public fun Application.magixModule(magixFlow: MutableSharedFlow<MagixMessage>, r
             }
             post("broadcast") {
                 val message = call.receive<MagixMessage>()
-                magixFlow.emit(message)
+                send(message)
             }
             //rSocket WS server. Filter from Payload
             rSocket(
                 "rsocket",
-                acceptor = RSocketMagixFlowPlugin.acceptor(application, magixFlow) { magixFlow.emit(it) }
+                acceptor = RSocketMagixFlowPlugin.acceptor(application, magixFlow) { send(it) }
             )
         }
     }
 }
 
+public fun Application.magixModule(
+    magixFlow: MutableSharedFlow<MagixMessage>,
+    route: String = "/",
+): Unit = magixModule(magixFlow, { magixFlow.emit(it) }, route)
+
 /**
  * Create a new loop [MutableSharedFlow] with given [buffer] and setup magix module based on it
  */
diff --git a/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/server.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/server.kt
index 2396e25..aa26bee 100644
--- a/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/server.kt
+++ b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/server.kt
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
 import space.kscience.magix.api.MagixEndpoint.Companion.DEFAULT_MAGIX_HTTP_PORT
 import space.kscience.magix.api.MagixFlowPlugin
 import space.kscience.magix.api.MagixMessage
+import space.kscience.magix.api.start
 
 
 /**
@@ -22,7 +23,6 @@ public fun CoroutineScope.startMagixServer(
 
     val magixFlow = MutableSharedFlow<MagixMessage>(
         replay = buffer,
-        extraBufferCapacity = buffer,
         onBufferOverflow = BufferOverflow.DROP_OLDEST
     )
 

From c55ce2cf9a393f43829d792de9f14dd93027353f Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 9 Jun 2024 20:51:12 +0300
Subject: [PATCH 105/125] Fix visibility range for collective

---
 .../controls/constructor/DeviceState.kt       | 15 ++---
 .../kscience/controls/misc/converters.kt      | 17 ++++--
 .../controls/client/clientPropertyAccess.kt   |  5 +-
 .../src/jvmMain/kotlin/CollectiveDevice.kt    | 43 +++++++++++---
 .../jvmMain/kotlin/DeviceCollectiveModel.kt   |  8 +--
 .../src/jvmMain/kotlin/main.kt                | 57 +++++++++----------
 6 files changed, 89 insertions(+), 56 deletions(-)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
index 846a37b..5b547f7 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -74,15 +74,16 @@ public fun <T, R> DeviceState.Companion.map(
 
 public fun <T, R> DeviceState<T>.map(mapper: (T) -> R): DeviceStateWithDependencies<R> = DeviceState.map(this, mapper)
 
-public fun DeviceState<NumericalValue<out UnitsOfMeasurement>>.values(): DeviceState<Double> = object : DeviceState<Double> {
-    override val value: Double
-        get() = this@values.value.value
+public fun DeviceState<NumericalValue<out UnitsOfMeasurement>>.values(): DeviceState<Double> =
+    object : DeviceState<Double> {
+        override val value: Double
+            get() = this@values.value.value
 
-    override val valueFlow: Flow<Double>
-        get() = this@values.valueFlow.map { it.value }
+        override val valueFlow: Flow<Double>
+            get() = this@values.valueFlow.map { it.value }
 
-    override fun toString(): String = this@values.toString()
-}
+        override fun toString(): String = this@values.toString()
+    }
 
 /**
  * Combine two device states into one read-only [DeviceState]. Only the latest value of each state is used.
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt
index 4297d20..c200758 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt
@@ -41,13 +41,22 @@ private object InstantConverter : MetaConverter<Instant> {
 public val MetaConverter.Companion.instant: MetaConverter<Instant> get() = InstantConverter
 
 private object DoubleRangeConverter : MetaConverter<ClosedFloatingPointRange<Double>> {
-    override fun readOrNull(source: Meta): ClosedFloatingPointRange<Double>? = source.value?.doubleArray?.let { (start, end)->
-        start..end
-    }
+    override fun readOrNull(source: Meta): ClosedFloatingPointRange<Double>? =
+        source.value?.doubleArray?.let { (start, end) ->
+            start..end
+        }
 
     override fun convert(
         obj: ClosedFloatingPointRange<Double>,
     ): Meta = Meta(doubleArrayOf(obj.start, obj.endInclusive).asValue())
 }
 
-public val MetaConverter.Companion.doubleRange: MetaConverter<ClosedFloatingPointRange<Double>> get() = DoubleRangeConverter
\ No newline at end of file
+public val MetaConverter.Companion.doubleRange: MetaConverter<ClosedFloatingPointRange<Double>> get() = DoubleRangeConverter
+
+private object StringListConverter : MetaConverter<List<String>> {
+    override fun convert(obj: List<String>): Meta = Meta(obj.map { it.asValue() }.asValue())
+
+    override fun readOrNull(source: Meta): List<String>? = source.stringList ?: source["@jsonArray"]?.stringList
+}
+
+public val MetaConverter.Companion.stringList: MetaConverter<List<String>> get() = StringListConverter
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt
index 20c59e8..10b1196 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt
@@ -19,10 +19,13 @@ import space.kscience.dataforge.meta.Meta
 public suspend fun <T> DeviceClient.read(propertySpec: DevicePropertySpec<*, T>): T =
     propertySpec.converter.readOrNull(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
 
-
 public suspend fun <T> DeviceClient.request(propertySpec: DevicePropertySpec<*, T>): T =
     propertySpec.converter.read(getOrReadProperty(propertySpec.name))
 
+public fun <T> DeviceClient.getCached(propertySpec: DevicePropertySpec<*, T>): T? =
+    getProperty(propertySpec.name)?.let { propertySpec.converter.read(it) }
+
+
 public suspend fun <T> DeviceClient.write(propertySpec: MutableDevicePropertySpec<*, T>, value: T) {
     writeProperty(propertySpec.name, propertySpec.converter.convert(value))
 }
diff --git a/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
index 9fce04d..941068c 100644
--- a/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
@@ -3,12 +3,10 @@
 package space.kscience.controls.demo.collective
 
 import space.kscience.controls.api.Device
-import space.kscience.controls.constructor.DeviceConstructor
-import space.kscience.controls.constructor.MutableDeviceState
-import space.kscience.controls.constructor.registerAsProperty
+import space.kscience.controls.constructor.*
+import space.kscience.controls.misc.stringList
 import space.kscience.controls.peer.PeerConnection
 import space.kscience.controls.spec.DeviceSpec
-import space.kscience.controls.spec.unit
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.MetaConverter
 import space.kscience.dataforge.meta.Scheme
@@ -56,9 +54,16 @@ interface CollectiveDevice : Device {
             write = { _, value -> setVelocity(value) }
         )
 
-        val listVisible by action(MetaConverter.unit, MetaConverter.valueList<String> { it.string }) {
-            listVisible().toList()
-        }
+        val visibleNeighbors by property(
+            MetaConverter.stringList,
+            read = {
+                listVisible().toList()
+            }
+        )
+
+//        val listVisible by action(MetaConverter.unit, MetaConverter.valueList<String> { it.string }) {
+//            listVisible().toList()
+//        }
     }
 }
 
@@ -74,8 +79,28 @@ class CollectiveDeviceConstructor(
 
     override val id: CollectiveDeviceId get() = configuration.deviceId
 
-    val position = registerAsProperty(CollectiveDevice.position, position.sample(configuration.reportInterval.milliseconds))
-    val velocity = registerAsProperty(CollectiveDevice.velocity, velocity.sample(configuration.reportInterval.milliseconds))
+    val position = registerAsProperty(
+        CollectiveDevice.position,
+        position.sample(configuration.reportInterval.milliseconds)
+    )
+
+    val velocity = registerAsProperty(
+        CollectiveDevice.velocity,
+        velocity.sample(configuration.reportInterval.milliseconds)
+    )
+
+    private val _visibleNeighbors: MutableDeviceState<Collection<CollectiveDeviceId>> = stateOf(emptyList())
+
+    val visibleNeighbors = registerAsProperty(
+        CollectiveDevice.visibleNeighbors,
+        _visibleNeighbors.map { it.toList() }
+    )
+
+    init {
+        position.onNext {
+            _visibleNeighbors.value = observation.invoke().keys
+        }
+    }
 
     override suspend fun getPosition(): Gmc = position.value
 
diff --git a/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
index 2c325b4..d8c64dd 100644
--- a/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
@@ -20,8 +20,6 @@ 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.time.Duration
-import kotlin.time.Duration.Companion.milliseconds
 
 
 internal data class CollectiveDeviceState(
@@ -46,9 +44,8 @@ internal fun VirtualDeviceState(
 internal class DeviceCollectiveModel(
     context: Context,
     val deviceStates: Collection<CollectiveDeviceState>,
-    val visibilityRange: Distance = 0.4.kilometers,
+    val visibilityRange: Distance = 1.kilometers,
     val radioRange: Distance = 5.kilometers,
-    val reportInterval: Duration = 1000.milliseconds
 ) : ModelConstructor(context), PeerConnection {
 
     /**
@@ -107,6 +104,7 @@ internal fun CoroutineScope.launchCollectiveMagixServer(
     val server = startMagixServer(
 //        RSocketMagixFlowPlugin()
     )
+    val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
 
     collectiveModel.devices.forEach { (id, device) ->
         val deviceContext = collectiveModel.context.buildContext(id.parseAsName()) {
@@ -116,7 +114,7 @@ internal fun CoroutineScope.launchCollectiveMagixServer(
 
         deviceContext.install(id, device)
 
-        val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+//        val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
 
         deviceContext.request(DeviceManager).launchMagixService(deviceEndpoint, id)
     }
diff --git a/demo/device-collective/src/jvmMain/kotlin/main.kt b/demo/device-collective/src/jvmMain/kotlin/main.kt
index 9aff323..1292a0e 100644
--- a/demo/device-collective/src/jvmMain/kotlin/main.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/main.kt
@@ -10,6 +10,7 @@ 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.CircularProgressIndicator
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.runtime.*
 import androidx.compose.ui.Alignment
@@ -24,9 +25,12 @@ 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.*
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
 import org.jetbrains.compose.splitpane.HorizontalSplitPane
 import org.jetbrains.compose.splitpane.rememberSplitPaneState
@@ -47,7 +51,6 @@ import space.kscience.maps.coordinates.Gmc
 import space.kscience.maps.coordinates.meters
 import space.kscience.maps.features.*
 import java.nio.file.Path
-import kotlin.time.Duration.Companion.seconds
 
 
 @Composable
@@ -55,6 +58,8 @@ fun rememberContext(name: String, contextBuilder: ContextBuilder.() -> Unit = {}
     Context(name, contextBuilder)
 }
 
+private val gmcMetaConverter = MetaConverter.serializable<Gmc>()
+
 @Composable
 fun App() {
     val scope = rememberCoroutineScope()
@@ -82,8 +87,7 @@ fun App() {
             val magixClient = MagixEndpoint.rSocketWithWebSockets("localhost")
 
             client.complete(magixClient)
-
-            collectiveModel.roster.forEach { (id, config) ->
+                collectiveModel.roster.forEach { (id, config) ->
                 devices[id] = magixClient.remoteDevice(parentContext, "listener", id, id.parseAsName())
             }
         }
@@ -129,23 +133,14 @@ fun App() {
                     client.await().subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) ->
                         if (deviceMessage is PropertyChangedMessage && deviceMessage.property == "position") {
                             val id = magixMessage.sourceEndpoint
-                            val position = MetaConverter.serializable<Gmc>().read(deviceMessage.value)
+                            val position = gmcMetaConverter.read(deviceMessage.value)
                             val activeDevice = selectedDeviceId?.let { devices[it] }
 
-                            suspend fun DeviceClient.idIsVisible() = try {
-                                withTimeout(1.seconds) {
-                                    id in execute(CollectiveDevice.listVisible)
-                                }
-                            } catch (ex: Exception) {
-                                ex.printStackTrace()
-                                true
-                            }
-
                             if (
                                 activeDevice == null ||
                                 id == selectedDeviceId ||
                                 !showOnlyVisible ||
-                                activeDevice.idIsVisible()
+                                id in activeDevice.request(CollectiveDevice.visibleNeighbors)
                             ) {
                                 rectangle(
                                     position,
@@ -168,24 +163,29 @@ fun App() {
             Column(
                 modifier = Modifier.verticalScroll(rememberScrollState())
             ) {
-                devices.forEach { (id, _) ->
+                collectiveModel.roster.forEach { (id, _) ->
                     Card(
                         elevation = 16.dp,
                         modifier = Modifier.padding(8.dp).onClick {
                             selectedDeviceId = id
                         }.conditional(id == selectedDeviceId) {
                             border(2.dp, Color.Blue)
-                        }
+                        },
                     ) {
                         Column(
                             modifier = Modifier.padding(8.dp)
                         ) {
-                            Text(
-                                text = id,
-                                fontSize = 16.sp,
-                                fontWeight = FontWeight.Bold,
-                                modifier = Modifier.padding(10.dp).fillMaxWidth()
-                            )
+                            Row(verticalAlignment = Alignment.CenterVertically) {
+                                if (devices[id] == null) {
+                                    CircularProgressIndicator()
+                                }
+                                Text(
+                                    text = id,
+                                    fontSize = 16.sp,
+                                    fontWeight = FontWeight.Bold,
+                                    modifier = Modifier.padding(10.dp).fillMaxWidth(),
+                                )
+                            }
                             if (id == selectedDeviceId) {
                                 roster[id]?.let {
                                     Text("Meta:", color = Color.Blue, fontWeight = FontWeight.Bold)
@@ -195,14 +195,11 @@ fun App() {
                                 }
 
                                 currentPosition?.let { currentPosition ->
-                                    Text("Широта: ${String.format("%.3f", currentPosition.latitude.toDegrees().value)}")
                                     Text(
-                                        "Долгота: ${
-                                            String.format(
-                                                "%.3f",
-                                                currentPosition.longitude.toDegrees().value
-                                            )
-                                        }"
+                                        "Широта: ${String.format("%.3f", currentPosition.latitude.toDegrees().value)}"
+                                    )
+                                    Text(
+                                        "Долгота: ${String.format("%.3f", currentPosition.longitude.toDegrees().value)}"
                                     )
                                     currentPosition.elevation?.let {
                                         Text("Высота: ${String.format("%.1f", it.meters)} м")

From 60a693b1b3eaebc0ca9a2e43dbd2b7cbd096a418 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 9 Jun 2024 21:12:18 +0300
Subject: [PATCH 106/125] Fix visibility range for collective

---
 .../src/jvmMain/kotlin/DeviceCollectiveModel.kt       |  2 +-
 .../src/jvmMain/kotlin/debugModel.kt                  |  2 +-
 demo/device-collective/src/jvmMain/kotlin/main.kt     | 11 ++++++++---
 3 files changed, 10 insertions(+), 5 deletions(-)

diff --git a/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
index d8c64dd..510ab3e 100644
--- a/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
@@ -44,7 +44,7 @@ internal fun VirtualDeviceState(
 internal class DeviceCollectiveModel(
     context: Context,
     val deviceStates: Collection<CollectiveDeviceState>,
-    val visibilityRange: Distance = 1.kilometers,
+    val visibilityRange: Distance = 0.5.kilometers,
     val radioRange: Distance = 5.kilometers,
 ) : ModelConstructor(context), PeerConnection {
 
diff --git a/demo/device-collective/src/jvmMain/kotlin/debugModel.kt b/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
index d0ec384..cdca5cd 100644
--- a/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
@@ -44,7 +44,7 @@ internal fun generateModel(
         }
     }
 
-    val model = DeviceCollectiveModel(context, devices, 0.2.kilometers)
+    val model = DeviceCollectiveModel(context, devices)
 
     return model
 }
diff --git a/demo/device-collective/src/jvmMain/kotlin/main.kt b/demo/device-collective/src/jvmMain/kotlin/main.kt
index 1292a0e..b05f038 100644
--- a/demo/device-collective/src/jvmMain/kotlin/main.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/main.kt
@@ -51,6 +51,7 @@ import space.kscience.maps.coordinates.Gmc
 import space.kscience.maps.coordinates.meters
 import space.kscience.maps.features.*
 import java.nio.file.Path
+import kotlin.time.Duration.Companion.seconds
 
 
 @Composable
@@ -69,7 +70,7 @@ fun App() {
     }
 
     val collectiveModel = remember {
-        generateModel(parentContext, 60)
+        generateModel(parentContext, 100, reportInterval = 1.seconds)
     }
 
     val roster = remember {
@@ -83,12 +84,16 @@ fun App() {
 
     LaunchedEffect(collectiveModel) {
         launchCollectiveMagixServer(collectiveModel)
+
         withContext(Dispatchers.IO) {
             val magixClient = MagixEndpoint.rSocketWithWebSockets("localhost")
 
             client.complete(magixClient)
-                collectiveModel.roster.forEach { (id, config) ->
-                devices[id] = magixClient.remoteDevice(parentContext, "listener", id, id.parseAsName())
+
+            collectiveModel.roster.forEach { (id, config) ->
+                scope.launch {
+                    devices[id] = magixClient.remoteDevice(parentContext, "listener", id, id.parseAsName())
+                }
             }
         }
 

From a5bb42706b1048854bf1223b4bd34f2e2172800e Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 12 Jun 2024 11:56:27 +0300
Subject: [PATCH 107/125] Change visualization for collective

---
 .../kscience/controls/peer/PeerConnection.kt  |  2 +-
 .../src/jvmMain/kotlin/CollectiveDevice.kt    |  4 +-
 ...eDeviceState.kt => DebounceDeviceState.kt} | 11 +--
 .../jvmMain/kotlin/DeviceCollectiveModel.kt   | 39 +++++---
 .../src/jvmMain/kotlin/debugModel.kt          | 10 +--
 .../src/jvmMain/kotlin/main.kt                | 88 +++++++++++++------
 6 files changed, 102 insertions(+), 52 deletions(-)
 rename demo/device-collective/src/jvmMain/kotlin/{SampleDeviceState.kt => DebounceDeviceState.kt} (70%)

diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt
index a831207..55624b7 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt
@@ -21,7 +21,7 @@ public interface PeerConnection {
         address: String,
         contentId: String,
         requestMeta: Meta = Meta.EMPTY,
-    ): Envelope
+    ): Envelope?
 
     /**
      * Send an [envelope] to a device on a given [address]
diff --git a/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
index 941068c..82170b9 100644
--- a/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
@@ -81,12 +81,12 @@ class CollectiveDeviceConstructor(
 
     val position = registerAsProperty(
         CollectiveDevice.position,
-        position.sample(configuration.reportInterval.milliseconds)
+        position.debounce(configuration.reportInterval.milliseconds)
     )
 
     val velocity = registerAsProperty(
         CollectiveDevice.velocity,
-        velocity.sample(configuration.reportInterval.milliseconds)
+        velocity.debounce(configuration.reportInterval.milliseconds)
     )
 
     private val _visibleNeighbors: MutableDeviceState<Collection<CollectiveDeviceId>> = stateOf(emptyList())
diff --git a/demo/device-collective/src/jvmMain/kotlin/SampleDeviceState.kt b/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt
similarity index 70%
rename from demo/device-collective/src/jvmMain/kotlin/SampleDeviceState.kt
rename to demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt
index 499c903..ac699e6 100644
--- a/demo/device-collective/src/jvmMain/kotlin/SampleDeviceState.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt
@@ -2,27 +2,28 @@ package space.kscience.controls.demo.collective
 
 import kotlinx.coroutines.FlowPreview
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.debounce
 import kotlinx.coroutines.flow.sample
 import space.kscience.controls.constructor.DeviceState
 import space.kscience.controls.constructor.MutableDeviceState
 import kotlin.time.Duration
 
 @OptIn(FlowPreview::class)
-class SampleDeviceState<T>(
+class DebounceDeviceState<T>(
     val origin: DeviceState<T>,
     val interval: Duration,
 ) : DeviceState<T> {
     override val value: T by origin::value
-    override val valueFlow: Flow<T> get() = origin.valueFlow.sample(interval)
+    override val valueFlow: Flow<T> get() = origin.valueFlow.debounce(interval)
 
     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)
 
 @OptIn(FlowPreview::class)
-class MutableSampleDeviceState<T>(
+class MutableDebounceDeviceState<T>(
     val origin: MutableDeviceState<T>,
     val interval: Duration,
 ) : MutableDeviceState<T> {
@@ -32,4 +33,4 @@ class MutableSampleDeviceState<T>(
     override fun toString(): String = "DebounceDeviceState($value, interval=$interval)"
 }
 
-fun <T> MutableDeviceState<T>.sample(interval: Duration) = MutableSampleDeviceState(this, interval)
\ No newline at end of file
+fun <T> MutableDeviceState<T>.debounce(interval: Duration) = MutableDebounceDeviceState(this, interval)
\ 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 510ab3e..320f477 100644
--- a/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
@@ -4,16 +4,20 @@ import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.launch
+import kotlinx.serialization.json.Json
+import space.kscience.controls.api.DeviceMessage
 import space.kscience.controls.client.launchMagixService
 import space.kscience.controls.constructor.ModelConstructor
 import space.kscience.controls.constructor.MutableDeviceState
 import space.kscience.controls.constructor.onTimer
 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.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.magix.api.MagixEndpoint
@@ -40,13 +44,17 @@ internal fun VirtualDeviceState(
     MutableDeviceState(GmcVelocity.zero)
 )
 
+private val json = Json {
+    ignoreUnknownKeys = true
+    prettyPrint = true
+}
 
 internal class DeviceCollectiveModel(
     context: Context,
     val deviceStates: Collection<CollectiveDeviceState>,
     val visibilityRange: Distance = 0.5.kilometers,
     val radioRange: Distance = 5.kilometers,
-) : ModelConstructor(context), PeerConnection {
+) : ModelConstructor(context) {
 
     /**
      * Propagate movement
@@ -70,32 +78,39 @@ internal class DeviceCollectiveModel(
         return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange }
     }
 
+    inner class RadioPeerConnection(private val peerState: CollectiveDeviceState) : 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) ->
+                check(envelope.data != null) { "Envelope data is empty" }
+                val message = json.decodeFromString(
+                    DeviceMessage.serializer(),
+                    envelope.data?.toByteArray()?.decodeToString() ?: ""
+                )
+                target.respondMessage(id.parseAsName(), message)
+            }
+        }
+    }
+
     val devices = deviceStates.associate {
         val device = CollectiveDeviceConstructor(
             context = context,
             configuration = it.configuration,
             position = it.position,
             velocity = it.velocity,
-            peerConnection = this,
+            peerConnection = RadioPeerConnection(it),
         ) {
             locateVisible(it.id)
         }
-        //start movement program
-        device.moveInCircles()
         it.id to device
     }
 
     val roster = deviceStates.associate { it.id to it.configuration }
 
-    override suspend fun receive(address: String, contentId: String, requestMeta: Meta): Envelope {
-        TODO("Not yet implemented")
-    }
 
-    override suspend fun send(address: String, envelope: Envelope, requestMeta: Meta) {
-//        devices.values.filter { it.configuration.radioFrequency == address }.forEach { device ->
-//            ```
-//        }
-    }
 }
 
 internal fun CoroutineScope.launchCollectiveMagixServer(
diff --git a/demo/device-collective/src/jvmMain/kotlin/debugModel.kt b/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
index cdca5cd..c9e59b6 100644
--- a/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/debugModel.kt
@@ -1,10 +1,8 @@
 package space.kscience.controls.demo.collective
 
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.delay
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import space.kscience.controls.spec.write
+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
@@ -49,7 +47,7 @@ internal fun generateModel(
     return model
 }
 
-fun CollectiveDevice.moveInCircles(): Job = launch {
+fun DeviceClient.moveInCircles(scope: CoroutineScope = this): Job = scope.launch {
     var bearing = Random.nextDouble(-PI, PI).radians
     write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity))
     while (isActive) {
diff --git a/demo/device-collective/src/jvmMain/kotlin/main.kt b/demo/device-collective/src/jvmMain/kotlin/main.kt
index b05f038..0d08cc8 100644
--- a/demo/device-collective/src/jvmMain/kotlin/main.kt
+++ b/demo/device-collective/src/jvmMain/kotlin/main.kt
@@ -7,6 +7,7 @@ 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
@@ -18,19 +19,16 @@ 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.CompletableDeferred
-import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.*
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
+import kotlinx.coroutines.flow.sample
 import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
 import org.jetbrains.compose.splitpane.HorizontalSplitPane
 import org.jetbrains.compose.splitpane.rememberSplitPaneState
@@ -51,6 +49,7 @@ import space.kscience.maps.coordinates.Gmc
 import space.kscience.maps.coordinates.meters
 import space.kscience.maps.features.*
 import java.nio.file.Path
+import kotlin.time.Duration.Companion.milliseconds
 import kotlin.time.Duration.Companion.seconds
 
 
@@ -92,7 +91,8 @@ fun App() {
 
             collectiveModel.roster.forEach { (id, config) ->
                 scope.launch {
-                    devices[id] = magixClient.remoteDevice(parentContext, "listener", id, id.parseAsName())
+                    val deviceClient = magixClient.remoteDevice(parentContext, "listener", id, id.parseAsName())
+                    devices[id] = deviceClient
                 }
             }
         }
@@ -111,6 +111,8 @@ fun App() {
 
     var showOnlyVisible by remember { mutableStateOf(false) }
 
+    var movementProgram: Job? by remember { mutableStateOf(null) }
+
     HorizontalSplitPane(
         splitPaneState = rememberSplitPaneState(0.9f)
     ) {
@@ -126,9 +128,28 @@ fun App() {
             ) {
                 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)
+                    device.position.valueFlow.sample(50.milliseconds).onEach {
+                        val activeDevice = selectedDeviceId?.let { devices[it] }
+                        val color = if (selectedDeviceId == device.id) {
+                            Color.Magenta
+                        } else if (
+                            showOnlyVisible &&
+                            activeDevice != null &&
+                            device.id in activeDevice.request(CollectiveDevice.visibleNeighbors)
+                        ) {
+                            Color.Cyan
+                        } else {
+                            Color.Red
+                        }
+
+                        circle(
+                            device.position.value,
+                            id = device.id + ".position",
+                            size = if (selectedDeviceId == device.id) 6.dp else 3.dp
+                        )
+                            .color(color)
+                            .modifyAttribute(ZAttribute, 10f)
+                            .modifyAttribute(AlphaAttribute, if (selectedDeviceId == device.id) 1f else 0.5f)
                             .modifyAttribute(AlphaAttribute, 0.5f) // does not work right now
                     }.launchIn(scope)
                 }
@@ -139,24 +160,11 @@ fun App() {
                         if (deviceMessage is PropertyChangedMessage && deviceMessage.property == "position") {
                             val id = magixMessage.sourceEndpoint
                             val position = gmcMetaConverter.read(deviceMessage.value)
-                            val activeDevice = selectedDeviceId?.let { devices[it] }
 
-                            if (
-                                activeDevice == null ||
-                                id == selectedDeviceId ||
-                                !showOnlyVisible ||
-                                id in activeDevice.request(CollectiveDevice.visibleNeighbors)
-                            ) {
-                                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)
-                            }
+                            rectangle(
+                                position,
+                                id = id,
+                            ).color(Color.Blue).onClick { selectedDeviceId = id }
                         }
                     }.launchIn(scope)
 
@@ -168,6 +176,34 @@ fun App() {
             Column(
                 modifier = Modifier.verticalScroll(rememberScrollState())
             ) {
+                Button(
+                    onClick = {
+                        if (movementProgram == null) {
+                            //start movement program
+                            movementProgram = parentContext.launch {
+                                devices.values.forEach { device ->
+                                    device.moveInCircles(this)
+                                }
+                            }
+                        } else {
+                            movementProgram?.cancel()
+                            parentContext.launch {
+                                devices.values.forEach { device ->
+                                    device.write(CollectiveDevice.velocity, GmcVelocity.zero)
+                                }
+                            }
+                            movementProgram = null
+                        }
+                    },
+                    modifier = Modifier.fillMaxWidth()
+                ) {
+                    if (movementProgram == null) {
+                        Text("Move")
+                    } else {
+                        Text("Stop")
+                    }
+                }
+
                 collectiveModel.roster.forEach { (id, _) ->
                     Card(
                         elevation = 16.dp,

From eb126a60905ced9e30745f4a4e2b3d4f177ee548 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 12 Jun 2024 16:31:14 +0300
Subject: [PATCH 108/125] Finalize collective demo

---
 .../kscience/controls/api/DeviceMessage.kt    |   2 +-
 .../controls/manager/respondMessage.kt        |   2 +-
 .../src/jvmMain/kotlin/CollectiveDevice.kt    |   8 +-
 .../jvmMain/kotlin/DeviceCollectiveModel.kt   | 169 +++++++++++++++---
 .../src/jvmMain/kotlin/debugModel.kt          |  58 ------
 .../src/jvmMain/kotlin/main.kt                |  44 ++++-
 6 files changed, 188 insertions(+), 95 deletions(-)
 delete mode 100644 demo/device-collective/src/jvmMain/kotlin/debugModel.kt

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<DeviceMessage> {
     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<CollectiveDeviceId, CollectiveDeviceConfiguration>
@@ -111,4 +115,4 @@ class CollectiveDeviceConstructor(
     }
 
     override suspend fun listVisible(): Collection<CollectiveDeviceId> = 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<GmcVelocity>,
 )
 
-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<CollectiveDeviceState>,
     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<Gmc>) : 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<CollectiveDeviceState> = 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<CollectiveDeviceState> = 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<Gmc>()
+internal val gmcMetaConverter = MetaConverter.serializable<Gmc>()
+internal val gmcVelocityMetaConverter = MetaConverter.serializable<GmcVelocity>()
 
 @Composable
 fun App() {
@@ -80,7 +79,6 @@ fun App() {
 
     val devices = remember { mutableStateMapOf<CollectiveDeviceId, DeviceClient>() }
 
-
     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<Gmc?>(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) {

From 92c4355f483769af77ef0c6e938366428807e1df Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 17 Jun 2024 17:49:12 +0300
Subject: [PATCH 109/125] update device-collective readme

---
 README.md                                         |  9 +++++++++
 controls-constructor/README.md                    |  4 ++--
 controls-core/README.md                           |  4 ++--
 controls-jupyter/README.md                        |  4 ++--
 controls-magix/README.md                          |  4 ++--
 controls-modbus/README.md                         |  4 ++--
 controls-opcua/README.md                          |  4 ++--
 controls-pi/README.md                             |  4 ++--
 controls-ports-ktor/README.md                     |  4 ++--
 controls-serial/README.md                         |  4 ++--
 controls-server/README.md                         |  4 ++--
 controls-storage/README.md                        |  4 ++--
 controls-storage/controls-xodus/README.md         |  4 ++--
 controls-vision/README.md                         |  6 ++----
 demo/device-collective/README.md                  | 13 +++++++++++++
 magix/magix-api/README.md                         |  4 ++--
 magix/magix-java-endpoint/README.md               |  4 ++--
 magix/magix-mqtt/README.md                        |  4 ++--
 magix/magix-rabbit/README.md                      |  4 ++--
 magix/magix-rsocket/README.md                     |  4 ++--
 magix/magix-server/README.md                      |  4 ++--
 magix/magix-storage/README.md                     |  4 ++--
 magix/magix-storage/magix-storage-xodus/README.md |  4 ++--
 magix/magix-zmq/README.md                         |  4 ++--
 24 files changed, 66 insertions(+), 46 deletions(-)
 create mode 100644 demo/device-collective/README.md

diff --git a/README.md b/README.md
index d5baaf9..6b9c9cd 100644
--- a/README.md
+++ b/README.md
@@ -134,6 +134,11 @@ Automatically checks consistency.
 >
 > **Maturity**: PROTOTYPE
 
+### [controls-visualisation-compose](controls-visualisation-compose)
+> Visualisation extension using compose-multiplatform
+>
+> **Maturity**: PROTOTYPE
+
 ### [demo](demo)
 >
 > **Maturity**: EXPERIMENTAL
@@ -159,6 +164,10 @@ Automatically checks consistency.
 >
 > **Maturity**: EXPERIMENTAL
 
+### [demo/device-collective](demo/device-collective)
+>
+> **Maturity**: EXPERIMENTAL
+
 ### [demo/echo](demo/echo)
 >
 > **Maturity**: EXPERIMENTAL
diff --git a/controls-constructor/README.md b/controls-constructor/README.md
index d388b01..7da4d0b 100644
--- a/controls-constructor/README.md
+++ b/controls-constructor/README.md
@@ -6,7 +6,7 @@ A low-code constructor for composite devices simulation
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-constructor:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-constructor:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-constructor:0.4.0-dev-1")
+    implementation("space.kscience:controls-constructor:0.4.0-dev-4")
 }
 ```
diff --git a/controls-core/README.md b/controls-core/README.md
index 71caf53..26ed618 100644
--- a/controls-core/README.md
+++ b/controls-core/README.md
@@ -16,7 +16,7 @@ Core interfaces for building a device server
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-core:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-core:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -26,6 +26,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-core:0.4.0-dev-1")
+    implementation("space.kscience:controls-core:0.4.0-dev-4")
 }
 ```
diff --git a/controls-jupyter/README.md b/controls-jupyter/README.md
index 7d0fc4f..6100ddd 100644
--- a/controls-jupyter/README.md
+++ b/controls-jupyter/README.md
@@ -6,7 +6,7 @@
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-jupyter:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-jupyter:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-jupyter:0.4.0-dev-1")
+    implementation("space.kscience:controls-jupyter:0.4.0-dev-4")
 }
 ```
diff --git a/controls-magix/README.md b/controls-magix/README.md
index 8c16ffd..c474221 100644
--- a/controls-magix/README.md
+++ b/controls-magix/README.md
@@ -12,7 +12,7 @@ Magix service for binding controls devices (both as RPC client and server)
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-magix:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-magix:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -22,6 +22,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-magix:0.4.0-dev-1")
+    implementation("space.kscience:controls-magix:0.4.0-dev-4")
 }
 ```
diff --git a/controls-modbus/README.md b/controls-modbus/README.md
index 61e4b60..9705e81 100644
--- a/controls-modbus/README.md
+++ b/controls-modbus/README.md
@@ -14,7 +14,7 @@ Automatically checks consistency.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-modbus:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-modbus:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -24,6 +24,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-modbus:0.4.0-dev-1")
+    implementation("space.kscience:controls-modbus:0.4.0-dev-4")
 }
 ```
diff --git a/controls-opcua/README.md b/controls-opcua/README.md
index 03dc32b..6867492 100644
--- a/controls-opcua/README.md
+++ b/controls-opcua/README.md
@@ -12,7 +12,7 @@ A client and server connectors for OPC-UA via Eclipse Milo
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-opcua:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-opcua:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -22,6 +22,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-opcua:0.4.0-dev-1")
+    implementation("space.kscience:controls-opcua:0.4.0-dev-4")
 }
 ```
diff --git a/controls-pi/README.md b/controls-pi/README.md
index 2873ac2..0afb324 100644
--- a/controls-pi/README.md
+++ b/controls-pi/README.md
@@ -6,7 +6,7 @@ Utils to work with controls-kt on Raspberry pi
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-pi:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-pi:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-pi:0.4.0-dev-1")
+    implementation("space.kscience:controls-pi:0.4.0-dev-4")
 }
 ```
diff --git a/controls-ports-ktor/README.md b/controls-ports-ktor/README.md
index 6b23d80..1cb277a 100644
--- a/controls-ports-ktor/README.md
+++ b/controls-ports-ktor/README.md
@@ -6,7 +6,7 @@ Implementation of byte ports on top os ktor-io asynchronous API
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-ports-ktor:0.4.0-dev-1")
+    implementation("space.kscience:controls-ports-ktor:0.4.0-dev-4")
 }
 ```
diff --git a/controls-serial/README.md b/controls-serial/README.md
index a961f55..ce95ee5 100644
--- a/controls-serial/README.md
+++ b/controls-serial/README.md
@@ -6,7 +6,7 @@ Implementation of direct serial port communication with JSerialComm
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-serial:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-serial:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-serial:0.4.0-dev-1")
+    implementation("space.kscience:controls-serial:0.4.0-dev-4")
 }
 ```
diff --git a/controls-server/README.md b/controls-server/README.md
index 114f618..896020d 100644
--- a/controls-server/README.md
+++ b/controls-server/README.md
@@ -6,7 +6,7 @@ A combined Magix event loop server with web server for visualization.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-server:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-server:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-server:0.4.0-dev-1")
+    implementation("space.kscience:controls-server:0.4.0-dev-4")
 }
 ```
diff --git a/controls-storage/README.md b/controls-storage/README.md
index 64751e8..dd4ab3d 100644
--- a/controls-storage/README.md
+++ b/controls-storage/README.md
@@ -6,7 +6,7 @@ An API for stand-alone Controls-kt device or a hub.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-storage:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-storage:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-storage:0.4.0-dev-1")
+    implementation("space.kscience:controls-storage:0.4.0-dev-4")
 }
 ```
diff --git a/controls-storage/controls-xodus/README.md b/controls-storage/controls-xodus/README.md
index e423c3f..44cdf26 100644
--- a/controls-storage/controls-xodus/README.md
+++ b/controls-storage/controls-xodus/README.md
@@ -6,7 +6,7 @@ An implementation of controls-storage on top of JetBrains Xodus.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-xodus:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-xodus:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-xodus:0.4.0-dev-1")
+    implementation("space.kscience:controls-xodus:0.4.0-dev-4")
 }
 ```
diff --git a/controls-vision/README.md b/controls-vision/README.md
index cbfc917..ff2b648 100644
--- a/controls-vision/README.md
+++ b/controls-vision/README.md
@@ -2,13 +2,11 @@
 
 Dashboard and visualization extensions for devices
 
-Hello world!
-
 ## Usage
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-vision:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:controls-vision:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -18,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-vision:0.4.0-dev-1")
+    implementation("space.kscience:controls-vision:0.4.0-dev-4")
 }
 ```
diff --git a/demo/device-collective/README.md b/demo/device-collective/README.md
new file mode 100644
index 0000000..5f639a7
--- /dev/null
+++ b/demo/device-collective/README.md
@@ -0,0 +1,13 @@
+# Module device-collective
+
+# Running demo from gradle
+
+1. Install JDK 17-21 in the system (for example, from https://sdkman.io/jdks or https://github.com/ScoopInstaller/Java).
+2. Clone the repository with Git.
+3. Run `./gradlew :demo:device-collective:run` from the project root directory.
+
+# Install distribution
+
+1 and 2 from above.
+3. Run `./gradlew :demo:device-collective:packageUberJarForCurrentOS` from the project root directory.
+4. Go to `build/compose/jars/device-collective-<your OS>-<version>.jar`. You can copy it and run with `java -jar <full-name>.jar`
diff --git a/magix/magix-api/README.md b/magix/magix-api/README.md
index 6228a7e..6f16ac0 100644
--- a/magix/magix-api/README.md
+++ b/magix/magix-api/README.md
@@ -6,7 +6,7 @@ A kotlin API for magix standard and some zero-dependency magix services
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-api:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:magix-api:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-api:0.4.0-dev-1")
+    implementation("space.kscience:magix-api:0.4.0-dev-4")
 }
 ```
diff --git a/magix/magix-java-endpoint/README.md b/magix/magix-java-endpoint/README.md
index 2db1d42..87359f1 100644
--- a/magix/magix-java-endpoint/README.md
+++ b/magix/magix-java-endpoint/README.md
@@ -6,7 +6,7 @@ Java API to work with magix endpoints without Kotlin
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-java-endpoint:0.4.0-dev-1")
+    implementation("space.kscience:magix-java-endpoint:0.4.0-dev-4")
 }
 ```
diff --git a/magix/magix-mqtt/README.md b/magix/magix-mqtt/README.md
index 7896472..0ee054a 100644
--- a/magix/magix-mqtt/README.md
+++ b/magix/magix-mqtt/README.md
@@ -6,7 +6,7 @@ MQTT client magix endpoint
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-mqtt:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:magix-mqtt:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-mqtt:0.4.0-dev-1")
+    implementation("space.kscience:magix-mqtt:0.4.0-dev-4")
 }
 ```
diff --git a/magix/magix-rabbit/README.md b/magix/magix-rabbit/README.md
index eee4a21..35b43c7 100644
--- a/magix/magix-rabbit/README.md
+++ b/magix/magix-rabbit/README.md
@@ -6,7 +6,7 @@ RabbitMQ client magix endpoint
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-rabbit:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:magix-rabbit:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-rabbit:0.4.0-dev-1")
+    implementation("space.kscience:magix-rabbit:0.4.0-dev-4")
 }
 ```
diff --git a/magix/magix-rsocket/README.md b/magix/magix-rsocket/README.md
index ef16b66..9f3a42c 100644
--- a/magix/magix-rsocket/README.md
+++ b/magix/magix-rsocket/README.md
@@ -6,7 +6,7 @@ Magix endpoint (client) based on RSocket
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-rsocket:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:magix-rsocket:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-rsocket:0.4.0-dev-1")
+    implementation("space.kscience:magix-rsocket:0.4.0-dev-4")
 }
 ```
diff --git a/magix/magix-server/README.md b/magix/magix-server/README.md
index 17bdbdf..8259e0f 100644
--- a/magix/magix-server/README.md
+++ b/magix/magix-server/README.md
@@ -6,7 +6,7 @@ A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket route
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-server:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:magix-server:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-server:0.4.0-dev-1")
+    implementation("space.kscience:magix-server:0.4.0-dev-4")
 }
 ```
diff --git a/magix/magix-storage/README.md b/magix/magix-storage/README.md
index dbb2729..672458e 100644
--- a/magix/magix-storage/README.md
+++ b/magix/magix-storage/README.md
@@ -6,7 +6,7 @@ Magix history database API
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-storage:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:magix-storage:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-storage:0.4.0-dev-1")
+    implementation("space.kscience:magix-storage:0.4.0-dev-4")
 }
 ```
diff --git a/magix/magix-storage/magix-storage-xodus/README.md b/magix/magix-storage/magix-storage-xodus/README.md
index 90a0050..2fb495a 100644
--- a/magix/magix-storage/magix-storage-xodus/README.md
+++ b/magix/magix-storage/magix-storage-xodus/README.md
@@ -6,7 +6,7 @@
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-storage-xodus:0.4.0-dev-1")
+    implementation("space.kscience:magix-storage-xodus:0.4.0-dev-4")
 }
 ```
diff --git a/magix/magix-zmq/README.md b/magix/magix-zmq/README.md
index 6e924db..952e02e 100644
--- a/magix/magix-zmq/README.md
+++ b/magix/magix-zmq/README.md
@@ -6,7 +6,7 @@ ZMQ client endpoint for Magix
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-zmq:0.4.0-dev-1`.
+The Maven coordinates of this project are `space.kscience:magix-zmq:0.4.0-dev-4`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-zmq:0.4.0-dev-1")
+    implementation("space.kscience:magix-zmq:0.4.0-dev-4")
 }
 ```

From f13b7268d6f33a93842f64815f4444959ced2bad Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 17 Jun 2024 17:52:02 +0300
Subject: [PATCH 110/125] update device-collective readme

---
 demo/device-collective/README.md | 3 ++-
 1 file changed, 2 insertions(+), 1 deletion(-)

diff --git a/demo/device-collective/README.md b/demo/device-collective/README.md
index 5f639a7..369ec04 100644
--- a/demo/device-collective/README.md
+++ b/demo/device-collective/README.md
@@ -8,6 +8,7 @@
 
 # Install distribution
 
-1 and 2 from above.
+1. Install JDK 17-21 in the system (for example, from https://sdkman.io/jdks or https://github.com/ScoopInstaller/Java).
+2. Clone the repository with Git.
 3. Run `./gradlew :demo:device-collective:packageUberJarForCurrentOS` from the project root directory.
 4. Go to `build/compose/jars/device-collective-<your OS>-<version>.jar`. You can copy it and run with `java -jar <full-name>.jar`

From 8c7c017ab420c0625fec6e71da53e2458cd49f73 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 18 Jun 2024 19:25:55 +0300
Subject: [PATCH 111/125] Add linear drive calibration demo

---
 .../controls/constructor/devices/StepDrive.kt |  8 +--
 .../jvmMain/kotlin/LinearDriveCalibration.kt  | 66 +++++++++++++++++++
 2 files changed, 69 insertions(+), 5 deletions(-)
 create mode 100644 demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
index be32f07..ab33e39 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
@@ -22,6 +22,7 @@ import kotlin.time.DurationUnit
 public class StepDrive(
     context: Context,
     ticksPerSecond: MutableDeviceState<Double>,
+    position: MutableDeviceState<Long> = MutableDeviceState(0),
     target: MutableDeviceState<Long> = MutableDeviceState(0),
     private val writeTicks: suspend (ticks: Long, speed: Double) -> Unit = { _, _ -> },
 ) : DeviceConstructor(context) {
@@ -30,10 +31,7 @@ public class StepDrive(
 
     public val speed: MutableDeviceState<Double> by property(MetaConverter.double, ticksPerSecond)
 
-    private val positionState = stateOf(target.value)
-
-    public val position: DeviceState<Long> by property(MetaConverter.long, positionState)
-
+    public val position: DeviceState<Long> by property(MetaConverter.long, position)
 
     //FIXME round to zero problem
     private val ticker = onTimer(reads = setOf(target, position), writes = setOf(position)) { prev, next ->
@@ -46,7 +44,7 @@ public class StepDrive(
             else -> return@onTimer
         }
         writeTicks(steps, tickSpeed)
-        positionState.value += steps
+        position.value += steps
     }
 }
 
diff --git a/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt b/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt
new file mode 100644
index 0000000..1a2d5e9
--- /dev/null
+++ b/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt
@@ -0,0 +1,66 @@
+package space.kscience.controls.demo.constructor
+
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.ensureActive
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.device
+import space.kscience.controls.constructor.devices.LimitSwitch
+import space.kscience.controls.constructor.devices.StepDrive
+import space.kscience.controls.constructor.models.MutableRangeState
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.dataforge.context.Context
+
+private val ticksPerSecond = MutableDeviceState(3000.0)
+
+class LinearStepDrive(
+    context: Context,
+    drive: StepDrive,
+    atStart: LimitSwitch,
+    atEnd: LimitSwitch,
+):DeviceConstructor(context){
+    val drive by device(drive)
+    val atStart by device(atStart)
+    val atEnd by device(atEnd)
+}
+
+
+fun LinearStepDrive(
+    context: Context,
+    position: MutableRangeState<Long>,
+): LinearStepDrive  = LinearStepDrive(
+    context = context,
+    drive = StepDrive(context, ticksPerSecond, position),
+    atStart = LimitSwitch(context, position.atStart),
+    atEnd = LimitSwitch(context, position.atEnd)
+)
+
+suspend fun LinearStepDrive.calibrate(step: Long = 10): ClosedRange<Long> = coroutineScope{
+    do{
+        ensureActive()
+        drive.target.value += step
+    } while (!atEnd.locked.value)
+
+    val end = drive.position.value
+
+    do{
+        ensureActive()
+        drive.target.value -= step
+    } while (!atStart.locked.value)
+
+    val start = drive.position.value
+
+    return@coroutineScope start..end
+}
+
+suspend fun main() = coroutineScope {
+    val context = Context{
+        plugin(DeviceManager)
+    }
+
+    val positionModel = MutableRangeState<Long>(0L, -1002L..1012L)
+
+    val linearStepDrive = LinearStepDrive(context, positionModel)
+
+    println(linearStepDrive.calibrate())
+}
\ No newline at end of file

From a9d58bfac2b1da7314d7d0468e87a2abb3db74f6 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 18 Jun 2024 20:18:18 +0300
Subject: [PATCH 112/125] Fix StepDrive parameters

---
 .../controls/constructor/devices/StepDrive.kt | 15 ++++---
 .../jvmMain/kotlin/LinearDriveCalibration.kt  | 41 ++++++++++++-------
 .../constructor/src/jvmMain/kotlin/Plotter.kt |  2 +-
 3 files changed, 37 insertions(+), 21 deletions(-)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
index ab33e39..9d552c5 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
@@ -21,21 +21,26 @@ import kotlin.time.DurationUnit
  */
 public class StepDrive(
     context: Context,
-    ticksPerSecond: MutableDeviceState<Double>,
+    ticksPerSecond: Double,
     position: MutableDeviceState<Long> = MutableDeviceState(0),
-    target: MutableDeviceState<Long> = MutableDeviceState(0),
     private val writeTicks: suspend (ticks: Long, speed: Double) -> Unit = { _, _ -> },
 ) : DeviceConstructor(context) {
 
-    public val target: MutableDeviceState<Long> by property(MetaConverter.long, target)
+    public val target: MutableDeviceState<Long> by property(
+        MetaConverter.long,
+        MutableDeviceState<Long>(position.value)
+    )
 
-    public val speed: MutableDeviceState<Double> by property(MetaConverter.double, ticksPerSecond)
+    public val speed: MutableDeviceState<Double> by property(
+        MetaConverter.double,
+        MutableDeviceState<Double>(ticksPerSecond)
+    )
 
     public val position: DeviceState<Long> by property(MetaConverter.long, position)
 
     //FIXME round to zero problem
     private val ticker = onTimer(reads = setOf(target, position), writes = setOf(position)) { prev, next ->
-        val tickSpeed = ticksPerSecond.value
+        val tickSpeed = speed.value
         val timeDelta = (next - prev).toDouble(DurationUnit.SECONDS)
         val ticksDelta: Long = target.value - position.value
         val steps: Long = when {
diff --git a/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt b/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt
index 1a2d5e9..02192b9 100644
--- a/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt
+++ b/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt
@@ -1,24 +1,26 @@
 package space.kscience.controls.demo.constructor
 
 import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
 import space.kscience.controls.constructor.DeviceConstructor
-import space.kscience.controls.constructor.MutableDeviceState
+import space.kscience.controls.constructor.collectValuesIn
 import space.kscience.controls.constructor.device
 import space.kscience.controls.constructor.devices.LimitSwitch
 import space.kscience.controls.constructor.devices.StepDrive
 import space.kscience.controls.constructor.models.MutableRangeState
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.dataforge.context.Context
+import kotlin.time.Duration.Companion.seconds
 
-private val ticksPerSecond = MutableDeviceState(3000.0)
+private val ticksPerSecond = 3000.0
 
 class LinearStepDrive(
     context: Context,
     drive: StepDrive,
     atStart: LimitSwitch,
     atEnd: LimitSwitch,
-):DeviceConstructor(context){
+) : DeviceConstructor(context) {
     val drive by device(drive)
     val atStart by device(atStart)
     val atEnd by device(atEnd)
@@ -28,39 +30,48 @@ class LinearStepDrive(
 fun LinearStepDrive(
     context: Context,
     position: MutableRangeState<Long>,
-): LinearStepDrive  = LinearStepDrive(
+): LinearStepDrive = LinearStepDrive(
     context = context,
     drive = StepDrive(context, ticksPerSecond, position),
     atStart = LimitSwitch(context, position.atStart),
     atEnd = LimitSwitch(context, position.atEnd)
 )
 
-suspend fun LinearStepDrive.calibrate(step: Long = 10): ClosedRange<Long> = coroutineScope{
-    do{
-        ensureActive()
-        drive.target.value += step
-    } while (!atEnd.locked.value)
-
-    val end = drive.position.value
-
-    do{
+suspend fun LinearStepDrive.calibrate(step: Long = 10): ClosedRange<Long> = coroutineScope {
+    do {
         ensureActive()
         drive.target.value -= step
+        delay((step / ticksPerSecond).seconds)
     } while (!atStart.locked.value)
 
     val start = drive.position.value
 
+
+    do {
+        ensureActive()
+        drive.target.value += step
+        delay((step / ticksPerSecond).seconds)
+    } while (!atEnd.locked.value)
+
+    val end = drive.position.value
+
     return@coroutineScope start..end
 }
 
 suspend fun main() = coroutineScope {
-    val context = Context{
+    val context = Context {
         plugin(DeviceManager)
     }
 
-    val positionModel = MutableRangeState<Long>(0L, -1002L..1012L)
+    val positionModel = MutableRangeState<Long>(0L, -1000L..1012L)
 
     val linearStepDrive = LinearStepDrive(context, positionModel)
 
+    val printJob = linearStepDrive.drive.target.collectValuesIn(this){
+        println("Move to $it")
+    }
+
     println(linearStepDrive.calibrate())
+
+    printJob.cancel()
 }
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
index dfded59..3e139e3 100644
--- a/demo/constructor/src/jvmMain/kotlin/Plotter.kt
+++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
@@ -83,7 +83,7 @@ private suspend fun Plotter.square(xRange: IntRange, yRange: IntRange) {
 
 private val xRange = NumericalValue<Meters>(-0.5)..NumericalValue<Meters>(0.5)
 private val yRange = NumericalValue<Meters>(-0.5)..NumericalValue<Meters>(0.5)
-private val ticksPerSecond = MutableDeviceState(3000.0)
+private const val ticksPerSecond = 3000.0
 private val step = NumericalValue<Degrees>(1.8)
 
 

From fbf79f0a3739f23b53e245921aaafebd9171f4cc Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Mon, 22 Jul 2024 18:46:58 +0300
Subject: [PATCH 113/125] Implement new visualization

---
 CHANGELOG.md                                  |   1 +
 build.gradle.kts                              |   2 +-
 .../controls/constructor/internalState.kt     |   4 +-
 controls-visualisation-compose/README.md      |  21 +++
 .../build.gradle.kts                          |  24 +---
 .../src/commonMain/kotlin/DeviceDrawable2D.kt |  64 +++++++++
 .../kotlin/DeviceDrawable2DStore.kt           |  91 +++++++++++++
 demo/car/build.gradle.kts                     |   4 +-
 .../constructor/src/jvmMain/kotlin/Plotter.kt | 126 ++++++++++++------
 demo/echo/build.gradle.kts                    |   4 +-
 demo/many-devices/build.gradle.kts            |   4 +-
 gradle/libs.versions.toml                     |   4 +-
 magix/magix-java-endpoint/build.gradle.kts    |  17 +--
 13 files changed, 280 insertions(+), 86 deletions(-)
 create mode 100644 controls-visualisation-compose/README.md
 create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt
 create mode 100644 controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt

diff --git a/CHANGELOG.md b/CHANGELOG.md
index ae8c47f..29b1a5c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
 - Shortcuts to access all Controls devices in a magix network.
 - `DeviceClient` properly evaluates lifecycle and logs
 - `PeerConnection` API for direct device-device binary sharing
+- DeviceDrawable2D intermediate visualization implementation
 
 ### Changed
 - Constructor properties return `DeviceState` in order to be able to subscribe to them
diff --git a/build.gradle.kts b/build.gradle.kts
index 773272c..7f7e534 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -7,7 +7,7 @@ plugins {
 
 allprojects {
     group = "space.kscience"
-    version = "0.4.0-dev-4"
+    version = "0.4.0-dev-5"
     repositories{
         google()
     }
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
index 687804e..684f666 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
@@ -11,7 +11,7 @@ import kotlinx.coroutines.flow.emptyFlow
  */
 private class VirtualDeviceState<T>(
     initialValue: T,
-    private val callback: (T) -> Unit = {},
+    private val callback: (T) -> Unit = {}
 ) : MutableDeviceState<T> {
     private val flow = MutableStateFlow(initialValue)
     override val valueFlow: Flow<T> get() = flow
@@ -34,7 +34,7 @@ private class VirtualDeviceState<T>(
  */
 public fun <T> MutableDeviceState(
     initialValue: T,
-    callback: (T) -> Unit = {},
+    callback: (T) -> Unit = {}
 ): MutableDeviceState<T> = VirtualDeviceState(initialValue, callback)
 
 
diff --git a/controls-visualisation-compose/README.md b/controls-visualisation-compose/README.md
new file mode 100644
index 0000000..0f77d54
--- /dev/null
+++ b/controls-visualisation-compose/README.md
@@ -0,0 +1,21 @@
+# Module controls-visualisation-compose
+
+Visualisation extension using compose-multiplatform
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:controls-visualisation-compose:0.4.0-dev-4`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:controls-visualisation-compose:0.4.0-dev-4")
+}
+```
diff --git a/controls-visualisation-compose/build.gradle.kts b/controls-visualisation-compose/build.gradle.kts
index d9d8215..0c68988 100644
--- a/controls-visualisation-compose/build.gradle.kts
+++ b/controls-visualisation-compose/build.gradle.kts
@@ -18,25 +18,11 @@ kscience {
     useContextReceivers()
     commonMain {
         api(projects.controlsConstructor)
-        api("io.github.koalaplot:koalaplot-core:0.6.0")
-    }
-}
-
-kotlin {
-    sourceSets {
-        commonMain {
-            dependencies {
-                api(compose.foundation)
-                api(compose.material3)
-                @OptIn(ExperimentalComposeLibrary::class)
-                api(compose.desktop.components.splitPane)
-            }
-        }
-//        jvmMain {
-//            dependencies {
-//                implementation(compose.desktop.currentOs)
-//            }
-//        }
+        api(libs.koala.plots)
+        api(compose.foundation)
+        api(compose.material3)
+        @OptIn(ExperimentalComposeLibrary::class)
+        api(compose.desktop.components.splitPane)
     }
 }
 
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt
new file mode 100644
index 0000000..464514b
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt
@@ -0,0 +1,64 @@
+package space.kscience.controls.compose
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.rotate
+
+/**
+ * A single 2D drawable
+ */
+@Immutable
+public sealed interface DeviceDrawable2D {
+
+    public fun DrawScope.draw()
+
+    override fun equals(other: Any?): Boolean
+}
+
+@Immutable
+public data class CircleDrawable2D(val position: Offset, val radius: Float, val color: Color) : DeviceDrawable2D {
+    override fun DrawScope.draw() {
+        drawCircle(color, radius = radius, center = position)
+    }
+}
+
+@Drawable2DBuilder
+public fun DeviceDrawable2DStore.circle(id: String, position: Offset, radius: Float, color: Color) {
+    emit(id, CircleDrawable2D(position, radius, color))
+}
+
+@Immutable
+public data class RectangleDrawable2D(
+    val position: Offset,
+    val rectangleSize: Size,
+    val color: Color,
+    val rotateDegrees: Float = 0f,
+) : DeviceDrawable2D {
+    override fun DrawScope.draw() {
+        rotate(rotateDegrees) {
+            drawRect(
+                color = color,
+                topLeft = Offset(
+                    (position.x - rectangleSize.width / 2),
+                    (position.y - rectangleSize.height / 2)
+                ),
+                size = Size(rectangleSize.width, rectangleSize.height)
+            )
+        }
+    }
+}
+
+@Drawable2DBuilder
+public fun DeviceDrawable2DStore.rectangle(
+    id: String,
+    position: Offset,
+    rectangleSize: Size,
+    color: Color,
+    rotateDegrees: Float = 0f,
+) {
+    emit(id, RectangleDrawable2D(position, rectangleSize, color, rotateDegrees))
+}
+
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt
new file mode 100644
index 0000000..1c17aad
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt
@@ -0,0 +1,91 @@
+package space.kscience.controls.compose
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.*
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.drawscope.clipRect
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.unit.toSize
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import space.kscience.controls.api.Device
+import space.kscience.controls.constructor.DeviceState
+import space.kscience.controls.spec.DevicePropertySpec
+import space.kscience.controls.spec.propertyFlow
+
+@DslMarker
+public annotation class Drawable2DBuilder
+
+@Drawable2DBuilder
+public class DeviceDrawable2DStore(public val scope: CoroutineScope, public val size: Size) {
+    public val drawableFlow: MutableStateFlow<Map<String, DeviceDrawable2D>> = MutableStateFlow(emptyMap())
+}
+
+public fun DeviceDrawable2DStore.emit(id: String, drawable2D: DeviceDrawable2D){
+    drawableFlow.value += (id to drawable2D)
+}
+
+public fun DeviceDrawable2DStore.emitAll(drawables: Map<String, DeviceDrawable2D>){
+    drawableFlow.value += drawables
+}
+
+
+/**
+ * Fill drawables from a flow
+ */
+public fun DeviceDrawable2DStore.observe(id: String, flow: Flow<DeviceDrawable2D>): Job = flow.onEach {
+    drawableFlow.value += (id to it)
+}.launchIn(scope)
+
+/**
+ * Observe single [DeviceState]
+ */
+public fun <T> DeviceDrawable2DStore.observeState(
+    state: DeviceState<T>,
+    id: String = state.toString(),
+    transform: suspend DeviceDrawable2DStore.(T) -> DeviceDrawable2D,
+): Job = observe(id, state.valueFlow.map { transform(this, it) })
+
+/**
+ * Observe a single [Device] property
+ */
+public fun <T, D : Device, P : DevicePropertySpec<D, T>> DeviceDrawable2DStore.observeProperty(
+    device: D,
+    devicePropertySpec: DevicePropertySpec<D, T>,
+    id: String = devicePropertySpec.toString(),
+    transform: suspend DeviceDrawable2DStore.(T) -> DeviceDrawable2D,
+): Job = observe(id, device.propertyFlow(devicePropertySpec).map { transform(this, it) })
+
+@Composable
+public fun Device2DCanvas(
+    modifier: Modifier = Modifier,
+    flowBuilder: suspend DeviceDrawable2DStore.() -> Unit,
+) {
+    val coroutineScope = rememberCoroutineScope()
+    var canvasSize by remember { mutableStateOf(Size(100f, 100f)) }
+
+    val store = remember(canvasSize) {
+        DeviceDrawable2DStore(coroutineScope, canvasSize).apply {
+            coroutineScope.launch {
+                flowBuilder()
+            }
+        }
+    }
+
+    val drawables by store.drawableFlow.collectAsState()
+
+    key(store) {
+        Canvas(modifier.onGloballyPositioned {
+            canvasSize = it.size.toSize()
+        }) {
+            clipRect {
+                drawables.values.forEach {
+                    with(it) { draw() }
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/demo/car/build.gradle.kts b/demo/car/build.gradle.kts
index b8a7fb6..967b87d 100644
--- a/demo/car/build.gradle.kts
+++ b/demo/car/build.gradle.kts
@@ -37,8 +37,8 @@ kotlin{
 }
 
 tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
-    kotlinOptions {
-        freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn")
+    compilerOptions {
+        freeCompilerArgs.addAll("-Xjvm-default=all")
     }
 }
 
diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
index 474f5c2..16006d6 100644
--- a/demo/constructor/src/jvmMain/kotlin/Plotter.kt
+++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
@@ -1,19 +1,29 @@
 package space.kscience.controls.demo.constructor
 
-import androidx.compose.foundation.Canvas
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
 import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.material.MaterialTheme
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.material3.Button
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
 import androidx.compose.runtime.*
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Offset
 import androidx.compose.ui.geometry.Size
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
 import androidx.compose.ui.window.Window
 import androidx.compose.ui.window.application
+import kotlinx.coroutines.Job
 import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
+import org.jetbrains.compose.splitpane.HorizontalSplitPane
+import space.kscience.controls.compose.*
 import space.kscience.controls.constructor.*
 import space.kscience.controls.constructor.devices.LimitSwitch
 import space.kscience.controls.constructor.devices.StepDrive
@@ -121,24 +131,30 @@ private class PlotterModel(
         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),
+        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))
     }
 }
 
+private val range = -1000..1000
+
+@OptIn(ExperimentalSplitPaneApi::class)
 suspend fun main() = application {
     Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
         window.minimumSize = Dimension(400, 400)
 
-        val points = remember { mutableStateListOf<PlotterPoint>() }
-        var position by remember { mutableStateOf(XY<Meters>(0, 0)) }
+        val scope = rememberCoroutineScope()
 
-        LaunchedEffect(Unit) {
+        var updateJob: Job? = remember { null }
+
+        var points by remember { mutableStateOf<List<PlotterPoint>>(emptyList()) }
+
+        val plotterModel = remember {
             val context = Context {
                 plugin(DeviceManager)
                 plugin(ClockManager)
@@ -146,53 +162,81 @@ suspend fun main() = application {
 
             /* Here goes the device definition block */
 
-            val plotterModel = PlotterModel(context) { plotterPoint ->
-                points.add(plotterPoint)
+            PlotterModel(context) { plotterPoint ->
+                points += plotterPoint
             }
-
-            /* Start visualization program */
-
-            plotterModel.xy.valueFlow.onEach {
-                position = it
-            }.launchIn(this)
-
-            /* run program */
-
-
-            val range = -1000..1000
-//            plotterModel.plotter.modernArt(range, range)
-            plotterModel.plotter.square(range, range)
-
         }
 
-
         /* Here goes the visualization block */
 
         MaterialTheme {
-            Canvas(modifier = Modifier.fillMaxSize()) {
-                fun toOffset(x: NumericalValue<Meters>, y: NumericalValue<Meters>): Offset {
-                    val canvasX = (x - xRange.start) / (xRange.endInclusive - xRange.start) * size.width
-                    val canvasY = (y - yRange.start) / (yRange.endInclusive - yRange.start) * size.height
-                    return Offset(canvasX.toFloat(), canvasY.toFloat())
+            HorizontalSplitPane {
+                first(200.dp) {
+                    Column(modifier = Modifier.fillMaxHeight()) {
+                        Button({
+                            updateJob?.cancel()
+                            updateJob = scope.launch {
+                                plotterModel.plotter.square(range, range)
+                            }
+                        }, modifier = Modifier.fillMaxWidth()) {
+                            Text("Rectangle")
+                        }
+                        Button({
+                            updateJob?.cancel()
+                            updateJob = scope.launch {
+                                plotterModel.plotter.modernArt(range, range)
+                            }
+                        }, modifier = Modifier.fillMaxWidth()) {
+                            Text("Modern Art")
+                        }
+                        Button({
+                            updateJob?.cancel()
+                        }, modifier = Modifier.fillMaxWidth()) {
+                            Text("Stop")
+                        }
+                    }
+
                 }
+                second {
+                    Device2DCanvas(modifier = Modifier.fillMaxSize()) {
+                        fun xToPx(x: NumericalValue<Meters>): Float =
+                            ((x - xRange.start) / (xRange.endInclusive - xRange.start) * size.width).toFloat()
 
-                val center = toOffset(position.x, position.y)
+                        fun yToPx(y: NumericalValue<Meters>): Float =
+                            ((y - yRange.start) / (yRange.endInclusive - yRange.start) * size.height).toFloat()
 
 
-                drawRect(
-                    Color.LightGray,
-                    topLeft = Offset(0f, center.y - 5f),
-                    size = Size(size.width, 10f)
-                )
+                        fun toOffset(xy: XY<Meters>): Offset = Offset(xToPx(xy.x), yToPx(xy.y))
 
-                drawCircle(Color.Black, radius = 10f, center = center)
+                        observeState(plotterModel.y, "beam") { y ->
+                            RectangleDrawable2D(
+                                position = Offset(size.width / 2, yToPx(y)),
+                                rectangleSize = Size(size.width, 10f),
+                                color = Color.LightGray
+                            )
+                        }
 
+                        observeState(plotterModel.xy, "head") { xy ->
+                            CircleDrawable2D(
+                                position = toOffset(xy),
+                                radius = 10f,
+                                color = Color.Black
+                            )
+                        }
 
-                points.forEach {
-                    drawCircle(it.color, radius = 2f, center = toOffset(it.x, it.y))
+                        snapshotFlow { points }.onEach {
+                            it.forEachIndexed { index, plotterPoint ->
+                                circle(
+                                    "point[$index]",
+                                    Offset(xToPx(plotterPoint.x), yToPx(plotterPoint.y)),
+                                    radius = 5f,
+                                    color = plotterPoint.color
+                                )
+                            }
+                        }.launchIn(scope)
+                    }
                 }
             }
         }
     }
-
 }
\ No newline at end of file
diff --git a/demo/echo/build.gradle.kts b/demo/echo/build.gradle.kts
index 873220e..ed722bf 100644
--- a/demo/echo/build.gradle.kts
+++ b/demo/echo/build.gradle.kts
@@ -21,8 +21,8 @@ kotlin{
 }
 
 tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
-    kotlinOptions {
-        freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn")
+    compilerOptions {
+        freeCompilerArgs.addAll("-Xjvm-default=all")
     }
 }
 
diff --git a/demo/many-devices/build.gradle.kts b/demo/many-devices/build.gradle.kts
index 64fbfe2..5743341 100644
--- a/demo/many-devices/build.gradle.kts
+++ b/demo/many-devices/build.gradle.kts
@@ -26,8 +26,8 @@ kotlin{
 
 
 tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
-    kotlinOptions {
-        freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn")
+    compilerOptions {
+        freeCompilerArgs.addAll("-Xjvm-default=all")
     }
 }
 
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 7c5a861..207964a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -81,7 +81,9 @@ visionforge-markdown = { module = "space.kscience:visionforge-markdown", version
 visionforge-server = { module = "space.kscience:visionforge-server", version.ref = "visionforge" }
 visionforge-compose-html = { module = "space.kscience:visionforge-compose-html", version.ref = "visionforge" }
 
-sciprog-maps-compose = "space.kscience:maps-kt-compose:0.3.0"
+sciprog-maps-compose = { module = "space.kscience:maps-kt-compose", version = "0.3.0" }
+
+koala-plots = { module = "io.github.koalaplot:koalaplot-core", version = "0.6.1" }
 
 # Buildscript
 
diff --git a/magix/magix-java-endpoint/build.gradle.kts b/magix/magix-java-endpoint/build.gradle.kts
index ce20b5d..68c7075 100644
--- a/magix/magix-java-endpoint/build.gradle.kts
+++ b/magix/magix-java-endpoint/build.gradle.kts
@@ -1,5 +1,3 @@
-import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
-import space.kscience.gradle.KScienceVersions
 import space.kscience.gradle.Maturity
 
 plugins {
@@ -17,19 +15,6 @@ dependencies {
     implementation(spclibs.kotlinx.coroutines.jdk9)
 }
 
-//java {
-//    sourceCompatibility = KScienceVersions.JVM_TARGET
-//    targetCompatibility = KScienceVersions.JVM_TARGET
-//}
-
-
-//FIXME https://youtrack.jetbrains.com/issue/KT-52815/Compiler-option-Xjdk-release-fails-to-compile-mixed-projects
-tasks.withType<KotlinCompile>{
-    kotlinOptions {
-        freeCompilerArgs -= "-Xjdk-release=11"
-    }
-}
-
-readme{
+readme {
     maturity = Maturity.EXPERIMENTAL
 }
\ No newline at end of file

From f0f9d0e174ff3744490a22a678e60c38b32f30ee Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Tue, 23 Jul 2024 20:32:01 +0300
Subject: [PATCH 114/125] Add direct canvas customization for DeviceDrawable2D

---
 .../kscience/controls/constructor/devices/LimitSwitch.kt   | 2 +-
 .../src/commonMain/kotlin/DeviceDrawable2DStore.kt         | 7 +++++--
 2 files changed, 6 insertions(+), 3 deletions(-)

diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt
index cba6b83..d06a385 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt
@@ -14,7 +14,7 @@ import space.kscience.dataforge.context.Context
 
 
 /**
- * Virtual [LimitSwitch]
+ * A device that detects if a motor hits the end of its range
  */
 public class LimitSwitch(
     context: Context,
diff --git a/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt
index 1c17aad..94bf76c 100644
--- a/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt
+++ b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt
@@ -4,6 +4,7 @@ import androidx.compose.foundation.Canvas
 import androidx.compose.runtime.*
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.drawscope.DrawScope
 import androidx.compose.ui.graphics.drawscope.clipRect
 import androidx.compose.ui.layout.onGloballyPositioned
 import androidx.compose.ui.unit.toSize
@@ -24,11 +25,11 @@ public class DeviceDrawable2DStore(public val scope: CoroutineScope, public val
     public val drawableFlow: MutableStateFlow<Map<String, DeviceDrawable2D>> = MutableStateFlow(emptyMap())
 }
 
-public fun DeviceDrawable2DStore.emit(id: String, drawable2D: DeviceDrawable2D){
+public fun DeviceDrawable2DStore.emit(id: String, drawable2D: DeviceDrawable2D) {
     drawableFlow.value += (id to drawable2D)
 }
 
-public fun DeviceDrawable2DStore.emitAll(drawables: Map<String, DeviceDrawable2D>){
+public fun DeviceDrawable2DStore.emitAll(drawables: Map<String, DeviceDrawable2D>) {
     drawableFlow.value += drawables
 }
 
@@ -62,6 +63,7 @@ public fun <T, D : Device, P : DevicePropertySpec<D, T>> DeviceDrawable2DStore.o
 @Composable
 public fun Device2DCanvas(
     modifier: Modifier = Modifier,
+    onDraw: DrawScope.() -> Unit = {},
     flowBuilder: suspend DeviceDrawable2DStore.() -> Unit,
 ) {
     val coroutineScope = rememberCoroutineScope()
@@ -85,6 +87,7 @@ public fun Device2DCanvas(
                 drawables.values.forEach {
                     with(it) { draw() }
                 }
+                onDraw()
             }
         }
     }

From 47327aef19954032adba2233e58ca188c133913f Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Thu, 25 Jul 2024 12:28:58 +0300
Subject: [PATCH 115/125] Add message flow output for PidDemo

---
 .space.kts                                    | 45 -------------------
 build.gradle.kts                              |  1 +
 .../controls/demo/DemoControllerView.kt       |  6 ++-
 .../constructor/src/jvmMain/kotlin/PidDemo.kt | 18 ++++++--
 gradle/libs.versions.toml                     |  5 +--
 5 files changed, 21 insertions(+), 54 deletions(-)
 delete mode 100644 .space.kts

diff --git a/.space.kts b/.space.kts
deleted file mode 100644
index c5dd962..0000000
--- a/.space.kts
+++ /dev/null
@@ -1,45 +0,0 @@
-import kotlin.io.path.readText
-
-job("Build") {
-    gradlew("spc.registry.jetbrains.space/p/sci/containers/kotlin-ci:1.0.3", "build")
-}
-
-job("Publish") {
-    startOn {
-        gitPush { enabled = false }
-    }
-    container("spc.registry.jetbrains.space/p/sci/containers/kotlin-ci:1.0.3") {
-        env["SPACE_USER"] = "{{ project:space_user }}"
-        env["SPACE_TOKEN"] = "{{ project:space_token }}"
-        kotlinScript { api ->
-
-            val spaceUser = System.getenv("SPACE_USER")
-            val spaceToken = System.getenv("SPACE_TOKEN")
-
-            // write the version to the build directory
-            api.gradlew("version")
-
-            //read the version from build file
-            val version = java.nio.file.Path.of("build/project-version.txt").readText()
-
-            val revisionSuffix = if (version.endsWith("SNAPSHOT")) {
-                "-" + api.gitRevision().take(7)
-            } else {
-                ""
-            }
-
-            api.space().projects.automation.deployments.start(
-                project = api.projectIdentifier(),
-                targetIdentifier = TargetIdentifier.Key("maps-kt"),
-                version = version+revisionSuffix,
-                // automatically update deployment status based on the status of a job
-                syncWithAutomationJob = true
-            )
-            api.gradlew(
-                "publishAllPublicationsToSpaceRepository",
-                "-Ppublishing.space.user=\"$spaceUser\"",
-                "-Ppublishing.space.token=\"$spaceToken\"",
-            )
-        }
-    }
-}
\ No newline at end of file
diff --git a/build.gradle.kts b/build.gradle.kts
index 7f7e534..b9b6d19 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -3,6 +3,7 @@ import space.kscience.gradle.useSPCTeam
 
 plugins {
     id("space.kscience.gradle.project")
+    alias(libs.plugins.versions)
 }
 
 allprojects {
diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
index f107f56..fc72520 100644
--- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
+++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt
@@ -82,22 +82,24 @@ class DemoController : ContextAware {
         opcUaServer.startup()
         opcUaServer.serveDevices(deviceManager)
 
-
+        //create a remote listener endpoint
         val listenerEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
 
+        // subscribe remote endpoint
         listenerEndpoint.subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) ->
             // print all messages that are not property change message
             if (deviceMessage !is PropertyChangedMessage) {
                 println(">> ${json.encodeToString(MagixMessage.serializer(), magixMessage)}")
             }
         }.launchIn(this)
+
+        // send description request
         listenerEndpoint.send(
             format = DeviceManager.magixFormat,
             payload = GetDescriptionMessage(),
             source = "listener",
 //            target = "demoDevice"
         )
-
     }
 
     fun shutdown(): Job = context.launch {
diff --git a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
index 81a5923..9411321 100644
--- a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
+++ b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
@@ -19,10 +19,13 @@ import io.github.koalaplot.core.util.toString
 import io.github.koalaplot.core.xygraph.XYGraph
 import io.github.koalaplot.core.xygraph.rememberDoubleLinearAxisModel
 import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.filterIsInstance
+import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
 import kotlinx.datetime.Instant
 import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi
 import org.jetbrains.compose.splitpane.HorizontalSplitPane
+import space.kscience.controls.api.PropertyChangedMessage
 import space.kscience.controls.compose.NumberTextField
 import space.kscience.controls.compose.PlotNumericState
 import space.kscience.controls.compose.TimeAxisModel
@@ -39,11 +42,9 @@ import space.kscience.controls.constructor.onTimer
 import space.kscience.controls.constructor.units.Kilograms
 import space.kscience.controls.constructor.units.Meters
 import space.kscience.controls.constructor.units.NumericalValue
-import space.kscience.controls.manager.ClockManager
-import space.kscience.controls.manager.DeviceManager
-import space.kscience.controls.manager.clock
-import space.kscience.controls.manager.install
+import space.kscience.controls.manager.*
 import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.request
 import java.awt.Dimension
 import kotlin.math.PI
 import kotlin.math.sin
@@ -165,6 +166,15 @@ fun main() = application {
 
     //bind pid parameters
     LaunchedEffect(Unit) {
+
+        // start listening to local device hub
+        context.request(DeviceManager).hubMessageFlow()
+            .filterIsInstance<PropertyChangedMessage>() // filter only property change messages
+            //.filter { it.sourceDevice == "linearDrive".asName()} //optionally filter by device name
+            .onEach {
+                println("${it.sourceDevice} >> ${it.property} changed to ${it.value}")
+            }.launchIn(this)
+
         snapshotFlow {
             pidParameters
         }.onEach {
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 207964a..daf1fd6 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -31,8 +31,6 @@ plc4j = "0.12.0"
 
 visionforge = "0.4.2"
 
-versions = "0.51.0"
-
 [libraries]
 
 dataforge-io = { module = "space.kscience:dataforge-io", version.ref = "dataforge" }
@@ -89,4 +87,5 @@ koala-plots = { module = "io.github.koalaplot:koalaplot-core", version = "0.6.1"
 
 [plugins]
 
-versions = { id = "com.github.ben-manes.versions", version.ref = "versions" }
+versions = "com.github.ben-manes.versions:0.51.0"
+versions-update = "nl.littlerobots.version-catalog-update:0.8.4"

From c12f1ce1cdd7f1423fd33e6037447bd9c8285d51 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 3 Aug 2024 21:11:59 +0300
Subject: [PATCH 116/125] Add lifecycle to ports. Suspended device start

---
 CHANGELOG.md                                  |  3 +
 .../controls/constructor/DeviceGroup.kt       |  6 +-
 .../controls/constructor/DeviceGroupTest.kt   |  4 +-
 .../controls/api/AsynchronousSocket.kt        | 12 +---
 .../space/kscience/controls/api/Device.kt     | 47 ++++-----------
 .../kscience/controls/api/DeviceMessage.kt    |  2 +-
 .../kscience/controls/api/WithLifeCycle.kt    | 59 +++++++++++++++++++
 .../controls/ports/AsynchronousPort.kt        | 11 ++--
 .../controls/ports/SynchronousPort.kt         | 18 +++---
 .../kscience/controls/spec/DeviceBase.kt      | 13 ++--
 .../kscience/controls/spec/DeviceSpec.kt      |  2 +-
 .../kscience/controls/ports/ChannelPort.kt    | 18 +++---
 .../kscience/controls/ports/UdpSocketPort.kt  |  9 +--
 .../controls/ports/AsynchronousPortIOTest.kt  |  8 +--
 .../kscience/controls/client/DeviceClient.kt  |  4 +-
 .../controls/pi/AsynchronousPiPort.kt         | 10 ++--
 .../kscience/controls/pi/SynchronousPiPort.kt | 15 +++--
 .../kscience/controls/ports/KtorTcpPort.kt    | 16 ++---
 .../kscience/controls/ports/KtorUdpPort.kt    | 16 ++---
 .../controls/serial/AsynchronousSerialPort.kt | 13 ++--
 .../controls/serial/SynchronousSerialPort.kt  | 16 ++---
 .../sciprog/devices/mks/MksPdr900Device.kt    |  4 +-
 .../pimotionmaster/PiMotionMasterDevice.kt    |  4 +-
 .../PiMotionMasterVirtualDevice.kt            | 17 +++---
 24 files changed, 185 insertions(+), 142 deletions(-)
 create mode 100644 controls-core/src/commonMain/kotlin/space/kscience/controls/api/WithLifeCycle.kt

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 29b1a5c..26cb25f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,7 @@
 - `DeviceClient` properly evaluates lifecycle and logs
 - `PeerConnection` API for direct device-device binary sharing
 - DeviceDrawable2D intermediate visualization implementation
+- New interface `WithLifeCycle`. Change Port API to adhere to it.
 
 ### Changed
 - Constructor properties return `DeviceState` in order to be able to subscribe to them
@@ -16,6 +17,8 @@
 - `DeviceClient` now initializes property and action descriptors eagerly.
 - `DeviceHub` now works with `Name` instead of `NameToken`. Tree-like structure is made using `Path`. Device messages no longer have access to sub-devices.
 - Add some utility methods to ports. Synchronous port response could be now consumed as `Source`.
+- `DeviceLifecycleState` is replaced by `LifecycleState`.
+
 
 ### Deprecated
 
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
index a41899a..79ff2ed 100644
--- a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -3,7 +3,7 @@ package space.kscience.controls.constructor
 import kotlinx.coroutines.*
 import kotlinx.coroutines.flow.*
 import space.kscience.controls.api.*
-import space.kscience.controls.api.DeviceLifecycleState.*
+import space.kscience.controls.api.LifecycleState.*
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
 import space.kscience.controls.spec.DevicePropertySpec
@@ -165,11 +165,11 @@ public open class DeviceGroup(
         return action(argument)
     }
 
-    final override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
+    final override var lifecycleState: LifecycleState = LifecycleState.STOPPED
         private set
 
 
-    private suspend fun setLifecycleState(lifecycleState: DeviceLifecycleState) {
+    private suspend fun setLifecycleState(lifecycleState: LifecycleState) {
         this.lifecycleState = lifecycleState
         sharedMessageFlow.emit(
             DeviceLifeCycleMessage(lifecycleState)
diff --git a/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt
index 40f00dc..aa46133 100644
--- a/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt
+++ b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt
@@ -4,7 +4,7 @@ import kotlinx.coroutines.flow.first
 import kotlinx.coroutines.test.runTest
 import space.kscience.controls.api.Device
 import space.kscience.controls.api.DeviceLifeCycleMessage
-import space.kscience.controls.api.DeviceLifecycleState
+import space.kscience.controls.api.LifecycleState
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
 import space.kscience.controls.spec.doRecurring
@@ -37,7 +37,7 @@ class DeviceGroupTest {
             }
             error("Error!")
         }
-        testDevice.messageFlow.first { it is DeviceLifeCycleMessage && it.state == DeviceLifecycleState.STOPPED }
+        testDevice.messageFlow.first { it is DeviceLifeCycleMessage && it.state == LifecycleState.STOPPED }
         println("stopped")
     }
 }
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt
index defefb2..7c56084 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt
@@ -5,7 +5,7 @@ import kotlinx.coroutines.flow.Flow
 /**
  * A generic bidirectional asynchronous sender/receiver object
  */
-public interface AsynchronousSocket<T> : AutoCloseable {
+public interface AsynchronousSocket<T> : WithLifeCycle {
     /**
      * Send an object to the socket
      */
@@ -15,16 +15,6 @@ public interface AsynchronousSocket<T> : AutoCloseable {
      * Flow of objects received from socket
      */
     public fun subscribe(): Flow<T>
-
-    /**
-     * Start socket operation
-     */
-    public fun open()
-
-    /**
-     * Check if this socket is open
-     */
-    public val isOpen: Boolean
 }
 
 /**
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index 240eaa0..1e941bb 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
@@ -4,7 +4,6 @@ import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
 import kotlinx.coroutines.cancel
 import kotlinx.coroutines.flow.*
-import kotlinx.serialization.Serializable
 import space.kscience.controls.api.Device.Companion.DEVICE_TARGET
 import space.kscience.dataforge.context.ContextAware
 import space.kscience.dataforge.context.info
@@ -15,40 +14,13 @@ import space.kscience.dataforge.meta.string
 import space.kscience.dataforge.misc.DfType
 import space.kscience.dataforge.names.parseAsName
 
-/**
- * A lifecycle state of a device
- */
-@Serializable
-public enum class DeviceLifecycleState {
-
-    /**
-     * Device is initializing
-     */
-    STARTING,
-
-    /**
-     * The Device is initialized and running
-     */
-    STARTED,
-
-    /**
-     * The Device is closed
-     */
-    STOPPED,
-
-    /**
-     * The device encountered irrecoverable error
-     */
-    ERROR
-}
-
 /**
  *  General interface describing a managed Device.
  *  [Device] is a supervisor scope encompassing all operations on a device.
  *  When canceled, cancels all running processes.
  */
 @DfType(DEVICE_TARGET)
-public interface Device : ContextAware, CoroutineScope {
+public interface Device : ContextAware, WithLifeCycle, CoroutineScope {
 
     /**
      * Initial configuration meta for the device
@@ -94,18 +66,16 @@ public interface Device : ContextAware, CoroutineScope {
      * Initialize the device. This function suspends until the device is finished initialization.
      * Does nothing if the device is started or is starting
      */
-    public suspend fun start(): Unit = Unit
+    override suspend fun start(): Unit = Unit
 
     /**
      * Close and terminate the device. This function does not wait for the device to be closed.
      */
-    public suspend fun stop() {
+    override suspend fun stop() {
         coroutineContext[Job]?.cancel("The device is closed")
         logger.info { "Device $this is closed" }
     }
 
-    public val lifecycleState: DeviceLifecycleState
-
     public companion object {
         public const val DEVICE_TARGET: String = "device"
     }
@@ -114,7 +84,7 @@ public interface Device : ContextAware, CoroutineScope {
 /**
  * Inner id of a device. Not necessary corresponds to the name in the parent container
  */
-public val Device.id: String get() = meta["id"].string?: "device[${hashCode().toString(16)}]"
+public val Device.id: String get() = meta["id"].string ?: "device[${hashCode().toString(16)}]"
 
 /**
  * Device that caches properties values
@@ -167,3 +137,12 @@ public fun Device.onPropertyChange(
 public fun Device.propertyMessageFlow(propertyName: String): Flow<PropertyChangedMessage> = messageFlow
     .filterIsInstance<PropertyChangedMessage>()
     .filter { it.property == propertyName }
+
+/**
+ * React on device lifecycle events
+ */
+public fun Device.onLifecycleEvent(
+    block: suspend (LifecycleState) -> Unit
+): Job = messageFlow.filterIsInstance<DeviceLifeCycleMessage>().onEach {
+    block(it.state)
+}.launchIn(this)
\ No newline at end of file
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 1aeabf6..e93fe7d 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
@@ -240,7 +240,7 @@ public data class DeviceErrorMessage(
 @Serializable
 @SerialName("lifecycle")
 public data class DeviceLifeCycleMessage(
-    val state: DeviceLifecycleState,
+    val state: LifecycleState,
     override val sourceDevice: Name = Name.EMPTY,
     override val targetDevice: Name? = null,
     override val comment: String? = null,
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/WithLifeCycle.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/WithLifeCycle.kt
new file mode 100644
index 0000000..631a66d
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/WithLifeCycle.kt
@@ -0,0 +1,59 @@
+package space.kscience.controls.api
+
+import kotlinx.serialization.Serializable
+
+/**
+ * A lifecycle state of a device
+ */
+@Serializable
+public enum class LifecycleState {
+
+    /**
+     * Device is initializing
+     */
+    STARTING,
+
+    /**
+     * The Device is initialized and running
+     */
+    STARTED,
+
+    /**
+     * The Device is closed
+     */
+    STOPPED,
+
+    /**
+     * The device encountered irrecoverable error
+     */
+    ERROR
+}
+
+
+/**
+ * An object that could be started or stopped functioning
+ */
+public interface WithLifeCycle {
+
+    public suspend fun start()
+
+    public suspend fun stop()
+
+    public val lifecycleState: LifecycleState
+}
+
+/**
+ * Bind this object lifecycle to a device lifecycle
+ *
+ * The starting and stopping are done in device scope
+ */
+public fun WithLifeCycle.bindToDeviceLifecycle(device: Device){
+    device.onLifecycleEvent {
+        when(it){
+            LifecycleState.STARTING -> start()
+            LifecycleState.STARTED -> {/*ignore*/}
+            LifecycleState.STOPPED -> stop()
+            LifecycleState.ERROR -> stop()
+        }
+    }
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
index 9b37fd3..29502f9 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.receiveAsFlow
 import kotlinx.io.Source
 import space.kscience.controls.api.AsynchronousSocket
+import space.kscience.controls.api.LifecycleState
 import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.Meta
 import space.kscience.dataforge.meta.get
@@ -65,8 +66,8 @@ public abstract class AbstractAsynchronousPort(
 
     protected abstract fun onOpen()
 
-    final override fun open() {
-        if (!isOpen) {
+    final override suspend fun start() {
+        if (lifecycleState == LifecycleState.STOPPED) {
             sendJob = scope.launch {
                 for (data in outgoing) {
                     try {
@@ -80,7 +81,7 @@ public abstract class AbstractAsynchronousPort(
             }
             onOpen()
         } else {
-            logger.warn { "$this already opened" }
+            logger.warn { "$this already started" }
         }
     }
 
@@ -89,7 +90,7 @@ public abstract class AbstractAsynchronousPort(
      * Send a data packet via the port
      */
     override suspend fun send(data: ByteArray) {
-        check(isOpen) { "The port is not opened" }
+        check(lifecycleState == LifecycleState.STARTED) { "The port is not opened" }
         outgoing.send(data)
     }
 
@@ -100,7 +101,7 @@ public abstract class AbstractAsynchronousPort(
      */
     override fun subscribe(): Flow<ByteArray> = incoming.receiveAsFlow()
 
-    override fun close() {
+    override suspend fun stop() {
         outgoing.close()
         incoming.close()
         sendJob?.cancel()
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
index ac368c5..8427760 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt
@@ -7,6 +7,8 @@ import kotlinx.coroutines.sync.withLock
 import kotlinx.io.Buffer
 import kotlinx.io.Source
 import kotlinx.io.readByteArray
+import space.kscience.controls.api.LifecycleState
+import space.kscience.controls.api.WithLifeCycle
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.ContextAware
 
@@ -14,11 +16,7 @@ import space.kscience.dataforge.context.ContextAware
  * A port handler for synchronous (request-response) communication with a port.
  * Only one request could be active at a time (others are suspended).
  */
-public interface SynchronousPort : ContextAware, AutoCloseable {
-
-    public fun open()
-
-    public val isOpen: Boolean
+public interface SynchronousPort : ContextAware, WithLifeCycle {
 
     /**
      * Send a single message and wait for the flow of response chunks.
@@ -71,14 +69,14 @@ private class SynchronousOverAsynchronousPort(
 
     override val context: Context get() = port.context
 
-    override fun open() {
-        if (!port.isOpen) port.open()
+    override suspend fun start() {
+        if (port.lifecycleState == LifecycleState.STOPPED) port.start()
     }
 
-    override val isOpen: Boolean get() = port.isOpen
+    override val lifecycleState: LifecycleState get() = port.lifecycleState
 
-    override fun close() {
-        if (port.isOpen) port.close()
+    override suspend fun stop() {
+        if (port.lifecycleState == LifecycleState.STARTED) port.stop()
     }
 
     override suspend fun <R> respond(
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index 941eda9..a060f46 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
@@ -72,7 +72,6 @@ public abstract class DeviceBase<D : Device>(
         onBufferOverflow = BufferOverflow.DROP_OLDEST
     )
 
-    @OptIn(ExperimentalCoroutinesApi::class)
     override val coroutineContext: CoroutineContext = context.newCoroutineContext(
         SupervisorJob(context.coroutineContext[Job]) +
                 CoroutineName("Device $id") +
@@ -188,11 +187,11 @@ public abstract class DeviceBase<D : Device>(
         return spec.executeWithMeta(self, argument ?: Meta.EMPTY)
     }
 
-    final override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
+    final override var lifecycleState: LifecycleState = LifecycleState.STOPPED
         private set
 
 
-    private suspend fun setLifecycleState(lifecycleState: DeviceLifecycleState) {
+    private suspend fun setLifecycleState(lifecycleState: LifecycleState) {
         this.lifecycleState = lifecycleState
         sharedMessageFlow.emit(
             DeviceLifeCycleMessage(lifecycleState)
@@ -204,11 +203,11 @@ public abstract class DeviceBase<D : Device>(
     }
 
     final override suspend fun start() {
-        if (lifecycleState == DeviceLifecycleState.STOPPED) {
+        if (lifecycleState == LifecycleState.STOPPED) {
             super.start()
-            setLifecycleState(DeviceLifecycleState.STARTING)
+            setLifecycleState(LifecycleState.STARTING)
             onStart()
-            setLifecycleState(DeviceLifecycleState.STARTED)
+            setLifecycleState(LifecycleState.STARTED)
         } else {
             logger.debug { "Device $this is already started" }
         }
@@ -220,7 +219,7 @@ public abstract class DeviceBase<D : Device>(
 
     final override suspend fun stop() {
         onStop()
-        setLifecycleState(DeviceLifecycleState.STOPPED)
+        setLifecycleState(LifecycleState.STOPPED)
         super.stop()
     }
 
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
index 503df83..c6f8b0a 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt
@@ -33,7 +33,7 @@ public abstract class DeviceSpec<D : Device> {
     public open suspend fun D.onOpen() {
     }
 
-    public open fun D.onClose() {
+    public open suspend fun D.onClose() {
     }
 
 
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
index 85c3d5c..e5430db 100644
--- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
@@ -1,6 +1,7 @@
 package space.kscience.controls.ports
 
 import kotlinx.coroutines.*
+import space.kscience.controls.api.LifecycleState
 import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.*
 import java.net.InetSocketAddress
@@ -30,7 +31,7 @@ public class ChannelPort(
     meta: Meta,
     coroutineContext: CoroutineContext = context.coroutineContext,
     channelBuilder: suspend () -> ByteChannel,
-) : AbstractAsynchronousPort(context, meta, coroutineContext), AutoCloseable {
+) : AbstractAsynchronousPort(context, meta, coroutineContext) {
 
     /**
      * A handler to await port connection
@@ -41,7 +42,8 @@ public class ChannelPort(
 
     private var listenerJob: Job? = null
 
-    override val isOpen: Boolean get() = listenerJob?.isActive == true
+    override val lifecycleState: LifecycleState
+        get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
 
     override fun onOpen() {
         listenerJob = scope.launch(Dispatchers.IO) {
@@ -71,12 +73,12 @@ public class ChannelPort(
     }
 
     @OptIn(ExperimentalCoroutinesApi::class)
-    override fun close() {
+    override suspend fun stop() {
         listenerJob?.cancel()
         if (futureChannel.isCompleted) {
             futureChannel.getCompleted().close()
         }
-        super.close()
+        super.stop()
     }
 }
 
@@ -105,12 +107,12 @@ public object TcpPort : Factory<AsynchronousPort> {
     /**
      * Create and open TCP port
      */
-    public fun open(
+    public suspend fun start(
         context: Context,
         host: String,
         port: Int,
         coroutineContext: CoroutineContext = context.coroutineContext,
-    ): ChannelPort = build(context, host, port, coroutineContext).apply { open() }
+    ): ChannelPort = build(context, host, port, coroutineContext).apply { start() }
 
     override fun build(context: Context, meta: Meta): ChannelPort {
         val host = meta["host"].string ?: "localhost"
@@ -156,13 +158,13 @@ public object UdpPort : Factory<AsynchronousPort> {
     /**
      * Connect a datagram channel to a remote host/port. If [localPort] is provided, it is used to bind local port for receiving messages.
      */
-    public fun open(
+    public suspend fun start(
         context: Context,
         remoteHost: String,
         remotePort: Int,
         localPort: Int? = null,
         localHost: String = "localhost",
-    ): ChannelPort = build(context, remoteHost, remotePort, localPort, localHost).apply { open() }
+    ): ChannelPort = build(context, remoteHost, remotePort, localPort, localHost).apply { start() }
 
 
     override fun build(context: Context, meta: Meta): ChannelPort {
diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt
index ae65c64..39d4c13 100644
--- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt
@@ -1,6 +1,7 @@
 package space.kscience.controls.ports
 
 import kotlinx.coroutines.*
+import space.kscience.controls.api.LifecycleState
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.meta.Meta
 import java.net.DatagramPacket
@@ -39,13 +40,13 @@ public class UdpSocketPort(
         }
     }
 
-    override fun close() {
+    override suspend fun stop() {
         listenerJob?.cancel()
-        super.close()
+        super.stop()
     }
 
-    override val isOpen: Boolean get() = listenerJob?.isActive == true
-
+    override val lifecycleState: LifecycleState
+        get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
 
     override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) {
         val packet = DatagramPacket(
diff --git a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt
index b19cfad..6709f8c 100644
--- a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt
+++ b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt
@@ -29,8 +29,8 @@ internal class AsynchronousPortIOTest {
 
     @Test
     fun testUdpCommunication() = runTest {
-        val receiver = UdpPort.open(Global, "localhost", 8811, localPort = 8812)
-        val sender = UdpPort.open(Global, "localhost", 8812, localPort = 8811)
+        val receiver = UdpPort.start(Global, "localhost", 8811, localPort = 8812)
+        val sender = UdpPort.start(Global, "localhost", 8812, localPort = 8811)
 
         delay(30)
         repeat(10) {
@@ -44,7 +44,7 @@ internal class AsynchronousPortIOTest {
             .toList()
 
         assertEquals("Line number 3", res[3].trim())
-        receiver.close()
-        sender.close()
+        receiver.stop()
+        sender.stop()
     }
 }
\ No newline at end of file
diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index f4a232e..045ee02 100644
--- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
@@ -99,10 +99,10 @@ public class DeviceClient internal constructor(
     }
 
     private val lifecycleStateFlow = messageFlow.filterIsInstance<DeviceLifeCycleMessage>()
-        .map { it.state }.stateIn(this, started = SharingStarted.Eagerly, DeviceLifecycleState.STARTED)
+        .map { it.state }.stateIn(this, started = SharingStarted.Eagerly, LifecycleState.STARTED)
 
     @DFExperimental
-    override val lifecycleState: DeviceLifecycleState get() = lifecycleStateFlow.value
+    override val lifecycleState: LifecycleState get() = lifecycleStateFlow.value
 }
 
 /**
diff --git a/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
index 1aee657..019ac9e 100644
--- a/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
+++ b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
@@ -5,6 +5,7 @@ import com.pi4j.io.serial.Serial
 import com.pi4j.io.serial.SerialConfigBuilder
 import com.pi4j.ktx.io.serial
 import kotlinx.coroutines.*
+import space.kscience.controls.api.LifecycleState
 import space.kscience.controls.ports.AbstractAsynchronousPort
 import space.kscience.controls.ports.AsynchronousPort
 import space.kscience.controls.ports.copyToArray
@@ -49,9 +50,10 @@ public class AsynchronousPiPort(
     }
 
 
-    override val isOpen: Boolean get() = listenerJob?.isActive == true
+    override val lifecycleState: LifecycleState
+        get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
 
-    override fun close() {
+    override suspend fun stop() {
         listenerJob?.cancel()
         serial.close()
     }
@@ -74,11 +76,11 @@ public class AsynchronousPiPort(
             return AsynchronousPiPort(context, meta, serial)
         }
 
-        public fun open(
+        public suspend fun start(
             context: Context,
             device: String,
             block: SerialConfigBuilder.() -> Unit,
-        ): AsynchronousPiPort = build(context, device, block).apply { open() }
+        ): AsynchronousPiPort = build(context, device, block).apply { start() }
 
         override fun build(context: Context, meta: Meta): AsynchronousPort {
             val device: String = meta["device"].string ?: error("Device name not defined")
diff --git a/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
index 6bc590c..d91cd6c 100644
--- a/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
+++ b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
@@ -10,6 +10,7 @@ import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.runInterruptible
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
+import space.kscience.controls.api.LifecycleState
 import space.kscience.controls.ports.SynchronousPort
 import space.kscience.controls.ports.copyToArray
 import space.kscience.dataforge.context.*
@@ -27,11 +28,13 @@ public class SynchronousPiPort(
 ) : SynchronousPort {
 
     private val pi = context.request(PiPlugin)
-    override fun open() {
+
+    override suspend fun start() {
         serial.open()
     }
 
-    override val isOpen: Boolean get() = serial.isOpen
+    override val lifecycleState: LifecycleState
+        get() = if(serial.isOpen) LifecycleState.STARTED else LifecycleState.STOPPED
 
     override suspend fun <R> respond(
         request: ByteArray,
@@ -41,7 +44,7 @@ public class SynchronousPiPort(
         serial.write(request)
         flow<ByteArray> {
             val buffer = ByteBuffer.allocate(1024)
-            while (isOpen) {
+            while (serial.isOpen) {
                 try {
                     val num = serial.read(buffer)
                     if (num > 0) {
@@ -64,7 +67,7 @@ public class SynchronousPiPort(
         }
     }
 
-    override fun close() {
+    override suspend fun stop() {
         serial.close()
     }
 
@@ -86,11 +89,11 @@ public class SynchronousPiPort(
             return SynchronousPiPort(context, meta, serial)
         }
 
-        public fun open(
+        public suspend fun start(
             context: Context,
             device: String,
             block: SerialConfigBuilder.() -> Unit,
-        ): SynchronousPiPort = build(context, device, block).apply { open() }
+        ): SynchronousPiPort = build(context, device, block).apply { start() }
 
         override fun build(context: Context, meta: Meta): SynchronousPiPort {
             val device: String = meta["device"].string ?: error("Device name not defined")
diff --git a/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
index 463e922..d853e46 100644
--- a/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
+++ b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
@@ -6,9 +6,9 @@ import io.ktor.network.sockets.aSocket
 import io.ktor.network.sockets.openReadChannel
 import io.ktor.network.sockets.openWriteChannel
 import io.ktor.utils.io.consumeEachBufferRange
-import io.ktor.utils.io.core.Closeable
 import io.ktor.utils.io.writeAvailable
 import kotlinx.coroutines.*
+import space.kscience.controls.api.LifecycleState
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.meta.Meta
@@ -25,7 +25,7 @@ public class KtorTcpPort internal constructor(
     public val port: Int,
     coroutineContext: CoroutineContext = context.coroutineContext,
     socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {},
-) : AbstractAsynchronousPort(context, meta, coroutineContext), Closeable {
+) : AbstractAsynchronousPort(context, meta, coroutineContext) {
 
     override fun toString(): String = "port[tcp:$host:$port]"
 
@@ -55,13 +55,13 @@ public class KtorTcpPort internal constructor(
         writeChannel.await().writeAvailable(data)
     }
 
-    override val isOpen: Boolean
-        get() = listenerJob?.isActive == true
+    override val lifecycleState: LifecycleState
+        get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
 
-    override fun close() {
+    override suspend fun stop() {
         listenerJob?.cancel()
         futureSocket.cancel()
-        super.close()
+        super.stop()
     }
 
     public companion object : Factory<AsynchronousPort> {
@@ -82,13 +82,13 @@ public class KtorTcpPort internal constructor(
             return KtorTcpPort(context, meta, host, port, coroutineContext, socketOptions)
         }
 
-        public fun open(
+        public suspend fun start(
             context: Context,
             host: String,
             port: Int,
             coroutineContext: CoroutineContext = context.coroutineContext,
             socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {},
-        ): KtorTcpPort = build(context, host, port, coroutineContext, socketOptions).apply { open() }
+        ): KtorTcpPort = build(context, host, port, coroutineContext, socketOptions).apply { start() }
 
         override fun build(context: Context, meta: Meta): AsynchronousPort {
             val host = meta["host"].string ?: "localhost"
diff --git a/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorUdpPort.kt b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
index 48096cf..267daa4 100644
--- a/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
+++ b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
@@ -4,9 +4,9 @@ import io.ktor.network.selector.ActorSelectorManager
 import io.ktor.network.sockets.*
 import io.ktor.utils.io.ByteWriteChannel
 import io.ktor.utils.io.consumeEachBufferRange
-import io.ktor.utils.io.core.Closeable
 import io.ktor.utils.io.writeAvailable
 import kotlinx.coroutines.*
+import space.kscience.controls.api.LifecycleState
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
 import space.kscience.dataforge.meta.Meta
@@ -24,7 +24,7 @@ public class KtorUdpPort internal constructor(
     public val localHost: String = "localhost",
     coroutineContext: CoroutineContext = context.coroutineContext,
     socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {},
-) : AbstractAsynchronousPort(context, meta, coroutineContext), Closeable {
+) : AbstractAsynchronousPort(context, meta, coroutineContext) {
 
     override fun toString(): String = "port[udp:$remoteHost:$remotePort]"
 
@@ -58,13 +58,13 @@ public class KtorUdpPort internal constructor(
         writeChannel.await().writeAvailable(data)
     }
 
-    override val isOpen: Boolean
-        get() = listenerJob?.isActive == true
+    override val lifecycleState: LifecycleState
+        get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
 
-    override fun close() {
+    override suspend fun stop() {
         listenerJob?.cancel()
         futureSocket.cancel()
-        super.close()
+        super.stop()
     }
 
     public companion object : Factory<AsynchronousPort> {
@@ -101,7 +101,7 @@ public class KtorUdpPort internal constructor(
         /**
          * Create and open UDP port
          */
-        public fun open(
+        public suspend fun start(
             context: Context,
             remoteHost: String,
             remotePort: Int,
@@ -117,7 +117,7 @@ public class KtorUdpPort internal constructor(
             localHost,
             coroutineContext,
             socketOptions
-        ).apply { open() }
+        ).apply { start() }
 
         override fun build(context: Context, meta: Meta): AsynchronousPort {
             val remoteHost by meta.string { error("Remote host is not specified") }
diff --git a/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
index b6e83bc..a9c4ede 100644
--- a/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
+++ b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
@@ -5,6 +5,7 @@ import com.fazecast.jSerialComm.SerialPortDataListener
 import com.fazecast.jSerialComm.SerialPortEvent
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.launch
+import space.kscience.controls.api.LifecycleState
 import space.kscience.controls.ports.AbstractAsynchronousPort
 import space.kscience.controls.ports.AsynchronousPort
 import space.kscience.dataforge.context.Context
@@ -55,18 +56,20 @@ public class AsynchronousSerialPort(
         comPort.addDataListener(serialPortListener)
     }
 
-    override val isOpen: Boolean get() = comPort.isOpen
+    override val lifecycleState: LifecycleState
+        get() = if(comPort.isOpen) LifecycleState.STARTED else LifecycleState.STOPPED
+
 
     override suspend fun write(data: ByteArray) {
         comPort.writeBytes(data, data.size)
     }
 
-    override fun close() {
+    override suspend fun stop() {
         comPort.removeDataListener()
         if (comPort.isOpen) {
             comPort.closePort()
         }
-        super.close()
+        super.stop()
     }
 
     public companion object : Factory<AsynchronousPort> {
@@ -100,7 +103,7 @@ public class AsynchronousSerialPort(
         /**
          * Construct ComPort with given parameters
          */
-        public fun open(
+        public suspend fun start(
             context: Context,
             portName: String,
             baudRate: Int = 9600,
@@ -118,7 +121,7 @@ public class AsynchronousSerialPort(
             parity = parity,
             coroutineContext = coroutineContext,
             additionalConfig = additionalConfig
-        ).apply { open() }
+        ).apply { start() }
 
 
         override fun build(context: Context, meta: Meta): AsynchronousPort {
diff --git a/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
index 1a9b4a5..b9fc091 100644
--- a/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
+++ b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
@@ -7,6 +7,7 @@ import kotlinx.coroutines.flow.flow
 import kotlinx.coroutines.runInterruptible
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
+import space.kscience.controls.api.LifecycleState
 import space.kscience.controls.ports.SynchronousPort
 import space.kscience.dataforge.context.Context
 import space.kscience.dataforge.context.Factory
@@ -28,16 +29,17 @@ public class SynchronousSerialPort(
     override fun toString(): String = "port[${comPort.descriptivePortName}]"
 
 
-    override fun open() {
-        if (!isOpen) {
+    override suspend fun start() {
+        if (!comPort.isOpen) {
             comPort.openPort()
         }
     }
 
-    override val isOpen: Boolean get() = comPort.isOpen
+    override val lifecycleState: LifecycleState
+        get() = if(comPort.isOpen) LifecycleState.STARTED else LifecycleState.STOPPED
 
 
-    override fun close() {
+    override suspend fun stop() {
         if (comPort.isOpen) {
             comPort.closePort()
         }
@@ -52,7 +54,7 @@ public class SynchronousSerialPort(
         comPort.flushIOBuffers()
         comPort.writeBytes(request, request.size)
         flow<ByteArray> {
-            while (isOpen) {
+            while (comPort.isOpen) {
                 try {
                     val available = comPort.bytesAvailable()
                     if (available > 0) {
@@ -108,7 +110,7 @@ public class SynchronousSerialPort(
         /**
          * Construct ComPort with given parameters
          */
-        public fun open(
+        public suspend fun start(
             context: Context,
             portName: String,
             baudRate: Int = 9600,
@@ -124,7 +126,7 @@ public class SynchronousSerialPort(
             stopBits = stopBits,
             parity = parity,
             additionalConfig = additionalConfig
-        ).apply { open() }
+        ).apply { start() }
 
 
         override fun build(context: Context, meta: Meta): SynchronousPort {
diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
index b0fd562..d28acc0 100644
--- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
+++ b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
@@ -99,9 +99,9 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi
         val error by logicalProperty(MetaConverter.string)
 
 
-        override fun MksPdr900Device.onClose() {
+        override suspend fun MksPdr900Device.onClose() {
             if (portDelegate.isInitialized()) {
-                port.close()
+                port.stop()
             }
         }
     }
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index b0282d0..04e03df 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -168,7 +168,7 @@ class PiMotionMasterDevice(
             }
             //Update port
             //address = portSpec.node
-            port = portFactory(portSpec, context).apply { open() }
+            port = portFactory(portSpec, context).apply { start() }
 //        connector.open()
             //Initialize axes
             val idn = read(identity)
@@ -190,7 +190,7 @@ class PiMotionMasterDevice(
         }) {
             port?.let {
                 execute(stop)
-                it.close()
+                it.stop()
             }
             port = null
             propertyChanged(connected, false)
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt
index f343d7c..4ec21de 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt
@@ -6,6 +6,7 @@ import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import space.kscience.controls.api.AsynchronousSocket
+import space.kscience.controls.api.LifecycleState
 import space.kscience.controls.ports.AbstractAsynchronousPort
 import space.kscience.controls.ports.withDelimiter
 import space.kscience.dataforge.context.*
@@ -48,10 +49,10 @@ abstract class VirtualDevice(val scope: CoroutineScope) : AsynchronousSocket<Byt
         respond(response())
     }
 
-    override val isOpen: Boolean
-        get() = scope.isActive
+    override val lifecycleState: LifecycleState
+        get() = if(scope.isActive) LifecycleState.STARTED else LifecycleState.STOPPED
 
-    override fun close() = scope.cancel()
+    override suspend fun stop() = scope.cancel()
 }
 
 class VirtualPort(private val device: VirtualDevice, context: Context) : AbstractAsynchronousPort(context, Meta.EMPTY) {
@@ -72,12 +73,12 @@ class VirtualPort(private val device: VirtualDevice, context: Context) : Abstrac
         device.send(data)
     }
 
-    override val isOpen: Boolean
-        get() = respondJob?.isActive == true
+    override val lifecycleState: LifecycleState
+        get() = if(respondJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
 
-    override fun close() {
+    override suspend fun stop() {
         respondJob?.cancel()
-        super.close()
+        super.stop()
     }
 }
 
@@ -88,7 +89,7 @@ class PiMotionMasterVirtualDevice(
     scope: CoroutineScope = context,
 ) : VirtualDevice(scope), ContextAware {
 
-    override fun open() {
+    override suspend fun start() {
         //add asynchronous send logic here
     }
 

From 89d78c43bbc148d67bb61025502a6a085f8a68c5 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sat, 3 Aug 2024 21:26:50 +0300
Subject: [PATCH 117/125] Add wasm and native targets to core modules

---
 build.gradle.kts                               |  2 +-
 controls-constructor/build.gradle.kts          |  2 ++
 controls-core/build.gradle.kts                 |  1 +
 .../src/wasmJsMain/kotlin/fromSpec.wasm.kt     |  9 +++++++++
 controls-magix/build.gradle.kts                |  1 +
 .../kscience/controls/demo/car/IVirtualCar.kt  |  0
 .../controls/demo/car/MagixVirtualCar.kt       |  0
 .../kscience/controls/demo/car/VirtualCar.kt   |  0
 .../controls/demo/car/VirtualCarController.kt  |  0
 .../src/{main => jvmMain}/kotlin/zmq.kt        |  0
 magix/magix-api/build.gradle.kts               |  1 +
 magix/magix-mqtt/build.gradle.kts              | 15 +++++++++------
 .../magix/rabbit/RabbitMQMagixEndpoint.kt      |  0
 .../magix-storage-xodus/build.gradle.kts       | 18 +++++++++++-------
 magix/magix-zmq/build.gradle.kts               | 13 ++++++++-----
 .../kscince/magix/zmq/ZmqMagixEndpoint.kt      |  0
 .../kscince/magix/zmq/ZmqMagixFlowPlugin.kt    |  1 -
 17 files changed, 43 insertions(+), 20 deletions(-)
 create mode 100644 controls-core/src/wasmJsMain/kotlin/fromSpec.wasm.kt
 rename demo/car/src/{main => jvmMain}/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt (100%)
 rename demo/car/src/{main => jvmMain}/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt (100%)
 rename demo/car/src/{main => jvmMain}/kotlin/space/kscience/controls/demo/car/VirtualCar.kt (100%)
 rename demo/car/src/{main => jvmMain}/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt (100%)
 rename demo/magix-demo/src/{main => jvmMain}/kotlin/zmq.kt (100%)
 rename magix/magix-rabbit/src/{main => jvmMain}/kotlin/space/kscience/magix/rabbit/RabbitMQMagixEndpoint.kt (100%)
 rename magix/magix-zmq/src/{main => jvmMain}/kotlin/space/kscince/magix/zmq/ZmqMagixEndpoint.kt (100%)
 rename magix/magix-zmq/src/{main => jvmMain}/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt (97%)

diff --git a/build.gradle.kts b/build.gradle.kts
index b9b6d19..619a512 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,7 @@ plugins {
 
 allprojects {
     group = "space.kscience"
-    version = "0.4.0-dev-5"
+    version = "0.4.0-dev-6"
     repositories{
         google()
     }
diff --git a/controls-constructor/build.gradle.kts b/controls-constructor/build.gradle.kts
index 008a242..1947f91 100644
--- a/controls-constructor/build.gradle.kts
+++ b/controls-constructor/build.gradle.kts
@@ -10,6 +10,8 @@ description = """
 kscience{
     jvm()
     js()
+    native()
+    wasm()
     useCoroutines()
     useSerialization()
     commonMain {
diff --git a/controls-core/build.gradle.kts b/controls-core/build.gradle.kts
index 9624b35..868cdd0 100644
--- a/controls-core/build.gradle.kts
+++ b/controls-core/build.gradle.kts
@@ -13,6 +13,7 @@ kscience {
     jvm()
     js()
     native()
+    wasm()
     useCoroutines()
     useSerialization{
         json()
diff --git a/controls-core/src/wasmJsMain/kotlin/fromSpec.wasm.kt b/controls-core/src/wasmJsMain/kotlin/fromSpec.wasm.kt
new file mode 100644
index 0000000..cd77248
--- /dev/null
+++ b/controls-core/src/wasmJsMain/kotlin/fromSpec.wasm.kt
@@ -0,0 +1,9 @@
+package space.kscience.controls.spec
+
+import space.kscience.controls.api.ActionDescriptor
+import space.kscience.controls.api.PropertyDescriptor
+import kotlin.reflect.KProperty
+
+internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>){}
+
+internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){}
\ No newline at end of file
diff --git a/controls-magix/build.gradle.kts b/controls-magix/build.gradle.kts
index ea03a7e..1425eb0 100644
--- a/controls-magix/build.gradle.kts
+++ b/controls-magix/build.gradle.kts
@@ -13,6 +13,7 @@ kscience {
     jvm()
     js()
     native()
+    wasm()
     useCoroutines()
     useSerialization {
         json()
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt
similarity index 100%
rename from demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt
rename to demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt
similarity index 100%
rename from demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt
rename to demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
similarity index 100%
rename from demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
rename to demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
similarity index 100%
rename from demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
rename to demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt
diff --git a/demo/magix-demo/src/main/kotlin/zmq.kt b/demo/magix-demo/src/jvmMain/kotlin/zmq.kt
similarity index 100%
rename from demo/magix-demo/src/main/kotlin/zmq.kt
rename to demo/magix-demo/src/jvmMain/kotlin/zmq.kt
diff --git a/magix/magix-api/build.gradle.kts b/magix/magix-api/build.gradle.kts
index 68151c3..253f3e3 100644
--- a/magix/magix-api/build.gradle.kts
+++ b/magix/magix-api/build.gradle.kts
@@ -13,6 +13,7 @@ kscience {
     jvm()
     js()
     native()
+    wasm()
     useCoroutines()
     useSerialization{
         json()
diff --git a/magix/magix-mqtt/build.gradle.kts b/magix/magix-mqtt/build.gradle.kts
index 5261a00..9241929 100644
--- a/magix/magix-mqtt/build.gradle.kts
+++ b/magix/magix-mqtt/build.gradle.kts
@@ -1,5 +1,5 @@
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
 }
 
@@ -7,12 +7,15 @@ description = """
    MQTT client magix endpoint
 """.trimIndent()
 
-dependencies {
-    api(projects.magix.magixApi)
-    implementation(libs.hivemq.mqtt.client)
-    implementation(spclibs.kotlinx.coroutines.jdk8)
+kscience {
+    jvm()
+    jvmMain {
+        api(projects.magix.magixApi)
+        implementation(libs.hivemq.mqtt.client)
+        implementation(spclibs.kotlinx.coroutines.jdk8)
+    }
 }
 
-readme{
+readme {
     maturity = space.kscience.gradle.Maturity.PROTOTYPE
 }
diff --git a/magix/magix-rabbit/src/main/kotlin/space/kscience/magix/rabbit/RabbitMQMagixEndpoint.kt b/magix/magix-rabbit/src/jvmMain/kotlin/space/kscience/magix/rabbit/RabbitMQMagixEndpoint.kt
similarity index 100%
rename from magix/magix-rabbit/src/main/kotlin/space/kscience/magix/rabbit/RabbitMQMagixEndpoint.kt
rename to magix/magix-rabbit/src/jvmMain/kotlin/space/kscience/magix/rabbit/RabbitMQMagixEndpoint.kt
diff --git a/magix/magix-storage/magix-storage-xodus/build.gradle.kts b/magix/magix-storage/magix-storage-xodus/build.gradle.kts
index f50abe3..6c35cef 100644
--- a/magix/magix-storage/magix-storage-xodus/build.gradle.kts
+++ b/magix/magix-storage/magix-storage-xodus/build.gradle.kts
@@ -1,20 +1,24 @@
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
 }
 
 kscience {
+    jvm()
     useCoroutines()
-}
-
-dependencies {
-    api(projects.magix.magixStorage)
-    implementation(libs.xodus.entity.store)
+    jvmMain {
+        api(projects.magix.magixStorage)
+        implementation(libs.xodus.entity.store)
 //    implementation("org.jetbrains.xodus:dnq:2.0.0")
 
-    testImplementation(spclibs.kotlinx.coroutines.test)
+    }
+
+    jvmTest{
+        implementation(spclibs.kotlinx.coroutines.test)
+    }
 }
 
+
 readme {
     maturity = space.kscience.gradle.Maturity.PROTOTYPE
 }
diff --git a/magix/magix-zmq/build.gradle.kts b/magix/magix-zmq/build.gradle.kts
index 20cc246..7eedafa 100644
--- a/magix/magix-zmq/build.gradle.kts
+++ b/magix/magix-zmq/build.gradle.kts
@@ -1,7 +1,7 @@
 import space.kscience.gradle.Maturity
 
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
 }
 
@@ -9,10 +9,13 @@ description = """
     ZMQ client endpoint for Magix
 """.trimIndent()
 
-dependencies {
-    api(projects.magix.magixApi)
-    api("org.slf4j:slf4j-api:2.0.6")
-    api("org.zeromq:jeromq:0.5.3")
+kscience {
+    jvm()
+    jvmMain {
+        api(projects.magix.magixApi)
+        api("org.slf4j:slf4j-api:2.0.6")
+        api("org.zeromq:jeromq:0.5.3")
+    }
 }
 
 readme {
diff --git a/magix/magix-zmq/src/main/kotlin/space/kscince/magix/zmq/ZmqMagixEndpoint.kt b/magix/magix-zmq/src/jvmMain/kotlin/space/kscince/magix/zmq/ZmqMagixEndpoint.kt
similarity index 100%
rename from magix/magix-zmq/src/main/kotlin/space/kscince/magix/zmq/ZmqMagixEndpoint.kt
rename to magix/magix-zmq/src/jvmMain/kotlin/space/kscince/magix/zmq/ZmqMagixEndpoint.kt
diff --git a/magix/magix-zmq/src/main/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt b/magix/magix-zmq/src/jvmMain/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt
similarity index 97%
rename from magix/magix-zmq/src/main/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt
rename to magix/magix-zmq/src/jvmMain/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt
index 7813b8c..c35aa39 100644
--- a/magix/magix-zmq/src/main/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt
+++ b/magix/magix-zmq/src/jvmMain/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt
@@ -4,7 +4,6 @@ import kotlinx.coroutines.*
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.launchIn
 import kotlinx.coroutines.flow.onEach
-import kotlinx.serialization.decodeFromString
 import kotlinx.serialization.encodeToString
 import org.slf4j.LoggerFactory
 import org.zeromq.SocketType

From 284f9feb9383431d03284408b0802630339a8f8c Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Wed, 13 Nov 2024 17:11:53 +0300
Subject: [PATCH 118/125] Simulation initial commit

---
 settings.gradle.kts                           |  1 +
 simulation-kt/build.gradle.kts                | 32 +++++++
 .../commonMain/kotlin/GeneratingTimeline.kt   | 96 +++++++++++++++++++
 .../src/commonMain/kotlin/MergedTimeline.kt   | 49 ++++++++++
 .../src/commonMain/kotlin/SharedTimeline.kt   | 86 +++++++++++++++++
 .../src/commonMain/kotlin/Timeline.kt         | 76 +++++++++++++++
 6 files changed, 340 insertions(+)
 create mode 100644 simulation-kt/build.gradle.kts
 create mode 100644 simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
 create mode 100644 simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
 create mode 100644 simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
 create mode 100644 simulation-kt/src/commonMain/kotlin/Timeline.kt

diff --git a/settings.gradle.kts b/settings.gradle.kts
index 7e84c71..702ce9b 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -52,6 +52,7 @@ dependencyResolutionManagement {
 }
 
 include(
+    ":simulation-kt",
     ":controls-core",
     ":controls-ports-ktor",
     ":controls-serial",
diff --git a/simulation-kt/build.gradle.kts b/simulation-kt/build.gradle.kts
new file mode 100644
index 0000000..c30480a
--- /dev/null
+++ b/simulation-kt/build.gradle.kts
@@ -0,0 +1,32 @@
+import space.kscience.gradle.Maturity
+
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+description = """
+    Core interfaces for building a device server
+""".trimIndent()
+
+kscience {
+    jvm()
+    js()
+    native()
+    wasm()
+    useCoroutines()
+    useContextReceivers()
+
+    commonMain {
+        api(spclibs.kotlinx.datetime)
+    }
+
+    jvmTest{
+        implementation(spclibs.logback.classic)
+    }
+}
+
+
+readme{
+    maturity = Maturity.EXPERIMENTAL
+}
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
new file mode 100644
index 0000000..4e9da50
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
@@ -0,0 +1,96 @@
+package space.kscience.simulation
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.datetime.Instant
+import kotlin.time.Duration
+
+/**
+ * @param lookaheadInterval an interval for generated events ahead of the last observed event.
+ */
+public class GeneratingTimeline<E : TimelineEvent>(
+    private val generationScope: CoroutineScope,
+    private val initialEvent: E,
+    private val lookaheadInterval: Duration,
+    private val generatorChain: suspend (E) -> E
+) : Timeline<E> {
+
+    private val mutex = Mutex()
+
+    private val events = ArrayDeque<E>()
+
+    private val observers: MutableSet<TimelineObserver> = mutableSetOf()
+
+    override val lastEventTime: Instant?
+        get() = events.lastOrNull()?.time
+
+    override val observedTime: Instant
+        get() = observers.minOfOrNull { it.lastCollectedEventTime } ?: Instant.DISTANT_PAST
+
+    override fun flowUnobservedEvents(): Flow<E> = events.asFlow()
+
+    override suspend fun advance(toTime: Instant) {
+        observers.forEach {
+            it.collect(toTime)
+        }
+    }
+
+    private var generatorJob: Job = launchGenerateJob(initialEvent)
+
+    private fun launchGenerateJob(event: E): Job = generationScope.launch {
+        var currentEvent = event
+        while(currentEvent.time < observedTime + lookaheadInterval) {
+            val nextEvent = generatorChain(currentEvent)
+            mutex.withLock {
+                events.add(nextEvent)
+            }
+            currentEvent = nextEvent
+        }
+    }
+
+    private fun regenerate(event: E) {
+        generatorJob.cancel()
+        generatorJob = launchGenerateJob(event)
+    }
+
+    /**
+     * Discard unconsumed events after [atTime].
+     */
+    override suspend fun interrupt(atTime: Instant): Unit {
+        if (atTime < observedTime)
+            error("Timeline interrupt at time $atTime is not possible because there are observed events before $observedTime")
+        mutex.withLock {
+            while (events.isNotEmpty() && events.last().time > atTime) {
+                events.removeLast()
+            }
+        }
+    }
+
+    override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
+        val observer = object : TimelineObserver {
+            val observerMutex = Mutex()
+            override var lastCollectedEventTime: Instant = Instant.DISTANT_PAST
+
+            override suspend fun collect(upTo: Instant) = observerMutex.withLock {
+                flowUnobservedEvents().takeWhile { it.time <= upTo }.onEach {
+                    lastCollectedEventTime = it.time
+                }.collector()
+                //cleanup()
+            }
+
+            override fun close() {
+                observers.remove(this)
+            }
+
+        }
+        observers.add(observer)
+        return observer
+    }
+}
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
new file mode 100644
index 0000000..991cdc4
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
@@ -0,0 +1,49 @@
+package space.kscience.simulation
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.merge
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.datetime.Instant
+
+public class MergedTimeline<E : TimelineEvent>(
+    private val timelines: List<Timeline<E>>
+) : Timeline<E> {
+    override val lastEventTime: Instant?
+        get() = timelines.minOfOrNull { it.lastEventTime ?: Instant.DISTANT_PAST }
+    override val observedTime: Instant
+        get() = timelines.maxOfOrNull { it.observedTime } ?: Instant.DISTANT_FUTURE
+
+    override fun flowUnobservedEvents(): Flow<E> = timelines.map { flowUnobservedEvents() }.merge()
+
+    override suspend fun advance(toTime: Instant) {
+        timelines.forEach { it.advance(toTime) }
+    }
+
+    override suspend fun interrupt(atTime: Instant) {
+        timelines.forEach { it.interrupt(atTime) }
+    }
+
+    private val observers: MutableSet<TimelineObserver> = mutableSetOf()
+
+    override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
+        val observer = object : TimelineObserver {
+            override var lastCollectedEventTime: Instant = Instant.DISTANT_PAST
+
+            override suspend fun collect(upTo: Instant) = timelines
+                .map { flowUnobservedEvents() }
+                .merge()
+                .takeWhile { it.time <= upTo }.onEach {
+                    lastCollectedEventTime = it.time
+                }.collector()
+
+
+            override fun close() {
+                observers.remove(this)
+            }
+
+        }
+        observers.add(observer)
+        return observer
+    }
+}
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
new file mode 100644
index 0000000..067d4d2
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
@@ -0,0 +1,86 @@
+package space.kscience.simulation
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.asFlow
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.datetime.Instant
+
+/**
+ * A manually mutable [Timeline] that could be modified via [emit] method by multiple
+ */
+public class SharedTimeline<E : TimelineEvent> : Timeline<E> {
+
+    private val mutex = Mutex()
+
+    private val events = ArrayDeque<E>()
+
+    private val observers: MutableSet<TimelineObserver> = mutableSetOf()
+
+    override val lastEventTime: Instant?
+        get() = events.lastOrNull()?.time
+
+    override val observedTime: Instant
+        get() = observers.minOfOrNull { it.lastCollectedEventTime } ?: Instant.DISTANT_PAST
+
+    override fun flowUnobservedEvents(): Flow<E> = events.asFlow()
+
+    /**
+     * Emit new event to the timeline
+     */
+    public suspend fun emit(event: E): Boolean = mutex.withLock {
+        if (event.time < observedTime) error("Can't emit event $event because there are observed events after $observedTime")
+        events.add(event)
+    }
+
+    override suspend fun advance(toTime: Instant) {
+        observers.forEach {
+            it.collect(toTime)
+        }
+    }
+
+    /**
+     * Discard all events before [observedTime]
+     */
+    private suspend fun cleanup(): Unit {
+        val threshold = observedTime
+        while (events.isNotEmpty() && events.last().time > threshold) {
+            events.removeFirst()
+        }
+    }
+
+    /**
+     * Discard unconsumed events after [atTime].
+     */
+    override suspend fun interrupt(atTime: Instant): Unit = mutex.withLock {
+        val threshold = observedTime
+        if (atTime < threshold)
+            error("Timeline interrupt at time $atTime is not possible because there are observed events before $threshold")
+        while (events.isNotEmpty() && events.last().time > atTime) {
+            events.removeLast()
+        }
+    }
+
+    override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
+        val observer = object : TimelineObserver {
+            val observerMutex = Mutex()
+            override var lastCollectedEventTime: Instant = Instant.DISTANT_PAST
+
+            override suspend fun collect(upTo: Instant) = observerMutex.withLock {
+                flowUnobservedEvents().takeWhile { it.time <= upTo }.onEach {
+                    lastCollectedEventTime = it.time
+                }.collector()
+                cleanup()
+            }
+
+            override fun close() {
+                observers.remove(this)
+            }
+
+        }
+        observers.add(observer)
+        return observer
+    }
+}
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/Timeline.kt b/simulation-kt/src/commonMain/kotlin/Timeline.kt
new file mode 100644
index 0000000..f9f4535
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/Timeline.kt
@@ -0,0 +1,76 @@
+package space.kscience.simulation
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.datetime.Instant
+
+
+public interface TimelineEvent {
+    public val time: Instant
+}
+
+public interface TimelineObserver: AutoCloseable {
+    /**
+     * The time of the last event collected by this collector
+     */
+    public val lastCollectedEventTime: Instant
+
+    /**
+     * Collect all uncollected events from [lastCollectedEventTime] to [upTo].
+     *
+     * By default, collects all events.
+     */
+    public suspend fun collect(upTo: Instant = Instant.DISTANT_FUTURE)
+}
+
+/**
+ * A time-ordered sequence of events of type [E]. There time of events is strictly monotonic, meaning that the time of
+ * the next event is greater than the previous event time.
+ *
+ * Timeline guarantees that all collectors could read all events when they need. Meaning that all unread events are cached.
+ *
+ * Timeline guarantees that already read events won't change, but unread events could change.
+ */
+public interface Timeline<E : TimelineEvent> {
+    /**
+     * The timestamp of the last event in a timeline
+     */
+    public val lastEventTime: Instant?
+
+    /**
+     * The time of the last event that was observed by all observers
+     */
+    public val observedTime: Instant
+
+    /**
+     * Flow events from [observedTime] to [lastEventTime].
+     *
+     * The resulting flow is finite and should not suspend.
+     *
+     * This method does not affect [observedTime].
+     */
+    public fun flowUnobservedEvents(): Flow<E>
+
+    /**
+     * Attach observer to this [Timeline]. The observer collection is not triggered right away, but only on demand.
+     *
+     * Each collection shifts [TimelineObserver.lastCollectedEventTime] for this observer.
+     * The value of [observedTime] is the least of all observers [TimelineObserver.lastCollectedEventTime].
+     */
+    public suspend fun observe(
+        collector: suspend Flow<E>.() -> Unit
+    ): TimelineObserver
+
+    /**
+     * Advance simulation time to [toTime]. This method forces all observers to collect all events in the given range.
+     *
+     * This method suspends until all advancement is done
+     */
+    public suspend fun advance(toTime: Instant)
+
+    /**
+     * Interrupt generation of this timeline and discard unconsumed events after [atTime].
+     *
+     * Throw exception if at least one observer advanced
+     */
+    public suspend fun interrupt(atTime: Instant): Unit
+}
\ No newline at end of file

From becde944032c84074ec0b940680149988a2ac09b Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Thu, 21 Nov 2024 09:42:53 +0300
Subject: [PATCH 119/125] GeneratingTimeLine prototype is working

---
 .../commonMain/kotlin/GeneratingTimeline.kt   | 108 ++++++++++++------
 .../src/commonMain/kotlin/MergedTimeline.kt   |  20 ++--
 .../src/commonMain/kotlin/SharedTimeline.kt   |  42 +++----
 .../src/commonMain/kotlin/Timeline.kt         |  43 ++++---
 .../src/commonMain/kotlin/notNullUtils.kt     |  35 ++++++
 .../src/commonTest/kotlin/TimelineTests.kt    |  34 ++++++
 6 files changed, 201 insertions(+), 81 deletions(-)
 create mode 100644 simulation-kt/src/commonMain/kotlin/notNullUtils.kt
 create mode 100644 simulation-kt/src/commonTest/kotlin/TimelineTests.kt

diff --git a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
index 4e9da50..f3d7d92 100644
--- a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
@@ -2,10 +2,9 @@ package space.kscience.simulation
 
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.Job
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.takeWhile
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
@@ -20,21 +19,49 @@ public class GeneratingTimeline<E : TimelineEvent>(
     private val initialEvent: E,
     private val lookaheadInterval: Duration,
     private val generatorChain: suspend (E) -> E
-) : Timeline<E> {
+) : Timeline<E>, AutoCloseable {
+
+    // push to this channel to trigger event generation
+    private val wakeupChannel = Channel<Unit>(onBufferOverflow = BufferOverflow.DROP_OLDEST)
+
+    private suspend fun kickGenerator() {
+        wakeupChannel.send(Unit)
+    }
 
     private val mutex = Mutex()
 
-    private val events = ArrayDeque<E>()
+    private val history = ArrayDeque<E>()
+
+    private val lastEvent = MutableSharedFlow<E>(replay = Int.MAX_VALUE)
+
+    private val updateHistoryJob = generationScope.launch {
+        lastEvent.onEach {
+            mutex.withLock {
+                history.add(it)
+                //cleanup old events
+                val threshold = observedTime ?: return@withLock
+                while (history.isNotEmpty() && history.last().time > threshold) {
+                    history.removeFirst()
+                }
+
+            }
+        }
+    }
 
     private val observers: MutableSet<TimelineObserver> = mutableSetOf()
 
-    override val lastEventTime: Instant?
-        get() = events.lastOrNull()?.time
+    override val time: Instant
+        get() = history.lastOrNull()?.time ?: initialEvent.time
 
-    override val observedTime: Instant
-        get() = observers.minOfOrNull { it.lastCollectedEventTime } ?: Instant.DISTANT_PAST
+    override val observedTime: Instant?
+        get() = observers.minOfNotNullOrNull { it.time }
 
-    override fun flowUnobservedEvents(): Flow<E> = events.asFlow()
+    override fun flowUnobservedEvents(): Flow<E> = flow {
+        history.forEach { e ->
+            emit(e)
+        }
+        emitAll(lastEvent)
+    }
 
     override suspend fun advance(toTime: Instant) {
         observers.forEach {
@@ -42,51 +69,60 @@ public class GeneratingTimeline<E : TimelineEvent>(
         }
     }
 
-    private var generatorJob: Job = launchGenerateJob(initialEvent)
+    private var generatorJob: Job = launchGenerator(initialEvent)
 
-    private fun launchGenerateJob(event: E): Job = generationScope.launch {
+    private fun launchGenerator(event: E): Job = generationScope.launch {
+        kickGenerator()
         var currentEvent = event
-        while(currentEvent.time < observedTime + lookaheadInterval) {
-            val nextEvent = generatorChain(currentEvent)
-            mutex.withLock {
-                events.add(nextEvent)
+        // for each wakeup generate all events in lookaheadInterval
+        for (u in wakeupChannel) {
+            while (currentEvent.time < (observedTime ?: event.time) + lookaheadInterval) {
+                val nextEvent = generatorChain(currentEvent)
+                lastEvent.emit(nextEvent)
+                currentEvent = nextEvent
             }
-            currentEvent = nextEvent
         }
     }
 
-    private fun regenerate(event: E) {
-        generatorJob.cancel()
-        generatorJob = launchGenerateJob(event)
-    }
 
-    /**
-     * Discard unconsumed events after [atTime].
-     */
-    override suspend fun interrupt(atTime: Instant): Unit {
-        if (atTime < observedTime)
-            error("Timeline interrupt at time $atTime is not possible because there are observed events before $observedTime")
+    public suspend fun interrupt(newStart: E) {
+        check(newStart.time > (observedTime ?: Instant.DISTANT_FUTURE)) {
+            "Can't interrupt generating timeline after observed event"
+        }
         mutex.withLock {
-            while (events.isNotEmpty() && events.last().time > atTime) {
-                events.removeLast()
+            while (history.isNotEmpty() && history.last().time > newStart.time) {
+                history.removeLast()
             }
+            generatorJob.cancel()
+            generatorJob = launchGenerator(newStart)
+
         }
+        kickGenerator()
+    }
+
+    override fun close() {
+        updateHistoryJob.cancel()
+        generatorJob.cancel()
     }
 
     override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
         val observer = object : TimelineObserver {
-            val observerMutex = Mutex()
-            override var lastCollectedEventTime: Instant = Instant.DISTANT_PAST
+            override var time: Instant = this@GeneratingTimeline.time
 
-            override suspend fun collect(upTo: Instant) = observerMutex.withLock {
-                flowUnobservedEvents().takeWhile { it.time <= upTo }.onEach {
-                    lastCollectedEventTime = it.time
+            override suspend fun collect(upTo: Instant) {
+                flowUnobservedEvents().takeWhile {
+                    it.time <= upTo
+                }.onEach {
+                    time = it.time
+                    kickGenerator()
                 }.collector()
-                //cleanup()
             }
 
             override fun close() {
                 observers.remove(this)
+                if(observers.isEmpty()){
+                    this@GeneratingTimeline.close()
+                }
             }
 
         }
diff --git a/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
index 991cdc4..c498bb8 100644
--- a/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
@@ -6,13 +6,15 @@ import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.takeWhile
 import kotlinx.datetime.Instant
 
+
 public class MergedTimeline<E : TimelineEvent>(
     private val timelines: List<Timeline<E>>
 ) : Timeline<E> {
-    override val lastEventTime: Instant?
-        get() = timelines.minOfOrNull { it.lastEventTime ?: Instant.DISTANT_PAST }
-    override val observedTime: Instant
-        get() = timelines.maxOfOrNull { it.observedTime } ?: Instant.DISTANT_FUTURE
+    override val time: Instant
+        get() = timelines.minOfNotNullOrNull { it.time } ?: Instant.DISTANT_PAST
+
+    override val observedTime: Instant?
+        get() = timelines.maxOfNotNullOrNull { it.observedTime }
 
     override fun flowUnobservedEvents(): Flow<E> = timelines.map { flowUnobservedEvents() }.merge()
 
@@ -20,21 +22,21 @@ public class MergedTimeline<E : TimelineEvent>(
         timelines.forEach { it.advance(toTime) }
     }
 
-    override suspend fun interrupt(atTime: Instant) {
-        timelines.forEach { it.interrupt(atTime) }
-    }
+//    override suspend fun interrupt(atTime: Instant) {
+//        timelines.forEach { it.interrupt(atTime) }
+//    }
 
     private val observers: MutableSet<TimelineObserver> = mutableSetOf()
 
     override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
         val observer = object : TimelineObserver {
-            override var lastCollectedEventTime: Instant = Instant.DISTANT_PAST
+            override var time: Instant = this@MergedTimeline.time
 
             override suspend fun collect(upTo: Instant) = timelines
                 .map { flowUnobservedEvents() }
                 .merge()
                 .takeWhile { it.time <= upTo }.onEach {
-                    lastCollectedEventTime = it.time
+                    time = it.time
                 }.collector()
 
 
diff --git a/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
index 067d4d2..b5e4845 100644
--- a/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
@@ -19,11 +19,11 @@ public class SharedTimeline<E : TimelineEvent> : Timeline<E> {
 
     private val observers: MutableSet<TimelineObserver> = mutableSetOf()
 
-    override val lastEventTime: Instant?
-        get() = events.lastOrNull()?.time
+    override val time: Instant
+        get() = events.lastOrNull()?.time ?: Instant.DISTANT_PAST
 
-    override val observedTime: Instant
-        get() = observers.minOfOrNull { it.lastCollectedEventTime } ?: Instant.DISTANT_PAST
+    override val observedTime: Instant?
+        get() = observers.minOfNotNullOrNull { it.time }
 
     override fun flowUnobservedEvents(): Flow<E> = events.asFlow()
 
@@ -31,7 +31,9 @@ public class SharedTimeline<E : TimelineEvent> : Timeline<E> {
      * Emit new event to the timeline
      */
     public suspend fun emit(event: E): Boolean = mutex.withLock {
-        if (event.time < observedTime) error("Can't emit event $event because there are observed events after $observedTime")
+        if (event.time < (observedTime ?: Instant.DISTANT_PAST)) {
+            error("Can't emit event $event because there are observed events after $observedTime")
+        }
         events.add(event)
     }
 
@@ -44,33 +46,33 @@ public class SharedTimeline<E : TimelineEvent> : Timeline<E> {
     /**
      * Discard all events before [observedTime]
      */
-    private suspend fun cleanup(): Unit {
-        val threshold = observedTime
+    private suspend fun cleanup(): Unit = mutex.withLock {
+        val threshold = observedTime ?: return@withLock
         while (events.isNotEmpty() && events.last().time > threshold) {
             events.removeFirst()
         }
     }
 
-    /**
-     * Discard unconsumed events after [atTime].
-     */
-    override suspend fun interrupt(atTime: Instant): Unit = mutex.withLock {
-        val threshold = observedTime
-        if (atTime < threshold)
-            error("Timeline interrupt at time $atTime is not possible because there are observed events before $threshold")
-        while (events.isNotEmpty() && events.last().time > atTime) {
-            events.removeLast()
-        }
-    }
+//    /**
+//     * Discard unconsumed events after [atTime].
+//     */
+//    override suspend fun interrupt(atTime: Instant): Unit = mutex.withLock {
+//        val threshold = observedTime
+//        if (atTime < threshold)
+//            error("Timeline interrupt at time $atTime is not possible because there are observed events before $threshold")
+//        while (events.isNotEmpty() && events.last().time > atTime) {
+//            events.removeLast()
+//        }
+//    }
 
     override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
         val observer = object : TimelineObserver {
             val observerMutex = Mutex()
-            override var lastCollectedEventTime: Instant = Instant.DISTANT_PAST
+            override var time: Instant = this@SharedTimeline.time
 
             override suspend fun collect(upTo: Instant) = observerMutex.withLock {
                 flowUnobservedEvents().takeWhile { it.time <= upTo }.onEach {
-                    lastCollectedEventTime = it.time
+                    time = it.time
                 }.collector()
                 cleanup()
             }
diff --git a/simulation-kt/src/commonMain/kotlin/Timeline.kt b/simulation-kt/src/commonMain/kotlin/Timeline.kt
index f9f4535..64974ff 100644
--- a/simulation-kt/src/commonMain/kotlin/Timeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/Timeline.kt
@@ -2,20 +2,31 @@ package space.kscience.simulation
 
 import kotlinx.coroutines.flow.Flow
 import kotlinx.datetime.Instant
+import kotlin.time.Duration
 
 
 public interface TimelineEvent {
     public val time: Instant
 }
 
-public interface TimelineObserver: AutoCloseable {
+public interface TimelineInterval : TimelineEvent {
+    public val startTime: Instant
+    public val duration: Duration
+
+    override val time: Instant
+        get() = startTime + duration
+}
+
+public data class SimpleTimelineEvent<T>(override val time: Instant, val value: T) : TimelineEvent
+
+public interface TimelineObserver : AutoCloseable {
     /**
-     * The time of the last event collected by this collector
+     * The subjective time of this observer
      */
-    public val lastCollectedEventTime: Instant
+    public val time: Instant
 
     /**
-     * Collect all uncollected events from [lastCollectedEventTime] to [upTo].
+     * Collect all uncollected events from [time] to [upTo].
      *
      * By default, collects all events.
      */
@@ -32,17 +43,17 @@ public interface TimelineObserver: AutoCloseable {
  */
 public interface Timeline<E : TimelineEvent> {
     /**
-     * The timestamp of the last event in a timeline
+     * A subjective time of this timeline. The time could advance without events being produced.
      */
-    public val lastEventTime: Instant?
+    public val time: Instant
 
     /**
      * The time of the last event that was observed by all observers
      */
-    public val observedTime: Instant
+    public val observedTime: Instant?
 
     /**
-     * Flow events from [observedTime] to [lastEventTime].
+     * Flow events from [observedTime] to [time].
      *
      * The resulting flow is finite and should not suspend.
      *
@@ -53,8 +64,8 @@ public interface Timeline<E : TimelineEvent> {
     /**
      * Attach observer to this [Timeline]. The observer collection is not triggered right away, but only on demand.
      *
-     * Each collection shifts [TimelineObserver.lastCollectedEventTime] for this observer.
-     * The value of [observedTime] is the least of all observers [TimelineObserver.lastCollectedEventTime].
+     * Each collection shifts [TimelineObserver.time] for this observer.
+     * The value of [observedTime] is the least of all observers [TimelineObserver.time].
      */
     public suspend fun observe(
         collector: suspend Flow<E>.() -> Unit
@@ -67,10 +78,10 @@ public interface Timeline<E : TimelineEvent> {
      */
     public suspend fun advance(toTime: Instant)
 
-    /**
-     * Interrupt generation of this timeline and discard unconsumed events after [atTime].
-     *
-     * Throw exception if at least one observer advanced
-     */
-    public suspend fun interrupt(atTime: Instant): Unit
+//    /**
+//     * Interrupt generation of this timeline and discard unconsumed events after [atTime].
+//     *
+//     * Throw exception if at least one observer advanced
+//     */
+//    public suspend fun interrupt(atTime: Instant): Unit
 }
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/notNullUtils.kt b/simulation-kt/src/commonMain/kotlin/notNullUtils.kt
new file mode 100644
index 0000000..5e7d64b
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/notNullUtils.kt
@@ -0,0 +1,35 @@
+package space.kscience.simulation
+
+internal inline fun <T, R : Comparable<R>> Iterable<T>.minOfNotNullOrNull(selector: (T) -> R?): R? {
+    val iterator = iterator()
+    if (!iterator.hasNext()) return null
+    var minValue = selector(iterator.next())
+    while (iterator.hasNext()) {
+        val v = selector(iterator.next())
+        when {
+            minValue == null -> minValue = v
+            v == null -> {/*do nothing*/}
+            minValue > v -> {
+                minValue = v
+            }
+        }
+    }
+    return minValue
+}
+
+internal inline fun <T, R : Comparable<R>> Iterable<T>.maxOfNotNullOrNull(selector: (T) -> R?): R? {
+    val iterator = iterator()
+    if (!iterator.hasNext()) return null
+    var maxValue = selector(iterator.next())
+    while (iterator.hasNext()) {
+        val v = selector(iterator.next())
+        when {
+            maxValue == null -> maxValue = v
+            v == null -> {/*do nothing*/}
+            maxValue < v -> {
+                maxValue = v
+            }
+        }
+    }
+    return maxValue
+}
\ No newline at end of file
diff --git a/simulation-kt/src/commonTest/kotlin/TimelineTests.kt b/simulation-kt/src/commonTest/kotlin/TimelineTests.kt
new file mode 100644
index 0000000..a1bf632
--- /dev/null
+++ b/simulation-kt/src/commonTest/kotlin/TimelineTests.kt
@@ -0,0 +1,34 @@
+package space.kscience.simulation
+
+import kotlinx.coroutines.test.runTest
+import kotlinx.datetime.Instant
+import kotlin.test.Test
+import kotlin.time.Duration.Companion.seconds
+
+class TimelineTests {
+
+
+    @Test
+    fun testGeneration() = runTest(timeout = 5.seconds) {
+        val startTime = Instant.parse("2020-01-01T00:00:00.000Z")
+
+        val generation = GeneratingTimeline<SimpleTimelineEvent<DoubleArray>>(
+            this,
+            initialEvent = SimpleTimelineEvent(startTime, List(10) { it.toDouble() }.toDoubleArray()),
+            lookaheadInterval = 1.seconds
+        ) { event ->
+            val time = event.time + 0.1.seconds
+            println("Emit: $time")
+            SimpleTimelineEvent(time, event.value.map { it + 1.0 }.toDoubleArray())
+        }
+
+        val collector = generation.observe {
+            collect {
+                println("Consume: ${it.time}")
+            }
+        }
+
+        collector.collect(startTime + 2.seconds)
+        collector.close()
+    }
+}
\ No newline at end of file

From 9a0c55b24aed4a8831e0d7113804342199be5405 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 29 Nov 2024 10:04:55 +0300
Subject: [PATCH 120/125] Fix flag in serial port

---
 .../space/kscience/controls/serial/AsynchronousSerialPort.kt    | 2 +-
 .../kotlin/space/kscience/controls/serial/SerialPortPlugin.kt   | 2 ++
 2 files changed, 3 insertions(+), 1 deletion(-)

diff --git a/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
index a9c4ede..b581405 100644
--- a/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
+++ b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
@@ -29,7 +29,7 @@ public class AsynchronousSerialPort(
 
     private val serialPortListener = object : SerialPortDataListener {
         override fun getListeningEvents(): Int =
-            SerialPort.LISTENING_EVENT_DATA_RECEIVED and SerialPort.LISTENING_EVENT_DATA_AVAILABLE
+            SerialPort.LISTENING_EVENT_DATA_RECEIVED or SerialPort.LISTENING_EVENT_DATA_AVAILABLE
 
         override fun serialEvent(event: SerialPortEvent) {
             when (event.eventType) {
diff --git a/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
index f0d099f..b43fc00 100644
--- a/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
+++ b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
@@ -11,6 +11,8 @@ import space.kscience.dataforge.names.asName
 
 public class SerialPortPlugin : AbstractPlugin() {
 
+    public val ports: Ports by require(Ports)
+
     override val tag: PluginTag get() = Companion.tag
 
     override fun content(target: String): Map<Name, Any> = when (target) {

From b4b534df1d55665fb440c373521aaf2e91162497 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Fri, 29 Nov 2024 12:58:24 +0300
Subject: [PATCH 121/125] Change property delegates names

---
 build.gradle.kts                              |  2 +-
 .../controls/spec/propertySpecDelegates.kt    | 43 +++++++++++++------
 .../sciprog/devices/mks/MksPdr900Device.kt    |  6 +--
 .../pimotionmaster/PiMotionMasterDevice.kt    |  6 +--
 4 files changed, 36 insertions(+), 21 deletions(-)

diff --git a/build.gradle.kts b/build.gradle.kts
index 619a512..7e06cfa 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -8,7 +8,7 @@ plugins {
 
 allprojects {
     group = "space.kscience"
-    version = "0.4.0-dev-6"
+    version = "0.4.0-dev-7"
     repositories{
         google()
     }
diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
index 8bd22b6..efb66a7 100644
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt
@@ -41,25 +41,24 @@ public fun <T, D : Device> DeviceSpec<D>.mutableProperty(
         write = { _, value: T -> readWriteProperty.set(this, value) }
     )
 
+//read only delegates
+
 /**
- * Register a mutable logical property (without a corresponding physical state) for a device
+ * Register a read-only logical property
+ * (without a corresponding physical state or with a state that is updated asynchronously) for a device
  */
-public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
+public fun <T, D : DeviceBase<D>> DeviceSpec<D>.property(
     converter: MetaConverter<T>,
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
-    mutableProperty(
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> =
+    property(
         converter,
         descriptorBuilder,
         name,
         read = { propertyName -> getProperty(propertyName)?.let(converter::readOrNull) },
-        write = { propertyName, value -> writeProperty(propertyName, converter.convert(value)) }
     )
 
-
-//read only delegates
-
 public fun <D : Device> DeviceSpec<D>.booleanProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
@@ -141,7 +140,25 @@ public fun <D : Device> DeviceSpec<D>.metaProperty(
 
 //read-write delegates
 
-public fun <D : Device> DeviceSpec<D>.booleanProperty(
+
+/**
+ * Register a mutable logical property
+ * (without a corresponding physical state or with a state that is updated asynchronously) for a device
+ */
+public fun <T, D : DeviceBase<D>> DeviceSpec<D>.mutableProperty(
+    converter: MetaConverter<T>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    name: String? = null,
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
+    mutableProperty(
+        converter,
+        descriptorBuilder,
+        name,
+        read = { propertyName -> getProperty(propertyName)?.let(converter::readOrNull) },
+        write = { propertyName, value -> writeProperty(propertyName, converter.convert(value)) }
+    )
+
+public fun <D : Device> DeviceSpec<D>.mutableBooleanProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
     read: suspend D.(propertyName: String) -> Boolean?,
@@ -161,7 +178,7 @@ public fun <D : Device> DeviceSpec<D>.booleanProperty(
     )
 
 
-public fun <D : Device> DeviceSpec<D>.numberProperty(
+public fun <D : Device> DeviceSpec<D>.mutableNumberProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
     read: suspend D.(propertyName: String) -> Number,
@@ -169,7 +186,7 @@ public fun <D : Device> DeviceSpec<D>.numberProperty(
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Number>>> =
     mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write)
 
-public fun <D : Device> DeviceSpec<D>.doubleProperty(
+public fun <D : Device> DeviceSpec<D>.mutableDoubleProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
     read: suspend D.(propertyName: String) -> Double,
@@ -177,7 +194,7 @@ public fun <D : Device> DeviceSpec<D>.doubleProperty(
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Double>>> =
     mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write)
 
-public fun <D : Device> DeviceSpec<D>.stringProperty(
+public fun <D : Device> DeviceSpec<D>.mutableStringProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
     read: suspend D.(propertyName: String) -> String,
@@ -185,7 +202,7 @@ public fun <D : Device> DeviceSpec<D>.stringProperty(
 ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, String>>> =
     mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write)
 
-public fun <D : Device> DeviceSpec<D>.metaProperty(
+public fun <D : Device> DeviceSpec<D>.mutableMetaProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
     read: suspend D.(propertyName: String) -> Meta,
diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
index d28acc0..892d465 100644
--- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
+++ b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt
@@ -88,15 +88,15 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi
 
         override fun build(context: Context, meta: Meta): MksPdr900Device = MksPdr900Device(context, meta)
 
-        val powerOn by booleanProperty(read = { readPowerOn() }, write = { _, value -> writePowerOn(value) })
+        val powerOn by mutableBooleanProperty(read = { readPowerOn() }, write = { _, value -> writePowerOn(value) })
 
-        val channel by logicalProperty(MetaConverter.int)
+        val channel by property(MetaConverter.int)
 
         val value by doubleProperty(read = {
             readChannelData(getOrRead(channel))
         })
 
-        val error by logicalProperty(MetaConverter.string)
+        val error by property(MetaConverter.string)
 
 
         override suspend fun MksPdr900Device.onClose() {
diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
index 04e03df..4324b9b 100644
--- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
+++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -23,8 +23,6 @@ import space.kscience.dataforge.context.*
 import space.kscience.dataforge.meta.*
 import space.kscience.dataforge.names.Name
 import space.kscience.dataforge.names.parseAsName
-import kotlin.collections.component1
-import kotlin.collections.component2
 import kotlin.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
 
@@ -238,7 +236,7 @@ class PiMotionMasterDevice(
             private fun axisBooleanProperty(
                 command: String,
                 descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-            ) = booleanProperty(
+            ) = mutableBooleanProperty(
                 read = {
                     readAxisBoolean("$command?")
                 },
@@ -251,7 +249,7 @@ class PiMotionMasterDevice(
             private fun axisNumberProperty(
                 command: String,
                 descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-            ) = doubleProperty(
+            ) = mutableDoubleProperty(
                 read = {
                     mm.requestAndParse("$command?", axisId)[axisId]?.toDoubleOrNull()
                         ?: error("Malformed $command response. Should include float value for $axisId")

From bb09a747105bb055798a98f68056935eca923246 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 8 Dec 2024 10:37:47 +0300
Subject: [PATCH 122/125] GeneratingTimeline fully functional

---
 gradle/wrapper/gradle-wrapper.properties      |   2 +-
 settings.gradle.kts                           |   4 +
 simulation-kt/build.gradle.kts                |   2 +-
 .../src/commonMain/kotlin/AbstractTimeline.kt |  70 +++++++++
 .../commonMain/kotlin/GeneratingTimeline.kt   | 143 +++++-------------
 .../src/commonMain/kotlin/MergedTimeline.kt   |  54 ++-----
 .../src/commonMain/kotlin/SharedTimeline.kt   |  84 ++--------
 .../src/commonMain/kotlin/Timeline.kt         |  27 +---
 .../src/commonTest/kotlin/TimelineTests.kt    |  38 +++--
 9 files changed, 176 insertions(+), 248 deletions(-)
 create mode 100644 simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt

diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 48c0a02..81aa1c0 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,5 +1,5 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 702ce9b..1c6b8b2 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -21,6 +21,10 @@ pluginManagement {
     }
 }
 
+plugins {
+    id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
+}
+
 dependencyResolutionManagement {
 
     val toolsVersion: String by extra
diff --git a/simulation-kt/build.gradle.kts b/simulation-kt/build.gradle.kts
index c30480a..b365d98 100644
--- a/simulation-kt/build.gradle.kts
+++ b/simulation-kt/build.gradle.kts
@@ -28,5 +28,5 @@ kscience {
 
 
 readme{
-    maturity = Maturity.EXPERIMENTAL
+    maturity = Maturity.PROTOTYPE
 }
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt b/simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt
new file mode 100644
index 0000000..9876083
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt
@@ -0,0 +1,70 @@
+package space.kscience.simulation
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.BufferOverflow
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import kotlinx.datetime.Instant
+
+public abstract class AbstractTimeline<E : TimelineEvent>(
+    protected val timelineScope: CoroutineScope,
+    protected var startTime: Instant,
+) : Timeline<E> {
+
+    private val observers: MutableSet<TimelineObserver> = mutableSetOf()
+
+    private val feedbackChannel = Channel<Unit>(onBufferOverflow = BufferOverflow.DROP_OLDEST)
+
+    override val time: StateFlow<Instant> = feedbackChannel.consumeAsFlow().map {
+        maxOf(startTime,observers.maxOfOrNull { it.time.value } ?: startTime)
+    }.stateIn(timelineScope, SharingStarted.Lazily, startTime)
+
+    override suspend fun advance(toTime: Instant) {
+        observers.forEach {
+            it.collect(toTime)
+        }
+    }
+
+    /**
+     * Flow unobserved events starting at [time]. The flow could be interrupted if timeline changes
+     */
+    protected abstract fun events(): Flow<E>
+
+    override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
+        val context = currentCoroutineContext()
+        val observer = object : TimelineObserver {
+            // observed time
+            override val time = MutableStateFlow(startTime)
+
+            private val channel = Channel<E>()
+
+            private val collectJob = timelineScope.launch(context) {
+                channel.consumeAsFlow().onEach {
+                    time.emit(it.time)
+                    feedbackChannel.send(Unit)
+                }.collector()
+            }
+
+
+            override suspend fun collect(upTo: Instant) = coroutineScope {
+                require(upTo >= time.value) { "Requested time $upTo is lower than observed ${time.value}" }
+                events().takeWhile {
+                    it.time <= upTo
+                }.collect {
+                    channel.send(it)
+                }
+            }
+
+            override fun close() {
+                collectJob.cancel()
+                observers.remove(this)
+            }
+
+        }
+        observers.add(observer)
+        return observer
+    }
+}
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
index f3d7d92..6f29266 100644
--- a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
@@ -1,132 +1,65 @@
 package space.kscience.simulation
 
 import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
 import kotlinx.datetime.Instant
 import kotlin.time.Duration
 
+/**
+ * Suspend the collection of this [Flow] until event time is lower that threshold
+ */
+public fun <E : TimelineEvent> Flow<E>.withTimeThreshold(
+    threshold: Flow<Instant>
+): Flow<E> = transform { event ->
+    threshold.first { it > event.time }
+    emit(event)
+}
+
 /**
  * @param lookaheadInterval an interval for generated events ahead of the last observed event.
  */
 public class GeneratingTimeline<E : TimelineEvent>(
     private val generationScope: CoroutineScope,
-    private val initialEvent: E,
+    private val origin: E,
     private val lookaheadInterval: Duration,
-    private val generatorChain: suspend (E) -> E
-) : Timeline<E>, AutoCloseable {
+    private val generator: suspend FlowCollector<E>.(E) -> Unit
+) : AbstractTimeline<E>(generationScope, origin.time) {
 
-    // push to this channel to trigger event generation
-    private val wakeupChannel = Channel<Unit>(onBufferOverflow = BufferOverflow.DROP_OLDEST)
+    private val startEventFlow = MutableStateFlow(origin)
 
-    private suspend fun kickGenerator() {
-        wakeupChannel.send(Unit)
+    private data class EventWithOrigin<E : TimelineEvent>(val origin: E, val event: E) : TimelineEvent {
+        override val time: Instant get() = event.time
     }
 
-    private val mutex = Mutex()
-
-    private val history = ArrayDeque<E>()
-
-    private val lastEvent = MutableSharedFlow<E>(replay = Int.MAX_VALUE)
-
-    private val updateHistoryJob = generationScope.launch {
-        lastEvent.onEach {
-            mutex.withLock {
-                history.add(it)
-                //cleanup old events
-                val threshold = observedTime ?: return@withLock
-                while (history.isNotEmpty() && history.last().time > threshold) {
-                    history.removeFirst()
-                }
-
+    private val events: SharedFlow<E> = flow {
+        coroutineScope {
+            startEventFlow.collect { startEvent ->
+                emitAll(
+                    flow { generator(startEvent) }.takeWhile { startEvent == startEventFlow.value }.map {
+                        EventWithOrigin(startEvent, it)
+                    }
+                )
             }
         }
-    }
-
-    private val observers: MutableSet<TimelineObserver> = mutableSetOf()
-
-    override val time: Instant
-        get() = history.lastOrNull()?.time ?: initialEvent.time
-
-    override val observedTime: Instant?
-        get() = observers.minOfNotNullOrNull { it.time }
-
-    override fun flowUnobservedEvents(): Flow<E> = flow {
-        history.forEach { e ->
-            emit(e)
-        }
-        emitAll(lastEvent)
-    }
-
-    override suspend fun advance(toTime: Instant) {
-        observers.forEach {
-            it.collect(toTime)
-        }
-    }
-
-    private var generatorJob: Job = launchGenerator(initialEvent)
-
-    private fun launchGenerator(event: E): Job = generationScope.launch {
-        kickGenerator()
-        var currentEvent = event
-        // for each wakeup generate all events in lookaheadInterval
-        for (u in wakeupChannel) {
-            while (currentEvent.time < (observedTime ?: event.time) + lookaheadInterval) {
-                val nextEvent = generatorChain(currentEvent)
-                lastEvent.emit(nextEvent)
-                currentEvent = nextEvent
-            }
-        }
-    }
+    }.withTimeThreshold(
+        threshold = time.map { it + lookaheadInterval }
+    ).buffer(Channel.UNLIMITED).mapNotNull {
+        //it.event
+        it.takeIf { it.origin == startEventFlow.value }?.event
+    }.shareIn(
+        scope = generationScope,
+        started = SharingStarted.Eagerly,
+    )
 
+    override fun events(): Flow<E> = events
 
     public suspend fun interrupt(newStart: E) {
-        check(newStart.time > (observedTime ?: Instant.DISTANT_FUTURE)) {
+        check(newStart.time >= time.value) {
             "Can't interrupt generating timeline after observed event"
         }
-        mutex.withLock {
-            while (history.isNotEmpty() && history.last().time > newStart.time) {
-                history.removeLast()
-            }
-            generatorJob.cancel()
-            generatorJob = launchGenerator(newStart)
-
-        }
-        kickGenerator()
-    }
-
-    override fun close() {
-        updateHistoryJob.cancel()
-        generatorJob.cancel()
-    }
-
-    override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
-        val observer = object : TimelineObserver {
-            override var time: Instant = this@GeneratingTimeline.time
-
-            override suspend fun collect(upTo: Instant) {
-                flowUnobservedEvents().takeWhile {
-                    it.time <= upTo
-                }.onEach {
-                    time = it.time
-                    kickGenerator()
-                }.collector()
-            }
-
-            override fun close() {
-                observers.remove(this)
-                if(observers.isEmpty()){
-                    this@GeneratingTimeline.close()
-                }
-            }
-
-        }
-        observers.add(observer)
-        return observer
+        startTime = newStart.time
+        startEventFlow.emit(newStart)
     }
 }
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
index c498bb8..334e8c7 100644
--- a/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
@@ -1,51 +1,25 @@
 package space.kscience.simulation
 
+import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.merge
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.takeWhile
-import kotlinx.datetime.Instant
+import kotlinx.coroutines.flow.flow
 
 
 public class MergedTimeline<E : TimelineEvent>(
+    timelineScope: CoroutineScope,
     private val timelines: List<Timeline<E>>
-) : Timeline<E> {
-    override val time: Instant
-        get() = timelines.minOfNotNullOrNull { it.time } ?: Instant.DISTANT_PAST
+) : AbstractTimeline<E>(timelineScope, timelines.minOf { it.time.value }) {
 
-    override val observedTime: Instant?
-        get() = timelines.maxOfNotNullOrNull { it.observedTime }
-
-    override fun flowUnobservedEvents(): Flow<E> = timelines.map { flowUnobservedEvents() }.merge()
-
-    override suspend fun advance(toTime: Instant) {
-        timelines.forEach { it.advance(toTime) }
+    override fun events(): Flow<E> = flow {
+        val buffer = TODO()
+//
+//        timelines.forEach { timeline ->
+//            timeline.observe {
+//                collect{
+//                    buffer.add(it)
+//                }
+//            }
+//        }
     }
 
-//    override suspend fun interrupt(atTime: Instant) {
-//        timelines.forEach { it.interrupt(atTime) }
-//    }
-
-    private val observers: MutableSet<TimelineObserver> = mutableSetOf()
-
-    override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
-        val observer = object : TimelineObserver {
-            override var time: Instant = this@MergedTimeline.time
-
-            override suspend fun collect(upTo: Instant) = timelines
-                .map { flowUnobservedEvents() }
-                .merge()
-                .takeWhile { it.time <= upTo }.onEach {
-                    time = it.time
-                }.collector()
-
-
-            override fun close() {
-                observers.remove(this)
-            }
-
-        }
-        observers.add(observer)
-        return observer
-    }
 }
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
index b5e4845..d9962d8 100644
--- a/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
@@ -1,88 +1,30 @@
 package space.kscience.simulation
 
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.takeWhile
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.datetime.Instant
 
 /**
  * A manually mutable [Timeline] that could be modified via [emit] method by multiple
  */
-public class SharedTimeline<E : TimelineEvent> : Timeline<E> {
+public class SharedTimeline<E : TimelineEvent>(
+    timelineScope: CoroutineScope,
+    startTime: Instant
+) : AbstractTimeline<E>(timelineScope, startTime) {
 
-    private val mutex = Mutex()
+    private val events = MutableSharedFlow<E>(replay = Channel.UNLIMITED)
 
-    private val events = ArrayDeque<E>()
-
-    private val observers: MutableSet<TimelineObserver> = mutableSetOf()
-
-    override val time: Instant
-        get() = events.lastOrNull()?.time ?: Instant.DISTANT_PAST
-
-    override val observedTime: Instant?
-        get() = observers.minOfNotNullOrNull { it.time }
-
-    override fun flowUnobservedEvents(): Flow<E> = events.asFlow()
+    override fun events(): Flow<E> = events
 
     /**
      * Emit new event to the timeline
      */
-    public suspend fun emit(event: E): Boolean = mutex.withLock {
-        if (event.time < (observedTime ?: Instant.DISTANT_PAST)) {
-            error("Can't emit event $event because there are observed events after $observedTime")
+    public suspend fun emit(event: E) {
+        if (event.time < (events.replayCache.lastOrNull()?.time ?: time.value)) {
+            error("Can't emit event $event because timeline monotony is broken")
         }
-        events.add(event)
-    }
-
-    override suspend fun advance(toTime: Instant) {
-        observers.forEach {
-            it.collect(toTime)
-        }
-    }
-
-    /**
-     * Discard all events before [observedTime]
-     */
-    private suspend fun cleanup(): Unit = mutex.withLock {
-        val threshold = observedTime ?: return@withLock
-        while (events.isNotEmpty() && events.last().time > threshold) {
-            events.removeFirst()
-        }
-    }
-
-//    /**
-//     * Discard unconsumed events after [atTime].
-//     */
-//    override suspend fun interrupt(atTime: Instant): Unit = mutex.withLock {
-//        val threshold = observedTime
-//        if (atTime < threshold)
-//            error("Timeline interrupt at time $atTime is not possible because there are observed events before $threshold")
-//        while (events.isNotEmpty() && events.last().time > atTime) {
-//            events.removeLast()
-//        }
-//    }
-
-    override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
-        val observer = object : TimelineObserver {
-            val observerMutex = Mutex()
-            override var time: Instant = this@SharedTimeline.time
-
-            override suspend fun collect(upTo: Instant) = observerMutex.withLock {
-                flowUnobservedEvents().takeWhile { it.time <= upTo }.onEach {
-                    time = it.time
-                }.collector()
-                cleanup()
-            }
-
-            override fun close() {
-                observers.remove(this)
-            }
-
-        }
-        observers.add(observer)
-        return observer
+        events.emit(event)
     }
 }
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/Timeline.kt b/simulation-kt/src/commonMain/kotlin/Timeline.kt
index 64974ff..27219cd 100644
--- a/simulation-kt/src/commonMain/kotlin/Timeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/Timeline.kt
@@ -1,6 +1,7 @@
 package space.kscience.simulation
 
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
 import kotlinx.datetime.Instant
 import kotlin.time.Duration
 
@@ -21,9 +22,9 @@ public data class SimpleTimelineEvent<T>(override val time: Instant, val value:
 
 public interface TimelineObserver : AutoCloseable {
     /**
-     * The subjective time of this observer
+     * The subjective time of this observer (last observed time)
      */
-    public val time: Instant
+    public val time: StateFlow<Instant>
 
     /**
      * Collect all uncollected events from [time] to [upTo].
@@ -33,6 +34,8 @@ public interface TimelineObserver : AutoCloseable {
     public suspend fun collect(upTo: Instant = Instant.DISTANT_FUTURE)
 }
 
+public suspend fun TimelineObserver.collect(duration: Duration) = collect(time.value + duration)
+
 /**
  * A time-ordered sequence of events of type [E]. There time of events is strictly monotonic, meaning that the time of
  * the next event is greater than the previous event time.
@@ -43,29 +46,15 @@ public interface TimelineObserver : AutoCloseable {
  */
 public interface Timeline<E : TimelineEvent> {
     /**
-     * A subjective time of this timeline. The time could advance without events being produced.
+     * A subjective time of this timeline. The subjective time is the last observed time.
      */
-    public val time: Instant
+    public val time: StateFlow<Instant>
 
-    /**
-     * The time of the last event that was observed by all observers
-     */
-    public val observedTime: Instant?
-
-    /**
-     * Flow events from [observedTime] to [time].
-     *
-     * The resulting flow is finite and should not suspend.
-     *
-     * This method does not affect [observedTime].
-     */
-    public fun flowUnobservedEvents(): Flow<E>
 
     /**
      * Attach observer to this [Timeline]. The observer collection is not triggered right away, but only on demand.
      *
      * Each collection shifts [TimelineObserver.time] for this observer.
-     * The value of [observedTime] is the least of all observers [TimelineObserver.time].
      */
     public suspend fun observe(
         collector: suspend Flow<E>.() -> Unit
@@ -84,4 +73,4 @@ public interface Timeline<E : TimelineEvent> {
 //     * Throw exception if at least one observer advanced
 //     */
 //    public suspend fun interrupt(atTime: Instant): Unit
-}
\ No newline at end of file
+}
diff --git a/simulation-kt/src/commonTest/kotlin/TimelineTests.kt b/simulation-kt/src/commonTest/kotlin/TimelineTests.kt
index a1bf632..a052d64 100644
--- a/simulation-kt/src/commonTest/kotlin/TimelineTests.kt
+++ b/simulation-kt/src/commonTest/kotlin/TimelineTests.kt
@@ -1,34 +1,50 @@
 package space.kscience.simulation
 
+import kotlinx.coroutines.isActive
 import kotlinx.coroutines.test.runTest
 import kotlinx.datetime.Instant
 import kotlin.test.Test
+import kotlin.time.Duration
 import kotlin.time.Duration.Companion.seconds
 
 class TimelineTests {
 
 
     @Test
-    fun testGeneration() = runTest(timeout = 5.seconds) {
+    fun testGeneration() = runTest{
         val startTime = Instant.parse("2020-01-01T00:00:00.000Z")
 
         val generation = GeneratingTimeline<SimpleTimelineEvent<DoubleArray>>(
             this,
-            initialEvent = SimpleTimelineEvent(startTime, List(10) { it.toDouble() }.toDoubleArray()),
+            origin = SimpleTimelineEvent(startTime, List(10) { it.toDouble() }.toDoubleArray()),
             lookaheadInterval = 1.seconds
         ) { event ->
-            val time = event.time + 0.1.seconds
-            println("Emit: $time")
-            SimpleTimelineEvent(time, event.value.map { it + 1.0 }.toDoubleArray())
-        }
-
-        val collector = generation.observe {
-            collect {
-                println("Consume: ${it.time}")
+            var time = event.time
+            while (isActive) {
+                time += 0.1.seconds
+                println("Emit: ${time - startTime}")
+                emit(SimpleTimelineEvent(time, event.value.map { it + 1.0 }.toDoubleArray()))
             }
         }
 
-        collector.collect(startTime + 2.seconds)
+        val result = mutableListOf<Duration>()
+
+        val collector = generation.observe {
+            collect {
+                println("Consume: ${it.time - startTime}")
+                result.add(it.time - startTime)
+            }
+        }
+
+        collector.collect(2.seconds)
+        println("First collection complete")
+        collector.collect(2.seconds)
+        println("Second collection complete")
+        println("Interrupt")
+        generation.interrupt(SimpleTimelineEvent(startTime + 6.seconds, List(10) { it.toDouble() }.toDoubleArray()))
+        println("Collecting second")
+        collector.collect(startTime + 6.seconds + 2.5.seconds)
+        println(result)
         collector.close()
     }
 }
\ No newline at end of file

From 203a8c157006a8e8dc5719de4835dd031af83507 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 8 Dec 2024 11:12:08 +0300
Subject: [PATCH 123/125] GeneratingTimeline fully functional

---
 .../src/commonMain/kotlin/AbstractTimeline.kt | 22 ++++++++++----
 .../commonMain/kotlin/GeneratingTimeline.kt   | 13 ++++----
 .../src/commonMain/kotlin/MergedTimeline.kt   |  9 +++---
 .../src/commonMain/kotlin/SharedTimeline.kt   |  9 +++---
 .../src/commonMain/kotlin/Timeline.kt         | 21 ++++++++-----
 .../src/commonTest/kotlin/TimelineTests.kt    | 30 +++++++++----------
 6 files changed, 60 insertions(+), 44 deletions(-)

diff --git a/simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt b/simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt
index 9876083..b4d243f 100644
--- a/simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt
@@ -1,18 +1,23 @@
 package space.kscience.simulation
 
-import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.*
 import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.coroutineScope
-import kotlinx.coroutines.currentCoroutineContext
 import kotlinx.coroutines.flow.*
-import kotlinx.coroutines.launch
 import kotlinx.datetime.Instant
+import kotlin.coroutines.CoroutineContext
 
 public abstract class AbstractTimeline<E : TimelineEvent>(
-    protected val timelineScope: CoroutineScope,
     protected var startTime: Instant,
-) : Timeline<E> {
+    coroutineContext: CoroutineContext
+) : Timeline<E>, AutoCloseable {
+
+    protected val timelineScope: CoroutineScope = CoroutineScope(
+        coroutineContext +
+        SupervisorJob(coroutineContext[Job]) +
+        CoroutineExceptionHandler{ _, throwable -> throwable.printStackTrace() } +
+        CoroutineName("Timeline")
+    )
 
     private val observers: MutableSet<TimelineObserver> = mutableSetOf()
 
@@ -67,4 +72,9 @@ public abstract class AbstractTimeline<E : TimelineEvent>(
         observers.add(observer)
         return observer
     }
+
+    override fun close() {
+        observers.forEach { it.close() }
+        timelineScope.cancel()
+    }
 }
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
index 6f29266..5690d7c 100644
--- a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
@@ -1,10 +1,11 @@
 package space.kscience.simulation
 
-import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.flow.*
 import kotlinx.datetime.Instant
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
 import kotlin.time.Duration
 
 /**
@@ -21,11 +22,11 @@ public fun <E : TimelineEvent> Flow<E>.withTimeThreshold(
  * @param lookaheadInterval an interval for generated events ahead of the last observed event.
  */
 public class GeneratingTimeline<E : TimelineEvent>(
-    private val generationScope: CoroutineScope,
     private val origin: E,
     private val lookaheadInterval: Duration,
+    coroutineContext: CoroutineContext = EmptyCoroutineContext,
     private val generator: suspend FlowCollector<E>.(E) -> Unit
-) : AbstractTimeline<E>(generationScope, origin.time) {
+) : AbstractTimeline<E>(origin.time, coroutineContext) {
 
     private val startEventFlow = MutableStateFlow(origin)
 
@@ -46,11 +47,11 @@ public class GeneratingTimeline<E : TimelineEvent>(
     }.withTimeThreshold(
         threshold = time.map { it + lookaheadInterval }
     ).buffer(Channel.UNLIMITED).mapNotNull {
-        //it.event
+        //a barrier to avoid leaking stale events after interruption from buffer
         it.takeIf { it.origin == startEventFlow.value }?.event
     }.shareIn(
-        scope = generationScope,
-        started = SharingStarted.Eagerly,
+        scope = timelineScope,
+        started = SharingStarted.Lazily,
     )
 
     override fun events(): Flow<E> = events
diff --git a/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
index 334e8c7..0da6227 100644
--- a/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
@@ -1,14 +1,15 @@
 package space.kscience.simulation
 
-import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.flow
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
 
 
 public class MergedTimeline<E : TimelineEvent>(
-    timelineScope: CoroutineScope,
-    private val timelines: List<Timeline<E>>
-) : AbstractTimeline<E>(timelineScope, timelines.minOf { it.time.value }) {
+    private val timelines: List<Timeline<E>>,
+    coroutineContext: CoroutineContext = EmptyCoroutineContext
+) : AbstractTimeline<E>(timelines.minOf { it.time.value }, coroutineContext) {
 
     override fun events(): Flow<E> = flow {
         val buffer = TODO()
diff --git a/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
index d9962d8..1b45002 100644
--- a/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
@@ -1,18 +1,19 @@
 package space.kscience.simulation
 
-import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.datetime.Instant
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
 
 /**
  * A manually mutable [Timeline] that could be modified via [emit] method by multiple
  */
 public class SharedTimeline<E : TimelineEvent>(
-    timelineScope: CoroutineScope,
-    startTime: Instant
-) : AbstractTimeline<E>(timelineScope, startTime) {
+    startTime: Instant,
+    coroutineContext: CoroutineContext = EmptyCoroutineContext
+) : AbstractTimeline<E>(startTime, coroutineContext) {
 
     private val events = MutableSharedFlow<E>(replay = Channel.UNLIMITED)
 
diff --git a/simulation-kt/src/commonMain/kotlin/Timeline.kt b/simulation-kt/src/commonMain/kotlin/Timeline.kt
index 27219cd..20a0746 100644
--- a/simulation-kt/src/commonMain/kotlin/Timeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/Timeline.kt
@@ -34,7 +34,10 @@ public interface TimelineObserver : AutoCloseable {
     public suspend fun collect(upTo: Instant = Instant.DISTANT_FUTURE)
 }
 
-public suspend fun TimelineObserver.collect(duration: Duration) = collect(time.value + duration)
+/**
+ * Collect events for a fixed [duration] since last observed time
+ */
+public suspend fun TimelineObserver.collect(duration: Duration): Unit = collect(time.value + duration)
 
 /**
  * A time-ordered sequence of events of type [E]. There time of events is strictly monotonic, meaning that the time of
@@ -66,11 +69,13 @@ public interface Timeline<E : TimelineEvent> {
      * This method suspends until all advancement is done
      */
     public suspend fun advance(toTime: Instant)
-
-//    /**
-//     * Interrupt generation of this timeline and discard unconsumed events after [atTime].
-//     *
-//     * Throw exception if at least one observer advanced
-//     */
-//    public suspend fun interrupt(atTime: Instant): Unit
 }
+
+/**
+ * Perform [collector] action on each event
+ */
+public suspend fun <E : TimelineEvent> Timeline<E>.observeEach(
+    collector: suspend (E) -> Unit
+): TimelineObserver = observe {
+    collect(collector)
+}
\ No newline at end of file
diff --git a/simulation-kt/src/commonTest/kotlin/TimelineTests.kt b/simulation-kt/src/commonTest/kotlin/TimelineTests.kt
index a052d64..b9f53dd 100644
--- a/simulation-kt/src/commonTest/kotlin/TimelineTests.kt
+++ b/simulation-kt/src/commonTest/kotlin/TimelineTests.kt
@@ -11,40 +11,38 @@ class TimelineTests {
 
 
     @Test
-    fun testGeneration() = runTest{
+    fun testGeneration() = runTest(timeout = 1.seconds) {
         val startTime = Instant.parse("2020-01-01T00:00:00.000Z")
 
-        val generation = GeneratingTimeline<SimpleTimelineEvent<DoubleArray>>(
-            this,
-            origin = SimpleTimelineEvent(startTime, List(10) { it.toDouble() }.toDoubleArray()),
+        val generation = GeneratingTimeline(
+            origin = SimpleTimelineEvent(startTime, Unit),
             lookaheadInterval = 1.seconds
         ) { event ->
             var time = event.time
             while (isActive) {
                 time += 0.1.seconds
                 println("Emit: ${time - startTime}")
-                emit(SimpleTimelineEvent(time, event.value.map { it + 1.0 }.toDoubleArray()))
+                emit(SimpleTimelineEvent(time, Unit))
             }
         }
 
         val result = mutableListOf<Duration>()
 
-        val collector = generation.observe {
-            collect {
-                println("Consume: ${it.time - startTime}")
-                result.add(it.time - startTime)
-            }
+        val observer = generation.observeEach {
+            println("Consume: ${it.time - startTime}")
+            result.add(it.time - startTime)
         }
 
-        collector.collect(2.seconds)
+        observer.collect(2.seconds)
         println("First collection complete")
-        collector.collect(2.seconds)
+        observer.collect(2.seconds)
         println("Second collection complete")
         println("Interrupt")
-        generation.interrupt(SimpleTimelineEvent(startTime + 6.seconds, List(10) { it.toDouble() }.toDoubleArray()))
-        println("Collecting second")
-        collector.collect(startTime + 6.seconds + 2.5.seconds)
+        generation.interrupt(SimpleTimelineEvent(startTime + 6.seconds, Unit))
+        println("Collecting after interruption")
+        observer.collect(startTime + 6.seconds + 2.5.seconds)
         println(result)
-        collector.close()
+        generation.close()
+
     }
 }
\ No newline at end of file

From f708bb93e238ac100dc9ff26a2e2219e84762fa4 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 8 Dec 2024 12:04:11 +0300
Subject: [PATCH 124/125] Re-implement MergedTimeline

---
 .../commonMain/kotlin/GeneratingTimeline.kt   |  4 +-
 .../src/commonMain/kotlin/MergedTimeline.kt   | 82 ++++++++++++++++---
 ...bstractTimeline.kt => ProducerTimeline.kt} |  7 +-
 .../src/commonMain/kotlin/SharedTimeline.kt   |  2 +-
 4 files changed, 77 insertions(+), 18 deletions(-)
 rename simulation-kt/src/commonMain/kotlin/{AbstractTimeline.kt => ProducerTimeline.kt} (90%)

diff --git a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
index 5690d7c..0698b45 100644
--- a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
@@ -22,11 +22,11 @@ public fun <E : TimelineEvent> Flow<E>.withTimeThreshold(
  * @param lookaheadInterval an interval for generated events ahead of the last observed event.
  */
 public class GeneratingTimeline<E : TimelineEvent>(
-    private val origin: E,
+    origin: E,
     private val lookaheadInterval: Duration,
     coroutineContext: CoroutineContext = EmptyCoroutineContext,
     private val generator: suspend FlowCollector<E>.(E) -> Unit
-) : AbstractTimeline<E>(origin.time, coroutineContext) {
+) : ProducerTimeline<E>(origin.time, coroutineContext) {
 
     private val startEventFlow = MutableStateFlow(origin)
 
diff --git a/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
index 0da6227..4614fbb 100644
--- a/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
@@ -1,7 +1,11 @@
 package space.kscience.simulation
 
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.datetime.Instant
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
 
@@ -9,18 +13,70 @@ import kotlin.coroutines.EmptyCoroutineContext
 public class MergedTimeline<E : TimelineEvent>(
     private val timelines: List<Timeline<E>>,
     coroutineContext: CoroutineContext = EmptyCoroutineContext
-) : AbstractTimeline<E>(timelines.minOf { it.time.value }, coroutineContext) {
+) : Timeline<E> {
 
-    override fun events(): Flow<E> = flow {
-        val buffer = TODO()
-//
-//        timelines.forEach { timeline ->
-//            timeline.observe {
-//                collect{
-//                    buffer.add(it)
-//                }
-//            }
-//        }
+    protected val timelineScope: CoroutineScope = CoroutineScope(
+        coroutineContext +
+                SupervisorJob(coroutineContext[Job]) +
+                CoroutineExceptionHandler{ _, throwable -> throwable.printStackTrace() } +
+                CoroutineName("MergedTimeline")
+    )
+
+    override val time: StateFlow<Instant> = combine(timelines.map { it.time }){ array->
+        array.max()
+    }.stateIn(timelineScope, SharingStarted.Lazily, timelines.maxOf { it.time.value })
+
+    override suspend fun advance(toTime: Instant) {
+        observers.forEach {
+            it.collect(toTime)
+        }
+    }
+
+    private val observers: MutableSet<TimelineObserver> = mutableSetOf()
+
+    override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver {
+        val context = currentCoroutineContext()
+        val buffer = mutableListOf<E>()
+
+        val timelineObservers = timelines.map {
+            it.observeEach { event ->
+                buffer.add(event)
+            }
+        }
+
+        val observer = object : TimelineObserver {
+
+            private val channel = Channel<E>()
+
+            override val time = MutableStateFlow(this@MergedTimeline.time.value)
+
+            private val collectJob = timelineScope.launch(context) {
+                channel.consumeAsFlow().onEach {
+                    time.emit(it.time)
+                }.collector()
+            }
+
+            private val mutex = Mutex()
+
+            override suspend fun collect(upTo: Instant) = mutex.withLock{
+                timelineObservers.forEach {
+                    it.collect(upTo)
+                }
+                buffer.sortedBy { it.time }.forEach {
+                    channel.send(it)
+                    buffer.remove(it)
+                }
+            }
+
+            override fun close() {
+                collectJob.cancel()
+                observers.remove(this)
+            }
+
+        }
+
+        observers.add(observer)
+        return observer
     }
 
 }
\ No newline at end of file
diff --git a/simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt b/simulation-kt/src/commonMain/kotlin/ProducerTimeline.kt
similarity index 90%
rename from simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt
rename to simulation-kt/src/commonMain/kotlin/ProducerTimeline.kt
index b4d243f..a201d92 100644
--- a/simulation-kt/src/commonMain/kotlin/AbstractTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/ProducerTimeline.kt
@@ -4,10 +4,12 @@ import kotlinx.coroutines.*
 import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.channels.Channel
 import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
 import kotlinx.datetime.Instant
 import kotlin.coroutines.CoroutineContext
 
-public abstract class AbstractTimeline<E : TimelineEvent>(
+public abstract class ProducerTimeline<E : TimelineEvent>(
     protected var startTime: Instant,
     coroutineContext: CoroutineContext
 ) : Timeline<E>, AutoCloseable {
@@ -53,8 +55,9 @@ public abstract class AbstractTimeline<E : TimelineEvent>(
                 }.collector()
             }
 
+            private val mutex = Mutex()
 
-            override suspend fun collect(upTo: Instant) = coroutineScope {
+            override suspend fun collect(upTo: Instant) = mutex.withLock {
                 require(upTo >= time.value) { "Requested time $upTo is lower than observed ${time.value}" }
                 events().takeWhile {
                     it.time <= upTo
diff --git a/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
index 1b45002..051524e 100644
--- a/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
+++ b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
@@ -13,7 +13,7 @@ import kotlin.coroutines.EmptyCoroutineContext
 public class SharedTimeline<E : TimelineEvent>(
     startTime: Instant,
     coroutineContext: CoroutineContext = EmptyCoroutineContext
-) : AbstractTimeline<E>(startTime, coroutineContext) {
+) : ProducerTimeline<E>(startTime, coroutineContext) {
 
     private val events = MutableSharedFlow<E>(replay = Channel.UNLIMITED)
 

From e9a37f40fd1da68a1f469cb4b28f190b311ca4b1 Mon Sep 17 00:00:00 2001
From: Alexander Nozik <altavir@gmail.com>
Date: Sun, 8 Dec 2024 12:15:22 +0300
Subject: [PATCH 125/125] reademe update

---
 README.md                                     |  9 +++++++
 controls-constructor/README.md                |  4 +--
 controls-core/README.md                       |  4 +--
 controls-jupyter/README.md                    |  4 +--
 controls-magix/README.md                      |  4 +--
 controls-modbus/README.md                     |  4 +--
 controls-opcua/README.md                      |  4 +--
 controls-pi/README.md                         |  4 +--
 controls-plc4x/README.md                      | 21 +++++++++++++++
 controls-ports-ktor/README.md                 |  4 +--
 controls-serial/README.md                     |  4 +--
 controls-server/README.md                     |  4 +--
 controls-storage/README.md                    |  4 +--
 controls-storage/controls-xodus/README.md     |  4 +--
 controls-vision/README.md                     |  4 +--
 controls-visualisation-compose/README.md      |  4 +--
 demo/device-collective/README.md              | 10 -------
 magix/magix-api/README.md                     |  4 +--
 magix/magix-java-endpoint/README.md           |  4 +--
 magix/magix-mqtt/README.md                    |  4 +--
 magix/magix-rabbit/README.md                  |  4 +--
 magix/magix-rsocket/README.md                 |  4 +--
 magix/magix-server/README.md                  |  4 +--
 magix/magix-storage/README.md                 |  4 +--
 .../magix-storage-xodus/README.md             |  4 +--
 magix/magix-utils/README.md                   | 21 +++++++++++++++
 magix/magix-zmq/README.md                     |  4 +--
 simulation-kt/README.md                       | 26 +++++++++++++++++++
 simulation-kt/build.gradle.kts                | 13 +++++-----
 29 files changed, 130 insertions(+), 62 deletions(-)
 create mode 100644 controls-plc4x/README.md
 create mode 100644 magix/magix-utils/README.md
 create mode 100644 simulation-kt/README.md

diff --git a/README.md b/README.md
index 6b9c9cd..256b87d 100644
--- a/README.md
+++ b/README.md
@@ -147,6 +147,15 @@ Automatically checks consistency.
 >
 > **Maturity**: EXPERIMENTAL
 
+### [simulation-kt](simulation-kt)
+> A framework for combination of asynchronous simulations.        
+>
+> **Maturity**: PROTOTYPE
+>
+> **Features:**
+> - [timeline](simulation-kt/#) : Timeline is an ordered discrete history containing TimeLineEvent
+
+
 ### [controls-storage/controls-xodus](controls-storage/controls-xodus)
 > An implementation of controls-storage on top of JetBrains Xodus.
 >
diff --git a/controls-constructor/README.md b/controls-constructor/README.md
index 7da4d0b..f2186bd 100644
--- a/controls-constructor/README.md
+++ b/controls-constructor/README.md
@@ -6,7 +6,7 @@ A low-code constructor for composite devices simulation
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-constructor:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-constructor:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-constructor:0.4.0-dev-4")
+    implementation("space.kscience:controls-constructor:0.4.0-dev-7")
 }
 ```
diff --git a/controls-core/README.md b/controls-core/README.md
index 26ed618..f7bc0bd 100644
--- a/controls-core/README.md
+++ b/controls-core/README.md
@@ -16,7 +16,7 @@ Core interfaces for building a device server
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-core:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-core:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -26,6 +26,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-core:0.4.0-dev-4")
+    implementation("space.kscience:controls-core:0.4.0-dev-7")
 }
 ```
diff --git a/controls-jupyter/README.md b/controls-jupyter/README.md
index 6100ddd..045b8d0 100644
--- a/controls-jupyter/README.md
+++ b/controls-jupyter/README.md
@@ -6,7 +6,7 @@
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-jupyter:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-jupyter:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-jupyter:0.4.0-dev-4")
+    implementation("space.kscience:controls-jupyter:0.4.0-dev-7")
 }
 ```
diff --git a/controls-magix/README.md b/controls-magix/README.md
index c474221..f2dd35f 100644
--- a/controls-magix/README.md
+++ b/controls-magix/README.md
@@ -12,7 +12,7 @@ Magix service for binding controls devices (both as RPC client and server)
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-magix:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-magix:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -22,6 +22,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-magix:0.4.0-dev-4")
+    implementation("space.kscience:controls-magix:0.4.0-dev-7")
 }
 ```
diff --git a/controls-modbus/README.md b/controls-modbus/README.md
index 9705e81..ef52403 100644
--- a/controls-modbus/README.md
+++ b/controls-modbus/README.md
@@ -14,7 +14,7 @@ Automatically checks consistency.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-modbus:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-modbus:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -24,6 +24,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-modbus:0.4.0-dev-4")
+    implementation("space.kscience:controls-modbus:0.4.0-dev-7")
 }
 ```
diff --git a/controls-opcua/README.md b/controls-opcua/README.md
index 6867492..1d65c9d 100644
--- a/controls-opcua/README.md
+++ b/controls-opcua/README.md
@@ -12,7 +12,7 @@ A client and server connectors for OPC-UA via Eclipse Milo
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-opcua:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-opcua:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -22,6 +22,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-opcua:0.4.0-dev-4")
+    implementation("space.kscience:controls-opcua:0.4.0-dev-7")
 }
 ```
diff --git a/controls-pi/README.md b/controls-pi/README.md
index 0afb324..9c36e60 100644
--- a/controls-pi/README.md
+++ b/controls-pi/README.md
@@ -6,7 +6,7 @@ Utils to work with controls-kt on Raspberry pi
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-pi:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-pi:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-pi:0.4.0-dev-4")
+    implementation("space.kscience:controls-pi:0.4.0-dev-7")
 }
 ```
diff --git a/controls-plc4x/README.md b/controls-plc4x/README.md
new file mode 100644
index 0000000..21cf398
--- /dev/null
+++ b/controls-plc4x/README.md
@@ -0,0 +1,21 @@
+# Module controls-plc4x
+
+A plugin for Controls-kt device server on top of plc4x library
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:controls-plc4x:0.4.0-dev-7`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:controls-plc4x:0.4.0-dev-7")
+}
+```
diff --git a/controls-ports-ktor/README.md b/controls-ports-ktor/README.md
index 1cb277a..376bd13 100644
--- a/controls-ports-ktor/README.md
+++ b/controls-ports-ktor/README.md
@@ -6,7 +6,7 @@ Implementation of byte ports on top os ktor-io asynchronous API
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-ports-ktor:0.4.0-dev-4")
+    implementation("space.kscience:controls-ports-ktor:0.4.0-dev-7")
 }
 ```
diff --git a/controls-serial/README.md b/controls-serial/README.md
index ce95ee5..44598d0 100644
--- a/controls-serial/README.md
+++ b/controls-serial/README.md
@@ -6,7 +6,7 @@ Implementation of direct serial port communication with JSerialComm
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-serial:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-serial:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-serial:0.4.0-dev-4")
+    implementation("space.kscience:controls-serial:0.4.0-dev-7")
 }
 ```
diff --git a/controls-server/README.md b/controls-server/README.md
index 896020d..f143d50 100644
--- a/controls-server/README.md
+++ b/controls-server/README.md
@@ -6,7 +6,7 @@ A combined Magix event loop server with web server for visualization.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-server:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-server:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-server:0.4.0-dev-4")
+    implementation("space.kscience:controls-server:0.4.0-dev-7")
 }
 ```
diff --git a/controls-storage/README.md b/controls-storage/README.md
index dd4ab3d..116792d 100644
--- a/controls-storage/README.md
+++ b/controls-storage/README.md
@@ -6,7 +6,7 @@ An API for stand-alone Controls-kt device or a hub.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-storage:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-storage:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-storage:0.4.0-dev-4")
+    implementation("space.kscience:controls-storage:0.4.0-dev-7")
 }
 ```
diff --git a/controls-storage/controls-xodus/README.md b/controls-storage/controls-xodus/README.md
index 44cdf26..e206f1e 100644
--- a/controls-storage/controls-xodus/README.md
+++ b/controls-storage/controls-xodus/README.md
@@ -6,7 +6,7 @@ An implementation of controls-storage on top of JetBrains Xodus.
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-xodus:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-xodus:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-xodus:0.4.0-dev-4")
+    implementation("space.kscience:controls-xodus:0.4.0-dev-7")
 }
 ```
diff --git a/controls-vision/README.md b/controls-vision/README.md
index ff2b648..750f60f 100644
--- a/controls-vision/README.md
+++ b/controls-vision/README.md
@@ -6,7 +6,7 @@ Dashboard and visualization extensions for devices
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-vision:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-vision:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-vision:0.4.0-dev-4")
+    implementation("space.kscience:controls-vision:0.4.0-dev-7")
 }
 ```
diff --git a/controls-visualisation-compose/README.md b/controls-visualisation-compose/README.md
index 0f77d54..da0d9ef 100644
--- a/controls-visualisation-compose/README.md
+++ b/controls-visualisation-compose/README.md
@@ -6,7 +6,7 @@ Visualisation extension using compose-multiplatform
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:controls-visualisation-compose:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:controls-visualisation-compose:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:controls-visualisation-compose:0.4.0-dev-4")
+    implementation("space.kscience:controls-visualisation-compose:0.4.0-dev-7")
 }
 ```
diff --git a/demo/device-collective/README.md b/demo/device-collective/README.md
index 369ec04..433a46f 100644
--- a/demo/device-collective/README.md
+++ b/demo/device-collective/README.md
@@ -1,14 +1,4 @@
 # Module device-collective
 
-# Running demo from gradle
 
-1. Install JDK 17-21 in the system (for example, from https://sdkman.io/jdks or https://github.com/ScoopInstaller/Java).
-2. Clone the repository with Git.
-3. Run `./gradlew :demo:device-collective:run` from the project root directory.
 
-# Install distribution
-
-1. Install JDK 17-21 in the system (for example, from https://sdkman.io/jdks or https://github.com/ScoopInstaller/Java).
-2. Clone the repository with Git.
-3. Run `./gradlew :demo:device-collective:packageUberJarForCurrentOS` from the project root directory.
-4. Go to `build/compose/jars/device-collective-<your OS>-<version>.jar`. You can copy it and run with `java -jar <full-name>.jar`
diff --git a/magix/magix-api/README.md b/magix/magix-api/README.md
index 6f16ac0..0feb75d 100644
--- a/magix/magix-api/README.md
+++ b/magix/magix-api/README.md
@@ -6,7 +6,7 @@ A kotlin API for magix standard and some zero-dependency magix services
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-api:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:magix-api:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-api:0.4.0-dev-4")
+    implementation("space.kscience:magix-api:0.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-java-endpoint/README.md b/magix/magix-java-endpoint/README.md
index 87359f1..5b269a0 100644
--- a/magix/magix-java-endpoint/README.md
+++ b/magix/magix-java-endpoint/README.md
@@ -6,7 +6,7 @@ Java API to work with magix endpoints without Kotlin
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-java-endpoint:0.4.0-dev-4")
+    implementation("space.kscience:magix-java-endpoint:0.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-mqtt/README.md b/magix/magix-mqtt/README.md
index 0ee054a..3661749 100644
--- a/magix/magix-mqtt/README.md
+++ b/magix/magix-mqtt/README.md
@@ -6,7 +6,7 @@ MQTT client magix endpoint
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-mqtt:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:magix-mqtt:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-mqtt:0.4.0-dev-4")
+    implementation("space.kscience:magix-mqtt:0.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-rabbit/README.md b/magix/magix-rabbit/README.md
index 35b43c7..db0de2c 100644
--- a/magix/magix-rabbit/README.md
+++ b/magix/magix-rabbit/README.md
@@ -6,7 +6,7 @@ RabbitMQ client magix endpoint
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-rabbit:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:magix-rabbit:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-rabbit:0.4.0-dev-4")
+    implementation("space.kscience:magix-rabbit:0.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-rsocket/README.md b/magix/magix-rsocket/README.md
index 9f3a42c..b3cf1ba 100644
--- a/magix/magix-rsocket/README.md
+++ b/magix/magix-rsocket/README.md
@@ -6,7 +6,7 @@ Magix endpoint (client) based on RSocket
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-rsocket:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:magix-rsocket:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-rsocket:0.4.0-dev-4")
+    implementation("space.kscience:magix-rsocket:0.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-server/README.md b/magix/magix-server/README.md
index 8259e0f..4e0968e 100644
--- a/magix/magix-server/README.md
+++ b/magix/magix-server/README.md
@@ -6,7 +6,7 @@ A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket route
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-server:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:magix-server:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-server:0.4.0-dev-4")
+    implementation("space.kscience:magix-server:0.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-storage/README.md b/magix/magix-storage/README.md
index 672458e..f8d3c09 100644
--- a/magix/magix-storage/README.md
+++ b/magix/magix-storage/README.md
@@ -6,7 +6,7 @@ Magix history database API
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-storage:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:magix-storage:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-storage:0.4.0-dev-4")
+    implementation("space.kscience:magix-storage:0.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-storage/magix-storage-xodus/README.md b/magix/magix-storage/magix-storage-xodus/README.md
index 2fb495a..3a4a469 100644
--- a/magix/magix-storage/magix-storage-xodus/README.md
+++ b/magix/magix-storage/magix-storage-xodus/README.md
@@ -6,7 +6,7 @@
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-storage-xodus:0.4.0-dev-4")
+    implementation("space.kscience:magix-storage-xodus:0.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-utils/README.md b/magix/magix-utils/README.md
new file mode 100644
index 0000000..95f51a9
--- /dev/null
+++ b/magix/magix-utils/README.md
@@ -0,0 +1,21 @@
+# Module magix-utils
+
+Common utilities and services for Magix endpoints.   
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:magix-utils:0.4.0-dev-7`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:magix-utils:0.4.0-dev-7")
+}
+```
diff --git a/magix/magix-zmq/README.md b/magix/magix-zmq/README.md
index 952e02e..23c17c7 100644
--- a/magix/magix-zmq/README.md
+++ b/magix/magix-zmq/README.md
@@ -6,7 +6,7 @@ ZMQ client endpoint for Magix
 
 ## Artifact:
 
-The Maven coordinates of this project are `space.kscience:magix-zmq:0.4.0-dev-4`.
+The Maven coordinates of this project are `space.kscience:magix-zmq:0.4.0-dev-7`.
 
 **Gradle Kotlin DSL:**
 ```kotlin
@@ -16,6 +16,6 @@ repositories {
 }
 
 dependencies {
-    implementation("space.kscience:magix-zmq:0.4.0-dev-4")
+    implementation("space.kscience:magix-zmq:0.4.0-dev-7")
 }
 ```
diff --git a/simulation-kt/README.md b/simulation-kt/README.md
new file mode 100644
index 0000000..ed4308e
--- /dev/null
+++ b/simulation-kt/README.md
@@ -0,0 +1,26 @@
+# Module simulation-kt
+
+
+
+## Features
+
+ - [timeline](#) : Timeline is an ordered discrete history containing TimeLineEvent
+
+
+## Usage
+
+## Artifact:
+
+The Maven coordinates of this project are `space.kscience:simulation-kt:0.4.0-dev-7`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:simulation-kt:0.4.0-dev-7")
+}
+```
diff --git a/simulation-kt/build.gradle.kts b/simulation-kt/build.gradle.kts
index b365d98..a80b1df 100644
--- a/simulation-kt/build.gradle.kts
+++ b/simulation-kt/build.gradle.kts
@@ -5,10 +5,6 @@ plugins {
     `maven-publish`
 }
 
-description = """
-    Core interfaces for building a device server
-""".trimIndent()
-
 kscience {
     jvm()
     js()
@@ -21,12 +17,17 @@ kscience {
         api(spclibs.kotlinx.datetime)
     }
 
-    jvmTest{
+    jvmTest {
         implementation(spclibs.logback.classic)
     }
 }
 
 
-readme{
+readme {
     maturity = Maturity.PROTOTYPE
+    description = """
+        A framework for combination of asynchronous simulations.        
+    """.trimIndent()
+
+    feature("timeline") { "Timeline is an ordered discrete history containing TimeLineEvent" }
 }
\ No newline at end of file