diff --git a/.gitignore b/.gitignore
index 3bf252e..5fab474 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,6 +1,7 @@
 # Created by .ignore support plugin (hsz.mobi)
 .idea/
 .gradle
+.kotlin
 
 *.iws
 *.iml
@@ -8,4 +9,7 @@
 
 out/
 build/
-!gradle-wrapper.jar
\ No newline at end of file
+
+!gradle-wrapper.jar
+
+/demo/device-collective/mapCache/
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/.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/CHANGELOG.md b/CHANGELOG.md
index d6f9d13..26cb25f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,64 @@
 ## Unreleased
 
 ### Added
+- Value averaging plot extension
+- 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
+- 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
+- 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`.
+- `DeviceLifecycleState` is replaced by `LifecycleState`.
+
+
+### Deprecated
+
+### Removed
+
+### Fixed
+- Fix a problem with rsocket endpoint with no filter.
+
+### 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
+
+## 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
@@ -20,13 +78,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/README.md b/README.md
index d5b40c3..256b87d 100644
--- a/README.md
+++ b/README.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.
@@ -42,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
 >
@@ -56,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)
 >
@@ -93,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
 >
@@ -113,6 +129,16 @@ Automatically checks consistency.
 >
 > **Maturity**: PROTOTYPE
 
+### [controls-vision](controls-vision)
+> Dashboard and visualization extensions for devices
+>
+> **Maturity**: PROTOTYPE
+
+### [controls-visualisation-compose](controls-visualisation-compose)
+> Visualisation extension using compose-multiplatform
+>
+> **Maturity**: PROTOTYPE
+
 ### [demo](demo)
 >
 > **Maturity**: EXPERIMENTAL
@@ -121,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.
 >
@@ -134,6 +169,14 @@ Automatically checks consistency.
 >
 > **Maturity**: EXPERIMENTAL
 
+### [demo/constructor](demo/constructor)
+>
+> **Maturity**: EXPERIMENTAL
+
+### [demo/device-collective](demo/device-collective)
+>
+> **Maturity**: EXPERIMENTAL
+
 ### [demo/echo](demo/echo)
 >
 > **Maturity**: EXPERIMENTAL
@@ -189,6 +232,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/build.gradle.kts b/build.gradle.kts
index df7c664..7e06cfa 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,37 +1,26 @@
-import space.kscience.gradle.isInDevelopment
 import space.kscience.gradle.useApache2Licence
 import space.kscience.gradle.useSPCTeam
 
 plugins {
     id("space.kscience.gradle.project")
+    alias(libs.plugins.versions)
 }
 
-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"
+    version = "0.4.0-dev-7"
     repositories{
-        maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
+        google()
     }
 }
 
 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")
 }
 
 readme.readmeTemplate = file("docs/templates/README-TEMPLATE.md")
\ No newline at end of file
diff --git a/controls-constructor/README.md b/controls-constructor/README.md
new file mode 100644
index 0000000..f2186bd
--- /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.4.0-dev-7`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:controls-constructor:0.4.0-dev-7")
+}
+```
diff --git a/controls-constructor/build.gradle.kts b/controls-constructor/build.gradle.kts
new file mode 100644
index 0000000..1947f91
--- /dev/null
+++ b/controls-constructor/build.gradle.kts
@@ -0,0 +1,28 @@
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+description = """
+    A low-code constructor for composite devices simulation
+""".trimIndent()
+
+kscience{
+    jvm()
+    js()
+    native()
+    wasm()
+    useCoroutines()
+    useSerialization()
+    commonMain {
+        api(projects.controlsCore)
+    }
+
+    commonTest{
+        implementation(spclibs.logback.classic)
+    }
+}
+
+readme{
+    maturity = space.kscience.gradle.Maturity.PROTOTYPE
+}
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
new file mode 100644
index 0000000..8073689
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
@@ -0,0 +1,241 @@
+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
+ */
+public sealed interface ConstructorElement
+
+/**
+ * A binding that exposes device property as read-only state
+ */
+public class PropertyConstructorElement<T>(
+    public val device: Device,
+    public val propertyName: String,
+    public val state: DeviceState<T>,
+) : ConstructorElement
+
+/**
+ * A binding for independent state like a timer
+ */
+public class StateConstructorElement<T>(
+    public val state: DeviceState<T>,
+) : ConstructorElement
+
+public class ConnectionConstrucorElement(
+    public val reads: Collection<DeviceState<*>>,
+    public val writes: Collection<DeviceState<*>>,
+) : ConstructorElement
+
+public class ModelConstructorElement(
+    public val model: ModelConstructor,
+) : ConstructorElement
+
+
+public interface StateContainer : ContextAware, CoroutineScope {
+    public val constructorElements: Set<ConstructorElement>
+    public fun registerElement(constructorElement: ConstructorElement)
+    public fun unregisterElement(constructorElement: ConstructorElement)
+
+
+    /**
+     * 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(
+        writes: Collection<DeviceState<*>> = emptySet(),
+        reads: Collection<DeviceState<*>> = emptySet(),
+        onChange: suspend (T) -> Unit,
+    ): Job = valueFlow.onEach(onChange).launchIn(this@StateContainer).also {
+        registerElement(ConnectionConstrucorElement(reads + this, writes))
+    }
+
+    public fun <T> DeviceState<T>.onChange(
+        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)
+    }.onEach { pair ->
+        if (pair.first != pair.second) {
+            onChange(pair.first, pair.second)
+        }
+    }.launchIn(this@StateContainer).also {
+        registerElement(ConnectionConstrucorElement(reads + this, 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.registerState(state: D): D {
+    registerElement(StateConstructorElement(state))
+    return state
+}
+
+/**
+ * Create a register a [MutableDeviceState]
+ */
+public fun <T> StateContainer.stateOf(initialValue: T): MutableDeviceState<T> = registerState(
+    MutableDeviceState(initialValue)
+)
+
+public fun <T : ModelConstructor> StateContainer.model(model: T): T {
+    registerElement(ModelConstructorElement(model))
+    return model
+}
+
+/**
+ * Create and register a timer state.
+ */
+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> = registerState(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 registerState(state.withDependencies(setOf(origin)))
+}
+
+/**
+ * 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> = registerState(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.bind(sourceState: DeviceState<T>, targetState: MutableDeviceState<T>): Job {
+    val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
+    registerElement(descriptor)
+    return sourceState.valueFlow.onEach {
+        targetState.value = it
+    }.launchIn(this).apply {
+        invokeOnCompletion {
+            unregisterElement(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 = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
+    registerElement(descriptor)
+    return sourceState.valueFlow.onEach {
+        targetState.value = transformation(it)
+    }.launchIn(this).apply {
+        invokeOnCompletion {
+            unregisterElement(descriptor)
+        }
+    }
+}
+
+/**
+ * Register [ConstructorElement] 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 = 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 {
+            unregisterElement(descriptor)
+        }
+    }
+}
+
+/**
+ * Register [ConstructorElement] 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 = ConnectionConstrucorElement(sourceStates, setOf(targetState))
+    registerElement(descriptor)
+    return kotlinx.coroutines.flow.combine(sourceStates.map { it.valueFlow }, transformation).onEach {
+        targetState.value = it
+    }.launchIn(this).apply {
+        invokeOnCompletion {
+            unregisterElement(descriptor)
+        }
+    }
+}
\ 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
new file mode 100644
index 0000000..371e94f
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt
@@ -0,0 +1,150 @@
+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.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.names.Name
+import space.kscience.dataforge.names.asName
+import kotlin.properties.PropertyDelegateProvider
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+import kotlin.time.Duration
+
+/**
+ * A base for strongly typed device constructor block. Has additional delegates for type-safe devices
+ */
+public abstract class DeviceConstructor(
+    context: Context,
+    meta: Meta = Meta.EMPTY,
+) : DeviceGroup(context, meta), StateContainer {
+    private val _constructorElements: MutableSet<ConstructorElement> = mutableSetOf()
+    override val constructorElements: Set<ConstructorElement> get() = _constructorElements
+
+    override fun registerElement(constructorElement: ConstructorElement) {
+        _constructorElements.add(constructorElement)
+    }
+
+    override fun unregisterElement(constructorElement: ConstructorElement) {
+        _constructorElements.remove(constructorElement)
+    }
+
+    override fun <T, S: DeviceState<T>> registerProperty(
+        converter: MetaConverter<T>,
+        descriptor: PropertyDescriptor,
+        state: S,
+    ): S {
+        val res = super.registerProperty(converter, descriptor, state)
+        registerElement(PropertyConstructorElement(this, descriptor.name, state))
+        return res
+    }
+}
+
+/**
+ * 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> 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>> DeviceConstructor.property(
+    converter: MetaConverter<T>,
+    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(converter, descriptor, state)
+        ReadOnlyProperty { _: DeviceConstructor, _ ->
+            state
+        }
+    }
+
+/**
+ * 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(
+    metaConverter,
+    DeviceState.external(this, readInterval, initialState, reader),
+    descriptorBuilder,
+    nameOverride,
+)
+
+/**
+ * Create and 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(
+    metaConverter,
+    DeviceState.external(this, readInterval, initialState, reader, writer),
+    descriptorBuilder,
+    nameOverride,
+)
+
+/**
+ * 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(
+    metaConverter,
+    MutableDeviceState(initialState, callback),
+    descriptorBuilder,
+    nameOverride,
+)
+
+public fun <T, S : DeviceState<T>> DeviceConstructor.registerAsProperty(
+    spec: DevicePropertySpec<*, T>,
+    state: S,
+): S {
+    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
new file mode 100644
index 0000000..79ff2ed
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt
@@ -0,0 +1,318 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+import space.kscience.controls.api.*
+import space.kscience.controls.api.LifecycleState.*
+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
+import space.kscience.dataforge.meta.MetaConverter
+import space.kscience.dataforge.meta.MutableMeta
+import space.kscience.dataforge.misc.DFExperimental
+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
+
+
+/**
+ * A mutable group of devices and properties to be used for lightweight design and simulations.
+ */
+public open class DeviceGroup(
+    final override val context: Context,
+    override val meta: Meta,
+) : DeviceHub, CachingDevice {
+
+    private class Property<T>(
+        val state: DeviceState<T>,
+        val converter: MetaConverter<T>,
+        val descriptor: PropertyDescriptor,
+    ) {
+        val valueAsMeta get() = converter.convert(state.value)
+
+        fun setMeta(meta: Meta) {
+            check(state is MutableDeviceState) { "Can't write to read-only property" }
+
+            state.value = converter.read(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>()
+
+    override val messageFlow: Flow<DeviceMessage>
+        get() = sharedMessageFlow
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    override val coroutineContext: CoroutineContext = context.newCoroutineContext(
+        SupervisorJob(context.coroutineContext[Job]) +
+                CoroutineName("Device $id") +
+                CoroutineExceptionHandler { _, throwable ->
+                    context.launch {
+                        sharedMessageFlow.emit(
+                            DeviceErrorMessage(
+                                errorMessage = throwable.message,
+                                errorType = throwable::class.simpleName,
+                                errorStackTrace = throwable.stackTraceToString()
+                            )
+                        )
+                    }
+                    logger.error(throwable) { "Exception in device $id" }
+                }
+    )
+
+
+    private val _devices = hashMapOf<Name, Device>()
+
+    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: 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() }
+        _devices[token] = device
+        return device
+    }
+
+    private val properties: MutableMap<Name, Property<*>> = hashMapOf()
+
+    /**
+     * Register a new property based on [DeviceState]. Properties could be modified dynamically
+     */
+    public open fun <T, S : DeviceState<T>> registerProperty(
+        converter: MetaConverter<T>,
+        descriptor: PropertyDescriptor,
+        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)
+        state.valueFlow.map(converter::convert).onEach {
+            sharedMessageFlow.emit(
+                PropertyChangedMessage(
+                    descriptor.name,
+                    it
+                )
+            )
+        }.launchIn(this)
+        return state
+    }
+
+    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 }
+
+    override val actionDescriptors: Collection<ActionDescriptor>
+        get() = actions.values.map { it.descriptor }
+
+    override suspend fun readProperty(propertyName: String): Meta =
+        properties[propertyName.parseAsName()]?.valueAsMeta
+            ?: error("Property with name $propertyName not found")
+
+    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()] ?: error("Property with name $propertyName not found")
+        property.setMeta(value)
+    }
+
+
+    override suspend fun execute(actionName: String, argument: Meta?): Meta? {
+        val action: Action<*, *> = actions[actionName] ?: error("Action with name $actionName not found")
+        return action(argument)
+    }
+
+    final override var lifecycleState: LifecycleState = LifecycleState.STOPPED
+        private set
+
+
+    private suspend fun setLifecycleState(lifecycleState: LifecycleState) {
+        this.lifecycleState = lifecycleState
+        sharedMessageFlow.emit(
+            DeviceLifeCycleMessage(lifecycleState)
+        )
+    }
+
+
+    override suspend fun start() {
+        setLifecycleState(STARTING)
+        super.start()
+        devices.values.forEach {
+            it.start()
+        }
+        setLifecycleState(STARTED)
+    }
+
+    override suspend fun stop() {
+        devices.values.forEach {
+            it.stop()
+        }
+        setLifecycleState(STOPPED)
+        super.stop()
+    }
+
+    public companion object {
+
+    }
+}
+
+public fun <T> DeviceGroup.registerAsProperty(propertySpec: DevicePropertySpec<*, T>, state: DeviceState<T>) {
+    registerProperty(propertySpec.converter, propertySpec.descriptor, state)
+}
+
+public fun DeviceManager.registerDeviceGroup(
+    name: String = "@group",
+    meta: Meta = Meta.EMPTY,
+    block: DeviceGroup.() -> Unit,
+): DeviceGroup {
+    val group = DeviceGroup(context, meta).apply(block)
+    install(name, group)
+    return group
+}
+
+public fun Context.registerDeviceGroup(
+    name: String = "@group",
+    meta: Meta = Meta.EMPTY,
+    block: DeviceGroup.() -> Unit,
+): DeviceGroup = request(DeviceManager).registerDeviceGroup(name, meta, block)
+
+///**
+// * 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)
+
+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.
+ * @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 <D : Device> DeviceGroup.install(
+    name: Name,
+    factory: Factory<D>,
+    deviceMeta: Meta? = null,
+    metaLocation: Name = name,
+): D {
+    val newDevice = factory.build(context, Laminate(deviceMeta, meta[metaLocation]))
+    install(name, newDevice)
+    return newDevice
+}
+
+public fun <D : Device> DeviceGroup.install(
+    name: String,
+    factory: Factory<D>,
+    metaLocation: Name = name.parseAsName(),
+    metaBuilder: (MutableMeta.() -> Unit)? = null,
+): D = install(name.parseAsName(), factory, metaBuilder?.let { Meta(it) }, metaLocation)
+
+/**
+ * Create or edit a group with a given [name].
+ */
+public fun DeviceGroup.registerDeviceGroup(name: Name, block: DeviceGroup.() -> Unit): DeviceGroup =
+    install(name, DeviceGroup(context, meta).apply(block))
+
+public fun DeviceGroup.registerDeviceGroup(name: String, block: DeviceGroup.() -> Unit): DeviceGroup =
+    registerDeviceGroup(name.parseAsName(), block)
+
+/**
+ * Register read-only property based on [state]
+ */
+public fun <T : Any> DeviceGroup.registerAsProperty(
+    name: String,
+    converter: MetaConverter<T>,
+    state: DeviceState<T>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+) {
+    registerProperty(
+        converter,
+        PropertyDescriptor(name).apply(descriptorBuilder),
+        state
+    )
+}
+
+/**
+ * Register a mutable property based on mutable [state]
+ */
+public fun <T : Any> DeviceGroup.registerMutableProperty(
+    name: String,
+    converter: MetaConverter<T>,
+    state: MutableDeviceState<T>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+) {
+    registerProperty(
+        converter,
+        PropertyDescriptor(name).apply(descriptorBuilder),
+        state
+    )
+}
+
+
+/**
+ * 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>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    callback: (T) -> Unit = {},
+): MutableDeviceState<T> {
+    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/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
new file mode 100644
index 0000000..5b547f7
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt
@@ -0,0 +1,103 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+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<out T> {
+    public val value: T
+
+    public val valueFlow: Flow<T>
+
+    override fun toString(): String
+
+    public companion object
+}
+
+
+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
+ */
+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
+}
+
+/**
+ * Device state with a value that depends on other device states
+ */
+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].
+ */
+public fun <T, R> DeviceState.Companion.map(
+    state: DeviceState<T>,
+    mapper: (T) -> R,
+): DeviceStateWithDependencies<R> = object : DeviceStateWithDependencies<R> {
+    override val dependencies = listOf(state)
+
+    override val value: R get() = mapper(state.value)
+
+    override val valueFlow: Flow<R> = state.valueFlow.map(mapper)
+
+    override fun toString(): String = "DeviceState.map(state=${state})"
+}
+
+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
+
+        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.
+ */
+public fun <T1, T2, R> DeviceState.Companion.combine(
+    state1: DeviceState<T1>,
+    state2: DeviceState<T2>,
+    mapper: (T1, T2) -> R,
+): DeviceStateWithDependencies<R> = object : DeviceStateWithDependencies<R> {
+    override val dependencies = listOf(state1, state2)
+
+    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)"
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ModelConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ModelConstructor.kt
new file mode 100644
index 0000000..8fdc708
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ModelConstructor.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 ModelConstructor(
+    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/TimerState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
new file mode 100644
index 0000000..baf9646
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt
@@ -0,0 +1,39 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.delay
+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 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> {
+
+    private val clock = MutableStateFlow(clockManager.clock.now())
+
+    private val updateJob = clockManager.context.launch(clockManager.asDispatcher()) {
+        while (isActive) {
+            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..1c128fa
--- /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>(
+    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.propertyAsState(
+    propertySpec: MutableDevicePropertySpec<D, T>,
+): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter)
+
+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/devices/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt
new file mode 100644
index 0000000..9880508
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt
@@ -0,0 +1,19 @@
+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
+
+//TODO use current as input
+
+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/devices/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt
new file mode 100644
index 0000000..d06a385
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt
@@ -0,0 +1,44 @@
+package space.kscience.controls.constructor.devices
+
+import space.kscience.controls.constructor.DeviceConstructor
+import space.kscience.controls.constructor.DeviceState
+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.spec.DevicePropertySpec
+import space.kscience.controls.spec.DeviceSpec
+import space.kscience.controls.spec.booleanProperty
+import space.kscience.dataforge.context.Context
+
+
+/**
+ * A device that detects if a motor hits the end of its range
+ */
+public class LimitSwitch(
+    context: Context,
+    locked: DeviceState<Boolean>,
+) : DeviceConstructor(context) {
+
+    public val locked: DeviceState<Boolean> = registerAsProperty(LimitSwitch.locked, locked)
+
+    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/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..9d552c5
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt
@@ -0,0 +1,65 @@
+package space.kscience.controls.constructor.devices
+
+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.roundToLong
+import kotlin.time.DurationUnit
+
+/**
+ * A step drive
+ *
+ * @param ticksPerSecond ticks per second
+ * @param target target ticks state
+ * @param writeTicks a hardware callback
+ */
+public class StepDrive(
+    context: Context,
+    ticksPerSecond: Double,
+    position: MutableDeviceState<Long> = MutableDeviceState(0),
+    private val writeTicks: suspend (ticks: Long, speed: Double) -> Unit = { _, _ -> },
+) : DeviceConstructor(context) {
+
+    public val target: MutableDeviceState<Long> by property(
+        MetaConverter.long,
+        MutableDeviceState<Long>(position.value)
+    )
+
+    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 = speed.value
+        val timeDelta = (next - prev).toDouble(DurationUnit.SECONDS)
+        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)
+        position.value += steps
+    }
+}
+
+/**
+ * Compute a state using given tick-to-angle transformation
+ */
+public fun StepDrive.angle(
+    step: NumericalValue<Degrees>,
+    zero: NumericalValue<Degrees> = NumericalValue(0),
+): DeviceState<NumericalValue<Degrees>> = position.map {
+    zero + it * step
+}
+
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..9b4a15f
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt
@@ -0,0 +1,65 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.launch
+import kotlin.time.Duration
+
+
+private open class ExternalState<T>(
+    val scope: CoroutineScope,
+    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()"
+}
+
+/**
+ * Create a [DeviceState] which is constructed by regularly reading external value
+ */
+public fun <T> DeviceState.Companion.external(
+    scope: CoroutineScope,
+    readInterval: Duration,
+    initialValue: T,
+    reader: suspend () -> T,
+): DeviceState<T> = ExternalState(scope,  readInterval, initialValue, reader)
+
+private class MutableExternalState<T>(
+    scope: CoroutineScope,
+    readInterval: Duration,
+    initialValue: T,
+    reader: suspend () -> T,
+    val writer: suspend (T) -> Unit,
+) : ExternalState<T>(scope, 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,
+    readInterval: Duration,
+    initialValue: T,
+    reader: suspend () -> T,
+    writer: suspend (T) -> Unit,
+): 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
new file mode 100644
index 0000000..34a4910
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt
@@ -0,0 +1,20 @@
+package space.kscience.controls.constructor
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+
+private class StateFlowAsState<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($value)"
+}
+
+/**
+ * Create a read-only [DeviceState] that wraps [MutableStateFlow].
+ * No data copy is performed.
+ */
+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
new file mode 100644
index 0000000..684f666
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt
@@ -0,0 +1,53 @@
+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
+ *
+ * @param callback a synchronous callback that could be used without a scope
+ */
+private class VirtualDeviceState<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($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> 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/models/Inertia.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
new file mode 100644
index 0000000..1259151
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt
@@ -0,0 +1,70 @@
+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
+
+/**
+ * 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>>,
+) : ModelConstructor(context) {
+
+    init {
+        registerState(position)
+        registerState(velocity)
+    }
+
+    private var currentForce = force.value
+
+    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
+        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 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
+        )
+    }
+}
\ No newline at end of file
diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Leadscrew.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Leadscrew.kt
new file mode 100644
index 0000000..ebe0d30
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Leadscrew.kt
@@ -0,0 +1,31 @@
+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.*
+import space.kscience.dataforge.context.Context
+import kotlin.math.PI
+
+/**
+ * https://en.wikipedia.org/wiki/Leadscrew
+ */
+public class Leadscrew(
+    context: Context,
+    public val leverage: NumericalValue<Meters>,
+) : ModelConstructor(context) {
+
+    public fun torqueToForce(
+        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 )
+    }
+
+}
\ No newline at end of file
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..cc44a85
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt
@@ -0,0 +1,48 @@
+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(
+        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
+        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
+        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/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..202fa71
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt
@@ -0,0 +1,70 @@
+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: ClosedRange<T>,
+): RangeState<T> = RangeState(this, range)
+
+
+public fun <T : Comparable<T>> MutableDeviceState<T>.coerceIn(
+    range: ClosedRange<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/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
new file mode 100644
index 0000000..8e77001
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt
@@ -0,0 +1,60 @@
+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
+
+
+/**
+ * A value without identity coupled to units of measurements.
+ */
+@JvmInline
+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 fun <U : UnitsOfMeasurement> NumericalValue(
+    number: Number,
+): NumericalValue<U> = NumericalValue(number.toDouble())
+
+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())
+
+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)
+
+    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
new file mode 100644
index 0000000..1264423
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt
@@ -0,0 +1,60 @@
+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 sealed interface UnitsOfAngles : UnitsOfMeasurement
+
+public data object Radians : UnitsOfAngles
+public data object Degrees : UnitsOfAngles
+
+/**/
+
+public sealed interface UnitsAngularOfVelocity : UnitsOfMeasurement
+
+public data object RadiansPerSecond : UnitsAngularOfVelocity
+
+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-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..027dbbb
--- /dev/null
+++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt
@@ -0,0 +1,44 @@
+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)))
+
+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 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))
+
+@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/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..aa46133
--- /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.LifecycleState
+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 == LifecycleState.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..38f7d83
--- /dev/null
+++ b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt
@@ -0,0 +1,23 @@
+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(100).onEach {
+            println(it)
+        }.collect()
+
+    }
+}
\ No newline at end of file
diff --git a/controls-core/README.md b/controls-core/README.md
index b75961d..f7bc0bd 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/controls-core/build.gradle.kts b/controls-core/build.gradle.kts
index bbe32eb..868cdd0 100644
--- a/controls-core/build.gradle.kts
+++ b/controls-core/build.gradle.kts
@@ -9,21 +9,24 @@ description = """
     Core interfaces for building a device server
 """.trimIndent()
 
-val dataforgeVersion: String by rootProject.extra
-
 kscience {
     jvm()
     js()
     native()
+    wasm()
     useCoroutines()
     useSerialization{
         json()
     }
     useContextReceivers()
-    dependencies {
-        api("space.kscience:dataforge-io:$dataforgeVersion")
+    commonMain {
+        api(libs.dataforge.io)
         api(spclibs.kotlinx.datetime)
     }
+
+    jvmTest{
+        implementation(spclibs.logback.classic)
+    }
 }
 
 
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..7c56084
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt
@@ -0,0 +1,30 @@
+package space.kscience.controls.api
+
+import kotlinx.coroutines.flow.Flow
+
+/**
+ * A generic bidirectional asynchronous sender/receiver object
+ */
+public interface AsynchronousSocket<T> : WithLifeCycle {
+    /**
+     * Send an object to the socket
+     */
+    public suspend fun send(data: T)
+
+    /**
+     * Flow of objects received from socket
+     */
+    public fun subscribe(): Flow<T>
+}
+
+/**
+ * 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/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt
index 4fc6365..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
@@ -3,41 +3,31 @@ 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 space.kscience.controls.api.Device.Companion.DEVICE_TARGET
 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.misc.DFExperimental
-import space.kscience.dataforge.misc.Type
-import space.kscience.dataforge.names.Name
-
-/**
- * A lifecycle state of a device
- */
-public enum class DeviceLifecycleState{
-    INIT,
-    OPEN,
-    CLOSED
-}
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.string
+import space.kscience.dataforge.misc.DfType
+import space.kscience.dataforge.names.parseAsName
 
 /**
  *  General interface describing a managed Device.
  *  [Device] is a supervisor scope encompassing all operations on a device.
  *  When canceled, cancels all running processes.
  */
-@Type(DEVICE_TARGET)
-public interface Device : AutoCloseable, ContextAware, CoroutineScope {
+@DfType(DEVICE_TARGET)
+public interface Device : ContextAware, WithLifeCycle, CoroutineScope {
 
     /**
      * Initial configuration meta for the device
      */
     public val meta: Meta get() = Meta.EMPTY
 
+
     /**
      * List of supported property descriptors
      */
@@ -54,18 +44,6 @@ public interface Device : AutoCloseable, 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.
@@ -85,44 +63,86 @@ public interface Device : AutoCloseable, 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 open(): Unit = Unit
+    override suspend fun start(): Unit = Unit
 
     /**
      * Close and terminate the device. This function does not wait for the device to be closed.
      */
-    override fun close() {
+    override suspend fun stop() {
+        coroutineContext[Job]?.cancel("The device is closed")
         logger.info { "Device $this is closed" }
-        cancel("The device is closed")
     }
 
-    @DFExperimental
-    public val lifecycleState: DeviceLifecycleState
-
     public companion object {
         public const val DEVICE_TARGET: String = "device"
     }
 }
 
+/**
+ * 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
+ */
+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.getOrReadProperty(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))
+        set(descriptor.name.parseAsName(), getProperty(descriptor.name))
     }
 }
 
 /**
  * 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)
+
+/**
+ * A [Flow] of property change messages for specific property.
+ */
+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/DeviceHub.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt
index 8bd7452..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
@@ -1,73 +1,62 @@
 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
 
     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)
-                }
-            }
-        }
+        devices
     } else {
         emptyMap()
     }
+    //TODO send message on device change
 
     public companion object
 }
 
-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 -> get(name.firstOrNull()!!)
-    else -> (get(name.firstOrNull()!!) as? DeviceHub)?.getOrNull(name.cutFirst())
+public fun DeviceHub(deviceMap: Map<Name, Device>): DeviceHub = object : DeviceHub {
+    override val devices: Map<Name, Device>
+        get() = deviceMap
 }
 
-public operator fun DeviceHub.get(name: Name): Device =
-    getOrNull(name) ?: error("Device with name $name not found in $this")
+/**
+ * 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 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")
+    devices.forEach {
+        val name: Name = it.key
+        put(name.asPath(), it.value)
+        (it.value as? DeviceHub)?.let { hub ->
+            putAll(name.asPath(), hub)
+        }
+    }
+}
 
 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/api/DeviceMessage.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt
index 45d7f0b..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
@@ -22,10 +22,10 @@ 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, 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
 
@@ -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))
 }
@@ -71,11 +71,11 @@ 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 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))
 }
@@ -103,9 +103,9 @@ 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(),
+    @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,22 +160,28 @@ 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))
 }
 
 /**
- * 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,
-    @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 +196,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))
 }
@@ -203,12 +209,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(),
+    @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,10 +226,25 @@ 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(),
+    @EncodeDefault override val time: Instant = Clock.System.now(),
+) : DeviceMessage() {
+    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: LifecycleState,
+    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))
 }
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 02598ba..0000000
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-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
-import kotlinx.coroutines.launch
-
-/**
- * A generic bidirectional sender/receiver object
- */
-public interface Socket<T> : Closeable {
-    /**
-     * 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/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/api/descriptors.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt
index 8e1705b..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
@@ -12,21 +12,26 @@ 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){
-    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 info: 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
new file mode 100644
index 0000000..4204730
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt
@@ -0,0 +1,109 @@
+package space.kscience.controls.manager
+
+import kotlinx.coroutines.*
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import space.kscience.controls.api.Device
+import space.kscience.dataforge.context.*
+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(
+    val clockManager: ClockManager,
+    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 {
+        if (timeCompression == 1.0) {
+            Clock.System
+        } else {
+            CompressedClock(Clock.System.now(), timeCompression)
+        }
+    }
+
+    /**
+     * 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(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)
+
+        override fun build(context: Context, meta: Meta): ClockManager = ClockManager()
+    }
+}
+
+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/manager/DeviceManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt
index cc043c7..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,12 +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
 
@@ -21,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)
@@ -38,13 +39,19 @@ 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.open()
+        device.start()
     }
     return device
 }
 
+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].
@@ -62,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) {
@@ -72,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/manager/respondMessage.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt
index 13d072c..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
@@ -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
@@ -24,11 +23,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
         }
 
         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),
@@ -64,6 +59,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
         is DeviceErrorMessage,
         is EmptyDeviceMessage,
         is DeviceLogMessage,
+        is DeviceLifeCycleMessage,
         -> null
     }
 } catch (ex: Exception) {
@@ -71,42 +67,41 @@ 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 the 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) {
+            devices.mapNotNull {
+                it.value.respondMessage(it.key, request)
+            }
+        } else {
+            val device = devices[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))
     }
 }
 
 /**
  * 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) {
-        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)
+public fun DeviceHub.hubMessageFlow(): Flow<DeviceMessage> {
+
+    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-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..092d9bb
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt
@@ -0,0 +1,70 @@
+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.MetaConverter
+import space.kscience.dataforge.names.Name
+
+/**
+ * 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 deviceName: Name,
+    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.sourceDevice == deviceName && it.property == propertyName }
+        .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>> =
+        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,
+    deviceName: Name,
+    propertyName: String,
+    converter: MetaConverter<T>,
+    maxSize: Int = 1000,
+): 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, deviceName, spec.name, spec.converter, maxSize)
\ No newline at end of file
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..f25c4f4
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt
@@ -0,0 +1,69 @@
+package space.kscience.controls.misc
+
+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.MetaConverter
+import space.kscience.dataforge.meta.get
+
+/**
+ * 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 fun readFrom(source: Source): ValueWithTime<T> {
+        val timestamp = InstantIOFormat.readFrom(source)
+        val value = valueFormat.readFrom(source)
+        return ValueWithTime(value, timestamp)
+    }
+
+    override fun writeTo(sink: Sink, obj: ValueWithTime<T>) {
+        InstantIOFormat.writeTo(sink, obj.time)
+        valueFormat.writeTo(sink, obj.value)
+    }
+
+}
+
+private class ValueWithTimeMetaConverter<T>(
+    val valueConverter: MetaConverter<T>,
+) : MetaConverter<ValueWithTime<T>> {
+
+
+    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 convert(obj: ValueWithTime<T>): Meta = Meta {
+        ValueWithTime.META_TIME_KEY put obj.time.toMeta()
+        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/misc/converters.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt
new file mode 100644
index 0000000..c200758
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt
@@ -0,0 +1,62 @@
+package space.kscience.controls.misc
+
+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
+
+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
+
+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-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..8686c67
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt
@@ -0,0 +1,42 @@
+package space.kscience.controls.misc
+
+
+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
+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 writeTo(sink: Sink, obj: Instant) {
+        sink.writeLong(obj.epochSeconds)
+        sink.writeInt(obj.nanosecondsOfSecond)
+    }
+
+    override fun readFrom(source: Source): Instant {
+        val seconds = source.readLong()
+        val nanoseconds = source.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-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..55624b7
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt
@@ -0,0 +1,39 @@
+package space.kscience.controls.peer
+
+import space.kscience.dataforge.io.Envelope
+import space.kscience.dataforge.meta.Meta
+
+/**
+ * A manager that allows direct synchronous sending and receiving binary data
+ */
+public interface PeerConnection {
+    /**
+     * 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.
+     *
+     * 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,
+        contentId: String,
+        requestMeta: Meta = Meta.EMPTY,
+    ): Envelope?
+
+    /**
+     * 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.
+     *
+     * Additional metadata in [requestMeta] could be required for authentication.
+     */
+    public suspend fun send(
+        address: String,
+        envelope: Envelope,
+        requestMeta: Meta = Meta.EMPTY,
+    )
+}
\ 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
new file mode 100644
index 0000000..29502f9
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt
@@ -0,0 +1,116 @@
+package space.kscience.controls.ports
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+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
+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 = subscribe().consumeAsSource(scope)
+
+
+/**
+ * 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) { "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)
+
+    /**
+     * 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 suspend fun start() {
+        if (lifecycleState == LifecycleState.STOPPED) {
+            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 started" }
+        }
+    }
+
+
+    /**
+     * Send a data packet via the port
+     */
+    override suspend fun send(data: ByteArray) {
+        check(lifecycleState == LifecycleState.STARTED) { "The port is not opened" }
+        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 suspend fun stop() {
+        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 1f07251..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.Type
-import kotlin.coroutines.CoroutineContext
-
-/**
- * Raw [ByteArray] port
- */
-public interface Port : ContextAware, Socket<ByteArray>
-
-/**
- * A specialized factory for [Port]
- */
-@Type(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>(Channel.CONFLATED)
-
-    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.
-     * In order 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()
-        sendJob.cancel()
-        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..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
@@ -1,28 +1,103 @@
 package space.kscience.controls.ports
 
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.first
+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.controls.api.LifecycleState
+import space.kscience.controls.api.WithLifeCycle
+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, WithLifeCycle {
+
     /**
-     * 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
+
+    /**
+     * 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)
     }
 }
 
 /**
- * Provide a synchronous wrapper for a port
+ * Read response to a given message using [Source] abstraction
  */
-public fun Port.synchronous(mutex: Mutex = Mutex()): SynchronousPort = SynchronousPort(this, mutex)
+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,
+) : SynchronousPort {
+
+    override val context: Context get() = port.context
+
+    override suspend fun start() {
+        if (port.lifecycleState == LifecycleState.STOPPED) port.start()
+    }
+
+    override val lifecycleState: LifecycleState get() = port.lifecycleState
+
+    override suspend fun stop() {
+        if (port.lifecycleState == LifecycleState.STARTED) port.stop()
+    }
+
+    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 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 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/ioExtensions.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt
new file mode 100644
index 0000000..5d7d3bf
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt
@@ -0,0 +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() }
+
+/**
+ * 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
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..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
@@ -1,21 +1,27 @@
 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
+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" }
 
-    val output = BytePacketBuilder()
+    val output = Buffer()
     var matcherPosition = 0
 
+    onCompletion {
+        output.close()
+    }
+
     return transform { chunk ->
         chunk.forEach { byte ->
             output.writeByte(byte)
@@ -24,9 +30,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) {
@@ -37,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
  */
@@ -47,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/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt
index 56f5aa9..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
@@ -1,36 +1,44 @@
 package space.kscience.controls.spec
 
 import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.BufferOverflow
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.SharedFlow
 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 space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.int
 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"))
+private suspend fun <D : Device, T> MutableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) {
+    write(device, converter.readOrNull(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)
+    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) }
 }
 
 
@@ -39,8 +47,8 @@ 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,
-) : Device {
+    final override val meta: Meta = Meta.EMPTY,
+) : CachingDevice {
 
     /**
      * Collection of property specifications
@@ -58,15 +66,28 @@ public abstract class DeviceBase<D : Device>(
     override val actionDescriptors: Collection<ActionDescriptor>
         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" }
+
+    private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow(
+        replay = meta["message.buffer"].int ?: 1000,
+        onBufferOverflow = BufferOverflow.DROP_OLDEST
+    )
+
+    override val coroutineContext: CoroutineContext = context.newCoroutineContext(
+        SupervisorJob(context.coroutineContext[Job]) +
+                CoroutineName("Device $id") +
+                CoroutineExceptionHandler { _, throwable ->
+                    launch {
+                        sharedMessageFlow.emit(
+                            DeviceErrorMessage(
+                                errorMessage = throwable.message,
+                                errorType = throwable::class.simpleName,
+                                errorStackTrace = throwable.stackTraceToString()
+                            )
+                        )
                     }
-        )
-    }
+                    logger.error(throwable) { "Exception in device $id" }
+                }
+    )
 
 
     /**
@@ -74,8 +95,6 @@ public abstract class DeviceBase<D : Device>(
      */
     private val logicalState: HashMap<String, Meta?> = HashMap()
 
-    private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow()
-
     public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow
 
     @Suppress("UNCHECKED_CAST")
@@ -87,7 +106,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 +118,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.convert(value))
     }
 
     /**
@@ -112,7 +131,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 +141,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
     }
 
@@ -135,15 +154,26 @@ 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 is a physical property with a given name, invalidate logical property and write physical one
-                updateLogical(propertyName, value)
+                //If there are no registered physical properties with given name, write a logical one.
+                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)
+                // 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)
+                }
             }
 
             else -> {
@@ -157,22 +187,43 @@ public abstract class DeviceBase<D : Device>(
         return spec.executeWithMeta(self, argument ?: Meta.EMPTY)
     }
 
-    @DFExperimental
-    override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.INIT
-        protected set
+    final override var lifecycleState: LifecycleState = LifecycleState.STOPPED
+        private set
 
-    @OptIn(DFExperimental::class)
-    override suspend fun open() {
-        super.open()
-        lifecycleState = DeviceLifecycleState.OPEN
+
+    private suspend fun setLifecycleState(lifecycleState: LifecycleState) {
+        this.lifecycleState = lifecycleState
+        sharedMessageFlow.emit(
+            DeviceLifeCycleMessage(lifecycleState)
+        )
     }
 
-    @OptIn(DFExperimental::class)
-    override fun close() {
-        lifecycleState = DeviceLifecycleState.CLOSED
-        super.close()
+    protected open suspend fun onStart() {
+
     }
 
+    final override suspend fun start() {
+        if (lifecycleState == LifecycleState.STOPPED) {
+            super.start()
+            setLifecycleState(LifecycleState.STARTING)
+            onStart()
+            setLifecycleState(LifecycleState.STARTED)
+        } else {
+            logger.debug { "Device $this is already started" }
+        }
+    }
+
+    protected open suspend fun onStop() {
+
+    }
+
+    final override suspend fun stop() {
+        onStop()
+        setLifecycleState(LifecycleState.STOPPED)
+        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..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
@@ -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 suspend 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/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 cf8c741..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
@@ -4,11 +4,8 @@ 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.dataforge.meta.transformations.MetaConverter
+import space.kscience.controls.api.*
+import space.kscience.dataforge.meta.MetaConverter
 
 
 /**
@@ -20,7 +17,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
      */
@@ -44,7 +41,7 @@ public interface DevicePropertySpec<in D : Device, 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
      */
@@ -53,7 +50,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
      */
@@ -75,30 +72,29 @@ public interface DeviceActionSpec<in D : Device, 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 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.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: WritableDevicePropertySpec<D, T>, value: T) {
-    writeProperty(propertySpec.name, propertySpec.converter.objectToMeta(value))
+public suspend fun <T, D : Device> D.write(propertySpec: MutableDevicePropertySpec<D, T>, value: T) {
+    writeProperty(propertySpec.name, propertySpec.converter.convert(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)
 }
 
@@ -108,37 +104,39 @@ public operator fun <T, D : Device> D.set(propertySpec: WritableDevicePropertySp
 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].
  */
 public fun <D : Device, T> D.onPropertyChange(
     spec: DevicePropertySpec<D, 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)
+        val newValue = spec.converter.read(change.value)
         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>()
         .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)
             }
@@ -149,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 d05bf30..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
@@ -1,28 +1,26 @@
 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.*
 import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.meta.MetaConverter
+import space.kscience.dataforge.meta.descriptors.MetaDescriptor
 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
+public object UnitMetaConverter : MetaConverter<Unit> {
 
-    override fun objectToMeta(obj: Unit): Meta = Meta.EMPTY
+    override fun readOrNull(source: Meta): Unit = Unit
+
+    override fun convert(obj: Unit): Meta = Meta.EMPTY
 }
 
 public val MetaConverter.Companion.unit: MetaConverter<Unit> get() = UnitMetaConverter
 
 @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
     )
@@ -35,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() {
     }
 
 
@@ -44,72 +42,30 @@ public abstract class DeviceSpec<D : Device> {
         return deviceProperty
     }
 
-    public fun <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
-                    writable = 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?, WritableDevicePropertySpec<D, T>>> =
-        PropertyDelegateProvider { _, property ->
-            val deviceProperty = object : WritableDevicePropertySpec<D, T> {
-
-                override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply {
-                    //TODO add the type from converter
-                    writable = 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 = {},
         name: String? = null,
-        read: suspend D.() -> 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
             val deviceProperty = object : DevicePropertySpec<D, T> {
-                override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
+
+                override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
+                    converter.descriptor?.let { converterDescriptor ->
+                        metaDescriptor {
+                            from(converterDescriptor)
+                        }
+                    }
+                    fromSpec(property)
+                    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>> { _, _ ->
@@ -121,23 +77,35 @@ public abstract class DeviceSpec<D : Device> {
         converter: MetaConverter<T>,
         descriptorBuilder: PropertyDescriptor.() -> Unit = {},
         name: String? = null,
-        read: suspend D.() -> T?,
-        write: suspend D.(T) -> Unit,
-    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>>> =
+        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
-            val deviceProperty = object : WritableDevicePropertySpec<D, T> {
-                override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
+            val deviceProperty = object : MutableDevicePropertySpec<D, T> {
+                override val descriptor: PropertyDescriptor = PropertyDescriptor(
+                    propertyName,
+                    mutable = true
+                ).apply {
+                    converter.descriptor?.let { converterDescriptor ->
+                        metaDescriptor {
+                            from(converterDescriptor)
+                        }
+                    }
+                    fromSpec(property)
+                    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
-            ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>> { _, _ ->
+            registerProperty(deviceProperty)
+            ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>> { _, _ ->
                 deviceProperty
             }
         }
@@ -155,10 +123,26 @@ 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)
+                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()
+                }
 
                 override val inputConverter: MetaConverter<I> = inputConverter
                 override val outputConverter: MetaConverter<O> = outputConverter
@@ -172,69 +156,52 @@ public abstract class DeviceSpec<D : Device> {
                 deviceAction
             }
         }
-
-    /**
-     * 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()
-        }
 }
 
+/**
+ * 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,
+        name
+    ) {
+        execute()
+    }
+
+/**
+ * An action that takes [Meta] and returns [Meta]. No conversions are done
+ */
+public fun <D : Device> DeviceSpec<D>.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)
+    }
+
 
 /**
- * Register a mutable logical property for a device
+ * Throw an exception if device does not have all properties and actions defined by this specification
  */
-@OptIn(InternalDeviceAPI::class)
-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 { _, property ->
-        val deviceProperty = object : WritableDevicePropertySpec<D, T> {
-            val propertyName = name ?: property.name
-            override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
-                //TODO add type from converter
-                writable = true
-            }.apply(descriptorBuilder)
+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}" }
+    }
 
-            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
+    actions.map { it.value.descriptor }.forEach { specAction ->
+        check(specAction in device.actionDescriptors) { "Action ${specAction.name} not registered in ${device.id}" }
+    }
+}
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..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
@@ -1,38 +1,46 @@
 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 space.kscience.controls.manager.getCoroutineDispatcher
 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 is canceled when the device scope is canceled
+ * Do a recurring (with a fixed delay) task on a device.
  */
-public fun <D : Device, R> D.readRecurring(interval: Duration, reader: suspend D.() -> R): Flow<R> = flow {
-    while (isActive) {
-        delay(interval)
-        launch {
-            emit(reader())
+public fun <D : Device> D.doRecurring(
+    interval: Duration,
+    debugTaskName: String? = null,
+    task: suspend D.() -> Unit,
+): Job {
+    val taskName = debugTaskName ?: "task[${task.hashCode().toString(16)}]"
+    val dispatcher = getCoroutineDispatcher()
+    return launch(CoroutineName(taskName) + dispatcher) {
+        while (isActive) {
+            delay(interval)
+            //launch in parent scope to properly evaluate exceptions
+            this@doRecurring.launch(CoroutineName("$taskName-recurring") + dispatcher) {
+                task()
+            }
         }
     }
 }
 
 /**
- * Do a recurring (with a fixed delay) task on a device.
+ * 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 caller context. To call it on device context, use `flowOn(coroutineContext)`.
+ *
+ * The flow is canceled when the device scope is canceled
  */
-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
new file mode 100644
index 0000000..ad5862b
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt
@@ -0,0 +1,10 @@
+package space.kscience.controls.spec
+
+import space.kscience.controls.api.ActionDescriptor
+import space.kscience.controls.api.PropertyDescriptor
+import kotlin.reflect.KProperty
+
+
+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/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 e264212..0000000
--- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package space.kscience.controls.spec
-
-import space.kscience.dataforge.meta.*
-import space.kscience.dataforge.meta.transformations.MetaConverter
-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 metaToObject(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")
-            return@run value.toDuration(unit)
-        }
-
-    override fun objectToMeta(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 ff89662..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
@@ -4,22 +4,70 @@ 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
+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) }
+    )
 
 //read only delegates
 
+/**
+ * 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>.property(
+    converter: MetaConverter<T>,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    name: String? = null,
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> =
+    property(
+        converter,
+        descriptorBuilder,
+        name,
+        read = { propertyName -> getProperty(propertyName)?.let(converter::readOrNull) },
+    )
+
 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,
     {
         metaDescriptor {
-            type(ValueType.BOOLEAN)
+            valueType(ValueType.BOOLEAN)
         }
         descriptorBuilder()
     },
@@ -31,15 +79,15 @@ private inline fun numberDescriptor(
     crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {}
 ): PropertyDescriptor.() -> Unit = {
     metaDescriptor {
-        type(ValueType.NUMBER)
+        valueType(ValueType.NUMBER)
     }
     descriptorBuilder()
 }
 
 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 +98,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,12 +109,12 @@ 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,
     {
         metaDescriptor {
-            type(ValueType.STRING)
+            valueType(ValueType.STRING)
         }
         descriptorBuilder()
     },
@@ -77,12 +125,12 @@ 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,
     {
         metaDescriptor {
-            type(ValueType.STRING)
+            valueType(ValueType.STRING)
         }
         descriptorBuilder()
     },
@@ -92,17 +140,35 @@ 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,
-    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, 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?,
+    write: suspend D.(propertyName: String, value: Boolean) -> Unit
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Boolean>>> =
     mutableProperty(
         MetaConverter.boolean,
         {
             metaDescriptor {
-                type(ValueType.BOOLEAN)
+                valueType(ValueType.BOOLEAN)
             }
             descriptorBuilder()
         },
@@ -112,34 +178,34 @@ 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.() -> Number,
-    write: suspend D.(Number) -> Unit
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Number>>> =
+    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(
+public fun <D : Device> DeviceSpec<D>.mutableDoubleProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> Double,
-    write: suspend D.(Double) -> Unit
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Double>>> =
+    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(
+public fun <D : Device> DeviceSpec<D>.mutableStringProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> String,
-    write: suspend D.(String) -> Unit
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, String>>> =
+    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(
+public fun <D : Device> DeviceSpec<D>.mutableMetaProperty(
     descriptorBuilder: PropertyDescriptor.() -> Unit = {},
     name: String? = null,
-    read: suspend D.() -> Meta,
-    write: suspend D.(Meta) -> Unit
-): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Meta>>> =
+    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/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-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/ports/ChannelPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt
index d7983f2..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,19 +1,21 @@
 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.controls.api.LifecycleState
+import space.kscience.dataforge.context.*
 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
 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)
@@ -26,32 +28,42 @@ 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> = this.scope.async(Dispatchers.IO) {
-        channelBuilder()
-    }
+) : AbstractAsynchronousPort(context, meta, coroutineContext) {
 
     /**
      * 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 = this.scope.launch(Dispatchers.IO) {
-        val channel = futureChannel.await()
-        val buffer = ByteBuffer.allocate(1024)
-        while (isActive) {
-            try {
-                val num = channel.read(buffer)
-                if (num > 0) {
-                    receive(buffer.toArray(num))
+    private var listenerJob: Job? = null
+
+    override val lifecycleState: LifecycleState
+        get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
+
+    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)
+                    }
                 }
-                if (num < 0) cancel("The input channel is exhausted")
-            } catch (ex: Exception) {
-                logger.error(ex) { "Channel read error" }
-                delay(1000)
             }
         }
     }
@@ -61,73 +73,105 @@ public class ChannelPort(
     }
 
     @OptIn(ExperimentalCoroutinesApi::class)
-    override fun close() {
-        listenerJob.cancel()
+    override suspend fun stop() {
+        listenerJob?.cancel()
         if (futureChannel.isCompleted) {
             futureChannel.getCompleted().close()
-        } else {
-            futureChannel.cancel()
         }
-        super.close()
+        super.stop()
     }
 }
 
 /**
- * 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 open(
+    public fun build(
         context: Context,
         host: String,
         port: Int,
         coroutineContext: CoroutineContext = context.coroutineContext,
-    ): ChannelPort = ChannelPort(context, coroutineContext) {
-        SocketChannel.open(InetSocketAddress(host, port))
+    ): 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 suspend fun start(
+        context: Context,
+        host: String,
+        port: Int,
+        coroutineContext: CoroutineContext = context.coroutineContext,
+    ): ChannelPort = build(context, host, port, coroutineContext).apply { start() }
+
     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 open(
+    public suspend fun start(
         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 { start() }
+
 
     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 open(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
new file mode 100644
index 0000000..39d4c13
--- /dev/null
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt
@@ -0,0 +1,60 @@
+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
+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,
+    meta: Meta,
+    private val socket: DatagramSocket,
+    coroutineContext: CoroutineContext = context.coroutineContext,
+) : AbstractAsynchronousPort(context, meta, coroutineContext) {
+
+    private var listenerJob: Job? = null
+
+    override fun onOpen() {
+        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 suspend fun stop() {
+        listenerJob?.cancel()
+        super.stop()
+    }
+
+    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(
+            data,
+            data.size,
+            socket.remoteSocketAddress
+        )
+        socket.send(packet)
+    }
+
+}
\ 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..cb64fc3
--- /dev/null
+++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt
@@ -0,0 +1,19 @@
+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.value
+    }
+}
+
+internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){
+    property.findAnnotation<Description>()?.let {
+        description = it.value
+    }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..6709f8c
--- /dev/null
+++ b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt
@@ -0,0 +1,50 @@
+package space.kscience.controls.ports
+
+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
+import space.kscience.dataforge.context.Global
+import kotlin.test.assertEquals
+
+
+internal class AsynchronousPortIOTest {
+
+    @Test
+    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("defgb?:", result[1].decodeToString())
+            assertEquals("ddf34fb?:", result[2].decodeToString())
+        }
+    }
+
+    @Test
+    fun testUdpCommunication() = runTest {
+        val receiver = UdpPort.start(Global, "localhost", 8811, localPort = 8812)
+        val sender = UdpPort.start(Global, "localhost", 8812, localPort = 8811)
+
+        delay(30)
+        repeat(10) {
+            sender.send("Line number $it\n")
+        }
+
+        val res = receiver
+            .subscribe()
+            .withStringDelimiter("\n")
+            .take(10)
+            .toList()
+
+        assertEquals("Line number 3", res[3].trim())
+        receiver.stop()
+        sender.stop()
+    }
+}
\ 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
deleted file mode 100644
index bdf6891..0000000
--- a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt
+++ /dev/null
@@ -1,25 +0,0 @@
-package space.kscience.controls.ports
-
-import kotlinx.coroutines.flow.flowOf
-import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.flow.toList
-import kotlinx.coroutines.runBlocking
-import org.junit.jupiter.api.Test
-import kotlin.test.assertEquals
-
-
-internal class PortIOTest{
-
-    @Test
-    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("defgb?:", result[1].decodeToString())
-            assertEquals("ddf34fb?:", result[2].decodeToString())
-        }
-    }
-}
\ 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
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-jupyter/README.md b/controls-jupyter/README.md
new file mode 100644
index 0000000..045b8d0
--- /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.4.0-dev-7`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:controls-jupyter:0.4.0-dev-7")
+}
+```
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-jupyter/build.gradle.kts b/controls-jupyter/build.gradle.kts
new file mode 100644
index 0000000..21a3a82
--- /dev/null
+++ b/controls-jupyter/build.gradle.kts
@@ -0,0 +1,18 @@
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+kscience {
+    fullStack("js/controls-jupyter.js")
+    useKtor()
+    useContextReceivers()
+    jupyterLibrary("space.kscience.controls.jupyter.ControlsJupyter")
+    dependencies {
+        implementation(projects.controlsVision)
+        implementation(libs.visionforge.jupiter)
+    }
+    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..388e1ab
--- /dev/null
+++ b/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt
@@ -0,0 +1,14 @@
+import space.kscience.visionforge.html.runVisionClient
+import space.kscience.visionforge.jupyter.VFNotebookClient
+import space.kscience.visionforge.markup.MarkupPlugin
+import space.kscience.visionforge.plotly.PlotlyPlugin
+
+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..ec4bcee
--- /dev/null
+++ b/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt
@@ -0,0 +1,71 @@
+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
+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.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.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-magix/README.md b/controls-magix/README.md
index 5473f02..f2dd35f 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/controls-magix/build.gradle.kts b/controls-magix/build.gradle.kts
index 0296b8c..1425eb0 100644
--- a/controls-magix/build.gradle.kts
+++ b/controls-magix/build.gradle.kts
@@ -12,13 +12,26 @@ description = """
 kscience {
     jvm()
     js()
+    native()
+    wasm()
+    useCoroutines()
     useSerialization {
         json()
     }
-    dependencies {
+
+    commonMain {
         api(projects.magix.magixApi)
         api(projects.controlsCore)
-        api("com.benasher44:uuid:0.8.0")
+        api(libs.uuid)
+    }
+
+    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/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt
index 64e8a9e..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
@@ -1,12 +1,17 @@
 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.newCoroutineContext
+import kotlinx.coroutines.launch
 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
@@ -21,46 +26,41 @@ 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,
+    propertyDescriptors: Collection<PropertyDescriptor>,
+    actionDescriptors: Collection<ActionDescriptor>,
     incomingFlow: Flow<DeviceMessage>,
     private val send: suspend (DeviceMessage) -> Unit,
-) : Device {
+) : CachingDevice {
 
-    @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
-    override val coroutineContext: CoroutineContext = newCoroutineContext(context.coroutineContext)
+
+    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()
 
     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 {
-        it.onEach { message ->
-            when (message) {
-                is PropertyChangedMessage -> mutex.withLock {
-                    propertyCache[message.property] = message.value
-                }
-
-                is DescriptionMessage -> mutex.withLock {
-                    propertyDescriptors = message.properties
-                    actionDescriptors = message.actions
-                }
-
-                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
 
@@ -69,7 +69,7 @@ public class DeviceClient(
         send(
             PropertyGetMessage(propertyName, targetDevice = deviceName)
         )
-        return flowInternal.filterIsInstance<PropertyChangedMessage>().first {
+        return messageFlow.filterIsInstance<PropertyChangedMessage>().first {
             it.property == propertyName
         }.value
     }
@@ -93,25 +93,181 @@ public class DeviceClient(
         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, LifecycleState.STARTED)
+
     @DFExperimental
-    override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.OPEN
+    override val lifecycleState: LifecycleState get() = lifecycleStateFlow.value
 }
 
 /**
  * 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 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 fun MagixEndpoint.remoteDevice(context: Context, endpointName: String, deviceName: Name): DeviceClient {
-    val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second }
-    return DeviceClient(context, deviceName, subscription) {
-        send(DeviceManager.magixFormat, it, endpointName, id = stringUID())
+public suspend fun MagixEndpoint.remoteDevice(
+    context: Context,
+    thisEndpoint: String,
+    deviceEndpoint: String,
+    deviceName: Name,
+): DeviceClient = coroutineScope {
+    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()
+        )
     }
+
+    send(
+        format = DeviceManager.magixFormat,
+        payload = GetDescriptionMessage(targetDevice = deviceName),
+        source = thisEndpoint,
+        target = deviceEndpoint,
+        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,
+            source = thisEndpoint,
+            target = deviceEndpoint,
+            id = stringUID()
+        )
+    }
+}
+
+/**
+ * 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 { 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)
+
+
+    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()
+    )
+}
+
+/**
+ * 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.read(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.convert(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.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
new file mode 100644
index 0000000..10b1196
--- /dev/null
+++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt
@@ -0,0 +1,82 @@
+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.getOrReadProperty
+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.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))
+}
+
+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.readOrNull(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.readOrNull(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.readOrNull(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.convert(input)
+    val res = execute(actionSpec.name, inputMeta)
+    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.read(res ?: Meta.EMPTY)
+}
\ No newline at end of file
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..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,5 +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
@@ -12,6 +14,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(
@@ -27,22 +31,25 @@ 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)}"
+    uuid4().leastSignificantBits.toULong().toString(16)
 }
 
 /**
  * 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) ->
-        val responsePayload = respondHubMessage(payload)
-        if (responsePayload != null) {
+    endpointID: String,
+    coroutineContext: CoroutineContext = EmptyCoroutineContext,
+): Job = context.launch(coroutineContext) {
+    endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID, null)).onEach { (request, payload) ->
+        val responsePayload: List<DeviceMessage> = respondHubMessage(payload)
+        responsePayload.forEach {
             endpoint.send(
                 format = controlsMagixFormat,
-                payload = responsePayload,
+                payload = it,
                 source = endpointID,
                 target = request.sourceEndpoint,
                 id = generateId(request),
@@ -50,10 +57,10 @@ 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(this).onEach { payload ->
+    hubMessageFlow().onEach { payload ->
         endpoint.send(
             format = controlsMagixFormat,
             payload = payload,
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
new file mode 100644
index 0000000..18a3dfc
--- /dev/null
+++ b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt
@@ -0,0 +1,130 @@
+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.respondHubMessage
+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.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 {
+
+    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 deviceClient() = runTest {
+        val context = Context {
+            plugin(DeviceManager)
+        }
+        val deviceManager = context.request(DeviceManager)
+
+        deviceManager.install("test", TestDevice)
+
+        val virtualMagixEndpoint = VirtualMagixEndpoint(deviceManager)
+
+        val remoteDevice: DeviceClient = 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
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..31f73fc
--- /dev/null
+++ b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt
@@ -0,0 +1,56 @@
+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 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 realDeviceHub() = runTest {
+        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-modbus/README.md b/controls-modbus/README.md
index 78d515a..ef52403 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/controls-modbus/build.gradle.kts b/controls-modbus/build.gradle.kts
index aee64d5..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("com.ghgande:j2mod:3.1.1")
+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 73%
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
index 2f56a8a..3b3f91b 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
+++ b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt
@@ -1,15 +1,14 @@
 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 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.spec.DevicePropertySpec
-import space.kscience.controls.spec.WritableDevicePropertySpec
-import space.kscience.controls.spec.set
-import space.kscience.controls.spec.useProperty
+import space.kscience.controls.ports.readShort
+import space.kscience.controls.spec.*
+import space.kscience.dataforge.io.Binary
 
 
 public class DeviceProcessImageBuilder<D : Device> internal constructor(
@@ -29,10 +28,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 +88,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)
@@ -109,37 +108,63 @@ 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))
             }
         }
     }
 
-    public fun <T> bind(key: ModbusRegistryKey.HoldingRange<T>, propertySpec: WritableDevicePropertySpec<D, T>) {
+    /**
+     * Trigger [block] if one of register changes.
+     */
+    private fun List<ObservableRegister>.onChange(block: suspend (Buffer) -> Unit) {
+        var ready = false
+
+        forEach { register ->
+            register.addObserver { _, _ ->
+                ready = true
+            }
+        }
+
+        device.launch {
+            val builder = Buffer()
+            while (isActive) {
+                delay(1)
+                if (ready) {
+                    val packet = builder.apply {
+                        forEach { value ->
+                            writeShort(value.toShort())
+                        }
+                    }
+                    block(packet)
+                    ready = false
+                }
+            }
+        }
+    }
+
+    public fun <T> bind(key: ModbusRegistryKey.HoldingRange<T>, propertySpec: MutableDevicePropertySpec<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.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))
             }
         }
     }
@@ -182,20 +207,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.readFrom(packet))
+            }
+        }
+
         return registers
     }
 
@@ -205,14 +227,16 @@ 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()
+            start()
         }
     }
     return image
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 79%
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
index ea4330c..2585302 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt
+++ b/controls-modbus/src/jvmMain/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
@@ -21,9 +20,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
@@ -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)
     }
 
 
@@ -61,8 +60,8 @@ public interface ModbusDevice : Device {
     }
 
     public operator fun <T> ModbusRegistryKey.HoldingRange<T>.getValue(thisRef: Any?, property: KProperty<*>): T {
-        val packet = readInputRegistersToPacket(address, count)
-        return format.readObject(packet)
+        val packet = readHoldingRegistersToPacket(address, count)
+        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)
     }
 
@@ -82,35 +81,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)
@@ -122,17 +121,17 @@ 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())
     }
 }
 
 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()
+public fun ModbusDevice.readInputRegistersToPacket(address: Int, count: Int): Buffer =
+    master.readInputRegisters(unitId, address, count).toPacket()
 
 public fun ModbusDevice.readDoubleInput(address: Int): Double =
     readInputRegistersToBuffer(address, Double.SIZE_BYTES).getDouble()
@@ -141,7 +140,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 +148,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()
+public fun ModbusDevice.readHoldingRegistersToPacket(address: Int, count: Int): Buffer =
+    master.readMultipleRegisters(unitId, address, count).toPacket()
 
 public fun ModbusDevice.readDoubleRegister(address: Int): Double =
     readHoldingRegistersToBuffer(address, Double.SIZE_BYTES).getDouble()
@@ -162,14 +161,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())
     )
@@ -183,8 +182,11 @@ 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.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(
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 79%
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 916187f..8557f9d 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
@@ -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
@@ -15,21 +15,19 @@ 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,
 ) : ModbusDevice, DeviceBySpec<D>(spec, context, meta){
-    override suspend fun open() {
+    override suspend fun onStart() {
         master.connect()
-        super<DeviceBySpec>.open()
     }
 
-    override fun close() {
+    override suspend fun onStop() {
         if(disposeMasterOnClose){
             master.disconnect()
         }
-        super<ModbusDevice>.close()
     }
 }
 
@@ -37,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-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
similarity index 58%
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
index a6fc894..1d81d1b 100644
--- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt
+++ b/controls-modbus/src/jvmMain/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}"
-                    }
-                    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/controls-opcua/README.md b/controls-opcua/README.md
index 8cdd373..1d65c9d 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
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-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..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)
@@ -107,7 +113,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)
         }
     }
 
@@ -147,7 +153,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-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..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
@@ -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
 
@@ -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
@@ -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.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 eb9b688..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
@@ -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)
 
@@ -63,8 +63,7 @@ public open class OpcUaDeviceBySpec<D : Device>(
         }
     }
 
-    override fun close() {
+    override suspend 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..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
@@ -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
@@ -19,19 +18,17 @@ 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.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 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)
 
@@ -41,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
@@ -73,18 +52,21 @@ 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)
                     }
+
                     descriptor.readable -> {
                         setAccessLevel(AccessLevel.READ_ONLY)
                         setUserAccessLevel(AccessLevel.READ_ONLY)
                     }
+
                     else -> {
                         setAccessLevel(AccessLevel.NONE)
                         setUserAccessLevel(AccessLevel.NONE)
@@ -93,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
@@ -106,25 +88,24 @@ 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
+                }
             }
 
-            /**
-             * 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,8 +118,11 @@ public class DeviceNameSpace(
         //Subscribe on properties updates
         device.onPropertyChange {
             nodes[property]?.let { node ->
-                val sourceTime = time?.let { DateTime(it.toJavaInstant()) }
-                node.value = value.toOpc(sourceTime = sourceTime)
+                val sourceTime = DateTime(time.toJavaInstant())
+                val newValue = value.toOpc(sourceTime = sourceTime)
+                if (node.value.value != newValue.value) {
+                    node.value = newValue
+                }
             }
         }
         //recursively add sub-devices
@@ -169,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)
     }
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..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 {
@@ -29,7 +29,7 @@ class OpcUaClientTest {
                 return DemoOpcUaDevice(config)
             }
 
-            val randomDouble by doubleProperty(read = DemoOpcUaDevice::readRandomDouble)
+            val randomDouble by doubleProperty { readRandomDouble() }
 
         }
 
@@ -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/controls-pi/README.md b/controls-pi/README.md
index cd9ee0a..9c36e60 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
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-pi/build.gradle.kts b/controls-pi/build.gradle.kts
index a763396..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("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")
+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/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
new file mode 100644
index 0000000..019ac9e
--- /dev/null
+++ b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt
@@ -0,0 +1,97 @@
+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.api.LifecycleState
+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 lifecycleState: LifecycleState
+        get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
+
+    override suspend fun stop() {
+        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 suspend fun start(
+            context: Context,
+            device: String,
+            block: SerialConfigBuilder.() -> Unit,
+        ): 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")
+            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/jvmMain/kotlin/space/kscience/controls/pi/PiPlugin.kt b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/PiPlugin.kt
new file mode 100644
index 0000000..0f2bb02
--- /dev/null
+++ b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/PiPlugin.kt
@@ -0,0 +1,49 @@
+package space.kscience.controls.pi
+
+import com.pi4j.Pi4J
+import space.kscience.controls.manager.DeviceManager
+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
+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) {
+        Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf(
+            "serial".asName() to AsynchronousPiPort,
+        )
+        Ports.SYNCHRONOUS_PORT_TYPE -> mapOf(
+            "serial".asName() to SynchronousPiPort,
+        )
+
+        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()
+
+        @Suppress("UNUSED_PARAMETER")
+        public fun createPiContext(context: Context, meta: Meta): PiContext = Pi4J.newAutoContext()
+
+    }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..d91cd6c
--- /dev/null
+++ b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt
@@ -0,0 +1,110 @@
+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.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.*
+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 suspend fun start() {
+        serial.open()
+    }
+
+    override val lifecycleState: LifecycleState
+        get() = if(serial.isOpen) LifecycleState.STARTED else LifecycleState.STOPPED
+
+    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 (serial.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 suspend fun respondFixedMessageSize(request: ByteArray, responseSize: Int): ByteArray = mutex.withLock {
+        runInterruptible {
+            serial.drain()
+            serial.write(request)
+            serial.readNBytes(responseSize)
+        }
+    }
+
+    override suspend fun stop() {
+        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 suspend fun start(
+            context: Context,
+            device: String,
+            block: SerialConfigBuilder.() -> Unit,
+        ): 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")
+            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-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt
deleted file mode 100644
index 547a142..0000000
--- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package space.kscience.controls.pi
-
-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
-
-public class PiPlugin : AbstractPlugin() {
-    public val ports: Ports by require(Ports)
-
-    override val tag: PluginTag get() = Companion.tag
-
-    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()
-
-    }
-}
\ 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
deleted file mode 100644
index 4924b8d..0000000
--- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt
+++ /dev/null
@@ -1,75 +0,0 @@
-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
-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.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 PiSerialPort(
-    context: Context,
-    coroutineContext: CoroutineContext = context.coroutineContext,
-    public val serialBuilder: () -> Serial,
-) : AbstractPort(context, coroutineContext) {
-
-    private val serial: Serial by lazy { 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) {
-                Pi4J.newAutoContext().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) {
-                baud8N1(baudRate)
-            }
-        }
-
-    }
-}
-
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-plc4x/build.gradle.kts b/controls-plc4x/build.gradle.kts
new file mode 100644
index 0000000..01f7f68
--- /dev/null
+++ b/controls-plc4x/build.gradle.kts
@@ -0,0 +1,22 @@
+import space.kscience.gradle.Maturity
+
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+description = """
+    A plugin for Controls-kt device server on top of plc4x library
+""".trimIndent()
+
+kscience {
+    jvm()
+    jvmMain {
+        api(projects.controlsCore)
+        api(libs.plc4j.spi)
+    }
+}
+
+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..9b08538
--- /dev/null
+++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt
@@ -0,0 +1,76 @@
+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
+
+private val PlcTagResponse.responseCodes: Map<String, PlcResponseCode>
+    get() = tagNames.associateWith { getResponseCode(it) }
+
+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
new file mode 100644
index 0000000..cfeedd2
--- /dev/null
+++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt
@@ -0,0 +1,39 @@
+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 val keys: Set<String>
+
+    public fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder
+
+    public fun PlcReadResponse.readProperty(): Meta
+
+    public fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder
+}
+
+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 =
+        getPlcValue(name).toMeta()
+
+    override fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder =
+        addTagAddress(name, address, meta.toPlcValue(plcValueType))
+}
+
+public fun Plc4xProperty(address: String, plcValueType: PlcValueType, name: String = "@default"): Plc4xProperty =
+    DefaultPlc4xProperty(address, plcValueType, name)
\ 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
diff --git a/controls-ports-ktor/README.md b/controls-ports-ktor/README.md
index 7f935f9..376bd13 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/controls-ports-ktor/build.gradle.kts b/controls-ports-ktor/build.gradle.kts
index 4c8ad9a..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,11 +9,12 @@ 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")
+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 88%
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
index 9c6dbba..6e3041c 100644
--- 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
@@ -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/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
new file mode 100644
index 0000000..d853e46
--- /dev/null
+++ b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
@@ -0,0 +1,99 @@
+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
+import io.ktor.utils.io.consumeEachBufferRange
+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
+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 = {},
+) : AbstractAsynchronousPort(context, meta, coroutineContext) {
+
+    override fun toString(): String = "port[tcp:$host:$port]"
+
+    private val futureSocket = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
+        aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().connect(host, port, socketOptions)
+    }
+
+    private val writeChannel = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
+        futureSocket.await().openWriteChannel(true)
+    }
+
+    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
+            }
+        }
+    }
+
+    override suspend fun write(data: ByteArray) {
+        writeChannel.await().writeAvailable(data)
+    }
+
+    override val lifecycleState: LifecycleState
+        get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
+
+    override suspend fun stop() {
+        listenerJob?.cancel()
+        futureSocket.cancel()
+        super.stop()
+    }
+
+    public companion object : Factory<AsynchronousPort> {
+
+        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 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 { start() }
+
+        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 build(context, host, port)
+        }
+    }
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..267daa4
--- /dev/null
+++ b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
@@ -0,0 +1,130 @@
+package space.kscience.controls.ports
+
+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.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
+import space.kscience.dataforge.meta.int
+import space.kscience.dataforge.meta.number
+import space.kscience.dataforge.meta.string
+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 = {},
+) : AbstractAsynchronousPort(context, meta, coroutineContext) {
+
+    override fun toString(): String = "port[udp:$remoteHost:$remotePort]"
+
+    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) },
+            configure = socketOptions
+        )
+    }
+
+    private val writeChannel: Deferred<ByteWriteChannel> = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
+        futureSocket.await().openWriteChannel(true)
+    }
+
+    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
+            }
+        }
+    }
+
+    override suspend fun write(data: ByteArray) {
+        writeChannel.await().writeAvailable(data)
+    }
+
+    override val lifecycleState: LifecycleState
+        get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
+
+    override suspend fun stop() {
+        listenerJob?.cancel()
+        futureSocket.cancel()
+        super.stop()
+    }
+
+    public companion object : Factory<AsynchronousPort> {
+
+        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 suspend fun start(
+            context: Context,
+            remoteHost: String,
+            remotePort: Int,
+            localPort: Int? = null,
+            localHost: String = "localhost",
+            coroutineContext: CoroutineContext = context.coroutineContext,
+            socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {},
+        ): KtorUdpPort = build(
+            context,
+            remoteHost,
+            remotePort,
+            localPort,
+            localHost,
+            coroutineContext,
+            socketOptions
+        ).apply { start() }
+
+        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 build(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost")
+        }
+    }
+}
\ 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
deleted file mode 100644
index 7f906d3..0000000
--- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt
+++ /dev/null
@@ -1,77 +0,0 @@
-package space.kscience.controls.ports
-
-import io.ktor.network.selector.ActorSelectorManager
-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.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.isActive
-import kotlinx.coroutines.launch
-import space.kscience.dataforge.context.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
-
-public class KtorTcpPort internal constructor(
-    context: Context,
-    public val host: String,
-    public val port: Int,
-    coroutineContext: CoroutineContext = context.coroutineContext,
-) : 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)
-    }
-
-    private val writeChannel = scope.async {
-        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
-        }
-    }
-
-    override suspend fun write(data: ByteArray) {
-        writeChannel.await().writeAvailable(data)
-    }
-
-    override fun close() {
-        listenerJob.cancel()
-        futureSocket.cancel()
-        super.close()
-    }
-
-    public companion object : PortFactory {
-
-        override val type: String = "tcp"
-
-        public fun open(
-            context: Context,
-            host: String,
-            port: Int,
-            coroutineContext: CoroutineContext = context.coroutineContext,
-        ): KtorTcpPort {
-            return KtorTcpPort(context, host, port, coroutineContext)
-        }
-
-        override fun build(context: Context, meta: Meta): Port {
-            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)
-        }
-    }
-}
\ 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
deleted file mode 100644
index 8b8446c..0000000
--- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-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.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 space.kscience.dataforge.context.Context
-import space.kscience.dataforge.meta.Meta
-import space.kscience.dataforge.meta.int
-import space.kscience.dataforge.meta.number
-import space.kscience.dataforge.meta.string
-import kotlin.coroutines.CoroutineContext
-
-public class KtorUdpPort internal constructor(
-    context: Context,
-    public val remoteHost: String,
-    public val remotePort: Int,
-    public val localPort: Int? = null,
-    public val localHost: String = "localhost",
-    coroutineContext: CoroutineContext = context.coroutineContext,
-) : AbstractPort(context, coroutineContext), Closeable {
-
-    override fun toString(): String = "port[udp:$remoteHost:$remotePort]"
-
-    private val futureSocket = scope.async {
-        aSocket(ActorSelectorManager(Dispatchers.IO)).udp().connect(
-            remoteAddress = InetSocketAddress(remoteHost, remotePort),
-            localAddress = localPort?.let { InetSocketAddress(localHost, localPort) }
-        )
-    }
-
-    private val writeChannel = scope.async {
-        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
-        }
-    }
-
-    override suspend fun write(data: ByteArray) {
-        writeChannel.await().writeAvailable(data)
-    }
-
-    override fun close() {
-        listenerJob.cancel()
-        futureSocket.cancel()
-        super.close()
-    }
-
-    public companion object : PortFactory {
-
-        override val type: String = "udp"
-
-        public fun open(
-            context: Context,
-            remoteHost: String,
-            remotePort: Int,
-            localPort: Int? = null,
-            localHost: String = "localhost",
-            coroutineContext: CoroutineContext = context.coroutineContext,
-        ): KtorUdpPort {
-            return KtorUdpPort(context, remoteHost, remotePort, localPort, localHost, coroutineContext)
-        }
-
-        override fun build(context: Context, meta: Meta): Port {
-            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")
-        }
-    }
-}
\ No newline at end of file
diff --git a/controls-serial/README.md b/controls-serial/README.md
index 209055d..44598d0 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/controls-serial/build.gradle.kts b/controls-serial/build.gradle.kts
index a9afc41..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("com.fazecast:jSerialComm:2.10.3")
+kscience {
+    jvm()
+    jvmMain {
+        api(project(":controls-core"))
+        implementation(libs.jSerialComm)
+    }
 }
 
 readme{
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
new file mode 100644
index 0000000..b581405
--- /dev/null
+++ b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt
@@ -0,0 +1,137 @@
+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.api.LifecycleState
+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 or 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 lifecycleState: LifecycleState
+        get() = if(comPort.isOpen) LifecycleState.STARTED else LifecycleState.STOPPED
+
+
+    override suspend fun write(data: ByteArray) {
+        comPort.writeBytes(data, data.size)
+    }
+
+    override suspend fun stop() {
+        comPort.removeDataListener()
+        if (comPort.isOpen) {
+            comPort.closePort()
+        }
+        super.stop()
+    }
+
+    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 suspend fun start(
+            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 { start() }
+
+
+        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/SerialPortPlugin.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
similarity index 63%
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
index ae9d4fc..b43fc00 100644
--- a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
+++ b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt
@@ -1,19 +1,29 @@
 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() {
 
+    public val ports: Ports by require(Ports)
+
     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/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
new file mode 100644
index 0000000..b9fc091
--- /dev/null
+++ b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt
@@ -0,0 +1,142 @@
+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.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
+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 suspend fun start() {
+        if (!comPort.isOpen) {
+            comPort.openPort()
+        }
+    }
+
+    override val lifecycleState: LifecycleState
+        get() = if(comPort.isOpen) LifecycleState.STARTED else LifecycleState.STOPPED
+
+
+    override suspend fun stop() {
+        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 (comPort.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)
+            val buffer = ByteArray(responseSize)
+            comPort.readBytes(buffer, responseSize)
+            buffer
+        }
+    }
+
+    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 suspend fun start(
+            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 { start() }
+
+
+        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/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 3e0601c..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) {
-                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-server/README.md b/controls-server/README.md
index 83408e8..f143d50 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/controls-server/build.gradle.kts b/controls-server/build.gradle.kts
index 43a9d61..7a57d57 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`
 }
 
@@ -9,19 +9,20 @@ 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")
+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-server/src/main/kotlin/space/kscience/controls/server/deviceWebServer.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
similarity index 90%
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
index ba63583..8fd104e 100644
--- a/controls-server/src/main/kotlin/space/kscience/controls/server/deviceWebServer.kt
+++ b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt
@@ -29,19 +29,19 @@ 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.api.start
 import space.kscience.magix.server.magixModule
 
 
-
 private fun Application.deviceServerModule(manager: DeviceManager) {
     install(StatusPages) {
         exception<IllegalArgumentException> { call, cause ->
@@ -100,10 +100,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 }
@@ -157,8 +156,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 +176,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 +196,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)
                         }
@@ -217,5 +216,6 @@ public fun Application.deviceManagerModule(
     plugins.forEach {
         it.start(this, magixFlow)
     }
+
     magixModule(magixFlow)
 }
\ No newline at end of file
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 79%
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
index 93d11c4..ffe489f 100644
--- a/controls-server/src/main/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/controls-storage/README.md b/controls-storage/README.md
index 51cdbcb..116792d 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/controls-storage/controls-xodus/README.md b/controls-storage/controls-xodus/README.md
index 790b356..e206f1e 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/controls-storage/controls-xodus/build.gradle.kts b/controls-storage/controls-xodus/build.gradle.kts
index 329f3ce..04be46a 100644
--- a/controls-storage/controls-xodus/build.gradle.kts
+++ b/controls-storage/controls-xodus/build.gradle.kts
@@ -1,21 +1,24 @@
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `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")
+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 81%
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
index e5d2e4a..559d2a0 100644
--- 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
@@ -4,9 +4,9 @@ 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.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
@@ -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))
 }
 
@@ -68,7 +65,7 @@ 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,
@@ -79,19 +76,19 @@ public class XodusDeviceMessageStorage(
                 it.getBlobString("json") ?: error("No json content found")
             )
         }
-    }
+    }.asFlow()
 
-    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 {
+        ).filter {
             it.timeInRange(range) &&
                     it.propertyMatchesName(DeviceMessage::sourceDevice.name, sourceDevice) &&
                     it.propertyMatchesName(DeviceMessage::targetDevice.name, targetDevice)
@@ -100,8 +97,8 @@ public class XodusDeviceMessageStorage(
                 DeviceMessage.serializer(),
                 it.getBlobString("json") ?: error("No json content found")
             )
-        }.sortedBy { it.time }.toList()
-    }
+        }
+    }.asFlow()
 
     override fun close() {
         entityStore.close()
@@ -123,17 +120,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/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt b/controls-storage/controls-xodus/src/jvmTest/kotlin/PropertyHistoryTest.kt
similarity index 94%
rename from controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt
rename to controls-storage/controls-xodus/src/jvmTest/kotlin/PropertyHistoryTest.kt
index 1724079..e7017b1 100644
--- a/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt
+++ b/controls-storage/controls-xodus/src/jvmTest/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/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..b52ed2b
--- /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.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.read(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 2a453cf..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
@@ -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)
@@ -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()
@@ -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
-//}
 
diff --git a/controls-vision/README.md b/controls-vision/README.md
new file mode 100644
index 0000000..750f60f
--- /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.4.0-dev-7`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:controls-vision:0.4.0-dev-7")
+}
+```
diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts
new file mode 100644
index 0000000..09c644b
--- /dev/null
+++ b/controls-vision/build.gradle.kts
@@ -0,0 +1,32 @@
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+description = """
+    Dashboard and visualization extensions for devices
+""".trimIndent()
+
+kscience {
+    fullStack("js/controls-vision.js")
+    useKtor()
+    useSerialization()
+    useContextReceivers()
+    commonMain {
+        api(projects.controlsCore)
+        api(projects.controlsConstructor)
+        api(libs.visionforge.plotly)
+        api(libs.visionforge.markdown)
+//        api("space.kscience:tables-kt:0.2.1")
+//        api("space.kscience:visionforge-tables:$visionforgeVersion")
+    }
+
+    jvmMain{
+        api(libs.visionforge.server)
+        api(spclibs.ktor.server.cio)
+    }
+}
+
+readme {
+    maturity = space.kscience.gradle.Maturity.PROTOTYPE
+}
\ No newline at end of file
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/ControlVisionPlugin.kt b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
new file mode 100644
index 0000000..b51d15c
--- /dev/null
+++ b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt
@@ -0,0 +1,27 @@
+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{
+    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 {
+    polymorphic(Vision::class) {
+        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/koalaPlotExtensions.kt b/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt
new file mode 100644
index 0000000..8be10df
--- /dev/null
+++ b/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt
@@ -0,0 +1,239 @@
+@file:OptIn(FlowPreview::class, FlowPreview::class)
+
+package space.kscience.controls.vision
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.*
+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.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
+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.minutes
+import kotlin.time.Duration.Companion.seconds
+
+private var TraceValues.values: List<Value>
+    get() = value?.list ?: emptyList()
+    set(newValues) {
+        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 }
+    }
+}
+
+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
+ */
+public fun Plot.plotDeviceProperty(
+    device: Device,
+    propertyName: String,
+    extractValue: Meta.() -> Value = { value ?: Null },
+    maxAge: Duration = defaultMaxAge,
+    maxPoints: Int = defaultMaxPoints,
+    minPoints: Int = defaultMinPoints,
+    sampling: Duration = defaultSampling,
+    coroutineScope: CoroutineScope = device.context,
+    configuration: Scatter.() -> Unit = {},
+): Job = scatter(configuration).run {
+    val data = TimeData()
+    device.propertyMessageFlow(propertyName).sample(sampling).transform {
+        data.append(it.time, it.value.extractValue())
+        data.trim(maxAge, maxPoints, minPoints)
+        emit(data)
+    }.onEach {
+        it.fillPlot(x, y)
+    }.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,
+    maxAge: Duration,
+    maxPoints: Int,
+    minPoints: Int,
+    sampling: Duration,
+): Job {
+    val clock = context.clock
+    val data = TimeData()
+    return state.valueFlow.sample(sampling).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 = { Value.of(it) },
+    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)
+}
+
+
+public fun Plot.plotNumberState(
+    context: Context,
+    state: DeviceState<Number>,
+    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)
+}
+
+
+public fun Plot.plotBooleanState(
+    context: Context,
+    state: DeviceState<Boolean>,
+    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)
+}
+
+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)
+        }
+    }
+}
+
+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 [startValue] on each sample interval if no events arrived.
+ */
+@DFExperimental
+public fun Plot.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,
+    coroutineScope: CoroutineScope = device.context,
+    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(), 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)
+    }.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
new file mode 100644
index 0000000..f75630b
--- /dev/null
+++ b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt
@@ -0,0 +1,65 @@
+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.dataforge.names.Name
+import space.kscience.dataforge.names.asName
+import space.kscience.visionforge.VisionPlugin
+import space.kscience.visionforge.html.ElementVisionRenderer
+
+
+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 ->
+
+}
+
+
+public actual class ControlVisionPlugin : VisionPlugin() {
+    actual override val tag: PluginTag get() = Companion.tag
+
+    actual override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule
+
+    override fun content(target: String): Map<Name, Any> = when (target) {
+        ElementVisionRenderer.TYPE -> mapOf(
+            "indicator".asName() to indicatorRenderer,
+            "slider".asName() to sliderRenderer
+        )
+
+        else -> super.content(target)
+    }
+
+    public actual companion object : PluginFactory<ControlVisionPlugin> {
+        actual override val tag: PluginTag = PluginTag("controls.vision")
+
+        actual override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin()
+
+    }
+}
\ No newline at end of file
diff --git a/controls-vision/src/jsMain/kotlin/client.kt b/controls-vision/src/jsMain/kotlin/client.kt
new file mode 100644
index 0000000..78da2e8
--- /dev/null
+++ b/controls-vision/src/jsMain/kotlin/client.kt
@@ -0,0 +1,11 @@
+package space.kscience.controls.vision
+
+import space.kscience.visionforge.html.runVisionClient
+import space.kscience.visionforge.markup.MarkupPlugin
+import space.kscience.visionforge.plotly.PlotlyPlugin
+
+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/ControlsVisionPlugin.jvm.kt b/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt
new file mode 100644
index 0000000..53ada2e
--- /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() {
+    actual override val tag: PluginTag get() = Companion.tag
+
+    actual override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule
+
+    public actual companion object : PluginFactory<ControlVisionPlugin> {
+        actual override val tag: PluginTag = PluginTag("controls.vision")
+
+        actual override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin()
+
+    }
+}
\ No newline at end of file
diff --git a/controls-vision/src/jvmMain/kotlin/dashboard.kt b/controls-vision/src/jvmMain/kotlin/dashboard.kt
new file mode 100644
index 0000000..1b64b15
--- /dev/null
+++ b/controls-vision/src/jvmMain/kotlin/dashboard.kt
@@ -0,0 +1,72 @@
+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.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
+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 {
+    //create a sub-context for visualization
+    val visualisationContext = buildContext {
+        plugin(PlotlyPlugin)
+        plugin(ControlVisionPlugin)
+        plugin(MarkupPlugin)
+    }
+
+    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") {
+            //
+        }
+
+        it.close()
+    }
+}
+
+context(VisionTagConsumer<*>)
+public fun TagConsumer<*>.plot(
+    config: PlotlyConfig = PlotlyConfig(),
+    block: Plot.() -> Unit,
+) {
+    vision {
+        plotly(config, block)
+    }
+}
diff --git a/controls-visualisation-compose/README.md b/controls-visualisation-compose/README.md
new file mode 100644
index 0000000..da0d9ef
--- /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-7`.
+
+**Gradle Kotlin DSL:**
+```kotlin
+repositories {
+    maven("https://repo.kotlin.link")
+    mavenCentral()
+}
+
+dependencies {
+    implementation("space.kscience:controls-visualisation-compose:0.4.0-dev-7")
+}
+```
diff --git a/controls-visualisation-compose/build.gradle.kts b/controls-visualisation-compose/build.gradle.kts
new file mode 100644
index 0000000..0c68988
--- /dev/null
+++ b/controls-visualisation-compose/build.gradle.kts
@@ -0,0 +1,32 @@
+import org.jetbrains.compose.ExperimentalComposeLibrary
+
+plugins {
+    id("space.kscience.gradle.mpp")
+    alias(spclibs.plugins.compose.compiler)
+    alias(spclibs.plugins.compose.jb)
+    `maven-publish`
+}
+
+description = """
+    Visualisation extension using compose-multiplatform
+""".trimIndent()
+
+kscience {
+    jvm()
+    useKtor()
+    useSerialization()
+    useContextReceivers()
+    commonMain {
+        api(projects.controlsConstructor)
+        api(libs.koala.plots)
+        api(compose.foundation)
+        api(compose.material3)
+        @OptIn(ExperimentalComposeLibrary::class)
+        api(compose.desktop.components.splitPane)
+    }
+}
+
+
+readme {
+    maturity = space.kscience.gradle.Maturity.PROTOTYPE
+}
\ No newline at end of file
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..94bf76c
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt
@@ -0,0 +1,94 @@
+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.DrawScope
+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,
+    onDraw: DrawScope.() -> Unit = {},
+    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() }
+                }
+                onDraw()
+            }
+        }
+    }
+}
\ 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..3ab10c6
--- /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.automirrored.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.automirrored.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.AutoMirrored.Filled.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.AutoMirrored.Filled.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
new file mode 100644
index 0000000..65ce8d9
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt
@@ -0,0 +1,42 @@
+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()
+        return object : TickValues<Instant> {
+            override val majorTickValues: List<Instant> = List(numTicks) {
+                currentRange.start + it.toDouble() / (numTicks - 1) * rangeLength
+            }
+
+            override val minorTickValues: List<Instant> = emptyList()
+        }
+    }
+
+    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..87a9fca
--- /dev/null
+++ b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt
@@ -0,0 +1,244 @@
+@file:OptIn(FlowPreview::class)
+
+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.*
+import kotlinx.coroutines.flow.*
+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.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
+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<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)
+}
+
+@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()
+    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/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/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/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/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts
index 05d7395..077e55d 100644
--- a/demo/all-things/build.gradle.kts
+++ b/demo/all-things/build.gradle.kts
@@ -1,7 +1,7 @@
 plugins {
     kotlin("jvm")
-    id("org.openjfx.javafxplugin") version "0.0.13"
-    application
+    alias(spclibs.plugins.compose.compiler)
+    alias(spclibs.plugins.compose.jb)
 }
 
 
@@ -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,29 +19,47 @@ dependencies {
     implementation(projects.magix.magixZmq)
     implementation(projects.controlsOpcua)
 
-    implementation("io.ktor:ktor-client-cio:$ktorVersion")
-    implementation("no.tornado:tornadofx:1.7.20")
-    implementation("space.kscience:plotlykt-server:0.5.3")
+    implementation(spclibs.ktor.client.cio)
+    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 94815fa..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
@@ -1,17 +1,26 @@
 package space.kscience.controls.demo
 
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+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.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.GetDescriptionMessage
+import space.kscience.controls.api.PropertyChangedMessage
 import space.kscience.controls.client.launchMagixService
-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.client.magixFormat
 import space.kscience.controls.manager.DeviceManager
 import space.kscience.controls.manager.install
 import space.kscience.controls.opcua.server.OpcUaServer
@@ -20,21 +29,26 @@ 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
 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 {
+
+private val json = Json { prettyPrint = true }
+
+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 {
@@ -49,28 +63,46 @@ 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)
 
-            //serve devices as OPC-UA namespace
-            opcUaServer.startup()
-            opcUaServer.serveDevices(deviceManager)
-        }
+    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, "demoDevice")
+        //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)
+
+        //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() {
+    fun shutdown(): Job = context.launch {
         logger.info { "Shutting down..." }
         opcUaServer.shutdown()
         logger.info { "OpcUa server stopped" }
@@ -78,92 +110,99 @@ 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()
     }
 }
 
+@Composable
+fun DemoControls(controller: DemoController) {
+    var timeScale by remember { mutableStateOf(5000f) }
+    var xScale by remember { mutableStateOf(1f) }
+    var yScale by remember { mutableStateOf(1f) }
 
-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()
-
-    override val root: Parent = vbox {
-        hbox {
-            label("Time scale")
-            pane {
-                hgrow = Priority.ALWAYS
+    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)
             }
-            timeScaleSlider = slider(1000..10000, 5000) {
-                isShowTickLabels = true
-                isShowTickMarks = true
+            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)
             }
-        }
-        hbox {
-            label("X scale")
-            pane {
-                hgrow = Priority.ALWAYS
+            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)
             }
-            xScaleSlider = slider(0.1..2.0, 1.0) {
-                isShowTickLabels = true
-                isShowTickMarks = true
+            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")
+                }
             }
-        }
-        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)
-                    }
+            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")
                 }
             }
         }
-        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)
-                }
-            }
+
+    }
+}
+
+
+fun main() = application {
+    val controller = remember { DemoController().apply { start() } }
+
+    Window(
+        title = "All things control",
+        onCloseRequest = {
+            controller.shutdown()
+            exitApplication()
+        },
+        state = rememberWindowState(width = 400.dp, height = 320.dp)
+    ) {
+        MaterialTheme {
+            DemoControls(controller)
         }
     }
-}
-
-
-class DemoControllerApp : App(DemoControllerView::class) {
-    private val controller: DemoController by inject()
-
-    override fun start(stage: Stage) {
-        super.start(stage)
-        controller.init()
-    }
-
-    override fun stop() {
-        controller.shutdown()
-        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/DemoDevice.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt
index dd65b78..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,16 +7,16 @@ 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
 import kotlin.time.Duration.Companion.milliseconds
 
 
-interface IDemoDevice: Device {
+interface IDemoDevice : Device {
     var timeScaleState: Double
     var sinScaleState: Double
     var cosScaleState: Double
@@ -42,16 +42,21 @@ 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)
             }
-            info = "Real to virtual time scale"
+            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(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 = {
@@ -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)
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 }
 
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/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/car/build.gradle.kts b/demo/car/build.gradle.kts
index 5d53f11..967b87d 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,14 +21,14 @@ dependencies {
     implementation(projects.magix.magixStorage.magixStorageXodus)
 //    implementation(projects.controlsMongo)
 
-    implementation("io.ktor:ktor-client-cio:$ktorVersion")
-    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.ktor.client.cio)
+    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")
 }
 
@@ -40,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/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 87%
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
index 3bb8c79..c61041b 100644
--- 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
@@ -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/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 85%
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
index 03c781b..27ea243 100644
--- 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
@@ -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) {
 
@@ -23,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))
                     }
                 }
             }
@@ -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/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCar.kt
similarity index 78%
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
index 1f1dc69..86564a5 100644
--- 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
@@ -4,18 +4,14 @@ 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
 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.time.Duration
 import kotlin.time.Duration.Companion.milliseconds
@@ -23,24 +19,28 @@ 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 fun metaToObject(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
         }
     }
 }
 
-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
 
     private val mass by meta.double(1000.0) // mass in kilograms
@@ -57,7 +57,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
@@ -100,10 +100,9 @@ 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()
+        timeState = clock.now()
         //starting regular updates
         doRecurring(100.milliseconds) {
             update()
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 94%
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
index 7a170ac..a598d4b 100644
--- 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
@@ -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
@@ -63,17 +64,17 @@ 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")
         }
     }
 
-    fun shutdown() {
+    suspend fun shutdown() {
         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()
     }
@@ -137,7 +138,9 @@ class VirtualCarControllerApp : App(VirtualCarControllerView::class) {
     }
 
     override fun stop() {
-        controller.shutdown()
+        runBlocking {
+            controller.shutdown()
+        }
         super.stop()
     }
 }
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
+
+
+
diff --git a/demo/constructor/api/constructor.api b/demo/constructor/api/constructor.api
new file mode 100644
index 0000000..7206553
--- /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 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
+}
+
+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/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts
new file mode 100644
index 0000000..6d6d7f0
--- /dev/null
+++ b/demo/constructor/build.gradle.kts
@@ -0,0 +1,54 @@
+import org.jetbrains.compose.desktop.application.dsl.TargetFormat
+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()
+    useKtor()
+    useSerialization()
+    useContextReceivers()
+    commonMain {
+        implementation(projects.controlsVisualisationCompose)
+//        implementation(projects.controlsVision)
+        implementation(projects.controlsConstructor)
+//        implementation("io.github.koalaplot:koalaplot-core:0.6.0")
+    }
+    jvmMain {
+//        implementation("io.ktor:ktor-server-cio")
+        implementation(spclibs.logback.classic)
+    }
+}
+
+kotlin {
+    sourceSets {
+        jvmMain {
+            dependencies {
+                implementation(compose.desktop.currentOs)
+            }
+        }
+    }
+}
+
+//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/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
new file mode 100644
index 0000000..af6da52
--- /dev/null
+++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt
@@ -0,0 +1,129 @@
+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.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.window.Window
+import androidx.compose.ui.window.application
+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
+
+
+private class Spring(
+    context: Context,
+    val k: Double,
+    val l0: NumericalValue<Meters>,
+    val begin: DeviceState<XYZ<Meters>>,
+    val end: DeviceState<XYZ<Meters>>,
+) : ModelConstructor(context) {
+
+    /**
+     * Tension at the beginning point
+     */
+    val tension: DeviceState<XYZ<Newtons>> = combineState(begin, end) { begin: XYZ<Meters>, end: XYZ<Meters> ->
+        val delta = end - begin
+        val l = delta.length.value
+        ((delta / l) * k * (l - l0.value)).cast(Newtons)
+    }
+}
+
+
+private class BodyOnSprings(
+    context: Context,
+    mass: NumericalValue<Kilograms>,
+    k: Double,
+    startPosition: XYZ<Meters>,
+    l0: NumericalValue<Meters> = NumericalValue(1.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 = stateOf(startPosition)
+    val velocity: MutableDeviceState<XYZ<MetersPerSecond>> = stateOf(XYZ(0, 0, 0))
+
+    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(XYZ<Meters>(xRight, (yTop + yBottom) / 2, 0.0))
+
+    val rightSpring = model(
+        Spring(context, k, l0, rightAnchor, position)
+    )
+
+    val force: DeviceState<XYZ<Newtons>> =
+        combineState(leftSpring.tension, rightSpring.tension) { left: XYZ<Newtons>, right ->
+            -left - right
+        }
+
+
+    val body = model(
+        MaterialPoint(
+            context = context,
+            mass = mass,
+            force = force,
+            position = position,
+            velocity = velocity
+        )
+    )
+}
+
+fun main() = application {
+    val initialState = XYZ<Meters>(0.05, 0.4, 0)
+
+    Window(title = "Ball on springs", onCloseRequest = ::exitApplication) {
+        window.minimumSize = Dimension(400, 400)
+        MaterialTheme {
+            val context = remember {
+                Context("simulation")
+            }
+
+            val model = remember {
+                BodyOnSprings(context, NumericalValue(10.0), 100.0, initialState)
+            }
+
+            //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: 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()
+                )
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt b/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt
new file mode 100644
index 0000000..02192b9
--- /dev/null
+++ b/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt
@@ -0,0 +1,77 @@
+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.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 = 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
+        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 {
+        plugin(DeviceManager)
+    }
+
+    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/PidDemo.kt b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
new file mode 100644
index 0000000..9411321
--- /dev/null
+++ b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt
@@ -0,0 +1,317 @@
+package space.kscience.controls.demo.constructor
+
+import androidx.compose.foundation.background
+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.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 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.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
+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.Leadscrew
+import space.kscience.controls.constructor.models.MutableRangeState
+import space.kscience.controls.constructor.models.PidParameters
+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.*
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.request
+import java.awt.Dimension
+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
+
+
+class Modulator(
+    context: Context,
+    target: MutableDeviceState<NumericalValue<Meters>>,
+    var timeStep: Duration = 5.milliseconds,
+    var freq: Double = 0.1,
+) : DeviceConstructor(context) {
+    private val clockStart = clock.now()
+
+    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) +
+                    sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / timeStep))
+        )
+    }
+}
+
+
+private val mass = NumericalValue<Kilograms>(1)
+
+private val leverage = NumericalValue<Meters>(1.0)
+
+private val maxAge = 10.seconds
+
+private val range = -6.0..6.0
+
+/**
+ * The whole physical model is here
+ */
+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)
+
+    //a screw drive to convert a rotational moment into a force
+    val leadscrew = Leadscrew(context, leverage)
+
+
+    /**
+     * 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 inertiaModel = Inertia.linear(
+        context = context,
+        force = leadscrew.torqueToForce(drive.force),
+        mass = mass,
+        position = position
+    )
+
+    /**
+     * Create a limit switches from physical position
+     */
+    val startLimitSwitch = LimitSwitch(context, position.atStart)
+    val endLimitSwitch = LimitSwitch(context, position.atEnd)
+
+    /**
+     * Install the resulting device
+     */
+    return LinearDrive(drive, startLimitSwitch, endLimitSwitch, position, pidParameters)
+
+}
+
+private fun createModulator(linearDrive: LinearDrive): Modulator = linearDrive.context.install(
+    "modulator",
+    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 {
+        Context {
+            plugin(DeviceManager)
+            plugin(ClockManager)
+        }
+    }
+
+    var pidParameters by remember {
+        mutableStateOf(startPid)
+    }
+
+    val linearDrive: LinearDrive = remember {
+        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 {
+        context.install("modulator", createModulator(linearDrive))
+    }
+
+    //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 {
+            linearDrive.pid.pidParameters = pidParameters
+        }.collect()
+    }
+
+    val clock = remember { context.clock }
+
+    Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) {
+        window.minimumSize = Dimension(800, 400)
+        MaterialTheme {
+            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))
+                            NumberTextField(
+                                value = pidParameters.kp,
+                                onValueChange = { pidParameters = pidParameters.copy(kp = it.toDouble()) },
+                                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..100f,
+                                steps = 100
+                            )
+                        }
+                        Row {
+                            Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
+                            NumberTextField(
+                                value = pidParameters.ki,
+                                onValueChange = { pidParameters = pidParameters.copy(ki = it.toDouble()) },
+                                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 = -10f..10f,
+                                steps = 100
+                            )
+                        }
+                        Row {
+                            Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp))
+                            NumberTextField(
+                                value = pidParameters.kd,
+                                onValueChange = { pidParameters = pidParameters.copy(kd = it.toDouble()) },
+                                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 = -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(200.dp),
+                                enabled = false
+                            )
+
+                            Slider(
+                                pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(),
+                                { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) },
+                                valueRange = 1f..100f,
+                                steps = 100
+                            )
+                        }
+                        Row {
+                            Button({
+                                pidParameters = startPid
+                            }) {
+                                Text("Reset")
+                            }
+                        }
+                    }
+                }
+                second(400.dp) {
+                    ChartLayout {
+                        XYGraph<Instant, Double>(
+                            xAxisModel = remember { TimeAxisModel.recent(maxAge, clock) },
+                            yAxisModel = rememberDoubleLinearAxisModel((range.start - 1.0)..(range.endInclusive + 1.0)),
+                            xAxisTitle = { Text("Time in seconds relative to current") },
+                            xAxisLabels = { it: Instant ->
+                                Text(
+                                    (clock.now() - it).toDouble(
+                                        DurationUnit.SECONDS
+                                    ).toString(2)
+                                )
+                            },
+                            yAxisLabels = { it: Double -> Text(it.toString(2)) }
+                        ) {
+                            PlotNumericState(
+                                context = context,
+                                state = linearDrive.position,
+                                maxAge = maxAge,
+                                sampling = 50.milliseconds,
+                                lineStyle = LineStyle(SolidColor(Color.Blue))
+                            )
+                            PlotNumericState(
+                                context = context,
+                                state = linearDrive.pid.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 target", color = Color.Red)
+                                    }
+                                }
+                            })
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
new file mode 100644
index 0000000..16006d6
--- /dev/null
+++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt
@@ -0,0 +1,242 @@
+package space.kscience.controls.demo.constructor
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+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
+import space.kscience.controls.constructor.devices.angle
+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
+import space.kscience.controls.manager.DeviceManager
+import space.kscience.dataforge.context.Context
+import java.awt.Dimension
+import kotlin.random.Random
+
+
+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()
+        yDrive.target.value = y.toLong()
+    }
+
+    val ticks = combineState(xDrive.position, yDrive.position) { x, y ->
+        x to y
+    }
+
+    //TODO add calibration
+
+    // TODO add draw as action
+}
+
+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)
+        moveToXY(randomX, randomY)
+        //TODO wait for position instead of custom delay
+        delay(500)
+        paint(Color(Random.nextInt()))
+    }
+}
+
+private 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 const val ticksPerSecond = 3000.0
+private val step = NumericalValue<Degrees>(1.8)
+
+
+private data class PlotterPoint(
+    val x: NumericalValue<Meters>,
+    val y: NumericalValue<Meters>,
+    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 = Leadscrew(context, NumericalValue(0.01))
+    val x = xTransmission.degreesToMeters(xDrive.angle(step)).coerceIn(xRange)
+
+    private val yDrive = StepDrive(context, ticksPerSecond)
+    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) }
+
+    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))
+    }
+}
+
+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 scope = rememberCoroutineScope()
+
+        var updateJob: Job? = remember { null }
+
+        var points by remember { mutableStateOf<List<PlotterPoint>>(emptyList()) }
+
+        val plotterModel = remember {
+            val context = Context {
+                plugin(DeviceManager)
+                plugin(ClockManager)
+            }
+
+            /* Here goes the device definition block */
+
+            PlotterModel(context) { plotterPoint ->
+                points += plotterPoint
+            }
+        }
+
+        /* Here goes the visualization block */
+
+        MaterialTheme {
+            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()
+
+                        fun yToPx(y: NumericalValue<Meters>): Float =
+                            ((y - yRange.start) / (yRange.endInclusive - yRange.start) * size.height).toFloat()
+
+
+                        fun toOffset(xy: XY<Meters>): Offset = Offset(xToPx(xy.x), yToPx(xy.y))
+
+                        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
+                            )
+                        }
+
+                        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/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/device-collective/README.md b/demo/device-collective/README.md
new file mode 100644
index 0000000..433a46f
--- /dev/null
+++ b/demo/device-collective/README.md
@@ -0,0 +1,4 @@
+# Module device-collective
+
+
+
diff --git a/demo/device-collective/build.gradle.kts b/demo/device-collective/build.gradle.kts
new file mode 100644
index 0000000..3290a0d
--- /dev/null
+++ b/demo/device-collective/build.gradle.kts
@@ -0,0 +1,44 @@
+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)
+        implementation(projects.magix.magixServer)
+        implementation(projects.magix.magixRsocket)
+        implementation(projects.controlsMagix)
+    }
+    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.collective.MainKt"
+    }
+}
\ No newline at end of file
diff --git a/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
new file mode 100644
index 0000000..c550830
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt
@@ -0,0 +1,118 @@
+@file:OptIn(DFExperimental::class)
+
+package space.kscience.controls.demo.collective
+
+import space.kscience.controls.api.Device
+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.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
+
+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 = DEFAULT_FREQUENCY)
+
+    companion object {
+        const val DEFAULT_FREQUENCY = "169 MHz"
+    }
+}
+
+typealias CollectiveDeviceRoster = Map<CollectiveDeviceId, CollectiveDeviceConfiguration>
+
+interface CollectiveDevice : Device {
+
+    public val id: CollectiveDeviceId
+
+    public val peerConnection: PeerConnection
+
+    suspend fun getPosition(): Gmc
+
+    suspend fun getVelocity(): GmcVelocity
+
+    suspend fun setVelocity(value: GmcVelocity)
+
+    suspend fun listVisible(): Collection<CollectiveDeviceId>
+
+    companion object : DeviceSpec<CollectiveDevice>() {
+        val position by property<Gmc>(
+            converter = MetaConverter.serializable(),
+            read = { getPosition() }
+        )
+
+        val velocity by mutableProperty<GmcVelocity>(
+            converter = MetaConverter.serializable(),
+            read = { getVelocity() },
+            write = { _, value -> setVelocity(value) }
+        )
+
+        val visibleNeighbors by property(
+            MetaConverter.stringList,
+            read = {
+                listVisible().toList()
+            }
+        )
+
+//        val listVisible by action(MetaConverter.unit, MetaConverter.valueList<String> { it.string }) {
+//            listVisible().toList()
+//        }
+    }
+}
+
+
+class CollectiveDeviceConstructor(
+    context: Context,
+    val configuration: CollectiveDeviceConfiguration,
+    position: MutableDeviceState<Gmc>,
+    velocity: MutableDeviceState<GmcVelocity>,
+    override val peerConnection: PeerConnection,
+    private val observation: suspend () -> Map<CollectiveDeviceId, GmcCurve>,
+) : DeviceConstructor(context, configuration.meta), CollectiveDevice {
+
+    override val id: CollectiveDeviceId get() = configuration.deviceId
+
+    val position = registerAsProperty(
+        CollectiveDevice.position,
+        position.debounce(configuration.reportInterval.milliseconds)
+    )
+
+    val velocity = registerAsProperty(
+        CollectiveDevice.velocity,
+        velocity.debounce(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
+
+    override suspend fun getVelocity(): GmcVelocity = velocity.value
+
+    override suspend fun setVelocity(value: GmcVelocity) {
+        velocity.value = value
+    }
+
+    override suspend fun listVisible(): Collection<CollectiveDeviceId> = observation.invoke().keys
+}
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..ac699e6
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt
@@ -0,0 +1,36 @@
+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 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.debounce(interval)
+
+    override fun toString(): String = "DebounceDeviceState($value, interval=$interval)"
+}
+
+
+fun <T> DeviceState<T>.debounce(interval: Duration) = DebounceDeviceState(this, interval)
+
+@OptIn(FlowPreview::class)
+class MutableDebounceDeviceState<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>.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
new file mode 100644
index 0000000..b5e67e4
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt
@@ -0,0 +1,255 @@
+package space.kscience.controls.demo.collective
+
+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
+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,
+    val position: MutableDeviceState<Gmc>,
+    val velocity: MutableDeviceState<GmcVelocity>,
+)
+
+internal fun CollectiveDeviceState(
+    id: CollectiveDeviceId,
+    position: Gmc,
+    configuration: CollectiveDeviceConfiguration.() -> Unit = {},
+) = CollectiveDeviceState(
+    id,
+    CollectiveDeviceConfiguration(id).apply(configuration),
+    MutableDeviceState(position),
+    MutableDeviceState(GmcVelocity.zero)
+)
+
+internal class DeviceCollectiveModel(
+    context: Context,
+    val deviceStates: Collection<CollectiveDeviceState>,
+    val visibilityRange: Distance = 0.5.kilometers,
+    val radioRange: Distance = 1.kilometers,
+) : 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)
+        }
+    }
+
+    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")
+
+        val allCurves = coordinatesSnapshot
+            .filterKeys { it != id }
+            .mapValues { GeoEllipsoid.WGS84.curveBetween(selected, it.value) }
+
+        return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange }
+    }
+
+    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.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(target.configuration.deviceId.parseAsName(), message)
+            }
+        }
+    }
+
+    val devices = deviceStates.associate { state ->
+        val device = CollectiveDeviceConstructor(
+            context = context,
+            configuration = state.configuration,
+            position = state.position,
+            velocity = state.velocity,
+            peerConnection = RadioPeerConnectionModel(state.position),
+        ) {
+            locateVisible(state.id)
+        }
+        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) {
+    val server = startMagixServer(
+//        RSocketMagixFlowPlugin()
+    )
+    val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost")
+
+    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)
+    }
+}
+
+
+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/GmcVelocity.kt b/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt
new file mode 100644
index 0000000..9d356c6
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt
@@ -0,0 +1,24 @@
+package space.kscience.controls.demo.collective
+
+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/main.kt b/demo/device-collective/src/jvmMain/kotlin/main.kt
new file mode 100644
index 0000000..f92b0eb
--- /dev/null
+++ b/demo/device-collective/src/jvmMain/kotlin/main.kt
@@ -0,0 +1,303 @@
+@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.*
+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
+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.*
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.flow.sample
+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.dataforge.context.Context
+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.milliseconds
+import kotlin.time.Duration.Companion.seconds
+
+
+@Composable
+fun rememberContext(name: String, contextBuilder: ContextBuilder.() -> Unit = {}): Context = remember {
+    Context(name, contextBuilder)
+}
+
+internal val gmcMetaConverter = MetaConverter.serializable<Gmc>()
+internal val gmcVelocityMetaConverter = MetaConverter.serializable<GmcVelocity>()
+
+@Composable
+fun App() {
+    val scope = rememberCoroutineScope()
+
+    val parentContext = rememberContext("Parent") {
+        plugin(DeviceManager)
+    }
+
+    val collectiveModel = remember {
+        generateModel(parentContext, 100, reportInterval = 1.seconds)
+    }
+
+    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) ->
+                scope.launch {
+                    val deviceClient = magixClient.remoteDevice(parentContext, "listener", id, id.parseAsName())
+                    devices[id] = deviceClient
+                }
+            }
+        }
+
+    }
+
+    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 showOnlyVisible by remember { mutableStateOf(false) }
+
+    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(
+                        client = HttpClient(CIO),
+                        cacheDirectory = Path.of("mapCache")
+                    )
+                },
+                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 {
+                        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)
+                }
+
+                //draw received data
+                scope.launch {
+                    client.await().subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) ->
+                        if (deviceMessage is PropertyChangedMessage && deviceMessage.property == "position") {
+                            val id = magixMessage.sourceEndpoint
+                            val position = gmcMetaConverter.read(deviceMessage.value)
+
+                            rectangle(
+                                position,
+                                id = id,
+                            ).color(Color.Blue).onClick { selectedDeviceId = id }
+                        }
+                    }.launchIn(scope)
+
+                }
+
+                // draw trawler
+
+                trawler.position.valueFlow.onEach {
+                    circle(it, id = "trawler").color(Color.Black)
+                }.launchIn(scope)
+            }
+        }
+        second(200.dp) {
+
+            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,
+                        modifier = Modifier.padding(8.dp).onClick {
+                            selectedDeviceId = id
+                        }.conditional(id == selectedDeviceId) {
+                            border(2.dp, Color.Blue)
+                        },
+                    ) {
+                        Column(
+                            modifier = Modifier.padding(8.dp)
+                        ) {
+                            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)
+                                    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()
+        }
+    }
+}
\ 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 0000000..953de16
Binary files /dev/null and b/demo/device-collective/src/jvmMain/resources/SPC-logo.png differ
diff --git a/demo/echo/build.gradle.kts b/demo/echo/build.gradle.kts
index 5563ba3..ed722bf 100644
--- a/demo/echo/build.gradle.kts
+++ b/demo/echo/build.gradle.kts
@@ -8,24 +8,21 @@ 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")
+    implementation(libs.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")
+    compilerOptions {
+        freeCompilerArgs.addAll("-Xjvm-default=all")
     }
 }
 
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/demo/many-devices/build.gradle.kts b/demo/many-devices/build.gradle.kts
index 7248e42..5743341 100644
--- a/demo/many-devices/build.gradle.kts
+++ b/demo/many-devices/build.gradle.kts
@@ -9,17 +9,14 @@ 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("space.kscience:plotlykt-server:0.6.0")
+    implementation(spclibs.ktor.client.cio)
+    implementation(libs.plotlykt.server)
     implementation(spclibs.logback.classic)
 }
 
@@ -29,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/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..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
@@ -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,18 +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.Plotly
-import space.kscience.plotly.bar
+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
@@ -49,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")
 
@@ -65,65 +68,68 @@ 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)
+    }
+
+    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) {
+            plot(renderer = container, config = PlotlyConfig { saveAsSvg() }) {
                 layout {
-                    title = "Latest event"
+//                    title = "Latest event"
+
                     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.inWholeMilliseconds / 1000.0 + 0.0001 }
-                            }
-                        }
-                    }
-                }
+                traces(trace)
             }
         }
     }
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/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..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
@@ -4,15 +4,14 @@ 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
 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
@@ -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
@@ -49,16 +48,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 +67,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
@@ -89,20 +88,20 @@ 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 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(get(channel) ?: DEFAULT_CHANNEL)
+            readChannelData(getOrRead(channel))
         })
 
-        val error by logicalProperty(MetaConverter.string)
+        val error by property(MetaConverter.string)
 
 
-        override fun MksPdr900Device.onClose() {
+        override suspend fun MksPdr900Device.onClose() {
             if (portDelegate.isInitialized()) {
-                port.close()
+                port.stop()
             }
         }
     }
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/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/demo/motors/build.gradle.kts b/demo/motors/build.gradle.kts
index 8626c72..774df46 100644
--- a/demo/motors/build.gradle.kts
+++ b/demo/motors/build.gradle.kts
@@ -1,18 +1,7 @@
 plugins {
     id("space.kscience.gradle.jvm")
-    application
-    id("org.openjfx.javafxplugin")
-}
-
-//TODO to be moved to a separate project
-
-javafx {
-    version = "17"
-    modules = listOf("javafx.controls")
-}
-
-application{
-    mainClass.set("ru.mipt.npm.devices.pimotionmaster.PiMotionMasterAppKt")
+    alias(spclibs.plugins.compose.compiler)
+    alias(spclibs.plugins.compose.jb)
 }
 
 kotlin{
@@ -25,5 +14,17 @@ val dataforgeVersion: String by extra
 dependencies {
     implementation(project(":controls-ports-ktor"))
     implementation(projects.controlsMagix)
-    implementation("no.tornado:tornadofx:1.7.20")
+
+    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..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
@@ -1,31 +1,195 @@
 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,
+) {
+    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)
+
+        Column {
+            Slider(
+                value = position.toFloat(),
+                enabled = false,
+                onValueChange = { },
+                valueRange = min..max
+            )
+            Slider(
+                value = targetPosition,
+                onValueChange = { newPosition ->
+                    targetPosition = newPosition
+                    scope.launch {
+                        axis.move(newPosition.toDouble())
+                    }
+                },
+                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 +198,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 4eff6c4..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
@@ -7,40 +7,37 @@ 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.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
+import space.kscience.controls.ports.withStringDelimiter
 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.names.NameToken
-import kotlin.collections.component1
-import kotlin.collections.component2
+import space.kscience.dataforge.meta.*
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.parseAsName
 import kotlin.time.Duration
 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) }
 
 
-    fun disconnect() {
-        runBlocking {
-            execute(disconnect)
-        }
+    suspend fun disconnect() {
+        execute(disconnect)
     }
 
     var timeoutValue: Duration = 200.milliseconds
@@ -51,20 +48,18 @@ 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()
         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()
@@ -87,7 +82,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()
         }
     }
@@ -100,14 +95,14 @@ 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")
                 }.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" }
@@ -138,7 +133,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
         }
@@ -157,13 +152,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) {
@@ -171,12 +166,12 @@ class PiMotionMasterDevice(
             }
             //Update port
             //address = portSpec.node
-            port = portFactory(portSpec, context)
-            updateLogical(connected, true)
+            port = portFactory(portSpec, context).apply { start() }
 //        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()) {
@@ -189,19 +184,19 @@ 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)
-                it.close()
+                it.stop()
             }
             port = null
-            updateLogical(connected, false)
+            propertyChanged(connected, false)
         }
 
 
         val timeout by mutableProperty(MetaConverter.duration, PiMotionMasterDevice::timeoutValue) {
-            info = "Timeout"
+            description = "Timeout"
         }
     }
 
@@ -241,12 +236,12 @@ class PiMotionMasterDevice(
             private fun axisBooleanProperty(
                 command: String,
                 descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-            ) = booleanProperty(
+            ) = mutableBooleanProperty(
                 read = {
                     readAxisBoolean("$command?")
                 },
-                write = {
-                    writeAxisBoolean(command, it)
+                write = { _, value ->
+                    writeAxisBoolean(command, value)
                 },
                 descriptorBuilder = descriptorBuilder
             )
@@ -254,12 +249,12 @@ 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")
                 },
-                write = { newValue ->
+                write = { _, newValue ->
                     mm.send(command, axisId, newValue.toString())
                     mm.failIfError()
                 },
@@ -267,7 +262,7 @@ class PiMotionMasterDevice(
             )
 
             val enabled by axisBooleanProperty("EAX") {
-                info = "Motor enable state."
+                description = "Motor enable state."
             }
 
             val halt by unitAction {
@@ -275,20 +270,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 +293,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/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..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
@@ -5,14 +5,16 @@ 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.api.LifecycleState
+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,34 +42,43 @@ 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 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) : 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 fun close() {
-        respondJob.cancel()
-        super.close()
+    override val lifecycleState: LifecycleState
+        get() = if(respondJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
+
+    override suspend fun stop() {
+        respondJob?.cancel()
+        super.stop()
     }
 }
 
@@ -78,7 +89,7 @@ class PiMotionMasterVirtualDevice(
     scope: CoroutineScope = context,
 ) : VirtualDevice(scope), ContextAware {
 
-    init {
+    override suspend fun start() {
         //add asynchronous send logic here
     }
 
@@ -102,9 +113,11 @@ class PiMotionMasterVirtualDevice(
                         abs(distance) < proposedStep -> {
                             position = targetPosition
                         }
+
                         targetPosition > position -> {
                             position += proposedStep
                         }
+
                         else -> {
                             position -= proposedStep
                         }
@@ -180,8 +193,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 +210,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 +253,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 +276,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/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 0328631..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: WritableDevicePropertySpec<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) {
-                    set(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 021dff5..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
 
@@ -18,41 +20,40 @@ 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.receiving().collect {
+                val sendJob = virtualDevice.subscribe().onEach {
                     //println("Sending: ${it.decodeToString()}")
                     output.writeAvailable(it)
                     output.flush()
-                }
-            }
+                }.launchIn(this)
 
-            try {
-                while (isActive) {
-                    input.read { buffer ->
-                        val array = buffer.moveToByteArray()
-                        launch {
-                            virtualDevice.send(array)
+
+                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")
                 }
-            } catch (e: Throwable) {
-                e.printStackTrace()
-                sendJob.cancel()
-                socket.close()
-            } finally {
-                println("Socket closed")
             }
-
         }
     }
 }
diff --git a/demo/notebooks/constructor.ipynb b/demo/notebooks/constructor.ipynb
new file mode 100644
index 0000000..3b4d73e
--- /dev/null
+++ b/demo/notebooks/constructor.ipynb
@@ -0,0 +1,195 @@
+{
+ "cells": [
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {},
+   "outputs": [],
+   "source": [
+    "//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",
+    "    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"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false
+   },
+   "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))"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false
+   },
+   "outputs": [],
+   "source": [
+    "\n",
+    "val job = device.run {\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",
+    "}"
+   ]
+  },
+  {
+   "cell_type": "code",
+   "execution_count": null,
+   "metadata": {
+    "collapsed": false
+   },
+   "outputs": [],
+   "source": [
+    "val maxAge = 10.seconds\n",
+    "\n",
+    "\n",
+    "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",
+    "    }\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",
+    "}"
+   ]
+  },
+  {
+   "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",
+   "execution_count": null,
+   "outputs": [],
+   "source": [],
+   "metadata": {
+    "collapsed": false
+   }
+  }
+ ],
+ "metadata": {
+  "kernelspec": {
+   "display_name": "Kotlin",
+   "language": "kotlin",
+   "name": "kotlin"
+  },
+  "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": 4
+}
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/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.
diff --git a/gradle.properties b/gradle.properties
index 5b956f1..99c39d0 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.14.10-kotlin-1.9.0
\ 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
new file mode 100644
index 0000000..daf1fd6
--- /dev/null
+++ b/gradle/libs.versions.toml
@@ -0,0 +1,91 @@
+[versions]
+
+dataforge = "0.9.0"
+rsocket = "0.15.4"
+xodus = "2.0.1"
+
+uuid = "0.8.0"
+
+fazecast = "2.10.3"
+
+tornadofx = "1.7.20"
+
+plotlykt = "0.7.2"
+
+logback = "1.2.11"
+
+hivemq = "1.3.1"
+
+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"
+
+plc4j = "0.12.0"
+
+visionforge = "0.4.2"
+
+[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" }
+
+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" }
+
+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" }
+
+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" }
+visionforge-compose-html = { module = "space.kscience:visionforge-compose-html", version.ref = "visionforge" }
+
+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
+
+[plugins]
+
+versions = "com.github.ben-manes.versions:0.51.0"
+versions-update = "nl.littlerobots.version-catalog-update:0.8.4"
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index fae0804..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.1.1-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-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==
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
+
+
+
diff --git a/magix/magix-api/README.md b/magix/magix-api/README.md
index ee00c53..0feb75d 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-api/build.gradle.kts b/magix/magix-api/build.gradle.kts
index 159989d..253f3e3 100644
--- a/magix/magix-api/build.gradle.kts
+++ b/magix/magix-api/build.gradle.kts
@@ -13,10 +13,15 @@ kscience {
     jvm()
     js()
     native()
+    wasm()
     useCoroutines()
     useSerialization{
         json()
     }
+
+    commonMain{
+        implementation(spclibs.atomicfu)
+    }
 }
 
 readme{
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-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..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
@@ -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 {
@@ -56,6 +56,6 @@ public suspend fun <T> MagixEndpoint.send(
         parentId = parentId,
         user = user
     )
-    broadcast(message)
+    send(message)
 }
 
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-java-endpoint/README.md b/magix/magix-java-endpoint/README.md
index abcaa6f..5b269a0 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-java-endpoint/build.gradle.kts b/magix/magix-java-endpoint/build.gradle.kts
index ff51835..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 {
@@ -14,22 +12,9 @@ description = """
 
 dependencies {
     implementation(project(":magix:magix-rsocket"))
-    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:${KScienceVersions.coroutinesVersion}")
+    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
diff --git a/magix/magix-mqtt/README.md b/magix/magix-mqtt/README.md
index 6c34fdc..3661749 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-mqtt/build.gradle.kts b/magix/magix-mqtt/build.gradle.kts
index e7037e6..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("com.hivemq:hivemq-mqtt-client:1.3.1")
-    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/README.md b/magix/magix-rabbit/README.md
index 7fc42ad..db0de2c 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
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-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-rsocket/README.md b/magix/magix-rsocket/README.md
index 799717d..b3cf1ba 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
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/README.md b/magix/magix-server/README.md
index 27d97e0..4e0968e 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-server/build.gradle.kts b/magix/magix-server/build.gradle.kts
index cb63049..28fe492 100644
--- a/magix/magix-server/build.gradle.kts
+++ b/magix/magix-server/build.gradle.kts
@@ -1,36 +1,37 @@
 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()
 
+val dataforgeVersion: String by rootProject.extra
+val ktorVersion: String  = space.kscience.gradle.KScienceVersions.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)
+    }
+
 }
 
-val dataforgeVersion: String by rootProject.extra
-val rsocketVersion: 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")
-
-    api("io.rsocket.kotlin:rsocket-ktor-server:$rsocketVersion")
-    api("io.rsocket.kotlin:rsocket-transport-ktor-tcp:$rsocketVersion")
-}
 
 readme{
     maturity = Maturity.EXPERIMENTAL
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 89%
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
index a00c33f..699a281 100644
--- 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
@@ -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 ->
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 65%
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
index dc197ad..a1edec5 100644
--- 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
@@ -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/main/kotlin/space/kscience/magix/server/server.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/server.kt
similarity index 96%
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
index 2396e25..aa26bee 100644
--- 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
@@ -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
     )
 
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
diff --git a/magix/magix-storage/README.md b/magix/magix-storage/README.md
index fa367b4..f8d3c09 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
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/magix/magix-storage/magix-storage-xodus/README.md b/magix/magix-storage/magix-storage-xodus/README.md
index b3d871b..3a4a469 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-storage/magix-storage-xodus/build.gradle.kts b/magix/magix-storage/magix-storage-xodus/build.gradle.kts
index ed1dc32..6c35cef 100644
--- a/magix/magix-storage/magix-storage-xodus/build.gradle.kts
+++ b/magix/magix-storage/magix-storage-xodus/build.gradle.kts
@@ -1,22 +1,24 @@
 plugins {
-    id("space.kscience.gradle.jvm")
+    id("space.kscience.gradle.mpp")
     `maven-publish`
 }
 
-val xodusVersion: String by rootProject.extra
-
 kscience {
+    jvm()
     useCoroutines()
-}
-
-dependencies {
-    api(projects.magix.magixStorage)
-    implementation("org.jetbrains.xodus:xodus-entity-store:$xodusVersion")
+    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-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)
             }
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-utils/build.gradle.kts b/magix/magix-utils/build.gradle.kts
new file mode 100644
index 0000000..9bec805
--- /dev/null
+++ b/magix/magix-utils/build.gradle.kts
@@ -0,0 +1,25 @@
+import space.kscience.gradle.Maturity
+
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+description = """
+    Common utilities and services for Magix endpoints.   
+""".trimIndent()
+
+kscience {
+    jvm()
+    js()
+    native()
+    useSerialization()
+    commonMain {
+        api(projects.magix.magixApi)
+        api(libs.dataforge.meta)
+    }
+}
+
+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/magix/magix-zmq/README.md b/magix/magix-zmq/README.md
index 1338cce..23c17c7 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.4.0-dev-7`.
 
 **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.4.0-dev-7")
 }
 ```
diff --git a/magix/magix-zmq/build.gradle.kts b/magix/magix-zmq/build.gradle.kts
index cf4ee9b..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.2")
+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
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 20ac44e..1c6b8b2 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -18,10 +18,13 @@ 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"
     }
 }
 
+plugins {
+    id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
+}
+
 dependencyResolutionManagement {
 
     val toolsVersion: String by extra
@@ -35,11 +38,25 @@ dependencyResolutionManagement {
     versionCatalogs {
         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")
+            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")
         }
     }
 }
 
 include(
+    ":simulation-kt",
     ":controls-core",
     ":controls-ports-ktor",
     ":controls-serial",
@@ -47,11 +64,17 @@ include(
     ":controls-server",
     ":controls-opcua",
     ":controls-modbus",
+    ":controls-plc4x",
 //    ":controls-mongo",
     ":controls-storage",
     ":controls-storage:controls-xodus",
+    ":controls-constructor",
+    ":controls-visualisation-compose",
+    ":controls-vision",
+    ":controls-jupyter",
     ":magix",
     ":magix:magix-api",
+    ":magix:magix-utils",
     ":magix:magix-server",
     ":magix:magix-rsocket",
     ":magix:magix-java-endpoint",
@@ -67,5 +90,7 @@ include(
     ":demo:car",
     ":demo:motors",
     ":demo:echo",
-    ":demo:mks-pdr900"
+    ":demo:mks-pdr900",
+    ":demo:constructor",
+    ":demo:device-collective"
 )
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
new file mode 100644
index 0000000..a80b1df
--- /dev/null
+++ b/simulation-kt/build.gradle.kts
@@ -0,0 +1,33 @@
+import space.kscience.gradle.Maturity
+
+plugins {
+    id("space.kscience.gradle.mpp")
+    `maven-publish`
+}
+
+kscience {
+    jvm()
+    js()
+    native()
+    wasm()
+    useCoroutines()
+    useContextReceivers()
+
+    commonMain {
+        api(spclibs.kotlinx.datetime)
+    }
+
+    jvmTest {
+        implementation(spclibs.logback.classic)
+    }
+}
+
+
+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
diff --git a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
new file mode 100644
index 0000000..0698b45
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt
@@ -0,0 +1,66 @@
+package space.kscience.simulation
+
+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
+
+/**
+ * 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>(
+    origin: E,
+    private val lookaheadInterval: Duration,
+    coroutineContext: CoroutineContext = EmptyCoroutineContext,
+    private val generator: suspend FlowCollector<E>.(E) -> Unit
+) : ProducerTimeline<E>(origin.time, coroutineContext) {
+
+    private val startEventFlow = MutableStateFlow(origin)
+
+    private data class EventWithOrigin<E : TimelineEvent>(val origin: E, val event: E) : TimelineEvent {
+        override val time: Instant get() = event.time
+    }
+
+    private val events: SharedFlow<E> = flow {
+        coroutineScope {
+            startEventFlow.collect { startEvent ->
+                emitAll(
+                    flow { generator(startEvent) }.takeWhile { startEvent == startEventFlow.value }.map {
+                        EventWithOrigin(startEvent, it)
+                    }
+                )
+            }
+        }
+    }.withTimeThreshold(
+        threshold = time.map { it + lookaheadInterval }
+    ).buffer(Channel.UNLIMITED).mapNotNull {
+        //a barrier to avoid leaking stale events after interruption from buffer
+        it.takeIf { it.origin == startEventFlow.value }?.event
+    }.shareIn(
+        scope = timelineScope,
+        started = SharingStarted.Lazily,
+    )
+
+    override fun events(): Flow<E> = events
+
+    public suspend fun interrupt(newStart: E) {
+        check(newStart.time >= time.value) {
+            "Can't interrupt generating timeline after observed event"
+        }
+        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
new file mode 100644
index 0000000..4614fbb
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt
@@ -0,0 +1,82 @@
+package space.kscience.simulation
+
+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
+
+
+public class MergedTimeline<E : TimelineEvent>(
+    private val timelines: List<Timeline<E>>,
+    coroutineContext: CoroutineContext = EmptyCoroutineContext
+) : Timeline<E> {
+
+    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/ProducerTimeline.kt b/simulation-kt/src/commonMain/kotlin/ProducerTimeline.kt
new file mode 100644
index 0000000..a201d92
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/ProducerTimeline.kt
@@ -0,0 +1,83 @@
+package space.kscience.simulation
+
+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 ProducerTimeline<E : TimelineEvent>(
+    protected var startTime: Instant,
+    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()
+
+    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()
+            }
+
+            private val mutex = Mutex()
+
+            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
+                }.collect {
+                    channel.send(it)
+                }
+            }
+
+            override fun close() {
+                collectJob.cancel()
+                observers.remove(this)
+            }
+
+        }
+        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/SharedTimeline.kt b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
new file mode 100644
index 0000000..051524e
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt
@@ -0,0 +1,31 @@
+package space.kscience.simulation
+
+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>(
+    startTime: Instant,
+    coroutineContext: CoroutineContext = EmptyCoroutineContext
+) : ProducerTimeline<E>(startTime, coroutineContext) {
+
+    private val events = MutableSharedFlow<E>(replay = Channel.UNLIMITED)
+
+    override fun events(): Flow<E> = events
+
+    /**
+     * Emit new event to the timeline
+     */
+    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.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
new file mode 100644
index 0000000..20a0746
--- /dev/null
+++ b/simulation-kt/src/commonMain/kotlin/Timeline.kt
@@ -0,0 +1,81 @@
+package space.kscience.simulation
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.datetime.Instant
+import kotlin.time.Duration
+
+
+public interface TimelineEvent {
+    public val time: Instant
+}
+
+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 subjective time of this observer (last observed time)
+     */
+    public val time: StateFlow<Instant>
+
+    /**
+     * Collect all uncollected events from [time] to [upTo].
+     *
+     * By default, collects all events.
+     */
+    public suspend fun collect(upTo: Instant = Instant.DISTANT_FUTURE)
+}
+
+/**
+ * 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
+ * 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> {
+    /**
+     * A subjective time of this timeline. The subjective time is the last observed time.
+     */
+    public val time: StateFlow<Instant>
+
+
+    /**
+     * 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.
+     */
+    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)
+}
+
+/**
+ * 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/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..b9f53dd
--- /dev/null
+++ b/simulation-kt/src/commonTest/kotlin/TimelineTests.kt
@@ -0,0 +1,48 @@
+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 = 1.seconds) {
+        val startTime = Instant.parse("2020-01-01T00:00:00.000Z")
+
+        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, Unit))
+            }
+        }
+
+        val result = mutableListOf<Duration>()
+
+        val observer = generation.observeEach {
+            println("Consume: ${it.time - startTime}")
+            result.add(it.time - startTime)
+        }
+
+        observer.collect(2.seconds)
+        println("First collection complete")
+        observer.collect(2.seconds)
+        println("Second collection complete")
+        println("Interrupt")
+        generation.interrupt(SimpleTimelineEvent(startTime + 6.seconds, Unit))
+        println("Collecting after interruption")
+        observer.collect(startTime + 6.seconds + 2.5.seconds)
+        println(result)
+        generation.close()
+
+    }
+}
\ No newline at end of file