diff --git a/.gitignore b/.gitignore
index ea7eb83..3bf252e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,11 @@
 # Created by .ignore support plugin (hsz.mobi)
 .idea/
 .gradle
+
 *.iws
+*.iml
+*.ipr
+
 out/
 build/
 !gradle-wrapper.jar
\ No newline at end of file
diff --git a/README.md b/README.md
index 37b1297..c2037e9 100644
--- a/README.md
+++ b/README.md
@@ -2,13 +2,12 @@
 
 # Controls.kt
 
-Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is
-based on DataForge, a software framework for automated data processing. 
+Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing.
 This repository contains a prototype of API and simple implementation 
 of a slow control system, including a demo.
 
 Controls.kt uses some concepts and modules of DataForge, 
-such as `Meta` (immutable tree-like structure) and `MetaItem` (which 
+such as `Meta` (immutable tree-like structure) and `Meta` (which 
 includes a scalar value, or a tree of values, easily convertable to/from JSON 
 if needed).  
 
@@ -37,12 +36,12 @@ Among other things, you can:
 ### `dataforge-control-core` module packages
 
 - `api` - defines API for device management. The main class here is 
-[`Device`](dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/Device.kt).
+[`Device`](controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/Device.kt).
 Generally, a Device has Properties that can be read and written. Also, some Actions
 can optionally be applied on a device (may or may not affect properties). 
 
 - `base` - contains baseline `Device` implementation 
-[`DeviceBase`](dataforge-control-core/src/commonMain/kotlin/hep/dataforge/control/base/DeviceBase.kt) 
+[`DeviceBase`](controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/DeviceBase.kt)
 and property implementation, including property asynchronous flows.
 
 - `controllers` - implements Message Controller that can be attached to the event bus, Message 
diff --git a/build.gradle.kts b/build.gradle.kts
index 23e34aa..6d7df71 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,18 +1,21 @@
-val dataforgeVersion by extra("0.1.8")
-val plotlyVersion by extra("0.2.0-dev-12")
-
-
-allprojects {
-    repositories {
-        mavenLocal()
-        maven("https://dl.bintray.com/pdvrieze/maven")
-        maven("http://maven.jzy3d.org/releases")
-        maven("https://kotlin.bintray.com/js-externals")
-    }
-
-    group = "hep.dataforge"
-    version = "0.0.1"
+plugins {
+    id("ru.mipt.npm.gradle.project")
 }
 
-val githubProject by extra("dataforge-control")
-val bintrayRepo by extra("dataforge")
\ No newline at end of file
+val dataforgeVersion: String by extra("0.5.1")
+val ktorVersion: String by extra(ru.mipt.npm.gradle.KScienceVersions.ktorVersion)
+val rsocketVersion by extra("0.13.1")
+
+allprojects {
+    group = "ru.mipt.npm"
+    version = "0.1.1"
+}
+
+ksciencePublish {
+    github("controls.kt")
+    space()
+}
+
+apiValidation {
+    validationDisabled = true
+}
\ No newline at end of file
diff --git a/controls-core/build.gradle.kts b/controls-core/build.gradle.kts
new file mode 100644
index 0000000..53769c9
--- /dev/null
+++ b/controls-core/build.gradle.kts
@@ -0,0 +1,24 @@
+plugins {
+    id("ru.mipt.npm.gradle.mpp")
+    `maven-publish`
+}
+
+val dataforgeVersion: String by rootProject.extra
+
+kscience {
+    useCoroutines("1.4.1")
+    useSerialization{
+        json()
+    }
+}
+
+kotlin {
+    sourceSets {
+        commonMain{
+            dependencies {
+                api("space.kscience:dataforge-io:$dataforgeVersion")
+                api(npm.kotlinx.datetime)
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/Device.kt
new file mode 100644
index 0000000..6298657
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/Device.kt
@@ -0,0 +1,100 @@
+package ru.mipt.npm.controls.api
+
+import io.ktor.utils.io.core.Closeable
+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 ru.mipt.npm.controls.api.Device.Companion.DEVICE_TARGET
+import space.kscience.dataforge.context.ContextAware
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.misc.Type
+import space.kscience.dataforge.names.Name
+
+
+/**
+ *  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 : Closeable, ContextAware, CoroutineScope {
+    /**
+     * List of supported property descriptors
+     */
+    public val propertyDescriptors: Collection<PropertyDescriptor>
+
+    /**
+     * List of supported action descriptors. Action is a request to the device that
+     * may or may not change the properties
+     */
+    public val actionDescriptors: Collection<ActionDescriptor>
+
+    /**
+     * Read physical state of property and update/push notifications if needed.
+     */
+    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.
+     */
+    public suspend fun writeProperty(propertyName: String, value: Meta)
+
+    /**
+     * A subscription-based [Flow] of [DeviceMessage] provided by device. The flow is guaranteed to be readable
+     * multiple times
+     */
+    public val messageFlow: Flow<DeviceMessage>
+
+    /**
+     * Send an action request and suspend caller while request is being processed.
+     * Could return null if request does not return a meaningful answer.
+     */
+    public suspend fun execute(action: String, argument: Meta? = null): Meta?
+
+    override fun close() {
+        cancel("The device is closed")
+    }
+
+    public companion object {
+        public const val DEVICE_TARGET: String = "device"
+    }
+}
+
+/**
+ * Get the logical state of property or suspend to read the physical value.
+ */
+public suspend fun Device.getOrReadProperty(propertyName: String): Meta =
+    getProperty(propertyName) ?: readProperty(propertyName)
+
+/**
+ * Get a snapshot of logical state of the device
+ *
+ * TODO currently this 
+ */
+public fun Device.getProperties(): Meta = Meta {
+    for (descriptor in propertyDescriptors) {
+        setMeta(Name.parse(descriptor.name), 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)
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/DeviceHub.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/DeviceHub.kt
new file mode 100644
index 0000000..aba8517
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/DeviceHub.kt
@@ -0,0 +1,75 @@
+package ru.mipt.npm.controls.api
+
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.names.*
+import space.kscience.dataforge.provider.Provider
+
+/**
+ * A hub that could locate multiple devices and redirect actions to them
+ */
+public interface DeviceHub : Provider {
+    public val devices: Map<NameToken, 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) {
+            return 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)
+                    }
+                }
+            }
+        } else {
+            throw IllegalArgumentException("Target $target is not supported for $this")
+        }
+    }
+
+    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 operator fun DeviceHub.get(name: Name): Device =
+    getOrNull(name) ?: error("Device with name $name not found in $this")
+
+public fun DeviceHub.getOrNull(nameString: String): Device? = getOrNull(Name.parse(nameString))
+
+public operator fun DeviceHub.get(nameString: String): Device =
+    getOrNull(nameString) ?: error("Device with name $nameString not found in $this")
+
+public suspend fun DeviceHub.readProperty(deviceName: Name, propertyName: String): Meta =
+    this[deviceName].readProperty(propertyName)
+
+public suspend fun DeviceHub.writeProperty(deviceName: Name, propertyName: String, value: Meta) {
+    this[deviceName].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
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/DeviceMessage.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/DeviceMessage.kt
new file mode 100644
index 0000000..f919f1e
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/DeviceMessage.kt
@@ -0,0 +1,222 @@
+package ru.mipt.npm.controls.api
+
+import kotlinx.datetime.Clock
+import kotlinx.datetime.Instant
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.decodeFromJsonElement
+import kotlinx.serialization.json.encodeToJsonElement
+import space.kscience.dataforge.io.SimpleEnvelope
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.toJson
+import space.kscience.dataforge.meta.toMeta
+import space.kscience.dataforge.names.Name
+
+@Serializable
+public sealed class DeviceMessage {
+    public abstract val sourceDevice: Name?
+    public abstract val targetDevice: Name?
+    public abstract val comment: String?
+    public abstract val time: Instant?
+
+    /**
+     * Update the source device name for composition. If the original name is null, resulting name is also null.
+     */
+    public abstract fun changeSource(block: (Name) -> Name): DeviceMessage
+
+    public companion object {
+        public fun error(
+            cause: Throwable,
+            sourceDevice: Name,
+            targetDevice: Name? = null,
+        ): DeviceErrorMessage = DeviceErrorMessage(
+            errorMessage = cause.message,
+            errorType = cause::class.simpleName,
+            errorStackTrace = cause.stackTraceToString(),
+            sourceDevice = sourceDevice,
+            targetDevice = targetDevice
+        )
+
+        public fun fromMeta(meta: Meta): DeviceMessage = Json.decodeFromJsonElement(meta.toJson())
+    }
+}
+
+/**
+ * Notify that property is changed. [sourceDevice] is mandatory.
+ * [property] corresponds to property name.
+ *
+ */
+@Serializable
+@SerialName("property.changed")
+public data class PropertyChangedMessage(
+    public val property: String,
+    public val value: Meta,
+    override val sourceDevice: Name = Name.EMPTY,
+    override val targetDevice: Name? = null,
+    override val comment: String? = null,
+    override val time: Instant? = Clock.System.now()
+) : DeviceMessage(){
+    override fun changeSource(block: (Name) -> Name):DeviceMessage  = copy(sourceDevice = block(sourceDevice))
+}
+
+/**
+ * A command to set or invalidate property. [targetDevice] is mandatory.
+ */
+@Serializable
+@SerialName("property.set")
+public data class PropertySetMessage(
+    public val property: String,
+    public val value: Meta?,
+    override val sourceDevice: Name? = null,
+    override val targetDevice: Name,
+    override val comment: String? = null,
+    override val time: Instant? = Clock.System.now()
+) : DeviceMessage(){
+    override fun changeSource(block: (Name) -> Name):DeviceMessage  = copy(sourceDevice = sourceDevice?.let(block))
+}
+
+/**
+ * A command to request property value asynchronously. [targetDevice] is mandatory.
+ * The property value should be returned asynchronously via [PropertyChangedMessage].
+ */
+@Serializable
+@SerialName("property.get")
+public data class PropertyGetMessage(
+    public val property: String,
+    override val sourceDevice: Name? = null,
+    override val targetDevice: Name,
+    override val comment: String? = null,
+    override val time: Instant? = Clock.System.now()
+) : DeviceMessage(){
+    override fun changeSource(block: (Name) -> Name):DeviceMessage  = copy(sourceDevice = sourceDevice?.let(block))
+}
+
+/**
+ * Request device description. The result is returned in form of [DescriptionMessage]
+ */
+@Serializable
+@SerialName("description.get")
+public data class GetDescriptionMessage(
+    override val sourceDevice: Name? = null,
+    override val targetDevice: Name,
+    override val comment: String? = null,
+    override val time: Instant? = Clock.System.now()
+) : DeviceMessage(){
+    override fun changeSource(block: (Name) -> Name):DeviceMessage  = copy(sourceDevice = sourceDevice?.let(block))
+}
+
+/**
+ * The full device description message
+ */
+@Serializable
+@SerialName("description")
+public data class DescriptionMessage(
+    val description: Meta,
+    override val sourceDevice: Name,
+    override val targetDevice: Name? = null,
+    override val comment: String? = null,
+    override val time: Instant? = Clock.System.now()
+) : DeviceMessage(){
+    override fun changeSource(block: (Name) -> Name):DeviceMessage  = copy(sourceDevice = block(sourceDevice))
+}
+
+/**
+ * A request to execute an action. [targetDevice] is mandatory
+ */
+@Serializable
+@SerialName("action.execute")
+public data class ActionExecuteMessage(
+    public val action: String,
+    public val argument: Meta?,
+    override val sourceDevice: Name? = null,
+    override val targetDevice: Name,
+    override val comment: String? = null,
+    override val time: Instant? = Clock.System.now()
+) : DeviceMessage(){
+    override fun changeSource(block: (Name) -> Name):DeviceMessage  = copy(sourceDevice = sourceDevice?.let(block))
+}
+
+/**
+ * Asynchronous action result. [sourceDevice] is mandatory
+ */
+@Serializable
+@SerialName("action.result")
+public data class ActionResultMessage(
+    public val action: String,
+    public val result: Meta?,
+    override val sourceDevice: Name,
+    override val targetDevice: Name? = null,
+    override val comment: String? = null,
+    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.
+ */
+@Serializable
+@SerialName("binary.notification")
+public data class BinaryNotificationMessage(
+    val binaryID: String,
+    override val sourceDevice: Name,
+    override val targetDevice: Name? = null,
+    override val comment: String? = null,
+    override val time: Instant? = Clock.System.now()
+) : DeviceMessage(){
+    override fun changeSource(block: (Name) -> Name):DeviceMessage  = copy(sourceDevice = block(sourceDevice))
+}
+
+/**
+ * The message states that the message is received, but no meaningful response is produced.
+ * This message could be used for a heartbeat.
+ */
+@Serializable
+@SerialName("empty")
+public data class EmptyDeviceMessage(
+    override val sourceDevice: Name? = null,
+    override val targetDevice: Name? = null,
+    override val comment: String? = null,
+    override val time: Instant? = Clock.System.now()
+) : DeviceMessage(){
+    override fun changeSource(block: (Name) -> Name):DeviceMessage  = copy(sourceDevice = sourceDevice?.let(block))
+}
+
+/**
+ * Information log message
+ */
+@Serializable
+@SerialName("log")
+public data class DeviceLogMessage(
+    val message: String,
+    val data: Meta? = null,
+    override val sourceDevice: Name? = null,
+    override val targetDevice: Name? = null,
+    override val comment: String? = null,
+    override val time: Instant? = Clock.System.now()
+) : DeviceMessage(){
+    override fun changeSource(block: (Name) -> Name):DeviceMessage  = copy(sourceDevice = sourceDevice?.let(block))
+}
+
+/**
+ * The evaluation of the message produced a service error
+ */
+@Serializable
+@SerialName("error")
+public data class DeviceErrorMessage(
+    public val errorMessage: String?,
+    public val errorType: String? = null,
+    public val errorStackTrace: String? = null,
+    override val sourceDevice: Name,
+    override val targetDevice: Name? = null,
+    override val comment: String? = null,
+    override val time: Instant? = Clock.System.now()
+) : DeviceMessage(){
+    override fun changeSource(block: (Name) -> Name):DeviceMessage  = copy(sourceDevice = block(sourceDevice))
+}
+
+
+public fun DeviceMessage.toMeta(): Meta = Json.encodeToJsonElement(this).toMeta()
+
+public fun DeviceMessage.toEnvelope(): SimpleEnvelope = SimpleEnvelope(toMeta(), null)
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/Socket.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/Socket.kt
new file mode 100644
index 0000000..eda8942
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/Socket.kt
@@ -0,0 +1,34 @@
+package ru.mipt.npm.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.flow.collect
+import kotlinx.coroutines.launch
+
+/**
+ * A generic bi-directional 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/ru/mipt/npm/controls/api/descriptors.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/descriptors.kt
new file mode 100644
index 0000000..1e70962
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/api/descriptors.kt
@@ -0,0 +1,32 @@
+package ru.mipt.npm.controls.api
+
+import kotlinx.serialization.Serializable
+import space.kscience.dataforge.meta.descriptors.MetaDescriptor
+import space.kscience.dataforge.meta.descriptors.MetaDescriptorBuilder
+
+//TODO add proper builders
+
+/**
+ * A descriptor for property
+ */
+@Serializable
+public class PropertyDescriptor(
+    public val name: String,
+    public var info: String? = null,
+    public var metaDescriptor: MetaDescriptor = MetaDescriptor(),
+    public var readable: Boolean = true,
+    public var writable: Boolean = false
+)
+
+public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Unit){
+    metaDescriptor = MetaDescriptor(block)
+}
+
+/**
+ * A descriptor for property
+ */
+@Serializable
+public class ActionDescriptor(public val name: String) {
+    public var info: String? = null
+}
+
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/DeviceAction.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/DeviceAction.kt
new file mode 100644
index 0000000..b75b79f
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/DeviceAction.kt
@@ -0,0 +1,10 @@
+package ru.mipt.npm.controls.base
+
+import ru.mipt.npm.controls.api.ActionDescriptor
+import space.kscience.dataforge.meta.Meta
+
+public interface DeviceAction {
+    public val name: String
+    public val descriptor: ActionDescriptor
+    public suspend operator fun invoke(arg: Meta? = null): Meta?
+}
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/DeviceBase.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/DeviceBase.kt
new file mode 100644
index 0000000..52d37bf
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/DeviceBase.kt
@@ -0,0 +1,252 @@
+package ru.mipt.npm.controls.base
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import ru.mipt.npm.controls.api.*
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.misc.DFExperimental
+import kotlin.collections.set
+import kotlin.coroutines.CoroutineContext
+
+//TODO move to DataForge-core
+@DFExperimental
+public data class LogEntry(val content: String, val priority: Int = 0)
+
+
+@OptIn(ExperimentalCoroutinesApi::class)
+private open class BasicReadOnlyDeviceProperty(
+    val device: DeviceBase,
+    override val name: String,
+    default: Meta?,
+    override val descriptor: PropertyDescriptor,
+    private val getter: suspend (before: Meta?) -> Meta,
+) : ReadOnlyDeviceProperty {
+
+    override val scope: CoroutineScope get() = device
+
+    private val state: MutableStateFlow<Meta?> = MutableStateFlow(default)
+    override val value: Meta? get() = state.value
+
+    override suspend fun invalidate() {
+        state.value = null
+    }
+
+    override fun updateLogical(item: Meta) {
+        state.value = item
+        scope.launch {
+            device.sharedMessageFlow.emit(
+                PropertyChangedMessage(
+                    property = name,
+                    value = item,
+                )
+            )
+        }
+    }
+
+    override suspend fun read(force: Boolean): Meta {
+        //backup current value
+        val currentValue = value
+        return if (force || currentValue == null) {
+            //all device operations should be run on device context
+            //propagate error, but do not fail scope
+            val res = withContext(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) {
+                getter(currentValue)
+            }
+            updateLogical(res)
+            res
+        } else {
+            currentValue
+        }
+    }
+
+    override fun flow(): StateFlow<Meta?> = state
+}
+
+
+@OptIn(ExperimentalCoroutinesApi::class)
+private class BasicDeviceProperty(
+    device: DeviceBase,
+    name: String,
+    default: Meta?,
+    descriptor: PropertyDescriptor,
+    getter: suspend (Meta?) -> Meta,
+    private val setter: suspend (oldValue: Meta?, newValue: Meta) -> Meta?,
+) : BasicReadOnlyDeviceProperty(device, name, default, descriptor, getter), DeviceProperty {
+
+    override var value: Meta?
+        get() = super.value
+        set(value) {
+            scope.launch {
+                if (value == null) {
+                    invalidate()
+                } else {
+                    write(value)
+                }
+            }
+        }
+
+    private val writeLock = Mutex()
+
+    override suspend fun write(item: Meta) {
+        writeLock.withLock {
+            //fast return if value is not changed
+            if (item == value) return@withLock
+            val oldValue = value
+            //all device operations should be run on device context
+            withContext(scope.coroutineContext + SupervisorJob(scope.coroutineContext[Job])) {
+                setter(oldValue, item)?.let {
+                    updateLogical(it)
+                }
+            }
+        }
+    }
+}
+
+/**
+ * Baseline implementation of [Device] interface
+ */
+@Suppress("EXPERIMENTAL_API_USAGE")
+public abstract class DeviceBase(final override val context: Context) : Device {
+
+    override val coroutineContext: CoroutineContext =
+        context.coroutineContext + SupervisorJob(context.coroutineContext[Job])
+
+    private val _properties = HashMap<String, ReadOnlyDeviceProperty>()
+    public val properties: Map<String, ReadOnlyDeviceProperty> get() = _properties
+    private val _actions = HashMap<String, DeviceAction>()
+    public val actions: Map<String, DeviceAction> get() = _actions
+
+    internal val sharedMessageFlow = MutableSharedFlow<DeviceMessage>()
+
+    override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow
+    private val sharedLogFlow = MutableSharedFlow<LogEntry>()
+
+    /**
+     * The [SharedFlow] of log messages
+     */
+    @DFExperimental
+    public val logFlow: SharedFlow<LogEntry>
+        get() = sharedLogFlow
+
+    protected suspend fun log(message: String, priority: Int = 0) {
+        sharedLogFlow.emit(LogEntry(message, priority))
+    }
+
+    override val propertyDescriptors: Collection<PropertyDescriptor>
+        get() = _properties.values.map { it.descriptor }
+
+    override val actionDescriptors: Collection<ActionDescriptor>
+        get() = _actions.values.map { it.descriptor }
+
+    private fun <P : ReadOnlyDeviceProperty> registerProperty(name: String, property: P) {
+        if (_properties.contains(name)) error("Property with name $name already registered")
+        _properties[name] = property
+    }
+
+    internal fun registerAction(name: String, action: DeviceAction) {
+        if (_actions.contains(name)) error("Action with name $name already registered")
+        _actions[name] = action
+    }
+
+    override suspend fun readProperty(propertyName: String): Meta =
+        (_properties[propertyName] ?: error("Property with name $propertyName not defined")).read()
+
+    override fun getProperty(propertyName: String): Meta? =
+        (_properties[propertyName] ?: error("Property with name $propertyName not defined")).value
+
+    override suspend fun invalidate(propertyName: String) {
+        (_properties[propertyName] ?: error("Property with name $propertyName not defined")).invalidate()
+    }
+
+    override suspend fun writeProperty(propertyName: String, value: Meta) {
+        (_properties[propertyName] as? DeviceProperty ?: error("Property with name $propertyName not defined")).write(
+            value
+        )
+    }
+
+    override suspend fun execute(action: String, argument: Meta?): Meta? =
+        (_actions[action] ?: error("Request with name $action not defined")).invoke(argument)
+
+    /**
+     * Create a bound read-only property with given [getter]
+     */
+    public fun createReadOnlyProperty(
+        name: String,
+        default: Meta?,
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        getter: suspend (Meta?) -> Meta,
+    ): ReadOnlyDeviceProperty {
+        val property = BasicReadOnlyDeviceProperty(
+            this,
+            name,
+            default,
+            PropertyDescriptor(name).apply(descriptorBuilder),
+            getter
+        )
+        registerProperty(name, property)
+        return property
+    }
+
+
+    /**
+     * Create a bound mutable property with given [getter] and [setter]
+     */
+    internal fun createMutableProperty(
+        name: String,
+        default: Meta?,
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        getter: suspend (Meta?) -> Meta,
+        setter: suspend (oldValue: Meta?, newValue: Meta) -> Meta?,
+    ): DeviceProperty {
+        val property = BasicDeviceProperty(
+            this,
+            name,
+            default,
+            PropertyDescriptor(name).apply(descriptorBuilder),
+            getter,
+            setter
+        )
+        registerProperty(name, property)
+        return property
+    }
+
+    /**
+     * A stand-alone action
+     */
+    private inner class BasicDeviceAction(
+        override val name: String,
+        override val descriptor: ActionDescriptor,
+        private val block: suspend (Meta?) -> Meta?,
+    ) : DeviceAction {
+        override suspend fun invoke(arg: Meta?): Meta? =
+            withContext(coroutineContext) {
+                block(arg)
+            }
+    }
+
+    /**
+     * Create a new bound action
+     */
+    internal fun createAction(
+        name: String,
+        descriptorBuilder: ActionDescriptor.() -> Unit = {},
+        block: suspend (Meta?) -> Meta?,
+    ): DeviceAction {
+        val action = BasicDeviceAction(name, ActionDescriptor(name).apply(descriptorBuilder), block)
+        registerAction(name, action)
+        return action
+    }
+
+    public companion object {
+
+    }
+}
+
+
+
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/DeviceProperty.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/DeviceProperty.kt
similarity index 53%
rename from dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/DeviceProperty.kt
rename to controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/DeviceProperty.kt
index d100b9c..5f67acf 100644
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/DeviceProperty.kt
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/DeviceProperty.kt
@@ -1,60 +1,60 @@
-package hep.dataforge.control.base
+package ru.mipt.npm.controls.base
 
-import hep.dataforge.control.api.PropertyDescriptor
-import hep.dataforge.meta.MetaItem
 import kotlinx.coroutines.*
 import kotlinx.coroutines.flow.Flow
+import ru.mipt.npm.controls.api.PropertyDescriptor
+import space.kscience.dataforge.meta.Meta
 import kotlin.time.Duration
 
 /**
  * Read-only device property
  */
-interface ReadOnlyDeviceProperty {
+public interface ReadOnlyDeviceProperty {
     /**
      * Property name, should be unique in device
      */
-    val name: String
+    public val name: String
 
     /**
      * Property descriptor
      */
-    val descriptor: PropertyDescriptor
+    public val descriptor: PropertyDescriptor
 
-    val scope: CoroutineScope
+    public val scope: CoroutineScope
 
     /**
      * Erase logical value and force re-read from device on next [read]
      */
-    suspend fun invalidate()
+    public suspend fun invalidate()
+
+    /**
+     * Directly update property logical value and notify listener without writing it to device
+     */
+    public fun updateLogical(item: Meta)
 
-//    /**
-//     * Update property logical value and notify listener without writing it to device
-//     */
-//    suspend fun update(item: MetaItem<*>)
-//
     /**
      *  Get cached value and return null if value is invalid or not initialized
      */
-    val value: MetaItem<*>?
+    public val value: Meta?
 
     /**
      * Read value either from cache if cache is valid or directly from physical device.
-     * If [force], reread
+     * If [force], reread from physical state even if the logical state is set.
      */
-    suspend fun read(force: Boolean = false): MetaItem<*>
+    public suspend fun read(force: Boolean = false): Meta
 
     /**
      * The [Flow] representing future logical states of the property.
      * Produces null when the state is invalidated
      */
-    fun flow(): Flow<MetaItem<*>?>
+    public fun flow(): Flow<Meta?>
 }
 
 
 /**
  * Launch recurring force re-read job on a property scope with given [duration] between reads.
  */
-fun ReadOnlyDeviceProperty.readEvery(duration: Duration): Job = scope.launch {
+public fun ReadOnlyDeviceProperty.readEvery(duration: Duration): Job = scope.launch {
     while (isActive) {
         read(true)
         delay(duration)
@@ -64,11 +64,11 @@ fun ReadOnlyDeviceProperty.readEvery(duration: Duration): Job = scope.launch {
 /**
  * A writeable device property with non-suspended write
  */
-interface DeviceProperty : ReadOnlyDeviceProperty {
-    override var value: MetaItem<*>?
+public interface DeviceProperty : ReadOnlyDeviceProperty {
+    override var value: Meta?
 
     /**
      * Write value to physical device. Invalidates logical value, but does not update it automatically
      */
-    suspend fun write(item: MetaItem<*>)
+    public suspend fun write(item: Meta)
 }
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/TypedDeviceProperty.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/TypedDeviceProperty.kt
new file mode 100644
index 0000000..b783fe2
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/TypedDeviceProperty.kt
@@ -0,0 +1,58 @@
+package ru.mipt.npm.controls.base
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.transformations.MetaConverter
+
+/**
+ * A type-safe wrapper on top of read-only property
+ */
+public open class TypedReadOnlyDeviceProperty<T : Any>(
+    private val property: ReadOnlyDeviceProperty,
+    protected val converter: MetaConverter<T>,
+) : ReadOnlyDeviceProperty by property {
+
+    public fun updateLogical(obj: T) {
+        property.updateLogical(converter.objectToMeta(obj))
+    }
+
+    public open val typedValue: T? get() = value?.let { converter.metaToObject(it) }
+
+    public suspend fun readTyped(force: Boolean = false): T {
+        val meta = read(force)
+        return converter.metaToObject(meta)
+            ?: error("Meta $meta could not be converted by $converter")
+    }
+
+    public fun flowTyped(): Flow<T?> = flow().map { it?.let { converter.metaToObject(it) } }
+}
+
+/**
+ * A type-safe wrapper for a read-write device property
+ */
+public class TypedDeviceProperty<T : Any>(
+    private val property: DeviceProperty,
+    converter: MetaConverter<T>,
+) : TypedReadOnlyDeviceProperty<T>(property, converter), DeviceProperty {
+
+    override var value: Meta?
+        get() = property.value
+        set(arg) {
+            property.value = arg
+        }
+
+    public override var typedValue: T?
+        get() = value?.let { converter.metaToObject(it) }
+        set(arg) {
+            property.value = arg?.let { converter.objectToMeta(arg) }
+        }
+
+    override suspend fun write(item: Meta) {
+        property.write(item)
+    }
+
+    public suspend fun write(obj: T) {
+        property.write(converter.objectToMeta(obj))
+    }
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/actionDelegates.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/actionDelegates.kt
new file mode 100644
index 0000000..452e5a1
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/actionDelegates.kt
@@ -0,0 +1,58 @@
+package ru.mipt.npm.controls.base
+
+import ru.mipt.npm.controls.api.ActionDescriptor
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MutableMeta
+import space.kscience.dataforge.values.Value
+import kotlin.properties.PropertyDelegateProvider
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+
+private fun <D : DeviceBase> D.provideAction(): ReadOnlyProperty<D, DeviceAction> =
+    ReadOnlyProperty { _: D, property: KProperty<*> ->
+        val name = property.name
+        return@ReadOnlyProperty actions[name]!!
+    }
+
+public typealias ActionDelegate = ReadOnlyProperty<DeviceBase, DeviceAction>
+
+private class ActionProvider<D : DeviceBase>(
+    val owner: D,
+    val descriptorBuilder: ActionDescriptor.() -> Unit = {},
+    val block: suspend (Meta?) -> Meta?,
+) : PropertyDelegateProvider<D, ActionDelegate> {
+    override operator fun provideDelegate(thisRef: D, property: KProperty<*>): ActionDelegate {
+        val name = property.name
+        owner.createAction(name, descriptorBuilder, block)
+        return owner.provideAction()
+    }
+}
+
+public fun DeviceBase.requesting(
+    descriptorBuilder: ActionDescriptor.() -> Unit = {},
+    action: suspend (Meta?) -> Meta?,
+): PropertyDelegateProvider<DeviceBase, ActionDelegate> = ActionProvider(this, descriptorBuilder, action)
+
+public fun <D : DeviceBase> D.requestingValue(
+    descriptorBuilder: ActionDescriptor.() -> Unit = {},
+    action: suspend (Meta?) -> Any?,
+): PropertyDelegateProvider<D, ActionDelegate> = ActionProvider(this, descriptorBuilder) {
+    val res = action(it)
+    Meta(Value.of(res))
+}
+
+public fun <D : DeviceBase> D.requestingMeta(
+    descriptorBuilder: ActionDescriptor.() -> Unit = {},
+    action: suspend MutableMeta.(Meta?) -> Unit,
+): PropertyDelegateProvider<D, ActionDelegate> = ActionProvider(this, descriptorBuilder) {
+    Meta { action(it) }
+}
+
+public fun DeviceBase.acting(
+    descriptorBuilder: ActionDescriptor.() -> Unit = {},
+    action: suspend (Meta?) -> Unit,
+): PropertyDelegateProvider<DeviceBase, ActionDelegate> = ActionProvider(this, descriptorBuilder) {
+    action(it)
+    null
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/devicePropertyDelegates.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/devicePropertyDelegates.kt
new file mode 100644
index 0000000..0f47204
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/devicePropertyDelegates.kt
@@ -0,0 +1,283 @@
+package ru.mipt.npm.controls.base
+
+import ru.mipt.npm.controls.api.PropertyDescriptor
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MutableMeta
+import space.kscience.dataforge.meta.boolean
+import space.kscience.dataforge.meta.double
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.values.Null
+import space.kscience.dataforge.values.Value
+import space.kscience.dataforge.values.asValue
+import kotlin.properties.PropertyDelegateProvider
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KProperty
+
+private fun <D : DeviceBase> D.provideProperty(name: String): ReadOnlyProperty<D, ReadOnlyDeviceProperty> =
+    ReadOnlyProperty { _: D, _: KProperty<*> ->
+        return@ReadOnlyProperty properties.getValue(name)
+    }
+
+private fun <D : DeviceBase, T : Any> D.provideProperty(
+    name: String,
+    converter: MetaConverter<T>,
+): ReadOnlyProperty<D, TypedReadOnlyDeviceProperty<T>> =
+    ReadOnlyProperty { _: D, _: KProperty<*> ->
+        return@ReadOnlyProperty TypedReadOnlyDeviceProperty(properties.getValue(name), converter)
+    }
+
+
+public typealias ReadOnlyPropertyDelegate = ReadOnlyProperty<DeviceBase, ReadOnlyDeviceProperty>
+public typealias TypedReadOnlyPropertyDelegate<T> = ReadOnlyProperty<DeviceBase, TypedReadOnlyDeviceProperty<T>>
+
+private class ReadOnlyDevicePropertyProvider<D : DeviceBase>(
+    val owner: D,
+    val default: Meta?,
+    val descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    private val getter: suspend (Meta?) -> Meta,
+) : PropertyDelegateProvider<D, ReadOnlyPropertyDelegate> {
+
+    override operator fun provideDelegate(thisRef: D, property: KProperty<*>): ReadOnlyPropertyDelegate {
+        val name = property.name
+        owner.createReadOnlyProperty(name, default, descriptorBuilder, getter)
+        return owner.provideProperty(name)
+    }
+}
+
+private class TypedReadOnlyDevicePropertyProvider<D : DeviceBase, T : Any>(
+    val owner: D,
+    val default: Meta?,
+    val converter: MetaConverter<T>,
+    val descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    private val getter: suspend (Meta?) -> Meta,
+) : PropertyDelegateProvider<D, TypedReadOnlyPropertyDelegate<T>> {
+
+    override operator fun provideDelegate(thisRef: D, property: KProperty<*>): TypedReadOnlyPropertyDelegate<T> {
+        val name = property.name
+        owner.createReadOnlyProperty(name, default, descriptorBuilder, getter)
+        return owner.provideProperty(name, converter)
+    }
+}
+
+public fun DeviceBase.reading(
+    default: Meta? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    getter: suspend (Meta?) -> Meta,
+): PropertyDelegateProvider<DeviceBase, ReadOnlyPropertyDelegate> = ReadOnlyDevicePropertyProvider(
+    this,
+    default,
+    descriptorBuilder,
+    getter
+)
+
+public fun DeviceBase.readingValue(
+    default: Value? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    getter: suspend () -> Any?,
+): PropertyDelegateProvider<DeviceBase, ReadOnlyPropertyDelegate> = ReadOnlyDevicePropertyProvider(
+    this,
+    default?.let { Meta(it) },
+    descriptorBuilder,
+    getter = { Meta(Value.of(getter())) }
+)
+
+public fun DeviceBase.readingNumber(
+    default: Number? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    getter: suspend () -> Number,
+): PropertyDelegateProvider<DeviceBase, TypedReadOnlyPropertyDelegate<Number>> = TypedReadOnlyDevicePropertyProvider(
+    this,
+    default?.let { Meta(it.asValue()) },
+    MetaConverter.number,
+    descriptorBuilder,
+    getter = {
+        val number = getter()
+        Meta(number.asValue())
+    }
+)
+
+public fun DeviceBase.readingDouble(
+    default: Number? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    getter: suspend () -> Double,
+): PropertyDelegateProvider<DeviceBase, TypedReadOnlyPropertyDelegate<Double>> = TypedReadOnlyDevicePropertyProvider(
+    this,
+    default?.let { Meta(it.asValue()) },
+    MetaConverter.double,
+    descriptorBuilder,
+    getter = {
+        val number = getter()
+        Meta(number.asValue())
+    }
+)
+
+public fun DeviceBase.readingString(
+    default: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    getter: suspend () -> String,
+): PropertyDelegateProvider<DeviceBase, TypedReadOnlyPropertyDelegate<String>> = TypedReadOnlyDevicePropertyProvider(
+    this,
+    default?.let { Meta(it.asValue()) },
+    MetaConverter.string,
+    descriptorBuilder,
+    getter = {
+        val number = getter()
+        Meta(number.asValue())
+    }
+)
+
+public fun DeviceBase.readingBoolean(
+    default: Boolean? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    getter: suspend () -> Boolean,
+): PropertyDelegateProvider<DeviceBase, TypedReadOnlyPropertyDelegate<Boolean>> = TypedReadOnlyDevicePropertyProvider(
+    this,
+    default?.let { Meta(it.asValue()) },
+    MetaConverter.boolean,
+    descriptorBuilder,
+    getter = {
+        val boolean = getter()
+        Meta(boolean.asValue())
+    }
+)
+
+public fun DeviceBase.readingMeta(
+    default: Meta? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    getter: suspend MutableMeta.() -> Unit,
+): PropertyDelegateProvider<DeviceBase, TypedReadOnlyPropertyDelegate<Meta>> = TypedReadOnlyDevicePropertyProvider(
+    this,
+    default,
+    MetaConverter.meta,
+    descriptorBuilder,
+    getter = {
+        Meta { getter() }
+    }
+)
+
+private fun DeviceBase.provideMutableProperty(name: String): ReadOnlyProperty<DeviceBase, DeviceProperty> =
+    ReadOnlyProperty { _: DeviceBase, _: KProperty<*> ->
+        return@ReadOnlyProperty properties[name] as DeviceProperty
+    }
+
+private fun <T : Any> DeviceBase.provideMutableProperty(
+    name: String,
+    converter: MetaConverter<T>,
+): ReadOnlyProperty<DeviceBase, TypedDeviceProperty<T>> =
+    ReadOnlyProperty { _: DeviceBase, _: KProperty<*> ->
+        return@ReadOnlyProperty TypedDeviceProperty(properties[name] as DeviceProperty, converter)
+    }
+
+public typealias PropertyDelegate = ReadOnlyProperty<DeviceBase, DeviceProperty>
+public typealias TypedPropertyDelegate<T> = ReadOnlyProperty<DeviceBase, TypedDeviceProperty<T>>
+
+private class DevicePropertyProvider<D : DeviceBase>(
+    val owner: D,
+    val default: Meta?,
+    val descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    private val getter: suspend (Meta?) -> Meta,
+    private val setter: suspend (oldValue: Meta?, newValue: Meta) -> Meta?,
+) : PropertyDelegateProvider<D, PropertyDelegate> {
+
+    override operator fun provideDelegate(thisRef: D, property: KProperty<*>): PropertyDelegate {
+        val name = property.name
+        owner.createMutableProperty(name, default, descriptorBuilder, getter, setter)
+        return owner.provideMutableProperty(name)
+    }
+}
+
+private class TypedDevicePropertyProvider<D : DeviceBase, T : Any>(
+    val owner: D,
+    val default: Meta?,
+    val converter: MetaConverter<T>,
+    val descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    private val getter: suspend (Meta?) -> Meta,
+    private val setter: suspend (oldValue: Meta?, newValue: Meta) -> Meta?,
+) : PropertyDelegateProvider<D, TypedPropertyDelegate<T>> {
+
+    override operator fun provideDelegate(thisRef: D, property: KProperty<*>): TypedPropertyDelegate<T> {
+        val name = property.name
+        owner.createMutableProperty(name, default, descriptorBuilder, getter, setter)
+        return owner.provideMutableProperty(name, converter)
+    }
+}
+
+public fun DeviceBase.writing(
+    default: Meta? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    getter: suspend (Meta?) -> Meta,
+    setter: suspend (oldValue: Meta?, newValue: Meta) -> Meta?,
+): PropertyDelegateProvider<DeviceBase, PropertyDelegate> = DevicePropertyProvider(
+    this,
+    default,
+    descriptorBuilder,
+    getter,
+    setter
+)
+
+public fun DeviceBase.writingVirtual(
+    default: Meta,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+): PropertyDelegateProvider<DeviceBase, PropertyDelegate> = writing(
+    default,
+    descriptorBuilder,
+    getter = { it ?: default },
+    setter = { _, newItem -> newItem }
+)
+
+public fun DeviceBase.writingVirtual(
+    default: Value,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+): PropertyDelegateProvider<DeviceBase, PropertyDelegate> = writing(
+    Meta(default),
+    descriptorBuilder,
+    getter = { it ?: Meta(default) },
+    setter = { _, newItem -> newItem }
+)
+
+public fun <D : DeviceBase> D.writingDouble(
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    getter: suspend (Double) -> Double,
+    setter: suspend (oldValue: Double?, newValue: Double) -> Double?,
+): PropertyDelegateProvider<D, TypedPropertyDelegate<Double>> {
+    val innerGetter: suspend (Meta?) -> Meta = {
+        Meta(getter(it.double ?: Double.NaN).asValue())
+    }
+
+    val innerSetter: suspend (oldValue: Meta?, newValue: Meta) -> Meta? = { oldValue, newValue ->
+        setter(oldValue.double, newValue.double ?: Double.NaN)?.asMeta()
+    }
+
+    return TypedDevicePropertyProvider(
+        this,
+        Meta(Double.NaN.asValue()),
+        MetaConverter.double,
+        descriptorBuilder,
+        innerGetter,
+        innerSetter
+    )
+}
+
+public fun <D : DeviceBase> D.writingBoolean(
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    getter: suspend (Boolean?) -> Boolean,
+    setter: suspend (oldValue: Boolean?, newValue: Boolean) -> Boolean?,
+): PropertyDelegateProvider<D, TypedPropertyDelegate<Boolean>> {
+    val innerGetter: suspend (Meta?) -> Meta = {
+        Meta(getter(it.boolean).asValue())
+    }
+
+    val innerSetter: suspend (oldValue: Meta?, newValue: Meta) -> Meta? = { oldValue, newValue ->
+        setter(oldValue.boolean, newValue.boolean ?: error("Can't convert $newValue to boolean"))?.asValue()
+            ?.let { Meta(it) }
+    }
+
+    return TypedDevicePropertyProvider(
+        this,
+        Meta(Null),
+        MetaConverter.boolean,
+        descriptorBuilder,
+        innerGetter,
+        innerSetter
+    )
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/misc.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/misc.kt
new file mode 100644
index 0000000..111789f
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/base/misc.kt
@@ -0,0 +1,28 @@
+package ru.mipt.npm.controls.base
+
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.double
+import space.kscience.dataforge.meta.enum
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.values.asValue
+import space.kscience.dataforge.values.double
+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/ru/mipt/npm/controls/controllers/DeviceManager.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/controllers/DeviceManager.kt
new file mode 100644
index 0000000..03880ca
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/controllers/DeviceManager.kt
@@ -0,0 +1,54 @@
+package ru.mipt.npm.controls.controllers
+
+import ru.mipt.npm.controls.api.Device
+import ru.mipt.npm.controls.api.DeviceHub
+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 kotlin.collections.set
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KClass
+
+public class DeviceManager : AbstractPlugin(), DeviceHub {
+    override val tag: PluginTag get() = Companion.tag
+
+    /**
+     * Actual list of connected devices
+     */
+    private val top = HashMap<NameToken, Device>()
+    override val devices: Map<NameToken, Device> get() = top
+
+    public fun registerDevice(name: NameToken, device: Device) {
+        top[name] = device
+    }
+
+    override fun content(target: String): Map<Name, Any> = super<DeviceHub>.content(target)
+
+    public companion object : PluginFactory<DeviceManager> {
+        override val tag: PluginTag = PluginTag("devices", group = PluginTag.DATAFORGE_GROUP)
+        override val type: KClass<out DeviceManager> = DeviceManager::class
+
+        override fun invoke(meta: Meta, context: Context): DeviceManager = DeviceManager()
+    }
+}
+
+
+public fun <D : Device> DeviceManager.install(name: String, factory: Factory<D>, meta: Meta = Meta.EMPTY): D {
+    val device = factory(meta, context)
+    registerDevice(NameToken(name), device)
+    return device
+}
+
+public inline fun <D : Device> DeviceManager.installing(
+    factory: Factory<D>,
+    builder: MutableMeta.() -> Unit = {},
+): ReadOnlyProperty<Any?, D> {
+    val meta = Meta(builder)
+    return ReadOnlyProperty { _, property ->
+        val name = property.name
+        install(name, factory, meta)
+    }
+}
+
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/controllers/deviceMessages.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/controllers/deviceMessages.kt
new file mode 100644
index 0000000..5158961
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/controllers/deviceMessages.kt
@@ -0,0 +1,118 @@
+package ru.mipt.npm.controls.controllers
+
+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.serialization.json.Json
+import kotlinx.serialization.json.encodeToJsonElement
+import ru.mipt.npm.controls.api.*
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.toMeta
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.plus
+
+public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMessage): DeviceMessage? = try {
+    when (request) {
+        is PropertyGetMessage -> {
+            PropertyChangedMessage(
+                property = request.property,
+                value = getOrReadProperty(request.property),
+                sourceDevice = deviceTarget,
+                targetDevice = request.sourceDevice
+            )
+        }
+
+        is PropertySetMessage -> {
+            if (request.value == null) {
+                invalidate(request.property)
+            } else {
+                writeProperty(request.property, request.value)
+            }
+            PropertyChangedMessage(
+                property = request.property,
+                value = getOrReadProperty(request.property),
+                sourceDevice = deviceTarget,
+                targetDevice = request.sourceDevice
+            )
+        }
+
+        is ActionExecuteMessage -> {
+            ActionResultMessage(
+                action = request.action,
+                result = execute(request.action, request.argument),
+                sourceDevice = deviceTarget,
+                targetDevice = request.sourceDevice
+            )
+        }
+
+        is GetDescriptionMessage -> {
+            val descriptionMeta = Meta {
+                "properties" put {
+                    propertyDescriptors.map { descriptor ->
+                        descriptor.name put Json.encodeToJsonElement(descriptor).toMeta()
+                    }
+                }
+                "actions" put {
+                    actionDescriptors.map { descriptor ->
+                        descriptor.name put Json.encodeToJsonElement(descriptor).toMeta()
+                    }
+                }
+            }
+
+            DescriptionMessage(
+                description = descriptionMeta,
+                sourceDevice = deviceTarget,
+                targetDevice = request.sourceDevice
+            )
+        }
+
+        is DescriptionMessage,
+        is PropertyChangedMessage,
+        is ActionResultMessage,
+        is BinaryNotificationMessage,
+        is DeviceErrorMessage,
+        is EmptyDeviceMessage,
+        is DeviceLogMessage,
+        -> null
+    }
+} catch (ex: Exception) {
+    DeviceMessage.error(ex, sourceDevice = deviceTarget, targetDevice = request.sourceDevice)
+}
+
+public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): 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)
+    } catch (ex: Exception) {
+        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> {
+    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)
+        } else {
+            childDevice.messageFlow
+        }
+        flow.onEach { deviceMessage ->
+            outbox.emit(
+                deviceMessage.changeSource { token + it }
+            )
+        }.launchIn(scope)
+    }
+    return outbox
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/ports/Port.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/ports/Port.kt
new file mode 100644
index 0000000..4cf672d
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/ports/Port.kt
@@ -0,0 +1,87 @@
+package ru.mipt.npm.controls.ports
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.receiveAsFlow
+import ru.mipt.npm.controls.api.Socket
+import space.kscience.dataforge.context.*
+import kotlin.coroutines.CoroutineContext
+
+public interface Port : ContextAware, Socket<ByteArray>
+
+public typealias PortFactory = Factory<Port>
+
+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 fun receive(data: ByteArray) {
+        scope.launch {
+            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 used on top of it.
+     * For example [delimitedIncoming] generates phrases with fixed delimiter.
+     */
+    override fun receiving(): Flow<ByteArray> {
+        return 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/ru/mipt/npm/controls/ports/PortProxy.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/ports/PortProxy.kt
new file mode 100644
index 0000000..686992d
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/ports/PortProxy.kt
@@ -0,0 +1,65 @@
+package ru.mipt.npm.controls.ports
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
+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/ru/mipt/npm/controls/ports/SynchronousPortHandler.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/ports/SynchronousPortHandler.kt
new file mode 100644
index 0000000..508ce6d
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/ports/SynchronousPortHandler.kt
@@ -0,0 +1,34 @@
+package ru.mipt.npm.controls.ports
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+/**
+ * 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.
+ *
+ */
+public class SynchronousPortHandler(public val port: Port) {
+    private val mutex = Mutex()
+
+    /**
+     * Send a single message and wait for the flow of respond messages.
+     */
+    public suspend fun <R> respond(data: ByteArray, transform: suspend Flow<ByteArray>.() -> R): R {
+        return mutex.withLock {
+            port.send(data)
+            transform(port.receiving())
+        }
+    }
+}
+
+/**
+ * Send request and read incoming data blocks until the delimiter is encountered
+ */
+public suspend fun SynchronousPortHandler.respondWithDelimiter(data: ByteArray, delimiter: ByteArray): ByteArray {
+    return respond(data) {
+        withDelimiter(delimiter).first()
+    }
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/ports/phrases.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/ports/phrases.kt
new file mode 100644
index 0000000..62d075a
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/ports/phrases.kt
@@ -0,0 +1,51 @@
+package ru.mipt.npm.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.transform
+
+/**
+ * Transform byte fragments into complete phrases using given delimiter. Not thread safe.
+ */
+public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray, expectedMessageSize: Int = 32): Flow<ByteArray> {
+    require(delimiter.isNotEmpty()) { "Delimiter must not be empty" }
+
+    val output = BytePacketBuilder(expectedMessageSize)
+    var matcherPosition = 0
+
+    return transform { chunk ->
+        chunk.forEach { byte ->
+            output.writeByte(byte)
+            //matching current symbol in delimiter
+            if (byte == delimiter[matcherPosition]) {
+                matcherPosition++
+                if (matcherPosition == delimiter.size) {
+                    //full match achieved, sending result
+                    val bytes = output.build()
+                    emit(bytes.readBytes())
+                    output.reset()
+                    matcherPosition = 0
+                }
+            } else if (matcherPosition > 0) {
+                //Reset matcher since full match not achieved
+                matcherPosition = 0
+            }
+        }
+    }
+}
+
+/**
+ * Transform byte fragments into utf-8 phrases using utf-8 delimiter
+ */
+public fun Flow<ByteArray>.withDelimiter(delimiter: String, expectedMessageSize: Int = 32): Flow<String> {
+    return withDelimiter(delimiter.encodeToByteArray(), expectedMessageSize).map { it.decodeToString() }
+}
+
+/**
+ * A flow of delimited phrases
+ */
+public suspend fun Port.delimitedIncoming(delimiter: ByteArray, expectedMessageSize: Int = 32): Flow<ByteArray> =
+    receiving().withDelimiter(delimiter, expectedMessageSize)
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DeviceBySpec.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DeviceBySpec.kt
new file mode 100644
index 0000000..c569cc3
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DeviceBySpec.kt
@@ -0,0 +1,141 @@
+package ru.mipt.npm.controls.properties
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.SharedFlow
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import ru.mipt.npm.controls.api.*
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Global
+import space.kscience.dataforge.meta.Meta
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * A device generated from specification
+ * @param D recursive self-type for properties and actions
+ */
+@OptIn(InternalDeviceAPI::class)
+public open class DeviceBySpec<D : DeviceBySpec<D>>(
+    public val spec: DeviceSpec<D>,
+    context: Context = Global,
+    meta: Meta = Meta.EMPTY
+) : Device {
+    override var context: Context = context
+        internal set
+
+    public var meta: Meta = meta
+        internal set
+
+    public val properties: Map<String, DevicePropertySpec<D, *>> get() = spec.properties
+    public val actions: Map<String, DeviceActionSpec<D, *, *>> get() = spec.actions
+
+    override val propertyDescriptors: Collection<PropertyDescriptor>
+        get() = properties.values.map { it.descriptor }
+
+    override val actionDescriptors: Collection<ActionDescriptor>
+        get() = actions.values.map { it.descriptor }
+
+    override val coroutineContext: CoroutineContext by lazy {
+        context.coroutineContext + SupervisorJob(context.coroutineContext[Job])
+    }
+
+    private val logicalState: HashMap<String, Meta?> = HashMap()
+
+    private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow()
+
+    public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow
+
+    @Suppress("UNCHECKED_CAST")
+    internal val self: D
+        get() = this as D
+
+    private val stateLock = Mutex()
+
+    /**
+     * Update logical property state and notify listeners
+     */
+    protected suspend fun updateLogical(propertyName: String, value: Meta?) {
+        if (value != logicalState[propertyName]) {
+            stateLock.withLock {
+                logicalState[propertyName] = value
+            }
+            if (value != null) {
+                sharedMessageFlow.emit(PropertyChangedMessage(propertyName, value))
+            }
+        }
+    }
+
+    /**
+     * Force read physical value and push an update if it is changed. It does not matter if logical state is present.
+     * The logical state is updated after read
+     */
+    override suspend fun readProperty(propertyName: String): Meta {
+        val newValue = properties[propertyName]?.readMeta(self)
+            ?: error("A property with name $propertyName is not registered in $this")
+        updateLogical(propertyName, newValue)
+        return newValue
+    }
+
+    override fun getProperty(propertyName: String): Meta? = logicalState[propertyName]
+
+    override suspend fun invalidate(propertyName: String) {
+        stateLock.withLock {
+            logicalState.remove(propertyName)
+        }
+    }
+
+    override suspend fun writeProperty(propertyName: String, value: Meta): Unit {
+        //If there is a physical property with given name, invalidate logical property and write physical one
+        (properties[propertyName] as? WritableDevicePropertySpec<D, out Any?>)?.let {
+            invalidate(propertyName)
+            it.writeMeta(self, value)
+        } ?: run {
+            updateLogical(propertyName, value)
+        }
+    }
+
+    override suspend fun execute(action: String, argument: Meta?): Meta? =
+        actions[action]?.executeMeta(self, argument)
+
+    /**
+     * Read typed value and update/push event if needed
+     */
+    public suspend fun <T> DevicePropertySpec<D, T>.read(): T {
+        val res = read(self)
+        updateLogical(name, converter.objectToMeta(res))
+        return res
+    }
+
+    public fun <T> DevicePropertySpec<D, T>.get(): T? = getProperty(name)?.let(converter::metaToObject)
+
+    /**
+     * Write typed property state and invalidate logical state
+     */
+    public suspend fun <T> WritableDevicePropertySpec<D, T>.write(value: T) {
+        invalidate(name)
+        write(self, value)
+        //perform asynchronous read and update after write
+        launch {
+            read()
+        }
+    }
+
+    override fun close() {
+        with(spec) { self.onShutdown() }
+        super.close()
+    }
+}
+
+public suspend fun <D : DeviceBySpec<D>, T : Any> D.read(
+    propertySpec: DevicePropertySpec<D, T>
+): T = propertySpec.read()
+
+public fun <D : DeviceBySpec<D>, T> D.write(
+    propertySpec: WritableDevicePropertySpec<D, T>,
+    value: T
+): Job = launch {
+    propertySpec.write(value)
+}
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DevicePropertySpec.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DevicePropertySpec.kt
new file mode 100644
index 0000000..23ceffb
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DevicePropertySpec.kt
@@ -0,0 +1,85 @@
+package ru.mipt.npm.controls.properties
+
+import ru.mipt.npm.controls.api.ActionDescriptor
+import ru.mipt.npm.controls.api.Device
+import ru.mipt.npm.controls.api.PropertyDescriptor
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.transformations.MetaConverter
+
+
+/**
+ * This API is internal and should not be used in user code
+ */
+@RequiresOptIn
+public annotation class InternalDeviceAPI
+
+public interface DevicePropertySpec<in D : Device, T> {
+    /**
+     * Property name, should be unique in device
+     */
+    public val name: String
+
+    /**
+     * Property descriptor
+     */
+    public val descriptor: PropertyDescriptor
+
+    /**
+     * Meta item converter for resulting type
+     */
+    public val converter: MetaConverter<T>
+
+    /**
+     * Read physical value from the given [device]
+     */
+    @InternalDeviceAPI
+    public suspend fun read(device: D): T
+}
+
+@OptIn(InternalDeviceAPI::class)
+public suspend fun <D : Device, T> DevicePropertySpec<D, T>.readMeta(device: D): Meta =
+    converter.objectToMeta(read(device))
+
+
+public interface WritableDevicePropertySpec<in D : Device, T> : DevicePropertySpec<D, T> {
+    /**
+     * Write physical value to a device
+     */
+    @InternalDeviceAPI
+    public suspend fun write(device: D, value: T)
+}
+
+@OptIn(InternalDeviceAPI::class)
+public 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"))
+}
+
+public interface DeviceActionSpec<in D : Device, I, O> {
+    /**
+     * Action name, should be unique in device
+     */
+    public val name: String
+
+    /**
+     * Action descriptor
+     */
+    public val descriptor: ActionDescriptor
+
+    public val inputConverter: MetaConverter<I>
+
+    public val outputConverter: MetaConverter<O>
+
+    /**
+     * Execute action on a device
+     */
+    public suspend fun execute(device: D, input: I?): O?
+}
+
+public suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeMeta(
+    device: D,
+    item: Meta?
+): Meta? {
+    val arg = item?.let { inputConverter.metaToObject(item) }
+    val res = execute(device, arg)
+    return res?.let { outputConverter.objectToMeta(res) }
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DeviceSpec.kt
new file mode 100644
index 0000000..934220f
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DeviceSpec.kt
@@ -0,0 +1,171 @@
+package ru.mipt.npm.controls.properties
+
+import kotlinx.coroutines.withContext
+import ru.mipt.npm.controls.api.ActionDescriptor
+import ru.mipt.npm.controls.api.PropertyDescriptor
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Factory
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import kotlin.properties.PropertyDelegateProvider
+import kotlin.properties.ReadOnlyProperty
+import kotlin.reflect.KMutableProperty1
+import kotlin.reflect.KProperty
+import kotlin.reflect.KProperty1
+
+@OptIn(InternalDeviceAPI::class)
+public abstract class DeviceSpec<D : DeviceBySpec<D>>(
+    private val buildDevice: () -> D
+) : Factory<D> {
+    private val _properties = HashMap<String, DevicePropertySpec<D, *>>()
+    public val properties: Map<String, DevicePropertySpec<D, *>> get() = _properties
+
+    private val _actions = HashMap<String, DeviceActionSpec<D, *, *>>()
+    public val actions: Map<String, DeviceActionSpec<D, *, *>> get() = _actions
+
+    public fun <T : Any, P : DevicePropertySpec<D, T>> registerProperty(deviceProperty: P): P {
+        _properties[deviceProperty.name] = deviceProperty
+        return deviceProperty
+    }
+
+    public fun <T : Any> registerProperty(
+        converter: MetaConverter<T>,
+        readOnlyProperty: KProperty1<D, T>,
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {}
+    ): DevicePropertySpec<D, T> {
+        val deviceProperty = object : DevicePropertySpec<D, T> {
+            override val name: String = readOnlyProperty.name
+            override val descriptor: PropertyDescriptor = PropertyDescriptor(this.name).apply(descriptorBuilder)
+            override val converter: MetaConverter<T> = converter
+            override suspend fun read(device: D): T =
+                withContext(device.coroutineContext) { readOnlyProperty.get(device) }
+        }
+        return registerProperty(deviceProperty)
+    }
+
+    public fun <T : Any> property(
+        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 name: String = property.name
+
+                override val descriptor: PropertyDescriptor = PropertyDescriptor(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) {
+                    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 : Any> property(
+        converter: MetaConverter<T>,
+        name: String? = null,
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        read: suspend D.() -> 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 name: String = propertyName
+                override val descriptor: PropertyDescriptor = PropertyDescriptor(this.name).apply(descriptorBuilder)
+                override val converter: MetaConverter<T> = converter
+
+                override suspend fun read(device: D): T = withContext(device.coroutineContext) { device.read() }
+            }
+            registerProperty(deviceProperty)
+            ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>> { _, _ ->
+                deviceProperty
+            }
+        }
+
+    public fun <T : Any> property(
+        converter: MetaConverter<T>,
+        name: String? = null,
+        descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+        read: suspend D.() -> T,
+        write: suspend D.(T) -> Unit
+    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>>> =
+        PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
+            val propertyName = name ?: property.name
+            val deviceProperty = object : WritableDevicePropertySpec<D, T> {
+                override val name: String = propertyName
+                override val descriptor: PropertyDescriptor = PropertyDescriptor(this.name).apply(descriptorBuilder)
+                override val converter: MetaConverter<T> = converter
+
+                override suspend fun read(device: D): T = withContext(device.coroutineContext) { device.read() }
+
+                override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) {
+                    device.write(value)
+                }
+            }
+            _properties[propertyName] = deviceProperty
+            ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>> { _, _ ->
+                deviceProperty
+            }
+        }
+
+
+    public fun <I : Any, O : Any> registerAction(deviceAction: DeviceActionSpec<D, I, O>): DeviceActionSpec<D, I, O> {
+        _actions[deviceAction.name] = deviceAction
+        return deviceAction
+    }
+
+    public fun <I : Any, O : Any> action(
+        inputConverter: MetaConverter<I>,
+        outputConverter: MetaConverter<O>,
+        name: String? = null,
+        descriptorBuilder: ActionDescriptor.() -> Unit = {},
+        execute: suspend D.(I?) -> O?
+    ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, I, O>>> =
+        PropertyDelegateProvider { _: DeviceSpec<D>, property ->
+            val actionName = name ?: property.name
+            val deviceAction = object : DeviceActionSpec<D, I, O> {
+                override val name: String = actionName
+                override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply(descriptorBuilder)
+
+                override val inputConverter: MetaConverter<I> = inputConverter
+                override val outputConverter: MetaConverter<O> = outputConverter
+
+                override suspend fun execute(device: D, input: I?): O? = withContext(device.coroutineContext) {
+                    device.execute(input)
+                }
+            }
+            _actions[actionName] = deviceAction
+            ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, I, O>> { _, _ ->
+                deviceAction
+            }
+        }
+
+    /**
+     * The function is executed right after device initialization is finished
+     */
+    public open fun D.onStartup() {}
+
+    /**
+     * The function is executed before device is shut down
+     */
+    public open fun D.onShutdown() {}
+
+
+    override fun invoke(meta: Meta, context: Context): D = buildDevice().apply {
+        this.context = context
+        this.meta = meta
+        onStartup()
+    }
+}
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/deviceExtensions.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/deviceExtensions.kt
new file mode 100644
index 0000000..582c8a7
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/deviceExtensions.kt
@@ -0,0 +1,32 @@
+package ru.mipt.npm.controls.properties
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+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
+ */
+public fun <D : DeviceBySpec<D>, R> D.readRecurring(interval: Duration, reader: suspend D.() -> R): Flow<R> = flow {
+    while (isActive) {
+        kotlinx.coroutines.delay(interval)
+        emit(reader())
+    }
+}
+
+/**
+ * Do a recurring task on a device. The task could
+ */
+public fun <D : DeviceBySpec<D>> D.doRecurring(interval: Duration, task: suspend D.() -> Unit): Job = launch {
+    while (isActive) {
+        kotlinx.coroutines.delay(interval)
+        task()
+    }
+}
\ No newline at end of file
diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/propertySpecDelegates.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/propertySpecDelegates.kt
new file mode 100644
index 0000000..d087505
--- /dev/null
+++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/propertySpecDelegates.kt
@@ -0,0 +1,144 @@
+package ru.mipt.npm.controls.properties
+
+import ru.mipt.npm.controls.api.PropertyDescriptor
+import ru.mipt.npm.controls.api.metaDescriptor
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import space.kscience.dataforge.values.ValueType
+import kotlin.properties.PropertyDelegateProvider
+import kotlin.properties.ReadOnlyProperty
+
+//read only delegates
+
+public fun <D : DeviceBySpec<D>> DeviceSpec<D>.booleanProperty(
+    name: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    read: suspend D.() -> Boolean
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Boolean>>> = property(
+    MetaConverter.boolean,
+    name,
+    {
+        metaDescriptor {
+            type(ValueType.BOOLEAN)
+        }
+        descriptorBuilder()
+    },
+    read
+)
+
+private inline fun numberDescriptor(
+    crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {}
+): PropertyDescriptor.() -> Unit = {
+    metaDescriptor {
+        type(ValueType.NUMBER)
+    }
+    descriptorBuilder()
+}
+
+public fun <D : DeviceBySpec<D>> DeviceSpec<D>.numberProperty(
+    name: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    read: suspend D.() -> Number
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Number>>> = property(
+    MetaConverter.number,
+    name,
+    numberDescriptor(descriptorBuilder),
+    read
+)
+
+public fun <D : DeviceBySpec<D>> DeviceSpec<D>.doubleProperty(
+    name: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    read: suspend D.() -> Double
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Double>>> = property(
+    MetaConverter.double,
+    name,
+    numberDescriptor(descriptorBuilder),
+    read
+)
+
+public fun <D : DeviceBySpec<D>> DeviceSpec<D>.stringProperty(
+    name: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    read: suspend D.() -> String
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, String>>> = property(
+    MetaConverter.string,
+    name,
+    {
+        metaDescriptor {
+            type(ValueType.STRING)
+        }
+        descriptorBuilder()
+    },
+    read
+)
+
+public fun <D : DeviceBySpec<D>> DeviceSpec<D>.metaProperty(
+    name: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    read: suspend D.() -> Meta
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Meta>>> = property(
+    MetaConverter.meta,
+    name,
+    {
+        metaDescriptor {
+            type(ValueType.STRING)
+        }
+        descriptorBuilder()
+    },
+    read
+)
+
+//read-write delegates
+
+public fun <D : DeviceBySpec<D>> DeviceSpec<D>.booleanProperty(
+    name: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    read: suspend D.() -> Boolean,
+    write: suspend D.(Boolean) -> Unit
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Boolean>>> =
+    property(
+        MetaConverter.boolean,
+        name,
+        {
+            metaDescriptor {
+                type(ValueType.BOOLEAN)
+            }
+            descriptorBuilder()
+        },
+        read,
+        write
+    )
+
+
+public fun <D : DeviceBySpec<D>> DeviceSpec<D>.numberProperty(
+    name: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    read: suspend D.() -> Number,
+    write: suspend D.(Number) -> Unit
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Number>>> =
+    property(MetaConverter.number, name, numberDescriptor(descriptorBuilder), read, write)
+
+public fun <D : DeviceBySpec<D>> DeviceSpec<D>.doubleProperty(
+    name: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    read: suspend D.() -> Double,
+    write: suspend D.(Double) -> Unit
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Double>>> =
+    property(MetaConverter.double, name, numberDescriptor(descriptorBuilder), read, write)
+
+public fun <D : DeviceBySpec<D>> DeviceSpec<D>.stringProperty(
+    name: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    read: suspend D.() -> String,
+    write: suspend D.(String) -> Unit
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, String>>> =
+    property(MetaConverter.string, name, descriptorBuilder, read, write)
+
+public fun <D : DeviceBySpec<D>> DeviceSpec<D>.metaProperty(
+    name: String? = null,
+    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
+    read: suspend D.() -> Meta,
+    write: suspend D.(Meta) -> Unit
+): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Meta>>> =
+    property(MetaConverter.meta, name, descriptorBuilder, read, write)
\ No newline at end of file
diff --git a/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/misc/javaTimeMeta.kt b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/misc/javaTimeMeta.kt
new file mode 100644
index 0000000..eec5774
--- /dev/null
+++ b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/misc/javaTimeMeta.kt
@@ -0,0 +1,19 @@
+package ru.mipt.npm.controls.misc
+
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.long
+import space.kscience.dataforge.values.long
+import java.time.Instant
+
+// TODO move to core
+
+public fun Instant.toMeta(): Meta = Meta {
+    "seconds" put epochSecond
+    "nanos" put nano
+}
+
+public fun Meta.instant(): Instant = value?.long?.let { Instant.ofEpochMilli(it) } ?: Instant.ofEpochSecond(
+    get("seconds")?.long ?: 0L,
+    get("nanos")?.long ?: 0L,
+)
\ No newline at end of file
diff --git a/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/ports/TcpPort.kt b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/ports/TcpPort.kt
new file mode 100644
index 0000000..cfd810b
--- /dev/null
+++ b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/ports/TcpPort.kt
@@ -0,0 +1,91 @@
+package ru.mipt.npm.controls.ports
+
+import kotlinx.coroutines.*
+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.get
+import space.kscience.dataforge.meta.int
+import space.kscience.dataforge.meta.string
+import java.net.InetSocketAddress
+import java.nio.ByteBuffer
+import java.nio.channels.SocketChannel
+import kotlin.coroutines.CoroutineContext
+
+internal fun ByteBuffer.readArray(limit: Int = limit()): ByteArray {
+    rewind()
+    val response = ByteArray(limit)
+    get(response)
+    rewind()
+    return response
+}
+
+public class TcpPort private constructor(
+    context: Context,
+    public val host: String,
+    public val port: Int,
+    coroutineContext: CoroutineContext = context.coroutineContext,
+) : AbstractPort(context, coroutineContext), AutoCloseable {
+
+    override fun toString(): String = "port[tcp:$host:$port]"
+
+    private val futureChannel: Deferred<SocketChannel> = this.scope.async(Dispatchers.IO) {
+        SocketChannel.open(InetSocketAddress(host, port)).apply {
+            configureBlocking(false)
+        }
+    }
+
+    /**
+     * A handler to await port connection
+     */
+    public val startJob: Job get() = futureChannel
+
+    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.readArray(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) {
+        futureChannel.await().write(ByteBuffer.wrap(data))
+    }
+
+    override fun close() {
+        listenerJob.cancel()
+        if(futureChannel.isCompleted){
+            futureChannel.getCompleted().close()
+        } else {
+            futureChannel.cancel()
+        }
+        super.close()
+    }
+
+    public companion object : PortFactory {
+        public fun open(
+            context: Context,
+            host: String,
+            port: Int,
+            coroutineContext: CoroutineContext = context.coroutineContext,
+        ): TcpPort {
+            return TcpPort(context, host, port, coroutineContext)
+        }
+
+        override fun invoke(meta: Meta, context: Context): 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-core/src/jvmMain/kotlin/ru/mipt/npm/controls/properties/delegates.kt b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/properties/delegates.kt
new file mode 100644
index 0000000..7def81d
--- /dev/null
+++ b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/properties/delegates.kt
@@ -0,0 +1,89 @@
+package ru.mipt.npm.controls.controllers
+
+import kotlinx.coroutines.runBlocking
+import ru.mipt.npm.controls.base.*
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import kotlin.properties.ReadOnlyProperty
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
+import kotlin.time.Duration
+
+/**
+ * Blocking read of the value
+ */
+public operator fun ReadOnlyDeviceProperty.getValue(thisRef: Any?, property: KProperty<*>): Meta =
+    runBlocking(scope.coroutineContext) {
+        read()
+    }
+
+public operator fun <T: Any> TypedReadOnlyDeviceProperty<T>.getValue(thisRef: Any?, property: KProperty<*>): T =
+    runBlocking(scope.coroutineContext) {
+        readTyped()
+    }
+
+public operator fun DeviceProperty.setValue(thisRef: Any?, property: KProperty<*>, value: Meta) {
+    this.value = value
+}
+
+public operator fun  <T: Any> TypedDeviceProperty<T>.setValue(thisRef: Any?, property: KProperty<*>, value: T) {
+    this.typedValue = value
+}
+
+public fun <T : Any> ReadOnlyDeviceProperty.convert(
+    metaConverter: MetaConverter<T>,
+    forceRead: Boolean,
+): ReadOnlyProperty<Any?, T> {
+    return ReadOnlyProperty { _, _ ->
+        runBlocking(scope.coroutineContext) {
+            val meta = read(forceRead)
+            metaConverter.metaToObject(meta)?: error("Meta $meta could not be converted by $metaConverter")
+        }
+    }
+}
+
+public fun <T : Any> DeviceProperty.convert(
+    metaConverter: MetaConverter<T>,
+    forceRead: Boolean,
+): ReadWriteProperty<Any?, T> {
+    return object : ReadWriteProperty<Any?, T> {
+        override fun getValue(thisRef: Any?, property: KProperty<*>): T = runBlocking(scope.coroutineContext) {
+            val meta = read(forceRead)
+            metaConverter.metaToObject(meta)?: error("Meta $meta could not be converted by $metaConverter")
+        }
+
+        override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
+            this@convert.setValue(thisRef, property, value.let { metaConverter.objectToMeta(it) })
+        }
+    }
+}
+
+public fun ReadOnlyDeviceProperty.double(forceRead: Boolean = false): ReadOnlyProperty<Any?, Double> =
+    convert(MetaConverter.double, forceRead)
+
+public fun DeviceProperty.double(forceRead: Boolean = false): ReadWriteProperty<Any?, Double> =
+    convert(MetaConverter.double, forceRead)
+
+public fun ReadOnlyDeviceProperty.int(forceRead: Boolean = false): ReadOnlyProperty<Any?, Int> =
+    convert(MetaConverter.int, forceRead)
+
+public fun DeviceProperty.int(forceRead: Boolean = false): ReadWriteProperty<Any?, Int> =
+    convert(MetaConverter.int, forceRead)
+
+public fun ReadOnlyDeviceProperty.string(forceRead: Boolean = false): ReadOnlyProperty<Any?, String> =
+    convert(MetaConverter.string, forceRead)
+
+public fun DeviceProperty.string(forceRead: Boolean = false): ReadWriteProperty<Any?, String> =
+    convert(MetaConverter.string, forceRead)
+
+public fun ReadOnlyDeviceProperty.boolean(forceRead: Boolean = false): ReadOnlyProperty<Any?, Boolean> =
+    convert(MetaConverter.boolean, forceRead)
+
+public fun DeviceProperty.boolean(forceRead: Boolean = false): ReadWriteProperty<Any?, Boolean> =
+    convert(MetaConverter.boolean, forceRead)
+
+public fun ReadOnlyDeviceProperty.duration(forceRead: Boolean = false): ReadOnlyProperty<Any?, Duration> =
+    convert(DurationConverter, forceRead)
+
+public fun DeviceProperty.duration(forceRead: Boolean = false): ReadWriteProperty<Any?, Duration> =
+    convert(DurationConverter, forceRead)
\ No newline at end of file
diff --git a/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/properties/getDeviceProperty.kt b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/properties/getDeviceProperty.kt
new file mode 100644
index 0000000..3be61d6
--- /dev/null
+++ b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/properties/getDeviceProperty.kt
@@ -0,0 +1,10 @@
+package ru.mipt.npm.controls.properties
+
+import kotlinx.coroutines.runBlocking
+
+/**
+ * Blocking property get call
+ */
+public operator fun <D : DeviceBySpec<D>, T : Any> D.get(
+    propertySpec: DevicePropertySpec<D, T>
+): T = runBlocking { read(propertySpec) }
\ No newline at end of file
diff --git a/controls-core/src/jvmTest/kotlin/ru/mipt/npm/controls/ports/PortIOTest.kt b/controls-core/src/jvmTest/kotlin/ru/mipt/npm/controls/ports/PortIOTest.kt
new file mode 100644
index 0000000..cdb3107
--- /dev/null
+++ b/controls-core/src/jvmTest/kotlin/ru/mipt/npm/controls/ports/PortIOTest.kt
@@ -0,0 +1,25 @@
+package ru.mipt.npm.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-magix-client/build.gradle.kts b/controls-magix-client/build.gradle.kts
new file mode 100644
index 0000000..3d50b56
--- /dev/null
+++ b/controls-magix-client/build.gradle.kts
@@ -0,0 +1,21 @@
+plugins {
+    id("ru.mipt.npm.gradle.mpp")
+    `maven-publish`
+}
+
+kscience{
+    useSerialization {
+        json()
+    }
+}
+
+kotlin {
+    sourceSets {
+        commonMain {
+            dependencies {
+                implementation(project(":magix:magix-rsocket"))
+                implementation(project(":controls-core"))
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/controls-magix-client/src/commonMain/kotlin/ru/mipt/npm/controls/client/dfMagix.kt b/controls-magix-client/src/commonMain/kotlin/ru/mipt/npm/controls/client/dfMagix.kt
new file mode 100644
index 0000000..2e92205
--- /dev/null
+++ b/controls-magix-client/src/commonMain/kotlin/ru/mipt/npm/controls/client/dfMagix.kt
@@ -0,0 +1,64 @@
+package ru.mipt.npm.controls.client
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import ru.mipt.npm.controls.api.DeviceMessage
+import ru.mipt.npm.controls.controllers.DeviceManager
+import ru.mipt.npm.controls.controllers.hubMessageFlow
+import ru.mipt.npm.controls.controllers.respondHubMessage
+import ru.mipt.npm.magix.api.MagixEndpoint
+import ru.mipt.npm.magix.api.MagixMessage
+import space.kscience.dataforge.context.error
+import space.kscience.dataforge.context.logger
+
+
+public const val DATAFORGE_MAGIX_FORMAT: String = "dataforge"
+
+internal fun generateId(request: MagixMessage<*>): String = if (request.id != null) {
+    "${request.id}.response"
+} else {
+    "df[${request.payload.hashCode()}"
+}
+
+/**
+ * Communicate with server in [Magix format](https://github.com/waltz-controls/rfc/tree/master/1)
+ */
+public fun DeviceManager.connectToMagix(
+    endpoint: MagixEndpoint<DeviceMessage>,
+    endpointID: String = DATAFORGE_MAGIX_FORMAT,
+): Job = context.launch {
+    endpoint.subscribe().onEach { request ->
+        val responsePayload = respondHubMessage(request.payload)
+        if (responsePayload != null) {
+            val response = MagixMessage(
+                format = DATAFORGE_MAGIX_FORMAT,
+                id = generateId(request),
+                parentId = request.id,
+                origin = endpointID,
+                payload = responsePayload
+            )
+
+            endpoint.broadcast(response)
+        }
+    }.catch { error ->
+        logger.error(error) { "Error while responding to message" }
+    }.launchIn(this)
+
+    hubMessageFlow(this).onEach { payload ->
+        endpoint.broadcast(
+            MagixMessage(
+                format = DATAFORGE_MAGIX_FORMAT,
+                id = "df[${payload.hashCode()}]",
+                origin = endpointID,
+                payload = payload
+            )
+        )
+    }.catch { error ->
+        logger.error(error) { "Error while sending a message" }
+    }.launchIn(this)
+}
+
+
diff --git a/controls-magix-client/src/commonMain/kotlin/ru/mipt/npm/controls/client/doocsMagix.kt b/controls-magix-client/src/commonMain/kotlin/ru/mipt/npm/controls/client/doocsMagix.kt
new file mode 100644
index 0000000..8f42deb
--- /dev/null
+++ b/controls-magix-client/src/commonMain/kotlin/ru/mipt/npm/controls/client/doocsMagix.kt
@@ -0,0 +1,119 @@
+package ru.mipt.npm.controls.client
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import space.kscience.dataforge.meta.Meta
+
+
+/*
+  "action":"get|set",
+  "eq_address": "string",
+  "eq_data": {
+    "type_id": "int[required]",
+    "type": "string[optional]",
+    "value": "object|value",
+    "event_id": "int[optional]",
+    "error": "int[optional]",
+    "time": "long[optional]",
+    "message": "string[optional]"
+  }
+ */
+
+@Serializable
+public enum class DoocsAction {
+    get,
+    set,
+    names
+}
+
+@Serializable
+public data class EqData(
+    @SerialName("type_id")
+    val typeId: Int,
+    val type: String? = null,
+    val value: Meta? = null,
+    @SerialName("event_id")
+    val eventId: Int? = null,
+    val error: Int? = null,
+    val time: Long? = null,
+    val message: String? = null
+) {
+    public companion object {
+        internal const val DATA_NULL: Int = 0
+        internal const val DATA_INT: Int = 1
+        internal const val DATA_FLOAT: Int = 2
+        internal const val DATA_STRING: Int = 3
+        internal const val DATA_BOOL: Int = 4
+        internal const val DATA_STRING16: Int = 5
+        internal const val DATA_DOUBLE: Int = 6
+        internal const val DATA_TEXT: Int = 7
+        internal const val DATA_TDS: Int = 12
+        internal const val DATA_XY: Int = 13
+        internal const val DATA_IIII: Int = 14
+        internal const val DATA_IFFF: Int = 15
+        internal const val DATA_USTR: Int = 16
+        internal const val DATA_TTII: Int = 18
+        internal const val DATA_SPECTRUM: Int = 19
+        internal const val DATA_XML: Int = 20
+        internal const val DATA_XYZS: Int = 21
+        internal const val DATA_IMAGE: Int = 22
+        internal const val DATA_GSPECTRUM: Int = 24
+        internal const val DATA_SHORT: Int = 25
+        internal const val DATA_LONG: Int = 26
+        internal const val DATA_USHORT: Int = 27
+        internal const val DATA_UINT: Int = 28
+        internal const val DATA_ULONG: Int = 29
+
+
+        internal const val DATA_A_FLOAT: Int = 100
+        internal const val DATA_A_TDS: Int = 101
+        internal const val DATA_A_XY: Int = 102
+        internal const val DATA_A_USTR: Int = 103
+        internal const val DATA_A_INT: Int = 105
+        internal const val DATA_A_BYTE: Int = 106
+        internal const val DATA_A_XYZS: Int = 108
+        internal const val DATA_MDA_FLOAT: Int = 109
+        internal const val DATA_A_DOUBLE: Int = 110
+        internal const val DATA_A_BOOL: Int = 111
+        internal const val DATA_A_STRING: Int = 112
+        internal const val DATA_A_SHORT: Int = 113
+        internal const val DATA_A_LONG: Int = 114
+        internal const val DATA_MDA_DOUBLE: Int = 115
+        internal const val DATA_A_USHORT: Int = 116
+        internal const val DATA_A_UINT: Int = 117
+        internal const val DATA_A_ULONG: Int = 118
+
+        internal const val DATA_A_THUMBNAIL: Int = 120
+
+        internal const val DATA_A_TS_BOOL: Int = 1000
+        internal const val DATA_A_TS_INT: Int = 1001
+        internal const val DATA_A_TS_FLOAT: Int = 1002
+        internal const val DATA_A_TS_DOUBLE: Int = 1003
+        internal const val DATA_A_TS_LONG: Int = 1004
+        internal const val DATA_A_TS_STRING: Int = 1005
+        internal const val DATA_A_TS_USTR: Int = 1006
+        internal const val DATA_A_TS_XML: Int = 1007
+        internal const val DATA_A_TS_XY: Int = 1008
+        internal const val DATA_A_TS_IIII: Int = 1009
+        internal const val DATA_A_TS_IFFF: Int = 1010
+        internal const val DATA_A_TS_SPECTRUM: Int = 1013
+        internal const val DATA_A_TS_XYZS: Int = 1014
+        internal const val DATA_A_TS_GSPECTRUM: Int = 1015
+
+        internal const val DATA_KEYVAL: Int = 1016
+
+        internal const val DATA_A_TS_SHORT: Int = 1017
+        internal const val DATA_A_TS_USHORT: Int = 1018
+        internal const val DATA_A_TS_UINT: Int = 1019
+        internal const val DATA_A_TS_ULONG: Int = 1020
+    }
+}
+
+@Serializable
+public data class DoocsPayload(
+    val action: DoocsAction,
+    @SerialName("eq_address")
+    val address: String,
+    @SerialName("eq_data")
+    val data: EqData?
+)
\ No newline at end of file
diff --git a/controls-magix-client/src/commonMain/kotlin/ru/mipt/npm/controls/client/tangoMagix.kt b/controls-magix-client/src/commonMain/kotlin/ru/mipt/npm/controls/client/tangoMagix.kt
new file mode 100644
index 0000000..922098a
--- /dev/null
+++ b/controls-magix-client/src/commonMain/kotlin/ru/mipt/npm/controls/client/tangoMagix.kt
@@ -0,0 +1,146 @@
+package ru.mipt.npm.controls.client
+
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.launch
+import kotlinx.serialization.Serializable
+import ru.mipt.npm.controls.api.get
+import ru.mipt.npm.controls.api.getOrReadProperty
+import ru.mipt.npm.controls.controllers.DeviceManager
+import ru.mipt.npm.magix.api.MagixEndpoint
+import ru.mipt.npm.magix.api.MagixMessage
+import space.kscience.dataforge.context.error
+import space.kscience.dataforge.context.logger
+import space.kscience.dataforge.meta.Meta
+
+public const val TANGO_MAGIX_FORMAT: String = "tango"
+
+/*
+  See https://github.com/waltz-controls/rfc/tree/master/4 for details
+
+  "action":"read|write|exec|pipe",
+  "timestamp": "int",
+  "host":"tango_host",
+  "device":"device name",
+  "name":"attribute, command or pipe's name",
+  "[value]":"attribute's value",
+  "[quality]":"VALID|WARNING|ALARM",
+  "[argin]":"command argin",
+  "[argout]":"command argout",
+  "[data]":"pipe's data",
+  "[errors]":[]
+ */
+
+@Serializable
+public enum class TangoAction {
+    read,
+    write,
+    exec,
+    pipe
+}
+
+@Serializable
+public enum class TangoQuality {
+    VALID,
+    WARNING,
+    ALARM
+}
+
+@Serializable
+public data class TangoPayload(
+    val action: TangoAction,
+    val timestamp: Int,
+    val host: String,
+    val device: String,
+    val name: String,
+    val value: Meta? = null,
+    val quality: TangoQuality = TangoQuality.VALID,
+    val argin: Meta? = null,
+    val argout: Meta? = null,
+    val data: Meta? = null,
+    val errors: List<String>? = null
+)
+
+public fun DeviceManager.launchTangoMagix(
+    endpoint: MagixEndpoint<TangoPayload>,
+    endpointID: String = TANGO_MAGIX_FORMAT,
+): Job {
+    suspend fun respond(request: MagixMessage<TangoPayload>, payloadBuilder: (TangoPayload) -> TangoPayload) {
+        endpoint.broadcast(
+            request.copy(
+                id = generateId(request),
+                parentId = request.id,
+                origin = endpointID,
+                payload = payloadBuilder(request.payload)
+            )
+        )
+    }
+
+
+    return context.launch {
+        endpoint.subscribe().onEach { request ->
+            try {
+                val device = get(request.payload.device)
+                when (request.payload.action) {
+                    TangoAction.read -> {
+                        val value = device.getOrReadProperty(request.payload.name)
+                        respond(request) { requestPayload ->
+                            requestPayload.copy(
+                                value = value,
+                                quality = TangoQuality.VALID
+                            )
+                        }
+                    }
+                    TangoAction.write -> {
+                        request.payload.value?.let { value ->
+                            device.writeProperty(request.payload.name, value)
+                        }
+                        //wait for value to be written and return final state
+                        val value = device.getOrReadProperty(request.payload.name)
+                        respond(request) { requestPayload ->
+                            requestPayload.copy(
+                                value = value,
+                                quality = TangoQuality.VALID
+                            )
+                        }
+                    }
+                    TangoAction.exec -> {
+                        val result = device.execute(request.payload.name, request.payload.argin)
+                        respond(request) { requestPayload ->
+                            requestPayload.copy(
+                                argout = result,
+                                quality = TangoQuality.VALID
+                            )
+                        }
+                    }
+                    TangoAction.pipe -> TODO("Pipe not implemented")
+                }
+            } catch (ex: Exception) {
+                logger.error(ex) { "Error while responding to message" }
+                endpoint.broadcast(
+                    request.copy(
+                        id = generateId(request),
+                        parentId = request.id,
+                        origin = endpointID,
+                        payload = request.payload.copy(quality = TangoQuality.WARNING)
+                    )
+                )
+            }
+        }.launchIn(this)
+
+//TODO implement subscriptions?
+//    controller.messageOutput().onEach { payload ->
+//        endpoint.broadcast(
+//            MagixMessage(
+//                format = TANGO_MAGIX_FORMAT,
+//                id = "df[${payload.hashCode()}]",
+//                origin = endpointID,
+//                payload = payload
+//            )
+//        )
+//    }.catch { error ->
+//        logger.error(error) { "Error while sending a message" }
+//    }.launchIn(this)
+    }
+}
\ No newline at end of file
diff --git a/controls-opcua/build.gradle.kts b/controls-opcua/build.gradle.kts
new file mode 100644
index 0000000..a2a225d
--- /dev/null
+++ b/controls-opcua/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+    id("ru.mipt.npm.gradle.jvm")
+}
+
+val ktorVersion: String by rootProject.extra
+
+val miloVersion: String = "0.6.3"
+
+dependencies {
+    api(project(":controls-core"))
+    api("org.jetbrains.kotlinx:kotlinx-coroutines-jdk8:${ru.mipt.npm.gradle.KScienceVersions.coroutinesVersion}")
+
+    api("org.eclipse.milo:sdk-client:$miloVersion")
+    api("org.eclipse.milo:bsd-parser:$miloVersion")
+
+    api("org.eclipse.milo:sdk-server:$miloVersion")
+}
diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/MetaBsdParser.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/MetaBsdParser.kt
new file mode 100644
index 0000000..171b74e
--- /dev/null
+++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/MetaBsdParser.kt
@@ -0,0 +1,208 @@
+package ru.mipt.npm.controls.opcua.client
+
+import org.eclipse.milo.opcua.binaryschema.AbstractCodec
+import org.eclipse.milo.opcua.binaryschema.parser.BsdParser
+import org.eclipse.milo.opcua.stack.core.UaSerializationException
+import org.eclipse.milo.opcua.stack.core.serialization.OpcUaBinaryStreamDecoder
+import org.eclipse.milo.opcua.stack.core.serialization.OpcUaBinaryStreamEncoder
+import org.eclipse.milo.opcua.stack.core.serialization.SerializationContext
+import org.eclipse.milo.opcua.stack.core.serialization.codecs.OpcUaBinaryDataTypeCodec
+import org.eclipse.milo.opcua.stack.core.types.builtin.*
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.*
+import org.opcfoundation.opcua.binaryschema.EnumeratedType
+import org.opcfoundation.opcua.binaryschema.StructuredType
+import ru.mipt.npm.controls.misc.instant
+import ru.mipt.npm.controls.misc.toMeta
+import space.kscience.dataforge.meta.*
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.asName
+import space.kscience.dataforge.values.*
+import java.util.*
+
+
+public class MetaBsdParser : BsdParser() {
+    override fun getEnumCodec(enumeratedType: EnumeratedType): OpcUaBinaryDataTypeCodec<*> {
+        return MetaEnumCodec()
+    }
+
+    override fun getStructCodec(structuredType: StructuredType): OpcUaBinaryDataTypeCodec<*> {
+        return MetaStructureCodec(structuredType)
+    }
+}
+
+internal class MetaEnumCodec : OpcUaBinaryDataTypeCodec<Number> {
+    override fun getType(): Class<Number> {
+        return Number::class.java
+    }
+
+    @Throws(UaSerializationException::class)
+    override fun encode(
+        context: SerializationContext,
+        encoder: OpcUaBinaryStreamEncoder,
+        value: Number
+    ) {
+        encoder.writeInt32(value.toInt())
+    }
+
+    @Throws(UaSerializationException::class)
+    override fun decode(
+        context: SerializationContext,
+        decoder: OpcUaBinaryStreamDecoder
+    ): Number {
+        return decoder.readInt32()
+    }
+}
+
+internal fun opcToMeta(value: Any?): Meta = when (value) {
+    null -> Meta(Null)
+    is Meta -> value
+    is Value -> Meta(value)
+    is Number -> when (value) {
+        is UByte -> Meta(value.toShort().asValue())
+        is UShort -> Meta(value.toInt().asValue())
+        is UInteger -> Meta(value.toLong().asValue())
+        is ULong -> Meta(value.toBigInteger().asValue())
+        else -> Meta(value.asValue())
+    }
+    is Boolean -> Meta(value.asValue())
+    is String -> Meta(value.asValue())
+    is Char -> Meta(value.toString().asValue())
+    is DateTime -> value.javaInstant.toMeta()
+    is UUID -> Meta(value.toString().asValue())
+    is QualifiedName -> Meta {
+        "namespaceIndex" put value.namespaceIndex
+        "name" put value.name?.asValue()
+    }
+    is LocalizedText -> Meta {
+        "locale" put value.locale?.asValue()
+        "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.toMeta() }
+        value.sourcePicoseconds?.let { "sourcePicoseconds" put Meta(it.asValue()) }
+        value.serverTime?.javaInstant?.let { "serverTime" put it.toMeta() }
+        value.serverPicoseconds?.let { "serverPicoseconds" put Meta(it.asValue()) }
+    }
+    is ByteString -> Meta(value.bytesOrEmpty().asValue())
+    is XmlElement -> Meta(value.fragment?.asValue() ?: Null)
+    is NodeId -> Meta(value.toParseableString().asValue())
+    is ExpandedNodeId -> Meta(value.toParseableString().asValue())
+    is StatusCode -> Meta(value.value.asValue())
+    //is ExtensionObject -> value.decode(client.getDynamicSerializationContext())
+    else -> error("Could not create Meta for value: $value")
+}
+
+
+/**
+ * based on https://github.com/eclipse/milo/blob/master/opc-ua-stack/bsd-parser-gson/src/main/java/org/eclipse/milo/opcua/binaryschema/gson/JsonStructureCodec.java
+ */
+internal class MetaStructureCodec(
+    structuredType: StructuredType?
+) : AbstractCodec<Meta, Meta>(structuredType) {
+
+    override fun getType(): Class<Meta> = Meta::class.java
+
+    override fun createStructure(name: String, members: LinkedHashMap<String, Meta>): Meta = Meta {
+        members.forEach { (property: String, value: Meta?) ->
+            setMeta(Name.parse(property), value)
+        }
+    }
+
+    override fun opcUaToMemberTypeScalar(name: String, value: Any?, typeName: String): Meta = opcToMeta(value)
+
+    override fun opcUaToMemberTypeArray(name: String, values: Any?, typeName: String): Meta = if (values == null) {
+        Meta(Null)
+    } else {
+        // This is a bit array...
+        when (values) {
+            is DoubleArray -> Meta(values.asValue())
+            is FloatArray -> Meta(values.asValue())
+            is IntArray -> Meta(values.asValue())
+            is ByteArray -> Meta(values.asValue())
+            is ShortArray -> Meta(values.asValue())
+            is Array<*> -> Meta {
+                setIndexed(Name.parse(name), values.map { opcUaToMemberTypeScalar(name, it, typeName) })
+            }
+            is Number -> Meta(values.asValue())
+            else -> error("Could not create Meta for value: $values")
+        }
+    }
+
+    override fun memberTypeToOpcUaScalar(member: Meta?, typeName: String): Any? =
+        if (member == null || member.isEmpty()) {
+            null
+        } else when (typeName) {
+            "Boolean" -> member.boolean
+            "SByte" -> member.value?.numberOrNull?.toByte()
+            "Int16" -> member.value?.numberOrNull?.toShort()
+            "Int32" -> member.value?.numberOrNull?.toInt()
+            "Int64" -> member.value?.numberOrNull?.toLong()
+            "Byte" -> member.value?.numberOrNull?.toShort()?.let { Unsigned.ubyte(it) }
+            "UInt16" -> member.value?.numberOrNull?.toInt()?.let { Unsigned.ushort(it) }
+            "UInt32" -> member.value?.numberOrNull?.toLong()?.let { Unsigned.uint(it) }
+            "UInt64" -> member.value?.numberOrNull?.toLong()?.let { Unsigned.ulong(it) }
+            "Float" -> member.value?.numberOrNull?.toFloat()
+            "Double" -> member.value?.numberOrNull?.toDouble()
+            "String" -> member.string
+            "DateTime" -> DateTime(member.instant())
+            "Guid" -> member.string?.let { UUID.fromString(it) }
+            "ByteString" -> member.value?.list?.let { list ->
+                ByteString(list.map { it.number.toByte() }.toByteArray())
+            }
+            "XmlElement" -> member.string?.let { XmlElement(it) }
+            "NodeId" -> member.string?.let { NodeId.parse(it) }
+            "ExpandedNodeId" -> member.string?.let { ExpandedNodeId.parse(it) }
+            "StatusCode" -> member.long?.let { StatusCode(it) }
+            "QualifiedName" -> QualifiedName(
+                member["namespaceIndex"].int ?: 0,
+                member["name"].string
+            )
+            "LocalizedText" -> LocalizedText(
+                member["locale"].string,
+                member["text"].string
+            )
+            else -> member.toString()
+        }
+
+    override fun memberTypeToOpcUaArray(member: Meta, typeName: String): Any = if ("Bit" == typeName) {
+        member.value?.int ?: error("Meta node does not contain int value")
+    } else {
+        when (typeName) {
+            "SByte" -> member.value?.list?.map { it.number.toByte() }?.toByteArray() ?: emptyArray<Byte>()
+            "Int16" -> member.value?.list?.map { it.number.toShort() }?.toShortArray() ?: emptyArray<Short>()
+            "Int32" -> member.value?.list?.map { it.number.toInt() }?.toIntArray() ?: emptyArray<Int>()
+            "Int64" -> member.value?.list?.map { it.number.toLong() }?.toLongArray() ?: emptyArray<Long>()
+            "Byte" -> member.value?.list?.map {
+                Unsigned.ubyte(it.number.toShort())
+            }?.toTypedArray() ?: emptyArray<UByte>()
+            "UInt16" -> member.value?.list?.map {
+                Unsigned.ushort(it.number.toInt())
+            }?.toTypedArray() ?: emptyArray<UShort>()
+            "UInt32" -> member.value?.list?.map {
+                Unsigned.uint(it.number.toLong())
+            }?.toTypedArray() ?: emptyArray<UInteger>()
+            "UInt64" -> member.value?.list?.map {
+                Unsigned.ulong(it.number.toLong())
+            }?.toTypedArray() ?: emptyArray<kotlin.ULong>()
+            "Float" -> member.value?.list?.map { it.number.toFloat() }?.toFloatArray() ?: emptyArray<Float>()
+            "Double" -> member.value?.list?.map { it.number.toDouble() }?.toDoubleArray() ?: emptyArray<Double>()
+            else -> member.getIndexed(Meta.JSON_ARRAY_KEY.asName()).map {
+                memberTypeToOpcUaScalar(it.value, typeName)
+            }.toTypedArray()
+        }
+    }
+
+    override fun getMembers(value: Meta): Map<String, Meta> = value.items.mapKeys { it.toString() }
+}
+
+public fun Variant.toMeta(serializationContext: SerializationContext): Meta = (value as? ExtensionObject)?.let {
+    it.decode(serializationContext) as Meta
+} ?: opcToMeta(value)
+
+//public fun Meta.toVariant(): Variant = if (items.isEmpty()) {
+//    Variant(value?.value)
+//} else {
+//    TODO()
+//}
diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/MiloDevice.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/MiloDevice.kt
new file mode 100644
index 0000000..de56325
--- /dev/null
+++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/MiloDevice.kt
@@ -0,0 +1,83 @@
+package ru.mipt.npm.controls.opcua.client
+
+import kotlinx.coroutines.future.await
+import kotlinx.serialization.json.Json
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient
+import org.eclipse.milo.opcua.stack.core.types.builtin.*
+import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn
+import ru.mipt.npm.controls.api.Device
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaSerializer
+import space.kscience.dataforge.meta.transformations.MetaConverter
+
+
+/**
+ * An OPC-UA device backed by Eclipse Milo client
+ */
+public interface MiloDevice : Device {
+    /**
+     * The OPC-UA client initialized on first use
+     */
+    public val client: OpcUaClient
+
+    override fun close() {
+        client.disconnect()
+        super.close()
+    }
+}
+
+/**
+ * Read OPC-UA value with timestamp
+ * @param T the type of property to read. The value is coerced to it.
+ */
+public suspend inline fun <reified T: Any> MiloDevice.readOpcWithTime(
+    nodeId: NodeId,
+    converter: MetaConverter<T>,
+    magAge: Double = 500.0
+): Pair<T, DateTime> {
+    val data = 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
+        content is Meta -> content as Meta
+        content is ExtensionObject -> (content as ExtensionObject).decode(client.dynamicSerializationContext) as Meta
+        else -> error("Incompatible OPC property value $content")
+    }
+
+    val res: T = converter.metaToObject(meta) ?: error("Meta $meta could not be converted to ${T::class}")
+    return res to time
+}
+
+/**
+ * Read and coerce value from OPC-UA
+ */
+public suspend inline fun <reified T> MiloDevice.readOpc(
+    nodeId: NodeId,
+    converter: MetaConverter<T>,
+    magAge: Double = 500.0
+): T {
+    val data: DataValue = client.readValue(magAge, TimestampsToReturn.Neither, nodeId).await()
+
+    val content = data.value.value
+    if(content is T) return  content
+    val meta: Meta = when (content) {
+        is Meta -> content
+        //Always decode string as Json meta
+        is String -> Json.decodeFromString(MetaSerializer, content)
+        is Number -> Meta(content)
+        is Boolean -> Meta(content)
+        //content is ExtensionObject -> (content as ExtensionObject).decode(client.dynamicSerializationContext) as Meta
+        else -> error("Incompatible OPC property value $content")
+    }
+
+    return converter.metaToObject(meta) ?: error("Meta $meta could not be converted to ${T::class}")
+}
+
+public suspend inline fun <reified T> MiloDevice.writeOpc(
+    nodeId: NodeId,
+    converter: MetaConverter<T>,
+    value: T
+): StatusCode {
+    val meta = converter.objectToMeta(value)
+    return client.writeValue(nodeId, DataValue(Variant(meta))).await()
+}
\ No newline at end of file
diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/MiloDeviceBySpec.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/MiloDeviceBySpec.kt
new file mode 100644
index 0000000..351115c
--- /dev/null
+++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/MiloDeviceBySpec.kt
@@ -0,0 +1,69 @@
+package ru.mipt.npm.controls.opcua.client
+
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient
+import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId
+import ru.mipt.npm.controls.properties.DeviceBySpec
+import ru.mipt.npm.controls.properties.DeviceSpec
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Global
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.meta.string
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import kotlin.properties.ReadWriteProperty
+import kotlin.reflect.KProperty
+
+public open class MiloDeviceBySpec<D : MiloDeviceBySpec<D>>(
+    spec: DeviceSpec<D>,
+    context: Context = Global,
+    meta: Meta = Meta.EMPTY
+) : MiloDevice, DeviceBySpec<D>(spec, context, meta) {
+
+    override val client: OpcUaClient by lazy {
+        val endpointUrl = meta["endpointUrl"].string ?: error("Endpoint url is not defined")
+        context.createMiloClient(endpointUrl).apply {
+            connect().get()
+        }
+    }
+
+    override fun close() {
+        super<MiloDevice>.close()
+        super<DeviceBySpec>.close()
+    }
+}
+
+/**
+ * A device-bound OPC-UA property. Does not trigger device properties change.
+ */
+public inline fun <reified T> MiloDeviceBySpec<*>.opc(
+    nodeId: NodeId,
+    converter: MetaConverter<T>,
+    magAge: Double = 500.0
+): ReadWriteProperty<Any?, T> = object : ReadWriteProperty<Any?, T> {
+    override fun getValue(thisRef: Any?, property: KProperty<*>): T = runBlocking {
+        readOpc(nodeId, converter, magAge)
+    }
+
+    override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
+        launch {
+            writeOpc(nodeId, converter, value)
+        }
+    }
+}
+
+public inline fun <reified T> MiloDeviceBySpec<*>.opcDouble(
+    nodeId: NodeId,
+    magAge: Double = 1.0
+): ReadWriteProperty<Any?, Double> = opc(nodeId, MetaConverter.double, magAge)
+
+public inline fun <reified T> MiloDeviceBySpec<*>.opcInt(
+    nodeId: NodeId,
+    magAge: Double = 1.0
+): ReadWriteProperty<Any?, Int> = opc(nodeId, MetaConverter.int, magAge)
+
+public inline fun <reified T> MiloDeviceBySpec<*>.opcString(
+    nodeId: NodeId,
+    magAge: Double = 1.0
+): ReadWriteProperty<Any?, String> = opc(nodeId, MetaConverter.string, magAge)
\ No newline at end of file
diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/miloClient.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/miloClient.kt
new file mode 100644
index 0000000..2d489d6
--- /dev/null
+++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/client/miloClient.kt
@@ -0,0 +1,63 @@
+package ru.mipt.npm.controls.opcua.client
+
+import org.eclipse.milo.opcua.sdk.client.OpcUaClient
+import org.eclipse.milo.opcua.sdk.client.api.config.OpcUaClientConfigBuilder
+import org.eclipse.milo.opcua.sdk.client.api.identity.AnonymousProvider
+import org.eclipse.milo.opcua.sdk.client.api.identity.IdentityProvider
+import org.eclipse.milo.opcua.stack.client.security.DefaultClientCertificateValidator
+import org.eclipse.milo.opcua.stack.core.security.DefaultTrustListManager
+import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText
+import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint
+import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.info
+import space.kscience.dataforge.context.logger
+import java.nio.file.Files
+import java.nio.file.Path
+import java.nio.file.Paths
+import java.util.*
+
+public fun <T:Any> T?.toOptional(): Optional<T> = if(this == null) Optional.empty() else Optional.of(this)
+
+
+internal fun Context.createMiloClient(
+    endpointUrl: String, //"opc.tcp://localhost:12686/milo"
+    securityPolicy: SecurityPolicy = SecurityPolicy.Basic256Sha256,
+    identityProvider: IdentityProvider = AnonymousProvider(),
+    endpointFilter: (EndpointDescription?) -> Boolean = { securityPolicy.uri == it?.securityPolicyUri }
+): OpcUaClient {
+
+    val securityTempDir: Path = Paths.get(System.getProperty("java.io.tmpdir"), "client", "security")
+    Files.createDirectories(securityTempDir)
+    check(Files.exists(securityTempDir)) { "Unable to create security dir: $securityTempDir" }
+
+    val pkiDir: Path = securityTempDir.resolve("pki")
+    logger.info { "Milo client security dir: ${securityTempDir.toAbsolutePath()}" }
+    logger.info { "Security pki dir: ${pkiDir.toAbsolutePath()}" }
+
+    //val loader: KeyStoreLoader = KeyStoreLoader().load(securityTempDir)
+    val trustListManager = DefaultTrustListManager(pkiDir.toFile())
+    val certificateValidator = DefaultClientCertificateValidator(trustListManager)
+
+    return OpcUaClient.create(
+        endpointUrl,
+        { endpoints: List<EndpointDescription?> ->
+            endpoints.firstOrNull(endpointFilter).toOptional()
+        }
+    ) { configBuilder: OpcUaClientConfigBuilder ->
+        configBuilder
+            .setApplicationName(LocalizedText.english("Controls.kt"))
+            .setApplicationUri("urn:ru.mipt:npm:controls:opcua")
+//            .setKeyPair(loader.getClientKeyPair())
+//            .setCertificate(loader.getClientCertificate())
+//            .setCertificateChain(loader.getClientCertificateChain())
+            .setCertificateValidator(certificateValidator)
+            .setIdentityProvider(identityProvider)
+            .setRequestTimeout(uint(5000))
+            .build()
+    }
+//        .apply {
+//        addSessionInitializer(DataTypeDictionarySessionInitializer(MetaBsdParser()))
+//    }
+}
\ No newline at end of file
diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/DeviceNameSpace.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/DeviceNameSpace.kt
new file mode 100644
index 0000000..e187f5e
--- /dev/null
+++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/DeviceNameSpace.kt
@@ -0,0 +1,212 @@
+package ru.mipt.npm.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
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer
+import org.eclipse.milo.opcua.sdk.server.api.DataItem
+import org.eclipse.milo.opcua.sdk.server.api.ManagedNamespaceWithLifecycle
+import org.eclipse.milo.opcua.sdk.server.api.MonitoredItem
+import org.eclipse.milo.opcua.sdk.server.nodes.UaFolderNode
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode
+import org.eclipse.milo.opcua.sdk.server.nodes.UaVariableNode
+import org.eclipse.milo.opcua.sdk.server.util.SubscriptionModel
+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 ru.mipt.npm.controls.api.Device
+import ru.mipt.npm.controls.api.DeviceHub
+import ru.mipt.npm.controls.api.PropertyDescriptor
+import ru.mipt.npm.controls.api.onPropertyChange
+import ru.mipt.npm.controls.controllers.DeviceManager
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaSerializer
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.asName
+import space.kscience.dataforge.names.plus
+import space.kscience.dataforge.values.ValueType
+
+
+public operator fun Device.get(propertyDescriptor: PropertyDescriptor): Meta? = getProperty(propertyDescriptor.name)
+
+public suspend fun Device.read(propertyDescriptor: PropertyDescriptor): Meta = readProperty(propertyDescriptor.name)
+
+/*
+https://github.com/eclipse/milo/blob/master/milo-examples/server-examples/src/main/java/org/eclipse/milo/examples/server/ExampleNamespace.java
+ */
+
+public class DeviceNameSpace(
+    server: OpcUaServer,
+    public val deviceManager: DeviceManager
+) : ManagedNamespaceWithLifecycle(server, NAMESPACE_URI) {
+
+    private val subscription = SubscriptionModel(server, this)
+
+    init {
+        lifecycleManager.addLifecycle(subscription)
+
+        lifecycleManager.addStartupTask {
+            deviceManager.devices.forEach { (deviceName, device) ->
+                val tokenAsString = deviceName.toString()
+                val deviceFolder = UaFolderNode(
+                    this.nodeContext,
+                    newNodeId(tokenAsString),
+                    newQualifiedName(tokenAsString),
+                    LocalizedText.english(tokenAsString)
+                )
+                deviceFolder.addReference(
+                    Reference(
+                        deviceFolder.nodeId,
+                        Identifiers.Organizes,
+                        Identifiers.ObjectsFolder.expanded(),
+                        false
+                    )
+                )
+                deviceFolder.registerDeviceNodes(deviceName.asName(), device)
+                this.nodeManager.addNode(deviceFolder)
+            }
+        }
+
+        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
+
+
+            val node: UaVariableNode = UaVariableNode.UaVariableNodeBuilder(nodeContext).apply {
+                //for now use DF path as id
+                nodeId = newNodeId("${deviceName.tokens.joinToString("/")}/$propertyName")
+                when {
+                    descriptor.readable && descriptor.writable -> {
+                        setAccessLevel(AccessLevel.READ_WRITE)
+                        setUserAccessLevel(AccessLevel.READ_WRITE)
+                    }
+                    descriptor.writable -> {
+                        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)
+                    }
+                }
+
+                browseName = newQualifiedName(propertyName)
+                displayName = LocalizedText.english(propertyName)
+                dataType = if (descriptor.metaDescriptor.children.isNotEmpty()) {
+                    Identifiers.String
+                } else when (descriptor.metaDescriptor.valueTypes?.first()) {
+                    null, ValueType.STRING, ValueType.NULL -> Identifiers.String
+                    ValueType.NUMBER -> Identifiers.Number
+                    ValueType.BOOLEAN -> Identifiers.Boolean
+                    ValueType.LIST -> Identifiers.ArrayItemType
+                }
+
+
+                setTypeDefinition(Identifiers.BaseDataVariableType)
+            }.build()
+
+
+            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)
+                    }
+                }
+            }
+
+            nodeManager.addNode(node)
+            addOrganizes(node)
+            propertyName to node
+        }
+
+        //Subscribe on properties updates
+        device.onPropertyChange {
+            nodes[property]?.let { node ->
+                val sourceTime = time?.let { DateTime(it.toJavaInstant()) }
+                node.value = value.toOpc(sourceTime = sourceTime)
+            }
+        }
+        //recursively add sub-devices
+        if (device is DeviceHub) {
+            registerHub(device, deviceName)
+        }
+    }
+
+    private fun UaNode.registerHub(hub: DeviceHub, namePrefix: Name) {
+        hub.devices.forEach { (deviceName, device) ->
+            val tokenAsString = deviceName.toString()
+            val deviceFolder = UaFolderNode(
+                this.nodeContext,
+                newNodeId(tokenAsString),
+                newQualifiedName(tokenAsString),
+                LocalizedText.english(tokenAsString)
+            )
+            deviceFolder.addReference(
+                Reference(
+                    deviceFolder.nodeId,
+                    Identifiers.Organizes,
+                    Identifiers.ObjectsFolder.expanded(),
+                    false
+                )
+            )
+            deviceFolder.registerDeviceNodes(namePrefix + deviceName, device)
+            this.nodeManager.addNode(deviceFolder)
+        }
+    }
+
+    override fun onDataItemsCreated(dataItems: List<DataItem?>?) {
+        subscription.onDataItemsCreated(dataItems)
+    }
+
+    override fun onDataItemsModified(dataItems: List<DataItem?>?) {
+        subscription.onDataItemsModified(dataItems)
+    }
+
+    override fun onDataItemsDeleted(dataItems: List<DataItem?>?) {
+        subscription.onDataItemsDeleted(dataItems)
+    }
+
+    override fun onMonitoringModeChanged(monitoredItems: List<MonitoredItem?>?) {
+        subscription.onMonitoringModeChanged(monitoredItems)
+    }
+
+    public companion object {
+        public const val NAMESPACE_URI: String = "urn:space:kscience:controls:opcua:server"
+    }
+}
+
+public fun OpcUaServer.serveDevices(deviceManager: DeviceManager): DeviceNameSpace =
+    DeviceNameSpace(this, deviceManager).apply { startup() }
\ No newline at end of file
diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/metaToOpc.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/metaToOpc.kt
new file mode 100644
index 0000000..cbcd2ec
--- /dev/null
+++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/metaToOpc.kt
@@ -0,0 +1,38 @@
+package ru.mipt.npm.controls.opcua.server
+
+import kotlinx.serialization.json.Json
+import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue
+import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime
+import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode
+import org.eclipse.milo.opcua.stack.core.types.builtin.Variant
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.MetaSerializer
+import space.kscience.dataforge.meta.isLeaf
+import space.kscience.dataforge.values.*
+import java.time.Instant
+
+/**
+ * Convert Meta to OPC data value using
+ */
+internal fun Meta.toOpc(
+    statusCode: StatusCode = StatusCode.GOOD,
+    sourceTime: DateTime? = null,
+    serverTime: DateTime? = null
+): DataValue {
+    val variant: Variant = if (isLeaf) {
+        when (value?.type) {
+            null, ValueType.NULL -> Variant.NULL_VALUE
+            ValueType.NUMBER -> Variant(value!!.number)
+            ValueType.STRING -> Variant(value!!.string)
+            ValueType.BOOLEAN -> Variant(value!!.boolean)
+            ValueType.LIST -> if (value!!.list.all { it.type == ValueType.NUMBER }) {
+                Variant(value!!.doubleArray.toTypedArray())
+            } else {
+                Variant(value!!.stringList.toTypedArray())
+            }
+        }
+    } else {
+        Variant(Json.encodeToString(MetaSerializer,this))
+    }
+    return DataValue(variant, statusCode, sourceTime,serverTime ?: DateTime(Instant.now()))
+}
\ No newline at end of file
diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/nodeUtils.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/nodeUtils.kt
new file mode 100644
index 0000000..783c229
--- /dev/null
+++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/nodeUtils.kt
@@ -0,0 +1,77 @@
+package ru.mipt.npm.controls.opcua.server
+
+import org.eclipse.milo.opcua.sdk.core.AccessLevel
+import org.eclipse.milo.opcua.sdk.core.Reference
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNode
+import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext
+import org.eclipse.milo.opcua.sdk.server.nodes.UaVariableNode
+import org.eclipse.milo.opcua.stack.core.Identifiers
+import org.eclipse.milo.opcua.stack.core.types.builtin.*
+
+
+internal fun UaNode.inverseReferenceTo(targetNodeId: NodeId, typeId: NodeId) {
+    addReference(
+        Reference(
+            nodeId,
+            typeId,
+            targetNodeId.expanded(),
+            Reference.Direction.INVERSE
+        )
+    )
+}
+
+internal fun NodeId.resolve(child: String): NodeId {
+    val id = this.identifier.toString()
+    return NodeId(this.namespaceIndex, "$id/$child")
+}
+
+
+internal fun UaNodeContext.addVariableNode(
+    parentNodeId: NodeId,
+    name: String,
+    nodeId: NodeId = parentNodeId.resolve(name),
+    dataTypeId: NodeId,
+    value: Any,
+    referenceTypeId: NodeId = Identifiers.HasComponent
+): UaVariableNode {
+
+    val variableNode: UaVariableNode = UaVariableNode.UaVariableNodeBuilder(this).apply {
+        setNodeId(nodeId)
+        setAccessLevel(AccessLevel.toValue(AccessLevel.READ_WRITE))
+        setUserAccessLevel(AccessLevel.toValue(AccessLevel.READ_WRITE))
+        setBrowseName(QualifiedName(parentNodeId.namespaceIndex, name))
+        setDisplayName(LocalizedText.english(name))
+        setDataType(dataTypeId)
+        setTypeDefinition(Identifiers.BaseDataVariableType)
+        setMinimumSamplingInterval(100.0)
+        setValue(DataValue(Variant(value)))
+    }.build()
+
+//    variableNode.filterChain.addFirst(AttributeLoggingFilter())
+
+    nodeManager.addNode(variableNode)
+
+    variableNode.inverseReferenceTo(
+        parentNodeId,
+        referenceTypeId
+    )
+
+    return variableNode
+}
+//
+//fun UaNodeContext.addVariableNode(
+//    parentNodeId: NodeId,
+//    name: String,
+//    nodeId: NodeId = parentNodeId.resolve(name),
+//    dataType: BuiltinDataType = BuiltinDataType.Int32,
+//    referenceTypeId: NodeId = Identifiers.HasComponent
+//): UaVariableNode = addVariableNode(
+//    parentNodeId,
+//    name,
+//    nodeId,
+//    dataType.nodeId,
+//    dataType.defaultValue(),
+//    referenceTypeId
+//)
+
+
diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/serverUtils.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/serverUtils.kt
new file mode 100644
index 0000000..6ba3d36
--- /dev/null
+++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/server/serverUtils.kt
@@ -0,0 +1,28 @@
+package ru.mipt.npm.controls.opcua.server
+
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer
+import org.eclipse.milo.opcua.sdk.server.api.config.OpcUaServerConfig
+import org.eclipse.milo.opcua.sdk.server.api.config.OpcUaServerConfigBuilder
+import org.eclipse.milo.opcua.stack.server.EndpointConfiguration
+
+public fun OpcUaServer(block: OpcUaServerConfigBuilder.() -> Unit): OpcUaServer {
+//        .setProductUri(DemoServer.PRODUCT_URI)
+//        .setApplicationUri("${DemoServer.APPLICATION_URI}:$applicationUuid")
+//        .setApplicationName(LocalizedText.english("Eclipse Milo OPC UA Demo Server"))
+//        .setBuildInfo(buildInfo())
+//        .setTrustListManager(trustListManager)
+//        .setCertificateManager(certificateManager)
+//        .setCertificateValidator(certificateValidator)
+//        .setIdentityValidator(identityValidator)
+//        .setEndpoints(endpoints)
+//        .setLimits(ServerLimits)
+
+    val config = OpcUaServerConfig.builder().apply(block)
+
+    return OpcUaServer(config.build())
+}
+
+public fun OpcUaServerConfigBuilder.endpoint(block: EndpointConfiguration.Builder.() -> Unit) {
+    val endpoint = EndpointConfiguration.Builder().apply(block).build()
+    setEndpoints(setOf(endpoint))
+}
diff --git a/controls-serial/build.gradle.kts b/controls-serial/build.gradle.kts
new file mode 100644
index 0000000..733348c
--- /dev/null
+++ b/controls-serial/build.gradle.kts
@@ -0,0 +1,9 @@
+plugins {
+    id("ru.mipt.npm.gradle.jvm")
+    `maven-publish`
+}
+
+dependencies{
+    api(project(":controls-core"))
+    implementation("org.scream3r:jssc:2.8.0")
+}
\ No newline at end of file
diff --git a/controls-serial/src/main/kotlin/ru/mipt/npm/controls/serial/SerialPort.kt b/controls-serial/src/main/kotlin/ru/mipt/npm/controls/serial/SerialPort.kt
new file mode 100644
index 0000000..1c9ad27
--- /dev/null
+++ b/controls-serial/src/main/kotlin/ru/mipt/npm/controls/serial/SerialPort.kt
@@ -0,0 +1,89 @@
+package ru.mipt.npm.controls.serial
+
+import jssc.SerialPort.*
+import jssc.SerialPortEventListener
+import ru.mipt.npm.controls.ports.AbstractPort
+import ru.mipt.npm.controls.ports.Port
+import ru.mipt.npm.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
+import jssc.SerialPort as JSSCPort
+
+/**
+ * COM/USB port
+ */
+public class SerialPort private constructor(
+    context: Context,
+    private val jssc: JSSCPort,
+    coroutineContext: CoroutineContext = context.coroutineContext,
+) : AbstractPort(context, coroutineContext) {
+
+    override fun toString(): String = "port[${jssc.portName}]"
+
+    private val serialPortListener = SerialPortEventListener { event ->
+        if (event.isRXCHAR) {
+            val chars = event.eventValue
+            val bytes = jssc.readBytes(chars)
+            receive(bytes)
+        }
+    }
+
+    init {
+        jssc.addEventListener(serialPortListener)
+    }
+
+    /**
+     * Clear current input and output buffers
+     */
+    internal fun clearPort() {
+        jssc.purgePort(PURGE_RXCLEAR or PURGE_TXCLEAR)
+    }
+
+    override suspend fun write(data: ByteArray) {
+        jssc.writeBytes(data)
+    }
+
+    @Throws(Exception::class)
+    override fun close() {
+        jssc.removeEventListener()
+        clearPort()
+        if (jssc.isOpened) {
+            jssc.closePort()
+        }
+        super.close()
+    }
+
+    public companion object : PortFactory {
+
+        /**
+         * Construct ComPort with given parameters
+         */
+        public fun open(
+            context: Context,
+            portName: String,
+            baudRate: Int = BAUDRATE_9600,
+            dataBits: Int = DATABITS_8,
+            stopBits: Int = STOPBITS_1,
+            parity: Int = PARITY_NONE,
+            coroutineContext: CoroutineContext = context.coroutineContext,
+        ): SerialPort {
+            val jssc = JSSCPort(portName).apply {
+                openPort()
+                setParams(baudRate, dataBits, stopBits, parity)
+            }
+            return SerialPort(context, jssc, coroutineContext)
+        }
+
+        override fun invoke(meta: Meta, context: Context): Port {
+            val name by meta.string { error("Serial port name not defined") }
+            val baudRate by meta.int(BAUDRATE_9600)
+            val dataBits by meta.int(DATABITS_8)
+            val stopBits by meta.int(STOPBITS_1)
+            val parity by meta.int(PARITY_NONE)
+            return open(context, name, baudRate, dataBits, stopBits, parity)
+        }
+    }
+}
\ No newline at end of file
diff --git a/controls-server/build.gradle.kts b/controls-server/build.gradle.kts
new file mode 100644
index 0000000..7eba824
--- /dev/null
+++ b/controls-server/build.gradle.kts
@@ -0,0 +1,21 @@
+plugins {
+    id("ru.mipt.npm.gradle.jvm")
+    `maven-publish`
+}
+
+description = """
+   A magix event loop server with web server for visualization.
+""".trimIndent()
+
+val dataforgeVersion: String by rootProject.extra
+val ktorVersion: String by rootProject.extra
+
+dependencies {
+    implementation(project(":controls-core"))
+    implementation(project(":controls-tcp"))
+    implementation(projects.magix.magixServer)
+    implementation("io.ktor:ktor-server-cio:$ktorVersion")
+    implementation("io.ktor:ktor-websockets:$ktorVersion")
+    implementation("io.ktor:ktor-serialization:$ktorVersion")
+    implementation("io.ktor:ktor-html-builder:$ktorVersion")
+}
\ No newline at end of file
diff --git a/controls-server/src/main/kotlin/ru/mipt/npm/controls/server/deviceWebServer.kt b/controls-server/src/main/kotlin/ru/mipt/npm/controls/server/deviceWebServer.kt
new file mode 100644
index 0000000..f896a24
--- /dev/null
+++ b/controls-server/src/main/kotlin/ru/mipt/npm/controls/server/deviceWebServer.kt
@@ -0,0 +1,223 @@
+package ru.mipt.npm.controls.server
+
+
+import io.ktor.application.*
+import io.ktor.features.CORS
+import io.ktor.features.StatusPages
+import io.ktor.html.respondHtml
+import io.ktor.http.HttpStatusCode
+import io.ktor.request.receiveText
+import io.ktor.response.respond
+import io.ktor.response.respondRedirect
+import io.ktor.response.respondText
+import io.ktor.routing.get
+import io.ktor.routing.post
+import io.ktor.routing.route
+import io.ktor.routing.routing
+import io.ktor.server.cio.CIO
+import io.ktor.server.engine.ApplicationEngine
+import io.ktor.server.engine.embeddedServer
+import io.ktor.util.getValue
+import io.ktor.websocket.WebSockets
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.html.*
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonArray
+import kotlinx.serialization.json.encodeToJsonElement
+import kotlinx.serialization.json.put
+import ru.mipt.npm.controls.api.DeviceMessage
+import ru.mipt.npm.controls.api.PropertyGetMessage
+import ru.mipt.npm.controls.api.PropertySetMessage
+import ru.mipt.npm.controls.api.getOrNull
+import ru.mipt.npm.controls.controllers.DeviceManager
+import ru.mipt.npm.controls.controllers.respondHubMessage
+import ru.mipt.npm.magix.api.MagixEndpoint
+import ru.mipt.npm.magix.server.GenericMagixMessage
+import ru.mipt.npm.magix.server.launchMagixServerRawRSocket
+import ru.mipt.npm.magix.server.magixModule
+import space.kscience.dataforge.meta.toMeta
+import space.kscience.dataforge.names.Name
+import space.kscience.dataforge.names.asName
+
+/**
+ * Create and start a web server for several devices
+ */
+public fun CoroutineScope.startDeviceServer(
+    manager: DeviceManager,
+    port: Int = MagixEndpoint.DEFAULT_MAGIX_HTTP_PORT,
+    host: String = "localhost",
+): ApplicationEngine {
+
+    return this.embeddedServer(CIO, port, host) {
+        install(WebSockets)
+        install(CORS) {
+            anyHost()
+        }
+        install(StatusPages) {
+            exception<IllegalArgumentException> { cause ->
+                call.respond(HttpStatusCode.BadRequest, cause.message ?: "")
+            }
+        }
+        deviceManagerModule(manager)
+        routing {
+            get("/") {
+                call.respondRedirect("/dashboard")
+            }
+        }
+    }.start()
+}
+
+public fun ApplicationEngine.whenStarted(callback: Application.() -> Unit) {
+    environment.monitor.subscribe(ApplicationStarted, callback)
+}
+
+
+public val WEB_SERVER_TARGET: Name = "@webServer".asName()
+
+public fun Application.deviceManagerModule(
+    manager: DeviceManager,
+    deviceNames: Collection<String> = manager.devices.keys.map { it.toString() },
+    route: String = "/",
+    rawSocketPort: Int = MagixEndpoint.DEFAULT_MAGIX_RAW_PORT,
+    buffer: Int = 100,
+) {
+    if (featureOrNull(WebSockets) == null) {
+        install(WebSockets)
+    }
+
+    if (featureOrNull(CORS) == null) {
+        install(CORS) {
+            anyHost()
+        }
+    }
+
+    routing {
+        route(route) {
+            get("dashboard") {
+                call.respondHtml {
+                    head {
+                        title("Device server dashboard")
+                    }
+                    body {
+                        h1 {
+                            +"Device server dashboard"
+                        }
+                        deviceNames.forEach { deviceName ->
+                            val device =
+                                manager.getOrNull(deviceName)
+                                    ?: error("The device with name $deviceName not found in $manager")
+                            div {
+                                id = deviceName
+                                h2 { +deviceName }
+                                h3 { +"Properties" }
+                                ul {
+                                    device.propertyDescriptors.forEach { property ->
+                                        li {
+                                            a(href = "../$deviceName/${property.name}/get") { +"${property.name}: " }
+                                            code {
+                                                +Json.encodeToString(property)
+                                            }
+                                        }
+                                    }
+                                }
+                                h3 { +"Actions" }
+                                ul {
+                                    device.actionDescriptors.forEach { action ->
+                                        li {
+                                            +("${action.name}: ")
+                                            code {
+                                                +Json.encodeToString(action)
+                                            }
+                                        }
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+
+            get("list") {
+                call.respondJson {
+                    manager.devices.forEach { (name, device) ->
+                        put("target", name.toString())
+                        put("properties", buildJsonArray {
+                            device.propertyDescriptors.forEach { descriptor ->
+                                add(Json.encodeToJsonElement(descriptor))
+                            }
+                        })
+                        put("actions", buildJsonArray {
+                            device.actionDescriptors.forEach { actionDescriptor ->
+                                add(Json.encodeToJsonElement(actionDescriptor))
+                            }
+                        })
+                    }
+                }
+            }
+
+            post("message") {
+                val body = call.receiveText()
+                val request: DeviceMessage = MagixEndpoint.magixJson.decodeFromString(DeviceMessage.serializer(), body)
+                val response = manager.respondHubMessage(request)
+                if (response != null) {
+                    call.respondMessage(response)
+                } else {
+                    call.respondText("No response")
+                }
+            }
+
+            route("{target}") {
+                //global route for the device
+
+                route("{property}") {
+                    get("get") {
+                        val target: String by call.parameters
+                        val property: String by call.parameters
+                        val request = PropertyGetMessage(
+                            sourceDevice = WEB_SERVER_TARGET,
+                            targetDevice = Name.parse(target),
+                            property = property,
+                        )
+
+                        val response = manager.respondHubMessage(request)
+                        if (response != null) {
+                            call.respondMessage(response)
+                        } else {
+                            call.respond(HttpStatusCode.InternalServerError)
+                        }
+                    }
+                    post("set") {
+                        val target: String by call.parameters
+                        val property: String by call.parameters
+                        val body = call.receiveText()
+                        val json = Json.parseToJsonElement(body)
+
+                        val request = PropertySetMessage(
+                            sourceDevice = WEB_SERVER_TARGET,
+                            targetDevice = Name.parse(target),
+                            property = property,
+                            value = json.toMeta()
+                        )
+
+                        val response = manager.respondHubMessage(request)
+                        if (response != null) {
+                            call.respondMessage(response)
+                        } else {
+                            call.respond(HttpStatusCode.InternalServerError)
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    val magixFlow = MutableSharedFlow<GenericMagixMessage>(
+        buffer,
+        extraBufferCapacity = buffer
+    )
+
+    launchMagixServerRawRSocket(magixFlow, rawSocketPort)
+    magixModule(magixFlow)
+}
\ No newline at end of file
diff --git a/controls-server/src/main/kotlin/ru/mipt/npm/controls/server/responses.kt b/controls-server/src/main/kotlin/ru/mipt/npm/controls/server/responses.kt
new file mode 100644
index 0000000..8041acb
--- /dev/null
+++ b/controls-server/src/main/kotlin/ru/mipt/npm/controls/server/responses.kt
@@ -0,0 +1,31 @@
+package ru.mipt.npm.controls.server
+
+import io.ktor.application.ApplicationCall
+import io.ktor.http.ContentType
+import io.ktor.response.respondText
+import kotlinx.serialization.json.JsonObjectBuilder
+import kotlinx.serialization.json.buildJsonObject
+import ru.mipt.npm.controls.api.DeviceMessage
+import ru.mipt.npm.magix.api.MagixEndpoint
+
+
+//internal fun Frame.toEnvelope(): Envelope {
+//    return data.asBinary().readWith(TaggedEnvelopeFormat)
+//}
+//
+//internal fun Envelope.toFrame(): Frame {
+//    val data = buildByteArray {
+//        writeWith(TaggedEnvelopeFormat, this@toFrame)
+//    }
+//    return Frame.Binary(false, data)
+//}
+
+internal suspend fun ApplicationCall.respondJson(builder: JsonObjectBuilder.() -> Unit) {
+    val json = buildJsonObject(builder)
+    respondText(json.toString(), contentType = ContentType.Application.Json)
+}
+
+internal suspend fun ApplicationCall.respondMessage(message: DeviceMessage): Unit = respondText(
+    MagixEndpoint.magixJson.encodeToString(DeviceMessage.serializer(), message),
+    contentType = ContentType.Application.Json
+)
\ No newline at end of file
diff --git a/controls-tcp/build.gradle.kts b/controls-tcp/build.gradle.kts
new file mode 100644
index 0000000..88d2928
--- /dev/null
+++ b/controls-tcp/build.gradle.kts
@@ -0,0 +1,17 @@
+plugins {
+    id("ru.mipt.npm.gradle.mpp")
+}
+
+val ktorVersion: String by rootProject.extra
+
+kotlin {
+    sourceSets {
+        commonMain {
+            dependencies {
+                api(project(":controls-core"))
+                api("io.ktor:ktor-network:$ktorVersion")
+            }
+        }
+
+    }
+}
\ No newline at end of file
diff --git a/controls-tcp/src/jvmMain/kotlin/ru/mipt/npm/controls/ports/KtorTcpPort.kt b/controls-tcp/src/jvmMain/kotlin/ru/mipt/npm/controls/ports/KtorTcpPort.kt
new file mode 100644
index 0000000..d3f48d3
--- /dev/null
+++ b/controls-tcp/src/jvmMain/kotlin/ru/mipt/npm/controls/ports/KtorTcpPort.kt
@@ -0,0 +1,75 @@
+package ru.mipt.npm.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 java.net.InetSocketAddress
+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(InetSocketAddress(host, port))
+    }
+
+    private val writeChannel = scope.async {
+        futureSocket.await().openWriteChannel(true)
+    }
+
+    private val listenerJob = scope.launch {
+        val input = futureSocket.await().openReadChannel()
+        input.consumeEachBufferRange { buffer, last ->
+            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 {
+        public fun open(
+            context: Context,
+            host: String,
+            port: Int,
+            coroutineContext: CoroutineContext = context.coroutineContext,
+        ): KtorTcpPort {
+            return KtorTcpPort(context, host, port, coroutineContext)
+        }
+
+        override fun invoke(meta: Meta, context: Context): 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/dataforge-device-client/build.gradle.kts b/dataforge-device-client/build.gradle.kts
deleted file mode 100644
index 1404ffe..0000000
--- a/dataforge-device-client/build.gradle.kts
+++ /dev/null
@@ -1,18 +0,0 @@
-plugins {
-    id("scientifik.mpp")
-    id("scientifik.publish")
-}
-
-val ktorVersion: String by extra("1.3.2")
-
-
-kotlin {
-    sourceSets {
-        commonMain{
-            dependencies {
-                implementation(project(":dataforge-device-core"))
-                implementation("io.ktor:ktor-client-cio:$ktorVersion")
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/dataforge-device-core/build.gradle.kts b/dataforge-device-core/build.gradle.kts
deleted file mode 100644
index a809fd7..0000000
--- a/dataforge-device-core/build.gradle.kts
+++ /dev/null
@@ -1,23 +0,0 @@
-import scientifik.useCoroutines
-import scientifik.useSerialization
-
-plugins {
-    id("scientifik.mpp")
-    id("scientifik.publish")
-}
-
-val dataforgeVersion: String by rootProject.extra
-
-useCoroutines(version = "1.3.7")
-useSerialization()
-
-kotlin {
-    sourceSets {
-        commonMain{
-            dependencies {
-                api("hep.dataforge:dataforge-io:$dataforgeVersion")
-                //implementation("org.jetbrains.kotlinx:atomicfu-common:0.14.3")
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/Device.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/Device.kt
deleted file mode 100644
index cda02fc..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/Device.kt
+++ /dev/null
@@ -1,69 +0,0 @@
-package hep.dataforge.control.api
-
-import hep.dataforge.meta.Meta
-import hep.dataforge.meta.MetaItem
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.cancel
-import kotlinx.io.Closeable
-
-/**
- *  General interface describing a managed Device
- */
-interface Device: Closeable {
-    /**
-     * List of supported property descriptors
-     */
-    val propertyDescriptors: Collection<PropertyDescriptor>
-
-    /**
-     * List of supported action descriptors. Action is a request to the device that
-     * may or may not change the properties
-     */
-    val actionDescriptors: Collection<ActionDescriptor>
-
-    /**
-     * The scope encompassing all operations on a device. When canceled, cancels all running processes
-     */
-    val scope: CoroutineScope
-
-    /**
-     * Register a new property change listener for this device.
-     * [owner] is provided optionally in order for listener to be
-     * easily removable
-     */
-    fun registerListener(listener: DeviceListener, owner: Any? = listener)
-
-    /**
-     * Remove all listeners belonging to the specified owner
-     */
-    fun removeListeners(owner: Any?)
-
-    /**
-     * Get the value of the property or throw error if property in not defined.
-     * Suspend if property value is not available
-     */
-    suspend fun getProperty(propertyName: String): MetaItem<*>
-
-    /**
-     * Invalidate property and force recalculate
-     */
-    suspend fun invalidateProperty(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.
-     */
-    suspend fun setProperty(propertyName: String, value: MetaItem<*>)
-
-    /**
-     * Send an action request and suspend caller while request is being processed.
-     * Could return null if request does not return a meaningful answer.
-     */
-    suspend fun exec(action: String, argument: MetaItem<*>? = null): MetaItem<*>?
-
-    override fun close() {
-        scope.cancel("The device is closed")
-    }
-}
-
-suspend fun Device.exec(name: String, meta: Meta?) = exec(name, meta?.let { MetaItem.NodeItem(it) })
\ No newline at end of file
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/DeviceHub.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/DeviceHub.kt
deleted file mode 100644
index 3ad2014..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/DeviceHub.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package hep.dataforge.control.api
-
-import hep.dataforge.meta.MetaItem
-
-/**
- * A hub that could locate multiple devices and redirect actions to them
- */
-interface DeviceHub {
-    fun getDevice(deviceName: String): Device?
-}
-
-suspend fun DeviceHub.getProperty(deviceName: String, propertyName: String): MetaItem<*> =
-    (getDevice(deviceName) ?: error("Device with name $deviceName not found in the hub"))
-        .getProperty(propertyName)
-
-suspend fun DeviceHub.setProperty(deviceName: String, propertyName: String, value: MetaItem<*>) {
-    (getDevice(deviceName) ?: error("Device with name $deviceName not found in the hub"))
-        .setProperty(propertyName, value)
-}
-
-suspend fun DeviceHub.exec(deviceName: String, command: String, argument: MetaItem<*>?): MetaItem<*>? =
-    (getDevice(deviceName) ?: error("Device with name $deviceName not found in the hub"))
-        .exec(command, argument)
\ No newline at end of file
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/DeviceListener.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/DeviceListener.kt
deleted file mode 100644
index 4082483..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/DeviceListener.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package hep.dataforge.control.api
-
-import hep.dataforge.meta.MetaItem
-
-/**
- * PropertyChangeListener Interface
- * [value] is a new value that property has after a change; null is for invalid state.
- */
-interface DeviceListener {
-    fun propertyChanged(propertyName: String, value: MetaItem<*>?)
-    //TODO add general message listener method
-}
\ No newline at end of file
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/descriptors.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/descriptors.kt
deleted file mode 100644
index f64d61c..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/api/descriptors.kt
+++ /dev/null
@@ -1,20 +0,0 @@
-package hep.dataforge.control.api
-
-import hep.dataforge.meta.Scheme
-import hep.dataforge.meta.string
-
-/**
- * A descriptor for property
- */
-class PropertyDescriptor(name: String) : Scheme() {
-    val name by string(name)
-}
-
-/**
- * A descriptor for property
- */
-class ActionDescriptor(name: String) : Scheme() {
-    val name by string(name)
-    //var descriptor by spec(ItemDescriptor)
-}
-
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/Action.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/Action.kt
deleted file mode 100644
index 7b6099b..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/Action.kt
+++ /dev/null
@@ -1,64 +0,0 @@
-package hep.dataforge.control.base
-
-import hep.dataforge.control.api.ActionDescriptor
-import hep.dataforge.meta.MetaBuilder
-import hep.dataforge.meta.MetaItem
-import hep.dataforge.values.Value
-import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
-
-interface Action {
-    val name: String
-    val descriptor: ActionDescriptor
-    suspend operator fun invoke(arg: MetaItem<*>? = null): MetaItem<*>?
-}
-
-class SimpleAction(
-    override val name: String,
-    override val descriptor: ActionDescriptor,
-    val block: suspend (MetaItem<*>?) -> MetaItem<*>?
-) : Action {
-    override suspend fun invoke(arg: MetaItem<*>?): MetaItem<*>? = block(arg)
-}
-
-class ActionDelegate<D : DeviceBase>(
-    val owner: D,
-    val descriptorBuilder: ActionDescriptor.()->Unit = {},
-    val block: suspend (MetaItem<*>?) -> MetaItem<*>?
-) : ReadOnlyProperty<D, Action> {
-    override fun getValue(thisRef: D, property: KProperty<*>): Action {
-        val name = property.name
-        return owner.resolveAction(name) {
-            SimpleAction(name, ActionDescriptor(name).apply(descriptorBuilder), block)
-        }
-    }
-}
-
-fun <D : DeviceBase> D.request(
-    descriptorBuilder: ActionDescriptor.()->Unit = {},
-    block: suspend (MetaItem<*>?) -> MetaItem<*>?
-): ActionDelegate<D> = ActionDelegate(this, descriptorBuilder, block)
-
-fun <D : DeviceBase> D.requestValue(
-    descriptorBuilder: ActionDescriptor.()->Unit = {},
-    block: suspend (MetaItem<*>?) -> Any?
-): ActionDelegate<D> = ActionDelegate(this, descriptorBuilder){
-    val res = block(it)
-    MetaItem.ValueItem(Value.of(res))
-}
-
-fun <D : DeviceBase> D.requestMeta(
-    descriptorBuilder: ActionDescriptor.()->Unit = {},
-    block: suspend MetaBuilder.(MetaItem<*>?) -> Unit
-): ActionDelegate<D> = ActionDelegate(this, descriptorBuilder){
-    val res = MetaBuilder().apply { block(it)}
-    MetaItem.NodeItem(res)
-}
-
-fun <D : DeviceBase> D.action(
-    descriptorBuilder: ActionDescriptor.()->Unit = {},
-    block: suspend (MetaItem<*>?) -> Unit
-): ActionDelegate<D> = ActionDelegate(this, descriptorBuilder) {
-    block(it)
-    null
-}
\ No newline at end of file
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/DeviceBase.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/DeviceBase.kt
deleted file mode 100644
index 16a2ff5..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/DeviceBase.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package hep.dataforge.control.base
-
-import hep.dataforge.control.api.ActionDescriptor
-import hep.dataforge.control.api.Device
-import hep.dataforge.control.api.DeviceListener
-import hep.dataforge.control.api.PropertyDescriptor
-import hep.dataforge.meta.MetaItem
-
-/**
- * Baseline implementation of [Device] interface
- */
-abstract class DeviceBase : Device {
-    private val properties = HashMap<String, ReadOnlyDeviceProperty>()
-    private val actions = HashMap<String, Action>()
-
-    private val listeners = ArrayList<Pair<Any?, DeviceListener>>(4)
-
-    override fun registerListener(listener: DeviceListener, owner: Any?) {
-        listeners.add(owner to listener)
-    }
-
-    override fun removeListeners(owner: Any?) {
-        listeners.removeAll { it.first == owner }
-    }
-
-    internal fun propertyChanged(propertyName: String, value: MetaItem<*>?) {
-        listeners.forEach { it.second.propertyChanged(propertyName, value) }
-    }
-
-    override val propertyDescriptors: Collection<PropertyDescriptor>
-        get() = properties.values.map { it.descriptor }
-
-    override val actionDescriptors: Collection<ActionDescriptor>
-        get() = actions.values.map { it.descriptor }
-
-    internal fun resolveProperty(name: String, builder: () -> ReadOnlyDeviceProperty): ReadOnlyDeviceProperty {
-        return properties.getOrPut(name, builder)
-    }
-
-    internal fun resolveAction(name: String, builder: () -> Action): Action {
-        return actions.getOrPut(name, builder)
-    }
-
-    override suspend fun getProperty(propertyName: String): MetaItem<*> =
-        (properties[propertyName] ?: error("Property with name $propertyName not defined")).read()
-
-    override suspend fun invalidateProperty(propertyName: String) {
-        (properties[propertyName] ?: error("Property with name $propertyName not defined")).invalidate()
-    }
-
-    override suspend fun setProperty(propertyName: String, value: MetaItem<*>) {
-        (properties[propertyName] as? DeviceProperty ?: error("Property with name $propertyName not defined")).write(
-            value
-        )
-    }
-
-    override suspend fun exec(action: String, argument: MetaItem<*>?): MetaItem<*>? =
-        (actions[action] ?: error("Request with name $action not defined")).invoke(argument)
-
-
-    companion object {
-
-    }
-}
-
-
-
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/IsolatedDeviceProperty.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/IsolatedDeviceProperty.kt
deleted file mode 100644
index e24ac17..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/IsolatedDeviceProperty.kt
+++ /dev/null
@@ -1,257 +0,0 @@
-package hep.dataforge.control.base
-
-import hep.dataforge.control.api.PropertyDescriptor
-import hep.dataforge.meta.Meta
-import hep.dataforge.meta.MetaBuilder
-import hep.dataforge.meta.MetaItem
-import hep.dataforge.meta.double
-import hep.dataforge.values.Value
-import hep.dataforge.values.asValue
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.sync.Mutex
-import kotlinx.coroutines.sync.withLock
-import kotlinx.coroutines.withContext
-import kotlin.properties.ReadOnlyProperty
-import kotlin.reflect.KProperty
-
-/**
- * A stand-alone [ReadOnlyDeviceProperty] implementation not directly attached to a device
- */
-@OptIn(ExperimentalCoroutinesApi::class)
-open class IsolatedReadOnlyDeviceProperty(
-    override val name: String,
-    default: MetaItem<*>?,
-    override val descriptor: PropertyDescriptor,
-    override val scope: CoroutineScope,
-    private val updateCallback: (name: String, item: MetaItem<*>) -> Unit,
-    private val getter: suspend (before: MetaItem<*>?) -> MetaItem<*>
-) : ReadOnlyDeviceProperty {
-
-    private val state: MutableStateFlow<MetaItem<*>?> = MutableStateFlow(default)
-    override val value: MetaItem<*>? get() = state.value
-
-    override suspend fun invalidate() {
-        state.value = null
-    }
-
-    protected fun update(item: MetaItem<*>) {
-        state.value = item
-        updateCallback(name, item)
-    }
-
-    override suspend fun read(force: Boolean): MetaItem<*> {
-        //backup current value
-        val currentValue = value
-        return if (force || currentValue == null) {
-            val res = withContext(scope.coroutineContext) {
-                //all device operations should be run on device context
-                //TODO add error catching
-                getter(currentValue)
-            }
-            update(res)
-            res
-        } else {
-            currentValue
-        }
-    }
-
-    override fun flow(): StateFlow<MetaItem<*>?> = state
-}
-
-private class ReadOnlyDevicePropertyDelegate<D : DeviceBase>(
-    val owner: D,
-    val default: MetaItem<*>?,
-    val descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    private val getter: suspend (MetaItem<*>?) -> MetaItem<*>
-) : ReadOnlyProperty<D, IsolatedReadOnlyDeviceProperty> {
-
-    override fun getValue(thisRef: D, property: KProperty<*>): IsolatedReadOnlyDeviceProperty {
-        val name = property.name
-
-        return owner.resolveProperty(name) {
-            @OptIn(ExperimentalCoroutinesApi::class)
-            IsolatedReadOnlyDeviceProperty(
-                name,
-                default,
-                PropertyDescriptor(name).apply(descriptorBuilder),
-                owner.scope,
-                owner::propertyChanged,
-                getter
-            )
-        } as IsolatedReadOnlyDeviceProperty
-    }
-}
-
-fun <D : DeviceBase> D.reading(
-    default: MetaItem<*>? = null,
-    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    getter: suspend (MetaItem<*>?) -> MetaItem<*>
-): ReadOnlyProperty<D, IsolatedReadOnlyDeviceProperty> = ReadOnlyDevicePropertyDelegate(
-    this,
-    default,
-    descriptorBuilder,
-    getter
-)
-
-fun <D : DeviceBase> D.readingValue(
-    default: Value? = null,
-    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    getter: suspend () -> Any
-): ReadOnlyProperty<D, IsolatedReadOnlyDeviceProperty> = ReadOnlyDevicePropertyDelegate(
-    this,
-    default?.let { MetaItem.ValueItem(it) },
-    descriptorBuilder,
-    getter = { MetaItem.ValueItem(Value.of(getter())) }
-)
-
-fun <D : DeviceBase> D.readingNumber(
-    default: Number? = null,
-    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    getter: suspend () -> Number
-): ReadOnlyProperty<D, IsolatedReadOnlyDeviceProperty> = ReadOnlyDevicePropertyDelegate(
-    this,
-    default?.let { MetaItem.ValueItem(it.asValue()) },
-    descriptorBuilder,
-    getter = {
-        val number = getter()
-        MetaItem.ValueItem(number.asValue())
-    }
-)
-
-fun <D : DeviceBase> D.readingMeta(
-    default: Meta? = null,
-    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    getter: suspend MetaBuilder.() -> Unit
-): ReadOnlyProperty<D, IsolatedReadOnlyDeviceProperty> = ReadOnlyDevicePropertyDelegate(
-    this,
-    default?.let { MetaItem.NodeItem(it) },
-    descriptorBuilder,
-    getter = {
-        MetaItem.NodeItem(MetaBuilder().apply { getter() })
-    }
-)
-
-@OptIn(ExperimentalCoroutinesApi::class)
-class IsolatedDeviceProperty(
-    name: String,
-    default: MetaItem<*>?,
-    descriptor: PropertyDescriptor,
-    scope: CoroutineScope,
-    updateCallback: (name: String, item: MetaItem<*>?) -> Unit,
-    getter: suspend (MetaItem<*>?) -> MetaItem<*>,
-    private val setter: suspend (oldValue: MetaItem<*>?, newValue: MetaItem<*>) -> MetaItem<*>?
-) : IsolatedReadOnlyDeviceProperty(name, default, descriptor, scope, updateCallback, getter), DeviceProperty {
-
-    override var value: MetaItem<*>?
-        get() = super.value
-        set(value) {
-            scope.launch {
-                if (value == null) {
-                    invalidate()
-                } else {
-                    write(value)
-                }
-            }
-        }
-
-    private val writeLock = Mutex()
-
-    override suspend fun write(item: MetaItem<*>) {
-        writeLock.withLock {
-            //fast return if value is not changed
-            if (item == value) return@withLock
-            val oldValue = value
-            //all device operations should be run on device context
-            withContext(scope.coroutineContext) {
-                //TODO add error catching
-                setter(oldValue, item)?.let {
-                    update(it)
-                }
-            }
-        }
-    }
-}
-
-private class DevicePropertyDelegate<D : DeviceBase>(
-    val owner: D,
-    val default: MetaItem<*>?,
-    val descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    private val getter: suspend (MetaItem<*>?) -> MetaItem<*>,
-    private val setter: suspend (oldValue: MetaItem<*>?, newValue: MetaItem<*>) -> MetaItem<*>?
-) : ReadOnlyProperty<D, IsolatedDeviceProperty> {
-
-    override fun getValue(thisRef: D, property: KProperty<*>): IsolatedDeviceProperty {
-        val name = property.name
-        return owner.resolveProperty(name) {
-            @OptIn(ExperimentalCoroutinesApi::class)
-            IsolatedDeviceProperty(
-                name,
-                default,
-                PropertyDescriptor(name).apply(descriptorBuilder),
-                owner.scope,
-                owner::propertyChanged,
-                getter,
-                setter
-            )
-        } as IsolatedDeviceProperty
-    }
-}
-
-fun <D : DeviceBase> D.writing(
-    default: MetaItem<*>? = null,
-    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    getter: suspend (MetaItem<*>?) -> MetaItem<*>,
-    setter: suspend (oldValue: MetaItem<*>?, newValue: MetaItem<*>) -> MetaItem<*>?
-): ReadOnlyProperty<D, IsolatedDeviceProperty> = DevicePropertyDelegate(
-    this,
-    default,
-    descriptorBuilder,
-    getter,
-    setter
-)
-
-fun <D : DeviceBase> D.writingVirtual(
-    default: MetaItem<*>,
-    descriptorBuilder: PropertyDescriptor.() -> Unit = {}
-): ReadOnlyProperty<D, IsolatedDeviceProperty> = writing(
-    default,
-    descriptorBuilder,
-    getter = { it ?: default },
-    setter = { _, newItem -> newItem }
-)
-
-fun <D : DeviceBase> D.writingVirtual(
-    default: Value,
-    descriptorBuilder: PropertyDescriptor.() -> Unit = {}
-): ReadOnlyProperty<D, IsolatedDeviceProperty> = writing(
-    MetaItem.ValueItem(default),
-    descriptorBuilder,
-    getter = { it ?: MetaItem.ValueItem(default) },
-    setter = { _, newItem -> newItem }
-)
-
-fun <D : DeviceBase> D.writingDouble(
-    descriptorBuilder: PropertyDescriptor.() -> Unit = {},
-    getter: suspend (Double) -> Double,
-    setter: suspend (oldValue: Double?, newValue: Double) -> Double?
-): ReadOnlyProperty<D, IsolatedDeviceProperty> {
-    val innerGetter: suspend (MetaItem<*>?) -> MetaItem<*> = {
-        MetaItem.ValueItem(getter(it.double ?: Double.NaN).asValue())
-    }
-
-    val innerSetter: suspend (oldValue: MetaItem<*>?, newValue: MetaItem<*>) -> MetaItem<*>? = { oldValue, newValue ->
-        setter(oldValue.double, newValue.double ?: Double.NaN)?.asMetaItem()
-    }
-
-    return DevicePropertyDelegate(
-        this,
-        MetaItem.ValueItem(Double.NaN.asValue()),
-        descriptorBuilder,
-        innerGetter,
-        innerSetter
-    )
-}
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/misc.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/misc.kt
deleted file mode 100644
index e627c18..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/base/misc.kt
+++ /dev/null
@@ -1,6 +0,0 @@
-package hep.dataforge.control.base
-
-import hep.dataforge.meta.MetaItem
-import hep.dataforge.values.asValue
-
-fun Double.asMetaItem(): MetaItem.ValueItem = MetaItem.ValueItem(asValue())
\ No newline at end of file
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/DeviceMessage.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/DeviceMessage.kt
deleted file mode 100644
index e666f9b..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/DeviceMessage.kt
+++ /dev/null
@@ -1,73 +0,0 @@
-package hep.dataforge.control.controllers
-
-import hep.dataforge.control.controllers.DeviceMessage.Companion.PAYLOAD_VALUE_KEY
-import hep.dataforge.meta.*
-import hep.dataforge.names.asName
-import kotlinx.serialization.*
-
-@Serializable
-class DeviceMessage : Scheme() {
-    var id by string()
-    var parent by string()
-    var origin by string()
-    var target by string()
-    var action by string(default = MessageController.GET_PROPERTY_ACTION, key = MESSAGE_ACTION_KEY)
-    var comment by string()
-    var status by string(RESPONSE_OK_STATUS)
-    var payload: List<MessagePayload>
-        get() = config.getIndexed(MESSAGE_PAYLOAD_KEY).values.map { MessagePayload.wrap(it.node!!) }
-        set(value) {
-            config[MESSAGE_PAYLOAD_KEY] = value.map { it.config }
-        }
-
-    /**
-     * Append a payload to this message according to the given scheme
-     */
-    fun <T : Configurable> append(spec: Specification<T>, block: T.() -> Unit): T =
-        spec.invoke(block).also { config.append(MESSAGE_PAYLOAD_KEY, it) }
-
-    companion object : SchemeSpec<DeviceMessage>(::DeviceMessage), KSerializer<DeviceMessage> {
-        val MESSAGE_ACTION_KEY = "action".asName()
-        val MESSAGE_PAYLOAD_KEY = "payload".asName()
-        val PAYLOAD_VALUE_KEY = "value".asName()
-        const val RESPONSE_OK_STATUS = "response.OK"
-        const val RESPONSE_FAIL_STATUS = "response.FAIL"
-        const val PROPERTY_CHANGED_ACTION = "event.propertyChange"
-
-        inline fun ok(
-            request: DeviceMessage? = null,
-            block: DeviceMessage.() -> Unit = {}
-        ): DeviceMessage = DeviceMessage {
-            parent = request?.id
-        }.apply(block)
-
-        inline fun fail(
-            request: DeviceMessage? = null,
-            block: DeviceMessage.() -> Unit = {}
-        ): DeviceMessage = DeviceMessage {
-            parent = request?.id
-            status = RESPONSE_FAIL_STATUS
-        }.apply(block)
-
-        override val descriptor: SerialDescriptor = MetaSerializer.descriptor
-
-        override fun deserialize(decoder: Decoder): DeviceMessage {
-            val meta = MetaSerializer.deserialize(decoder)
-            return wrap(meta)
-        }
-
-        override fun serialize(encoder: Encoder, value: DeviceMessage) {
-            MetaSerializer.serialize(encoder, value.toMeta())
-        }
-    }
-}
-
-class MessagePayload : Scheme() {
-    var name by string { error("Property name could not be empty") }
-    var value by item(key = PAYLOAD_VALUE_KEY)
-
-    companion object : SchemeSpec<MessagePayload>(::MessagePayload)
-}
-
-@DFBuilder
-fun DeviceMessage.property(block: MessagePayload.() -> Unit): MessagePayload = append(MessagePayload, block)
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/MessageController.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/MessageController.kt
deleted file mode 100644
index 1c61c33..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/MessageController.kt
+++ /dev/null
@@ -1,149 +0,0 @@
-package hep.dataforge.control.controllers
-
-import hep.dataforge.control.api.Device
-import hep.dataforge.control.api.DeviceListener
-import hep.dataforge.control.controllers.DeviceMessage.Companion.PROPERTY_CHANGED_ACTION
-import hep.dataforge.io.Envelope
-import hep.dataforge.io.Responder
-import hep.dataforge.io.SimpleEnvelope
-import hep.dataforge.meta.MetaItem
-import hep.dataforge.meta.wrap
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.flow.consumeAsFlow
-import kotlinx.coroutines.launch
-import kotlinx.io.Binary
-
-/**
- * A consumer of envelopes
- */
-interface Consumer {
-    fun consume(message: Envelope): Unit
-}
-
-class MessageController(
-    val device: Device,
-    val deviceTarget: String,
-    val scope: CoroutineScope = device.scope
-) : Consumer, Responder, DeviceListener {
-
-    init {
-        device.registerListener(this, this)
-    }
-
-    private val outputChannel = Channel<Envelope>(Channel.CONFLATED)
-
-    suspend fun respondMessage(
-        request: DeviceMessage
-    ): DeviceMessage = if (request.target != null && request.target != deviceTarget) {
-        DeviceMessage.fail {
-            comment = "Wrong target name $deviceTarget expected but ${request.target} found"
-        }
-    } else try {
-        val result: List<MessagePayload> = when (val action = request.action) {
-            GET_PROPERTY_ACTION -> {
-                request.payload.map { property ->
-                    MessagePayload {
-                        name = property.name
-                        value = device.getProperty(name)
-                    }
-                }
-            }
-            SET_PROPERTY_ACTION -> {
-                request.payload.map { property ->
-                    val propertyName: String = property.name
-                    val propertyValue = property.value
-                    if (propertyValue == null) {
-                        device.invalidateProperty(propertyName)
-                    } else {
-                        device.setProperty(propertyName, propertyValue)
-                    }
-                    MessagePayload {
-                        name = propertyName
-                        value = device.getProperty(propertyName)
-                    }
-                }
-            }
-            EXECUTE_ACTION -> {
-                request.payload.map { payload ->
-                    MessagePayload {
-                        name = payload.name
-                        value = device.exec(payload.name, payload.value)
-                    }
-                }
-            }
-            PROPERTY_LIST_ACTION -> {
-                device.propertyDescriptors.map { descriptor ->
-                    MessagePayload {
-                        name = descriptor.name
-                        value = MetaItem.NodeItem(descriptor.config)
-                    }
-                }
-            }
-
-            ACTION_LIST_ACTION -> {
-                device.actionDescriptors.map { descriptor ->
-                    MessagePayload {
-                        name = descriptor.name
-                        value = MetaItem.NodeItem(descriptor.config)
-                    }
-                }
-            }
-
-            else -> {
-                error("Unrecognized action $action")
-            }
-        }
-        DeviceMessage.ok {
-            this.parent = request.id
-            this.origin = deviceTarget
-            this.target = request.origin
-            this.payload = result
-        }
-    } catch (ex: Exception) {
-        DeviceMessage.fail {
-            comment = ex.message
-        }
-    }
-
-    override fun consume(message: Envelope) {
-        // Fire the respond procedure and forget about the result
-        scope.launch {
-            respond(message)
-        }
-    }
-
-    override suspend fun respond(request: Envelope): Envelope {
-        val requestMessage = DeviceMessage.wrap(request.meta)
-        val responseMessage = respondMessage(requestMessage)
-        return SimpleEnvelope(responseMessage.toMeta(), Binary.EMPTY)
-    }
-
-    override fun propertyChanged(propertyName: String, value: MetaItem<*>?) {
-        if (value == null) return
-        scope.launch {
-            val change = DeviceMessage.ok {
-                this.origin = deviceTarget
-                action = PROPERTY_CHANGED_ACTION
-                property {
-                    name = propertyName
-                    this.value = value
-                }
-            }
-            val envelope = SimpleEnvelope(change.toMeta(), Binary.EMPTY)
-
-            outputChannel.send(envelope)
-        }
-    }
-
-    fun output() = outputChannel.consumeAsFlow()
-
-
-    companion object {
-        const val GET_PROPERTY_ACTION = "read"
-        const val SET_PROPERTY_ACTION = "write"
-        const val EXECUTE_ACTION = "execute"
-        const val PROPERTY_LIST_ACTION = "propertyList"
-        const val ACTION_LIST_ACTION = "actionList"
-    }
-}
\ No newline at end of file
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/MessageFlow.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/MessageFlow.kt
deleted file mode 100644
index e69de29..0000000
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/PropertyFlow.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/PropertyFlow.kt
deleted file mode 100644
index ed45c53..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/PropertyFlow.kt
+++ /dev/null
@@ -1,28 +0,0 @@
-package hep.dataforge.control.controllers
-
-import hep.dataforge.control.api.Device
-import hep.dataforge.control.api.DeviceListener
-import hep.dataforge.meta.MetaItem
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.channels.awaitClose
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.callbackFlow
-import kotlinx.coroutines.launch
-
-
-@ExperimentalCoroutinesApi
-suspend fun Device.flowValues(): Flow<Pair<String, MetaItem<*>>> = callbackFlow {
-    val listener = object : DeviceListener {
-        override fun propertyChanged(propertyName: String, value: MetaItem<*>?) {
-            if (value != null) {
-                launch {
-                    send(propertyName to value)
-                }
-            }
-        }
-    }
-    registerListener(listener)
-    awaitClose {
-        removeListeners(listener)
-    }
-}
\ No newline at end of file
diff --git a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/delegates.kt b/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/delegates.kt
deleted file mode 100644
index b315926..0000000
--- a/dataforge-device-core/src/commonMain/kotlin/hep/dataforge/control/controllers/delegates.kt
+++ /dev/null
@@ -1,40 +0,0 @@
-package hep.dataforge.control.controllers
-
-import hep.dataforge.control.base.DeviceProperty
-import hep.dataforge.control.base.ReadOnlyDeviceProperty
-import hep.dataforge.meta.MetaItem
-import hep.dataforge.meta.transformations.MetaConverter
-import hep.dataforge.values.Null
-import kotlin.properties.ReadOnlyProperty
-import kotlin.properties.ReadWriteProperty
-import kotlin.reflect.KProperty
-
-operator fun ReadOnlyDeviceProperty.getValue(thisRef: Any?, property: KProperty<*>): MetaItem<*> =
-    value ?: MetaItem.ValueItem(Null)
-
-operator fun DeviceProperty.setValue(thisRef: Any?, property: KProperty<*>, value: MetaItem<*>) {
-    this.value = value
-}
-
-fun <T : Any> ReadOnlyDeviceProperty.convert(metaConverter: MetaConverter<T>): ReadOnlyProperty<Any?, T> {
-    return object : ReadOnlyProperty<Any?, T> {
-        override fun getValue(thisRef: Any?, property: KProperty<*>): T {
-            return this@convert.getValue(thisRef, property).let { metaConverter.itemToObject(it) }
-        }
-    }
-}
-
-fun <T : Any> DeviceProperty.convert(metaConverter: MetaConverter<T>): ReadWriteProperty<Any?, T> {
-    return object : ReadWriteProperty<Any?, T> {
-        override fun getValue(thisRef: Any?, property: KProperty<*>): T {
-            return this@convert.getValue(thisRef, property).let { metaConverter.itemToObject(it) }
-        }
-
-        override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) {
-            this@convert.setValue(thisRef, property, value.let { metaConverter.objectToMetaItem(it) })
-        }
-    }
-}
-
-fun ReadOnlyDeviceProperty.double() = convert(MetaConverter.double)
-fun DeviceProperty.double() = convert(MetaConverter.double)
diff --git a/dataforge-device-server/build.gradle.kts b/dataforge-device-server/build.gradle.kts
deleted file mode 100644
index a04075f..0000000
--- a/dataforge-device-server/build.gradle.kts
+++ /dev/null
@@ -1,19 +0,0 @@
-import scientifik.useSerialization
-
-plugins {
-    id("scientifik.jvm")
-    id("scientifik.publish")
-}
-
-useSerialization()
-
-val dataforgeVersion: String by rootProject.extra
-val ktorVersion: String by extra("1.3.2")
-
-dependencies{
-    implementation(project(":dataforge-device-core"))
-    implementation("io.ktor:ktor-server-cio:$ktorVersion")
-    implementation("io.ktor:ktor-websockets:$ktorVersion")
-    implementation("io.ktor:ktor-serialization:$ktorVersion")
-    implementation("io.ktor:ktor-html-builder:$ktorVersion")
-}
\ No newline at end of file
diff --git a/dataforge-device-server/src/main/kotlin/hep/dataforge/control/server/conversions.kt b/dataforge-device-server/src/main/kotlin/hep/dataforge/control/server/conversions.kt
deleted file mode 100644
index 7bc25eb..0000000
--- a/dataforge-device-server/src/main/kotlin/hep/dataforge/control/server/conversions.kt
+++ /dev/null
@@ -1,43 +0,0 @@
-package hep.dataforge.control.server
-
-import hep.dataforge.control.controllers.DeviceMessage
-import hep.dataforge.io.*
-import hep.dataforge.meta.MetaSerializer
-import io.ktor.application.ApplicationCall
-import io.ktor.http.ContentType
-import io.ktor.http.cio.websocket.Frame
-import io.ktor.response.respondText
-import kotlinx.io.asBinary
-import kotlinx.serialization.UnstableDefault
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.JsonObjectBuilder
-import kotlinx.serialization.json.json
-
-fun Frame.toEnvelope(): Envelope {
-    return data.asBinary().readWith(TaggedEnvelopeFormat)
-}
-
-fun Envelope.toFrame(): Frame {
-    val data = buildByteArray {
-        writeWith(TaggedEnvelopeFormat,this@toFrame)
-    }
-    return Frame.Binary(false, data)
-}
-
-suspend fun ApplicationCall.respondJson(builder: JsonObjectBuilder.() -> Unit) {
-    val json = json(builder)
-    respondText(json.toString(), contentType = ContentType.Application.Json)
-}
-
-@OptIn(UnstableDefault::class)
-suspend fun ApplicationCall.respondMessage(message: DeviceMessage) {
-    respondText(Json.stringify(MetaSerializer,message.toMeta()), contentType = ContentType.Application.Json)
-}
-
-suspend fun ApplicationCall.respondMessage(builder: DeviceMessage.() -> Unit) {
-    respondMessage(DeviceMessage(builder))
-}
-
-suspend fun ApplicationCall.respondFail(builder: DeviceMessage.() -> Unit) {
-    respondMessage(DeviceMessage.fail(null, builder))
-}
\ No newline at end of file
diff --git a/dataforge-device-server/src/main/kotlin/hep/dataforge/control/server/deviceWebServer.kt b/dataforge-device-server/src/main/kotlin/hep/dataforge/control/server/deviceWebServer.kt
deleted file mode 100644
index 0ccc854..0000000
--- a/dataforge-device-server/src/main/kotlin/hep/dataforge/control/server/deviceWebServer.kt
+++ /dev/null
@@ -1,230 +0,0 @@
-@file:OptIn(ExperimentalCoroutinesApi::class, KtorExperimentalAPI::class, FlowPreview::class, UnstableDefault::class)
-
-package hep.dataforge.control.server
-
-import hep.dataforge.control.api.Device
-import hep.dataforge.control.controllers.DeviceMessage
-import hep.dataforge.control.controllers.MessageController
-import hep.dataforge.control.controllers.MessageController.Companion.GET_PROPERTY_ACTION
-import hep.dataforge.control.controllers.MessageController.Companion.SET_PROPERTY_ACTION
-import hep.dataforge.control.controllers.property
-import hep.dataforge.meta.toJson
-import hep.dataforge.meta.toMeta
-import hep.dataforge.meta.toMetaItem
-import hep.dataforge.meta.wrap
-import io.ktor.application.*
-import io.ktor.features.CORS
-import io.ktor.features.StatusPages
-import io.ktor.html.respondHtml
-import io.ktor.http.HttpStatusCode
-import io.ktor.request.receiveText
-import io.ktor.response.respond
-import io.ktor.response.respondRedirect
-import io.ktor.routing.*
-import io.ktor.server.cio.CIO
-import io.ktor.server.engine.ApplicationEngine
-import io.ktor.server.engine.embeddedServer
-import io.ktor.util.KtorExperimentalAPI
-import io.ktor.util.getValue
-import io.ktor.websocket.WebSockets
-import io.ktor.websocket.webSocket
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.ExperimentalCoroutinesApi
-import kotlinx.coroutines.FlowPreview
-import kotlinx.coroutines.flow.asFlow
-import kotlinx.coroutines.flow.collect
-import kotlinx.coroutines.flow.flatMapMerge
-import kotlinx.html.body
-import kotlinx.html.h1
-import kotlinx.html.head
-import kotlinx.html.title
-import kotlinx.serialization.UnstableDefault
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.JsonObject
-import kotlinx.serialization.json.jsonArray
-
-/**
- * Create and start a web server for several devices
- */
-fun CoroutineScope.startDeviceServer(
-    devices: Map<String, Device>,
-    port: Int = 8111,
-    host: String = "localhost"
-): ApplicationEngine {
-
-    val controllers = devices.mapValues {
-        MessageController(it.value, it.key, this)
-    }
-
-    return this.embeddedServer(CIO, port, host) {
-        install(WebSockets)
-        install(CORS) {
-            anyHost()
-        }
-//        install(ContentNegotiation) {
-//            json()
-//        }
-        install(StatusPages) {
-            exception<IllegalArgumentException> { cause ->
-                call.respond(HttpStatusCode.BadRequest, cause.message ?: "")
-            }
-        }
-        deviceModule(controllers)
-        routing {
-            get("/") {
-                call.respondRedirect("/dashboard")
-            }
-        }
-    }.start()
-}
-
-fun ApplicationEngine.whenStarted(callback: Application.() -> Unit){
-    environment.monitor.subscribe(ApplicationStarted, callback)
-}
-
-
-const val WEB_SERVER_TARGET = "@webServer"
-
-private suspend fun ApplicationCall.message(target: MessageController) {
-    val body = receiveText()
-    val json = Json.parseJson(body) as? JsonObject
-        ?: throw IllegalArgumentException("The body is not a json object")
-    val meta = json.toMeta()
-
-    val request = DeviceMessage.wrap(meta)
-
-    val response = target.respondMessage(request)
-    respondMessage(response)
-}
-
-private suspend fun ApplicationCall.getProperty(target: MessageController) {
-    val property: String by parameters
-    val request = DeviceMessage {
-        action = GET_PROPERTY_ACTION
-        origin = WEB_SERVER_TARGET
-        this.target = target.deviceTarget
-        property {
-            name = property
-        }
-    }
-
-    val response = target.respondMessage(request)
-    respondMessage(response)
-}
-
-private suspend fun ApplicationCall.setProperty(target: MessageController) {
-    val property: String by parameters
-    val body = receiveText()
-    val json = Json.parseJson(body)
-
-    val request = DeviceMessage {
-        action = SET_PROPERTY_ACTION
-        origin = WEB_SERVER_TARGET
-        this.target = target.deviceTarget
-        property {
-            name = property
-            value = json.toMetaItem()
-        }
-    }
-
-    val response = target.respondMessage(request)
-    respondMessage(response)
-}
-
-@OptIn(KtorExperimentalAPI::class)
-fun Application.deviceModule(targets: Map<String, MessageController>, route: String = "/") {
-    if(featureOrNull(WebSockets) == null) {
-        install(WebSockets)
-    }
-    if(featureOrNull(CORS)==null){
-        install(CORS) {
-            anyHost()
-        }
-    }
-    fun generateFlow(target: String?) = if (target == null) {
-        targets.values.asFlow().flatMapMerge { it.output() }
-    } else {
-        targets[target]?.output() ?: error("The device with target $target not found")
-    }
-    routing {
-        route(route) {
-            get("dashboard") {
-                call.respondHtml {
-                    head {
-                        title("Device server dashboard")
-                    }
-                    body {
-                        h1 {
-                            +"Under construction"
-                        }
-                    }
-                }
-            }
-
-            get("list") {
-                call.respondJson {
-                    targets.values.forEach { controller ->
-                        "target" to controller.deviceTarget
-                        val device = controller.device
-                        "properties" to jsonArray {
-                            device.propertyDescriptors.forEach { descriptor ->
-                                +descriptor.config.toJson()
-                            }
-                        }
-                        "actions" to jsonArray {
-                            device.actionDescriptors.forEach { actionDescriptor ->
-                                +actionDescriptor.config.toJson()
-                            }
-                        }
-                    }
-                }
-            }
-            //Check if application supports websockets and if it does add a push channel
-            if (this.application.featureOrNull(WebSockets) != null) {
-                webSocket("ws") {
-                    //subscribe on device
-                    val target: String? by call.request.queryParameters
-
-                    try {
-                        application.log.debug("Opened server socket for ${call.request.queryParameters}")
-
-                        generateFlow(target).collect {
-                            outgoing.send(it.toFrame())
-                        }
-
-                    } catch (ex: Exception) {
-                        application.log.debug("Closed server socket for ${call.request.queryParameters}")
-                    }
-                }
-            }
-
-            post("message") {
-                val target: String by call.request.queryParameters
-                val controller =
-                    targets[target] ?: throw IllegalArgumentException("Target $target not found in $targets")
-                call.message(controller)
-            }
-
-            route("{target}") {
-                //global route for the device
-
-                route("{property}") {
-                    get("get") {
-                        val target: String by call.parameters
-                        val controller = targets[target]
-                            ?: throw IllegalArgumentException("Target $target not found in $targets")
-
-                        call.getProperty(controller)
-                    }
-                    post("set") {
-                        val target: String by call.parameters
-                        val controller =
-                            targets[target] ?: throw IllegalArgumentException("Target $target not found in $targets")
-
-                        call.setProperty(controller)
-                    }
-                }
-            }
-        }
-    }
-}
\ No newline at end of file
diff --git a/dataforge-device-server/src/main/kotlin/hep/dataforge/control/server/sse.kt b/dataforge-device-server/src/main/kotlin/hep/dataforge/control/server/sse.kt
deleted file mode 100644
index 34ce2c1..0000000
--- a/dataforge-device-server/src/main/kotlin/hep/dataforge/control/server/sse.kt
+++ /dev/null
@@ -1,41 +0,0 @@
-package hep.dataforge.control.server
-
-import io.ktor.application.ApplicationCall
-import io.ktor.http.CacheControl
-import io.ktor.http.ContentType
-import io.ktor.response.cacheControl
-import io.ktor.response.respondTextWriter
-import kotlinx.coroutines.channels.ReceiveChannel
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.collect
-
-/**
- * The data class representing a SSE Event that will be sent to the client.
- */
-data class SseEvent(val data: String, val event: String? = null, val id: String? = null)
-
-/**
- * Method that responds an [ApplicationCall] by reading all the [SseEvent]s from the specified [events] [ReceiveChannel]
- * and serializing them in a way that is compatible with the Server-Sent Events specification.
- *
- * You can read more about it here: https://www.html5rocks.com/en/tutorials/eventsource/basics/
- */
-@Suppress("BlockingMethodInNonBlockingContext")
-suspend fun ApplicationCall.respondSse(events: Flow<SseEvent>) {
-    response.cacheControl(CacheControl.NoCache(null))
-    respondTextWriter(contentType = ContentType.Text.EventStream) {
-        events.collect {  event->
-            if (event.id != null) {
-                write("id: ${event.id}\n")
-            }
-            if (event.event != null) {
-                write("event: ${event.event}\n")
-            }
-            for (dataLine in event.data.lines()) {
-                write("data: $dataLine\n")
-            }
-            write("\n")
-            flush()
-        }
-    }
-}
\ No newline at end of file
diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts
index 700a287..0fb18d9 100644
--- a/demo/build.gradle.kts
+++ b/demo/build.gradle.kts
@@ -1,31 +1,40 @@
 plugins {
-    kotlin("jvm") version "1.3.72"
-    id("org.openjfx.javafxplugin") version "0.0.8"
+    kotlin("jvm")
+    id("org.openjfx.javafxplugin") version "0.0.9"
     application
 }
 
-val plotlyVersion: String by rootProject.extra
 
 repositories{
+    mavenCentral()
     jcenter()
+    maven("https://repo.kotlin.link")
     maven("https://kotlin.bintray.com/kotlinx")
-    maven("https://dl.bintray.com/kotlin/kotlin-eap")
-    maven("https://dl.bintray.com/mipt-npm/dataforge")
-    maven("https://dl.bintray.com/mipt-npm/scientifik")
-    maven("https://dl.bintray.com/mipt-npm/dev")
 }
 
+val ktorVersion: String by rootProject.extra
+val rsocketVersion: String by rootProject.extra
+
 dependencies{
-    implementation(project(":dataforge-device-core"))
-    implementation(project(":dataforge-device-server"))
+    implementation(projects.controlsCore)
+    //implementation(projects.controlsServer)
+    implementation(projects.magix.magixServer)
+    implementation(projects.controlsMagixClient)
+    implementation(projects.magix.magixRsocket)
+    implementation(projects.magix.magixZmq)
+    implementation(projects.controlsOpcua)
+
+    implementation("io.ktor:ktor-client-cio:$ktorVersion")
     implementation("no.tornado:tornadofx:1.7.20")
-    implementation(kotlin("stdlib-jdk8"))
-    implementation("scientifik:plotlykt-server:$plotlyVersion")
+    implementation("space.kscience:plotlykt-server:0.5.0-dev-1")
+    implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6")
+    implementation("ch.qos.logback:logback-classic:1.2.3")
 }
 
 tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
     kotlinOptions {
         jvmTarget = "11"
+        freeCompilerArgs = freeCompilerArgs + "-Xjvm-default=all"
     }
 }
 
@@ -35,5 +44,5 @@ javafx{
 }
 
 application{
-    mainClassName = "hep.dataforge.control.demo.DemoControllerViewKt"
+    mainClass.set("ru.mipt.npm.controls.demo.DemoControllerViewKt")
 }
\ No newline at end of file
diff --git a/demo/src/main/kotlin/hep/dataforge/control/demo/DemoControllerView.kt b/demo/src/main/kotlin/hep/dataforge/control/demo/DemoControllerView.kt
deleted file mode 100644
index ba3b733..0000000
--- a/demo/src/main/kotlin/hep/dataforge/control/demo/DemoControllerView.kt
+++ /dev/null
@@ -1,119 +0,0 @@
-package hep.dataforge.control.demo
-
-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.*
-import org.slf4j.LoggerFactory
-import tornadofx.*
-import java.awt.Desktop
-import java.net.URI
-import kotlin.coroutines.CoroutineContext
-
-val logger = LoggerFactory.getLogger("Demo")
-
-class DemoController : Controller(), CoroutineScope {
-
-    var device: DemoDevice? = null
-    var server: ApplicationEngine? = null
-    override val coroutineContext: CoroutineContext = GlobalScope.newCoroutineContext(Dispatchers.Default) + Job()
-
-    fun init() {
-        launch {
-            device = DemoDevice(this)
-            server = device?.let { this.startDemoDeviceServer(it) }
-        }
-    }
-
-    fun shutdown() {
-        logger.info("Shutting down...")
-        server?.stop(1000, 5000)
-        logger.info("Visualization server stopped")
-        device?.close()
-        logger.info("Device server stopped")
-        cancel("Application context closed")
-    }
-}
-
-
-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
-            }
-            timeScaleSlider = slider(1000..10000, 5000) {
-                isShowTickLabels = true
-                isShowTickMarks = true
-            }
-        }
-        hbox {
-            label("X scale")
-            pane {
-                hgrow = Priority.ALWAYS
-            }
-            xScaleSlider = slider(0.0..2.0, 1.0) {
-                isShowTickLabels = true
-                isShowTickMarks = true
-            }
-        }
-        hbox {
-            label("Y scale")
-            pane {
-                hgrow = Priority.ALWAYS
-            }
-            yScaleSlider = slider(0.0..2.0, 1.0) {
-                isShowTickLabels = true
-                isShowTickMarks = true
-            }
-        }
-        button("Submit") {
-            useMaxWidth = true
-            action {
-                controller.device?.apply {
-                    timeScaleValue = timeScaleSlider.value
-                    sinScaleValue = xScaleSlider.value
-                    cosScaleValue = yScaleSlider.value
-                }
-            }
-        }
-        button("Show plots") {
-            useMaxWidth = true
-            action {
-                controller.server?.run {
-                    val host = "localhost"//environment.connectors.first().host
-                    val port = environment.connectors.first().port
-                    val uri = URI("http", null, host, port, "/plots", null, null)
-                    Desktop.getDesktop().browse(uri)
-                }
-            }
-        }
-    }
-}
-
-class DemoControllerApp : App(DemoControllerView::class) {
-    private val controller: DemoController by inject()
-
-    override fun start(stage: Stage) {
-        super.start(stage)
-        controller.init()
-    }
-
-    override fun stop() {
-        controller.shutdown()
-        super.stop()
-    }
-}
-
-
-fun main() {
-    launch<DemoControllerApp>()
-}
\ No newline at end of file
diff --git a/demo/src/main/kotlin/hep/dataforge/control/demo/DemoDevice.kt b/demo/src/main/kotlin/hep/dataforge/control/demo/DemoDevice.kt
deleted file mode 100644
index 52b4c56..0000000
--- a/demo/src/main/kotlin/hep/dataforge/control/demo/DemoDevice.kt
+++ /dev/null
@@ -1,67 +0,0 @@
-package hep.dataforge.control.demo
-
-import hep.dataforge.control.base.*
-import hep.dataforge.control.controllers.double
-import hep.dataforge.values.asValue
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.asCoroutineDispatcher
-import java.time.Instant
-import java.util.concurrent.Executors
-import kotlin.math.cos
-import kotlin.math.sin
-import kotlin.time.ExperimentalTime
-import kotlin.time.seconds
-
-@OptIn(ExperimentalTime::class)
-class DemoDevice(parentScope: CoroutineScope = GlobalScope) : DeviceBase() {
-
-    private val executor = Executors.newSingleThreadExecutor()
-
-    override val scope: CoroutineScope = CoroutineScope(
-        parentScope.coroutineContext + executor.asCoroutineDispatcher() + Job(parentScope.coroutineContext[Job])
-    )
-
-    val timeScale: IsolatedDeviceProperty by writingVirtual(5000.0.asValue())
-    var timeScaleValue by timeScale.double()
-
-    val sinScale by writingVirtual(1.0.asValue())
-    var sinScaleValue by sinScale.double()
-    val sin by readingNumber {
-        val time = Instant.now()
-        sin(time.toEpochMilli().toDouble() / timeScaleValue)*sinScaleValue
-    }
-
-    val cosScale by writingVirtual(1.0.asValue())
-    var cosScaleValue by cosScale.double()
-    val cos by readingNumber {
-        val time = Instant.now()
-        cos(time.toEpochMilli().toDouble() / timeScaleValue)*cosScaleValue
-    }
-
-    val coordinates by readingMeta {
-        val time = Instant.now()
-        "time" put time.toEpochMilli()
-        "x" put sin(time.toEpochMilli().toDouble() / timeScaleValue)*sinScaleValue
-        "y" put cos(time.toEpochMilli().toDouble() / timeScaleValue)*cosScaleValue
-    }
-
-
-    val resetScale: Action by action {
-        timeScaleValue = 5000.0
-        sinScaleValue = 1.0
-        cosScaleValue = 1.0
-    }
-
-    init {
-        sin.readEvery(0.2.seconds)
-        cos.readEvery(0.2.seconds)
-        coordinates.readEvery(0.3.seconds)
-    }
-
-    override fun close() {
-        super.close()
-        executor.shutdown()
-    }
-}
\ No newline at end of file
diff --git a/demo/src/main/kotlin/ru/mipt/npm/controls/demo/DemoControllerView.kt b/demo/src/main/kotlin/ru/mipt/npm/controls/demo/DemoControllerView.kt
new file mode 100644
index 0000000..8a273ab
--- /dev/null
+++ b/demo/src/main/kotlin/ru/mipt/npm/controls/demo/DemoControllerView.kt
@@ -0,0 +1,160 @@
+package ru.mipt.npm.controls.demo
+
+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.launch
+import org.eclipse.milo.opcua.sdk.server.OpcUaServer
+import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText
+import ru.mipt.npm.controls.api.DeviceMessage
+import ru.mipt.npm.controls.client.connectToMagix
+import ru.mipt.npm.controls.controllers.DeviceManager
+import ru.mipt.npm.controls.controllers.install
+import ru.mipt.npm.controls.demo.DemoDevice.Companion.cosScale
+import ru.mipt.npm.controls.demo.DemoDevice.Companion.sinScale
+import ru.mipt.npm.controls.demo.DemoDevice.Companion.timeScale
+import ru.mipt.npm.controls.opcua.server.OpcUaServer
+import ru.mipt.npm.controls.opcua.server.endpoint
+import ru.mipt.npm.controls.opcua.server.serveDevices
+import ru.mipt.npm.magix.api.MagixEndpoint
+import ru.mipt.npm.magix.rsocket.rSocketWithTcp
+import ru.mipt.npm.magix.rsocket.rSocketWithWebSockets
+import ru.mipt.npm.magix.server.startMagixServer
+import space.kscience.dataforge.context.*
+import tornadofx.*
+import java.awt.Desktop
+import java.net.URI
+
+class DemoController : Controller(), ContextAware {
+
+    var device: DemoDevice? = null
+    var magixServer: ApplicationEngine? = null
+    var visualizer: ApplicationEngine? = null
+    var opcUaServer: OpcUaServer = OpcUaServer {
+        setApplicationName(LocalizedText.english("ru.mipt.npm.controls.opcua"))
+        endpoint {
+            setBindPort(9999)
+            //use default endpoint
+        }
+    }
+
+    override val context = Context("demoDevice") {
+        plugin(DeviceManager)
+    }
+
+    private val deviceManager = context.fetch(DeviceManager)
+
+    fun init() {
+        context.launch {
+            device = deviceManager.install("demo", DemoDevice)
+            //starting magix event loop
+            magixServer = startMagixServer(enableRawRSocket = true, enableZmq = true)
+            //Launch device client and connect it to the server
+            val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost", DeviceMessage.serializer())
+            deviceManager.connectToMagix(deviceEndpoint)
+            val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost", DeviceMessage.serializer())
+            visualizer = visualEndpoint.startDemoDeviceServer()
+
+            opcUaServer.startup()
+            opcUaServer.serveDevices(deviceManager)
+        }
+    }
+
+    fun shutdown() {
+        logger.info { "Shutting down..." }
+        opcUaServer.shutdown()
+        logger.info { "OpcUa server stopped" }
+        visualizer?.stop(1000, 5000)
+        logger.info { "Visualization server stopped" }
+        magixServer?.stop(1000, 5000)
+        logger.info { "Magix server stopped" }
+        device?.close()
+        logger.info { "Device server stopped" }
+        context.close()
+    }
+}
+
+
+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
+            }
+            timeScaleSlider = slider(1000..10000, 5000) {
+                isShowTickLabels = true
+                isShowTickMarks = true
+            }
+        }
+        hbox {
+            label("X scale")
+            pane {
+                hgrow = Priority.ALWAYS
+            }
+            xScaleSlider = slider(0.1..2.0, 1.0) {
+                isShowTickLabels = true
+                isShowTickMarks = true
+            }
+        }
+        hbox {
+            label("Y scale")
+            pane {
+                hgrow = Priority.ALWAYS
+            }
+            yScaleSlider = slider(0.1..2.0, 1.0) {
+                isShowTickLabels = true
+                isShowTickMarks = true
+            }
+        }
+        button("Submit") {
+            useMaxWidth = true
+            action {
+                controller.device?.run {
+                    launch {
+                        timeScale.write(timeScaleSlider.value)
+                        sinScale.write(xScaleSlider.value)
+                        cosScale.write(yScaleSlider.value)
+                    }
+                }
+            }
+        }
+        button("Show plots") {
+            useMaxWidth = true
+            action {
+                controller.visualizer?.run {
+                    val host = "localhost"//environment.connectors.first().host
+                    val port = environment.connectors.first().port
+                    val uri = URI("http", null, host, port, "/", null, null)
+                    Desktop.getDesktop().browse(uri)
+                }
+            }
+        }
+    }
+}
+
+class DemoControllerApp : App(DemoControllerView::class) {
+    private val controller: DemoController by inject()
+
+    override fun start(stage: Stage) {
+        super.start(stage)
+        controller.init()
+    }
+
+    override fun stop() {
+        controller.shutdown()
+        super.stop()
+    }
+}
+
+
+fun main() {
+    launch<DemoControllerApp>()
+}
\ No newline at end of file
diff --git a/demo/src/main/kotlin/ru/mipt/npm/controls/demo/DemoDevice.kt b/demo/src/main/kotlin/ru/mipt/npm/controls/demo/DemoDevice.kt
new file mode 100644
index 0000000..7882cbc
--- /dev/null
+++ b/demo/src/main/kotlin/ru/mipt/npm/controls/demo/DemoDevice.kt
@@ -0,0 +1,60 @@
+package ru.mipt.npm.controls.demo
+
+import kotlinx.coroutines.launch
+import ru.mipt.npm.controls.properties.*
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.transformations.MetaConverter
+import java.time.Instant
+import kotlin.time.Duration
+import kotlin.time.ExperimentalTime
+
+
+class DemoDevice : DeviceBySpec<DemoDevice>(DemoDevice) {
+    private var timeScaleState = 5000.0
+    private var sinScaleState = 1.0
+    private var cosScaleState = 1.0
+
+    companion object : DeviceSpec<DemoDevice>(::DemoDevice) {
+        // register virtual properties based on actual object state
+        val timeScale by property(MetaConverter.double, DemoDevice::timeScaleState)
+        val sinScale by property(MetaConverter.double, DemoDevice::sinScaleState)
+        val cosScale by property(MetaConverter.double, DemoDevice::cosScaleState)
+
+        val sin by doubleProperty {
+            val time = Instant.now()
+            kotlin.math.sin(time.toEpochMilli().toDouble() / timeScaleState) * sinScaleState
+        }
+
+        val cos by doubleProperty {
+            val time = Instant.now()
+            kotlin.math.cos(time.toEpochMilli().toDouble() / timeScaleState) * sinScaleState
+        }
+
+        val coordinates by metaProperty {
+            Meta {
+                val time = Instant.now()
+                "time" put time.toEpochMilli()
+                "x" put read(sin)
+                "y" put read(cos)
+            }
+        }
+
+        val resetScale by action(MetaConverter.meta, MetaConverter.meta) {
+            timeScale.write(5000.0)
+            sinScale.write(1.0)
+            cosScale.write(1.0)
+            null
+        }
+
+        @OptIn(ExperimentalTime::class)
+        override fun DemoDevice.onStartup() {
+            launch {
+                sinScale.read()
+                cosScale.read()
+            }
+            doRecurring(Duration.milliseconds(50)){
+                coordinates.read()
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/demo/src/main/kotlin/hep/dataforge/control/demo/demoDeviceServer.kt b/demo/src/main/kotlin/ru/mipt/npm/controls/demo/demoDeviceServer.kt
similarity index 65%
rename from demo/src/main/kotlin/hep/dataforge/control/demo/demoDeviceServer.kt
rename to demo/src/main/kotlin/ru/mipt/npm/controls/demo/demoDeviceServer.kt
index 907f6c3..8d6f95b 100644
--- a/demo/src/main/kotlin/hep/dataforge/control/demo/demoDeviceServer.kt
+++ b/demo/src/main/kotlin/ru/mipt/npm/controls/demo/demoDeviceServer.kt
@@ -1,23 +1,27 @@
-package hep.dataforge.control.demo
+package ru.mipt.npm.controls.demo
 
-import hep.dataforge.control.server.startDeviceServer
-import hep.dataforge.control.server.whenStarted
-import hep.dataforge.meta.double
-import io.ktor.application.uninstall
+import io.ktor.application.install
+import io.ktor.features.CORS
+import io.ktor.server.cio.CIO
 import io.ktor.server.engine.ApplicationEngine
+import io.ktor.server.engine.embeddedServer
 import io.ktor.websocket.WebSockets
-import kotlinx.coroutines.CoroutineScope
+import io.rsocket.kotlin.transport.ktor.server.RSocketSupport
 import kotlinx.coroutines.flow.*
 import kotlinx.coroutines.launch
 import kotlinx.html.div
 import kotlinx.html.link
-import scientifik.plotly.layout
-import scientifik.plotly.models.Trace
-import scientifik.plotly.plot
-import scientifik.plotly.server.PlotlyServerConfig
-import scientifik.plotly.server.PlotlyUpdateMode
-import scientifik.plotly.server.plotlyModule
-import scientifik.plotly.trace
+import ru.mipt.npm.controls.api.DeviceMessage
+import ru.mipt.npm.controls.api.PropertyChangedMessage
+import ru.mipt.npm.magix.api.MagixEndpoint
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.double
+import space.kscience.plotly.layout
+import space.kscience.plotly.models.Trace
+import space.kscience.plotly.plot
+import space.kscience.plotly.server.PlotlyUpdateMode
+import space.kscience.plotly.server.plotlyModule
+import space.kscience.plotly.trace
 import java.util.concurrent.ConcurrentLinkedQueue
 
 /**
@@ -50,17 +54,33 @@ suspend fun Trace.updateXYFrom(flow: Flow<Iterable<Pair<Double, Double>>>) {
 }
 
 
+suspend fun MagixEndpoint<DeviceMessage>.startDemoDeviceServer(): ApplicationEngine =
+    embeddedServer(CIO, 9090) {
+        install(WebSockets)
+        install(RSocketSupport)
 
-fun CoroutineScope.startDemoDeviceServer(device: DemoDevice): ApplicationEngine {
-    val server = startDeviceServer(mapOf("demo" to device))
-    server.whenStarted {
-        uninstall(WebSockets)
-        plotlyModule(
-            "plots",
-            PlotlyServerConfig { updateMode = PlotlyUpdateMode.PUSH; updateInterval = 50 }
-        ) { container ->
-            val sinFlow = device.sin.flow()
-            val cosFlow = device.cos.flow()
+        install(CORS) {
+            anyHost()
+        }
+
+        val sinFlow = MutableSharedFlow<Meta?>()// = device.sin.flow()
+        val cosFlow = MutableSharedFlow<Meta?>()// = device.cos.flow()
+
+        launch {
+            subscribe().collect { magix ->
+                (magix.payload as? PropertyChangedMessage)?.let { message ->
+                    when (message.property) {
+                        "sin" -> sinFlow.emit(message.value)
+                        "cos" -> cosFlow.emit(message.value)
+                    }
+                }
+            }
+        }
+
+        plotlyModule().apply {
+            updateMode = PlotlyUpdateMode.PUSH
+            updateInterval = 50
+        }.page { container ->
             val sinCosFlow = sinFlow.zip(cosFlow) { sin, cos ->
                 sin.double!! to cos.double!!
             }
@@ -72,7 +92,7 @@ fun CoroutineScope.startDemoDeviceServer(device: DemoDevice): ApplicationEngine
             }
             div("row") {
                 div("col-6") {
-                    plot(container = container) {
+                    plot(renderer = container) {
                         layout {
                             title = "sin property"
                             xaxis.title = "point index"
@@ -87,7 +107,7 @@ fun CoroutineScope.startDemoDeviceServer(device: DemoDevice): ApplicationEngine
                     }
                 }
                 div("col-6") {
-                    plot(container = container) {
+                    plot(renderer = container) {
                         layout {
                             title = "cos property"
                             xaxis.title = "point index"
@@ -104,7 +124,7 @@ fun CoroutineScope.startDemoDeviceServer(device: DemoDevice): ApplicationEngine
             }
             div("row") {
                 div("col-12") {
-                    plot(container = container) {
+                    plot(renderer = container) {
                         layout {
                             title = "cos vs sin"
                             xaxis.title = "sin"
@@ -121,7 +141,5 @@ fun CoroutineScope.startDemoDeviceServer(device: DemoDevice): ApplicationEngine
                 }
             }
         }
-    }
-    return server
-}
+    }.apply { start() }
 
diff --git a/demo/src/main/kotlin/ru/mipt/npm/controls/demo/generateMessageSchema.kt b/demo/src/main/kotlin/ru/mipt/npm/controls/demo/generateMessageSchema.kt
new file mode 100644
index 0000000..420bca3
--- /dev/null
+++ b/demo/src/main/kotlin/ru/mipt/npm/controls/demo/generateMessageSchema.kt
@@ -0,0 +1,10 @@
+package ru.mipt.npm.controls.demo
+
+import com.github.ricky12awesome.jss.encodeToSchema
+import com.github.ricky12awesome.jss.globalJson
+import ru.mipt.npm.controls.api.DeviceMessage
+
+fun main() {
+    val schema = globalJson.encodeToSchema(DeviceMessage.serializer(), generateDefinitions = false)
+    println(schema)
+}
\ No newline at end of file
diff --git a/docs/pictures/async-to sync.png b/docs/pictures/async-to sync.png
new file mode 100644
index 0000000..4e71a87
Binary files /dev/null and b/docs/pictures/async-to sync.png differ
diff --git a/docs/pictures/sync-to-async.png b/docs/pictures/sync-to-async.png
new file mode 100644
index 0000000..1c58cef
Binary files /dev/null and b/docs/pictures/sync-to-async.png differ
diff --git a/docs/schemes/direct-vs-loop.vsdx b/docs/schemes/direct-vs-loop.vsdx
new file mode 100644
index 0000000..812a47e
Binary files /dev/null and b/docs/schemes/direct-vs-loop.vsdx differ
diff --git a/docs/uml/async-to sync.puml b/docs/uml/async-to sync.puml
new file mode 100644
index 0000000..f967f5b
--- /dev/null
+++ b/docs/uml/async-to sync.puml	
@@ -0,0 +1,18 @@
+@startuml
+title Transform asynchronous to synchronous
+
+participant Synchronous
+participant Adapter
+participant Asynchronous
+
+activate Adapter
+Asynchronous -> Adapter: message with ID
+Adapter -> Synchronous
+activate Synchronous
+hnote over Adapter : create a waiting thread
+Synchronous -> Adapter
+deactivate Synchronous
+Adapter -> Asynchronous: message with ID
+
+
+@enduml
\ No newline at end of file
diff --git a/docs/uml/async.puml b/docs/uml/async.puml
index ebd9858..3e44161 100644
--- a/docs/uml/async.puml
+++ b/docs/uml/async.puml
@@ -1,6 +1,8 @@
 @startuml
 title Simple call with callback
+
 Main -> Async: call
+activate Main
 activate Async
 
 Async -> Main: result
diff --git a/docs/uml/device-properties.puml b/docs/uml/device-properties.puml
new file mode 100644
index 0000000..2b3c5ae
--- /dev/null
+++ b/docs/uml/device-properties.puml
@@ -0,0 +1,25 @@
+@startuml
+participant Physical
+participant Logical
+participant Remote
+
+group Asynchronous update
+    Physical -> Logical: Notify changed
+    Logical -> Remote: Send event
+end
+
+group Timed update
+    Logical -> Logical: Timed check
+    Logical -> Physical: Request value
+    Physical -> Logical: Respond
+    Logical --> Remote: Send event if changed
+end
+
+group Request update
+    Remote -> Logical: Request value
+    Logical --> Physical: Request if needed
+    Physical --> Logical: Respond
+    Logical -> Remote: Force send event
+end
+
+@enduml
\ No newline at end of file
diff --git a/docs/uml/sync-to-async.puml b/docs/uml/sync-to-async.puml
new file mode 100644
index 0000000..5a33e82
--- /dev/null
+++ b/docs/uml/sync-to-async.puml
@@ -0,0 +1,23 @@
+@startuml
+title Transform synchronous to asynchronous
+
+participant Synchronous
+participant Adapter
+participant Asynchronous
+
+activate Synchronous
+
+Synchronous -> Adapter: call and block
+deactivate Synchronous
+
+activate Adapter
+
+Adapter -> Asynchronous: message with ID
+hnote over Adapter : create a waiting thread
+Asynchronous -> Adapter: message with ID
+
+Adapter -> Synchronous: return result
+deactivate Adapter
+activate Synchronous
+
+@enduml
\ No newline at end of file
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 62d4c05..e708b1c 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 622ab64..ffed3a2 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-6.5-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index fbd7c51..4f906e0 100755
--- a/gradlew
+++ b/gradlew
@@ -130,7 +130,7 @@ fi
 if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
     APP_HOME=`cygpath --path --mixed "$APP_HOME"`
     CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
-    
+
     JAVACMD=`cygpath --unix "$JAVACMD"`
 
     # We build the pattern for arguments to be converted via cygpath
diff --git a/gradlew.bat b/gradlew.bat
index 5093609..107acd3 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -40,7 +40,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
 
 set JAVA_EXE=java.exe
 %JAVA_EXE% -version >NUL 2>&1
-if "%ERRORLEVEL%" == "0" goto init
+if "%ERRORLEVEL%" == "0" goto execute
 
 echo.
 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
@@ -54,7 +54,7 @@ goto fail
 set JAVA_HOME=%JAVA_HOME:"=%
 set JAVA_EXE=%JAVA_HOME%/bin/java.exe
 
-if exist "%JAVA_EXE%" goto init
+if exist "%JAVA_EXE%" goto execute
 
 echo.
 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
@@ -64,21 +64,6 @@ echo location of your Java installation.
 
 goto fail
 
-:init
-@rem Get command-line arguments, handling Windows variants
-
-if not "%OS%" == "Windows_NT" goto win9xME_args
-
-:win9xME_args
-@rem Slurp the command line arguments.
-set CMD_LINE_ARGS=
-set _SKIP=2
-
-:win9xME_args_slurp
-if "x%~1" == "x" goto execute
-
-set CMD_LINE_ARGS=%*
-
 :execute
 @rem Setup the command line
 
@@ -86,7 +71,7 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
 
 
 @rem Execute Gradle
-"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
 
 :end
 @rem End local scope for the variables with windows NT shell
diff --git a/magix/build.gradle.kts b/magix/build.gradle.kts
new file mode 100644
index 0000000..ae31ca2
--- /dev/null
+++ b/magix/build.gradle.kts
@@ -0,0 +1,3 @@
+subprojects{
+
+}
\ No newline at end of file
diff --git a/magix/magix-api/build.gradle.kts b/magix/magix-api/build.gradle.kts
new file mode 100644
index 0000000..c6eb397
--- /dev/null
+++ b/magix/magix-api/build.gradle.kts
@@ -0,0 +1,12 @@
+plugins {
+    id("ru.mipt.npm.gradle.mpp")
+    `maven-publish`
+}
+
+kscience {
+    useCoroutines()
+    useSerialization{
+        json()
+    }
+}
+
diff --git a/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/MagixEndpoint.kt b/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/MagixEndpoint.kt
new file mode 100644
index 0000000..78ab54d
--- /dev/null
+++ b/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/MagixEndpoint.kt
@@ -0,0 +1,83 @@
+package ru.mipt.npm.magix.api
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonElement
+
+/**
+ * Inwards API of magix endpoint used to build services
+ */
+public interface MagixEndpoint<T> {
+
+    /**
+     * Subscribe to a [Flow] of messages
+     */
+    public fun subscribe(
+        filter: MagixMessageFilter = MagixMessageFilter.ALL,
+    ): Flow<MagixMessage<T>>
+
+
+    /**
+     * Send an event
+     */
+    public suspend fun broadcast(
+        message: MagixMessage<T>,
+    )
+
+    public companion object {
+        /**
+         * A default port for HTTP/WS connections
+         */
+        public const val DEFAULT_MAGIX_HTTP_PORT: Int = 7777
+
+        /**
+         * A default port for raw TCP connections
+         */
+        public const val DEFAULT_MAGIX_RAW_PORT: Int = 7778
+
+        /**
+         * A default PUB port for ZMQ connections
+         */
+        public const val DEFAULT_MAGIX_ZMQ_PUB_PORT: Int = 7781
+
+        /**
+         * A default PULL port for ZMQ connections
+         */
+        public const val DEFAULT_MAGIX_ZMQ_PULL_PORT: Int = 7782
+
+
+        public val magixJson: Json = Json {
+            ignoreUnknownKeys = true
+            encodeDefaults = false
+        }
+    }
+}
+
+/**
+ * Specialize this raw json endpoint to use specific serializer
+ */
+public fun <T : Any> MagixEndpoint<JsonElement>.specialize(
+    payloadSerializer: KSerializer<T>
+): MagixEndpoint<T> = object : MagixEndpoint<T> {
+    override fun subscribe(
+        filter: MagixMessageFilter
+    ): Flow<MagixMessage<T>> = this@specialize.subscribe(filter).map { message ->
+        message.replacePayload { payload ->
+            MagixEndpoint.magixJson.decodeFromJsonElement(payloadSerializer, payload)
+        }
+    }
+
+    override suspend fun broadcast(message: MagixMessage<T>) {
+        this@specialize.broadcast(
+            message.replacePayload { payload ->
+                MagixEndpoint.magixJson.encodeToJsonElement(
+                    payloadSerializer,
+                    payload
+                )
+            }
+        )
+    }
+
+}
\ No newline at end of file
diff --git a/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/MagixMessage.kt b/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/MagixMessage.kt
new file mode 100644
index 0000000..eaf2098
--- /dev/null
+++ b/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/MagixMessage.kt
@@ -0,0 +1,42 @@
+package ru.mipt.npm.magix.api
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.JsonElement
+
+
+/*
+ * {
+ *  "format": "string[required]",
+ *  "id":"string|number[optional, but desired]",
+ *  "parentId": "string|number[optional]",
+ *  "target":"string[optional]",
+ *  "origin":"string[required]",
+ *  "user":"string[optional]",
+ *  "action":"string[optional, default='heartbeat']",
+ *  "payload":"object[optional]"
+ * }
+ */
+
+/**
+ *
+ * Magix message according to [magix specification](https://github.com/piazza-controls/rfc/tree/master/1)
+ * with a [correction](https://github.com/piazza-controls/rfc/issues/12)
+ *
+ */
+@Serializable
+public data class MagixMessage<T>(
+    val format: String,
+    val origin: String,
+    val payload: T,
+    val target: String? = null,
+    val id: String? = null,
+    val parentId: String? = null,
+    val user: JsonElement? = null,
+)
+
+/**
+ * Create message with same field but replaced payload
+ */
+@Suppress("UNCHECKED_CAST")
+public fun <T, R> MagixMessage<T>.replacePayload(payloadTransform: (T) -> R): MagixMessage<R> =
+    MagixMessage(format, origin, payloadTransform(payload), target, id, parentId, user)
\ No newline at end of file
diff --git a/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/MagixMessageFilter.kt b/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/MagixMessageFilter.kt
new file mode 100644
index 0000000..2f941c0
--- /dev/null
+++ b/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/MagixMessageFilter.kt
@@ -0,0 +1,31 @@
+package ru.mipt.npm.magix.api
+
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.filter
+import kotlinx.serialization.Serializable
+
+@Serializable
+public data class MagixMessageFilter(
+    val format: List<String?>? = null,
+    val origin: List<String?>? = null,
+    val target: List<String?>? = null,
+) {
+    public companion object {
+        public val ALL: MagixMessageFilter = MagixMessageFilter()
+    }
+}
+
+/**
+ * Filter a [Flow] of messages based on given filter
+ */
+public fun <T> Flow<MagixMessage<T>>.filter(filter: MagixMessageFilter): Flow<MagixMessage<T>> {
+    if (filter == MagixMessageFilter.ALL) {
+        return this
+    }
+    return filter { message ->
+        filter.format?.contains(message.format) ?: true
+                && filter.origin?.contains(message.origin) ?: true
+                && filter.origin?.contains(message.origin) ?: true
+                && filter.target?.contains(message.target) ?: true
+    }
+}
\ No newline at end of file
diff --git a/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/converters.kt b/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/converters.kt
new file mode 100644
index 0000000..f1c854c
--- /dev/null
+++ b/magix/magix-api/src/commonMain/kotlin/ru/mipt/npm/magix/api/converters.kt
@@ -0,0 +1,30 @@
+package ru.mipt.npm.magix.api
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+
+/**
+ * Launch magix message converter service
+ */
+public fun <T, R> CoroutineScope.launchMagixConverter(
+    inputEndpoint: MagixEndpoint<T>,
+    outputEndpoint: MagixEndpoint<R>,
+    filter: MagixMessageFilter,
+    outputFormat: String,
+    newOrigin: String? = null,
+    transformer: suspend (T) -> R,
+): Job = inputEndpoint.subscribe(filter).onEach { message->
+    val newPayload = transformer(message.payload)
+    val transformed: MagixMessage<R> = MagixMessage(
+        outputFormat,
+        newOrigin ?: message.origin,
+        newPayload,
+        message.target,
+        message.id,
+        message.parentId,
+        message.user
+    )
+    outputEndpoint.broadcast(transformed)
+}.launchIn(this)
diff --git a/magix/magix-demo/build.gradle.kts b/magix/magix-demo/build.gradle.kts
new file mode 100644
index 0000000..0b6e72b
--- /dev/null
+++ b/magix/magix-demo/build.gradle.kts
@@ -0,0 +1,20 @@
+plugins {
+    id("ru.mipt.npm.gradle.jvm")
+    application
+}
+
+
+dependencies{
+    implementation(projects.magix.magixServer)
+    implementation(projects.magix.magixZmq)
+    implementation(projects.magix.magixRsocket)
+    implementation("ch.qos.logback:logback-classic:1.2.3")
+}
+
+kotlin{
+    explicitApi = null
+}
+
+application{
+    mainClass.set("ZmqKt")
+}
\ No newline at end of file
diff --git a/magix/magix-demo/src/main/kotlin/zmq.kt b/magix/magix-demo/src/main/kotlin/zmq.kt
new file mode 100644
index 0000000..2a73640
--- /dev/null
+++ b/magix/magix-demo/src/main/kotlin/zmq.kt
@@ -0,0 +1,70 @@
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.isActive
+import kotlinx.serialization.json.*
+import org.slf4j.LoggerFactory
+import ru.mipt.npm.magix.api.MagixEndpoint
+import ru.mipt.npm.magix.api.MagixMessage
+import ru.mipt.npm.magix.server.startMagixServer
+import ru.mipt.npm.magix.zmq.ZmqMagixEndpoint
+import java.awt.Desktop
+import java.net.URI
+
+
+suspend fun MagixEndpoint<JsonObject>.sendJson(
+    origin: String,
+    format: String = "json",
+    target: String? = null,
+    id: String? = null,
+    parentId: String? = null,
+    user: JsonElement? = null,
+    builder: JsonObjectBuilder.() -> Unit
+): Unit = broadcast(MagixMessage(format, origin, buildJsonObject(builder), target, id, parentId, user))
+
+internal const val numberOfMessages = 100
+
+suspend fun main(): Unit = coroutineScope {
+    val logger = LoggerFactory.getLogger("magix-demo")
+    logger.info("Starting magix server")
+    val server = startMagixServer(
+        buffer = 10,
+        enableRawRSocket = false //Disable rsocket to avoid kotlin 1.5 compatibility issue
+    )
+
+    server.apply {
+        val host = "localhost"//environment.connectors.first().host
+        val port = environment.connectors.first().port
+        val uri = URI("http", null, host, port, "/state", null, null)
+        Desktop.getDesktop().browse(uri)
+    }
+
+    logger.info("Starting client")
+    //Create zmq magix endpoint and wait for to finish
+    ZmqMagixEndpoint("tcp://localhost", JsonObject.serializer()).use { client ->
+        logger.info("Starting subscription")
+        client.subscribe().onEach {
+            println(it.payload)
+            if (it.payload["index"]?.jsonPrimitive?.int == numberOfMessages) {
+                logger.info("Index $numberOfMessages reached. Terminating")
+                cancel()
+            }
+        }.catch { it.printStackTrace() }.launchIn(this)
+
+
+        var counter = 0
+        while (isActive) {
+            delay(500)
+            val index = (counter++).toString()
+            logger.info("Sending message number $index")
+            client.sendJson("magix-demo", id = index) {
+                put("message", "Hello world!")
+                put("index", index)
+            }
+        }
+
+    }
+}
\ No newline at end of file
diff --git a/magix/magix-java-client/build.gradle.kts b/magix/magix-java-client/build.gradle.kts
new file mode 100644
index 0000000..5e34637
--- /dev/null
+++ b/magix/magix-java-client/build.gradle.kts
@@ -0,0 +1,10 @@
+plugins {
+    java
+    id("ru.mipt.npm.gradle.jvm")
+    `maven-publish`
+}
+
+dependencies {
+    implementation(project(":magix:magix-rsocket"))
+    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:${ru.mipt.npm.gradle.KScienceVersions.coroutinesVersion}")
+}
diff --git a/magix/magix-java-client/src/main/java/ru/mipt/npm/magix/client/MagixClient.java b/magix/magix-java-client/src/main/java/ru/mipt/npm/magix/client/MagixClient.java
new file mode 100644
index 0000000..50d6f09
--- /dev/null
+++ b/magix/magix-java-client/src/main/java/ru/mipt/npm/magix/client/MagixClient.java
@@ -0,0 +1,39 @@
+package ru.mipt.npm.magix.client;
+
+import kotlinx.serialization.json.JsonElement;
+import ru.mipt.npm.magix.api.MagixMessage;
+
+import java.io.IOException;
+import java.util.concurrent.Flow;
+
+/**
+ * See https://github.com/waltz-controls/rfc/tree/master/2
+ *
+ * @param <T>
+ */
+public interface MagixClient<T> {
+    void broadcast(MagixMessage<T> msg) throws IOException;
+
+    Flow.Publisher<MagixMessage<T>> subscribe();
+
+    /**
+     * Create a magix endpoint client using RSocket with raw tcp connection
+     * @param host host name of magix server event loop
+     * @param port port of magix server event loop
+     * @return the client
+     */
+    static MagixClient<JsonElement> rSocketTcp(String host, int port) {
+        return ControlsMagixClient.Companion.rSocketTcp(host, port, JsonElement.Companion.serializer());
+    }
+
+    /**
+     *
+     * @param host host name of magix server event loop
+     * @param port port of magix server event loop
+     * @param path
+     * @return
+     */
+    static MagixClient<JsonElement> rSocketWs(String host, int port, String path) {
+        return ControlsMagixClient.Companion.rSocketWs(host, port, JsonElement.Companion.serializer(), path);
+    }
+}
diff --git a/magix/magix-java-client/src/main/kotlin/ru/mipt/npm/magix/client/ControlsMagixClient.kt b/magix/magix-java-client/src/main/kotlin/ru/mipt/npm/magix/client/ControlsMagixClient.kt
new file mode 100644
index 0000000..476a73d
--- /dev/null
+++ b/magix/magix-java-client/src/main/kotlin/ru/mipt/npm/magix/client/ControlsMagixClient.kt
@@ -0,0 +1,49 @@
+package ru.mipt.npm.magix.client
+
+import kotlinx.coroutines.jdk9.asPublisher
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.KSerializer
+import ru.mipt.npm.magix.api.MagixEndpoint
+import ru.mipt.npm.magix.api.MagixMessage
+import ru.mipt.npm.magix.api.MagixMessageFilter
+import ru.mipt.npm.magix.rsocket.rSocketWithTcp
+import ru.mipt.npm.magix.rsocket.rSocketWithWebSockets
+import java.util.concurrent.Flow
+
+internal class ControlsMagixClient<T>(
+    private val endpoint: MagixEndpoint<T>,
+    private val filter: MagixMessageFilter,
+) : MagixClient<T> {
+
+    override fun broadcast(msg: MagixMessage<T>): Unit = runBlocking {
+        endpoint.broadcast(msg)
+    }
+
+    override fun subscribe(): Flow.Publisher<MagixMessage<T>> = endpoint.subscribe(filter).asPublisher()
+
+    companion object {
+
+        fun <T> rSocketTcp(
+            host: String,
+            port: Int,
+            payloadSerializer: KSerializer<T>
+        ): ControlsMagixClient<T> {
+            val endpoint = runBlocking {
+                MagixEndpoint.rSocketWithTcp(host, payloadSerializer, port)
+            }
+            return ControlsMagixClient(endpoint, MagixMessageFilter())
+        }
+
+        fun <T> rSocketWs(
+            host: String,
+            port: Int,
+            payloadSerializer: KSerializer<T>,
+            path: String = "/rsocket"
+        ): ControlsMagixClient<T> {
+            val endpoint = runBlocking {
+                MagixEndpoint.rSocketWithWebSockets(host, payloadSerializer, port, path)
+            }
+            return ControlsMagixClient(endpoint, MagixMessageFilter())
+        }
+    }
+}
\ No newline at end of file
diff --git a/magix/magix-rsocket/build.gradle.kts b/magix/magix-rsocket/build.gradle.kts
new file mode 100644
index 0000000..1e0647c
--- /dev/null
+++ b/magix/magix-rsocket/build.gradle.kts
@@ -0,0 +1,29 @@
+plugins {
+    id("ru.mipt.npm.gradle.mpp")
+    `maven-publish`
+}
+
+description = """
+    Magix endpoint (client) based on RSocket
+""".trimIndent()
+
+kscience {
+    useSerialization {
+        json()
+    }
+}
+
+val ktorVersion: String by rootProject.extra
+val rsocketVersion: String by rootProject.extra
+
+kotlin {
+    sourceSets {
+        commonMain {
+            dependencies {
+                api(projects.magix.magixApi)
+                implementation("io.ktor:ktor-client-core:$ktorVersion")
+                implementation("io.rsocket.kotlin:rsocket-transport-ktor-client:$rsocketVersion")
+            }
+        }
+    }
+}
\ No newline at end of file
diff --git a/magix/magix-rsocket/src/commonMain/kotlin/ru/mipt/npm/magix/rsocket/RSocketMagixEndpoint.kt b/magix/magix-rsocket/src/commonMain/kotlin/ru/mipt/npm/magix/rsocket/RSocketMagixEndpoint.kt
new file mode 100644
index 0000000..42639ab
--- /dev/null
+++ b/magix/magix-rsocket/src/commonMain/kotlin/ru/mipt/npm/magix/rsocket/RSocketMagixEndpoint.kt
@@ -0,0 +1,86 @@
+package ru.mipt.npm.magix.rsocket
+
+import io.ktor.client.HttpClient
+import io.ktor.client.features.websocket.WebSockets
+import io.rsocket.kotlin.RSocket
+import io.rsocket.kotlin.core.RSocketConnector
+import io.rsocket.kotlin.core.RSocketConnectorBuilder
+import io.rsocket.kotlin.payload.buildPayload
+import io.rsocket.kotlin.payload.data
+import io.rsocket.kotlin.transport.ktor.client.RSocketSupport
+import io.rsocket.kotlin.transport.ktor.client.rSocket
+import kotlinx.coroutines.CoroutineDispatcher
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.encodeToString
+import ru.mipt.npm.magix.api.MagixEndpoint
+import ru.mipt.npm.magix.api.MagixMessage
+import ru.mipt.npm.magix.api.MagixMessageFilter
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.coroutineContext
+
+public class RSocketMagixEndpoint<T>(
+    payloadSerializer: KSerializer<T>,
+    private val rSocket: RSocket,
+    private val coroutineContext: CoroutineContext,
+) : MagixEndpoint<T> {
+
+    private val serializer = MagixMessage.serializer(payloadSerializer)
+
+    override fun subscribe(
+        filter: MagixMessageFilter,
+    ): Flow<MagixMessage<T>> {
+        val payload = buildPayload { data(MagixEndpoint.magixJson.encodeToString(filter)) }
+        val flow = rSocket.requestStream(payload)
+        return flow.map {
+            MagixEndpoint.magixJson.decodeFromString(serializer, it.data.readText())
+        }.flowOn(coroutineContext[CoroutineDispatcher]?:Dispatchers.Unconfined)
+    }
+
+    override suspend fun broadcast(message: MagixMessage<T>) {
+        withContext(coroutineContext) {
+            val payload = buildPayload { data(MagixEndpoint.magixJson.encodeToString(serializer, message)) }
+            rSocket.fireAndForget(payload)
+        }
+    }
+
+    public companion object
+}
+
+
+internal fun buildConnector(rSocketConfig: RSocketConnectorBuilder.ConnectionConfigBuilder.() -> Unit) =
+    RSocketConnector {
+        reconnectable(10)
+        connectionConfig(rSocketConfig)
+    }
+
+/**
+ * Build a websocket based endpoint connected to [host], [port] and given routing [path]
+ */
+public suspend fun <T> MagixEndpoint.Companion.rSocketWithWebSockets(
+    host: String,
+    payloadSerializer: KSerializer<T>,
+    port: Int = DEFAULT_MAGIX_HTTP_PORT,
+    path: String = "/rsocket",
+    rSocketConfig: RSocketConnectorBuilder.ConnectionConfigBuilder.() -> Unit = {},
+): RSocketMagixEndpoint<T> {
+    val client = HttpClient {
+        install(WebSockets)
+        install(RSocketSupport) {
+            connector = buildConnector(rSocketConfig)
+        }
+    }
+
+    val rSocket = client.rSocket(host, port, path)
+
+    //Ensure client is closed after rSocket if finished
+    rSocket.job.invokeOnCompletion {
+        client.close()
+    }
+
+    return RSocketMagixEndpoint(payloadSerializer, rSocket, coroutineContext)
+}
\ No newline at end of file
diff --git a/magix/magix-rsocket/src/jvmMain/kotlin/ru/mipt/npm/magix/rsocket/withTcp.kt b/magix/magix-rsocket/src/jvmMain/kotlin/ru/mipt/npm/magix/rsocket/withTcp.kt
new file mode 100644
index 0000000..90c2ffa
--- /dev/null
+++ b/magix/magix-rsocket/src/jvmMain/kotlin/ru/mipt/npm/magix/rsocket/withTcp.kt
@@ -0,0 +1,34 @@
+package ru.mipt.npm.magix.rsocket
+
+import io.ktor.network.selector.ActorSelectorManager
+import io.ktor.network.sockets.SocketOptions
+import io.ktor.util.InternalAPI
+import io.rsocket.kotlin.core.RSocketConnectorBuilder
+import io.rsocket.kotlin.transport.ktor.TcpClientTransport
+import kotlinx.coroutines.Dispatchers
+import kotlinx.serialization.KSerializer
+import ru.mipt.npm.magix.api.MagixEndpoint
+import kotlin.coroutines.coroutineContext
+
+
+/**
+ * Create a plain TCP based [RSocketMagixEndpoint] connected to [host] and [port]
+ */
+@OptIn(InternalAPI::class)
+public suspend fun <T> MagixEndpoint.Companion.rSocketWithTcp(
+    host: String,
+    payloadSerializer: KSerializer<T>,
+    port: Int = DEFAULT_MAGIX_RAW_PORT,
+    tcpConfig: SocketOptions.TCPClientSocketOptions.() -> Unit = {},
+    rSocketConfig: RSocketConnectorBuilder.ConnectionConfigBuilder.() -> Unit = {},
+): RSocketMagixEndpoint<T> {
+    val transport = TcpClientTransport(
+        ActorSelectorManager(Dispatchers.IO),
+        hostname = host,
+        port = port,
+        configure = tcpConfig
+    )
+    val rSocket = buildConnector(rSocketConfig).connect(transport)
+
+    return RSocketMagixEndpoint(payloadSerializer, rSocket, coroutineContext)
+}
diff --git a/magix/magix-server/build.gradle.kts b/magix/magix-server/build.gradle.kts
new file mode 100644
index 0000000..7f01ea8
--- /dev/null
+++ b/magix/magix-server/build.gradle.kts
@@ -0,0 +1,32 @@
+plugins {
+    id("ru.mipt.npm.gradle.jvm")
+    `maven-publish`
+    application
+}
+
+description = """
+    A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes.
+""".trimIndent()
+
+kscience {
+    useSerialization{
+        json()
+    }
+}
+
+val dataforgeVersion: String by rootProject.extra
+val rsocketVersion: String by rootProject.extra
+val ktorVersion: String  = ru.mipt.npm.gradle.KScienceVersions.ktorVersion
+
+dependencies{
+    api(project(":magix:magix-api"))
+    api("io.ktor:ktor-server-cio:$ktorVersion")
+    api("io.ktor:ktor-websockets:$ktorVersion")
+    api("io.ktor:ktor-serialization:$ktorVersion")
+    api("io.ktor:ktor-html-builder:$ktorVersion")
+
+    api("io.rsocket.kotlin:rsocket-core:$rsocketVersion")
+    api("io.rsocket.kotlin:rsocket-transport-ktor-server:$rsocketVersion")
+
+    api("org.zeromq:jeromq:0.5.2")
+}
\ No newline at end of file
diff --git a/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/magixModule.kt b/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/magixModule.kt
new file mode 100644
index 0000000..3211243
--- /dev/null
+++ b/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/magixModule.kt
@@ -0,0 +1,164 @@
+package ru.mipt.npm.magix.server
+
+import io.ktor.application.*
+import io.ktor.features.CORS
+import io.ktor.features.ContentNegotiation
+import io.ktor.html.respondHtml
+import io.ktor.request.receive
+import io.ktor.routing.get
+import io.ktor.routing.post
+import io.ktor.routing.route
+import io.ktor.routing.routing
+import io.ktor.serialization.json
+import io.ktor.util.getValue
+import io.ktor.websocket.WebSockets
+import io.rsocket.kotlin.ConnectionAcceptor
+import io.rsocket.kotlin.RSocketRequestHandler
+import io.rsocket.kotlin.payload.Payload
+import io.rsocket.kotlin.payload.buildPayload
+import io.rsocket.kotlin.payload.data
+import io.rsocket.kotlin.transport.ktor.server.RSocketSupport
+import io.rsocket.kotlin.transport.ktor.server.rSocket
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.*
+import kotlinx.html.*
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.json.JsonElement
+import ru.mipt.npm.magix.api.MagixEndpoint.Companion.magixJson
+import ru.mipt.npm.magix.api.MagixMessage
+import ru.mipt.npm.magix.api.MagixMessageFilter
+import ru.mipt.npm.magix.api.filter
+import java.util.*
+
+public typealias GenericMagixMessage = MagixMessage<JsonElement>
+
+internal val genericMessageSerializer: KSerializer<MagixMessage<JsonElement>> =
+    MagixMessage.serializer(JsonElement.serializer())
+
+
+internal fun CoroutineScope.magixAcceptor(magixFlow: MutableSharedFlow<GenericMagixMessage>) = ConnectionAcceptor {
+    RSocketRequestHandler {
+        //handler for request/stream
+        requestStream { request: Payload ->
+            val filter = magixJson.decodeFromString(MagixMessageFilter.serializer(), request.data.readText())
+            magixFlow.filter(filter).map { message ->
+                val string = magixJson.encodeToString(genericMessageSerializer, message)
+                buildPayload { data(string) }
+            }
+        }
+        fireAndForget { request: Payload ->
+            val message = magixJson.decodeFromString(genericMessageSerializer, request.data.readText())
+            magixFlow.emit(message)
+        }
+        // bi-directional connection
+        requestChannel { request: Payload, input: Flow<Payload> ->
+            input.onEach {
+                magixFlow.emit(magixJson.decodeFromString(genericMessageSerializer, it.data.readText()))
+            }.launchIn(this@magixAcceptor)
+
+            val filter = magixJson.decodeFromString(MagixMessageFilter.serializer(), request.data.readText())
+
+            magixFlow.filter(filter).map { message ->
+                val string = magixJson.encodeToString(genericMessageSerializer, message)
+                buildPayload { data(string) }
+            }
+        }
+    }
+}
+
+/**
+ * Create a message filter from call parameters
+ */
+private fun ApplicationCall.buildFilter(): MagixMessageFilter {
+    val query = request.queryParameters
+
+    if (query.isEmpty()) {
+        return MagixMessageFilter.ALL
+    }
+
+    val format: List<String>? by query
+    val origin: List<String>? by query
+    return MagixMessageFilter(
+        format,
+        origin
+    )
+}
+
+/**
+ * Attache magix http/sse and websocket-based rsocket event loop + statistics page to existing [MutableSharedFlow]
+ */
+public fun Application.magixModule(magixFlow: MutableSharedFlow<GenericMagixMessage>, route: String = "/") {
+    if (featureOrNull(WebSockets) == null) {
+        install(WebSockets)
+    }
+
+    if (featureOrNull(CORS) == null) {
+        install(CORS) {
+            //TODO consider more safe policy
+            anyHost()
+        }
+    }
+    if (featureOrNull(ContentNegotiation) == null) {
+        install(ContentNegotiation) {
+            json()
+        }
+    }
+
+    if (featureOrNull(RSocketSupport) == null) {
+        install(RSocketSupport)
+    }
+
+    routing {
+        route(route) {
+            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(genericMessageSerializer, message)
+                                    }
+                                }
+                            }
+                        }
+                    }
+                }
+            }
+            //SSE server. Filter from query
+            get("sse") {
+                val filter = call.buildFilter()
+                val sseFlow = magixFlow.filter(filter).map {
+                    val data = magixJson.encodeToString(genericMessageSerializer, it)
+                    val id = UUID.randomUUID()
+                    SseEvent(data, id = id.toString(), event = "message")
+                }
+                call.respondSse(sseFlow)
+            }
+            post("broadcast") {
+                val message = call.receive<GenericMagixMessage>()
+                magixFlow.emit(message)
+            }
+            //rSocket server. Filter from Payload
+            rSocket("rsocket", acceptor = magixAcceptor(magixFlow))
+        }
+    }
+}
+
+/**
+ * Create a new loop [MutableSharedFlow] with given [buffer] and setup magix module based on it
+ */
+public fun Application.magixModule(route: String = "/", buffer: Int = 100) {
+    val magixFlow = MutableSharedFlow<GenericMagixMessage>(buffer)
+    magixModule(magixFlow, route)
+}
\ No newline at end of file
diff --git a/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/server.kt b/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/server.kt
new file mode 100644
index 0000000..8f425e3
--- /dev/null
+++ b/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/server.kt
@@ -0,0 +1,77 @@
+package ru.mipt.npm.magix.server
+
+import io.ktor.application.Application
+import io.ktor.network.selector.ActorSelectorManager
+import io.ktor.server.cio.CIO
+import io.ktor.server.engine.ApplicationEngine
+import io.ktor.server.engine.embeddedServer
+import io.ktor.util.InternalAPI
+import io.rsocket.kotlin.core.RSocketServer
+import io.rsocket.kotlin.transport.ktor.TcpServerTransport
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableSharedFlow
+import org.slf4j.LoggerFactory
+import ru.mipt.npm.magix.api.MagixEndpoint
+import ru.mipt.npm.magix.api.MagixEndpoint.Companion.DEFAULT_MAGIX_HTTP_PORT
+import ru.mipt.npm.magix.api.MagixEndpoint.Companion.DEFAULT_MAGIX_RAW_PORT
+
+/**
+ * Raw TCP magix server
+ */
+@OptIn(InternalAPI::class)
+public fun CoroutineScope.launchMagixServerRawRSocket(
+    magixFlow: MutableSharedFlow<GenericMagixMessage>,
+    rawSocketPort: Int = DEFAULT_MAGIX_RAW_PORT
+): Job {
+    val tcpTransport = TcpServerTransport(ActorSelectorManager(Dispatchers.IO), port = rawSocketPort)
+    val rSocketJob = RSocketServer().bind(tcpTransport, magixAcceptor(magixFlow))
+    coroutineContext[Job]?.invokeOnCompletion {
+        rSocketJob.cancel()
+    }
+    return rSocketJob;
+}
+
+/**
+ * A combined RSocket/TCP server
+ * @param applicationConfiguration optional additional configuration for magix loop server
+ */
+public fun CoroutineScope.startMagixServer(
+    port: Int = DEFAULT_MAGIX_HTTP_PORT,
+    buffer: Int = 100,
+    enableRawRSocket: Boolean = true,
+    enableZmq: Boolean = true,
+    applicationConfiguration: Application.(MutableSharedFlow<GenericMagixMessage>) -> Unit = {}
+): ApplicationEngine {
+    val logger = LoggerFactory.getLogger("magix-server")
+    val magixFlow = MutableSharedFlow<GenericMagixMessage>(
+        buffer,
+        extraBufferCapacity = buffer
+    )
+
+    if (enableRawRSocket) {
+        //Start tcpRSocket server
+        val rawRSocketPort = DEFAULT_MAGIX_RAW_PORT
+        logger.info("Starting magix raw rsocket server on port $rawRSocketPort")
+        launchMagixServerRawRSocket(magixFlow, rawRSocketPort)
+    }
+    if (enableZmq) {
+        //Start ZMQ server socket pair
+        val zmqPubSocketPort: Int = MagixEndpoint.DEFAULT_MAGIX_ZMQ_PUB_PORT
+        val zmqPullSocketPort: Int = MagixEndpoint.DEFAULT_MAGIX_ZMQ_PULL_PORT
+        logger.info("Starting magix zmq server on pub port $zmqPubSocketPort and pull port $zmqPullSocketPort")
+        launchMagixServerZmqSocket(
+            magixFlow,
+            zmqPubSocketPort = zmqPubSocketPort,
+            zmqPullSocketPort = zmqPullSocketPort
+        )
+    }
+
+    return embeddedServer(CIO, host = "localhost", port = port) {
+        magixModule(magixFlow)
+        applicationConfiguration(magixFlow)
+    }.apply {
+        start()
+    }
+}
\ No newline at end of file
diff --git a/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/sse.kt b/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/sse.kt
new file mode 100644
index 0000000..a1c057f
--- /dev/null
+++ b/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/sse.kt
@@ -0,0 +1,37 @@
+package ru.mipt.npm.magix.server
+
+import io.ktor.application.ApplicationCall
+import io.ktor.http.CacheControl
+import io.ktor.http.ContentType
+import io.ktor.response.cacheControl
+import io.ktor.response.respondBytesWriter
+import io.ktor.utils.io.ByteWriteChannel
+import io.ktor.utils.io.writeStringUtf8
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collect
+
+/**
+ * The data class representing a SSE Event that will be sent to the client.
+ */
+public data class SseEvent(val data: String, val event: String? = "message", val id: String? = null)
+
+public suspend fun ByteWriteChannel.writeSseFlow(events: Flow<SseEvent>): Unit = events.collect { event ->
+    if (event.id != null) {
+        writeStringUtf8("id: ${event.id}\n")
+    }
+    if (event.event != null) {
+        writeStringUtf8("event: ${event.event}\n")
+    }
+    for (dataLine in event.data.lines()) {
+        writeStringUtf8("data: $dataLine\n")
+    }
+    writeStringUtf8("\n")
+    flush()
+}
+
+public suspend fun ApplicationCall.respondSse(events: Flow<SseEvent>) {
+    response.cacheControl(CacheControl.NoCache(null))
+    respondBytesWriter(contentType = ContentType.Text.EventStream) {
+        writeSseFlow(events)
+    }
+}
\ No newline at end of file
diff --git a/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/zmqMagixServerSocket.kt b/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/zmqMagixServerSocket.kt
new file mode 100644
index 0000000..e62acd7
--- /dev/null
+++ b/magix/magix-server/src/main/kotlin/ru/mipt/npm/magix/server/zmqMagixServerSocket.kt
@@ -0,0 +1,45 @@
+package ru.mipt.npm.magix.server
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import org.slf4j.LoggerFactory
+import org.zeromq.SocketType
+import org.zeromq.ZContext
+import ru.mipt.npm.magix.api.MagixEndpoint
+
+public fun CoroutineScope.launchMagixServerZmqSocket(
+    magixFlow: MutableSharedFlow<GenericMagixMessage>,
+    localHost: String = "tcp://*",
+    zmqPubSocketPort: Int = MagixEndpoint.DEFAULT_MAGIX_ZMQ_PUB_PORT,
+    zmqPullSocketPort: Int = MagixEndpoint.DEFAULT_MAGIX_ZMQ_PULL_PORT,
+): Job = launch(Dispatchers.IO) {
+    val logger = LoggerFactory.getLogger("magix-server-zmq")
+
+    ZContext().use { context ->
+        //launch publishing job
+        val pubSocket = context.createSocket(SocketType.PUB)
+        pubSocket.bind("$localHost:$zmqPubSocketPort")
+        magixFlow.onEach { message ->
+            val string = MagixEndpoint.magixJson.encodeToString(genericMessageSerializer, message)
+            pubSocket.send(string)
+            logger.debug("Published: $string")
+        }.launchIn(this)
+
+        //launch pulling job
+        val pullSocket = context.createSocket(SocketType.PULL)
+        pullSocket.bind("$localHost:$zmqPullSocketPort")
+        pullSocket.receiveTimeOut = 500
+        //suspending loop while pulling is active
+        while (isActive) {
+            val string: String? = pullSocket.recvStr()
+            if (string != null) {
+                logger.debug("Received: $string")
+                val message = MagixEndpoint.magixJson.decodeFromString(genericMessageSerializer, string)
+                magixFlow.emit(message)
+            }
+        }
+    }
+}
+
diff --git a/magix/magix-zmq/build.gradle.kts b/magix/magix-zmq/build.gradle.kts
new file mode 100644
index 0000000..cf9e4be
--- /dev/null
+++ b/magix/magix-zmq/build.gradle.kts
@@ -0,0 +1,13 @@
+plugins {
+    id("ru.mipt.npm.gradle.jvm")
+    `maven-publish`
+}
+
+description = """
+    ZMQ client endpoint for Magix
+""".trimIndent()
+
+dependencies {
+    api(projects.magix.magixApi)
+    implementation("org.zeromq:jeromq:0.5.2")
+}
diff --git a/magix/magix-zmq/src/main/kotlin/ru/mipt/npm/magix/zmq/ZmqMagixEndpoint.kt b/magix/magix-zmq/src/main/kotlin/ru/mipt/npm/magix/zmq/ZmqMagixEndpoint.kt
new file mode 100644
index 0000000..d9d6be5
--- /dev/null
+++ b/magix/magix-zmq/src/main/kotlin/ru/mipt/npm/magix/zmq/ZmqMagixEndpoint.kt
@@ -0,0 +1,89 @@
+package ru.mipt.npm.magix.zmq
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.channelFlow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.serialization.KSerializer
+import org.zeromq.SocketType
+import org.zeromq.ZContext
+import org.zeromq.ZMQ
+import org.zeromq.ZMQException
+import ru.mipt.npm.magix.api.MagixEndpoint
+import ru.mipt.npm.magix.api.MagixMessage
+import ru.mipt.npm.magix.api.MagixMessageFilter
+import ru.mipt.npm.magix.api.filter
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.coroutineContext
+
+public class ZmqMagixEndpoint<T>(
+    private val host: String,
+    payloadSerializer: KSerializer<T>,
+    private val pubPort: Int = MagixEndpoint.DEFAULT_MAGIX_ZMQ_PUB_PORT,
+    private val pullPort: Int = MagixEndpoint.DEFAULT_MAGIX_ZMQ_PULL_PORT,
+    private val coroutineContext: CoroutineContext = Dispatchers.IO
+) : MagixEndpoint<T>, AutoCloseable {
+    private val zmqContext by lazy { ZContext() }
+
+    private val serializer = MagixMessage.serializer(payloadSerializer)
+
+    @OptIn(ExperimentalCoroutinesApi::class)
+    override fun subscribe(filter: MagixMessageFilter): Flow<MagixMessage<T>> {
+        val socket = zmqContext.createSocket(SocketType.SUB)
+        socket.connect("$host:$pubPort")
+        socket.subscribe("")
+
+        return channelFlow {
+            invokeOnClose {
+                socket.close()
+            }
+            while (isActive) {
+                try {
+                    //This is a blocking call.
+                    val string: String? = socket.recvStr()
+                    if (string != null) {
+                        val message = MagixEndpoint.magixJson.decodeFromString(serializer, string)
+                        send(message)
+                    }
+                } catch (t: Throwable) {
+                    socket.close()
+                    if (t is ZMQException && t.errorCode == ZMQ.Error.ETERM.code) {
+                        cancel("ZMQ connection terminated", t)
+                    } else {
+                        throw t
+                    }
+                }
+            }
+        }.filter(filter).flowOn(
+            coroutineContext[CoroutineDispatcher] ?: Dispatchers.IO
+        ) //should be flown on IO because of blocking calls
+    }
+
+    private val publishSocket by lazy {
+        zmqContext.createSocket(SocketType.PUSH).apply {
+            connect("$host:$pullPort")
+        }
+    }
+
+    override suspend fun broadcast(message: MagixMessage<T>): Unit = withContext(coroutineContext) {
+        val string = MagixEndpoint.magixJson.encodeToString(serializer, message)
+        publishSocket.send(string)
+    }
+
+    override fun close() {
+        zmqContext.close()
+    }
+}
+
+public suspend fun <T> MagixEndpoint.Companion.zmq(
+    host: String,
+    payloadSerializer: KSerializer<T>,
+    pubPort: Int = DEFAULT_MAGIX_ZMQ_PUB_PORT,
+    pullPort: Int = DEFAULT_MAGIX_ZMQ_PULL_PORT,
+): ZmqMagixEndpoint<T> = ZmqMagixEndpoint(
+    host,
+    payloadSerializer,
+    pubPort,
+    pullPort,
+    coroutineContext = coroutineContext
+)
\ No newline at end of file
diff --git a/motors/build.gradle.kts b/motors/build.gradle.kts
new file mode 100644
index 0000000..a2bd21d
--- /dev/null
+++ b/motors/build.gradle.kts
@@ -0,0 +1,27 @@
+plugins {
+    id("ru.mipt.npm.gradle.jvm")
+    `maven-publish`
+    application
+}
+
+//TODO to be moved to a separate project
+
+application{
+    mainClass.set("ru.mipt.npm.devices.pimotionmaster.PiMotionMasterAppKt")
+}
+
+kotlin{
+    explicitApi = null
+}
+
+kscience{
+    useFx(ru.mipt.npm.gradle.FXModule.CONTROLS, configuration = ru.mipt.npm.gradle.DependencyConfiguration.IMPLEMENTATION)
+}
+
+val ktorVersion: String by rootProject.extra
+
+dependencies {
+    implementation(project(":controls-tcp"))
+    implementation(project(":controls-magix-client"))
+    implementation("no.tornado:tornadofx:1.7.20")
+}
diff --git a/motors/docs/C885T0002-TN-C-885.PIMotionMaster-EN.pdf b/motors/docs/C885T0002-TN-C-885.PIMotionMaster-EN.pdf
new file mode 100644
index 0000000..61bb505
Binary files /dev/null and b/motors/docs/C885T0002-TN-C-885.PIMotionMaster-EN.pdf differ
diff --git a/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt b/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
new file mode 100644
index 0000000..67d475f
--- /dev/null
+++ b/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt
@@ -0,0 +1,158 @@
+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 kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import ru.mipt.npm.controls.controllers.DeviceManager
+import ru.mipt.npm.controls.controllers.installing
+import space.kscience.dataforge.context.Global
+import space.kscience.dataforge.context.fetch
+import tornadofx.*
+
+class PiMotionMasterApp : App(PiMotionMasterView::class)
+
+class PiMotionMasterController : Controller() {
+    //initialize context
+    val context = Global.buildContext("piMotionMaster"){
+        plugin(DeviceManager)
+    }
+
+    //initialize deviceManager plugin
+    val deviceManager: DeviceManager = context.fetch(DeviceManager)
+
+    // 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 {
+        val min = axis.minPosition.readTyped(true)
+        val max = axis.maxPosition.readTyped(true)
+        val positionProperty = axis.position.fxProperty(axis)
+        val startPosition = axis.position.readTyped(true)
+        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)
+                }
+            }
+        }
+    }
+}
+
+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.connected.fxProperty(device)
+    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/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
new file mode 100644
index 0000000..20c5dd1
--- /dev/null
+++ b/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt
@@ -0,0 +1,347 @@
+@file:Suppress("unused", "MemberVisibilityCanBePrivate")
+
+package ru.mipt.npm.devices.pimotionmaster
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+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 ru.mipt.npm.controls.api.DeviceHub
+import ru.mipt.npm.controls.api.PropertyDescriptor
+import ru.mipt.npm.controls.base.*
+import ru.mipt.npm.controls.controllers.duration
+import ru.mipt.npm.controls.ports.*
+import space.kscience.dataforge.context.*
+import space.kscience.dataforge.meta.Meta
+import space.kscience.dataforge.meta.double
+import space.kscience.dataforge.meta.get
+import space.kscience.dataforge.names.NameToken
+import space.kscience.dataforge.values.asValue
+import kotlin.collections.component1
+import kotlin.collections.component2
+import kotlin.time.Duration
+
+class PiMotionMasterDevice(
+    context: Context,
+    private val portFactory: PortFactory = KtorTcpPort,
+) : DeviceBase(context), DeviceHub {
+
+    private var port: Port? = null
+    //TODO make proxy work
+    //PortProxy { portFactory(address ?: error("The device is not connected"), context) }
+
+
+    val connected by readingBoolean(false, descriptorBuilder = {
+        info = "True if the connection address is defined and the device is initialized"
+    }) {
+        port != null
+    }
+
+
+    val connect: DeviceAction by acting({
+        info = "Connect to specific port and initialize axis"
+    }) { portSpec ->
+        //Clear current actions if present
+        if (port != null) {
+            disconnect()
+        }
+        //Update port
+        //address = portSpec.node
+        port = portFactory(portSpec ?: Meta.EMPTY, context)
+        connected.updateLogical(true)
+//        connector.open()
+        //Initialize axes
+        if (portSpec != null) {
+            val idn = identity.read()
+            failIfError { "Can't connect to $portSpec. Error code: $it" }
+            logger.info { "Connected to $idn on $portSpec" }
+            val ids = request("SAI?").map { it.trim() }
+            if (ids != axes.keys.toList()) {
+                //re-define axes if needed
+                axes = ids.associateWith { Axis(it) }
+            }
+            Meta(ids.map { it.asValue() }.asValue())
+            initialize()
+            failIfError()
+        }
+    }
+
+    val disconnect: DeviceAction by acting({
+        info = "Disconnect the program from the device if it is connected"
+    }) {
+        if (port != null) {
+            stop()
+            port?.close()
+        }
+        port = null
+        connected.updateLogical(false)
+    }
+
+    fun disconnect() {
+        runBlocking {
+            disconnect.invoke()
+        }
+    }
+
+    val timeout: DeviceProperty by writingVirtual(200.asValue()) {
+        info = "Timeout"
+    }
+
+    var timeoutValue: Duration by timeout.duration()
+
+    /**
+     * Name-friendly accessor for axis
+     */
+    var axes: Map<String, Axis> = emptyMap()
+        private set
+
+    override val devices: Map<NameToken, Axis> = axes.mapKeys { (key, _) -> NameToken(key) }
+
+    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 {
+            connect(Meta {
+                "host" put host
+                "port" put port
+            })
+        }
+    }
+
+    private val mutex = Mutex()
+
+    private suspend fun dispatchError(errorCode: Int) {
+        logger.error { "Error code: $errorCode" }
+        //TODO add error handling
+    }
+
+    private suspend fun sendCommandInternal(command: String, vararg arguments: String) {
+        val joinedArguments = if (arguments.isEmpty()) {
+            ""
+        } else {
+            arguments.joinToString(prefix = " ", separator = " ", postfix = "")
+        }
+        val stringToSend = "$command$joinedArguments\n"
+        port?.send(stringToSend) ?: error("Not connected to device")
+    }
+
+    suspend fun getErrorCode(): Int = mutex.withLock {
+        withTimeout(timeoutValue) {
+            sendCommandInternal("ERR?")
+            val errorString = port?.receiving()?.withDelimiter("\n")?.first() ?: error("Not connected to device")
+            errorString.trim().toInt()
+        }
+    }
+
+    /**
+     * Send a synchronous request and receive a list of lines as a response
+     */
+    @OptIn(ExperimentalCoroutinesApi::class)
+    private suspend fun request(command: String, vararg arguments: String): List<String> = mutex.withLock {
+        try {
+            withTimeout(timeoutValue) {
+                sendCommandInternal(command, *arguments)
+                val phrases = port?.receiving()?.withDelimiter("\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." }
+            val errorCode = getErrorCode()
+            dispatchError(errorCode)
+            logger.warn { "Error code $errorCode" }
+            error("Error code $errorCode")
+        }
+    }
+
+    private suspend fun requestAndParse(command: String, vararg arguments: String): Map<String, String> = buildMap {
+        request(command, *arguments).forEach { line ->
+            val (key, value) = line.split("=")
+            put(key, value.trim())
+        }
+    }
+
+    /**
+     * Send a synchronous command
+     */
+    private suspend fun send(command: String, vararg arguments: String) {
+        mutex.withLock {
+            withTimeout(timeoutValue) {
+                sendCommandInternal(command, *arguments)
+            }
+        }
+    }
+
+    val initialize: DeviceAction by acting {
+        send("INI")
+    }
+
+    val identity: ReadOnlyDeviceProperty by readingString {
+        request("*IDN?").first()
+    }
+
+    val firmwareVersion: ReadOnlyDeviceProperty by readingString {
+        request("VER?").first()
+    }
+
+    val stop: DeviceAction by acting(
+        descriptorBuilder = {
+            info = "Stop all axis"
+        },
+        action = { send("STP") }
+    )
+
+    inner class Axis(val axisId: String) : DeviceBase(context) {
+
+        private suspend fun readAxisBoolean(command: String): Boolean =
+            requestAndParse(command, axisId)[axisId]?.toIntOrNull()
+                    ?: error("Malformed $command response. Should include integer value for $axisId") != 0
+
+        private suspend fun writeAxisBoolean(command: String, value: Boolean): Boolean {
+            val boolean = if (value) {
+                "1"
+            } else {
+                "0"
+            }
+            send(command, axisId, boolean)
+            failIfError()
+            return value
+        }
+
+        private fun axisBooleanProperty(command: String, descriptorBuilder: PropertyDescriptor.() -> Unit = {}) =
+            writingBoolean(
+                getter = { readAxisBoolean("$command?") },
+                setter = { _, newValue ->
+                    writeAxisBoolean(command, newValue)
+                },
+                descriptorBuilder = descriptorBuilder
+            )
+
+        private fun axisNumberProperty(command: String, descriptorBuilder: PropertyDescriptor.() -> Unit = {}) =
+            writingDouble(
+                getter = {
+                    requestAndParse("$command?", axisId)[axisId]?.toDoubleOrNull()
+                        ?: error("Malformed $command response. Should include float value for $axisId")
+                },
+                setter = { _, newValue ->
+                    send(command, axisId, newValue.toString())
+                    failIfError()
+                    newValue
+                },
+                descriptorBuilder = descriptorBuilder
+            )
+
+        val enabled by axisBooleanProperty("EAX") {
+            info = "Motor enable state."
+        }
+
+        val halt: DeviceAction by acting {
+            send("HLT", axisId)
+        }
+
+        val targetPosition by axisNumberProperty("MOV") {
+            info = """
+                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: TypedReadOnlyDeviceProperty<Boolean> by readingBoolean(
+            descriptorBuilder = {
+                info = "Queries the on-target state of the specified axis."
+            },
+            getter = {
+                readAxisBoolean("ONT?")
+            }
+        )
+
+        val reference: ReadOnlyDeviceProperty by readingBoolean(
+            descriptorBuilder = {
+                info = "Get Referencing Result"
+            },
+            getter = {
+                readAxisBoolean("FRF?")
+            }
+        )
+
+        val moveToReference by acting {
+            send("FRF", axisId)
+        }
+
+        val minPosition by readingDouble(
+            descriptorBuilder = {
+                info = "Minimal position value for the axis"
+            },
+            getter = {
+                requestAndParse("TMN?", axisId)[axisId]?.toDoubleOrNull()
+                    ?: error("Malformed `TMN?` response. Should include float value for $axisId")
+            }
+        )
+
+        val maxPosition by readingDouble(
+            descriptorBuilder = {
+                info = "Maximal position value for the axis"
+            },
+            getter = {
+                requestAndParse("TMX?", axisId)[axisId]?.toDoubleOrNull()
+                    ?: error("Malformed `TMX?` response. Should include float value for $axisId")
+            }
+        )
+
+        val position by readingDouble(
+            descriptorBuilder = {
+                info = "The current axis position."
+            },
+            getter = {
+                requestAndParse("POS?", axisId)[axisId]?.toDoubleOrNull()
+                    ?: error("Malformed `POS?` response. Should include float value for $axisId")
+            }
+        )
+
+        val openLoopTarget: DeviceProperty by axisNumberProperty("OMA") {
+            info = "Position for open-loop operation."
+        }
+
+        val closedLoop: TypedDeviceProperty<Boolean> by axisBooleanProperty("SVO") {
+            info = "Servo closed loop mode"
+        }
+
+        val velocity: TypedDeviceProperty<Double> by axisNumberProperty("VEL") {
+            info = "Velocity value for closed-loop operation"
+        }
+
+        val move by acting {
+            val target = it.double ?: it?.get("target").double ?: error("Unacceptable target value $it")
+            closedLoop.write(true)
+            //optionally set velocity
+            it?.get("velocity").double?.let { v ->
+                velocity.write(v)
+            }
+            targetPosition.write(target)
+            //read `onTarget` and `position` properties in a cycle until movement is complete
+            while (!onTarget.readTyped(true)) {
+                position.read(true)
+                delay(200)
+            }
+        }
+
+        suspend fun move(target: Double) {
+            move(target.asMeta())
+        }
+    }
+
+    companion object : Factory<PiMotionMasterDevice> {
+        override fun invoke(meta: Meta, context: Context): PiMotionMasterDevice = PiMotionMasterDevice(context)
+    }
+
+}
\ No newline at end of file
diff --git a/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt b/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt
new file mode 100644
index 0000000..cb490e1
--- /dev/null
+++ b/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt
@@ -0,0 +1,277 @@
+package ru.mipt.npm.devices.pimotionmaster
+
+import kotlinx.coroutines.*
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.flow.*
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import ru.mipt.npm.controls.api.Socket
+import ru.mipt.npm.controls.ports.AbstractPort
+import ru.mipt.npm.controls.ports.withDelimiter
+import space.kscience.dataforge.context.*
+import kotlin.math.abs
+import kotlin.time.Duration
+
+abstract class VirtualDevice(val scope: CoroutineScope) : Socket<ByteArray> {
+
+    protected abstract suspend fun evaluateRequest(request: ByteArray)
+
+    protected open fun Flow<ByteArray>.transformRequests(): Flow<ByteArray> = this
+
+    private val toReceive = Channel<ByteArray>(100)
+    private val toRespond = Channel<ByteArray>(100)
+
+    private val mutex = Mutex()
+
+    private val receiveJob: Job = toReceive.consumeAsFlow().transformRequests().onEach {
+        mutex.withLock {
+            evaluateRequest(it)
+        }
+    }.catch {
+        it.printStackTrace()
+    }.launchIn(scope)
+
+
+    override suspend fun send(data: ByteArray) {
+        toReceive.send(data)
+    }
+
+    protected suspend fun respond(response: ByteArray) {
+        toRespond.send(response)
+    }
+
+    override fun receiving(): 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 fun close() = scope.cancel()
+}
+
+class VirtualPort(private val device: VirtualDevice, context: Context) : AbstractPort(context) {
+
+    private val respondJob = device.receiving().onEach {
+        receive(it)
+    }.catch {
+        it.printStackTrace()
+    }.launchIn(scope)
+
+
+    override suspend fun write(data: ByteArray) {
+        device.send(data)
+    }
+
+    override fun close() {
+        respondJob.cancel()
+        super.close()
+    }
+}
+
+
+class PiMotionMasterVirtualDevice(
+    override val context: Context,
+    axisIds: List<String>,
+    scope: CoroutineScope = context,
+) : VirtualDevice(scope), ContextAware {
+
+    init {
+        //add asynchronous send logic here
+    }
+
+    override fun Flow<ByteArray>.transformRequests(): Flow<ByteArray> = withDelimiter("\n".toByteArray())
+
+    private var errorCode: Int = 0
+
+    private val axisState: Map<String, VirtualAxisState> = axisIds.associateWith { VirtualAxisState() }
+
+    private inner class VirtualAxisState {
+        private var movementJob: Job? = null
+
+        private fun startMovement() {
+            movementJob?.cancel()
+            movementJob = scope.launch {
+                while (!onTarget()) {
+                    delay(100)
+                    val proposedStep = velocity / 10
+                    val distance = targetPosition - position
+                    when {
+                        abs(distance) < proposedStep -> {
+                            position = targetPosition
+                        }
+                        targetPosition > position -> {
+                            position += proposedStep
+                        }
+                        else -> {
+                            position -= proposedStep
+                        }
+                    }
+                }
+            }
+        }
+
+        var referenceMode = 1
+
+        var velocity = 0.6
+
+        var position = 0.0
+            private set
+        var servoMode: Int = 1
+
+        var targetPosition = 0.0
+            set(value) {
+                field = value
+                if (servoMode == 1) {
+                    startMovement()
+                }
+            }
+
+        fun onTarget() = abs(targetPosition - position) < 0.001
+
+        val minPosition = 0.0
+        val maxPosition = 26.0
+    }
+
+
+    private fun respond(str: String) = scope.launch {
+        respond((str + "\n").encodeToByteArray())
+    }
+
+    private fun respondForAllAxis(axisIds: List<String>, extract: VirtualAxisState.(index: String) -> Any) {
+        val selectedAxis = if (axisIds.isEmpty() || axisIds[0] == "ALL") {
+            axisState.keys
+        } else {
+            axisIds
+        }
+        val response = selectedAxis.joinToString(separator = " \n") {
+            val state = axisState.getValue(it)
+            val value = when (val extracted = state.extract(it)) {
+                true -> 1
+                false -> 0
+                else -> extracted
+            }
+            "$it=$value"
+        }
+        respond(response)
+    }
+
+    private suspend fun doForEachAxis(parts: List<String>, action: suspend (key: String, value: String) -> Unit) {
+        var i = 0
+        while (parts.size > 2 * i + 1) {
+            action(parts[2 * i + 1], parts[2 * i + 2])
+            i++
+        }
+    }
+
+    override suspend fun evaluateRequest(request: ByteArray) {
+        assert(request.last() == '\n'.code.toByte())
+        val string = request.decodeToString().substringBefore("\n")
+            .dropWhile { it != '*' && it != '#' && it !in 'A'..'Z' } //filter junk symbols at the beginning of the line
+
+        //logger.debug { "Received command: $string" }
+        val parts = string.split(' ')
+        val command = parts.firstOrNull() ?: error("Command not present")
+
+        val axisIds: List<String> = parts.drop(1)
+
+        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("""
+                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 
+                5: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550042, 00.039 
+                6: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550043, 00.039 
+                7: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550044, 00.039 
+                8: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550046, 00.039 
+                9: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550045, 00.039 
+                10: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550047, 00.039 
+                11: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550048, 00.039 
+                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("""
+                The following commands are valid: 
+                #4 Request Status Register 
+                #5 Request Motion Status 
+                #7 Request Controller Ready Status 
+                #24 Stop All Axes 
+                *IDN? Get Device Identification 
+                CST? [{<AxisID>}] Get Assignment Of Stages To Axes 
+                CSV? Get Current Syntax Version 
+                ERR? Get Error Number 
+                FRF [{<AxisID>}] Fast Reference Move To Reference Switch 
+                FRF? [{<AxisID>}] Get Referencing Result 
+                HLP? Get List Of Available Commands 
+                HLT [{<AxisID>}] Halt Motion Smoothly 
+                IFC {<InterfacePam> <PamValue>} Set Interface Parameters Temporarily 
+                IFC? [{<InterfacePam>}] Get Current Interface Parameters 
+                IFS <Pswd> {<InterfacePam> <PamValue>} Set Interface Parameters As Default Values 
+                IFS? [{<InterfacePam>}] Get Interface Parameters As Default Values 
+                INI Initialize Axes 
+                MAN? <CMD> Get Help String For Command 
+                MOV {<AxisID> <Position>} Set Target Position (start absolute motion) 
+                MOV? [{<AxisID>}] Get Target Position 
+                ONT? [{<AxisID>}] Get On-Target State 
+                POS {<AxisID> <Position>} Set Real Position (does not cause motion)
+                POS? [{<AxisID>}] Get Real Position 
+                RBT Reboot System 
+                RON {<AxisID> <ReferenceOn>} Set Reference Mode 
+                RON? [{<AxisID>}] Get Reference Mode 
+                SAI? [ALL] Get List Of Current Axis Identifiers 
+                SRG? {<AxisID> <RegisterID>} Query Status Register Value 
+                STP Stop All Axes 
+                SVO {<AxisID> <ServoState>} Set Servo Mode 
+                SVO? [{<AxisID>}] Get Servo Mode 
+                TMN? [{<AxisID>}] Get Minimum Commandable Position 
+                TMX? [{<AxisID>}] Get Maximum Commandable Position 
+                VEL {<AxisID> <Velocity>} Set Closed-Loop Velocity 
+                VEL? [{<AxisID>}] Get Closed-Loop Velocity 
+                VER? Get Versions Of Firmware And Drivers 
+                end of help
+            """.trimIndent())
+            "ERR?" -> {
+                respond(errorCode.toString())
+                errorCode = 0
+            }
+            "SAI?" -> respond(axisState.keys.joinToString(separator = " \n"))
+            "CST?" -> respondForAllAxis(axisIds) { "L-220.20SG" }
+            "RON?" -> respondForAllAxis(axisIds) { referenceMode }
+            "FRF?" -> respondForAllAxis(axisIds) { "1" } // WAT?
+            "SVO?" -> respondForAllAxis(axisIds) { servoMode }
+            "MOV?" -> respondForAllAxis(axisIds) { targetPosition }
+            "POS?" -> respondForAllAxis(axisIds) { position }
+            "TMN?" -> respondForAllAxis(axisIds) { minPosition }
+            "TMX?" -> respondForAllAxis(axisIds) { maxPosition }
+            "VEL?" -> respondForAllAxis(axisIds) { velocity }
+            "SRG?" -> respond(WAT)
+            "ONT?" -> respondForAllAxis(axisIds) { onTarget() }
+            "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
+            } // do not send anything. Ser error code
+        }
+    }
+
+    companion object {
+        private const val WAT = "WAT?"
+    }
+}
diff --git a/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt b/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt
new file mode 100644
index 0000000..48ef6d2
--- /dev/null
+++ b/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt
@@ -0,0 +1,60 @@
+package ru.mipt.npm.devices.pimotionmaster
+
+import javafx.beans.property.ObjectPropertyBase
+import javafx.beans.property.Property
+import javafx.beans.property.ReadOnlyProperty
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.launchIn
+import kotlinx.coroutines.flow.onEach
+import ru.mipt.npm.controls.api.Device
+import ru.mipt.npm.controls.base.TypedDeviceProperty
+import ru.mipt.npm.controls.base.TypedReadOnlyDeviceProperty
+import space.kscience.dataforge.context.info
+import space.kscience.dataforge.context.logger
+import tornadofx.*
+
+fun <T : Any> TypedReadOnlyDeviceProperty<T>.fxProperty(ownerDevice: Device?): ReadOnlyProperty<T> =
+    object : ObjectPropertyBase<T>() {
+        override fun getBean(): Any? = ownerDevice
+        override fun getName(): String = this@fxProperty.name
+
+        init {
+            //Read incoming changes
+            flowTyped().onEach {
+                if (it != null) {
+                    runLater {
+                        set(it)
+                    }
+                } else {
+                    invalidated()
+                }
+            }.catch {
+                ownerDevice?.logger?.info { "Failed to set property $name to $it" }
+            }.launchIn(scope)
+        }
+    }
+
+fun <T : Any> TypedDeviceProperty<T>.fxProperty(ownerDevice: Device?): Property<T> =
+    object : ObjectPropertyBase<T>() {
+        override fun getBean(): Any? = ownerDevice
+        override fun getName(): String = this@fxProperty.name
+
+        init {
+            //Read incoming changes
+            flowTyped().onEach {
+                if (it != null) {
+                    runLater {
+                        set(it)
+                    }
+                } else {
+                    invalidated()
+                }
+            }.catch {
+                ownerDevice?.logger?.info { "Failed to set property $name  to $it" }
+            }.launchIn(scope)
+
+            onChange {
+                typedValue = it
+            }
+        }
+    }
diff --git a/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt b/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
new file mode 100644
index 0000000..f5a4c81
--- /dev/null
+++ b/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt
@@ -0,0 +1,69 @@
+package ru.mipt.npm.devices.pimotionmaster
+
+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.util.InternalAPI
+import io.ktor.util.moveToByteArray
+import io.ktor.utils.io.writeAvailable
+import kotlinx.coroutines.*
+import kotlinx.coroutines.flow.collect
+import space.kscience.dataforge.context.Context
+import space.kscience.dataforge.context.Global
+import java.net.InetSocketAddress
+
+val exceptionHandler = CoroutineExceptionHandler { _, throwable ->
+    throwable.printStackTrace()
+}
+
+@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(InetSocketAddress("localhost", port))
+    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()
+
+            val sendJob = launch {
+                virtualDevice.receiving().collect {
+                    //println("Sending: ${it.decodeToString()}")
+                    output.writeAvailable(it)
+                    output.flush()
+                }
+            }
+
+            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("Socket closed")
+            }
+
+        }
+    }
+}
+
+fun main() {
+    val port = 10024
+    runBlocking(Dispatchers.Default) {
+        val serverJob = Global.launchPiDebugServer(port, listOf("1", "2"))
+        readLine()
+        serverJob.cancel()
+    }
+}
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 2cc6c04..cf5e79e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -1,46 +1,52 @@
+rootProject.name = "controls-kt"
+
+enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
+enableFeaturePreview("VERSION_CATALOGS")
+
 pluginManagement {
-    val kotlinVersion = "1.3.72"
-    val toolsVersion = "0.5.0"
+    val toolsVersion = "0.10.4"
 
     repositories {
-        mavenLocal()
-        jcenter()
+        maven("https://repo.kotlin.link")
+        mavenCentral()
         gradlePluginPortal()
-        maven("https://kotlin.bintray.com/kotlinx")
-        maven("https://dl.bintray.com/kotlin/kotlin-eap")
-        maven("https://dl.bintray.com/mipt-npm/dataforge")
-        maven("https://dl.bintray.com/mipt-npm/scientifik")
-        maven("https://dl.bintray.com/mipt-npm/dev")
     }
 
     plugins {
+        id("ru.mipt.npm.gradle.project") version toolsVersion
+        id("ru.mipt.npm.gradle.mpp") version toolsVersion
+        id("ru.mipt.npm.gradle.jvm") version toolsVersion
+        id("ru.mipt.npm.gradle.js") version toolsVersion
+    }
+}
 
-
-        kotlin("jvm") version kotlinVersion
-        id("scientifik.mpp") version toolsVersion
-        id("scientifik.jvm") version toolsVersion
-        id("scientifik.js") version toolsVersion
-        id("scientifik.publish") version toolsVersion
+dependencyResolutionManagement {
+    repositories {
+        maven("https://repo.kotlin.link")
+        mavenCentral()
     }
 
-    resolutionStrategy {
-        eachPlugin {
-            when (requested.id.id) {
-                "scientifik.publish", "scientifik.mpp", "scientifik.jvm", "scientifik.js" -> useModule("scientifik:gradle-tools:${toolsVersion}")
-                "kotlinx-atomicfu" -> useModule("org.jetbrains.kotlinx:atomicfu-gradle-plugin:${requested.version}")
-            }
+    versionCatalogs {
+        create("npm") {
+            from("ru.mipt.npm:version-catalog:0.10.4")
         }
     }
 }
 
-rootProject.name = "dataforge-device"
-
 include(
-    ":dataforge-device-core",
-    ":dataforge-device-server",
-    ":dataforge-device-client",
-    ":demo"
-)
-
-//includeBuild("../dataforge-core")
-//includeBuild("../plotly.kt")
\ No newline at end of file
+    ":controls-core",
+    ":controls-tcp",
+    ":controls-serial",
+    ":controls-server",
+    ":controls-opcua",
+    ":demo",
+    ":magix",
+    ":magix:magix-api",
+    ":magix:magix-server",
+    ":magix:magix-rsocket",
+    ":magix:magix-java-client",
+    ":magix:magix-zmq",
+    ":magix:magix-demo",
+    ":controls-magix-client",
+    ":motors"
+)
\ No newline at end of file