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