diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f9d13..4a258d7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,53 @@ ## Unreleased ### Added +- Value averaging plot extension +- PLC4X bindings + +### Changed +- Constructor properties return `DeviceStat` in order to be able to subscribe to them +- Refactored ports. Now we have `AsynchronousPort` as well as `SynchronousPort` + +### Deprecated + +### Removed + +### Fixed + +### Security + +## 0.3.0 - 2024-03-04 + +### Added + +- Device lifecycle message +- Low-code constructor +- Automatic description generation for spec properties (JVM only) + +### Changed + +- Property caching moved from core `Device` to the `CachingDevice` +- `DeviceSpec` properties no explicitly pass property name to getters and setters. +- `DeviceHub.respondHubMessage` now returns a list of messages to allow querying multiple devices. Device server also returns an array. +- DataForge 0.8.0 + +### Fixed + +- Property writing does not trigger change if logical state already is the same as value to be set. +- Modbus-slave triggers only once for multi-register write. +- Removed unnecessary scope in hub messageFlow + +## 0.2.2-dev-1 - 2023-09-24 + +### Changed + +- updating logical state in `DeviceBase` is now protected and called `propertyChanged()` +- `DeviceBase` tries to read property after write if the writer does not set the value. + +## 0.2.1 - 2023-09-24 + +### Added + - Core interfaces for building a device server - Magix service for binding controls devices (both as RPC client and server) - A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols @@ -20,13 +67,3 @@ - A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes. - Magix history database API - ZMQ client endpoint for Magix - -### Changed - -### Deprecated - -### Removed - -### Fixed - -### Security diff --git a/README.md b/README.md index d5b40c3..d6a602b 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ [![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub) +[![](https://maven.sciprog.center/api/badge/latest/kscience/space/kscience/controls-core-jvm?color=40c14a&name=repo.kotlin.link&prefix=v)](https://maven.sciprog.center/) + # Controls.kt Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing. @@ -42,6 +44,11 @@ Example view of a demo: ## Modules +### [controls-constructor](controls-constructor) +> A low-code constructor for composite devices simulation +> +> **Maturity**: PROTOTYPE + ### [controls-core](controls-core) > Core interfaces for building a device server > @@ -56,6 +63,10 @@ Example view of a demo: > - [ports](controls-core/src/commonMain/kotlin/space/kscience/controls/ports) : Working with asynchronous data sending and receiving raw byte arrays +### [controls-jupyter](controls-jupyter) +> +> **Maturity**: EXPERIMENTAL + ### [controls-magix](controls-magix) > Magix service for binding controls devices (both as RPC client and server) > @@ -113,6 +124,11 @@ Automatically checks consistency. > > **Maturity**: PROTOTYPE +### [controls-vision](controls-vision) +> Dashboard and visualization extensions for devices +> +> **Maturity**: PROTOTYPE + ### [demo](demo) > > **Maturity**: EXPERIMENTAL @@ -134,6 +150,10 @@ Automatically checks consistency. > > **Maturity**: EXPERIMENTAL +### [demo/constructor](demo/constructor) +> +> **Maturity**: EXPERIMENTAL + ### [demo/echo](demo/echo) > > **Maturity**: EXPERIMENTAL diff --git a/build.gradle.kts b/build.gradle.kts index 2463418..57cb978 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,4 +1,3 @@ -import space.kscience.gradle.isInDevelopment import space.kscience.gradle.useApache2Licence import space.kscience.gradle.useSPCTeam @@ -9,25 +8,19 @@ plugins { allprojects { group = "space.kscience" - version = "0.2.0" + version = "0.3.1-dev-1" repositories{ maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") } } ksciencePublish { - pom("https://github.com/SciProgCentre/controls.kt") { + pom("https://github.com/SciProgCentre/controls-kt") { useApache2Licence() useSPCTeam() } - github("controls.kt", "SciProgCentre") - space( - if (isInDevelopment) { - "https://maven.pkg.jetbrains.space/spc/p/sci/dev" - } else { - "https://maven.pkg.jetbrains.space/spc/p/sci/maven" - } - ) + repository("spc","https://maven.sciprog.center/kscience") + sonatype("https://oss.sonatype.org") } readme.readmeTemplate = file("docs/templates/README-TEMPLATE.md") \ No newline at end of file diff --git a/controls-constructor/README.md b/controls-constructor/README.md new file mode 100644 index 0000000..9a8b27c --- /dev/null +++ b/controls-constructor/README.md @@ -0,0 +1,21 @@ +# Module controls-constructor + +A low-code constructor for composite devices simulation + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:controls-constructor:0.3.0`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:controls-constructor:0.3.0") +} +``` diff --git a/controls-constructor/build.gradle.kts b/controls-constructor/build.gradle.kts new file mode 100644 index 0000000..e62dd8e --- /dev/null +++ b/controls-constructor/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +description = """ + A low-code constructor for composite devices simulation +""".trimIndent() + +kscience{ + jvm() + js() + dependencies { + api(projects.controlsCore) + } +} + +readme{ + maturity = space.kscience.gradle.Maturity.PROTOTYPE +} diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt new file mode 100644 index 0000000..63b5303 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt @@ -0,0 +1,118 @@ +package space.kscience.controls.constructor + +import space.kscience.controls.api.Device +import space.kscience.controls.api.PropertyDescriptor +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty +import kotlin.time.Duration + +/** + * A base for strongly typed device constructor blocks. Has additional delegates for type-safe devices + */ +public abstract class DeviceConstructor( + context: Context, + meta: Meta, +) : DeviceGroup(context, meta) { + + /** + * Register a device, provided by a given [factory] and + */ + public fun device( + factory: Factory, + meta: Meta? = null, + nameOverride: Name? = null, + metaLocation: Name? = null, + ): PropertyDelegateProvider> = + PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> -> + val name = nameOverride ?: property.name.asName() + val device = install(name, factory, meta, metaLocation ?: name) + ReadOnlyProperty { _: DeviceConstructor, _ -> + device + } + } + + public fun device( + device: D, + nameOverride: Name? = null, + ): PropertyDelegateProvider> = + PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> -> + val name = nameOverride ?: property.name.asName() + install(name, device) + ReadOnlyProperty { _: DeviceConstructor, _ -> + device + } + } + + + /** + * Register a property and provide a direct reader for it + */ + public fun > property( + state: S, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + nameOverride: String? = null, + ): PropertyDelegateProvider> = + PropertyDelegateProvider { _: DeviceConstructor, property -> + val name = nameOverride ?: property.name + val descriptor = PropertyDescriptor(name).apply(descriptorBuilder) + registerProperty(descriptor, state) + ReadOnlyProperty { _: DeviceConstructor, _ -> + state + } + } + + /** + * Register external state as a property + */ + public fun property( + metaConverter: MetaConverter, + reader: suspend () -> T, + readInterval: Duration, + initialState: T, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + nameOverride: String? = null, + ): PropertyDelegateProvider>> = property( + DeviceState.external(this, metaConverter, readInterval, initialState, reader), + descriptorBuilder, + nameOverride, + ) + + /** + * Register a mutable external state as a property + */ + public fun mutableProperty( + metaConverter: MetaConverter, + reader: suspend () -> T, + writer: suspend (T) -> Unit, + readInterval: Duration, + initialState: T, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + nameOverride: String? = null, + ): PropertyDelegateProvider>> = property( + DeviceState.external(this, metaConverter, readInterval, initialState, reader, writer), + descriptorBuilder, + nameOverride, + ) + + /** + * Create and register a virtual mutable property with optional [callback] + */ + public fun virtualProperty( + metaConverter: MetaConverter, + initialState: T, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + nameOverride: String? = null, + callback: (T) -> Unit = {}, + ): PropertyDelegateProvider>> = property( + DeviceState.virtual(metaConverter, initialState, callback), + descriptorBuilder, + nameOverride, + ) +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt new file mode 100644 index 0000000..ca05c27 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt @@ -0,0 +1,298 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import space.kscience.controls.api.* +import space.kscience.controls.api.DeviceLifecycleState.* +import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.manager.install +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.context.request +import space.kscience.dataforge.meta.* +import space.kscience.dataforge.misc.DFExperimental +import space.kscience.dataforge.names.* +import kotlin.collections.set +import kotlin.coroutines.CoroutineContext + + +/** + * A mutable group of devices and properties to be used for lightweight design and simulations. + */ +public open class DeviceGroup( + final override val context: Context, + override val meta: Meta, +) : DeviceHub, CachingDevice { + + internal class Property( + val state: DeviceState<*>, + val descriptor: PropertyDescriptor, + ) + + internal class Action( + val invoke: suspend (Meta?) -> Meta?, + val descriptor: ActionDescriptor, + ) + + + private val sharedMessageFlow = MutableSharedFlow() + + override val messageFlow: Flow + get() = sharedMessageFlow + + @OptIn(ExperimentalCoroutinesApi::class) + override val coroutineContext: CoroutineContext = context.newCoroutineContext( + SupervisorJob(context.coroutineContext[Job]) + + CoroutineName("Device $id") + + CoroutineExceptionHandler { _, throwable -> + context.launch { + sharedMessageFlow.emit( + DeviceErrorMessage( + errorMessage = throwable.message, + errorType = throwable::class.simpleName, + errorStackTrace = throwable.stackTraceToString() + ) + ) + } + } + ) + + + private val _devices = hashMapOf() + + override val devices: Map = _devices + + /** + * Register and initialize (synchronize child's lifecycle state with group state) a new device in this group + */ + @OptIn(DFExperimental::class) + public fun install(token: NameToken, device: D): D { + require(_devices[token] == null) { "A child device with name $token already exists" } + //start the child device if needed + if (lifecycleState == STARTED || lifecycleState == STARTING) launch { device.start() } + _devices[token] = device + return device + } + + private val properties: MutableMap = hashMapOf() + + /** + * Register a new property based on [DeviceState]. Properties could be modified dynamically + */ + public fun registerProperty(descriptor: PropertyDescriptor, state: DeviceState<*>) { + val name = descriptor.name.parseAsName() + require(properties[name] == null) { "Can't add property with name $name. It already exists." } + properties[name] = Property(state, descriptor) + state.metaFlow.onEach { + sharedMessageFlow.emit( + PropertyChangedMessage( + descriptor.name, + it + ) + ) + }.launchIn(this) + } + + private val actions: MutableMap = hashMapOf() + + override val propertyDescriptors: Collection + get() = properties.values.map { it.descriptor } + + override val actionDescriptors: Collection + get() = actions.values.map { it.descriptor } + + override suspend fun readProperty(propertyName: String): Meta = + properties[propertyName.parseAsName()]?.state?.valueAsMeta + ?: error("Property with name $propertyName not found") + + override fun getProperty(propertyName: String): Meta? = properties[propertyName.parseAsName()]?.state?.valueAsMeta + + override suspend fun invalidate(propertyName: String) { + //does nothing for this implementation + } + + override suspend fun writeProperty(propertyName: String, value: Meta) { + val property = (properties[propertyName.parseAsName()]?.state as? MutableDeviceState) + ?: error("Property with name $propertyName not found") + property.valueAsMeta = value + } + + + override suspend fun execute(actionName: String, argument: Meta?): Meta? { + val action = actions[actionName] ?: error("Action with name $actionName not found") + return action.invoke(argument) + } + + @DFExperimental + override var lifecycleState: DeviceLifecycleState = STOPPED + protected set(value) { + if (field != value) { + launch { + sharedMessageFlow.emit( + DeviceLifeCycleMessage(value) + ) + } + } + field = value + } + + + @OptIn(DFExperimental::class) + override suspend fun start() { + lifecycleState = STARTING + super.start() + devices.values.forEach { + it.start() + } + lifecycleState = STARTED + } + + @OptIn(DFExperimental::class) + override fun stop() { + devices.values.forEach { + it.stop() + } + super.stop() + lifecycleState = STOPPED + } + + public companion object { + + } +} + +public fun DeviceManager.registerDeviceGroup( + name: String = "@group", + meta: Meta = Meta.EMPTY, + block: DeviceGroup.() -> Unit, +): DeviceGroup { + val group = DeviceGroup(context, meta).apply(block) + install(name, group) + return group +} + +public fun Context.registerDeviceGroup( + name: String = "@group", + meta: Meta = Meta.EMPTY, + block: DeviceGroup.() -> Unit, +): DeviceGroup = request(DeviceManager).registerDeviceGroup(name, meta, block) + +private fun DeviceGroup.getOrCreateGroup(name: Name): DeviceGroup { + return when (name.length) { + 0 -> this + 1 -> { + val token = name.first() + when (val d = devices[token]) { + null -> install( + token, + DeviceGroup(context, meta[token] ?: Meta.EMPTY) + ) + + else -> (d as? DeviceGroup) ?: error("Device $name is not a DeviceGroup") + } + } + + else -> getOrCreateGroup(name.first().asName()).getOrCreateGroup(name.cutFirst()) + } +} + +/** + * Register a device at given [name] path + */ +public fun DeviceGroup.install(name: Name, device: D): D { + return when (name.length) { + 0 -> error("Can't use empty name for a child device") + 1 -> install(name.first(), device) + else -> getOrCreateGroup(name.cutLast()).install(name.tokens.last(), device) + } +} + +public fun DeviceGroup.install(name: String, device: D): D = + install(name.parseAsName(), device) + +public fun DeviceGroup.install(device: D): D = + install(device.id, device) + +public fun Context.install(name: String, device: D): D = request(DeviceManager).install(name, device) + +/** + * Add a device creating intermediate groups if necessary. If device with given [name] already exists, throws an error. + * @param name the name of the device in the group + * @param factory a factory used to create a device + * @param deviceMeta meta override for this specific device + * @param metaLocation location of the template meta in parent group meta + */ +public fun DeviceGroup.install( + name: Name, + factory: Factory, + deviceMeta: Meta? = null, + metaLocation: Name = name, +): D { + val newDevice = factory.build(context, Laminate(deviceMeta, meta[metaLocation])) + install(name, newDevice) + return newDevice +} + +public fun DeviceGroup.install( + name: String, + factory: Factory, + metaLocation: Name = name.parseAsName(), + metaBuilder: (MutableMeta.() -> Unit)? = null, +): D = install(name.parseAsName(), factory, metaBuilder?.let { Meta(it) }, metaLocation) + +/** + * Create or edit a group with a given [name]. + */ +public fun DeviceGroup.registerDeviceGroup(name: Name, block: DeviceGroup.() -> Unit): DeviceGroup = + getOrCreateGroup(name).apply(block) + +public fun DeviceGroup.registerDeviceGroup(name: String, block: DeviceGroup.() -> Unit): DeviceGroup = + registerDeviceGroup(name.parseAsName(), block) + +/** + * Register read-only property based on [state] + */ +public fun DeviceGroup.registerProperty( + name: String, + state: DeviceState, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, +) { + registerProperty( + PropertyDescriptor(name).apply(descriptorBuilder), + state + ) +} + +/** + * Register a mutable property based on mutable [state] + */ +public fun DeviceGroup.registerMutableProperty( + name: String, + state: MutableDeviceState, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, +) { + registerProperty( + PropertyDescriptor(name).apply(descriptorBuilder), + state + ) +} + + +/** + * Create a new virtual mutable state and a property based on it. + * @return the mutable state used in property + */ +public fun DeviceGroup.registerVirtualProperty( + name: String, + initialValue: T, + converter: MetaConverter, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + callback: (T) -> Unit = {}, +): MutableDeviceState { + val state = DeviceState.virtual(converter, initialValue, callback) + registerMutableProperty(name, state, descriptorBuilder) + return state +} diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt new file mode 100644 index 0000000..faa7b2f --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt @@ -0,0 +1,242 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import space.kscience.controls.api.Device +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.MutableDevicePropertySpec +import space.kscience.controls.spec.name +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter +import kotlin.reflect.KProperty +import kotlin.time.Duration + +/** + * An observable state of a device + */ +public interface DeviceState { + public val converter: MetaConverter + public val value: T + + public val valueFlow: Flow + + public companion object +} + +public val DeviceState.metaFlow: Flow get() = valueFlow.map(converter::convert) + +public val DeviceState.valueAsMeta: Meta get() = converter.convert(value) + +public operator fun DeviceState.getValue(thisRef: Any?, property: KProperty<*>): T = value + +/** + * Collect values in a given [scope] + */ +public fun DeviceState.collectValuesIn(scope: CoroutineScope, block: suspend (T)->Unit): Job = + valueFlow.onEach(block).launchIn(scope) + +/** + * A mutable state of a device + */ +public interface MutableDeviceState : DeviceState { + override var value: T +} + +public operator fun MutableDeviceState.setValue(thisRef: Any?, property: KProperty<*>, value: T) { + this.value = value +} + +public var MutableDeviceState.valueAsMeta: Meta + get() = converter.convert(value) + set(arg) { + value = converter.read(arg) + } + +/** + * A [MutableDeviceState] that does not correspond to a physical state + * + * @param callback a synchronous callback that could be used without a scope + */ +private class VirtualDeviceState( + override val converter: MetaConverter, + initialValue: T, + private val callback: (T) -> Unit = {}, +) : MutableDeviceState { + private val flow = MutableStateFlow(initialValue) + override val valueFlow: Flow get() = flow + + override var value: T + get() = flow.value + set(value) { + flow.value = value + callback(value) + } +} + + +/** + * A [MutableDeviceState] that does not correspond to a physical state + * + * @param callback a synchronous callback that could be used without a scope + */ +public fun DeviceState.Companion.virtual( + converter: MetaConverter, + initialValue: T, + callback: (T) -> Unit = {}, +): MutableDeviceState = VirtualDeviceState(converter, initialValue, callback) + +private class StateFlowAsState( + override val converter: MetaConverter, + val flow: MutableStateFlow, +) : MutableDeviceState { + override var value: T by flow::value + override val valueFlow: Flow get() = flow +} + +public fun MutableStateFlow.asDeviceState(converter: MetaConverter): DeviceState = + StateFlowAsState(converter, this) + + +private open class BoundDeviceState( + override val converter: MetaConverter, + val device: Device, + val propertyName: String, + initialValue: T, +) : DeviceState { + + override val valueFlow: StateFlow = device.messageFlow.filterIsInstance().filter { + it.property == propertyName + }.mapNotNull { + converter.read(it.value) + }.stateIn(device.context, SharingStarted.Eagerly, initialValue) + + override val value: T get() = valueFlow.value +} + +/** + * Bind a read-only [DeviceState] to a [Device] property + */ +public suspend fun Device.propertyAsState( + propertyName: String, + metaConverter: MetaConverter, +): DeviceState { + val initialValue = metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed") + return BoundDeviceState(metaConverter, this, propertyName, initialValue) +} + +public suspend fun D.propertyAsState( + propertySpec: DevicePropertySpec, +): DeviceState = propertyAsState(propertySpec.name, propertySpec.converter) + +public fun DeviceState.map( + converter: MetaConverter, mapper: (T) -> R, +): DeviceState = object : DeviceState { + override val converter: MetaConverter = converter + override val value: R + get() = mapper(this@map.value) + + override val valueFlow: Flow = this@map.valueFlow.map(mapper) +} + +private class MutableBoundDeviceState( + converter: MetaConverter, + device: Device, + propertyName: String, + initialValue: T, +) : BoundDeviceState(converter, device, propertyName, initialValue), MutableDeviceState { + + override var value: T + get() = valueFlow.value + set(newValue) { + device.launch { + device.writeProperty(propertyName, converter.convert(newValue)) + } + } +} + +public fun Device.mutablePropertyAsState( + propertyName: String, + metaConverter: MetaConverter, + initialValue: T, +): MutableDeviceState = MutableBoundDeviceState(metaConverter, this, propertyName, initialValue) + +public suspend fun Device.mutablePropertyAsState( + propertyName: String, + metaConverter: MetaConverter, +): MutableDeviceState { + val initialValue = metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed") + return mutablePropertyAsState(propertyName, metaConverter, initialValue) +} + +public suspend fun D.mutablePropertyAsState( + propertySpec: MutableDevicePropertySpec, +): MutableDeviceState = mutablePropertyAsState(propertySpec.name, propertySpec.converter) + +public fun D.mutablePropertyAsState( + propertySpec: MutableDevicePropertySpec, + initialValue: T, +): MutableDeviceState = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue) + + +private open class ExternalState( + val scope: CoroutineScope, + override val converter: MetaConverter, + val readInterval: Duration, + initialValue: T, + val reader: suspend () -> T, +) : DeviceState { + + protected val flow: StateFlow = flow { + while (true) { + delay(readInterval) + emit(reader()) + } + }.stateIn(scope, SharingStarted.Eagerly, initialValue) + + override val value: T get() = flow.value + override val valueFlow: Flow get() = flow +} + +/** + * Create a [DeviceState] which is constructed by periodically reading external value + */ +public fun DeviceState.Companion.external( + scope: CoroutineScope, + converter: MetaConverter, + readInterval: Duration, + initialValue: T, + reader: suspend () -> T, +): DeviceState = ExternalState(scope, converter, readInterval, initialValue, reader) + +private class MutableExternalState( + scope: CoroutineScope, + converter: MetaConverter, + readInterval: Duration, + initialValue: T, + reader: suspend () -> T, + val writer: suspend (T) -> Unit, +) : ExternalState(scope, converter, readInterval, initialValue, reader), MutableDeviceState { + override var value: T + get() = super.value + set(value) { + scope.launch { + writer(value) + } + } +} + +/** + * Create a [DeviceState] that regularly reads and caches an external value + */ +public fun DeviceState.Companion.external( + scope: CoroutineScope, + converter: MetaConverter, + readInterval: Duration, + initialValue: T, + reader: suspend () -> T, + writer: suspend (T) -> Unit, +): MutableDeviceState = MutableExternalState(scope, converter, readInterval, initialValue, reader, writer) \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt new file mode 100644 index 0000000..8301622 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Drive.kt @@ -0,0 +1,99 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import space.kscience.controls.api.Device +import space.kscience.controls.manager.clock +import space.kscience.controls.spec.* +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.meta.double +import space.kscience.dataforge.meta.get +import kotlin.math.pow +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit + +/** + * A classic drive regulated by force with encoder + */ +public interface Drive : Device { + /** + * Get or set drive force or momentum + */ + public var force: Double + + /** + * Current position value + */ + public val position: Double + + public companion object : DeviceSpec() { + public val force: MutableDevicePropertySpec by Drive.mutableProperty( + MetaConverter.double, + Drive::force + ) + + public val position: DevicePropertySpec by doubleProperty { position } + } +} + +/** + * A virtual drive + */ +public class VirtualDrive( + context: Context, + private val mass: Double, + public val positionState: MutableDeviceState, +) : Drive, DeviceBySpec(Drive, context) { + + private val dt = meta["time.step"].double?.milliseconds ?: 1.milliseconds + private val clock = context.clock + + override var force: Double = 0.0 + + override val position: Double get() = positionState.value + + public var velocity: Double = 0.0 + private set + + private var updateJob: Job? = null + + override suspend fun onStart() { + updateJob = launch { + var lastTime = clock.now() + while (isActive) { + delay(dt) + val realTime = clock.now() + val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS) + + //set last time and value to new values + lastTime = realTime + + // compute new value based on velocity and acceleration from the previous step + positionState.value += velocity * dtSeconds + force / mass * dtSeconds.pow(2) / 2 + propertyChanged(Drive.position, positionState.value) + + // compute new velocity based on acceleration on the previous step + velocity += force / mass * dtSeconds + } + } + } + + override fun onStop() { + updateJob?.cancel() + } + + public companion object { + public fun factory( + mass: Double, + positionState: MutableDeviceState, + ): Factory = Factory { context, _ -> + VirtualDrive(context, mass, positionState) + } + } +} + +public suspend fun Drive.stateOfForce(): MutableDeviceState = mutablePropertyAsState(Drive.force) diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt new file mode 100644 index 0000000..977930d --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/LimitSwitch.kt @@ -0,0 +1,44 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import space.kscience.controls.api.Device +import space.kscience.controls.spec.DeviceBySpec +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.DeviceSpec +import space.kscience.controls.spec.booleanProperty +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory + + +/** + * A limit switch device + */ +public interface LimitSwitch : Device { + + public val locked: Boolean + + public companion object : DeviceSpec() { + public val locked: DevicePropertySpec by booleanProperty { locked } + public fun factory(lockedState: DeviceState): Factory = Factory { context, _ -> + VirtualLimitSwitch(context, lockedState) + } + } +} + +/** + * Virtual [LimitSwitch] + */ +public class VirtualLimitSwitch( + context: Context, + public val lockedState: DeviceState, +) : DeviceBySpec(LimitSwitch, context), LimitSwitch { + + init { + lockedState.valueFlow.onEach { + propertyChanged(LimitSwitch.locked, it) + }.launchIn(this) + } + + override val locked: Boolean get() = lockedState.value +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt new file mode 100644 index 0000000..d782e05 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/PidRegulator.kt @@ -0,0 +1,92 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Instant +import space.kscience.controls.manager.clock +import space.kscience.controls.spec.DeviceBySpec +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit + +/** + * Pid regulator parameters + */ +public interface PidParameters { + public val kp: Double + public val ki: Double + public val kd: Double + public val timeStep: Duration +} + +private data class PidParametersImpl( + override val kp: Double, + override val ki: Double, + override val kd: Double, + override val timeStep: Duration, +) : PidParameters + +public fun PidParameters(kp: Double, ki: Double, kd: Double, timeStep: Duration = 1.milliseconds): PidParameters = + PidParametersImpl(kp, ki, kd, timeStep) + +/** + * A drive with PID regulator + */ +public class PidRegulator( + public val drive: Drive, + public val pidParameters: PidParameters, +) : DeviceBySpec(Regulator, drive.context), Regulator { + + private val clock = drive.context.clock + + override var target: Double = drive.position + + private var lastTime: Instant = clock.now() + private var lastPosition: Double = target + + private var integral: Double = 0.0 + + + private var updateJob: Job? = null + private val mutex = Mutex() + + + override suspend fun onStart() { + drive.start() + updateJob = launch { + while (isActive) { + delay(pidParameters.timeStep) + mutex.withLock { + val realTime = clock.now() + val delta = target - position + val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS) + integral += delta * dtSeconds + val derivative = (drive.position - lastPosition) / dtSeconds + + //set last time and value to new values + lastTime = realTime + lastPosition = drive.position + + drive.force = pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative + propertyChanged(Regulator.position, drive.position) + } + } + } + } + + override fun onStop() { + updateJob?.cancel() + } + + override val position: Double get() = drive.position +} + +public fun DeviceGroup.pid( + name: String, + drive: Drive, + pidParameters: PidParameters, +): PidRegulator = install(name, PidRegulator(drive, pidParameters)) \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt new file mode 100644 index 0000000..cbe6967 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/Regulator.kt @@ -0,0 +1,27 @@ +package space.kscience.controls.constructor + +import space.kscience.controls.api.Device +import space.kscience.controls.spec.* +import space.kscience.dataforge.meta.MetaConverter + + +/** + * A regulator with target value and current position + */ +public interface Regulator : Device { + /** + * Get or set target value + */ + public var target: Double + + /** + * Current position value + */ + public val position: Double + + public companion object : DeviceSpec() { + public val target: MutableDevicePropertySpec by mutableProperty(MetaConverter.double, Regulator::target) + + public val position: DevicePropertySpec by doubleProperty { position } + } +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt new file mode 100644 index 0000000..100f755 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/customState.kt @@ -0,0 +1,47 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import space.kscience.dataforge.meta.MetaConverter + + +/** + * A state describing a [Double] value in the [range] + */ +public class DoubleRangeState( + initialValue: Double, + public val range: ClosedFloatingPointRange, +) : MutableDeviceState { + + init { + require(initialValue in range) { "Initial value should be in range" } + } + + override val converter: MetaConverter = MetaConverter.double + + private val _valueFlow = MutableStateFlow(initialValue) + + override var value: Double + get() = _valueFlow.value + set(newValue) { + _valueFlow.value = newValue.coerceIn(range) + } + + override val valueFlow: StateFlow get() = _valueFlow + + /** + * A state showing that the range is on its lower boundary + */ + public val atStartState: DeviceState = map(MetaConverter.boolean) { it <= range.start } + + /** + * A state showing that the range is on its higher boundary + */ + public val atEndState: DeviceState = map(MetaConverter.boolean) { it >= range.endInclusive } +} + +@Suppress("UnusedReceiverParameter") +public fun DeviceGroup.rangeState( + initialValue: Double, + range: ClosedFloatingPointRange, +): DoubleRangeState = DoubleRangeState(initialValue, range) \ No newline at end of file diff --git a/controls-core/README.md b/controls-core/README.md index b75961d..b1edbdc 100644 --- a/controls-core/README.md +++ b/controls-core/README.md @@ -16,18 +16,16 @@ Core interfaces for building a device server ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-core:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-core:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-core:0.2.0") + implementation("space.kscience:controls-core:0.3.0") } ``` diff --git a/controls-core/build.gradle.kts b/controls-core/build.gradle.kts index 05bb084..9b8c9f9 100644 --- a/controls-core/build.gradle.kts +++ b/controls-core/build.gradle.kts @@ -20,10 +20,14 @@ kscience { json() } useContextReceivers() - dependencies { + commonMain { api(libs.dataforge.io) api(spclibs.kotlinx.datetime) } + + jvmTest{ + implementation(spclibs.logback.classic) + } } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt new file mode 100644 index 0000000..defefb2 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt @@ -0,0 +1,40 @@ +package space.kscience.controls.api + +import kotlinx.coroutines.flow.Flow + +/** + * A generic bidirectional asynchronous sender/receiver object + */ +public interface AsynchronousSocket : AutoCloseable { + /** + * Send an object to the socket + */ + public suspend fun send(data: T) + + /** + * Flow of objects received from socket + */ + public fun subscribe(): Flow + + /** + * Start socket operation + */ + public fun open() + + /** + * Check if this socket is open + */ + public val isOpen: Boolean +} + +/** + * Connect an input to this socket. + * Multiple inputs could be connected to the same [AsynchronousSocket]. + * + * This method suspends indefinitely, so it should be started in a separate coroutine. + */ +public suspend fun AsynchronousSocket.sendFlow(flow: Flow) { + flow.collect { send(it) } +} + + diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt index 4fc6365..3226422 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt @@ -3,26 +3,44 @@ package space.kscience.controls.api import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* +import kotlinx.serialization.Serializable import space.kscience.controls.api.Device.Companion.DEVICE_TARGET import space.kscience.dataforge.context.ContextAware import space.kscience.dataforge.context.info import space.kscience.dataforge.context.logger import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.string import space.kscience.dataforge.misc.DFExperimental -import space.kscience.dataforge.misc.Type -import space.kscience.dataforge.names.Name +import space.kscience.dataforge.misc.DfType +import space.kscience.dataforge.names.parseAsName /** * A lifecycle state of a device */ -public enum class DeviceLifecycleState{ - INIT, - OPEN, - CLOSED +@Serializable +public enum class DeviceLifecycleState { + + /** + * Device is initializing + */ + STARTING, + + /** + * The Device is initialized and running + */ + STARTED, + + /** + * The Device is closed + */ + STOPPED, + + /** + * The device encountered irrecoverable error + */ + ERROR } /** @@ -30,14 +48,15 @@ public enum class DeviceLifecycleState{ * [Device] is a supervisor scope encompassing all operations on a device. * When canceled, cancels all running processes. */ -@Type(DEVICE_TARGET) -public interface Device : AutoCloseable, ContextAware, CoroutineScope { +@DfType(DEVICE_TARGET) +public interface Device : ContextAware, CoroutineScope { /** * Initial configuration meta for the device */ public val meta: Meta get() = Meta.EMPTY + /** * List of supported property descriptors */ @@ -54,18 +73,6 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope { */ public suspend fun readProperty(propertyName: String): Meta - /** - * Get the logical state of property or return null if it is invalid - */ - public fun getProperty(propertyName: String): Meta? - - /** - * Invalidate property (set logical state to invalid) - * - * This message is suspended to provide lock-free local property changes (they require coroutine context). - */ - public suspend fun invalidate(propertyName: String) - /** * Set property [value] for a property with name [propertyName]. * In rare cases could suspend if the [Device] supports command queue, and it is full at the moment. @@ -85,14 +92,15 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope { public suspend fun execute(actionName: String, argument: Meta? = null): Meta? /** - * Initialize the device. This function suspends until the device is finished initialization + * Initialize the device. This function suspends until the device is finished initialization. + * Does nothing if the device is started or is starting */ - public suspend fun open(): Unit = Unit + public suspend fun start(): Unit = Unit /** * Close and terminate the device. This function does not wait for the device to be closed. */ - override fun close() { + public fun stop() { logger.info { "Device $this is closed" } cancel("The device is closed") } @@ -105,24 +113,59 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope { } } +/** + * Inner id of a device. Not necessary corresponds to the name in the parent container + */ +public val Device.id: String get() = meta["id"].string?: "device[${hashCode().toString(16)}]" + +/** + * Device that caches properties values + */ +public interface CachingDevice : Device { + + /** + * Immediately (without waiting) get the cached (logical) state of property or return null if it is invalid + */ + public fun getProperty(propertyName: String): Meta? + + /** + * Invalidate property (set logical state to invalid). + * + * This message is suspended to provide lock-free local property changes (they require coroutine context). + */ + public suspend fun invalidate(propertyName: String) +} + /** * Get the logical state of property or suspend to read the physical value. */ -public suspend fun Device.getOrReadProperty(propertyName: String): Meta = +public suspend fun Device.getOrReadProperty(propertyName: String): Meta = if (this is CachingDevice) { getProperty(propertyName) ?: readProperty(propertyName) +} else { + readProperty(propertyName) +} /** * Get a snapshot of the device logical state * */ -public fun Device.getAllProperties(): Meta = Meta { +public fun CachingDevice.getAllProperties(): Meta = Meta { for (descriptor in propertyDescriptors) { - setMeta(Name.parse(descriptor.name), getProperty(descriptor.name)) + set(descriptor.name.parseAsName(), getProperty(descriptor.name)) } } /** * Subscribe on property changes for the whole device */ -public fun Device.onPropertyChange(callback: suspend PropertyChangedMessage.() -> Unit): Job = - messageFlow.filterIsInstance().onEach(callback).launchIn(this) +public fun Device.onPropertyChange( + scope: CoroutineScope = this, + callback: suspend PropertyChangedMessage.() -> Unit, +): Job = messageFlow.filterIsInstance().onEach(callback).launchIn(scope) + +/** + * A [Flow] of property change messages for specific property. + */ +public fun Device.propertyMessageFlow(propertyName: String): Flow = messageFlow + .filterIsInstance() + .filter { it.property == propertyName } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt index 8bd7452..72b0dc3 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt @@ -14,22 +14,27 @@ public interface DeviceHub : Provider { override val defaultChainTarget: String get() = Device.DEVICE_TARGET - override fun content(target: String): Map = if (target == Device.DEVICE_TARGET) { - buildMap { - fun putAll(prefix: Name, hub: DeviceHub) { - hub.devices.forEach { - put(prefix + it.key, it.value) - } - } - - devices.forEach { - val name = it.key.asName() - put(name, it.value) - (it.value as? DeviceHub)?.let { hub -> - putAll(name, hub) - } + /** + * List all devices, including sub-devices + */ + public fun buildDeviceTree(): Map = buildMap { + fun putAll(prefix: Name, hub: DeviceHub) { + hub.devices.forEach { + put(prefix + it.key, it.value) } } + + devices.forEach { + val name = it.key.asName() + put(name, it.value) + (it.value as? DeviceHub)?.let { hub -> + putAll(name, hub) + } + } + } + + override fun content(target: String): Map = if (target == Device.DEVICE_TARGET) { + buildDeviceTree() } else { emptyMap() } @@ -37,6 +42,7 @@ public interface DeviceHub : Provider { public companion object } + public operator fun DeviceHub.get(nameToken: NameToken): Device = devices[nameToken] ?: error("Device with name $nameToken not found in $this") diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt index 45d7f0b..dda89de 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt @@ -22,10 +22,10 @@ public sealed class DeviceMessage { public abstract val sourceDevice: Name? public abstract val targetDevice: Name? public abstract val comment: String? - public abstract val time: Instant? + public abstract val time: Instant /** - * Update the source device name for composition. If the original name is null, resulting name is also null. + * Update the source device name for composition. If the original name is null, the resulting name is also null. */ public abstract fun changeSource(block: (Name) -> Name): DeviceMessage @@ -59,7 +59,7 @@ public data class PropertyChangedMessage( override val sourceDevice: Name = Name.EMPTY, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } @@ -71,11 +71,11 @@ public data class PropertyChangedMessage( @SerialName("property.set") public data class PropertySetMessage( public val property: String, - public val value: Meta?, + public val value: Meta, override val sourceDevice: Name? = null, override val targetDevice: Name, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) } @@ -91,7 +91,7 @@ public data class PropertyGetMessage( override val sourceDevice: Name? = null, override val targetDevice: Name, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) } @@ -103,9 +103,9 @@ public data class PropertyGetMessage( @SerialName("description.get") public data class GetDescriptionMessage( override val sourceDevice: Name? = null, - override val targetDevice: Name, + override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) } @@ -122,7 +122,7 @@ public data class DescriptionMessage( override val sourceDevice: Name, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } @@ -141,7 +141,7 @@ public data class ActionExecuteMessage( override val sourceDevice: Name? = null, override val targetDevice: Name, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) } @@ -160,7 +160,7 @@ public data class ActionResultMessage( override val sourceDevice: Name, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } @@ -175,7 +175,7 @@ public data class BinaryNotificationMessage( override val sourceDevice: Name, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } @@ -190,7 +190,7 @@ public data class EmptyDeviceMessage( override val sourceDevice: Name? = null, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) } @@ -203,12 +203,12 @@ public data class EmptyDeviceMessage( public data class DeviceLogMessage( val message: String, val data: Meta? = null, - override val sourceDevice: Name? = null, + override val sourceDevice: Name = Name.EMPTY, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { - override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) + override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } /** @@ -220,10 +220,25 @@ public data class DeviceErrorMessage( public val errorMessage: String?, public val errorType: String? = null, public val errorStackTrace: String? = null, - override val sourceDevice: Name, + override val sourceDevice: Name = Name.EMPTY, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), +) : DeviceMessage() { + override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) +} + +/** + * Device [Device.lifecycleState] is changed + */ +@Serializable +@SerialName("lifecycle") +public data class DeviceLifeCycleMessage( + val state: DeviceLifecycleState, + override val sourceDevice: Name = Name.EMPTY, + override val targetDevice: Name? = null, + override val comment: String? = null, + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt deleted file mode 100644 index 02598ba..0000000 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt +++ /dev/null @@ -1,33 +0,0 @@ -package space.kscience.controls.api - -import io.ktor.utils.io.core.Closeable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch - -/** - * A generic bidirectional sender/receiver object - */ -public interface Socket : Closeable { - /** - * Send an object to the socket - */ - public suspend fun send(data: T) - - /** - * Flow of objects received from socket - */ - public fun receiving(): Flow - 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 Socket.connectInput(scope: CoroutineScope, flow: Flow): Job = scope.launch { - flow.collect { send(it) } -} - - diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt index 8e1705b..2adb89a 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt @@ -12,10 +12,10 @@ import space.kscience.dataforge.meta.descriptors.MetaDescriptorBuilder @Serializable public class PropertyDescriptor( public val name: String, - public var info: String? = null, + public var description: String? = null, public var metaDescriptor: MetaDescriptor = MetaDescriptor(), public var readable: Boolean = true, - public var writable: Boolean = false + public var mutable: Boolean = false ) public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Unit){ @@ -27,6 +27,6 @@ public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Un */ @Serializable public class ActionDescriptor(public val name: String) { - public var info: String? = null + public var description: String? = null } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt new file mode 100644 index 0000000..ca69b91 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt @@ -0,0 +1,25 @@ +package space.kscience.controls.manager + +import kotlinx.datetime.Clock +import space.kscience.dataforge.context.AbstractPlugin +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.PluginFactory +import space.kscience.dataforge.context.PluginTag +import space.kscience.dataforge.meta.Meta + +public class ClockManager : AbstractPlugin() { + override val tag: PluginTag get() = DeviceManager.tag + + public val clock: Clock by lazy { + //TODO add clock customization + Clock.System + } + + public companion object : PluginFactory { + override val tag: PluginTag = PluginTag("clock", group = PluginTag.DATAFORGE_GROUP) + + override fun build(context: Context, meta: Meta): ClockManager = ClockManager() + } +} + +public val Context.clock: Clock get() = plugins[ClockManager]?.clock ?: Clock.System \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt index cc043c7..c8c7ffc 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt @@ -4,6 +4,7 @@ import kotlinx.coroutines.launch import space.kscience.controls.api.Device import space.kscience.controls.api.DeviceHub import space.kscience.controls.api.getOrNull +import space.kscience.controls.api.id import space.kscience.dataforge.context.* import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.MutableMeta @@ -40,11 +41,13 @@ public class DeviceManager : AbstractPlugin(), DeviceHub { public fun DeviceManager.install(name: String, device: D): D { registerDevice(NameToken(name), device) device.launch { - device.open() + device.start() } return device } +public fun DeviceManager.install(device: D): D = install(device.id, device) + /** * Register and start a device built by [factory] with current [Context] and [meta]. diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt index 13d072c..fc6fb5d 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt @@ -1,10 +1,9 @@ package space.kscience.controls.manager -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import space.kscience.controls.api.* import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.plus @@ -24,11 +23,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess } is PropertySetMessage -> { - if (request.value == null) { - invalidate(request.property) - } else { - writeProperty(request.property, request.value) - } + writeProperty(request.property, request.value) PropertyChangedMessage( property = request.property, value = getOrReadProperty(request.property), @@ -64,6 +59,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess is DeviceErrorMessage, is EmptyDeviceMessage, is DeviceLogMessage, + is DeviceLifeCycleMessage, -> null } } catch (ex: Exception) { @@ -71,42 +67,41 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess } /** - * Process incoming [DeviceMessage], using hub naming to evaluate target. + * Process incoming [DeviceMessage], using hub naming to find target. + * If the `targetDevice` is `null`, then message is sent to each device in this hub */ -public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): DeviceMessage? { +public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List { return try { - val targetName = request.targetDevice ?: return null - val device = getOrNull(targetName) ?: error("The device with name $targetName not found in $this") - device.respondMessage(targetName, request) + val targetName = request.targetDevice + if (targetName == null) { + buildDeviceTree().mapNotNull { + it.value.respondMessage(it.key, request) + } + } else { + val device = getOrNull(targetName) ?: error("The device with name $targetName not found in $this") + listOfNotNull(device.respondMessage(targetName, request)) + } } catch (ex: Exception) { - DeviceMessage.error(ex, sourceDevice = Name.EMPTY, targetDevice = request.sourceDevice) + listOf(DeviceMessage.error(ex, sourceDevice = Name.EMPTY, targetDevice = request.sourceDevice)) } } /** * Collect all messages from given [DeviceHub], applying proper relative names. */ -public fun DeviceHub.hubMessageFlow(scope: CoroutineScope): Flow { - - //TODO could we avoid using downstream scope? - val outbox = MutableSharedFlow() - if (this is Device) { - messageFlow.onEach { - outbox.emit(it) - }.launchIn(scope) - } - //TODO maybe better create map of all devices to limit copying - devices.forEach { (token, childDevice) -> - val flow = if (childDevice is DeviceHub) { - childDevice.hubMessageFlow(scope) +public fun DeviceHub.hubMessageFlow(): Flow { + + val deviceMessageFlow = if (this is Device) messageFlow else emptyFlow() + + val childrenFlows = devices.map { (token, childDevice) -> + if (childDevice is DeviceHub) { + childDevice.hubMessageFlow() } else { childDevice.messageFlow + }.map { deviceMessage -> + deviceMessage.changeSource { token + it } } - flow.onEach { deviceMessage -> - outbox.emit( - deviceMessage.changeSource { token + it } - ) - }.launchIn(scope) } - return outbox + + return merge(deviceMessageFlow, *childrenFlows.toTypedArray()) } \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt new file mode 100644 index 0000000..092d9bb --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt @@ -0,0 +1,70 @@ +package space.kscience.controls.misc + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import space.kscience.controls.api.Device +import space.kscience.controls.api.DeviceMessage +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.name +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.names.Name + +/** + * An interface for device property history. + */ +public interface PropertyHistory { + /** + * Flow property values filtered by a time range. The implementation could flow it as a chunk or provide paging. + * So the resulting flow is allowed to suspend. + * + * If [until] is in the future, the resulting flow is potentially unlimited. + * Theoretically, it could be also unlimited if the event source keeps producing new event with timestamp in a given range. + */ + public fun flowHistory( + from: Instant = Instant.DISTANT_PAST, + until: Instant = Clock.System.now(), + ): Flow> +} + +/** + * An in-memory property values history collector + */ +public class CollectedPropertyHistory( + public val scope: CoroutineScope, + eventFlow: Flow, + public val deviceName: Name, + public val propertyName: String, + public val converter: MetaConverter, + maxSize: Int = 1000, +) : PropertyHistory { + + private val store: SharedFlow> = eventFlow + .filterIsInstance() + .filter { it.sourceDevice == deviceName && it.property == propertyName } + .map { ValueWithTime(converter.read(it.value), it.time) } + .shareIn(scope, started = SharingStarted.Eagerly, replay = maxSize) + + override fun flowHistory(from: Instant, until: Instant): Flow> = + store.filter { it.time in from..until } +} + +/** + * Collect and store in memory device property changes for a given property + */ +public fun Device.collectPropertyHistory( + scope: CoroutineScope = this, + deviceName: Name, + propertyName: String, + converter: MetaConverter, + maxSize: Int = 1000, +): PropertyHistory = CollectedPropertyHistory(scope, messageFlow, deviceName, propertyName, converter, maxSize) + +public fun D.collectPropertyHistory( + scope: CoroutineScope = this, + deviceName: Name, + spec: DevicePropertySpec, + maxSize: Int = 1000, +): PropertyHistory = collectPropertyHistory(scope, deviceName, spec.name, spec.converter, maxSize) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt new file mode 100644 index 0000000..f25c4f4 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt @@ -0,0 +1,69 @@ +package space.kscience.controls.misc + +import kotlinx.datetime.Instant +import kotlinx.io.Sink +import kotlinx.io.Source +import space.kscience.dataforge.io.IOFormat +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.meta.get + +/** + * A value coupled to a time it was obtained at + */ +public data class ValueWithTime(val value: T, val time: Instant) { + public companion object { + /** + * Create a [ValueWithTime] format for given value value [IOFormat] + */ + public fun ioFormat( + valueFormat: IOFormat, + ): IOFormat> = ValueWithTimeIOFormat(valueFormat) + + /** + * Create a [MetaConverter] with time for given value [MetaConverter] + */ + public fun metaConverter( + valueConverter: MetaConverter, + ): MetaConverter> = ValueWithTimeMetaConverter(valueConverter) + + + public const val META_TIME_KEY: String = "time" + public const val META_VALUE_KEY: String = "value" + } +} + +private class ValueWithTimeIOFormat(val valueFormat: IOFormat) : IOFormat> { + + override fun readFrom(source: Source): ValueWithTime { + val timestamp = InstantIOFormat.readFrom(source) + val value = valueFormat.readFrom(source) + return ValueWithTime(value, timestamp) + } + + override fun writeTo(sink: Sink, obj: ValueWithTime) { + InstantIOFormat.writeTo(sink, obj.time) + valueFormat.writeTo(sink, obj.value) + } + +} + +private class ValueWithTimeMetaConverter( + val valueConverter: MetaConverter, +) : MetaConverter> { + + + override fun readOrNull( + source: Meta, + ): ValueWithTime? = valueConverter.read(source[ValueWithTime.META_VALUE_KEY] ?: Meta.EMPTY)?.let { + ValueWithTime(it, source[ValueWithTime.META_TIME_KEY]?.instant ?: Instant.DISTANT_PAST) + } + + override fun convert(obj: ValueWithTime): Meta = Meta { + ValueWithTime.META_TIME_KEY put obj.time.toMeta() + ValueWithTime.META_VALUE_KEY put valueConverter.convert(obj.value) + } +} + + +public fun MetaConverter.withTime(): MetaConverter> = ValueWithTimeMetaConverter(this) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt new file mode 100644 index 0000000..8686c67 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt @@ -0,0 +1,42 @@ +package space.kscience.controls.misc + + +import kotlinx.datetime.Instant +import kotlinx.io.Sink +import kotlinx.io.Source +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.io.IOFormat +import space.kscience.dataforge.io.IOFormatFactory +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.string +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName +import kotlin.reflect.KType +import kotlin.reflect.typeOf + + +/** + * An [IOFormat] for [Instant] + */ +public object InstantIOFormat : IOFormat, IOFormatFactory { + override fun build(context: Context, meta: Meta): IOFormat = this + + override val name: Name = "instant".asName() + + override val type: KType get() = typeOf() + + override fun writeTo(sink: Sink, obj: Instant) { + sink.writeLong(obj.epochSeconds) + sink.writeInt(obj.nanosecondsOfSecond) + } + + override fun readFrom(source: Source): Instant { + val seconds = source.readLong() + val nanoseconds = source.readInt() + return Instant.fromEpochSeconds(seconds, nanoseconds) + } +} + +public fun Instant.toMeta(): Meta = Meta(toString()) + +public val Meta.instant: Instant? get() = value?.string?.let { Instant.parse(it) } \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt deleted file mode 100644 index 11683d9..0000000 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt +++ /dev/null @@ -1,18 +0,0 @@ -package space.kscience.controls.misc - -import kotlinx.datetime.Instant -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.get -import space.kscience.dataforge.meta.long - -// TODO move to core - -public fun Instant.toMeta(): Meta = Meta { - "seconds" put epochSeconds - "nanos" put nanosecondsOfSecond -} - -public fun Meta.instant(): Instant = value?.long?.let { Instant.fromEpochMilliseconds(it) } ?: Instant.fromEpochSeconds( - get("seconds")?.long ?: 0L, - get("nanos")?.long ?: 0L, -) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt new file mode 100644 index 0000000..4462a42 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt @@ -0,0 +1,125 @@ +package space.kscience.controls.ports + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.io.Buffer +import kotlinx.io.Source +import space.kscience.controls.api.AsynchronousSocket +import space.kscience.dataforge.context.* +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.meta.string +import kotlin.coroutines.CoroutineContext + +/** + * Raw [ByteArray] port + */ +public interface AsynchronousPort : ContextAware, AsynchronousSocket + +/** + * Capture [AsynchronousPort] output as kotlinx-io [Source]. + * [scope] controls the consummation. + * If the scope is canceled, the source stops producing. + */ +public fun AsynchronousPort.receiveAsSource(scope: CoroutineScope): Source { + val buffer = Buffer() + + subscribe().onEach { + buffer.write(it) + }.launchIn(scope) + + return buffer +} + + +/** + * Common abstraction for [AsynchronousPort] based on [Channel] + */ +public abstract class AbstractAsynchronousPort( + override val context: Context, + public val meta: Meta, + coroutineContext: CoroutineContext = context.coroutineContext, +) : AsynchronousPort { + + + protected val scope: CoroutineScope by lazy { + CoroutineScope( + coroutineContext + + SupervisorJob(coroutineContext[Job]) + + CoroutineExceptionHandler { _, throwable -> logger.error(throwable) { throwable.stackTraceToString() } } + + CoroutineName(toString()) + ) + } + + private val outgoing = Channel(meta["outgoing.capacity"].int?:100) + private val incoming = Channel(meta["incoming.capacity"].int?:100) + + /** + * Internal method to synchronously send data + */ + protected abstract suspend fun write(data: ByteArray) + + /** + * Internal method to receive data synchronously + */ + protected suspend fun receive(data: ByteArray) { + logger.debug { "$this RECEIVED: ${data.decodeToString()}" } + incoming.send(data) + } + + private var sendJob: Job? = null + + protected abstract fun onOpen() + + final override fun open() { + if (!isOpen) { + sendJob = scope.launch { + for (data in outgoing) { + try { + write(data) + logger.debug { "${this@AbstractAsynchronousPort} SENT: ${data.decodeToString()}" } + } catch (ex: Exception) { + if (ex is CancellationException) throw ex + logger.error(ex) { "Error while writing data to the port" } + } + } + } + onOpen() + } else { + logger.warn { "$this already opened" } + } + } + + + /** + * Send a data packet via the port + */ + override suspend fun send(data: ByteArray) { + outgoing.send(data) + } + + /** + * Raw flow of incoming data chunks. The chunks are not guaranteed to be complete phrases. + * To form phrases, some condition should be used on top of it. + * For example [stringsDelimitedIncoming] generates phrases with fixed delimiter. + */ + override fun subscribe(): Flow = incoming.receiveAsFlow() + + override fun close() { + outgoing.close() + incoming.close() + sendJob?.cancel() + } + + override fun toString(): String = meta["name"].string?:"ChannelPort[${hashCode().toString(16)}]" +} + +/** + * Send UTF-8 encoded string + */ +public suspend fun AsynchronousPort.send(string: String): Unit = send(string.encodeToByteArray()) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt deleted file mode 100644 index 1f07251..0000000 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt +++ /dev/null @@ -1,100 +0,0 @@ -package space.kscience.controls.ports - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.receiveAsFlow -import space.kscience.controls.api.Socket -import space.kscience.dataforge.context.* -import space.kscience.dataforge.misc.Type -import kotlin.coroutines.CoroutineContext - -/** - * Raw [ByteArray] port - */ -public interface Port : ContextAware, Socket - -/** - * A specialized factory for [Port] - */ -@Type(PortFactory.TYPE) -public interface PortFactory : Factory { - public val type: String - - public companion object { - public const val TYPE: String = "controls.port" - } -} - -/** - * Common abstraction for [Port] based on [Channel] - */ -public abstract class AbstractPort( - override val context: Context, - coroutineContext: CoroutineContext = context.coroutineContext, -) : Port { - - protected val scope: CoroutineScope = CoroutineScope(coroutineContext + SupervisorJob(coroutineContext[Job])) - - private val outgoing = Channel(100) - private val incoming = Channel(Channel.CONFLATED) - - init { - scope.coroutineContext[Job]?.invokeOnCompletion { - close() - } - } - - /** - * Internal method to synchronously send data - */ - protected abstract suspend fun write(data: ByteArray) - - /** - * Internal method to receive data synchronously - */ - protected suspend fun receive(data: ByteArray) { - logger.debug { "${this@AbstractPort} RECEIVED: ${data.decodeToString()}" } - incoming.send(data) - } - - private val sendJob = scope.launch { - for (data in outgoing) { - try { - write(data) - logger.debug { "${this@AbstractPort} SENT: ${data.decodeToString()}" } - } catch (ex: Exception) { - if (ex is CancellationException) throw ex - logger.error(ex) { "Error while writing data to the port" } - } - } - } - - /** - * Send a data packet via the port - */ - override suspend fun send(data: ByteArray) { - outgoing.send(data) - } - - /** - * Raw flow of incoming data chunks. The chunks are not guaranteed to be complete phrases. - * In order to form phrases, some condition should be used on top of it. - * For example [stringsDelimitedIncoming] generates phrases with fixed delimiter. - */ - override fun receiving(): Flow = incoming.receiveAsFlow() - - override fun close() { - outgoing.close() - incoming.close() - sendJob.cancel() - scope.cancel() - } - - override fun isOpen(): Boolean = scope.isActive -} - -/** - * Send UTF-8 encoded string - */ -public suspend fun Port.send(string: String): Unit = send(string.encodeToByteArray()) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/PortProxy.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/PortProxy.kt deleted file mode 100644 index 4e51f6f..0000000 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/PortProxy.kt +++ /dev/null @@ -1,64 +0,0 @@ -package space.kscience.controls.ports - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import space.kscience.dataforge.context.* - -/** - * A port that could be closed multiple times and opens automatically on request - */ -public class PortProxy(override val context: Context = Global, public val factory: suspend () -> Port) : Port, ContextAware { - - private var actualPort: Port? = null - private val mutex: Mutex = Mutex() - - private suspend fun port(): Port { - return mutex.withLock { - if (actualPort?.isOpen() == true) { - actualPort!! - } else { - factory().also { - actualPort = it - } - } - } - } - - override suspend fun send(data: ByteArray) { - port().send(data) - } - - @OptIn(ExperimentalCoroutinesApi::class) - override fun receiving(): Flow = flow { - while (true) { - try { - //recreate port and Flow on connection problems - port().receiving().collect { - emit(it) - } - } catch (t: Throwable) { - logger.warn{"Port read failed: ${t.message}. Reconnecting."} - mutex.withLock { - actualPort?.close() - actualPort = null - } - } - } - } - - // open by default - override fun isOpen(): Boolean = true - - override fun close() { - context.launch { - mutex.withLock { - actualPort?.close() - actualPort = null - } - } - } -} \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt index 3d01e62..565caee 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt @@ -11,26 +11,43 @@ public class Ports : AbstractPlugin() { override val tag: PluginTag get() = Companion.tag - private val portFactories by lazy { - context.gather(PortFactory.TYPE) + private val synchronousPortFactories by lazy { + context.gather>(SYNCHRONOUS_PORT_TYPE) } - private val portCache = mutableMapOf() + private val asynchronousPortFactories by lazy { + context.gather>(ASYNCHRONOUS_PORT_TYPE) + } /** - * Create a new [Port] according to specification + * Create a new [AsynchronousPort] according to specification */ - public fun buildPort(meta: Meta): Port = portCache.getOrPut(meta) { + public fun buildAsynchronousPort(meta: Meta): AsynchronousPort { val type by meta.string { error("Port type is not defined") } - val factory = portFactories.values.firstOrNull { it.type == type } + val factory = asynchronousPortFactories.entries + .firstOrNull { it.key.toString() == type }?.value ?: error("Port factory for type $type not found") - factory.build(context, meta) + return factory.build(context, meta) + } + + /** + * Create a [SynchronousPort] according to specification or wrap an asynchronous implementation + */ + public fun buildSynchronousPort(meta: Meta): SynchronousPort { + val type by meta.string { error("Port type is not defined") } + val factory = synchronousPortFactories.entries + .firstOrNull { it.key.toString() == type }?.value + ?: return buildAsynchronousPort(meta).asSynchronousPort() + return factory.build(context, meta) } public companion object : PluginFactory { override val tag: PluginTag = PluginTag("controls.ports", group = PluginTag.DATAFORGE_GROUP) + public const val ASYNCHRONOUS_PORT_TYPE: String = "controls.asynchronousPort" + public const val SYNCHRONOUS_PORT_TYPE: String = "controls.synchronousPort" + override fun build(context: Context, meta: Meta): Ports = Ports() } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt index 0ed4764..49c2b06 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt @@ -2,27 +2,86 @@ package space.kscience.controls.ports import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.io.Buffer +import kotlinx.io.readByteArray +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.ContextAware /** - * A port handler for synchronous (request-response) communication with a port. Only one request could be active at a time (others are suspended. - * The handler does not guarantee exclusive access to the port so the user mush ensure that no other controller handles port at the moment. + * A port handler for synchronous (request-response) communication with a port. + * Only one request could be active at a time (others are suspended). */ -public class SynchronousPort(public val port: Port, private val mutex: Mutex) : Port by port { +public interface SynchronousPort : ContextAware, AutoCloseable { + + public fun open() + + public val isOpen: Boolean + /** - * Send a single message and wait for the flow of respond messages. + * Send a single message and wait for the flow of response chunks. + * The consumer is responsible for calling a terminal operation on the flow. */ - public suspend fun respond(data: ByteArray, transform: suspend Flow.() -> R): R = mutex.withLock { - port.send(data) - transform(port.receiving()) + public suspend fun respond( + request: ByteArray, + transform: suspend Flow.() -> R, + ): R + + /** + * Synchronously read fixed size response to a given [request]. Discard additional response bytes. + */ + public suspend fun respondFixedMessageSize( + request: ByteArray, + responseSize: Int, + ): ByteArray = respond(request) { + val buffer = Buffer() + takeWhile { + buffer.size < responseSize + }.collect { + buffer.write(it) + } + buffer.readByteArray(responseSize) } } +private class SynchronousOverAsynchronousPort( + val port: AsynchronousPort, + val mutex: Mutex, +) : SynchronousPort { + + override val context: Context get() = port.context + + override fun open() { + if (!port.isOpen) port.open() + } + + override val isOpen: Boolean get() = port.isOpen + + override fun close() { + if (port.isOpen) port.close() + } + + override suspend fun respond( + request: ByteArray, + transform: suspend Flow.() -> R, + ): R = mutex.withLock { + port.send(request) + transform(port.subscribe()) + } +} + + /** - * Provide a synchronous wrapper for a port + * Provide a synchronous wrapper for an asynchronous port. + * Optionally provide external [mutex] for operation synchronization. + * + * If the [AsynchronousPort] is called directly, it could violate [SynchronousPort] contract + * of only one request running simultaneously. */ -public fun Port.synchronous(mutex: Mutex = Mutex()): SynchronousPort = SynchronousPort(this, mutex) +public fun AsynchronousPort.asSynchronousPort(mutex: Mutex = Mutex()): SynchronousPort = + SynchronousOverAsynchronousPort(this, mutex) /** * Send request and read incoming data blocks until the delimiter is encountered diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt new file mode 100644 index 0000000..dbe7256 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt @@ -0,0 +1,5 @@ +package space.kscience.controls.ports + +import space.kscience.dataforge.io.Binary + +public fun Binary.readShort(position: Int): Short = read(position) { readShort() } \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt index 1214d01..89bee8a 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt @@ -1,21 +1,27 @@ package space.kscience.controls.ports -import io.ktor.utils.io.core.BytePacketBuilder -import io.ktor.utils.io.core.readBytes -import io.ktor.utils.io.core.reset import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.transform +import kotlinx.io.Buffer +import kotlinx.io.readByteArray /** * Transform byte fragments into complete phrases using given delimiter. Not thread safe. + * + * TODO add type wrapper for phrases */ public fun Flow.withDelimiter(delimiter: ByteArray): Flow { require(delimiter.isNotEmpty()) { "Delimiter must not be empty" } - val output = BytePacketBuilder() + val output = Buffer() var matcherPosition = 0 + onCompletion { + output.close() + } + return transform { chunk -> chunk.forEach { byte -> output.writeByte(byte) @@ -24,9 +30,8 @@ public fun Flow.withDelimiter(delimiter: ByteArray): Flow matcherPosition++ if (matcherPosition == delimiter.size) { //full match achieved, sending result - val bytes = output.build() - emit(bytes.readBytes()) - output.reset() + emit(output.readByteArray()) + output.clear() matcherPosition = 0 } } else if (matcherPosition > 0) { @@ -37,6 +42,31 @@ public fun Flow.withDelimiter(delimiter: ByteArray): Flow } } +private fun Flow.withFixedMessageSize(messageSize: Int): Flow { + require(messageSize > 0) { "Message size should be positive" } + + val output = Buffer() + + onCompletion { + output.close() + } + + return transform { chunk -> + val remaining: Int = (messageSize - output.size).toInt() + if (chunk.size >= remaining) { + output.write(chunk, endIndex = remaining) + emit(output.readByteArray()) + output.clear() + //write the remaining chunk fragment + if(chunk.size> remaining) { + output.write(chunk, startIndex = remaining) + } + } else { + output.write(chunk) + } + } +} + /** * Transform byte fragments into utf-8 phrases using utf-8 delimiter */ @@ -47,9 +77,9 @@ public fun Flow.withStringDelimiter(delimiter: String): Flow /** * A flow of delimited phrases */ -public fun Port.delimitedIncoming(delimiter: ByteArray): Flow = receiving().withDelimiter(delimiter) +public fun AsynchronousPort.delimitedIncoming(delimiter: ByteArray): Flow = subscribe().withDelimiter(delimiter) /** * A flow of delimited phrases with string content */ -public fun Port.stringsDelimitedIncoming(delimiter: String): Flow = receiving().withStringDelimiter(delimiter) +public fun AsynchronousPort.stringsDelimitedIncoming(delimiter: String): Flow = subscribe().withStringDelimiter(delimiter) diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt index 56f5aa9..dcb48b4 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt @@ -1,36 +1,44 @@ package space.kscience.controls.spec import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import space.kscience.controls.api.* import space.kscience.dataforge.context.Context -import space.kscience.dataforge.context.error +import space.kscience.dataforge.context.debug import space.kscience.dataforge.context.logger import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.int import space.kscience.dataforge.misc.DFExperimental import kotlin.coroutines.CoroutineContext - +/** + * Write a meta [item] to [device] + */ @OptIn(InternalDeviceAPI::class) -private suspend fun WritableDevicePropertySpec.writeMeta(device: D, item: Meta) { - write(device, converter.metaToObject(item) ?: error("Meta $item could not be read with $converter")) +private suspend fun MutableDevicePropertySpec.writeMeta(device: D, item: Meta) { + write(device, converter.readOrNull(item) ?: error("Meta $item could not be read with $converter")) } +/** + * Read Meta item from the [device] + */ @OptIn(InternalDeviceAPI::class) private suspend fun DevicePropertySpec.readMeta(device: D): Meta? = - read(device)?.let(converter::objectToMeta) + read(device)?.let(converter::convert) private suspend fun DeviceActionSpec.executeWithMeta( device: D, item: Meta, ): Meta? { - val arg: I = inputConverter.metaToObject(item) ?: error("Failed to convert $item with $inputConverter") + val arg: I = inputConverter.readOrNull(item) ?: error("Failed to convert $item with $inputConverter") val res = execute(device, arg) - return res?.let { outputConverter.objectToMeta(res) } + return res?.let { outputConverter.convert(res) } } @@ -39,8 +47,8 @@ private suspend fun DeviceActionSpec.executeWithMeta */ public abstract class DeviceBase( final override val context: Context, - override val meta: Meta = Meta.EMPTY, -) : Device { + final override val meta: Meta = Meta.EMPTY, +) : CachingDevice { /** * Collection of property specifications @@ -58,15 +66,27 @@ public abstract class DeviceBase( override val actionDescriptors: Collection get() = actions.values.map { it.descriptor } - override val coroutineContext: CoroutineContext by lazy { - context.newCoroutineContext( - SupervisorJob(context.coroutineContext[Job]) + - CoroutineName("Device $this") + - CoroutineExceptionHandler { _, throwable -> - logger.error(throwable) { "Exception in device $this job" } + + private val sharedMessageFlow: MutableSharedFlow = MutableSharedFlow( + replay = meta["message.buffer"].int ?: 1000, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + override val coroutineContext: CoroutineContext = context.newCoroutineContext( + SupervisorJob(context.coroutineContext[Job]) + + CoroutineName("Device $this") + + CoroutineExceptionHandler { _, throwable -> + launch { + sharedMessageFlow.emit( + DeviceErrorMessage( + errorMessage = throwable.message, + errorType = throwable::class.simpleName, + errorStackTrace = throwable.stackTraceToString() + ) + ) } - ) - } + } + ) /** @@ -74,8 +94,6 @@ public abstract class DeviceBase( */ private val logicalState: HashMap = HashMap() - private val sharedMessageFlow: MutableSharedFlow = MutableSharedFlow() - public override val messageFlow: SharedFlow get() = sharedMessageFlow @Suppress("UNCHECKED_CAST") @@ -87,7 +105,7 @@ public abstract class DeviceBase( /** * Update logical property state and notify listeners */ - protected suspend fun updateLogical(propertyName: String, value: Meta?) { + protected suspend fun propertyChanged(propertyName: String, value: Meta?) { if (value != logicalState[propertyName]) { stateLock.withLock { logicalState[propertyName] = value @@ -99,10 +117,10 @@ public abstract class DeviceBase( } /** - * Update logical state using given [spec] and its convertor + * Notify the device that a property with [spec] value is changed */ - public suspend fun updateLogical(spec: DevicePropertySpec, value: T) { - updateLogical(spec.name, spec.converter.objectToMeta(value)) + protected suspend fun propertyChanged(spec: DevicePropertySpec, value: T) { + propertyChanged(spec.name, spec.converter.convert(value)) } /** @@ -112,7 +130,7 @@ public abstract class DeviceBase( override suspend fun readProperty(propertyName: String): Meta { val spec = properties[propertyName] ?: error("Property with name $propertyName not found") val meta = spec.readMeta(self) ?: error("Failed to read property $propertyName") - updateLogical(propertyName, meta) + propertyChanged(propertyName, meta) return meta } @@ -122,7 +140,7 @@ public abstract class DeviceBase( public suspend fun readPropertyOrNull(propertyName: String): Meta? { val spec = properties[propertyName] ?: return null val meta = spec.readMeta(self) ?: return null - updateLogical(propertyName, meta) + propertyChanged(propertyName, meta) return meta } @@ -135,15 +153,26 @@ public abstract class DeviceBase( } override suspend fun writeProperty(propertyName: String, value: Meta): Unit { + //bypass property setting if it already has that value + if (logicalState[propertyName] == value) { + logger.debug { "Skipping setting $propertyName to $value because value is already set" } + return + } when (val property = properties[propertyName]) { null -> { - //If there is a physical property with a given name, invalidate logical property and write physical one - updateLogical(propertyName, value) + //If there are no registered physical properties with given name, write a logical one. + propertyChanged(propertyName, value) } - is WritableDevicePropertySpec -> { + is MutableDevicePropertySpec -> { + //if there is a writeable property with a given name, invalidate logical and write physical invalidate(propertyName) property.writeMeta(self, value) + // perform read after writing if the writer did not set the value and the value is still in invalid state + if (logicalState[propertyName] == null) { + val meta = property.readMeta(self) + propertyChanged(propertyName, meta) + } } else -> { @@ -158,21 +187,46 @@ public abstract class DeviceBase( } @DFExperimental - override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.INIT - protected set + final override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED + private set(value) { + if (field != value) { + launch { + sharedMessageFlow.emit( + DeviceLifeCycleMessage(value) + ) + } + } + field = value + } + + protected open suspend fun onStart() { - @OptIn(DFExperimental::class) - override suspend fun open() { - super.open() - lifecycleState = DeviceLifecycleState.OPEN } @OptIn(DFExperimental::class) - override fun close() { - lifecycleState = DeviceLifecycleState.CLOSED - super.close() + final override suspend fun start() { + if (lifecycleState == DeviceLifecycleState.STOPPED) { + super.start() + lifecycleState = DeviceLifecycleState.STARTING + onStart() + lifecycleState = DeviceLifecycleState.STARTED + } else { + logger.debug { "Device $this is already started" } + } } + protected open fun onStop() { + + } + + @OptIn(DFExperimental::class) + final override fun stop() { + onStop() + lifecycleState = DeviceLifecycleState.STOPPED + super.stop() + } + + abstract override fun toString(): String } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt index 9309224..31f4c09 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt @@ -16,15 +16,14 @@ public open class DeviceBySpec( override val properties: Map> get() = spec.properties override val actions: Map> get() = spec.actions - override suspend fun open(): Unit = with(spec) { - super.open() + override suspend fun onStart(): Unit = with(spec) { self.onOpen() } - override fun close(): Unit = with(spec) { + override fun onStop(): Unit = with(spec){ self.onClose() - super.close() } + override fun toString(): String = "Device(spec=$spec)" } \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt index 809d940..c5fe8b5 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt @@ -3,9 +3,9 @@ package space.kscience.controls.spec import space.kscience.controls.api.Device import space.kscience.controls.api.PropertyDescriptor import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.dataforge.meta.MetaConverter -internal object DeviceMetaPropertySpec: DevicePropertySpec { +internal object DeviceMetaPropertySpec : DevicePropertySpec { override val descriptor: PropertyDescriptor = PropertyDescriptor("@meta") override val converter: MetaConverter = MetaConverter.meta diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt index cf8c741..edf9f93 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt @@ -4,11 +4,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import space.kscience.controls.api.ActionDescriptor -import space.kscience.controls.api.Device -import space.kscience.controls.api.PropertyChangedMessage -import space.kscience.controls.api.PropertyDescriptor -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.controls.api.* +import space.kscience.dataforge.meta.MetaConverter /** @@ -20,7 +17,7 @@ public annotation class InternalDeviceAPI /** * Specification for a device read-only property */ -public interface DevicePropertySpec { +public interface DevicePropertySpec { /** * Property descriptor */ @@ -44,7 +41,7 @@ public interface DevicePropertySpec { public val DevicePropertySpec<*, *>.name: String get() = descriptor.name -public interface WritableDevicePropertySpec : DevicePropertySpec { +public interface MutableDevicePropertySpec : DevicePropertySpec { /** * Write physical value to a device */ @@ -53,7 +50,7 @@ public interface WritableDevicePropertySpec : DevicePropertySp } -public interface DeviceActionSpec { +public interface DeviceActionSpec { /** * Action descriptor */ @@ -75,30 +72,29 @@ public interface DeviceActionSpec { public val DeviceActionSpec<*, *, *>.name: String get() = descriptor.name public suspend fun D.read(propertySpec: DevicePropertySpec): T = - propertySpec.converter.metaToObject(readProperty(propertySpec.name)) ?: error("Property read result is not valid") + propertySpec.converter.readOrNull(readProperty(propertySpec.name)) ?: error("Property read result is not valid") /** * Read typed value and update/push event if needed. * Return null if property read is not successful or property is undefined. */ public suspend fun > D.readOrNull(propertySpec: DevicePropertySpec): T? = - readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::metaToObject) + readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::readOrNull) - -public operator fun D.get(propertySpec: DevicePropertySpec): T? = - getProperty(propertySpec.name)?.let(propertySpec.converter::metaToObject) +public suspend fun D.getOrRead(propertySpec: DevicePropertySpec): T = + propertySpec.converter.read(getOrReadProperty(propertySpec.name)) /** * Write typed property state and invalidate logical state */ -public suspend fun D.write(propertySpec: WritableDevicePropertySpec, value: T) { - writeProperty(propertySpec.name, propertySpec.converter.objectToMeta(value)) +public suspend fun D.write(propertySpec: MutableDevicePropertySpec, value: T) { + writeProperty(propertySpec.name, propertySpec.converter.convert(value)) } /** * Fire and forget variant of property writing. Actual write is performed asynchronously on a [Device] scope */ -public operator fun D.set(propertySpec: WritableDevicePropertySpec, value: T): Job = launch { +public fun D.writeAsync(propertySpec: MutableDevicePropertySpec, value: T): Job = launch { write(propertySpec, value) } @@ -108,37 +104,39 @@ public operator fun D.set(propertySpec: WritableDevicePropertySp public fun D.propertyFlow(spec: DevicePropertySpec): Flow = messageFlow .filterIsInstance() .filter { it.property == spec.name } - .mapNotNull { spec.converter.metaToObject(it.value) } + .mapNotNull { spec.converter.read(it.value) } /** * A type safe property change listener. Uses the device [CoroutineScope]. */ public fun D.onPropertyChange( spec: DevicePropertySpec, + scope: CoroutineScope = this, callback: suspend PropertyChangedMessage.(T) -> Unit, ): Job = messageFlow .filterIsInstance() .filter { it.property == spec.name } .onEach { change -> - val newValue = spec.converter.metaToObject(change.value) + val newValue = spec.converter.read(change.value) if (newValue != null) { change.callback(newValue) } - }.launchIn(this) + }.launchIn(scope) /** * Call [callback] on initial property value and each value change */ public fun D.useProperty( spec: DevicePropertySpec, + scope: CoroutineScope = this, callback: suspend (T) -> Unit, -): Job = launch { +): Job = scope.launch { callback(read(spec)) messageFlow .filterIsInstance() .filter { it.property == spec.name } .collect { change -> - val newValue = spec.converter.metaToObject(change.value) + val newValue = spec.converter.readOrNull(change.value) if (newValue != null) { callback(newValue) } @@ -149,7 +147,7 @@ public fun D.useProperty( /** * Reset the logical state of a property */ -public suspend fun D.invalidate(propertySpec: DevicePropertySpec) { +public suspend fun D.invalidate(propertySpec: DevicePropertySpec) { invalidate(propertySpec.name) } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt index d05bf30..2f90cb8 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt @@ -5,24 +5,23 @@ import space.kscience.controls.api.ActionDescriptor import space.kscience.controls.api.Device import space.kscience.controls.api.PropertyDescriptor import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.dataforge.meta.MetaConverter import kotlin.properties.PropertyDelegateProvider import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KMutableProperty1 import kotlin.reflect.KProperty -import kotlin.reflect.KProperty1 -public object UnitMetaConverter: MetaConverter{ - override fun metaToObject(meta: Meta): Unit = Unit +public object UnitMetaConverter : MetaConverter { - override fun objectToMeta(obj: Unit): Meta = Meta.EMPTY + override fun readOrNull(source: Meta): Unit = Unit + + override fun convert(obj: Unit): Meta = Meta.EMPTY } public val MetaConverter.Companion.unit: MetaConverter get() = UnitMetaConverter @OptIn(InternalDeviceAPI::class) public abstract class DeviceSpec { - //initializing meta property for everyone + //initializing the metadata property for everyone private val _properties = hashMapOf>( DeviceMetaPropertySpec.name to DeviceMetaPropertySpec ) @@ -44,72 +43,25 @@ public abstract class DeviceSpec { return deviceProperty } - public fun property( - converter: MetaConverter, - readOnlyProperty: KProperty1, - descriptorBuilder: PropertyDescriptor.() -> Unit = {}, - ): PropertyDelegateProvider, ReadOnlyProperty>> = - PropertyDelegateProvider { _, property -> - val deviceProperty = object : DevicePropertySpec { - override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply { - //TODO add type from converter - writable = true - }.apply(descriptorBuilder) - - override val converter: MetaConverter = converter - - override suspend fun read(device: D): T = withContext(device.coroutineContext) { - readOnlyProperty.get(device) - } - } - registerProperty(deviceProperty) - ReadOnlyProperty { _, _ -> - deviceProperty - } - } - - public fun mutableProperty( - converter: MetaConverter, - readWriteProperty: KMutableProperty1, - descriptorBuilder: PropertyDescriptor.() -> Unit = {}, - ): PropertyDelegateProvider, ReadOnlyProperty>> = - PropertyDelegateProvider { _, property -> - val deviceProperty = object : WritableDevicePropertySpec { - - override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply { - //TODO add the type from converter - writable = true - }.apply(descriptorBuilder) - - override val converter: MetaConverter = 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 property( converter: MetaConverter, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> T?, + read: suspend D.(propertyName: String) -> T?, ): PropertyDelegateProvider, ReadOnlyProperty, DevicePropertySpec>> = PropertyDelegateProvider { _: DeviceSpec, property -> val propertyName = name ?: property.name val deviceProperty = object : DevicePropertySpec { - override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder) + + override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply { + fromSpec(property) + descriptorBuilder() + } + override val converter: MetaConverter = converter - override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() } + override suspend fun read(device: D): T? = + withContext(device.coroutineContext) { device.read(propertyName) } } registerProperty(deviceProperty) ReadOnlyProperty, DevicePropertySpec> { _, _ -> @@ -121,23 +73,30 @@ public abstract class DeviceSpec { converter: MetaConverter, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> T?, - write: suspend D.(T) -> Unit, - ): PropertyDelegateProvider, ReadOnlyProperty, WritableDevicePropertySpec>> = + read: suspend D.(propertyName: String) -> T?, + write: suspend D.(propertyName: String, value: T) -> Unit, + ): PropertyDelegateProvider, ReadOnlyProperty, MutableDevicePropertySpec>> = PropertyDelegateProvider { _: DeviceSpec, property: KProperty<*> -> val propertyName = name ?: property.name - val deviceProperty = object : WritableDevicePropertySpec { - override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder) + val deviceProperty = object : MutableDevicePropertySpec { + override val descriptor: PropertyDescriptor = PropertyDescriptor( + propertyName, + mutable = true + ).apply { + fromSpec(property) + descriptorBuilder() + } override val converter: MetaConverter = converter - override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() } + override suspend fun read(device: D): T? = + withContext(device.coroutineContext) { device.read(propertyName) } override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) { - device.write(value) + device.write(propertyName, value) } } - _properties[propertyName] = deviceProperty - ReadOnlyProperty, WritableDevicePropertySpec> { _, _ -> + registerProperty(deviceProperty) + ReadOnlyProperty, MutableDevicePropertySpec> { _, _ -> deviceProperty } } @@ -155,10 +114,13 @@ public abstract class DeviceSpec { name: String? = null, execute: suspend D.(I) -> O, ): PropertyDelegateProvider, ReadOnlyProperty, DeviceActionSpec>> = - PropertyDelegateProvider { _: DeviceSpec, property -> + PropertyDelegateProvider { _: DeviceSpec, property: KProperty<*> -> val actionName = name ?: property.name val deviceAction = object : DeviceActionSpec { - override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply(descriptorBuilder) + override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply { + fromSpec(property) + descriptorBuilder() + } override val inputConverter: MetaConverter = inputConverter override val outputConverter: MetaConverter = outputConverter @@ -173,68 +135,39 @@ public abstract class DeviceSpec { } } - /** - * An action that takes [Meta] and returns [Meta]. No conversions are done - */ - public fun metaAction( - descriptorBuilder: ActionDescriptor.() -> Unit = {}, - name: String? = null, - execute: suspend D.(Meta) -> Meta, - ): PropertyDelegateProvider, ReadOnlyProperty, DeviceActionSpec>> = - action( - MetaConverter.Companion.meta, - MetaConverter.Companion.meta, - descriptorBuilder, - name - ) { - execute(it) - } - - /** - * An action that takes no parameters and returns no values - */ - public fun unitAction( - descriptorBuilder: ActionDescriptor.() -> Unit = {}, - name: String? = null, - execute: suspend D.() -> Unit, - ): PropertyDelegateProvider, ReadOnlyProperty, DeviceActionSpec>> = - action( - MetaConverter.Companion.unit, - MetaConverter.Companion.unit, - descriptorBuilder, - name - ) { - execute() - } } +/** + * An action that takes no parameters and returns no values + */ +public fun DeviceSpec.unitAction( + descriptorBuilder: ActionDescriptor.() -> Unit = {}, + name: String? = null, + execute: suspend D.() -> Unit, +): PropertyDelegateProvider, ReadOnlyProperty, DeviceActionSpec>> = + action( + MetaConverter.Companion.unit, + MetaConverter.Companion.unit, + descriptorBuilder, + name + ) { + execute() + } /** - * Register a mutable logical property for a device + * An action that takes [Meta] and returns [Meta]. No conversions are done */ -@OptIn(InternalDeviceAPI::class) -public fun > DeviceSpec.logicalProperty( - converter: MetaConverter, - descriptorBuilder: PropertyDescriptor.() -> Unit = {}, +public fun DeviceSpec.metaAction( + descriptorBuilder: ActionDescriptor.() -> Unit = {}, name: String? = null, -): PropertyDelegateProvider, ReadOnlyProperty>> = - PropertyDelegateProvider { _, property -> - val deviceProperty = object : WritableDevicePropertySpec { - val propertyName = name ?: property.name - override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply { - //TODO add type from converter - writable = true - }.apply(descriptorBuilder) + execute: suspend D.(Meta) -> Meta, +): PropertyDelegateProvider, ReadOnlyProperty, DeviceActionSpec>> = + action( + MetaConverter.Companion.meta, + MetaConverter.Companion.meta, + descriptorBuilder, + name + ) { + execute(it) + } - override val converter: MetaConverter = converter - - override suspend fun read(device: D): T? = device.getProperty(propertyName)?.let(converter::metaToObject) - - override suspend fun write(device: D, value: T): Unit = - device.writeProperty(propertyName, converter.objectToMeta(value)) - } - registerProperty(deviceProperty) - ReadOnlyProperty { _, _ -> - deviceProperty - } - } \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt index af42f60..fefb65e 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt @@ -12,7 +12,7 @@ import kotlin.time.Duration /** * Perform a recurring asynchronous read action and return a flow of results. * The flow is lazy, so action is not performed unless flow is consumed. - * The flow uses called context. In order to call it on device context, use `flowOn(coroutineContext)`. + * The flow uses caller context. To call it on device context, use `flowOn(coroutineContext)`. * * The flow is canceled when the device scope is canceled */ diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt new file mode 100644 index 0000000..000458e --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt @@ -0,0 +1,12 @@ +package space.kscience.controls.spec + +import space.kscience.controls.api.ActionDescriptor +import space.kscience.controls.api.PropertyDescriptor +import kotlin.reflect.KProperty + +@Target(AnnotationTarget.CLASS, AnnotationTarget.PROPERTY, AnnotationTarget.FIELD) +public annotation class Description(val content: String) + +internal expect fun PropertyDescriptor.fromSpec(property: KProperty<*>) + +internal expect fun ActionDescriptor.fromSpec(property: KProperty<*>) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt index e264212..1fc4649 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt @@ -1,7 +1,6 @@ package space.kscience.controls.spec import space.kscience.dataforge.meta.* -import space.kscience.dataforge.meta.transformations.MetaConverter import kotlin.time.Duration import kotlin.time.DurationUnit import kotlin.time.toDuration @@ -10,14 +9,14 @@ public fun Double.asMeta(): Meta = Meta(asValue()) //TODO to be moved to DF public object DurationConverter : MetaConverter { - override fun metaToObject(meta: Meta): Duration = meta.value?.double?.toDuration(DurationUnit.SECONDS) + override fun readOrNull(source: Meta): Duration = source.value?.double?.toDuration(DurationUnit.SECONDS) ?: run { - val unit: DurationUnit = meta["unit"].enum() ?: DurationUnit.SECONDS - val value = meta[Meta.VALUE_KEY].double ?: error("No value present for Duration") + val unit: DurationUnit = source["unit"].enum() ?: DurationUnit.SECONDS + val value = source[Meta.VALUE_KEY].double ?: error("No value present for Duration") return@run value.toDuration(unit) } - override fun objectToMeta(obj: Duration): Meta = obj.toDouble(DurationUnit.SECONDS).asMeta() + override fun convert(obj: Duration): Meta = obj.toDouble(DurationUnit.SECONDS).asMeta() } public val MetaConverter.Companion.duration: MetaConverter get() = DurationConverter \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt index ff89662..8bd22b6 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt @@ -4,22 +4,71 @@ import space.kscience.controls.api.Device import space.kscience.controls.api.PropertyDescriptor import space.kscience.controls.api.metaDescriptor import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter import space.kscience.dataforge.meta.ValueType -import space.kscience.dataforge.meta.transformations.MetaConverter import kotlin.properties.PropertyDelegateProvider import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.KProperty1 + +/** + * A read-only device property that delegates reading to a device [KProperty1] + */ +public fun DeviceSpec.property( + converter: MetaConverter, + readOnlyProperty: KProperty1, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, +): PropertyDelegateProvider, ReadOnlyProperty, DevicePropertySpec>> = property( + converter, + descriptorBuilder, + name = readOnlyProperty.name, + read = { readOnlyProperty.get(this) } +) + +/** + * Mutable property that delegates reading and writing to a device [KMutableProperty1] + */ +public fun DeviceSpec.mutableProperty( + converter: MetaConverter, + readWriteProperty: KMutableProperty1, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, +): PropertyDelegateProvider, ReadOnlyProperty, MutableDevicePropertySpec>> = + mutableProperty( + converter, + descriptorBuilder, + readWriteProperty.name, + read = { _ -> readWriteProperty.get(this) }, + write = { _, value: T -> readWriteProperty.set(this, value) } + ) + +/** + * Register a mutable logical property (without a corresponding physical state) for a device + */ +public fun > DeviceSpec.logicalProperty( + converter: MetaConverter, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + name: String? = null, +): PropertyDelegateProvider, ReadOnlyProperty, MutableDevicePropertySpec>> = + mutableProperty( + converter, + descriptorBuilder, + name, + read = { propertyName -> getProperty(propertyName)?.let(converter::readOrNull) }, + write = { propertyName, value -> writeProperty(propertyName, converter.convert(value)) } + ) + //read only delegates public fun DeviceSpec.booleanProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Boolean? + read: suspend D.(propertyName: String) -> Boolean? ): PropertyDelegateProvider, ReadOnlyProperty, DevicePropertySpec>> = property( MetaConverter.boolean, { metaDescriptor { - type(ValueType.BOOLEAN) + valueType(ValueType.BOOLEAN) } descriptorBuilder() }, @@ -31,15 +80,15 @@ private inline fun numberDescriptor( crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {} ): PropertyDescriptor.() -> Unit = { metaDescriptor { - type(ValueType.NUMBER) + valueType(ValueType.NUMBER) } descriptorBuilder() } public fun DeviceSpec.numberProperty( - name: String? = null, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, - read: suspend D.() -> Number? + name: String? = null, + read: suspend D.(propertyName: String) -> Number? ): PropertyDelegateProvider, ReadOnlyProperty, DevicePropertySpec>> = property( MetaConverter.number, numberDescriptor(descriptorBuilder), @@ -50,7 +99,7 @@ public fun DeviceSpec.numberProperty( public fun DeviceSpec.doubleProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Double? + read: suspend D.(propertyName: String) -> Double? ): PropertyDelegateProvider, ReadOnlyProperty, DevicePropertySpec>> = property( MetaConverter.double, numberDescriptor(descriptorBuilder), @@ -61,12 +110,12 @@ public fun DeviceSpec.doubleProperty( public fun DeviceSpec.stringProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> String? + read: suspend D.(propertyName: String) -> String? ): PropertyDelegateProvider, ReadOnlyProperty, DevicePropertySpec>> = property( MetaConverter.string, { metaDescriptor { - type(ValueType.STRING) + valueType(ValueType.STRING) } descriptorBuilder() }, @@ -77,12 +126,12 @@ public fun DeviceSpec.stringProperty( public fun DeviceSpec.metaProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Meta? + read: suspend D.(propertyName: String) -> Meta? ): PropertyDelegateProvider, ReadOnlyProperty, DevicePropertySpec>> = property( MetaConverter.meta, { metaDescriptor { - type(ValueType.STRING) + valueType(ValueType.STRING) } descriptorBuilder() }, @@ -95,14 +144,14 @@ public fun DeviceSpec.metaProperty( public fun DeviceSpec.booleanProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Boolean?, - write: suspend D.(Boolean) -> Unit -): PropertyDelegateProvider, ReadOnlyProperty, WritableDevicePropertySpec>> = + read: suspend D.(propertyName: String) -> Boolean?, + write: suspend D.(propertyName: String, value: Boolean) -> Unit +): PropertyDelegateProvider, ReadOnlyProperty, MutableDevicePropertySpec>> = mutableProperty( MetaConverter.boolean, { metaDescriptor { - type(ValueType.BOOLEAN) + valueType(ValueType.BOOLEAN) } descriptorBuilder() }, @@ -115,31 +164,31 @@ public fun DeviceSpec.booleanProperty( public fun DeviceSpec.numberProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Number, - write: suspend D.(Number) -> Unit -): PropertyDelegateProvider, ReadOnlyProperty, WritableDevicePropertySpec>> = + read: suspend D.(propertyName: String) -> Number, + write: suspend D.(propertyName: String, value: Number) -> Unit +): PropertyDelegateProvider, ReadOnlyProperty, MutableDevicePropertySpec>> = mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write) public fun DeviceSpec.doubleProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Double, - write: suspend D.(Double) -> Unit -): PropertyDelegateProvider, ReadOnlyProperty, WritableDevicePropertySpec>> = + read: suspend D.(propertyName: String) -> Double, + write: suspend D.(propertyName: String, value: Double) -> Unit +): PropertyDelegateProvider, ReadOnlyProperty, MutableDevicePropertySpec>> = mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write) public fun DeviceSpec.stringProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> String, - write: suspend D.(String) -> Unit -): PropertyDelegateProvider, ReadOnlyProperty, WritableDevicePropertySpec>> = + read: suspend D.(propertyName: String) -> String, + write: suspend D.(propertyName: String, value: String) -> Unit +): PropertyDelegateProvider, ReadOnlyProperty, MutableDevicePropertySpec>> = mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write) public fun DeviceSpec.metaProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Meta, - write: suspend D.(Meta) -> Unit -): PropertyDelegateProvider, ReadOnlyProperty, WritableDevicePropertySpec>> = + read: suspend D.(propertyName: String) -> Meta, + write: suspend D.(propertyName: String, value: Meta) -> Unit +): PropertyDelegateProvider, ReadOnlyProperty, MutableDevicePropertySpec>> = mutableProperty(MetaConverter.meta, descriptorBuilder, name, read, write) \ No newline at end of file diff --git a/controls-core/src/jsMain/kotlin/space/kscience/controls/spec/fromSpec.js.kt b/controls-core/src/jsMain/kotlin/space/kscience/controls/spec/fromSpec.js.kt new file mode 100644 index 0000000..cd77248 --- /dev/null +++ b/controls-core/src/jsMain/kotlin/space/kscience/controls/spec/fromSpec.js.kt @@ -0,0 +1,9 @@ +package space.kscience.controls.spec + +import space.kscience.controls.api.ActionDescriptor +import space.kscience.controls.api.PropertyDescriptor +import kotlin.reflect.KProperty + +internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>){} + +internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){} \ No newline at end of file diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt index d7983f2..85c3d5c 100644 --- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt +++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt @@ -1,19 +1,20 @@ package space.kscience.controls.ports import kotlinx.coroutines.* -import space.kscience.dataforge.context.Context -import space.kscience.dataforge.context.error -import space.kscience.dataforge.context.info -import space.kscience.dataforge.context.logger +import space.kscience.dataforge.context.* import space.kscience.dataforge.meta.* import java.net.InetSocketAddress import java.nio.ByteBuffer +import java.nio.channels.AsynchronousCloseException import java.nio.channels.ByteChannel import java.nio.channels.DatagramChannel import java.nio.channels.SocketChannel import kotlin.coroutines.CoroutineContext -public fun ByteBuffer.toArray(limit: Int = limit()): ByteArray { +/** + * Copy the contents of this buffer to an array + */ +public fun ByteBuffer.copyToArray(limit: Int = limit()): ByteArray { rewind() val response = ByteArray(limit) get(response) @@ -26,32 +27,41 @@ public fun ByteBuffer.toArray(limit: Int = limit()): ByteArray { */ public class ChannelPort( context: Context, + meta: Meta, coroutineContext: CoroutineContext = context.coroutineContext, channelBuilder: suspend () -> ByteChannel, -) : AbstractPort(context, coroutineContext), AutoCloseable { - - private val futureChannel: Deferred = this.scope.async(Dispatchers.IO) { - channelBuilder() - } +) : AbstractAsynchronousPort(context, meta, coroutineContext), AutoCloseable { /** * A handler to await port connection */ - public val startJob: Job get() = futureChannel + private val futureChannel: Deferred = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { + channelBuilder() + } - private val listenerJob = this.scope.launch(Dispatchers.IO) { - val channel = futureChannel.await() - val buffer = ByteBuffer.allocate(1024) - while (isActive) { - try { - val num = channel.read(buffer) - if (num > 0) { - receive(buffer.toArray(num)) + private var listenerJob: Job? = null + + override val isOpen: Boolean get() = listenerJob?.isActive == true + + override fun onOpen() { + listenerJob = scope.launch(Dispatchers.IO) { + val channel = futureChannel.await() + val buffer = ByteBuffer.allocate(1024) + while (isActive && channel.isOpen) { + try { + val num = channel.read(buffer) + if (num > 0) { + receive(buffer.copyToArray(num)) + } + if (num < 0) cancel("The input channel is exhausted") + } catch (ex: Exception) { + if (ex is AsynchronousCloseException) { + logger.info { "Channel $channel closed" } + } else { + logger.error(ex) { "Channel read error, retrying in 1 second" } + delay(1000) + } } - if (num < 0) cancel("The input channel is exhausted") - } catch (ex: Exception) { - logger.error(ex) { "Channel read error" } - delay(1000) } } } @@ -62,46 +72,86 @@ public class ChannelPort( @OptIn(ExperimentalCoroutinesApi::class) override fun close() { - listenerJob.cancel() + listenerJob?.cancel() if (futureChannel.isCompleted) { futureChannel.getCompleted().close() - } else { - futureChannel.cancel() } super.close() } } /** - * A [PortFactory] for TCP connections + * A [Factory] for TCP connections */ -public object TcpPort : PortFactory { +public object TcpPort : Factory { - override val type: String = "tcp" + public fun build( + context: Context, + host: String, + port: Int, + coroutineContext: CoroutineContext = context.coroutineContext, + ): ChannelPort { + val meta = Meta { + "name" put "tcp://$host:$port" + "type" put "tcp" + "host" put host + "port" put port + } + return ChannelPort(context, meta, coroutineContext) { + SocketChannel.open(InetSocketAddress(host, port)) + } + } + /** + * Create and open TCP port + */ public fun open( context: Context, host: String, port: Int, coroutineContext: CoroutineContext = context.coroutineContext, - ): ChannelPort = ChannelPort(context, coroutineContext) { - SocketChannel.open(InetSocketAddress(host, port)) - } + ): ChannelPort = build(context, host, port, coroutineContext).apply { open() } override fun build(context: Context, meta: Meta): ChannelPort { val host = meta["host"].string ?: "localhost" val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta") - return open(context, host, port) + return build(context, host, port) } + } /** - * A [PortFactory] for UDP connections + * A [Factory] for UDP connections */ -public object UdpPort : PortFactory { +public object UdpPort : Factory { - override val type: String = "udp" + public fun build( + context: Context, + remoteHost: String, + remotePort: Int, + localPort: Int? = null, + localHost: String? = null, + coroutineContext: CoroutineContext = context.coroutineContext, + ): ChannelPort { + val meta = Meta { + "name" put "udp://$remoteHost:$remotePort" + "type" put "udp" + "remoteHost" put remoteHost + "remotePort" put remotePort + localHost?.let { "localHost" put it } + localPort?.let { "localPort" put it } + } + return ChannelPort(context, meta, coroutineContext) { + DatagramChannel.open().apply { + //bind the channel to a local port to receive messages + localPort?.let { bind(InetSocketAddress(localHost ?: "localhost", it)) } + //connect to remote port to send messages + connect(InetSocketAddress(remoteHost, remotePort.toInt())) + context.logger.info { "Connected to UDP $remotePort on $remoteHost" } + } + } + } /** * Connect a datagram channel to a remote host/port. If [localPort] is provided, it is used to bind local port for receiving messages. @@ -112,22 +162,14 @@ public object UdpPort : PortFactory { remotePort: Int, localPort: Int? = null, localHost: String = "localhost", - coroutineContext: CoroutineContext = context.coroutineContext, - ): ChannelPort = ChannelPort(context, coroutineContext) { - DatagramChannel.open().apply { - //bind the channel to a local port to receive messages - localPort?.let { bind(InetSocketAddress(localHost, localPort)) } - //connect to remote port to send messages - connect(InetSocketAddress(remoteHost, remotePort)) - context.logger.info { "Connected to UDP $remotePort on $remoteHost" } - } - } + ): ChannelPort = build(context, remoteHost, remotePort, localPort, localHost).apply { open() } + override fun build(context: Context, meta: Meta): ChannelPort { val remoteHost by meta.string { error("Remote host is not specified") } val remotePort by meta.number { error("Remote port is not specified") } val localHost: String? by meta.string() val localPort: Int? by meta.int() - return open(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost") + return build(context, remoteHost, remotePort.toInt(), localPort, localHost) } } \ No newline at end of file diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt index d9d87e2..82e1ac0 100644 --- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt +++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt @@ -6,7 +6,7 @@ import space.kscience.dataforge.context.PluginFactory import space.kscience.dataforge.context.PluginTag import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.names.Name -import space.kscience.dataforge.names.parseAsName +import space.kscience.dataforge.names.asName /** * A plugin for loading JVM nio-based ports @@ -17,9 +17,9 @@ public class JvmPortsPlugin : AbstractPlugin() { override val tag: PluginTag get() = Companion.tag override fun content(target: String): Map = when(target){ - PortFactory.TYPE -> mapOf( - TcpPort.type.parseAsName() to TcpPort, - UdpPort.type.parseAsName() to UdpPort + Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf( + "tcp".asName() to TcpPort, + "udp".asName() to UdpPort ) else -> emptyMap() } diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt new file mode 100644 index 0000000..ae65c64 --- /dev/null +++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt @@ -0,0 +1,59 @@ +package space.kscience.controls.ports + +import kotlinx.coroutines.* +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.Meta +import java.net.DatagramPacket +import java.net.DatagramSocket +import kotlin.coroutines.CoroutineContext + +/** + * A port based on [DatagramSocket] for cases, where [ChannelPort] does not work for some reason + */ +public class UdpSocketPort( + override val context: Context, + meta: Meta, + private val socket: DatagramSocket, + coroutineContext: CoroutineContext = context.coroutineContext, +) : AbstractAsynchronousPort(context, meta, coroutineContext) { + + private var listenerJob: Job? = null + + override fun onOpen() { + listenerJob = context.launch(Dispatchers.IO) { + while (isActive) { + val buf = ByteArray(socket.receiveBufferSize) + + val packet = DatagramPacket( + buf, + buf.size, + ) + socket.receive(packet) + + val bytes = packet.data.copyOfRange( + packet.offset, + packet.offset + packet.length + ) + receive(bytes) + } + } + } + + override fun close() { + listenerJob?.cancel() + super.close() + } + + override val isOpen: Boolean get() = listenerJob?.isActive == true + + + override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) { + val packet = DatagramPacket( + data, + data.size, + socket.remoteSocketAddress + ) + socket.send(packet) + } + +} \ No newline at end of file diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt new file mode 100644 index 0000000..7ae572f --- /dev/null +++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt @@ -0,0 +1,18 @@ +package space.kscience.controls.spec + +import space.kscience.controls.api.ActionDescriptor +import space.kscience.controls.api.PropertyDescriptor +import kotlin.reflect.KProperty +import kotlin.reflect.full.findAnnotation + +internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) { + property.findAnnotation()?.let { + description = it.content + } +} + +internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){ + property.findAnnotation()?.let { + description = it.content + } +} \ No newline at end of file diff --git a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt new file mode 100644 index 0000000..b19cfad --- /dev/null +++ b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt @@ -0,0 +1,50 @@ +package space.kscience.controls.ports + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import space.kscience.dataforge.context.Global +import kotlin.test.assertEquals + + +internal class AsynchronousPortIOTest { + + @Test + fun testDelimiteredByteArrayFlow() { + val flow = flowOf("bb?b", "ddd?", ":defgb?:ddf", "34fb?:--").map { it.encodeToByteArray() } + val chunked = flow.withDelimiter("?:".encodeToByteArray()) + runBlocking { + val result = chunked.toList() + assertEquals(3, result.size) + assertEquals("bb?bddd?:", result[0].decodeToString()) + assertEquals("defgb?:", result[1].decodeToString()) + assertEquals("ddf34fb?:", result[2].decodeToString()) + } + } + + @Test + fun testUdpCommunication() = runTest { + val receiver = UdpPort.open(Global, "localhost", 8811, localPort = 8812) + val sender = UdpPort.open(Global, "localhost", 8812, localPort = 8811) + + delay(30) + repeat(10) { + sender.send("Line number $it\n") + } + + val res = receiver + .subscribe() + .withStringDelimiter("\n") + .take(10) + .toList() + + assertEquals("Line number 3", res[3].trim()) + receiver.close() + sender.close() + } +} \ No newline at end of file diff --git a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt deleted file mode 100644 index bdf6891..0000000 --- a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package space.kscience.controls.ports - -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Test -import kotlin.test.assertEquals - - -internal class PortIOTest{ - - @Test - fun testDelimiteredByteArrayFlow(){ - val flow = flowOf("bb?b","ddd?",":defgb?:ddf","34fb?:--").map { it.encodeToByteArray() } - val chunked = flow.withDelimiter("?:".encodeToByteArray()) - runBlocking { - val result = chunked.toList() - assertEquals(3, result.size) - assertEquals("bb?bddd?:",result[0].decodeToString()) - assertEquals("defgb?:", result[1].decodeToString()) - assertEquals("ddf34fb?:", result[2].decodeToString()) - } - } -} \ No newline at end of file diff --git a/controls-core/src/nativeMain/kotlin/space/kscience/controls/spec/fromSpec.native.kt b/controls-core/src/nativeMain/kotlin/space/kscience/controls/spec/fromSpec.native.kt new file mode 100644 index 0000000..1d1ccc4 --- /dev/null +++ b/controls-core/src/nativeMain/kotlin/space/kscience/controls/spec/fromSpec.native.kt @@ -0,0 +1,9 @@ +package space.kscience.controls.spec + +import space.kscience.controls.api.ActionDescriptor +import space.kscience.controls.api.PropertyDescriptor +import kotlin.reflect.KProperty + +internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) {} + +internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){} \ No newline at end of file diff --git a/controls-jupyter/README.md b/controls-jupyter/README.md new file mode 100644 index 0000000..15d8e2d --- /dev/null +++ b/controls-jupyter/README.md @@ -0,0 +1,21 @@ +# Module controls-jupyter + + + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:controls-jupyter:0.3.0`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:controls-jupyter:0.3.0") +} +``` diff --git a/controls-jupyter/api/controls-jupyter.api b/controls-jupyter/api/controls-jupyter.api new file mode 100644 index 0000000..726b523 --- /dev/null +++ b/controls-jupyter/api/controls-jupyter.api @@ -0,0 +1,8 @@ +public final class space/kscience/controls/jupyter/ControlsJupyter : space/kscience/visionforge/jupyter/VisionForgeIntegration { + public static final field Companion Lspace/kscience/controls/jupyter/ControlsJupyter$Companion; + public fun ()V +} + +public final class space/kscience/controls/jupyter/ControlsJupyter$Companion { +} + diff --git a/controls-jupyter/build.gradle.kts b/controls-jupyter/build.gradle.kts new file mode 100644 index 0000000..c8486bd --- /dev/null +++ b/controls-jupyter/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +val visionforgeVersion: String by rootProject.extra + +kscience { + fullStack("js/controls-jupyter.js") + useKtor() + useContextReceivers() + jupyterLibrary("space.kscience.controls.jupyter.ControlsJupyter") + dependencies { + implementation(projects.controlsVision) + implementation("space.kscience:visionforge-jupyter:$visionforgeVersion") + } + jvmMain { + implementation(spclibs.logback.classic) + } +} \ No newline at end of file diff --git a/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt b/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt new file mode 100644 index 0000000..388e1ab --- /dev/null +++ b/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt @@ -0,0 +1,14 @@ +import space.kscience.visionforge.html.runVisionClient +import space.kscience.visionforge.jupyter.VFNotebookClient +import space.kscience.visionforge.markup.MarkupPlugin +import space.kscience.visionforge.plotly.PlotlyPlugin + +public fun main(): Unit = runVisionClient { +// plugin(DeviceManager) +// plugin(ClockManager) + plugin(PlotlyPlugin) + plugin(MarkupPlugin) +// plugin(TableVisionJsPlugin) + plugin(VFNotebookClient) +} + diff --git a/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt b/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt new file mode 100644 index 0000000..ec4bcee --- /dev/null +++ b/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt @@ -0,0 +1,71 @@ +package space.kscience.controls.jupyter + +import org.jetbrains.kotlinx.jupyter.api.declare +import org.jetbrains.kotlinx.jupyter.api.libraries.resources +import space.kscience.controls.manager.ClockManager +import space.kscience.controls.manager.DeviceManager +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.misc.DFExperimental +import space.kscience.plotly.Plot +import space.kscience.visionforge.jupyter.VisionForge +import space.kscience.visionforge.jupyter.VisionForgeIntegration +import space.kscience.visionforge.markup.MarkupPlugin +import space.kscience.visionforge.plotly.PlotlyPlugin +import space.kscience.visionforge.plotly.asVision +import space.kscience.visionforge.visionManager + + +@OptIn(DFExperimental::class) +public class ControlsJupyter : VisionForgeIntegration(CONTEXT.visionManager) { + + override fun Builder.afterLoaded(vf: VisionForge) { + + resources { + js("controls-jupyter") { + classPath("js/controls-jupyter.js") + } + } + + onLoaded { + declare("context" to CONTEXT) + } + + import( + "kotlin.time.*", + "kotlin.time.Duration.Companion.milliseconds", + "kotlin.time.Duration.Companion.seconds", +// "space.kscience.tables.*", + "space.kscience.dataforge.meta.*", + "space.kscience.dataforge.context.*", + "space.kscience.plotly.*", + "space.kscience.plotly.models.*", + "space.kscience.visionforge.plotly.*", + "space.kscience.controls.manager.*", + "space.kscience.controls.constructor.*", + "space.kscience.controls.vision.*", + "space.kscience.controls.spec.*" + ) + +// render> { table -> +// vf.produceHtml { +// vision { table.toVision() } +// } +// } + + render { plot -> + vf.produceHtml { + vision { plot.asVision() } + } + } + } + + public companion object { + private val CONTEXT: Context = Context("controls-jupyter") { + plugin(DeviceManager) + plugin(ClockManager) + plugin(PlotlyPlugin) +// plugin(TableVisionPlugin) + plugin(MarkupPlugin) + } + } +} diff --git a/controls-magix/README.md b/controls-magix/README.md index 5473f02..4048990 100644 --- a/controls-magix/README.md +++ b/controls-magix/README.md @@ -12,18 +12,16 @@ Magix service for binding controls devices (both as RPC client and server) ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-magix:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-magix:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-magix:0.2.0") + implementation("space.kscience:controls-magix:0.3.0") } ``` diff --git a/controls-magix/build.gradle.kts b/controls-magix/build.gradle.kts index f0a6339..055ec98 100644 --- a/controls-magix/build.gradle.kts +++ b/controls-magix/build.gradle.kts @@ -12,6 +12,7 @@ description = """ kscience { jvm() js() + useCoroutines("1.8.0") useSerialization { json() } diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt index 64e8a9e..51836b9 100644 --- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt +++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt @@ -1,12 +1,14 @@ package space.kscience.controls.client import com.benasher44.uuid.uuid4 +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* -import kotlinx.coroutines.newCoroutineContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import space.kscience.controls.api.* import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.name import space.kscience.dataforge.context.Context import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.misc.DFExperimental @@ -26,10 +28,10 @@ public class DeviceClient( private val deviceName: Name, incomingFlow: Flow, private val send: suspend (DeviceMessage) -> Unit, -) : Device { +) : CachingDevice { - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) - override val coroutineContext: CoroutineContext = newCoroutineContext(context.coroutineContext) + + override val coroutineContext: CoroutineContext = context.coroutineContext + Job(context.coroutineContext[Job]) private val mutex = Mutex() @@ -99,19 +101,82 @@ public class DeviceClient( } @DFExperimental - override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.OPEN + override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STARTED } /** * Connect to a remote device via this endpoint. * * @param context a [Context] to run device in - * @param endpointName the name of endpoint in Magix to connect to + * @param sourceEndpointName the name of this endpoint + * @param targetEndpointName the name of endpoint in Magix to connect to * @param deviceName the name of device within endpoint */ -public fun MagixEndpoint.remoteDevice(context: Context, endpointName: String, deviceName: Name): DeviceClient { - val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second } +public fun MagixEndpoint.remoteDevice( + context: Context, + sourceEndpointName: String, + targetEndpointName: String, + deviceName: Name, +): DeviceClient { + val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(targetEndpointName)).map { it.second } return DeviceClient(context, deviceName, subscription) { - send(DeviceManager.magixFormat, it, endpointName, id = stringUID()) + send( + format = DeviceManager.magixFormat, + payload = it, + source = sourceEndpointName, + target = targetEndpointName, + id = stringUID() + ) } +} + +/** + * Subscribe on specific property of a device without creating a device + */ +public fun MagixEndpoint.controlsPropertyFlow( + endpointName: String, + deviceName: Name, + propertySpec: DevicePropertySpec<*, T>, +): Flow { + val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second } + + return subscription.filterIsInstance() + .filter { message -> + message.sourceDevice == deviceName && message.property == propertySpec.name + }.map { + propertySpec.converter.read(it.value) + } +} + +public suspend fun MagixEndpoint.sendControlsPropertyChange( + sourceEndpointName: String, + targetEndpointName: String, + deviceName: Name, + propertySpec: DevicePropertySpec<*, T>, + value: T, +) { + val message = PropertySetMessage( + property = propertySpec.name, + value = propertySpec.converter.convert(value), + targetDevice = deviceName + ) + send(DeviceManager.magixFormat, message, source = sourceEndpointName, target = targetEndpointName) +} + +/** + * Subscribe on property change messages together with property values + */ +public fun MagixEndpoint.controlsPropertyMessageFlow( + endpointName: String, + deviceName: Name, + propertySpec: DevicePropertySpec<*, T>, +): Flow> { + val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second } + + return subscription.filterIsInstance() + .filter { message -> + message.sourceDevice == deviceName && message.property == propertySpec.name + }.map { + it to propertySpec.converter.read(it.value) + } } \ No newline at end of file diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt new file mode 100644 index 0000000..20c59e8 --- /dev/null +++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt @@ -0,0 +1,79 @@ +package space.kscience.controls.client + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.api.getOrReadProperty +import space.kscience.controls.spec.DeviceActionSpec +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.MutableDevicePropertySpec +import space.kscience.controls.spec.name +import space.kscience.dataforge.meta.Meta + + +/** + * An accessor that allows DeviceClient to connect to any property without type checks + */ +public suspend fun DeviceClient.read(propertySpec: DevicePropertySpec<*, T>): T = + propertySpec.converter.readOrNull(readProperty(propertySpec.name)) ?: error("Property read result is not valid") + + +public suspend fun DeviceClient.request(propertySpec: DevicePropertySpec<*, T>): T = + propertySpec.converter.read(getOrReadProperty(propertySpec.name)) + +public suspend fun DeviceClient.write(propertySpec: MutableDevicePropertySpec<*, T>, value: T) { + writeProperty(propertySpec.name, propertySpec.converter.convert(value)) +} + +public fun DeviceClient.writeAsync(propertySpec: MutableDevicePropertySpec<*, T>, value: T): Job = launch { + write(propertySpec, value) +} + +public fun DeviceClient.propertyFlow(spec: DevicePropertySpec<*, T>): Flow = messageFlow + .filterIsInstance() + .filter { it.property == spec.name } + .mapNotNull { spec.converter.readOrNull(it.value) } + +public fun DeviceClient.onPropertyChange( + spec: DevicePropertySpec<*, T>, + scope: CoroutineScope = this, + callback: suspend PropertyChangedMessage.(T) -> Unit, +): Job = messageFlow + .filterIsInstance() + .filter { it.property == spec.name } + .onEach { change -> + val newValue = spec.converter.readOrNull(change.value) + if (newValue != null) { + change.callback(newValue) + } + }.launchIn(scope) + +public fun DeviceClient.useProperty( + spec: DevicePropertySpec<*, T>, + scope: CoroutineScope = this, + callback: suspend (T) -> Unit, +): Job = scope.launch { + callback(read(spec)) + messageFlow + .filterIsInstance() + .filter { it.property == spec.name } + .collect { change -> + val newValue = spec.converter.readOrNull(change.value) + if (newValue != null) { + callback(newValue) + } + } +} + +public suspend fun DeviceClient.execute(actionSpec: DeviceActionSpec<*, I, O>, input: I): O { + val inputMeta = actionSpec.inputConverter.convert(input) + val res = execute(actionSpec.name, inputMeta) + return actionSpec.outputConverter.read(res ?: Meta.EMPTY) +} + +public suspend fun DeviceClient.execute(actionSpec: DeviceActionSpec<*, Unit, O>): O { + val res = execute(actionSpec.name, Meta.EMPTY) + return actionSpec.outputConverter.read(res ?: Meta.EMPTY) +} \ No newline at end of file diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt index ed69bff..915d0a0 100644 --- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt +++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt @@ -12,6 +12,8 @@ import space.kscience.controls.manager.respondHubMessage import space.kscience.dataforge.context.error import space.kscience.dataforge.context.logger import space.kscience.magix.api.* +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext internal val controlsMagixFormat: MagixFormat = MagixFormat( @@ -32,17 +34,20 @@ internal fun generateId(request: MagixMessage): String = if (request.id != null) /** * Communicate with server in [Magix format](https://github.com/waltz-controls/rfc/tree/master/1) + * + * Accepts messages with target that equals [endpointID] or null (broadcast messages) */ public fun DeviceManager.launchMagixService( endpoint: MagixEndpoint, endpointID: String = controlsMagixFormat.defaultFormat, -): Job = context.launch { - endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID)).onEach { (request, payload) -> + coroutineContext: CoroutineContext = EmptyCoroutineContext, +): Job = context.launch(coroutineContext) { + endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID, null)).onEach { (request, payload) -> val responsePayload = respondHubMessage(payload) - if (responsePayload != null) { + responsePayload.forEach { endpoint.send( format = controlsMagixFormat, - payload = responsePayload, + payload = it, source = endpointID, target = request.sourceEndpoint, id = generateId(request), @@ -53,7 +58,7 @@ public fun DeviceManager.launchMagixService( logger.error(error) { "Error while responding to message: ${error.message}" } }.launchIn(this) - hubMessageFlow(this).onEach { payload -> + hubMessageFlow().onEach { payload -> endpoint.send( format = controlsMagixFormat, payload = payload, diff --git a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt new file mode 100644 index 0000000..1478899 --- /dev/null +++ b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt @@ -0,0 +1,83 @@ +package space.kscience.controls.client + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.manager.install +import space.kscience.controls.manager.respondMessage +import space.kscience.controls.spec.* +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.context.request +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.names.Name +import space.kscience.magix.api.MagixEndpoint +import space.kscience.magix.api.MagixMessage +import space.kscience.magix.api.MagixMessageFilter +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.time.Duration.Companion.milliseconds + + +internal class RemoteDeviceConnect { + + class TestDevice(context: Context, meta: Meta) : DeviceBySpec(TestDevice, context, meta) { + private val rng = Random(meta["seed"].int ?: 0) + + private val randomValue get() = rng.nextDouble() + + companion object : DeviceSpec(), Factory { + + override fun build(context: Context, meta: Meta): TestDevice = TestDevice(context, meta) + + val value by doubleProperty { randomValue } + + override suspend fun TestDevice.onOpen() { + doRecurring((meta["delay"].int ?: 10).milliseconds) { + read(value) + } + } + } + } + + @Test + fun wrapper() = runTest { + val context = Context { + plugin(DeviceManager) + } + + val device = context.request(DeviceManager).install("test", TestDevice) + + val virtualMagixEndpoint = object : MagixEndpoint { + + + override fun subscribe(filter: MagixMessageFilter): Flow = device.messageFlow.map { + MagixMessage( + format = DeviceManager.magixFormat.defaultFormat, + payload = MagixEndpoint.magixJson.encodeToJsonElement(DeviceManager.magixFormat.serializer, it), + sourceEndpoint = "source", + ) + } + + override suspend fun broadcast(message: MagixMessage) { + device.respondMessage( + Name.EMPTY, + Json.decodeFromJsonElement(DeviceManager.magixFormat.serializer, message.payload) + ) + } + + override fun close() { + // + } + } + + val remoteDevice = virtualMagixEndpoint.remoteDevice(context, "source", "target", Name.EMPTY) + + assertContains(0.0..1.0, remoteDevice.read(TestDevice.value)) + } +} \ No newline at end of file diff --git a/controls-modbus/README.md b/controls-modbus/README.md index 78d515a..18c37ee 100644 --- a/controls-modbus/README.md +++ b/controls-modbus/README.md @@ -14,18 +14,16 @@ Automatically checks consistency. ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-modbus:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-modbus:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-modbus:0.2.0") + implementation("space.kscience:controls-modbus:0.3.0") } ``` diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt index 2f56a8a..3b3f91b 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt +++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt @@ -1,15 +1,14 @@ package space.kscience.controls.modbus import com.ghgande.j2mod.modbus.procimg.* -import io.ktor.utils.io.core.buildPacket -import io.ktor.utils.io.core.readByteBuffer -import io.ktor.utils.io.core.writeShort +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.io.Buffer import space.kscience.controls.api.Device -import space.kscience.controls.spec.DevicePropertySpec -import space.kscience.controls.spec.WritableDevicePropertySpec -import space.kscience.controls.spec.set -import space.kscience.controls.spec.useProperty +import space.kscience.controls.ports.readShort +import space.kscience.controls.spec.* +import space.kscience.dataforge.io.Binary public class DeviceProcessImageBuilder internal constructor( @@ -29,10 +28,10 @@ public class DeviceProcessImageBuilder internal constructor( public fun bind( key: ModbusRegistryKey.Coil, - propertySpec: WritableDevicePropertySpec, + propertySpec: MutableDevicePropertySpec, ): ObservableDigitalOut = bind(key) { coil -> coil.addObserver { _, _ -> - device[propertySpec] = coil.isSet + device.writeAsync(propertySpec, coil.isSet) } device.useProperty(propertySpec) { value -> coil.set(value) @@ -89,10 +88,10 @@ public class DeviceProcessImageBuilder internal constructor( public fun bind( key: ModbusRegistryKey.HoldingRegister, - propertySpec: WritableDevicePropertySpec, + propertySpec: MutableDevicePropertySpec, ): ObservableRegister = bind(key) { register -> register.addObserver { _, _ -> - device[propertySpec] = register.toShort() + device.writeAsync(propertySpec, register.toShort()) } device.useProperty(propertySpec) { value -> register.setValue(value) @@ -109,37 +108,63 @@ public class DeviceProcessImageBuilder internal constructor( } device.useProperty(propertySpec) { value -> - val packet = buildPacket { - key.format.writeObject(this, value) - }.readByteBuffer() + val binary = Binary { + key.format.writeTo(this, value) + } registers.forEachIndexed { index, register -> - register.setValue(packet.getShort(index * 2)) + register.setValue(binary.readShort(index * 2)) } } } - public fun bind(key: ModbusRegistryKey.HoldingRange, propertySpec: WritableDevicePropertySpec) { + /** + * Trigger [block] if one of register changes. + */ + private fun List.onChange(block: suspend (Buffer) -> Unit) { + var ready = false + + forEach { register -> + register.addObserver { _, _ -> + ready = true + } + } + + device.launch { + val builder = Buffer() + while (isActive) { + delay(1) + if (ready) { + val packet = builder.apply { + forEach { value -> + writeShort(value.toShort()) + } + } + block(packet) + ready = false + } + } + } + } + + public fun bind(key: ModbusRegistryKey.HoldingRange, propertySpec: MutableDevicePropertySpec) { val registers = List(key.count) { ObservableRegister() } + registers.forEachIndexed { index, register -> - register.addObserver { _, _ -> - val packet = buildPacket { - registers.forEach { value -> - writeShort(value.toShort()) - } - } - device[propertySpec] = key.format.readObject(packet) - } image.addRegister(key.address + index, register) } + registers.onChange { packet -> + device.write(propertySpec, key.format.readFrom(packet)) + } + device.useProperty(propertySpec) { value -> - val packet = buildPacket { - key.format.writeObject(this, value) - }.readByteBuffer() + val binary = Binary { + key.format.writeTo(this, value) + } registers.forEachIndexed { index, observableRegister -> - observableRegister.setValue(packet.getShort(index * 2)) + observableRegister.setValue(binary.readShort(index * 2)) } } } @@ -182,20 +207,17 @@ public class DeviceProcessImageBuilder internal constructor( val registers = List(key.count) { ObservableRegister() } + registers.forEachIndexed { index, register -> - register.addObserver { _, _ -> - val packet = buildPacket { - registers.forEach { value -> - writeShort(value.toShort()) - } - } - device.launch { - device.action(key.format.readObject(packet)) - } - } image.addRegister(key.address + index, register) } + registers.onChange { packet -> + device.launch { + device.action(key.format.readFrom(packet)) + } + } + return registers } @@ -205,14 +227,16 @@ public class DeviceProcessImageBuilder internal constructor( * Bind the device to Modbus slave (server) image. */ public fun D.bindProcessImage( + unitId: Int = 0, openOnBind: Boolean = true, binding: DeviceProcessImageBuilder.() -> Unit, ): ProcessImage { - val image = SimpleProcessImage() + val image = SimpleProcessImage(unitId) DeviceProcessImageBuilder(this, image).apply(binding) + image.setLocked(true) if (openOnBind) { launch { - open() + start() } } return image diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt index ea4330c..2585302 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt +++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt @@ -5,11 +5,10 @@ import com.ghgande.j2mod.modbus.procimg.InputRegister import com.ghgande.j2mod.modbus.procimg.Register import com.ghgande.j2mod.modbus.procimg.SimpleInputRegister import com.ghgande.j2mod.modbus.util.BitVector -import io.ktor.utils.io.core.ByteReadPacket -import io.ktor.utils.io.core.buildPacket -import io.ktor.utils.io.core.readByteBuffer -import io.ktor.utils.io.core.writeShort +import kotlinx.io.Buffer import space.kscience.controls.api.Device +import space.kscience.dataforge.io.Buffer +import space.kscience.dataforge.io.ByteArray import java.nio.ByteBuffer import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty @@ -21,9 +20,9 @@ import kotlin.reflect.KProperty public interface ModbusDevice : Device { /** - * Client id for this specific device + * Unit id for this specific device */ - public val clientId: Int + public val unitId: Int /** * The modubus master connector @@ -45,7 +44,7 @@ public interface ModbusDevice : Device { public operator fun ModbusRegistryKey.InputRange.getValue(thisRef: Any?, property: KProperty<*>): T { val packet = readInputRegistersToPacket(address, count) - return format.readObject(packet) + return format.readFrom(packet) } @@ -61,8 +60,8 @@ public interface ModbusDevice : Device { } public operator fun ModbusRegistryKey.HoldingRange.getValue(thisRef: Any?, property: KProperty<*>): T { - val packet = readInputRegistersToPacket(address, count) - return format.readObject(packet) + val packet = readHoldingRegistersToPacket(address, count) + return format.readFrom(packet) } public operator fun ModbusRegistryKey.HoldingRange.setValue( @@ -70,9 +69,9 @@ public interface ModbusDevice : Device { property: KProperty<*>, value: T, ) { - val buffer = buildPacket { - format.writeObject(this, value) - }.readByteBuffer() + val buffer = ByteArray { + format.writeTo(this, value) + } writeHoldingRegisters(address, buffer) } @@ -82,35 +81,35 @@ public interface ModbusDevice : Device { * Read multiple sequential modbus coils (bit-values) */ public fun ModbusDevice.readCoils(address: Int, count: Int): BitVector = - master.readCoils(clientId, address, count) + master.readCoils(unitId, address, count) public fun ModbusDevice.readCoil(address: Int): Boolean = - master.readCoils(clientId, address, 1).getBit(0) + master.readCoils(unitId, address, 1).getBit(0) public fun ModbusDevice.writeCoils(address: Int, values: BooleanArray) { val bitVector = BitVector(values.size) values.forEachIndexed { index, value -> bitVector.setBit(index, value) } - master.writeMultipleCoils(clientId, address, bitVector) + master.writeMultipleCoils(unitId, address, bitVector) } public fun ModbusDevice.writeCoil(address: Int, value: Boolean) { - master.writeCoil(clientId, address, value) + master.writeCoil(unitId, address, value) } public fun ModbusDevice.writeCoil(key: ModbusRegistryKey.Coil, value: Boolean) { - master.writeCoil(clientId, key.address, value) + master.writeCoil(unitId, key.address, value) } public fun ModbusDevice.readInputDiscretes(address: Int, count: Int): BitVector = - master.readInputDiscretes(clientId, address, count) + master.readInputDiscretes(unitId, address, count) public fun ModbusDevice.readInputDiscrete(address: Int): Boolean = - master.readInputDiscretes(clientId, address, 1).getBit(0) + master.readInputDiscretes(unitId, address, 1).getBit(0) public fun ModbusDevice.readInputRegisters(address: Int, count: Int): List = - master.readInputRegisters(clientId, address, count).toList() + master.readInputRegisters(unitId, address, count).toList() private fun Array.toBuffer(): ByteBuffer { val buffer: ByteBuffer = ByteBuffer.allocate(size * 2) @@ -122,17 +121,17 @@ private fun Array.toBuffer(): ByteBuffer { return buffer } -private fun Array.toPacket(): ByteReadPacket = buildPacket { +private fun Array.toPacket(): Buffer = Buffer { forEach { value -> writeShort(value.toShort()) } } public fun ModbusDevice.readInputRegistersToBuffer(address: Int, count: Int): ByteBuffer = - master.readInputRegisters(clientId, address, count).toBuffer() + master.readInputRegisters(unitId, address, count).toBuffer() -public fun ModbusDevice.readInputRegistersToPacket(address: Int, count: Int): ByteReadPacket = - master.readInputRegisters(clientId, address, count).toPacket() +public fun ModbusDevice.readInputRegistersToPacket(address: Int, count: Int): Buffer = + master.readInputRegisters(unitId, address, count).toPacket() public fun ModbusDevice.readDoubleInput(address: Int): Double = readInputRegistersToBuffer(address, Double.SIZE_BYTES).getDouble() @@ -141,7 +140,7 @@ public fun ModbusDevice.readInputRegister(address: Int): Short = readInputRegisters(address, 1).first().toShort() public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List = - master.readMultipleRegisters(clientId, address, count).toList() + master.readMultipleRegisters(unitId, address, count).toList() /** * Read a number of registers to a [ByteBuffer] @@ -149,10 +148,10 @@ public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List(values.size) { SimpleInputRegister(values[it].toInt()) } ) public fun ModbusDevice.writeHoldingRegister(address: Int, value: Short): Int = master.writeSingleRegister( - clientId, + unitId, address, SimpleInputRegister(value.toInt()) ) @@ -183,8 +182,11 @@ public fun ModbusDevice.writeHoldingRegisters(address: Int, buffer: ByteBuffer): return writeHoldingRegisters(address, array) } -public fun ModbusDevice.writeShortRegister(address: Int, value: Short) { - master.writeSingleRegister(address, SimpleInputRegister(value.toInt())) +public fun ModbusDevice.writeHoldingRegisters(address: Int, byteArray: ByteArray): Int { + val buffer = ByteBuffer.wrap(byteArray) + val array: ShortArray = ShortArray(buffer.limit().floorDiv(2)) { buffer.getShort(it * 2) } + + return writeHoldingRegisters(address, array) } public fun ModbusDevice.modbusRegister( diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt index 916187f..995e0df 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt +++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt @@ -15,21 +15,19 @@ import space.kscience.dataforge.names.NameToken public open class ModbusDeviceBySpec( context: Context, spec: DeviceSpec, - override val clientId: Int, + override val unitId: Int, override val master: AbstractModbusMaster, private val disposeMasterOnClose: Boolean = true, meta: Meta = Meta.EMPTY, ) : ModbusDevice, DeviceBySpec(spec, context, meta){ - override suspend fun open() { + override suspend fun onStart() { master.connect() - super.open() } - override fun close() { + override fun onStop() { if(disposeMasterOnClose){ master.disconnect() } - super.close() } } diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt index a6fc894..1d81d1b 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt +++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt @@ -1,8 +1,15 @@ package space.kscience.controls.modbus +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import space.kscience.dataforge.io.IOFormat +/** + * Modbus registry key + */ public sealed class ModbusRegistryKey { public abstract val address: Int public open val count: Int = 1 @@ -25,6 +32,9 @@ public sealed class ModbusRegistryKey { override fun toString(): String = "InputRegister(address=$address)" } + /** + * A range of read-only register encoding a single value + */ public class InputRange( address: Int, override val count: Int, @@ -36,10 +46,16 @@ public sealed class ModbusRegistryKey { } + /** + * A single read-write register + */ public open class HoldingRegister(override val address: Int) : ModbusRegistryKey() { override fun toString(): String = "HoldingRegister(address=$address)" } + /** + * A range of read-write registers encoding a single value + */ public class HoldingRange( address: Int, override val count: Int, @@ -52,6 +68,9 @@ public sealed class ModbusRegistryKey { } } +/** + * A base class for modbus registers + */ public abstract class ModbusRegistryMap { private val _entries: MutableMap = mutableMapOf() @@ -63,36 +82,56 @@ public abstract class ModbusRegistryMap { return key } + /** + * Register a [ModbusRegistryKey.Coil] key and return it + */ protected fun coil(address: Int, description: String = ""): ModbusRegistryKey.Coil = register(ModbusRegistryKey.Coil(address), description) + /** + * Register a [ModbusRegistryKey.DiscreteInput] key and return it + */ protected fun discrete(address: Int, description: String = ""): ModbusRegistryKey.DiscreteInput = register(ModbusRegistryKey.DiscreteInput(address), description) + /** + * Register a [ModbusRegistryKey.InputRegister] key and return it + */ protected fun input(address: Int, description: String = ""): ModbusRegistryKey.InputRegister = register(ModbusRegistryKey.InputRegister(address), description) + /** + * Register a [ModbusRegistryKey.InputRange] key and return it + */ protected fun input( address: Int, count: Int, reader: IOFormat, description: String = "", - ): ModbusRegistryKey.InputRange = - register(ModbusRegistryKey.InputRange(address, count, reader), description) + ): ModbusRegistryKey.InputRange = register(ModbusRegistryKey.InputRange(address, count, reader), description) + /** + * Register a [ModbusRegistryKey.HoldingRegister] key and return it + */ protected fun register(address: Int, description: String = ""): ModbusRegistryKey.HoldingRegister = register(ModbusRegistryKey.HoldingRegister(address), description) + /** + * Register a [ModbusRegistryKey.HoldingRange] key and return it + */ protected fun register( address: Int, count: Int, format: IOFormat, description: String = "", - ): ModbusRegistryKey.HoldingRange = - register(ModbusRegistryKey.HoldingRange(address, count, format), description) + ): ModbusRegistryKey.HoldingRange = register(ModbusRegistryKey.HoldingRange(address, count, format), description) public companion object { + + /** + * Validate the register map. Throw an error if the map is invalid + */ public fun validate(map: ModbusRegistryMap) { var lastCoil: ModbusRegistryKey.Coil? = null var lastDiscreteInput: ModbusRegistryKey.DiscreteInput? = null @@ -127,36 +166,62 @@ public abstract class ModbusRegistryMap { } } - private val ModbusRegistryKey.sectionNumber - get() = when (this) { - is ModbusRegistryKey.Coil -> 1 - is ModbusRegistryKey.DiscreteInput -> 2 - is ModbusRegistryKey.HoldingRegister -> 4 - is ModbusRegistryKey.InputRegister -> 3 - } + } +} - public fun print(map: ModbusRegistryMap, to: Appendable = System.out) { - validate(map) - map.entries.entries - .sortedWith( - Comparator.comparingInt?> { it.key.sectionNumber } - .thenComparingInt { it.key.address } - ) - .forEach { (key, description) -> - val typeString = when (key) { - is ModbusRegistryKey.Coil -> "Coil" - is ModbusRegistryKey.DiscreteInput -> "Discrete" - is ModbusRegistryKey.HoldingRegister -> "Register" - is ModbusRegistryKey.InputRegister -> "Input" - } - val rangeString = if (key.count == 1) { - key.address.toString() - } else { - "${key.address} - ${key.address + key.count}" - } - to.appendLine("${typeString}\t$rangeString\t$description") - } +private val ModbusRegistryKey.sectionNumber + get() = when (this) { + is ModbusRegistryKey.Coil -> 1 + is ModbusRegistryKey.DiscreteInput -> 2 + is ModbusRegistryKey.HoldingRegister -> 4 + is ModbusRegistryKey.InputRegister -> 3 + } + +public fun ModbusRegistryMap.print(to: Appendable = System.out) { + ModbusRegistryMap.validate(this) + entries.entries + .sortedWith( + Comparator.comparingInt?> { it.key.sectionNumber } + .thenComparingInt { it.key.address } + ) + .forEach { (key, description) -> + val typeString = when (key) { + is ModbusRegistryKey.Coil -> "Coil" + is ModbusRegistryKey.DiscreteInput -> "Discrete" + is ModbusRegistryKey.HoldingRegister -> "Register" + is ModbusRegistryKey.InputRegister -> "Input" + } + val rangeString = if (key.count == 1) { + key.address.toString() + } else { + "${key.address} - ${key.address + key.count - 1}" + } + to.appendLine("${typeString}\t$rangeString\t$description") } +} + +public fun ModbusRegistryMap.toJson(): JsonArray = buildJsonArray { + ModbusRegistryMap.validate(this@toJson) + entries.forEach { (key, description) -> + + val entry = buildJsonObject { + put( + "type", + when (key) { + is ModbusRegistryKey.Coil -> "Coil" + is ModbusRegistryKey.DiscreteInput -> "Discrete" + is ModbusRegistryKey.HoldingRegister -> "Register" + is ModbusRegistryKey.InputRegister -> "Input" + } + ) + put("address", key.address) + if (key.count > 1) { + put("count", key.count) + } + put("description", description) + } + + add(entry) } } diff --git a/controls-opcua/README.md b/controls-opcua/README.md index 8cdd373..3befbb9 100644 --- a/controls-opcua/README.md +++ b/controls-opcua/README.md @@ -12,18 +12,16 @@ A client and server connectors for OPC-UA via Eclipse Milo ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-opcua:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-opcua:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-opcua:0.2.0") + implementation("space.kscience:controls-opcua:0.3.0") } ``` diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt index 574343e..48aa6dc 100644 --- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt +++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt @@ -56,6 +56,7 @@ internal class MetaEnumCodec : OpcUaBinaryDataTypeCodec { internal fun opcToMeta(value: Any?): Meta = when (value) { null -> Meta(Null) + is Variant -> opcToMeta(value.value) is Meta -> value is Value -> Meta(value) is Number -> when (value) { @@ -79,12 +80,17 @@ internal fun opcToMeta(value: Any?): Meta = when (value) { "text" put value.text?.asValue() } is DataValue -> Meta { - "value" put opcToMeta(value.value) // need SerializationContext to do that properly - value.statusCode?.value?.let { "status" put Meta(it.asValue()) } - value.sourceTime?.javaInstant?.let { "sourceTime" put it.toKotlinInstant().toMeta() } - value.sourcePicoseconds?.let { "sourcePicoseconds" put Meta(it.asValue()) } - value.serverTime?.javaInstant?.let { "serverTime" put it.toKotlinInstant().toMeta() } - value.serverPicoseconds?.let { "serverPicoseconds" put Meta(it.asValue()) } + val variant= opcToMeta(value.value) + update(variant)// need SerializationContext to do that properly + //TODO remove after DF 0.7.2 + this.value = variant.value + "@opc" put { + value.statusCode?.value?.let { "status" put Meta(it.asValue()) } + value.sourceTime?.javaInstant?.let { "sourceTime" put it.toKotlinInstant().toMeta() } + value.sourcePicoseconds?.let { "sourcePicoseconds" put Meta(it.asValue()) } + value.serverTime?.javaInstant?.let { "serverTime" put it.toKotlinInstant().toMeta() } + value.serverPicoseconds?.let { "serverPicoseconds" put Meta(it.asValue()) } + } } is ByteString -> Meta(value.bytesOrEmpty().asValue()) is XmlElement -> Meta(value.fragment?.asValue() ?: Null) @@ -107,7 +113,7 @@ internal class MetaStructureCodec( override fun createStructure(name: String, members: LinkedHashMap): Meta = Meta { members.forEach { (property: String, value: Meta?) -> - setMeta(Name.parse(property), value) + set(Name.parse(property), value) } } @@ -147,7 +153,7 @@ internal class MetaStructureCodec( "Float" -> member.value?.numberOrNull?.toFloat() "Double" -> member.value?.numberOrNull?.toDouble() "String" -> member.string - "DateTime" -> DateTime(member.instant().toJavaInstant()) + "DateTime" -> member.instant?.toJavaInstant()?.let { DateTime(it) } "Guid" -> member.string?.let { UUID.fromString(it) } "ByteString" -> member.value?.list?.let { list -> ByteString(list.map { it.number.toByte() }.toByteArray()) diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt index dd83e58..08a84b0 100644 --- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt +++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt @@ -9,8 +9,8 @@ import org.eclipse.milo.opcua.stack.core.types.builtin.* import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn import space.kscience.controls.api.Device import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter import space.kscience.dataforge.meta.MetaSerializer -import space.kscience.dataforge.meta.transformations.MetaConverter import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty @@ -43,7 +43,7 @@ public suspend inline fun OpcUaDevice.readOpcWithTime( else -> error("Incompatible OPC property value $content") } - val res: T = converter.metaToObject(meta) ?: error("Meta $meta could not be converted to ${T::class}") + val res: T = converter.read(meta) return res to time } @@ -69,7 +69,7 @@ public suspend inline fun OpcUaDevice.readOpc( else -> error("Incompatible OPC property value $content") } - return converter.metaToObject(meta) ?: error("Meta $meta could not be converted to ${T::class}") + return converter.readOrNull(meta) ?: error("Meta $meta could not be converted to ${T::class}") } public suspend inline fun OpcUaDevice.writeOpc( @@ -77,7 +77,7 @@ public suspend inline fun OpcUaDevice.writeOpc( converter: MetaConverter, value: T ): StatusCode { - val meta = converter.objectToMeta(value) + val meta = converter.convert(value) return client.writeValue(nodeId, DataValue(Variant(meta))).await() } diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt index eb9b688..7debc0e 100644 --- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt +++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt @@ -31,7 +31,7 @@ public class MiloConfiguration : Scheme() { public var endpointUrl: String by string { error("Endpoint url is not defined") } - public var username: MiloUsername? by specOrNull(MiloUsername) + public var username: MiloUsername? by schemeOrNull(MiloUsername) public var securityPolicy: SecurityPolicy by enum(SecurityPolicy.None) @@ -63,8 +63,7 @@ public open class OpcUaDeviceBySpec( } } - override fun close() { + override fun onStop() { client.disconnect() - super.close() } } diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt index 010c2c0..aa73890 100644 --- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt +++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt @@ -2,7 +2,6 @@ package space.kscience.controls.opcua.server import kotlinx.coroutines.launch import kotlinx.datetime.toJavaInstant -import kotlinx.serialization.json.Json import org.eclipse.milo.opcua.sdk.core.AccessLevel import org.eclipse.milo.opcua.sdk.core.Reference import org.eclipse.milo.opcua.sdk.server.Lifecycle @@ -19,19 +18,17 @@ import org.eclipse.milo.opcua.stack.core.AttributeId import org.eclipse.milo.opcua.stack.core.Identifiers import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText -import space.kscience.controls.api.Device -import space.kscience.controls.api.DeviceHub -import space.kscience.controls.api.PropertyDescriptor -import space.kscience.controls.api.onPropertyChange +import space.kscience.controls.api.* import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.opcua.client.opcToMeta import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.MetaSerializer import space.kscience.dataforge.meta.ValueType import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.plus -public operator fun Device.get(propertyDescriptor: PropertyDescriptor): Meta? = getProperty(propertyDescriptor.name) +public operator fun CachingDevice.get(propertyDescriptor: PropertyDescriptor): Meta? = + getProperty(propertyDescriptor.name) public suspend fun Device.read(propertyDescriptor: PropertyDescriptor): Meta = readProperty(propertyDescriptor.name) @@ -41,29 +38,11 @@ https://github.com/eclipse/milo/blob/master/milo-examples/server-examples/src/ma public class DeviceNameSpace( server: OpcUaServer, - public val deviceManager: DeviceManager + public val deviceManager: DeviceManager, ) : ManagedNamespaceWithLifecycle(server, NAMESPACE_URI) { private val subscription = SubscriptionModel(server, this) - init { - lifecycleManager.addLifecycle(subscription) - - lifecycleManager.addStartupTask { - nodeContext.registerHub(deviceManager, Name.EMPTY) - } - - lifecycleManager.addLifecycle(object : Lifecycle { - override fun startup() { - server.addressSpaceManager.register(this@DeviceNameSpace) - } - - override fun shutdown() { - server.addressSpaceManager.unregister(this@DeviceNameSpace) - } - }) - } - private fun UaFolderNode.registerDeviceNodes(deviceName: Name, device: Device) { val nodes = device.propertyDescriptors.associate { descriptor -> val propertyName = descriptor.name @@ -73,18 +52,21 @@ public class DeviceNameSpace( //for now, use DF paths as ids nodeId = newNodeId("${deviceName.tokens.joinToString("/")}/$propertyName") when { - descriptor.readable && descriptor.writable -> { + descriptor.readable && descriptor.mutable -> { setAccessLevel(AccessLevel.READ_WRITE) setUserAccessLevel(AccessLevel.READ_WRITE) } - descriptor.writable -> { + + descriptor.mutable -> { setAccessLevel(AccessLevel.WRITE_ONLY) setUserAccessLevel(AccessLevel.WRITE_ONLY) } + descriptor.readable -> { setAccessLevel(AccessLevel.READ_ONLY) setUserAccessLevel(AccessLevel.READ_ONLY) } + else -> { setAccessLevel(AccessLevel.NONE) setUserAccessLevel(AccessLevel.NONE) @@ -93,7 +75,7 @@ public class DeviceNameSpace( browseName = newQualifiedName(propertyName) displayName = LocalizedText.english(propertyName) - dataType = if (descriptor.metaDescriptor.children.isNotEmpty()) { + dataType = if (descriptor.metaDescriptor.nodes.isNotEmpty()) { Identifiers.String } else when (descriptor.metaDescriptor.valueTypes?.first()) { null, ValueType.STRING, ValueType.NULL -> Identifiers.String @@ -106,25 +88,24 @@ public class DeviceNameSpace( setTypeDefinition(Identifiers.BaseDataVariableType) }.build() - - device[descriptor]?.toOpc(sourceTime = null, serverTime = null)?.let { - node.value = it + // Update initial value, but only if it is cached + if (device is CachingDevice) { + device[descriptor]?.toOpc(sourceTime = null, serverTime = null)?.let { + node.value = it + } } - /** - * Subscribe to node value changes - */ - node.addAttributeObserver { _: UaNode, attributeId: AttributeId, value: Any -> - if (attributeId == AttributeId.Value) { - val meta: Meta = when (value) { - is Meta -> value - is Boolean -> Meta(value) - is Number -> Meta(value) - is String -> Json.decodeFromString(MetaSerializer, value) - else -> return@addAttributeObserver //TODO("other types not implemented") - } - deviceManager.context.launch { - device.writeProperty(propertyName, meta) + if (descriptor.mutable) { + + /** + * Subscribe to node value changes + */ + node.addAttributeObserver { _: UaNode, attributeId: AttributeId, value: Any? -> + if (attributeId == AttributeId.Value) { + val meta: Meta = opcToMeta(value) + deviceManager.context.launch { + device.writeProperty(propertyName, meta) + } } } } @@ -137,8 +118,11 @@ public class DeviceNameSpace( //Subscribe on properties updates device.onPropertyChange { nodes[property]?.let { node -> - val sourceTime = time?.let { DateTime(it.toJavaInstant()) } - node.value = value.toOpc(sourceTime = sourceTime) + val sourceTime = DateTime(time.toJavaInstant()) + val newValue = value.toOpc(sourceTime = sourceTime) + if (node.value.value != newValue.value) { + node.value = newValue + } } } //recursively add sub-devices @@ -169,6 +153,24 @@ public class DeviceNameSpace( } } + init { + lifecycleManager.addLifecycle(subscription) + + lifecycleManager.addStartupTask { + nodeContext.registerHub(deviceManager, Name.EMPTY) + } + + lifecycleManager.addLifecycle(object : Lifecycle { + override fun startup() { + server.addressSpaceManager.register(this@DeviceNameSpace) + } + + override fun shutdown() { + server.addressSpaceManager.unregister(this@DeviceNameSpace) + } + }) + } + override fun onDataItemsCreated(dataItems: List?) { subscription.onDataItemsCreated(dataItems) } diff --git a/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt b/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt index eedafe7..b4d63ad 100644 --- a/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt +++ b/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test import space.kscience.controls.spec.DeviceSpec import space.kscience.controls.spec.doubleProperty import space.kscience.controls.spec.read -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.dataforge.meta.MetaConverter import kotlin.test.Ignore class OpcUaClientTest { @@ -29,7 +29,7 @@ class OpcUaClientTest { return DemoOpcUaDevice(config) } - val randomDouble by doubleProperty(read = DemoOpcUaDevice::readRandomDouble) + val randomDouble by doubleProperty { readRandomDouble() } } @@ -40,9 +40,10 @@ class OpcUaClientTest { @Test @Ignore fun testReadDouble() = runTest { - DemoOpcUaDevice.build().use{ - println(it.read(DemoOpcUaDevice.randomDouble)) - } + val device = DemoOpcUaDevice.build() + device.start() + println(device.read(DemoOpcUaDevice.randomDouble)) + device.stop() } } \ No newline at end of file diff --git a/controls-pi/README.md b/controls-pi/README.md index cd9ee0a..6235995 100644 --- a/controls-pi/README.md +++ b/controls-pi/README.md @@ -6,18 +6,16 @@ Utils to work with controls-kt on Raspberry pi ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-pi:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-pi:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-pi:0.2.0") + implementation("space.kscience:controls-pi:0.3.0") } ``` diff --git a/controls-pi/api/controls-pi.api b/controls-pi/api/controls-pi.api index 2fdaf2d..34bfd53 100644 --- a/controls-pi/api/controls-pi.api +++ b/controls-pi/api/controls-pi.api @@ -1,6 +1,10 @@ public final class space/kscience/controls/pi/PiPlugin : space/kscience/dataforge/context/AbstractPlugin { public static final field Companion Lspace/kscience/controls/pi/PiPlugin$Companion; public fun ()V + public fun content (Ljava/lang/String;)Ljava/util/Map; + public fun detach ()V + public final fun getDevices ()Lspace/kscience/controls/manager/DeviceManager; + public final fun getPiContext ()Lcom/pi4j/context/Context; public final fun getPorts ()Lspace/kscience/controls/ports/Ports; public fun getTag ()Lspace/kscience/dataforge/context/PluginTag; } @@ -8,15 +12,16 @@ public final class space/kscience/controls/pi/PiPlugin : space/kscience/dataforg public final class space/kscience/controls/pi/PiPlugin$Companion : space/kscience/dataforge/context/PluginFactory { public synthetic fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object; public fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/pi/PiPlugin; + public final fun createPiContext (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lcom/pi4j/context/Context; public fun getTag ()Lspace/kscience/dataforge/context/PluginTag; } public final class space/kscience/controls/pi/PiSerialPort : space/kscience/controls/ports/AbstractPort { public static final field Companion Lspace/kscience/controls/pi/PiSerialPort$Companion; - public fun (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;)V - public synthetic fun (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V - public final fun getSerialBuilder ()Lkotlin/jvm/functions/Function0; + public final fun getSerialBuilder ()Lkotlin/jvm/functions/Function1; } public final class space/kscience/controls/pi/PiSerialPort$Companion : space/kscience/controls/ports/PortFactory { diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt new file mode 100644 index 0000000..1aee657 --- /dev/null +++ b/controls-pi/src/main/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt @@ -0,0 +1,95 @@ +package space.kscience.controls.pi + +import com.pi4j.io.serial.Baud +import com.pi4j.io.serial.Serial +import com.pi4j.io.serial.SerialConfigBuilder +import com.pi4j.ktx.io.serial +import kotlinx.coroutines.* +import space.kscience.controls.ports.AbstractAsynchronousPort +import space.kscience.controls.ports.AsynchronousPort +import space.kscience.controls.ports.copyToArray +import space.kscience.dataforge.context.* +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.enum +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.string +import java.nio.ByteBuffer +import kotlin.coroutines.CoroutineContext + +public class AsynchronousPiPort( + context: Context, + meta: Meta, + private val serial: Serial, + coroutineContext: CoroutineContext = context.coroutineContext, +) : AbstractAsynchronousPort(context, meta, coroutineContext) { + + + private var listenerJob: Job? = null + override fun onOpen() { + serial.open() + listenerJob = this.scope.launch(Dispatchers.IO) { + val buffer = ByteBuffer.allocate(1024) + while (isActive) { + try { + val num = serial.read(buffer) + if (num > 0) { + receive(buffer.copyToArray(num)) + } + if (num < 0) cancel("The input channel is exhausted") + } catch (ex: Exception) { + logger.error(ex) { "Channel read error" } + delay(1000) + } + } + } + } + + override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) { + serial.write(data) + } + + + override val isOpen: Boolean get() = listenerJob?.isActive == true + + override fun close() { + listenerJob?.cancel() + serial.close() + } + + public companion object : Factory { + + + public fun build( + context: Context, + device: String, + block: SerialConfigBuilder.() -> Unit, + ): AsynchronousPiPort { + val meta = Meta { + "name" put "pi://$device" + "type" put "serial" + } + val pi = context.request(PiPlugin) + + val serial = pi.piContext.serial(device, block) + return AsynchronousPiPort(context, meta, serial) + } + + public fun open( + context: Context, + device: String, + block: SerialConfigBuilder.() -> Unit, + ): AsynchronousPiPort = build(context, device, block).apply { open() } + + override fun build(context: Context, meta: Meta): AsynchronousPort { + val device: String = meta["device"].string ?: error("Device name not defined") + val baudRate: Baud = meta["baudRate"].enum() ?: Baud._9600 + val pi = context.request(PiPlugin) + val serial = pi.piContext.serial(device) { + baud8N1(baudRate) + } + return AsynchronousPiPort(context, meta, serial) + } + + } +} + diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt index 547a142..0f2bb02 100644 --- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt +++ b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt @@ -1,22 +1,49 @@ package space.kscience.controls.pi +import com.pi4j.Pi4J +import space.kscience.controls.manager.DeviceManager import space.kscience.controls.ports.Ports import space.kscience.dataforge.context.AbstractPlugin import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.PluginFactory import space.kscience.dataforge.context.PluginTag import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName +import com.pi4j.context.Context as PiContext public class PiPlugin : AbstractPlugin() { public val ports: Ports by require(Ports) + public val devices: DeviceManager by require(DeviceManager) override val tag: PluginTag get() = Companion.tag + public val piContext: PiContext by lazy { createPiContext(context, meta) } + + override fun content(target: String): Map = when (target) { + Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf( + "serial".asName() to AsynchronousPiPort, + ) + Ports.SYNCHRONOUS_PORT_TYPE -> mapOf( + "serial".asName() to SynchronousPiPort, + ) + + else -> super.content(target) + } + + override fun detach() { + piContext.shutdown() + super.detach() + } + public companion object : PluginFactory { override val tag: PluginTag = PluginTag("controls.ports.pi", group = PluginTag.DATAFORGE_GROUP) override fun build(context: Context, meta: Meta): PiPlugin = PiPlugin() + @Suppress("UNUSED_PARAMETER") + public fun createPiContext(context: Context, meta: Meta): PiContext = Pi4J.newAutoContext() + } } \ No newline at end of file diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt deleted file mode 100644 index 4924b8d..0000000 --- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt +++ /dev/null @@ -1,75 +0,0 @@ -package space.kscience.controls.pi - -import com.pi4j.Pi4J -import com.pi4j.io.serial.Baud -import com.pi4j.io.serial.Serial -import com.pi4j.io.serial.SerialConfigBuilder -import com.pi4j.ktx.io.serial -import kotlinx.coroutines.* -import space.kscience.controls.ports.AbstractPort -import space.kscience.controls.ports.Port -import space.kscience.controls.ports.PortFactory -import space.kscience.controls.ports.toArray -import space.kscience.dataforge.context.Context -import space.kscience.dataforge.context.error -import space.kscience.dataforge.context.logger -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.enum -import space.kscience.dataforge.meta.get -import space.kscience.dataforge.meta.string -import java.nio.ByteBuffer -import kotlin.coroutines.CoroutineContext - -public class PiSerialPort( - context: Context, - coroutineContext: CoroutineContext = context.coroutineContext, - public val serialBuilder: () -> Serial, -) : AbstractPort(context, coroutineContext) { - - private val serial: Serial by lazy { serialBuilder() } - - - private val listenerJob = this.scope.launch(Dispatchers.IO) { - val buffer = ByteBuffer.allocate(1024) - while (isActive) { - try { - val num = serial.read(buffer) - if (num > 0) { - receive(buffer.toArray(num)) - } - if (num < 0) cancel("The input channel is exhausted") - } catch (ex: Exception) { - logger.error(ex) { "Channel read error" } - delay(1000) - } - } - } - - override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) { - serial.write(data) - } - - override fun close() { - listenerJob.cancel() - serial.close() - } - - public companion object : PortFactory { - override val type: String get() = "pi" - - public fun open(context: Context, device: String, block: SerialConfigBuilder.() -> Unit): PiSerialPort = - PiSerialPort(context) { - Pi4J.newAutoContext().serial(device, block) - } - - override fun build(context: Context, meta: Meta): Port = PiSerialPort(context) { - val device: String = meta["device"].string ?: error("Device name not defined") - val baudRate: Baud = meta["baudRate"].enum() ?: Baud._9600 - Pi4J.newAutoContext().serial(device) { - baud8N1(baudRate) - } - } - - } -} - diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt new file mode 100644 index 0000000..6bc590c --- /dev/null +++ b/controls-pi/src/main/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt @@ -0,0 +1,107 @@ +package space.kscience.controls.pi + +import com.pi4j.io.serial.Baud +import com.pi4j.io.serial.Serial +import com.pi4j.io.serial.SerialConfigBuilder +import com.pi4j.ktx.io.serial +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import space.kscience.controls.ports.SynchronousPort +import space.kscience.controls.ports.copyToArray +import space.kscience.dataforge.context.* +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.enum +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.string +import java.nio.ByteBuffer + +public class SynchronousPiPort( + override val context: Context, + public val meta: Meta, + private val serial: Serial, + private val mutex: Mutex = Mutex(), +) : SynchronousPort { + + private val pi = context.request(PiPlugin) + override fun open() { + serial.open() + } + + override val isOpen: Boolean get() = serial.isOpen + + override suspend fun respond( + request: ByteArray, + transform: suspend Flow.() -> R, + ): R = mutex.withLock { + serial.drain() + serial.write(request) + flow { + val buffer = ByteBuffer.allocate(1024) + while (isOpen) { + try { + val num = serial.read(buffer) + if (num > 0) { + emit(buffer.copyToArray(num)) + } + if (num < 0) break + } catch (ex: Exception) { + logger.error(ex) { "Channel read error" } + delay(1000) + } + } + }.transform() + } + + override suspend fun respondFixedMessageSize(request: ByteArray, responseSize: Int): ByteArray = mutex.withLock { + runInterruptible { + serial.drain() + serial.write(request) + serial.readNBytes(responseSize) + } + } + + override fun close() { + serial.close() + } + + public companion object : Factory { + + + public fun build( + context: Context, + device: String, + block: SerialConfigBuilder.() -> Unit, + ): SynchronousPiPort { + val meta = Meta { + "name" put "pi://$device" + "type" put "serial" + } + val pi = context.request(PiPlugin) + + val serial = pi.piContext.serial(device, block) + return SynchronousPiPort(context, meta, serial) + } + + public fun open( + context: Context, + device: String, + block: SerialConfigBuilder.() -> Unit, + ): SynchronousPiPort = build(context, device, block).apply { open() } + + override fun build(context: Context, meta: Meta): SynchronousPiPort { + val device: String = meta["device"].string ?: error("Device name not defined") + val baudRate: Baud = meta["baudRate"].enum() ?: Baud._9600 + val pi = context.request(PiPlugin) + val serial = pi.piContext.serial(device) { + baud8N1(baudRate) + } + return SynchronousPiPort(context, meta, serial) + } + + } +} + diff --git a/controls-plc4x/build.gradle.kts b/controls-plc4x/build.gradle.kts new file mode 100644 index 0000000..e7b063c --- /dev/null +++ b/controls-plc4x/build.gradle.kts @@ -0,0 +1,24 @@ +import space.kscience.gradle.Maturity + +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +val plc4xVersion = "0.12.0" + +description = """ + A plugin for Controls-kt device server on top of plc4x library +""".trimIndent() + +kscience { + jvm() + jvmMain { + api(projects.controlsCore) + api("org.apache.plc4x:plc4j-spi:$plc4xVersion") + } +} + +readme { + maturity = Maturity.EXPERIMENTAL +} \ No newline at end of file diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt new file mode 100644 index 0000000..9b08538 --- /dev/null +++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt @@ -0,0 +1,76 @@ +package space.kscience.controls.plc4x + +import kotlinx.coroutines.future.await +import org.apache.plc4x.java.api.PlcConnection +import org.apache.plc4x.java.api.messages.PlcBrowseItem +import org.apache.plc4x.java.api.messages.PlcTagResponse +import org.apache.plc4x.java.api.messages.PlcWriteRequest +import org.apache.plc4x.java.api.messages.PlcWriteResponse +import org.apache.plc4x.java.api.types.PlcResponseCode +import space.kscience.controls.api.Device +import space.kscience.dataforge.meta.Meta + +private val PlcTagResponse.responseCodes: Map + get() = tagNames.associateWith { getResponseCode(it) } + +private val Map.isOK get() = values.all { it == PlcResponseCode.OK } + +public class PlcException(public val codes: Map) : Exception() { + override val message: String + get() = "Plc request unsuccessful:" + codes.entries.joinToString(prefix = "\n\t", separator = "\n\t") { + "${it.key}: ${it.value.name}" + } +} + +private fun PlcTagResponse.throwOnFail() { + val codes = responseCodes + if (!codes.isOK) throw PlcException(codes) +} + + +public interface Plc4XDevice : Device { + public val connection: PlcConnection +} + + +/** + * Send ping request and suspend until it comes back + */ +public suspend fun Plc4XDevice.ping(): PlcResponseCode = connection.ping().await().responseCode + +/** + * Send browse request to list available tags + */ +public suspend fun Plc4XDevice.browse(): Map> { + require(connection.metadata.isBrowseSupported){"Browse actions are not supported on connection"} + val request = connection.browseRequestBuilder().build() + val response = request.execute().await() + + return response.queryNames.associateWith { response.getValues(it) } +} + +/** + * Send read request and suspend until it returns. Throw a [PlcException] if at least one tag read fails. + * + * @throws PlcException + */ +public suspend fun Plc4XDevice.read(plc4xProperty: Plc4xProperty): Meta = with(plc4xProperty) { + require(connection.metadata.isReadSupported) {"Read actions are not supported on connections"} + val request = connection.readRequestBuilder().request().build() + val response = request.execute().await() + response.throwOnFail() + response.readProperty() +} + + +/** + * Send write request and suspend until it finishes. Throw a [PlcException] if at least one tag write fails. + * + * @throws PlcException + */ +public suspend fun Plc4XDevice.write(plc4xProperty: Plc4xProperty, value: Meta): Unit = with(plc4xProperty) { + require(connection.metadata.isWriteSupported){"Write actions are not supported on connection"} + val request: PlcWriteRequest = connection.writeRequestBuilder().writeProperty(value).build() + val response: PlcWriteResponse = request.execute().await() + response.throwOnFail() +} \ No newline at end of file diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDeviceBase.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDeviceBase.kt new file mode 100644 index 0000000..e25a001 --- /dev/null +++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDeviceBase.kt @@ -0,0 +1,22 @@ +package space.kscience.controls.plc4x + +import org.apache.plc4x.java.api.PlcConnection +import space.kscience.controls.spec.DeviceActionSpec +import space.kscience.controls.spec.DeviceBase +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.Meta + +public class Plc4XDeviceBase( + context: Context, + meta: Meta, + override val connection: PlcConnection, +) : Plc4XDevice, DeviceBase(context, meta) { + override val properties: Map> + get() = TODO("Not yet implemented") + override val actions: Map> = emptyMap() + + override fun toString(): String { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt new file mode 100644 index 0000000..cfeedd2 --- /dev/null +++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt @@ -0,0 +1,39 @@ +package space.kscience.controls.plc4x + +import org.apache.plc4x.java.api.messages.PlcReadRequest +import org.apache.plc4x.java.api.messages.PlcReadResponse +import org.apache.plc4x.java.api.messages.PlcWriteRequest +import org.apache.plc4x.java.api.types.PlcValueType +import space.kscience.dataforge.meta.Meta + +public interface Plc4xProperty { + + public val keys: Set + + public fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder + + public fun PlcReadResponse.readProperty(): Meta + + public fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder +} + +private class DefaultPlc4xProperty( + private val address: String, + private val plcValueType: PlcValueType, + private val name: String = "@default", +) : Plc4xProperty { + + override val keys: Set = setOf(name) + + override fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder = + addTagAddress(name, address) + + override fun PlcReadResponse.readProperty(): Meta = + getPlcValue(name).toMeta() + + override fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder = + addTagAddress(name, address, meta.toPlcValue(plcValueType)) +} + +public fun Plc4xProperty(address: String, plcValueType: PlcValueType, name: String = "@default"): Plc4xProperty = + DefaultPlc4xProperty(address, plcValueType, name) \ No newline at end of file diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/plc4xConnector.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/plc4xConnector.kt new file mode 100644 index 0000000..5d0b2de --- /dev/null +++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/plc4xConnector.kt @@ -0,0 +1,123 @@ +package space.kscience.controls.plc4x + +import org.apache.plc4x.java.api.types.PlcValueType +import org.apache.plc4x.java.api.value.PlcValue +import org.apache.plc4x.java.spi.values.* +import space.kscience.dataforge.meta.* +import space.kscience.dataforge.names.asName +import java.math.BigInteger + +internal fun PlcValue.toMeta(): Meta = Meta { + when (plcValueType) { + null, PlcValueType.NULL -> value = Null + PlcValueType.BOOL -> value = this@toMeta.boolean.asValue() + PlcValueType.BYTE -> this@toMeta.byte.asValue() + PlcValueType.WORD -> this@toMeta.short.asValue() + PlcValueType.DWORD -> this@toMeta.int.asValue() + PlcValueType.LWORD -> this@toMeta.long.asValue() + PlcValueType.USINT -> this@toMeta.short.asValue() + PlcValueType.UINT -> this@toMeta.int.asValue() + PlcValueType.UDINT -> this@toMeta.long.asValue() + PlcValueType.ULINT -> this@toMeta.bigInteger.asValue() + PlcValueType.SINT -> this@toMeta.byte.asValue() + PlcValueType.INT -> this@toMeta.short.asValue() + PlcValueType.DINT -> this@toMeta.int.asValue() + PlcValueType.LINT -> this@toMeta.long.asValue() + PlcValueType.REAL -> this@toMeta.float.asValue() + PlcValueType.LREAL -> this@toMeta.double.asValue() + PlcValueType.CHAR -> this@toMeta.int.asValue() + PlcValueType.WCHAR -> this@toMeta.short.asValue() + PlcValueType.STRING -> this@toMeta.string.asValue() + PlcValueType.WSTRING -> this@toMeta.string.asValue() + PlcValueType.TIME -> this@toMeta.duration.toString().asValue() + PlcValueType.LTIME -> this@toMeta.duration.toString().asValue() + PlcValueType.DATE -> this@toMeta.date.toString().asValue() + PlcValueType.LDATE -> this@toMeta.date.toString().asValue() + PlcValueType.TIME_OF_DAY -> this@toMeta.time.toString().asValue() + PlcValueType.LTIME_OF_DAY -> this@toMeta.time.toString().asValue() + PlcValueType.DATE_AND_TIME -> this@toMeta.dateTime.toString().asValue() + PlcValueType.DATE_AND_LTIME -> this@toMeta.dateTime.toString().asValue() + PlcValueType.LDATE_AND_TIME -> this@toMeta.dateTime.toString().asValue() + PlcValueType.Struct -> this@toMeta.struct.forEach { (name, item) -> + set(name, item.toMeta()) + } + + PlcValueType.List -> { + val listOfMeta = this@toMeta.list.map { it.toMeta() } + if (listOfMeta.all { it.items.isEmpty() }) { + value = listOfMeta.map { it.value ?: Null }.asValue() + } else { + setIndexed("@list".asName(), list.map { it.toMeta() }) + } + } + + PlcValueType.RAW_BYTE_ARRAY -> this@toMeta.raw.asValue() + } +} + +private fun Value.toPlcValue(): PlcValue = when (type) { + ValueType.NUMBER -> when (val number = number) { + is Short -> PlcINT(number.toShort()) + is Int -> PlcDINT(number.toInt()) + is Long -> PlcLINT(number.toLong()) + is Float -> PlcREAL(number.toFloat()) + else -> PlcLREAL(number.toDouble()) + } + + ValueType.STRING -> PlcSTRING(string) + ValueType.BOOLEAN -> PlcBOOL(boolean) + ValueType.NULL -> PlcNull() + ValueType.LIST -> TODO() +} + +internal fun Meta.toPlcValue(hint: PlcValueType): PlcValue = when (hint) { + PlcValueType.Struct -> PlcStruct( + items.entries.associate { (token, item) -> + token.toString() to item.toPlcValue(PlcValueType.Struct) + } + ) + + PlcValueType.NULL -> PlcNull() + PlcValueType.BOOL -> PlcBOOL(boolean) + PlcValueType.BYTE -> PlcBYTE(int) + PlcValueType.WORD -> PlcWORD(int) + PlcValueType.DWORD -> PlcDWORD(int) + PlcValueType.LWORD -> PlcLWORD(long) + PlcValueType.USINT -> PlcLWORD(short) + PlcValueType.UINT -> PlcUINT(int) + PlcValueType.UDINT -> PlcDINT(long) + PlcValueType.ULINT -> (number as? BigInteger)?.let { PlcULINT(it) } ?: PlcULINT(long) + PlcValueType.SINT -> PlcSINT(int) + PlcValueType.INT -> PlcINT(int) + PlcValueType.DINT -> PlcDINT(int) + PlcValueType.LINT -> PlcLINT(long) + PlcValueType.REAL -> PlcREAL(float) + PlcValueType.LREAL -> PlcLREAL(double) + PlcValueType.CHAR -> PlcCHAR(int) + PlcValueType.WCHAR -> PlcWCHAR(short) + PlcValueType.STRING -> PlcSTRING(string) + PlcValueType.WSTRING -> PlcWSTRING(string) + PlcValueType.TIME -> PlcTIME(string?.let { java.time.Duration.parse(it) }) + PlcValueType.LTIME -> PlcLTIME(string?.let { java.time.Duration.parse(it) }) + PlcValueType.DATE -> PlcDATE(string?.let { java.time.LocalDate.parse(it) }) + PlcValueType.LDATE -> PlcLDATE(string?.let { java.time.LocalDate.parse(it) }) + PlcValueType.TIME_OF_DAY -> PlcTIME_OF_DAY(string?.let { java.time.LocalTime.parse(it) }) + PlcValueType.LTIME_OF_DAY -> PlcLTIME_OF_DAY(string?.let { java.time.LocalTime.parse(it) }) + PlcValueType.DATE_AND_TIME -> PlcDATE_AND_TIME(string?.let { java.time.LocalDateTime.parse(it) }) + PlcValueType.DATE_AND_LTIME -> PlcDATE_AND_LTIME(string?.let { java.time.LocalDateTime.parse(it) }) + PlcValueType.LDATE_AND_TIME -> PlcLDATE_AND_TIME(string?.let { java.time.LocalDateTime.parse(it) }) + PlcValueType.List -> PlcList().apply { + value?.list?.forEach { add(it.toPlcValue()) } + getIndexed("@list").forEach { (_, meta) -> + if (meta.items.isEmpty()) { + meta.value?.let { add(it.toPlcValue()) } + } else { + add(meta.toPlcValue(PlcValueType.Struct)) + } + } + } + + PlcValueType.RAW_BYTE_ARRAY -> PlcRawByteArray( + value?.list?.map { it.number.toByte() }?.toByteArray() ?: error("The meta content is not byte array") + ) +} \ No newline at end of file diff --git a/controls-ports-ktor/README.md b/controls-ports-ktor/README.md index 7f935f9..b703521 100644 --- a/controls-ports-ktor/README.md +++ b/controls-ports-ktor/README.md @@ -6,18 +6,16 @@ Implementation of byte ports on top os ktor-io asynchronous API ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-ports-ktor:0.2.0") + implementation("space.kscience:controls-ports-ktor:0.3.0") } ``` diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt index 9c6dbba..6e3041c 100644 --- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt +++ b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt @@ -13,7 +13,7 @@ public class KtorPortsPlugin : AbstractPlugin() { override val tag: PluginTag get() = Companion.tag override fun content(target: String): Map = when (target) { - PortFactory.TYPE -> mapOf("tcp".asName() to KtorTcpPort, "udp".asName() to KtorUdpPort) + Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf("tcp".asName() to KtorTcpPort, "udp".asName() to KtorUdpPort) else -> emptyMap() } diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt index 7f906d3..463e922 100644 --- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt +++ b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt @@ -1,47 +1,53 @@ package space.kscience.controls.ports import io.ktor.network.selector.ActorSelectorManager +import io.ktor.network.sockets.SocketOptions import io.ktor.network.sockets.aSocket import io.ktor.network.sockets.openReadChannel import io.ktor.network.sockets.openWriteChannel import io.ktor.utils.io.consumeEachBufferRange import io.ktor.utils.io.core.Closeable import io.ktor.utils.io.writeAvailable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.get import space.kscience.dataforge.meta.int import space.kscience.dataforge.meta.string +import java.nio.ByteBuffer import kotlin.coroutines.CoroutineContext public class KtorTcpPort internal constructor( context: Context, + meta: Meta, public val host: String, public val port: Int, coroutineContext: CoroutineContext = context.coroutineContext, -) : AbstractPort(context, coroutineContext), Closeable { + socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {}, +) : AbstractAsynchronousPort(context, meta, coroutineContext), Closeable { override fun toString(): String = "port[tcp:$host:$port]" - private val futureSocket = scope.async { - aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().connect(host, port) + private val futureSocket = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { + aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().connect(host, port, socketOptions) } - private val writeChannel = scope.async { + private val writeChannel = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { futureSocket.await().openWriteChannel(true) } - private val listenerJob = scope.launch { - val input = futureSocket.await().openReadChannel() - input.consumeEachBufferRange { buffer, _ -> - val array = ByteArray(buffer.remaining()) - buffer.get(array) - receive(array) - isActive + private var listenerJob: Job? = null + + override fun onOpen() { + listenerJob = scope.launch { + val input = futureSocket.await().openReadChannel() + input.consumeEachBufferRange { buffer: ByteBuffer, last -> + val array = ByteArray(buffer.remaining()) + buffer.get(array) + receive(array) + !last && isActive + } } } @@ -49,29 +55,45 @@ public class KtorTcpPort internal constructor( writeChannel.await().writeAvailable(data) } + override val isOpen: Boolean + get() = listenerJob?.isActive == true + override fun close() { - listenerJob.cancel() + listenerJob?.cancel() futureSocket.cancel() super.close() } - public companion object : PortFactory { + public companion object : Factory { - override val type: String = "tcp" + public fun build( + context: Context, + host: String, + port: Int, + coroutineContext: CoroutineContext = context.coroutineContext, + socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {}, + ): KtorTcpPort { + val meta = Meta { + "name" put "tcp://$host:$port" + "type" put "tcp" + "host" put host + "port" put port + } + return KtorTcpPort(context, meta, host, port, coroutineContext, socketOptions) + } public fun open( context: Context, host: String, port: Int, coroutineContext: CoroutineContext = context.coroutineContext, - ): KtorTcpPort { - return KtorTcpPort(context, host, port, coroutineContext) - } + socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {}, + ): KtorTcpPort = build(context, host, port, coroutineContext, socketOptions).apply { open() } - override fun build(context: Context, meta: Meta): Port { + override fun build(context: Context, meta: Meta): AsynchronousPort { val host = meta["host"].string ?: "localhost" val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta") - return open(context, host, port) + return build(context, host, port) } } } \ No newline at end of file diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt index 8b8446c..48096cf 100644 --- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt +++ b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt @@ -1,18 +1,14 @@ package space.kscience.controls.ports import io.ktor.network.selector.ActorSelectorManager -import io.ktor.network.sockets.InetSocketAddress -import io.ktor.network.sockets.aSocket -import io.ktor.network.sockets.openReadChannel -import io.ktor.network.sockets.openWriteChannel +import io.ktor.network.sockets.* +import io.ktor.utils.io.ByteWriteChannel import io.ktor.utils.io.consumeEachBufferRange import io.ktor.utils.io.core.Closeable import io.ktor.utils.io.writeAvailable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.int import space.kscience.dataforge.meta.number @@ -21,33 +17,40 @@ import kotlin.coroutines.CoroutineContext public class KtorUdpPort internal constructor( context: Context, + meta: Meta, public val remoteHost: String, public val remotePort: Int, public val localPort: Int? = null, public val localHost: String = "localhost", coroutineContext: CoroutineContext = context.coroutineContext, -) : AbstractPort(context, coroutineContext), Closeable { + socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {}, +) : AbstractAsynchronousPort(context, meta, coroutineContext), Closeable { override fun toString(): String = "port[udp:$remoteHost:$remotePort]" - private val futureSocket = scope.async { + private val futureSocket = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { aSocket(ActorSelectorManager(Dispatchers.IO)).udp().connect( remoteAddress = InetSocketAddress(remoteHost, remotePort), - localAddress = localPort?.let { InetSocketAddress(localHost, localPort) } + localAddress = localPort?.let { InetSocketAddress(localHost, localPort) }, + configure = socketOptions ) } - private val writeChannel = scope.async { + private val writeChannel: Deferred = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { futureSocket.await().openWriteChannel(true) } - private val listenerJob = scope.launch { - val input = futureSocket.await().openReadChannel() - input.consumeEachBufferRange { buffer, _ -> - val array = ByteArray(buffer.remaining()) - buffer.get(array) - receive(array) - isActive + private var listenerJob: Job? = null + + override fun onOpen() { + listenerJob = scope.launch { + val input = futureSocket.await().openReadChannel() + input.consumeEachBufferRange { buffer, last -> + val array = ByteArray(buffer.remaining()) + buffer.get(array) + receive(array) + !last && isActive + } } } @@ -55,16 +58,49 @@ public class KtorUdpPort internal constructor( writeChannel.await().writeAvailable(data) } + override val isOpen: Boolean + get() = listenerJob?.isActive == true + override fun close() { - listenerJob.cancel() + listenerJob?.cancel() futureSocket.cancel() super.close() } - public companion object : PortFactory { + public companion object : Factory { - override val type: String = "udp" + public fun build( + context: Context, + remoteHost: String, + remotePort: Int, + localPort: Int? = null, + localHost: String? = null, + coroutineContext: CoroutineContext = context.coroutineContext, + socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {}, + ): KtorUdpPort { + val meta = Meta { + "name" put "udp://$remoteHost:$remotePort" + "type" put "udp" + "remoteHost" put remoteHost + "remotePort" put remotePort + localHost?.let { "localHost" put it } + localPort?.let { "localPort" put it } + } + return KtorUdpPort( + context = context, + meta = meta, + remoteHost = remoteHost, + remotePort = remotePort, + localPort = localPort, + localHost = localHost ?: "localhost", + coroutineContext = coroutineContext, + socketOptions = socketOptions + ) + } + /** + * Create and open UDP port + */ public fun open( context: Context, remoteHost: String, @@ -72,16 +108,23 @@ public class KtorUdpPort internal constructor( localPort: Int? = null, localHost: String = "localhost", coroutineContext: CoroutineContext = context.coroutineContext, - ): KtorUdpPort { - return KtorUdpPort(context, remoteHost, remotePort, localPort, localHost, coroutineContext) - } + socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {}, + ): KtorUdpPort = build( + context, + remoteHost, + remotePort, + localPort, + localHost, + coroutineContext, + socketOptions + ).apply { open() } - override fun build(context: Context, meta: Meta): Port { + override fun build(context: Context, meta: Meta): AsynchronousPort { val remoteHost by meta.string { error("Remote host is not specified") } val remotePort by meta.number { error("Remote port is not specified") } val localHost: String? by meta.string() val localPort: Int? by meta.int() - return open(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost") + return build(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost") } } } \ No newline at end of file diff --git a/controls-serial/README.md b/controls-serial/README.md index 209055d..861b6d3 100644 --- a/controls-serial/README.md +++ b/controls-serial/README.md @@ -6,18 +6,16 @@ Implementation of direct serial port communication with JSerialComm ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-serial:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-serial:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-serial:0.2.0") + implementation("space.kscience:controls-serial:0.3.0") } ``` diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt new file mode 100644 index 0000000..b6e83bc --- /dev/null +++ b/controls-serial/src/main/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt @@ -0,0 +1,134 @@ +package space.kscience.controls.serial + +import com.fazecast.jSerialComm.SerialPort +import com.fazecast.jSerialComm.SerialPortDataListener +import com.fazecast.jSerialComm.SerialPortEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import space.kscience.controls.ports.AbstractAsynchronousPort +import space.kscience.controls.ports.AsynchronousPort +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.meta.string +import kotlin.coroutines.CoroutineContext + +/** + * A port based on JSerialComm + */ +public class AsynchronousSerialPort( + context: Context, + meta: Meta, + private val comPort: SerialPort, + coroutineContext: CoroutineContext = context.coroutineContext, +) : AbstractAsynchronousPort(context, meta, coroutineContext) { + + override fun toString(): String = "port[${comPort.descriptivePortName}]" + + private val serialPortListener = object : SerialPortDataListener { + override fun getListeningEvents(): Int = + SerialPort.LISTENING_EVENT_DATA_RECEIVED and SerialPort.LISTENING_EVENT_DATA_AVAILABLE + + override fun serialEvent(event: SerialPortEvent) { + when (event.eventType) { + SerialPort.LISTENING_EVENT_DATA_RECEIVED -> { + scope.launch { receive(event.receivedData) } + } + + SerialPort.LISTENING_EVENT_DATA_AVAILABLE -> { + scope.launch(Dispatchers.IO) { + val available = comPort.bytesAvailable() + if (available > 0) { + val buffer = ByteArray(available) + comPort.readBytes(buffer, available) + receive(buffer) + } + } + } + } + } + } + + override fun onOpen() { + comPort.openPort() + comPort.addDataListener(serialPortListener) + } + + override val isOpen: Boolean get() = comPort.isOpen + + override suspend fun write(data: ByteArray) { + comPort.writeBytes(data, data.size) + } + + override fun close() { + comPort.removeDataListener() + if (comPort.isOpen) { + comPort.closePort() + } + super.close() + } + + public companion object : Factory { + + public fun build( + context: Context, + portName: String, + baudRate: Int = 9600, + dataBits: Int = 8, + stopBits: Int = SerialPort.ONE_STOP_BIT, + parity: Int = SerialPort.NO_PARITY, + coroutineContext: CoroutineContext = context.coroutineContext, + additionalConfig: SerialPort.() -> Unit = {}, + ): AsynchronousSerialPort { + val serialPort = SerialPort.getCommPort(portName).apply { + setComPortParameters(baudRate, dataBits, stopBits, parity) + additionalConfig() + } + val meta = Meta { + "name" put "com://$portName" + "type" put "serial" + "baudRate" put serialPort.baudRate + "dataBits" put serialPort.numDataBits + "stopBits" put serialPort.numStopBits + "parity" put serialPort.parity + } + return AsynchronousSerialPort(context, meta, serialPort, coroutineContext) + } + + + /** + * Construct ComPort with given parameters + */ + public fun open( + context: Context, + portName: String, + baudRate: Int = 9600, + dataBits: Int = 8, + stopBits: Int = SerialPort.ONE_STOP_BIT, + parity: Int = SerialPort.NO_PARITY, + coroutineContext: CoroutineContext = context.coroutineContext, + additionalConfig: SerialPort.() -> Unit = {}, + ): AsynchronousSerialPort = build( + context = context, + portName = portName, + baudRate = baudRate, + dataBits = dataBits, + stopBits = stopBits, + parity = parity, + coroutineContext = coroutineContext, + additionalConfig = additionalConfig + ).apply { open() } + + + override fun build(context: Context, meta: Meta): AsynchronousPort { + val name by meta.string { error("Serial port name not defined") } + val baudRate by meta.int(9600) + val dataBits by meta.int(8) + val stopBits by meta.int(SerialPort.ONE_STOP_BIT) + val parity by meta.int(SerialPort.NO_PARITY) + return build(context, name, baudRate, dataBits, stopBits, parity) + } + } + +} \ No newline at end of file diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt deleted file mode 100644 index 3e0601c..0000000 --- a/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt +++ /dev/null @@ -1,87 +0,0 @@ -package space.kscience.controls.serial - -import com.fazecast.jSerialComm.SerialPort -import com.fazecast.jSerialComm.SerialPortDataListener -import com.fazecast.jSerialComm.SerialPortEvent -import kotlinx.coroutines.launch -import space.kscience.controls.ports.AbstractPort -import space.kscience.controls.ports.Port -import space.kscience.controls.ports.PortFactory -import space.kscience.dataforge.context.Context -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.int -import space.kscience.dataforge.meta.string -import kotlin.coroutines.CoroutineContext - -/** - * A port based on JSerialComm - */ -public class JSerialCommPort( - context: Context, - private val comPort: SerialPort, - coroutineContext: CoroutineContext = context.coroutineContext, -) : AbstractPort(context, coroutineContext) { - - override fun toString(): String = "port[${comPort.descriptivePortName}]" - - private val serialPortListener = object : SerialPortDataListener { - override fun getListeningEvents(): Int = SerialPort.LISTENING_EVENT_DATA_AVAILABLE - - override fun serialEvent(event: SerialPortEvent) { - if (event.eventType == SerialPort.LISTENING_EVENT_DATA_AVAILABLE) { - scope.launch { receive(event.receivedData) } - } - } - } - - init { - comPort.addDataListener(serialPortListener) - } - - override suspend fun write(data: ByteArray) { - comPort.writeBytes(data, data.size) - } - - override fun close() { - comPort.removeDataListener() - if (comPort.isOpen) { - comPort.closePort() - } - super.close() - } - - public companion object : PortFactory { - - override val type: String = "com" - - - /** - * Construct ComPort with given parameters - */ - public fun open( - context: Context, - portName: String, - baudRate: Int = 9600, - dataBits: Int = 8, - stopBits: Int = SerialPort.ONE_STOP_BIT, - parity: Int = SerialPort.NO_PARITY, - coroutineContext: CoroutineContext = context.coroutineContext, - ): JSerialCommPort { - val serialPort = SerialPort.getCommPort(portName).apply { - setComPortParameters(baudRate, dataBits, stopBits, parity) - openPort() - } - return JSerialCommPort(context, serialPort, coroutineContext) - } - - override fun build(context: Context, meta: Meta): Port { - val name by meta.string { error("Serial port name not defined") } - val baudRate by meta.int(9600) - val dataBits by meta.int(8) - val stopBits by meta.int(SerialPort.ONE_STOP_BIT) - val parity by meta.int(SerialPort.NO_PARITY) - return open(context, name, baudRate, dataBits, stopBits, parity) - } - } - -} \ No newline at end of file diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt index ae9d4fc..f0d099f 100644 --- a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt +++ b/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt @@ -1,19 +1,27 @@ package space.kscience.controls.serial -import space.kscience.controls.ports.PortFactory +import space.kscience.controls.ports.Ports import space.kscience.dataforge.context.AbstractPlugin import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.PluginFactory import space.kscience.dataforge.context.PluginTag import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName public class SerialPortPlugin : AbstractPlugin() { override val tag: PluginTag get() = Companion.tag - override fun content(target: String): Map = when(target){ - PortFactory.TYPE -> mapOf(Name.EMPTY to JSerialCommPort) + override fun content(target: String): Map = when (target) { + Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf( + "serial".asName() to AsynchronousSerialPort, + ) + + Ports.SYNCHRONOUS_PORT_TYPE -> mapOf( + "serial".asName() to SynchronousSerialPort, + ) + else -> emptyMap() } diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt new file mode 100644 index 0000000..1a9b4a5 --- /dev/null +++ b/controls-serial/src/main/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt @@ -0,0 +1,140 @@ +package space.kscience.controls.serial + +import com.fazecast.jSerialComm.SerialPort +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import space.kscience.controls.ports.SynchronousPort +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.context.error +import space.kscience.dataforge.context.logger +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.meta.string + +/** + * A port based on JSerialComm + */ +public class SynchronousSerialPort( + override val context: Context, + public val meta: Meta, + private val comPort: SerialPort, +) : SynchronousPort { + + override fun toString(): String = "port[${comPort.descriptivePortName}]" + + + override fun open() { + if (!isOpen) { + comPort.openPort() + } + } + + override val isOpen: Boolean get() = comPort.isOpen + + + override fun close() { + if (comPort.isOpen) { + comPort.closePort() + } + } + + private val mutex = Mutex() + + override suspend fun respond( + request: ByteArray, + transform: suspend Flow.() -> R, + ): R = mutex.withLock { + comPort.flushIOBuffers() + comPort.writeBytes(request, request.size) + flow { + while (isOpen) { + try { + val available = comPort.bytesAvailable() + if (available > 0) { + val buffer = ByteArray(available) + comPort.readBytes(buffer, available) + emit(buffer) + } else if (available < 0) break + } catch (ex: Exception) { + logger.error(ex) { "Channel read error" } + delay(1000) + } + } + }.transform() + } + + override suspend fun respondFixedMessageSize(request: ByteArray, responseSize: Int): ByteArray = mutex.withLock { + runInterruptible { + comPort.flushIOBuffers() + comPort.writeBytes(request, request.size) + val buffer = ByteArray(responseSize) + comPort.readBytes(buffer, responseSize) + buffer + } + } + + public companion object : Factory { + + public fun build( + context: Context, + portName: String, + baudRate: Int = 9600, + dataBits: Int = 8, + stopBits: Int = SerialPort.ONE_STOP_BIT, + parity: Int = SerialPort.NO_PARITY, + additionalConfig: SerialPort.() -> Unit = {}, + ): SynchronousSerialPort { + val serialPort = SerialPort.getCommPort(portName).apply { + setComPortParameters(baudRate, dataBits, stopBits, parity) + additionalConfig() + } + val meta = Meta { + "name" put "com://$portName" + "type" put "serial" + "baudRate" put serialPort.baudRate + "dataBits" put serialPort.numDataBits + "stopBits" put serialPort.numStopBits + "parity" put serialPort.parity + } + return SynchronousSerialPort(context, meta, serialPort) + } + + + /** + * Construct ComPort with given parameters + */ + public fun open( + context: Context, + portName: String, + baudRate: Int = 9600, + dataBits: Int = 8, + stopBits: Int = SerialPort.ONE_STOP_BIT, + parity: Int = SerialPort.NO_PARITY, + additionalConfig: SerialPort.() -> Unit = {}, + ): SynchronousSerialPort = build( + context = context, + portName = portName, + baudRate = baudRate, + dataBits = dataBits, + stopBits = stopBits, + parity = parity, + additionalConfig = additionalConfig + ).apply { open() } + + + override fun build(context: Context, meta: Meta): SynchronousPort { + val name by meta.string { error("Serial port name not defined") } + val baudRate by meta.int(9600) + val dataBits by meta.int(8) + val stopBits by meta.int(SerialPort.ONE_STOP_BIT) + val parity by meta.int(SerialPort.NO_PARITY) + return build(context, name, baudRate, dataBits, stopBits, parity) + } + } + +} \ No newline at end of file diff --git a/controls-server/README.md b/controls-server/README.md index 83408e8..86a5bf5 100644 --- a/controls-server/README.md +++ b/controls-server/README.md @@ -6,18 +6,16 @@ A combined Magix event loop server with web server for visualization. ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-server:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-server:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-server:0.2.0") + implementation("space.kscience:controls-server:0.3.0") } ``` diff --git a/controls-server/build.gradle.kts b/controls-server/build.gradle.kts index 5528b63..3581a71 100644 --- a/controls-server/build.gradle.kts +++ b/controls-server/build.gradle.kts @@ -1,7 +1,7 @@ import space.kscience.gradle.Maturity plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` } diff --git a/controls-server/src/main/kotlin/space/kscience/controls/server/deviceWebServer.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt similarity index 94% rename from controls-server/src/main/kotlin/space/kscience/controls/server/deviceWebServer.kt rename to controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt index ba63583..04bb46d 100644 --- a/controls-server/src/main/kotlin/space/kscience/controls/server/deviceWebServer.kt +++ b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt @@ -157,8 +157,8 @@ public fun Application.deviceManagerModule( val body = call.receiveText() val request: DeviceMessage = MagixEndpoint.magixJson.decodeFromString(DeviceMessage.serializer(), body) val response = manager.respondHubMessage(request) - if (response != null) { - call.respondMessage(response) + if (response.isNotEmpty()) { + call.respondMessages(response) } else { call.respondText("No response") } @@ -177,9 +177,9 @@ public fun Application.deviceManagerModule( property = property, ) - val response = manager.respondHubMessage(request) - if (response != null) { - call.respondMessage(response) + val responses = manager.respondHubMessage(request) + if (responses.isNotEmpty()) { + call.respondMessages(responses) } else { call.respond(HttpStatusCode.InternalServerError) } @@ -197,9 +197,9 @@ public fun Application.deviceManagerModule( value = json.toMeta() ) - val response = manager.respondHubMessage(request) - if (response != null) { - call.respondMessage(response) + val responses = manager.respondHubMessage(request) + if (responses.isNotEmpty()) { + call.respondMessages(responses) } else { call.respond(HttpStatusCode.InternalServerError) } diff --git a/controls-server/src/main/kotlin/space/kscience/controls/server/responses.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt similarity index 79% rename from controls-server/src/main/kotlin/space/kscience/controls/server/responses.kt rename to controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt index 93d11c4..ffe489f 100644 --- a/controls-server/src/main/kotlin/space/kscience/controls/server/responses.kt +++ b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt @@ -5,6 +5,7 @@ import io.ktor.server.application.ApplicationCall import io.ktor.server.response.respondText import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.serializer import space.kscience.controls.api.DeviceMessage import space.kscience.magix.api.MagixEndpoint @@ -25,7 +26,7 @@ internal suspend fun ApplicationCall.respondJson(builder: JsonObjectBuilder.() - respondText(json.toString(), contentType = ContentType.Application.Json) } -internal suspend fun ApplicationCall.respondMessage(message: DeviceMessage): Unit = respondText( - MagixEndpoint.magixJson.encodeToString(DeviceMessage.serializer(), message), +internal suspend fun ApplicationCall.respondMessages(messages: List): Unit = respondText( + MagixEndpoint.magixJson.encodeToString(serializer>(), messages), contentType = ContentType.Application.Json ) \ No newline at end of file diff --git a/controls-storage/README.md b/controls-storage/README.md index 51cdbcb..3242de2 100644 --- a/controls-storage/README.md +++ b/controls-storage/README.md @@ -6,18 +6,16 @@ An API for stand-alone Controls-kt device or a hub. ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-storage:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-storage:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-storage:0.2.0") + implementation("space.kscience:controls-storage:0.3.0") } ``` diff --git a/controls-storage/controls-xodus/README.md b/controls-storage/controls-xodus/README.md index 790b356..2a6c247 100644 --- a/controls-storage/controls-xodus/README.md +++ b/controls-storage/controls-xodus/README.md @@ -6,18 +6,16 @@ An implementation of controls-storage on top of JetBrains Xodus. ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-xodus:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-xodus:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-xodus:0.2.0") + implementation("space.kscience:controls-xodus:0.3.0") } ``` diff --git a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt b/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt index e5d2e4a..559d2a0 100644 --- a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt +++ b/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt @@ -4,9 +4,9 @@ import jetbrains.exodus.entitystore.Entity import jetbrains.exodus.entitystore.PersistentEntityStore import jetbrains.exodus.entitystore.PersistentEntityStores import jetbrains.exodus.entitystore.StoreTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow import kotlinx.datetime.Instant -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.descriptors.serialDescriptor import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject @@ -19,7 +19,6 @@ import space.kscience.dataforge.context.request import space.kscience.dataforge.io.IOPlugin import space.kscience.dataforge.io.workDirectory import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.get import space.kscience.dataforge.meta.string import space.kscience.dataforge.misc.DFExperimental import space.kscience.dataforge.names.Name @@ -39,9 +38,7 @@ internal fun StoreTransaction.writeMessage(message: DeviceMessage): Unit { message.targetDevice?.let { entity.setProperty(DeviceMessage::targetDevice.name, it.toString()) } - message.time?.let { - entity.setProperty(DeviceMessage::targetDevice.name, it.toString()) - } + entity.setProperty(DeviceMessage::targetDevice.name, message.time.toString()) entity.setBlobString("json", Json.encodeToString(json)) } @@ -68,7 +65,7 @@ public class XodusDeviceMessageStorage( } } - override suspend fun readAll(): List = entityStore.computeInReadonlyTransaction { transaction -> + override fun readAll(): Flow = entityStore.computeInReadonlyTransaction { transaction -> transaction.sort( DEVICE_MESSAGE_ENTITY_TYPE, DeviceMessage::time.name, @@ -79,19 +76,19 @@ public class XodusDeviceMessageStorage( it.getBlobString("json") ?: error("No json content found") ) } - } + }.asFlow() - override suspend fun read( + override fun read( eventType: String, range: ClosedRange?, sourceDevice: Name?, targetDevice: Name?, - ): List = entityStore.computeInReadonlyTransaction { transaction -> + ): Flow = entityStore.computeInReadonlyTransaction { transaction -> transaction.find( DEVICE_MESSAGE_ENTITY_TYPE, "type", eventType - ).asSequence().filter { + ).filter { it.timeInRange(range) && it.propertyMatchesName(DeviceMessage::sourceDevice.name, sourceDevice) && it.propertyMatchesName(DeviceMessage::targetDevice.name, targetDevice) @@ -100,8 +97,8 @@ public class XodusDeviceMessageStorage( DeviceMessage.serializer(), it.getBlobString("json") ?: error("No json content found") ) - }.sortedBy { it.time }.toList() - } + } + }.asFlow() override fun close() { entityStore.close() @@ -123,17 +120,4 @@ public class XodusDeviceMessageStorage( return XodusDeviceMessageStorage(entityStore) } } -} - -/** - * Query all messages of given type - */ -@OptIn(ExperimentalSerializationApi::class) -public suspend inline fun XodusDeviceMessageStorage.query( - range: ClosedRange? = null, - sourceDevice: Name? = null, - targetDevice: Name? = null, -): List = read(serialDescriptor().serialName, range, sourceDevice, targetDevice).map { - //Check that all types are correct - it as T -} +} \ No newline at end of file diff --git a/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt b/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt index 1724079..e7017b1 100644 --- a/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt +++ b/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt @@ -1,5 +1,6 @@ import jetbrains.exodus.entitystore.PersistentEntityStores import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant import org.junit.jupiter.api.AfterAll @@ -7,8 +8,8 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.storage.read import space.kscience.controls.xodus.XodusDeviceMessageStorage -import space.kscience.controls.xodus.query import space.kscience.controls.xodus.writeMessage import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.names.Name @@ -67,7 +68,7 @@ internal class PropertyHistoryTest { XodusDeviceMessageStorage(entityStore).use { storage -> assertEquals( propertyChangedMessages[0], - storage.query( + storage.read( sourceDevice = "virtual-car".asName() ).first { it.property == "speed" } ) diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt index 87f4b74..b9cc84f 100644 --- a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt +++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt @@ -1,6 +1,10 @@ package space.kscience.controls.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.datetime.Instant +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.serialDescriptor import space.kscience.controls.api.DeviceMessage import space.kscience.dataforge.names.Name @@ -10,14 +14,34 @@ import space.kscience.dataforge.names.Name public interface DeviceMessageStorage { public suspend fun write(event: DeviceMessage) - public suspend fun readAll(): List + /** + * Return all messages in a storage as a flow + */ + public fun readAll(): Flow - public suspend fun read( + /** + * Flow messages with given [eventType] and filters by [range], [sourceDevice] and [targetDevice]. + * Null in filters means that there is not filtering for this field. + */ + public fun read( eventType: String, range: ClosedRange? = null, sourceDevice: Name? = null, targetDevice: Name? = null, - ): List + ): Flow public fun close() +} + +/** + * Query all messages of given type + */ +@OptIn(ExperimentalSerializationApi::class) +public inline fun DeviceMessageStorage.read( + range: ClosedRange? = null, + sourceDevice: Name? = null, + targetDevice: Name? = null, +): Flow = read(serialDescriptor().serialName, range, sourceDevice, targetDevice).map { + //Check that all types are correct + it as T } \ No newline at end of file diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt new file mode 100644 index 0000000..b52ed2b --- /dev/null +++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt @@ -0,0 +1,20 @@ +package space.kscience.controls.storage + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.datetime.Instant +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.misc.PropertyHistory +import space.kscience.controls.misc.ValueWithTime +import space.kscience.dataforge.meta.MetaConverter + +public fun DeviceMessageStorage.propertyHistory( + propertyName: String, + converter: MetaConverter, +): PropertyHistory = object : PropertyHistory { + override fun flowHistory(from: Instant, until: Instant): Flow> = + read(from..until) + .filter { it.property == propertyName } + .map { ValueWithTime(converter.read(it.value), it.time) } +} \ No newline at end of file diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt index 2a453cf..ed96efc 100644 --- a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt +++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt @@ -12,7 +12,7 @@ import space.kscience.dataforge.context.Factory import space.kscience.dataforge.context.debug import space.kscience.dataforge.context.logger -//TODO replace by plugin? + public fun DeviceManager.storage( factory: Factory, ): DeviceMessageStorage = factory.build(context, meta) @@ -31,7 +31,7 @@ public fun DeviceManager.storeMessages( val storage = factory.build(context, meta) logger.debug { "Message storage with meta = $meta created" } - return hubMessageFlow(context).filter(filterCondition).onEach { message -> + return hubMessageFlow().filter(filterCondition).onEach { message -> storage.write(message) }.onCompletion { storage.close() @@ -39,26 +39,4 @@ public fun DeviceManager.storeMessages( }.launchIn(context) } -///** -// * @return the list of deviceMessages that describes changes of specified property of specified device sorted by time -// * @param sourceDeviceName a name of device, history of which property we want to get -// * @param propertyName a name of property, history of which we want to get -// * @param factory a factory that produce mongo clients -// */ -//public suspend fun getPropertyHistory( -// sourceDeviceName: String, -// propertyName: String, -// factory: Factory, -// meta: Meta = Meta.EMPTY, -//): List { -// return factory(meta).use { -// it.getPropertyHistory(sourceDeviceName, propertyName) -// } -//} -// -// -//public enum class StorageKind { -// DEVICE_HUB, -// MAGIX_SERVER -//} diff --git a/controls-vision/README.md b/controls-vision/README.md new file mode 100644 index 0000000..41483ce --- /dev/null +++ b/controls-vision/README.md @@ -0,0 +1,21 @@ +# Module controls-vision + +Dashboard and visualization extensions for devices + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:controls-vision:0.3.0`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:controls-vision:0.3.0") +} +``` diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts new file mode 100644 index 0000000..9395b92 --- /dev/null +++ b/controls-vision/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +description = """ + Dashboard and visualization extensions for devices +""".trimIndent() + +val visionforgeVersion: String by rootProject.extra + +kscience { + fullStack("js/controls-vision.js") + useKtor() + useContextReceivers() + dependencies { + api(projects.controlsCore) + api(projects.controlsConstructor) + api("space.kscience:visionforge-plotly:$visionforgeVersion") + api("space.kscience:visionforge-markdown:$visionforgeVersion") +// api("space.kscience:tables-kt:0.2.1") +// api("space.kscience:visionforge-tables:$visionforgeVersion") + } + + jvmMain{ + api("space.kscience:visionforge-server:$visionforgeVersion") + api("io.ktor:ktor-server-cio") + } +} + +readme { + maturity = space.kscience.gradle.Maturity.PROTOTYPE +} \ No newline at end of file diff --git a/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt new file mode 100644 index 0000000..d587250 --- /dev/null +++ b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt @@ -0,0 +1,19 @@ +package space.kscience.controls.vision + +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import space.kscience.dataforge.context.PluginFactory +import space.kscience.visionforge.Vision +import space.kscience.visionforge.VisionPlugin +import space.kscience.visionforge.plotly.VisionOfPlotly + +public expect class ControlVisionPlugin: VisionPlugin{ + public companion object: PluginFactory +} + +internal val controlsVisionSerializersModule = SerializersModule { + polymorphic(Vision::class) { + subclass(VisionOfPlotly.serializer()) + } +} \ No newline at end of file diff --git a/controls-vision/src/commonMain/kotlin/IndicatorVision.kt b/controls-vision/src/commonMain/kotlin/IndicatorVision.kt new file mode 100644 index 0000000..9ec2319 --- /dev/null +++ b/controls-vision/src/commonMain/kotlin/IndicatorVision.kt @@ -0,0 +1,20 @@ +package space.kscience.controls.vision + +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.node +import space.kscience.visionforge.AbstractVision +import space.kscience.visionforge.Vision + +/** + * A [Vision] that shows an indicator + */ +public class IndicatorVision: AbstractVision() { + public val value: Meta? by properties.node() +} + +///** +// * A [Vision] that allows both showing the value and changing it +// */ +//public interface RegulatorVision: IndicatorVision{ +// +//} \ No newline at end of file diff --git a/controls-vision/src/commonMain/kotlin/plotExtensions.kt b/controls-vision/src/commonMain/kotlin/plotExtensions.kt new file mode 100644 index 0000000..6dfdee0 --- /dev/null +++ b/controls-vision/src/commonMain/kotlin/plotExtensions.kt @@ -0,0 +1,239 @@ +@file:OptIn(FlowPreview::class) + +package space.kscience.controls.vision + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import space.kscience.controls.api.Device +import space.kscience.controls.api.propertyMessageFlow +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.manager.clock +import space.kscience.controls.misc.ValueWithTime +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.name +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.* +import space.kscience.dataforge.misc.DFExperimental +import space.kscience.plotly.Plot +import space.kscience.plotly.bar +import space.kscience.plotly.models.Bar +import space.kscience.plotly.models.Scatter +import space.kscience.plotly.models.Trace +import space.kscience.plotly.models.TraceValues +import space.kscience.plotly.scatter +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +private var TraceValues.values: List + get() = value?.list ?: emptyList() + set(newValues) { + value = ListValue(newValues) + } + + +private var TraceValues.times: List + get() = value?.list?.map { Instant.parse(it.string) } ?: emptyList() + set(newValues) { + value = ListValue(newValues.map { it.toString().asValue() }) + } + + +private class TimeData(private var points: MutableList> = mutableListOf()) { + private val mutex = Mutex() + + suspend fun append(time: Instant, value: Value) = mutex.withLock { + points.add(ValueWithTime(value, time)) + } + + suspend fun trim(maxAge: Duration, maxPoints: Int = 800, minPoints: Int = 400) { + require(maxPoints > 2) + require(minPoints > 0) + require(maxPoints > minPoints) + val now = Clock.System.now() + // filter old points + points.removeAll { now - it.time > maxAge } + + if (points.size > maxPoints) { + val durationBetweenPoints = maxAge / minPoints + val markedForRemoval = buildList> { + var lastTime: Instant? = null + points.forEach { point -> + if (lastTime?.let { point.time - it < durationBetweenPoints } == true) { + add(point) + } else { + lastTime = point.time + } + } + } + points.removeAll(markedForRemoval) + } + } + + suspend fun fillPlot(x: TraceValues, y: TraceValues) = mutex.withLock { + x.strings = points.map { it.time.toString() } + y.values = points.map { it.value } + } +} + +private val defaultMaxAge get() = 10.minutes +private val defaultMaxPoints get() = 800 +private val defaultMinPoints get() = 400 +private val defaultSampling get() = 1.seconds + +/** + * Add a trace that shows a [Device] property change over time. Show only latest [maxPoints] . + * @return a [Job] that handles the listener + */ +public fun Plot.plotDeviceProperty( + device: Device, + propertyName: String, + extractValue: Meta.() -> Value = { value ?: Null }, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + coroutineScope: CoroutineScope = device.context, + configuration: Scatter.() -> Unit = {}, +): Job = scatter(configuration).run { + val data = TimeData() + device.propertyMessageFlow(propertyName).sample(sampling).transform { + data.append(it.time, it.value.extractValue()) + data.trim(maxAge, maxPoints, minPoints) + emit(data) + }.onEach { + it.fillPlot(x, y) + }.launchIn(coroutineScope) +} + +public fun Plot.plotDeviceProperty( + device: Device, + property: DevicePropertySpec<*, Number>, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + coroutineScope: CoroutineScope = device.context, + configuration: Scatter.() -> Unit = {}, +): Job = plotDeviceProperty( + device, property.name, { value ?: Null }, maxAge, maxPoints, minPoints, sampling, coroutineScope, configuration +) + +private fun Trace.updateFromState( + context: Context, + state: DeviceState, + extractValue: T.() -> Value, + maxAge: Duration, + maxPoints: Int, + minPoints: Int, + sampling: Duration, +): Job { + val clock = context.clock + val data = TimeData() + return state.valueFlow.sample(sampling).transform { + data.append(clock.now(), it.extractValue()) + data.trim(maxAge, maxPoints, minPoints) + }.onEach { + it.fillPlot(x, y) + }.launchIn(context) +} + +public fun Plot.plotDeviceState( + context: Context, + state: DeviceState, + extractValue: T.() -> Value = { state.converter.convert(this).value ?: Null }, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + configuration: Scatter.() -> Unit = {}, +): Job = scatter(configuration).run { + updateFromState(context, state, extractValue, maxAge, maxPoints, minPoints, sampling) +} + + +public fun Plot.plotNumberState( + context: Context, + state: DeviceState, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + configuration: Scatter.() -> Unit = {}, +): Job = scatter(configuration).run { + updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, sampling) +} + + +public fun Plot.plotBooleanState( + context: Context, + state: DeviceState, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + configuration: Bar.() -> Unit = {}, +): Job = bar(configuration).run { + updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, sampling) +} + +private fun Flow.chunkedByPeriod(duration: Duration): Flow> { + val collector: ArrayDeque = ArrayDeque() + return channelFlow { + launch { + while (isActive) { + delay(duration) + send(ArrayList(collector)) + collector.clear() + } + } + this@chunkedByPeriod.collect { + collector.add(it) + } + } +} + +private fun List.averageTime(): Instant { + val min = min() + val max = max() + val duration = max - min + return min + duration / 2 +} + +/** + * Average property value by [averagingInterval]. Return [startValue] on each sample interval if no events arrived. + */ +@DFExperimental +public fun Plot.plotAveragedDeviceProperty( + device: Device, + propertyName: String, + startValue: Double = 0.0, + extractValue: Meta.() -> Double = { value?.double ?: startValue }, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + averagingInterval: Duration = defaultSampling, + coroutineScope: CoroutineScope = device.context, + configuration: Scatter.() -> Unit = {}, +): Job = scatter(configuration).run { + val data = TimeData() + var lastValue = startValue + device.propertyMessageFlow(propertyName).chunkedByPeriod(averagingInterval).transform { eventList -> + if (eventList.isEmpty()) { + data.append(Clock.System.now(), lastValue.asValue()) + } else { + val time = eventList.map { it.time }.averageTime() + val value = eventList.map { extractValue(it.value) }.average() + data.append(time, value.asValue()) + lastValue = value + } + data.trim(maxAge, maxPoints, minPoints) + emit(data) + }.onEach { + it.fillPlot(x, y) + }.launchIn(coroutineScope) +} diff --git a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt new file mode 100644 index 0000000..55074ae --- /dev/null +++ b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt @@ -0,0 +1,21 @@ +package space.kscience.controls.vision + +import kotlinx.serialization.modules.SerializersModule +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.PluginFactory +import space.kscience.dataforge.context.PluginTag +import space.kscience.dataforge.meta.Meta +import space.kscience.visionforge.VisionPlugin + +public actual class ControlVisionPlugin : VisionPlugin() { + override val tag: PluginTag get() = Companion.tag + + override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule + + public actual companion object : PluginFactory { + override val tag: PluginTag = PluginTag("controls.vision") + + override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin() + + } +} \ No newline at end of file diff --git a/controls-vision/src/jsMain/kotlin/client.kt b/controls-vision/src/jsMain/kotlin/client.kt new file mode 100644 index 0000000..78da2e8 --- /dev/null +++ b/controls-vision/src/jsMain/kotlin/client.kt @@ -0,0 +1,11 @@ +package space.kscience.controls.vision + +import space.kscience.visionforge.html.runVisionClient +import space.kscience.visionforge.markup.MarkupPlugin +import space.kscience.visionforge.plotly.PlotlyPlugin + +public fun main(): Unit = runVisionClient { + plugin(PlotlyPlugin) + plugin(MarkupPlugin) +// plugin(TableVisionJsPlugin) +} \ No newline at end of file diff --git a/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt b/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt new file mode 100644 index 0000000..55074ae --- /dev/null +++ b/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt @@ -0,0 +1,21 @@ +package space.kscience.controls.vision + +import kotlinx.serialization.modules.SerializersModule +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.PluginFactory +import space.kscience.dataforge.context.PluginTag +import space.kscience.dataforge.meta.Meta +import space.kscience.visionforge.VisionPlugin + +public actual class ControlVisionPlugin : VisionPlugin() { + override val tag: PluginTag get() = Companion.tag + + override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule + + public actual companion object : PluginFactory { + override val tag: PluginTag = PluginTag("controls.vision") + + override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin() + + } +} \ No newline at end of file diff --git a/controls-vision/src/jvmMain/kotlin/dashboard.kt b/controls-vision/src/jvmMain/kotlin/dashboard.kt new file mode 100644 index 0000000..29c4740 --- /dev/null +++ b/controls-vision/src/jvmMain/kotlin/dashboard.kt @@ -0,0 +1,61 @@ +package space.kscience.controls.vision + +import io.ktor.server.cio.CIO +import io.ktor.server.engine.ApplicationEngine +import io.ktor.server.engine.embeddedServer +import io.ktor.server.http.content.staticResources +import io.ktor.server.routing.Routing +import io.ktor.server.routing.routing +import kotlinx.html.TagConsumer +import space.kscience.dataforge.context.Context +import space.kscience.plotly.Plot +import space.kscience.plotly.PlotlyConfig +import space.kscience.visionforge.html.HtmlVisionFragment +import space.kscience.visionforge.html.VisionPage +import space.kscience.visionforge.html.VisionTagConsumer +import space.kscience.visionforge.plotly.plotly +import space.kscience.visionforge.server.VisionRoute +import space.kscience.visionforge.server.close +import space.kscience.visionforge.server.openInBrowser +import space.kscience.visionforge.server.visionPage +import space.kscience.visionforge.visionManager + +public fun Context.showDashboard( + port: Int = 7777, + routes: Routing.() -> Unit = {}, + configurationBuilder: VisionRoute.() -> Unit = {}, + visionFragment: HtmlVisionFragment, +): ApplicationEngine = embeddedServer(CIO, port = port) { + routing { + staticResources("", null, null) + routes() + } + + visionPage( + visionManager, + VisionPage.scriptHeader("js/controls-vision.js"), + configurationBuilder = configurationBuilder, + visionFragment = visionFragment + ) +}.also { + it.start(false) + it.openInBrowser() + + + println("Enter 'exit' to close server") + while (readlnOrNull() != "exit") { + // + } + + it.close() +} + +context(VisionTagConsumer<*>) +public fun TagConsumer<*>.plot( + config: PlotlyConfig = PlotlyConfig(), + block: Plot.() -> Unit, +) { + vision { + plotly(config, block) + } +} diff --git a/demo/all-things/api/all-things.api b/demo/all-things/api/all-things.api index 4d283e5..1a1b140 100644 --- a/demo/all-things/api/all-things.api +++ b/demo/all-things/api/all-things.api @@ -47,11 +47,12 @@ public final class space/kscience/controls/demo/DemoDevice$Companion : space/ksc public fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/demo/DemoDevice; public final fun getCoordinates ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getCos ()Lspace/kscience/controls/spec/DevicePropertySpec; - public final fun getCosScale ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getCosScale ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public final fun getResetScale ()Lspace/kscience/controls/spec/DeviceActionSpec; + public final fun getSetSinScale ()Lspace/kscience/controls/spec/DeviceActionSpec; public final fun getSin ()Lspace/kscience/controls/spec/DevicePropertySpec; - public final fun getSinScale ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; - public final fun getTimeScale ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getSinScale ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; + public final fun getTimeScale ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public synthetic fun onOpen (Lspace/kscience/controls/api/Device;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onOpen (Lspace/kscience/controls/demo/IDemoDevice;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt index 94815fa..2d2086b 100644 --- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt +++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt @@ -5,10 +5,17 @@ import javafx.scene.Parent import javafx.scene.control.Slider import javafx.scene.layout.Priority import javafx.stage.Stage +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import org.eclipse.milo.opcua.sdk.server.OpcUaServer import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +import space.kscience.controls.api.DeviceMessage +import space.kscience.controls.api.GetDescriptionMessage +import space.kscience.controls.api.PropertyChangedMessage import space.kscience.controls.client.launchMagixService +import space.kscience.controls.client.magixFormat import space.kscience.controls.demo.DemoDevice.Companion.cosScale import space.kscience.controls.demo.DemoDevice.Companion.sinScale import space.kscience.controls.demo.DemoDevice.Companion.timeScale @@ -20,6 +27,8 @@ import space.kscience.controls.opcua.server.serveDevices import space.kscience.controls.spec.write import space.kscience.dataforge.context.* import space.kscience.magix.api.MagixEndpoint +import space.kscience.magix.api.send +import space.kscience.magix.api.subscribe import space.kscience.magix.rsocket.rSocketWithTcp import space.kscience.magix.rsocket.rSocketWithWebSockets import space.kscience.magix.server.RSocketMagixFlowPlugin @@ -49,6 +58,7 @@ class DemoController : Controller(), ContextAware { private val deviceManager = context.request(DeviceManager) + fun init() { context.launch { device = deviceManager.install("demo", DemoDevice) @@ -67,6 +77,17 @@ class DemoController : Controller(), ContextAware { //serve devices as OPC-UA namespace opcUaServer.startup() opcUaServer.serveDevices(deviceManager) + + + val listenerEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") + listenerEndpoint.subscribe(DeviceManager.magixFormat).onEach { (_, deviceMessage)-> + // print all messages that are not property change message + if(deviceMessage !is PropertyChangedMessage){ + println(">> ${Json.encodeToString(DeviceMessage.serializer(), deviceMessage)}") + } + }.launchIn(this) + listenerEndpoint.send(DeviceManager.magixFormat, GetDescriptionMessage(), "listener", "controls-kt") + } } @@ -78,7 +99,7 @@ class DemoController : Controller(), ContextAware { logger.info { "Visualization server stopped" } magixServer?.stop(1000, 5000) logger.info { "Magix server stopped" } - device?.close() + device?.stop() logger.info { "Device server stopped" } context.close() } diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt index dd65b78..5cf1b28 100644 --- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt +++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt @@ -7,16 +7,16 @@ import space.kscience.controls.spec.* import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Factory import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter import space.kscience.dataforge.meta.ValueType import space.kscience.dataforge.meta.descriptors.value -import space.kscience.dataforge.meta.transformations.MetaConverter import java.time.Instant import kotlin.math.cos import kotlin.math.sin import kotlin.time.Duration.Companion.milliseconds -interface IDemoDevice: Device { +interface IDemoDevice : Device { var timeScaleState: Double var sinScaleState: Double var cosScaleState: Double @@ -42,16 +42,21 @@ class DemoDevice(context: Context, meta: Meta) : DeviceBySpec(Compa // register virtual properties based on actual object state val timeScale by mutableProperty(MetaConverter.double, IDemoDevice::timeScaleState) { metaDescriptor { - type(ValueType.NUMBER) + valueType(ValueType.NUMBER) } - info = "Real to virtual time scale" + description = "Real to virtual time scale" } - val sinScale by mutableProperty(MetaConverter.double, IDemoDevice::sinScaleState) + val sinScale by mutableProperty(MetaConverter.double, IDemoDevice::sinScaleState){ + description = "The scale of sin plot" + metaDescriptor { + valueType(ValueType.NUMBER) + } + } val cosScale by mutableProperty(MetaConverter.double, IDemoDevice::cosScaleState) - val sin by doubleProperty(read = IDemoDevice::sinValue) - val cos by doubleProperty(read = IDemoDevice::cosValue) + val sin by doubleProperty { sinValue() } + val cos by doubleProperty { cosValue() } val coordinates by metaProperty( descriptorBuilder = { @@ -74,6 +79,10 @@ class DemoDevice(context: Context, meta: Meta) : DeviceBySpec(Compa write(cosScale, 1.0) } + val setSinScale by action(MetaConverter.double, MetaConverter.unit){ value: Double -> + write(sinScale, value) + } + override suspend fun IDemoDevice.onOpen() { launch { read(sinScale) diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt index fda8ead..5ff1287 100644 --- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt +++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt @@ -57,15 +57,15 @@ fun CoroutineScope.startDemoDeviceServer(magixEndpoint: MagixEndpoint): Applicat //share subscription to a parse message only once val subscription = magixEndpoint.subscribe(DeviceManager.magixFormat).shareIn(this, SharingStarted.Lazily) - val sinFlow = subscription.mapNotNull { (_, payload) -> + val sinFlow = subscription.mapNotNull { (_, payload) -> (payload as? PropertyChangedMessage)?.takeIf { it.property == DemoDevice.sin.name } }.map { it.value } - val cosFlow = subscription.mapNotNull { (_, payload) -> + val cosFlow = subscription.mapNotNull { (_, payload) -> (payload as? PropertyChangedMessage)?.takeIf { it.property == DemoDevice.cos.name } }.map { it.value } - val sinCosFlow = subscription.mapNotNull { (_, payload) -> + val sinCosFlow = subscription.mapNotNull { (_, payload) -> (payload as? PropertyChangedMessage)?.takeIf { it.property == DemoDevice.coordinates.name } }.map { it.value } diff --git a/demo/car/api/car.api b/demo/car/api/car.api index 5b1940f..f5bda95 100644 --- a/demo/car/api/car.api +++ b/demo/car/api/car.api @@ -9,7 +9,7 @@ public abstract interface class space/kscience/controls/demo/car/IVirtualCar : s } public final class space/kscience/controls/demo/car/IVirtualCar$Companion : space/kscience/controls/spec/DeviceSpec { - public final fun getAcceleration ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getAcceleration ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public final fun getLocation ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getSpeed ()Lspace/kscience/controls/spec/DevicePropertySpec; } @@ -17,7 +17,6 @@ public final class space/kscience/controls/demo/car/IVirtualCar$Companion : spac public final class space/kscience/controls/demo/car/MagixVirtualCar : space/kscience/controls/demo/car/VirtualCar { public static final field Companion Lspace/kscience/controls/demo/car/MagixVirtualCar$Companion; public fun (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)V - public fun open (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class space/kscience/controls/demo/car/MagixVirtualCar$Companion : space/kscience/dataforge/context/Factory { @@ -45,11 +44,11 @@ public final class space/kscience/controls/demo/car/Vector2D : space/kscience/da public fun toString ()Ljava/lang/String; } -public final class space/kscience/controls/demo/car/Vector2D$CoordinatesMetaConverter : space/kscience/dataforge/meta/transformations/MetaConverter { - public synthetic fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object; - public fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/demo/car/Vector2D; - public synthetic fun objectToMeta (Ljava/lang/Object;)Lspace/kscience/dataforge/meta/Meta; - public fun objectToMeta (Lspace/kscience/controls/demo/car/Vector2D;)Lspace/kscience/dataforge/meta/Meta; +public final class space/kscience/controls/demo/car/Vector2D$CoordinatesMetaConverter : space/kscience/dataforge/meta/MetaConverter { + public synthetic fun convert (Ljava/lang/Object;)Lspace/kscience/dataforge/meta/Meta; + public fun convert (Lspace/kscience/controls/demo/car/Vector2D;)Lspace/kscience/dataforge/meta/Meta; + public synthetic fun readOrNull (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object; + public fun readOrNull (Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/demo/car/Vector2D; } public class space/kscience/controls/demo/car/VirtualCar : space/kscience/controls/spec/DeviceBySpec, space/kscience/controls/demo/car/IVirtualCar { @@ -59,7 +58,7 @@ public class space/kscience/controls/demo/car/VirtualCar : space/kscience/contro public fun getAccelerationState ()Lspace/kscience/controls/demo/car/Vector2D; public fun getLocationState ()Lspace/kscience/controls/demo/car/Vector2D; public fun getSpeedState ()Lspace/kscience/controls/demo/car/Vector2D; - public fun open (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + protected fun onStart (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun setAccelerationState (Lspace/kscience/controls/demo/car/Vector2D;)V public fun setLocationState (Lspace/kscience/controls/demo/car/Vector2D;)V public fun setSpeedState (Lspace/kscience/controls/demo/car/Vector2D;)V diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt index 3bb8c79..c61041b 100644 --- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt +++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt @@ -2,6 +2,8 @@ package space.kscience.controls.demo.car import space.kscience.controls.api.Device import space.kscience.controls.spec.DeviceSpec +import space.kscience.controls.spec.mutableProperty +import space.kscience.controls.spec.property interface IVirtualCar : Device { var speedState: Vector2D diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt index 03c781b..27ea243 100644 --- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt +++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt @@ -14,7 +14,6 @@ import space.kscience.dataforge.names.Name import space.kscience.magix.api.MagixEndpoint import space.kscience.magix.api.subscribe import space.kscience.magix.rsocket.rSocketWithWebSockets -import kotlin.time.ExperimentalTime class MagixVirtualCar(context: Context, meta: Meta) : VirtualCar(context, meta) { @@ -23,7 +22,7 @@ class MagixVirtualCar(context: Context, meta: Meta) : VirtualCar(context, meta) (payload as? PropertyChangedMessage)?.let { message -> if (message.sourceDevice == Name.parse("virtual-car")) { when (message.property) { - "acceleration" -> write(IVirtualCar.acceleration, Vector2D.metaToObject(message.value)) + "acceleration" -> write(IVirtualCar.acceleration, Vector2D.read(message.value)) } } } @@ -31,17 +30,13 @@ class MagixVirtualCar(context: Context, meta: Meta) : VirtualCar(context, meta) } - @OptIn(ExperimentalTime::class) - override suspend fun open() { - super.open() + override suspend fun onStart() { val magixEndpoint = MagixEndpoint.rSocketWithWebSockets( meta["magixServerHost"].string ?: "localhost", ) - launch { - magixEndpoint.launchMagixVirtualCarUpdate() - } + magixEndpoint.launchMagixVirtualCarUpdate() } companion object : Factory { diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt index 1f1dc69..86564a5 100644 --- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt +++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt @@ -4,18 +4,14 @@ package space.kscience.controls.demo.car import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import space.kscience.controls.manager.clock import space.kscience.controls.spec.DeviceBySpec import space.kscience.controls.spec.doRecurring import space.kscience.controls.spec.read import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Factory -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.MetaRepr -import space.kscience.dataforge.meta.double -import space.kscience.dataforge.meta.get -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.dataforge.meta.* import kotlin.math.pow import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -23,24 +19,28 @@ import kotlin.time.ExperimentalTime data class Vector2D(var x: Double = 0.0, var y: Double = 0.0) : MetaRepr { - override fun toMeta(): Meta = objectToMeta(this) + override fun toMeta(): Meta = convert(this) operator fun div(arg: Double): Vector2D = Vector2D(x / arg, y / arg) companion object CoordinatesMetaConverter : MetaConverter { - override fun metaToObject(meta: Meta): Vector2D = Vector2D( - meta["x"].double ?: 0.0, - meta["y"].double ?: 0.0 + + override fun readOrNull(source: Meta): Vector2D = Vector2D( + source["x"].double ?: 0.0, + source["y"].double ?: 0.0 ) - override fun objectToMeta(obj: Vector2D): Meta = Meta { + override fun convert(obj: Vector2D): Meta = Meta { "x" put obj.x "y" put obj.y } } } -open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec(IVirtualCar, context, meta), IVirtualCar { +open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec(IVirtualCar, context, meta), + IVirtualCar { + private val clock = context.clock + private val timeScale = 1e-3 private val mass by meta.double(1000.0) // mass in kilograms @@ -57,7 +57,7 @@ open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec(I private var timeState: Instant? = null - private fun update(newTime: Instant = Clock.System.now()) { + private fun update(newTime: Instant = clock.now()) { //initialize time if it is not initialized if (timeState == null) { timeState = newTime @@ -100,10 +100,9 @@ open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec(I } @OptIn(ExperimentalTime::class) - override suspend fun open() { - super.open() + override suspend fun onStart() { //initializing the clock - timeState = Clock.System.now() + timeState = clock.now() //starting regular updates doRecurring(100.milliseconds) { update() diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt index 7a170ac..3a63003 100644 --- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt +++ b/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt @@ -71,9 +71,9 @@ class VirtualCarController : Controller(), ContextAware { logger.info { "Shutting down..." } magixServer?.stop(1000, 5000) logger.info { "Magix server stopped" } - magixVirtualCar?.close() + magixVirtualCar?.stop() logger.info { "Magix virtual car server stopped" } - virtualCar?.close() + virtualCar?.stop() logger.info { "Virtual car server stopped" } context.close() } diff --git a/demo/constructor/README.md b/demo/constructor/README.md new file mode 100644 index 0000000..3bd1e74 --- /dev/null +++ b/demo/constructor/README.md @@ -0,0 +1,4 @@ +# Module constructor + + + diff --git a/demo/constructor/api/constructor.api b/demo/constructor/api/constructor.api new file mode 100644 index 0000000..7206553 --- /dev/null +++ b/demo/constructor/api/constructor.api @@ -0,0 +1,27 @@ +public final class space/kscience/controls/demo/constructor/ComposableSingletons$MainKt { + public static final field INSTANCE Lspace/kscience/controls/demo/constructor/ComposableSingletons$MainKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function3; + public fun ()V + public final fun getLambda-1$constructor ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$constructor ()Lkotlin/jvm/functions/Function3; +} + +public final class space/kscience/controls/demo/constructor/LinearDrive : space/kscience/controls/constructor/DeviceConstructor { + public static final field $stable I + public fun (Lspace/kscience/dataforge/context/Context;Lspace/kscience/controls/constructor/DoubleRangeState;DLspace/kscience/controls/constructor/PidParameters;Lspace/kscience/dataforge/meta/Meta;)V + public synthetic fun (Lspace/kscience/dataforge/context/Context;Lspace/kscience/controls/constructor/DoubleRangeState;DLspace/kscience/controls/constructor/PidParameters;Lspace/kscience/dataforge/meta/Meta;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDrive ()Lspace/kscience/controls/constructor/Drive; + public final fun getEnd ()Lspace/kscience/controls/constructor/LimitSwitch; + public final fun getPid ()Lspace/kscience/controls/constructor/PidRegulator; + public final fun getPositionState ()Lspace/kscience/controls/constructor/DoubleRangeState; + public final fun getStart ()Lspace/kscience/controls/constructor/LimitSwitch; + public final fun getTarget ()D + public final fun setTarget (D)V +} + +public final class space/kscience/controls/demo/constructor/MainKt { + public static final fun main ()V + public static synthetic fun main ([Ljava/lang/String;)V +} + diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts new file mode 100644 index 0000000..6909639 --- /dev/null +++ b/demo/constructor/build.gradle.kts @@ -0,0 +1,51 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode + +plugins { + id("space.kscience.gradle.mpp") + alias(spclibs.plugins.compose) +} + +kscience { + jvm { + withJava() + } + useKtor() + useContextReceivers() + dependencies { + api(projects.controlsVision) + } + jvmMain { + implementation("io.ktor:ktor-server-cio") + implementation(spclibs.logback.classic) + } +} + +kotlin { + sourceSets { + jvmMain { + dependencies { + implementation(compose.desktop.currentOs) + } + } + } +} + +//application { +// mainClass.set("space.kscience.controls.demo.constructor.MainKt") +//} + +kotlin.explicitApi = ExplicitApiMode.Disabled + + +compose.desktop { + application { + mainClass = "space.kscience.controls.demo.constructor.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Exe) + packageName = "PidConstructor" + packageVersion = "1.0.0" + } + } +} \ No newline at end of file diff --git a/demo/constructor/src/jvmMain/kotlin/main.kt b/demo/constructor/src/jvmMain/kotlin/main.kt new file mode 100644 index 0000000..9b06d2e --- /dev/null +++ b/demo/constructor/src/jvmMain/kotlin/main.kt @@ -0,0 +1,229 @@ +package space.kscience.controls.demo.constructor + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import kotlinx.coroutines.launch +import space.kscience.controls.constructor.* +import space.kscience.controls.manager.ClockManager +import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.manager.clock +import space.kscience.controls.spec.doRecurring +import space.kscience.controls.spec.name +import space.kscience.controls.vision.plot +import space.kscience.controls.vision.plotDeviceProperty +import space.kscience.controls.vision.plotNumberState +import space.kscience.controls.vision.showDashboard +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.Meta +import space.kscience.plotly.models.ScatterMode +import space.kscience.visionforge.plotly.PlotlyPlugin +import kotlin.math.PI +import kotlin.math.sin +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + + +class LinearDrive( + context: Context, + state: DoubleRangeState, + mass: Double, + pidParameters: PidParameters, + meta: Meta = Meta.EMPTY, +) : DeviceConstructor(context, meta) { + + val drive by device(VirtualDrive.factory(mass, state)) + val pid by device(PidRegulator(drive, pidParameters)) + + val start by device(LimitSwitch.factory(state.atStartState)) + val end by device(LimitSwitch.factory(state.atEndState)) + + + val positionState: DoubleRangeState by property(state) + private val targetState: MutableDeviceState by property(pid.mutablePropertyAsState(Regulator.target, 0.0)) + var target by targetState +} + + +private fun Context.launchPidDevice( + state: DoubleRangeState, + pidParameters: PidParameters, + mass: Double, +) = launch { + val device = install( + "device", + LinearDrive(this@launchPidDevice, state, mass, pidParameters) + ).apply { + val clock = context.clock + val clockStart = clock.now() + doRecurring(10.milliseconds) { + val timeFromStart = clock.now() - clockStart + val t = timeFromStart.toDouble(DurationUnit.SECONDS) + val freq = 0.1 + + target = 5 * sin(2.0 * PI * freq * t) + + sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep)) + } + } + + + val maxAge = 10.seconds + + showDashboard { + plot { + plotNumberState(context, state, maxAge = maxAge, sampling = 50.milliseconds) { + name = "real position" + } + plotDeviceProperty(device.pid, Regulator.position.name, maxAge = maxAge, sampling = 50.milliseconds) { + name = "read position" + } + + plotDeviceProperty(device.pid, Regulator.target.name, maxAge = maxAge, sampling = 50.milliseconds) { + name = "target" + } + } + + plot { + plotDeviceProperty(device.start, LimitSwitch.locked.name, maxAge = maxAge, sampling = 50.milliseconds) { + name = "start measured" + mode = ScatterMode.markers + } + plotDeviceProperty(device.end, LimitSwitch.locked.name, maxAge = maxAge, sampling = 50.milliseconds) { + name = "end measured" + mode = ScatterMode.markers + } + } + + } +} + +fun main() = application { + val context = Context { + plugin(DeviceManager) + plugin(PlotlyPlugin) + plugin(ClockManager) + } + + class MutablePidParameters( + kp: Double, + ki: Double, + kd: Double, + timeStep: Duration, + ) : PidParameters { + override var kp by mutableStateOf(kp) + override var ki by mutableStateOf(ki) + override var kd by mutableStateOf(kd) + override var timeStep by mutableStateOf(timeStep) + } + + val pidParameters = remember { + MutablePidParameters( + kp = 2.5, + ki = 0.0, + kd = -0.1, + timeStep = 0.005.seconds + ) + } + + context.launchPidDevice( + DoubleRangeState(0.0, -6.0..6.0), + pidParameters, + mass = 0.05 + ) + + Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) { + MaterialTheme { + Column { + Row { + Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) + TextField( + String.format("%.2f",pidParameters.kp), + { pidParameters.kp = it.toDouble() }, + Modifier.width(100.dp), + enabled = false + ) + Slider( + pidParameters.kp.toFloat(), + { pidParameters.kp = it.toDouble() }, + valueRange = 0f..20f, + steps = 100 + ) + } + Row { + Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) + TextField( + String.format("%.2f",pidParameters.ki), + { pidParameters.ki = it.toDouble() }, + Modifier.width(100.dp), + enabled = false + ) + + Slider( + pidParameters.ki.toFloat(), + { pidParameters.ki = it.toDouble() }, + valueRange = -10f..10f, + steps = 100 + ) + } + Row { + Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) + TextField( + String.format("%.2f",pidParameters.kd), + { pidParameters.kd = it.toDouble() }, + Modifier.width(100.dp), + enabled = false + ) + + Slider( + pidParameters.kd.toFloat(), + { pidParameters.kd = it.toDouble() }, + valueRange = -10f..10f, + steps = 100 + ) + } + + Row { + Text("dt:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) + TextField( + pidParameters.timeStep.toString(DurationUnit.MILLISECONDS), + { pidParameters.timeStep = it.toDouble().milliseconds }, + Modifier.width(100.dp), + enabled = false + ) + + Slider( + pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(), + { pidParameters.timeStep = it.toDouble().milliseconds }, + valueRange = 0f..100f, + steps = 100 + ) + } + Row { + Button({ + pidParameters.run { + kp = 2.5 + ki = 0.0 + kd = -0.1 + timeStep = 0.005.seconds + } + }) { + Text("Reset") + } + } + } + } + } +} \ No newline at end of file diff --git a/demo/constructor/src/jvmMain/resources/logback.xml b/demo/constructor/src/jvmMain/resources/logback.xml new file mode 100644 index 0000000..3865b14 --- /dev/null +++ b/demo/constructor/src/jvmMain/resources/logback.xml @@ -0,0 +1,11 @@ + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + \ No newline at end of file diff --git a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt index 5c89c26..5eafabf 100644 --- a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt +++ b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt @@ -1,8 +1,11 @@ package space.kscience.controls.demo -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock @@ -19,18 +22,19 @@ import space.kscience.dataforge.meta.get import space.kscience.dataforge.meta.int import space.kscience.magix.api.MagixEndpoint import space.kscience.magix.api.subscribe -import space.kscience.magix.rsocket.rSocketWithTcp -import space.kscience.magix.rsocket.rSocketWithWebSockets +import space.kscience.magix.rsocket.rSocketStreamWithWebSockets import space.kscience.magix.server.RSocketMagixFlowPlugin import space.kscience.magix.server.startMagixServer import space.kscience.plotly.Plotly -import space.kscience.plotly.bar +import space.kscience.plotly.PlotlyConfig import space.kscience.plotly.layout +import space.kscience.plotly.models.Bar import space.kscience.plotly.plot import space.kscience.plotly.server.PlotlyUpdateMode import space.kscience.plotly.server.serve import space.kscience.plotly.server.show import space.kscince.magix.zmq.ZmqMagixFlowPlugin +import space.kscince.magix.zmq.zmq import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.ZERO @@ -49,14 +53,13 @@ class MassDevice(context: Context, meta: Meta) : DeviceBySpec(MassDe val value by doubleProperty { randomValue } override suspend fun MassDevice.onOpen() { - doRecurring((meta["delay"].int ?: 10).milliseconds) { + doRecurring((meta["delay"].int ?: 5).milliseconds) { read(value) } } } } -@OptIn(DelicateCoroutinesApi::class) suspend fun main() { val context = Context("Mass") @@ -65,65 +68,69 @@ suspend fun main() { ZmqMagixFlowPlugin() ) - val numDevices = 100 + val numDevices = 50 + repeat(numDevices) { - context.launch(newFixedThreadPoolContext(2, "Device${it}")) { - delay(1) - val deviceContext = Context("Device${it}") { - plugin(DeviceManager) + delay(1) + val deviceContext = Context("Device${it}") { + plugin(DeviceManager) + } + + val deviceManager = deviceContext.request(DeviceManager) + + deviceManager.install("device$it", MassDevice) + + val endpointId = "device$it" + val deviceEndpoint = MagixEndpoint.rSocketStreamWithWebSockets("localhost") + deviceManager.launchMagixService(deviceEndpoint, endpointId, Dispatchers.IO) + + } + + val trace = Bar { + context.launch(Dispatchers.IO) { + val monitorEndpoint = MagixEndpoint.zmq("localhost") + + val mutex = Mutex() + + val latest = HashMap() + val max = HashMap() + + monitorEndpoint.subscribe(DeviceManager.magixFormat).onEach { (magixMessage, payload) -> + mutex.withLock { + val delay = Clock.System.now() - payload.time + latest[magixMessage.sourceEndpoint] = Clock.System.now() - payload.time + max[magixMessage.sourceEndpoint] = + maxOf(delay, max[magixMessage.sourceEndpoint] ?: ZERO) + } + }.launchIn(this) + + while (isActive) { + delay(200) + mutex.withLock { + val sorted = max.mapKeys { it.key.substring(6).toInt() }.toSortedMap() + latest.clear() + max.clear() + x.numbers = sorted.keys + y.numbers = sorted.values.map { it.inWholeMicroseconds / 1000.0 + 0.0001 } + } } - - val deviceManager = deviceContext.request(DeviceManager) - - deviceManager.install("device$it", MassDevice) - - val endpointId = "device$it" - val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") - deviceManager.launchMagixService(deviceEndpoint, endpointId) } } - val application = Plotly.serve(port = 9091, scope = context) { + val application = Plotly.serve(port = 9091) { updateMode = PlotlyUpdateMode.PUSH updateInterval = 1000 + page { container -> - plot(renderer = container) { + plot(renderer = container, config = PlotlyConfig { saveAsSvg() }) { layout { - title = "Latest event" +// title = "Latest event" + xaxis.title = "Device number" yaxis.title = "Maximum latency in ms" } - bar { - launch(Dispatchers.IO) { - val monitorEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") - - val mutex = Mutex() - - val latest = HashMap() - val max = HashMap() - - monitorEndpoint.subscribe(DeviceManager.magixFormat).onEach { (magixMessage, payload) -> - mutex.withLock { - val delay = Clock.System.now() - payload.time!! - latest[magixMessage.sourceEndpoint] = Clock.System.now() - payload.time!! - max[magixMessage.sourceEndpoint] = - maxOf(delay, max[magixMessage.sourceEndpoint] ?: ZERO) - } - }.launchIn(this) - - while (isActive) { - delay(200) - mutex.withLock { - val sorted = max.mapKeys { it.key.substring(6).toInt() }.toSortedMap() - latest.clear() - max.clear() - x.numbers = sorted.keys - y.numbers = sorted.values.map { it.inWholeMilliseconds / 1000.0 + 0.0001 } - } - } - } - } + traces(trace) } } } diff --git a/demo/mks-pdr900/api/mks-pdr900.api b/demo/mks-pdr900/api/mks-pdr900.api index a9c9ecb..35e5f3c 100644 --- a/demo/mks-pdr900/api/mks-pdr900.api +++ b/demo/mks-pdr900/api/mks-pdr900.api @@ -10,19 +10,11 @@ public final class center/sciprog/devices/mks/MksPdr900Device : space/kscience/c public final class center/sciprog/devices/mks/MksPdr900Device$Companion : space/kscience/controls/spec/DeviceSpec, space/kscience/dataforge/context/Factory { public fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lcenter/sciprog/devices/mks/MksPdr900Device; public synthetic fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object; - public final fun getChannel ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; - public final fun getError ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; - public final fun getPowerOn ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getChannel ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; + public final fun getError ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; + public final fun getPowerOn ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public final fun getValue ()Lspace/kscience/controls/spec/DevicePropertySpec; public fun onClose (Lcenter/sciprog/devices/mks/MksPdr900Device;)V public synthetic fun onClose (Lspace/kscience/controls/api/Device;)V } -public final class center/sciprog/devices/mks/NullableStringMetaConverter : space/kscience/dataforge/meta/transformations/MetaConverter { - public static final field INSTANCE Lcenter/sciprog/devices/mks/NullableStringMetaConverter; - public synthetic fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object; - public fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/String; - public synthetic fun objectToMeta (Ljava/lang/Object;)Lspace/kscience/dataforge/meta/Meta; - public fun objectToMeta (Ljava/lang/String;)Lspace/kscience/dataforge/meta/Meta; -} - diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt index 949ced9..b0fd562 100644 --- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt +++ b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt @@ -4,15 +4,14 @@ import kotlinx.coroutines.withTimeoutOrNull import space.kscience.controls.ports.Ports import space.kscience.controls.ports.SynchronousPort import space.kscience.controls.ports.respondStringWithDelimiter -import space.kscience.controls.ports.synchronous import space.kscience.controls.spec.* import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Factory import space.kscience.dataforge.context.request import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter import space.kscience.dataforge.meta.get import space.kscience.dataforge.meta.int -import space.kscience.dataforge.meta.transformations.MetaConverter //TODO this device is not tested @@ -22,7 +21,7 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec writePowerOn(value) }) val channel by logicalProperty(MetaConverter.int) val value by doubleProperty(read = { - readChannelData(get(channel) ?: DEFAULT_CHANNEL) + readChannelData(getOrRead(channel)) }) val error by logicalProperty(MetaConverter.string) diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/NullableStringMetaConverter.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/NullableStringMetaConverter.kt deleted file mode 100644 index 40c20ed..0000000 --- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/NullableStringMetaConverter.kt +++ /dev/null @@ -1,10 +0,0 @@ -package center.sciprog.devices.mks - -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.string -import space.kscience.dataforge.meta.transformations.MetaConverter - -object NullableStringMetaConverter : MetaConverter { - override fun metaToObject(meta: Meta): String? = meta.string - override fun objectToMeta(obj: String?): Meta = Meta {} -} \ No newline at end of file diff --git a/demo/motors/api/motors.api b/demo/motors/api/motors.api index 506d7ee..05f430a 100644 --- a/demo/motors/api/motors.api +++ b/demo/motors/api/motors.api @@ -1,6 +1,6 @@ public final class ru/mipt/npm/devices/pimotionmaster/FxDevicePropertiesKt { public static final fun fxProperty (Lspace/kscience/controls/api/Device;Lspace/kscience/controls/spec/DevicePropertySpec;)Ljavafx/beans/property/ReadOnlyProperty; - public static final fun fxProperty (Lspace/kscience/controls/api/Device;Lspace/kscience/controls/spec/WritableDevicePropertySpec;)Ljavafx/beans/property/Property; + public static final fun fxProperty (Lspace/kscience/controls/api/Device;Lspace/kscience/controls/spec/MutableDevicePropertySpec;)Ljavafx/beans/property/Property; } public final class ru/mipt/npm/devices/pimotionmaster/PiDebugServerKt { @@ -50,19 +50,19 @@ public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Axis } public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Axis$Companion : space/kscience/controls/spec/DeviceSpec { - public final fun getClosedLoop ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; - public final fun getEnabled ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getClosedLoop ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; + public final fun getEnabled ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public final fun getHalt ()Lspace/kscience/controls/spec/DeviceActionSpec; public final fun getMaxPosition ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getMinPosition ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getMove ()Lspace/kscience/controls/spec/DeviceActionSpec; public final fun getMoveToReference ()Lspace/kscience/controls/spec/DeviceActionSpec; public final fun getOnTarget ()Lspace/kscience/controls/spec/DevicePropertySpec; - public final fun getOpenLoopTarget ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getOpenLoopTarget ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public final fun getPosition ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getReference ()Lspace/kscience/controls/spec/DevicePropertySpec; - public final fun getTargetPosition ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; - public final fun getVelocity ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getTargetPosition ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; + public final fun getVelocity ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; } public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Companion : space/kscience/controls/spec/DeviceSpec, space/kscience/dataforge/context/Factory { @@ -75,7 +75,7 @@ public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Compa public final fun getIdentity ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getInitialize ()Lspace/kscience/controls/spec/DeviceActionSpec; public final fun getStop ()Lspace/kscience/controls/spec/DeviceActionSpec; - public final fun getTimeout ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getTimeout ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; } public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterView : tornadofx/View { diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt index 4eff6c4..69dfb79 100644 --- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt +++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt @@ -16,11 +16,7 @@ import space.kscience.controls.api.PropertyDescriptor import space.kscience.controls.ports.* import space.kscience.controls.spec.* import space.kscience.dataforge.context.* -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.asValue -import space.kscience.dataforge.meta.double -import space.kscience.dataforge.meta.get -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.dataforge.meta.* import space.kscience.dataforge.names.NameToken import kotlin.collections.component1 import kotlin.collections.component2 @@ -29,10 +25,10 @@ import kotlin.time.Duration.Companion.milliseconds class PiMotionMasterDevice( context: Context, - private val portFactory: PortFactory = KtorTcpPort, + private val portFactory: Factory = KtorTcpPort, ) : DeviceBySpec(PiMotionMasterDevice, context), DeviceHub { - private var port: Port? = null + private var port: AsynchronousPort? = null //TODO make proxy work //PortProxy { portFactory(address ?: error("The device is not connected"), context) } @@ -87,7 +83,7 @@ class PiMotionMasterDevice( suspend fun getErrorCode(): Int = mutex.withLock { withTimeout(timeoutValue) { sendCommandInternal("ERR?") - val errorString = port?.receiving()?.withStringDelimiter("\n")?.first() ?: error("Not connected to device") + val errorString = port?.subscribe()?.withStringDelimiter("\n")?.first() ?: error("Not connected to device") errorString.trim().toInt() } } @@ -100,7 +96,7 @@ class PiMotionMasterDevice( try { withTimeout(timeoutValue) { sendCommandInternal(command, *arguments) - val phrases = port?.receiving()?.withStringDelimiter("\n") ?: error("Not connected to device") + val phrases = port?.subscribe()?.withStringDelimiter("\n") ?: error("Not connected to device") phrases.transformWhile { line -> emit(line) line.endsWith(" \n") @@ -138,7 +134,7 @@ class PiMotionMasterDevice( override fun build(context: Context, meta: Meta): PiMotionMasterDevice = PiMotionMasterDevice(context) val connected by booleanProperty(descriptorBuilder = { - info = "True if the connection address is defined and the device is initialized" + description = "True if the connection address is defined and the device is initialized" }) { port != null } @@ -157,13 +153,13 @@ class PiMotionMasterDevice( } val stop by unitAction({ - info = "Stop all axis" + description = "Stop all axis" }) { send("STP") } val connect by action(MetaConverter.meta, MetaConverter.unit, descriptorBuilder = { - info = "Connect to specific port and initialize axis" + description = "Connect to specific port and initialize axis" }) { portSpec -> //Clear current actions if present if (port != null) { @@ -172,7 +168,7 @@ class PiMotionMasterDevice( //Update port //address = portSpec.node port = portFactory(portSpec, context) - updateLogical(connected, true) + propertyChanged(connected, true) // connector.open() //Initialize axes val idn = read(identity) @@ -189,19 +185,19 @@ class PiMotionMasterDevice( } val disconnect by unitAction({ - info = "Disconnect the program from the device if it is connected" + description = "Disconnect the program from the device if it is connected" }) { port?.let { execute(stop) it.close() } port = null - updateLogical(connected, false) + propertyChanged(connected, false) } val timeout by mutableProperty(MetaConverter.duration, PiMotionMasterDevice::timeoutValue) { - info = "Timeout" + description = "Timeout" } } @@ -245,8 +241,8 @@ class PiMotionMasterDevice( read = { readAxisBoolean("$command?") }, - write = { - writeAxisBoolean(command, it) + write = { _, value -> + writeAxisBoolean(command, value) }, descriptorBuilder = descriptorBuilder ) @@ -259,7 +255,7 @@ class PiMotionMasterDevice( mm.requestAndParse("$command?", axisId)[axisId]?.toDoubleOrNull() ?: error("Malformed $command response. Should include float value for $axisId") }, - write = { newValue -> + write = { _, newValue -> mm.send(command, axisId, newValue.toString()) mm.failIfError() }, @@ -267,7 +263,7 @@ class PiMotionMasterDevice( ) val enabled by axisBooleanProperty("EAX") { - info = "Motor enable state." + description = "Motor enable state." } val halt by unitAction { @@ -275,20 +271,20 @@ class PiMotionMasterDevice( } val targetPosition by axisNumberProperty("MOV") { - info = """ + description = """ Sets a new absolute target position for the specified axis. Servo mode must be switched on for the commanded axis prior to using this command (closed-loop operation). """.trimIndent() } val onTarget by booleanProperty({ - info = "Queries the on-target state of the specified axis." + description = "Queries the on-target state of the specified axis." }) { readAxisBoolean("ONT?") } val reference by booleanProperty({ - info = "Get Referencing Result" + description = "Get Referencing Result" }) { readAxisBoolean("FRF?") } @@ -298,36 +294,36 @@ class PiMotionMasterDevice( } val minPosition by doubleProperty({ - info = "Minimal position value for the axis" + description = "Minimal position value for the axis" }) { mm.requestAndParse("TMN?", axisId)[axisId]?.toDoubleOrNull() ?: error("Malformed `TMN?` response. Should include float value for $axisId") } val maxPosition by doubleProperty({ - info = "Maximal position value for the axis" + description = "Maximal position value for the axis" }) { mm.requestAndParse("TMX?", axisId)[axisId]?.toDoubleOrNull() ?: error("Malformed `TMX?` response. Should include float value for $axisId") } val position by doubleProperty({ - info = "The current axis position." + description = "The current axis position." }) { mm.requestAndParse("POS?", axisId)[axisId]?.toDoubleOrNull() ?: error("Malformed `POS?` response. Should include float value for $axisId") } val openLoopTarget by axisNumberProperty("OMA") { - info = "Position for open-loop operation." + description = "Position for open-loop operation." } val closedLoop by axisBooleanProperty("SVO") { - info = "Servo closed loop mode" + description = "Servo closed loop mode" } val velocity by axisNumberProperty("VEL") { - info = "Velocity value for closed-loop operation" + description = "Velocity value for closed-loop operation" } val move by action(MetaConverter.meta, MetaConverter.unit) { diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt index 8efe4e9..f343d7c 100644 --- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt +++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt @@ -5,14 +5,15 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import space.kscience.controls.api.Socket -import space.kscience.controls.ports.AbstractPort +import space.kscience.controls.api.AsynchronousSocket +import space.kscience.controls.ports.AbstractAsynchronousPort import space.kscience.controls.ports.withDelimiter import space.kscience.dataforge.context.* +import space.kscience.dataforge.meta.Meta import kotlin.math.abs import kotlin.time.Duration -abstract class VirtualDevice(val scope: CoroutineScope) : Socket { +abstract class VirtualDevice(val scope: CoroutineScope) : AsynchronousSocket { protected abstract suspend fun evaluateRequest(request: ByteArray) @@ -40,33 +41,42 @@ abstract class VirtualDevice(val scope: CoroutineScope) : Socket { toRespond.send(response) } - override fun receiving(): Flow = toRespond.receiveAsFlow() + override fun subscribe(): Flow = toRespond.receiveAsFlow() protected fun respondInFuture(delay: Duration, response: suspend () -> ByteArray): Job = scope.launch { delay(delay) respond(response()) } - override fun isOpen(): Boolean = scope.isActive + override val isOpen: Boolean + get() = scope.isActive override fun close() = scope.cancel() } -class VirtualPort(private val device: VirtualDevice, context: Context) : AbstractPort(context) { +class VirtualPort(private val device: VirtualDevice, context: Context) : AbstractAsynchronousPort(context, Meta.EMPTY) { - private val respondJob = device.receiving().onEach { - receive(it) - }.catch { - it.printStackTrace() - }.launchIn(scope) + private var respondJob: Job? = null + + override fun onOpen() { + respondJob = device.subscribe().onEach { + receive(it) + }.catch { + it.printStackTrace() + }.launchIn(scope) + + } override suspend fun write(data: ByteArray) { device.send(data) } + override val isOpen: Boolean + get() = respondJob?.isActive == true + override fun close() { - respondJob.cancel() + respondJob?.cancel() super.close() } } @@ -78,7 +88,7 @@ class PiMotionMasterVirtualDevice( scope: CoroutineScope = context, ) : VirtualDevice(scope), ContextAware { - init { + override fun open() { //add asynchronous send logic here } @@ -102,9 +112,11 @@ class PiMotionMasterVirtualDevice( abs(distance) < proposedStep -> { position = targetPosition } + targetPosition > position -> { position += proposedStep } + else -> { position -= proposedStep } @@ -180,8 +192,10 @@ class PiMotionMasterVirtualDevice( when (command) { "XXX" -> { } + "IDN?", "*IDN?" -> respond("(c)2015 Physik Instrumente(PI) Karlsruhe, C-885.M1 TCP-IP Master,0,1.0.0.1") - "VER?" -> respond(""" + "VER?" -> respond( + """ 2: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550039, 00.039 3: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550040, 00.039 4: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550041, 00.039 @@ -195,8 +209,11 @@ class PiMotionMasterVirtualDevice( 12: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550049, 00.039 13: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550051, 00.039 FW_ARM: V1.0.0.1 - """.trimIndent()) - "HLP?" -> respond(""" + """.trimIndent() + ) + + "HLP?" -> respond( + """ The following commands are valid: #4 Request Status Register #5 Request Motion Status @@ -235,11 +252,14 @@ class PiMotionMasterVirtualDevice( VEL? [{}] Get Closed-Loop Velocity VER? Get Versions Of Firmware And Drivers end of help - """.trimIndent()) + """.trimIndent() + ) + "ERR?" -> { respond(errorCode.toString()) errorCode = 0 } + "SAI?" -> respond(axisState.keys.joinToString(separator = " \n")) "CST?" -> respondForAllAxis(axisIds) { "L-220.20SG" } "RON?" -> respondForAllAxis(axisIds) { referenceMode } @@ -255,15 +275,19 @@ class PiMotionMasterVirtualDevice( "SVO" -> doForEachAxis(parts) { key, value -> axisState[key]?.servoMode = value.toInt() } + "MOV" -> doForEachAxis(parts) { key, value -> axisState[key]?.targetPosition = value.toDouble() } + "VEL" -> doForEachAxis(parts) { key, value -> axisState[key]?.velocity = value.toDouble() } + "INI" -> { logger.info { "Axes initialized!" } } + else -> { logger.warn { "Unknown command: $command in message ${String(request)}" } errorCode = 2 diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt index 0328631..adef540 100644 --- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt +++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt @@ -32,7 +32,7 @@ fun D.fxProperty( } } -fun D.fxProperty(spec: WritableDevicePropertySpec): Property = +fun D.fxProperty(spec: MutableDevicePropertySpec): Property = object : ObjectPropertyBase() { override fun getBean(): Any = this override fun getName(): String = spec.name @@ -51,7 +51,7 @@ fun D.fxProperty(spec: WritableDevicePropertySpec): onChange { newValue -> if (newValue != null) { - set(spec, newValue) + writeAsync(spec, newValue) } } } diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt index 021dff5..c69b7fe 100644 --- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt +++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt @@ -29,7 +29,7 @@ fun Context.launchPiDebugServer(port: Int, axes: List): Job = launch(exc val output = socket.openWriteChannel() val sendJob = launch { - virtualDevice.receiving().collect { + virtualDevice.subscribe().collect { //println("Sending: ${it.decodeToString()}") output.writeAvailable(it) output.flush() diff --git a/demo/notebooks/constructor.ipynb b/demo/notebooks/constructor.ipynb new file mode 100644 index 0000000..3b4d73e --- /dev/null +++ b/demo/notebooks/constructor.ipynb @@ -0,0 +1,195 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "//import space.kscience.controls.jupyter.ControlsJupyter\n", + "\n", + "//USE(ControlsJupyter())\n", + "USE{\n", + " repositories{\n", + " maven(\"https://repo.kotlin.link\")\n", + " }\n", + " dependencies{\n", + " implementation(\"space.kscience:controls-jupyter-jvm:0.3.0-dev-2\")\n", + " }\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "class LinearDrive(\n", + " context: Context,\n", + " state: DoubleRangeState,\n", + " mass: Double,\n", + " pidParameters: PidParameters,\n", + " meta: Meta = Meta.EMPTY,\n", + ") : DeviceConstructor(context.request(DeviceManager), meta) {\n", + "\n", + " val drive by device(VirtualDrive.factory(mass, state))\n", + " val pid by device(PidRegulator(drive, pidParameters))\n", + "\n", + " val start by device(LimitSwitch.factory(state.atStartState))\n", + " val end by device(LimitSwitch.factory(state.atEndState))\n", + "\n", + "\n", + " val position by property(state)\n", + " var target by mutableProperty(pid.mutablePropertyAsState(Regulator.target, 0.0))\n", + "}\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "import kotlin.time.Duration.Companion.milliseconds\n", + "import kotlin.time.Duration.Companion.seconds\n", + "\n", + "val state = DoubleRangeState(0.0, -5.0..5.0)\n", + "\n", + "val pidParameters = PidParameters(\n", + " kp = 2.5,\n", + " ki = 0.0,\n", + " kd = -0.1,\n", + " timeStep = 0.005.seconds\n", + ")\n", + "\n", + "val device = context.install(\"device\", LinearDrive(context, state, 0.005, pidParameters))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "\n", + "val job = device.run {\n", + " val clock = context.clock\n", + " val clockStart = clock.now()\n", + " doRecurring(10.milliseconds) {\n", + " val timeFromStart = clock.now() - clockStart\n", + " val t = timeFromStart.toDouble(DurationUnit.SECONDS)\n", + " val freq = 0.1\n", + "\n", + " target = 5 * sin(2.0 * PI * freq * t) +\n", + " sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))\n", + " }\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "val maxAge = 10.seconds\n", + "\n", + "\n", + "VisionForge.fragment {\n", + " vision {\n", + " plotly {\n", + " \n", + " plotDeviceProperty(device.pid, Regulator.target.name, maxAge = maxAge) {\n", + " name = \"target\"\n", + " }\n", + " \n", + " plotNumberState(context, state, maxAge = maxAge) {\n", + " name = \"real position\"\n", + " }\n", + " \n", + " plotDeviceProperty(device.pid, Regulator.position.name, maxAge = maxAge) {\n", + " name = \"read position\"\n", + " }\n", + " }\n", + " }\n", + "\n", + " vision {\n", + " plotly {\n", + " plotDeviceProperty(device.start, LimitSwitch.locked.name, maxAge = maxAge) {\n", + " name = \"start measured\"\n", + " mode = ScatterMode.markers\n", + " }\n", + " plotDeviceProperty(device.end, LimitSwitch.locked.name, maxAge = maxAge) {\n", + " name = \"end measured\"\n", + " mode = ScatterMode.markers\n", + " }\n", + " }\n", + " }\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "import kotlinx.coroutines.cancel\n", + "\n", + "job.cancel()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [], + "metadata": { + "collapsed": false + } + } + ], + "metadata": { + "kernelspec": { + "display_name": "Kotlin", + "language": "kotlin", + "name": "kotlin" + }, + "ktnbPluginMetadata": { + "projectDependencies": [ + "controls-kt.controls-jupyter.jvmMain" + ] + }, + "language_info": { + "codemirror_mode": "text/x-kotlin", + "file_extension": ".kt", + "mimetype": "text/x-kotlin", + "name": "kotlin", + "nbconvert_exporter": "", + "pygments_lexer": "kotlin", + "version": "1.8.20" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/templates/README-TEMPLATE.md b/docs/templates/README-TEMPLATE.md index 11e0905..ef2cb74 100644 --- a/docs/templates/README-TEMPLATE.md +++ b/docs/templates/README-TEMPLATE.md @@ -1,5 +1,7 @@ [![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub) +[![](https://maven.sciprog.center/api/badge/latest/kscience/space/kscience/controls-core-jvm?color=40c14a&name=repo.kotlin.link&prefix=v)](https://maven.sciprog.center/) + # Controls.kt Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing. diff --git a/gradle.properties b/gradle.properties index 5b956f1..4f937f0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,10 +4,7 @@ kotlin.native.ignoreDisabledTargets=true org.gradle.parallel=true -publishing.github=false -publishing.sonatype=false - org.gradle.configureondemand=true org.gradle.jvmargs=-Xmx4096m -toolsVersion=0.14.10-kotlin-1.9.0 \ No newline at end of file +toolsVersion=0.15.2-kotlin-1.9.22 \ No newline at end of file diff --git a/magix/README.md b/magix/README.md new file mode 100644 index 0000000..de1d7a3 --- /dev/null +++ b/magix/README.md @@ -0,0 +1,4 @@ +# Module magix + + + diff --git a/magix/magix-api/README.md b/magix/magix-api/README.md index ee00c53..d624d84 100644 --- a/magix/magix-api/README.md +++ b/magix/magix-api/README.md @@ -6,18 +6,16 @@ A kotlin API for magix standard and some zero-dependency magix services ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-api:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-api:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-api:0.2.0") + implementation("space.kscience:magix-api:0.3.0") } ``` diff --git a/magix/magix-api/build.gradle.kts b/magix/magix-api/build.gradle.kts index 159989d..68151c3 100644 --- a/magix/magix-api/build.gradle.kts +++ b/magix/magix-api/build.gradle.kts @@ -17,6 +17,10 @@ kscience { useSerialization{ json() } + + commonMain{ + implementation(spclibs.atomicfu) + } } readme{ diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt index 115fcff..42d55b7 100644 --- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt +++ b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt @@ -26,7 +26,7 @@ public data class MagixFormat( public fun MagixEndpoint.subscribe( format: MagixFormat, originFilter: Collection? = null, - targetFilter: Collection? = null, + targetFilter: Collection? = null, ): Flow> = subscribe( MagixMessageFilter(format = format.formats, source = originFilter, target = targetFilter) ).map { diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt index 4bd4746..72c7f7f 100644 --- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt +++ b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt @@ -11,7 +11,7 @@ import kotlinx.serialization.Serializable public data class MagixMessageFilter( val format: Collection? = null, val source: Collection? = null, - val target: Collection? = null, + val target: Collection? = null, ) { public fun accepts(message: MagixMessage): Boolean = diff --git a/magix/magix-java-endpoint/README.md b/magix/magix-java-endpoint/README.md index abcaa6f..40f4c8e 100644 --- a/magix/magix-java-endpoint/README.md +++ b/magix/magix-java-endpoint/README.md @@ -6,18 +6,16 @@ Java API to work with magix endpoints without Kotlin ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-java-endpoint:0.2.0") + implementation("space.kscience:magix-java-endpoint:0.3.0") } ``` diff --git a/magix/magix-mqtt/README.md b/magix/magix-mqtt/README.md index 6c34fdc..35165a0 100644 --- a/magix/magix-mqtt/README.md +++ b/magix/magix-mqtt/README.md @@ -6,18 +6,16 @@ MQTT client magix endpoint ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-mqtt:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-mqtt:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-mqtt:0.2.0") + implementation("space.kscience:magix-mqtt:0.3.0") } ``` diff --git a/magix/magix-rabbit/README.md b/magix/magix-rabbit/README.md index 7fc42ad..609303d 100644 --- a/magix/magix-rabbit/README.md +++ b/magix/magix-rabbit/README.md @@ -6,18 +6,16 @@ RabbitMQ client magix endpoint ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-rabbit:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-rabbit:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-rabbit:0.2.0") + implementation("space.kscience:magix-rabbit:0.3.0") } ``` diff --git a/magix/magix-rsocket/README.md b/magix/magix-rsocket/README.md index 799717d..0a1b34b 100644 --- a/magix/magix-rsocket/README.md +++ b/magix/magix-rsocket/README.md @@ -6,18 +6,16 @@ Magix endpoint (client) based on RSocket ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-rsocket:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-rsocket:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-rsocket:0.2.0") + implementation("space.kscience:magix-rsocket:0.3.0") } ``` diff --git a/magix/magix-server/README.md b/magix/magix-server/README.md index 27d97e0..4175dcd 100644 --- a/magix/magix-server/README.md +++ b/magix/magix-server/README.md @@ -6,18 +6,16 @@ A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket route ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-server:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-server:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-server:0.2.0") + implementation("space.kscience:magix-server:0.3.0") } ``` diff --git a/magix/magix-storage/README.md b/magix/magix-storage/README.md index fa367b4..db1d340 100644 --- a/magix/magix-storage/README.md +++ b/magix/magix-storage/README.md @@ -6,18 +6,16 @@ Magix history database API ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-storage:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-storage:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-storage:0.2.0") + implementation("space.kscience:magix-storage:0.3.0") } ``` diff --git a/magix/magix-storage/magix-storage-xodus/README.md b/magix/magix-storage/magix-storage-xodus/README.md index b3d871b..2d9e202 100644 --- a/magix/magix-storage/magix-storage-xodus/README.md +++ b/magix/magix-storage/magix-storage-xodus/README.md @@ -6,18 +6,16 @@ ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-storage-xodus:0.2.0") + implementation("space.kscience:magix-storage-xodus:0.3.0") } ``` diff --git a/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt b/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt index 4a3efa3..8d0d852 100644 --- a/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt +++ b/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt @@ -39,9 +39,8 @@ public class XodusMagixHistory(private val store: PersistentEntityStore) : Write setBlobString(MagixMessage::payload.name, magixJson.encodeToString(message.payload)) - message.targetEndpoint?.let { - setProperty(MagixMessage::targetEndpoint.name, it) - } + setProperty(MagixMessage::targetEndpoint.name, (message.targetEndpoint ?: "")) + message.id?.let { setProperty(MagixMessage::id.name, it) } @@ -68,14 +67,14 @@ public class XodusMagixHistory(private val store: PersistentEntityStore) : Write ): Unit = store.executeInReadonlyTransaction { transaction -> val all = transaction.getAll(XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE) - fun StoreTransaction.findAllIn( + fun findAllIn( entityType: String, field: String, - values: Collection?, + values: Collection?, ): EntityIterable? { var union: EntityIterable? = null values?.forEach { - val filter = transaction.find(entityType, field, it) + val filter = transaction.find(entityType, field, it ?: "") union = union?.union(filter) ?: filter } return union @@ -84,21 +83,24 @@ public class XodusMagixHistory(private val store: PersistentEntityStore) : Write // filter by magix filter val filteredByMagix: EntityIterable = magixFilter?.let { mf -> var res = all - transaction.findAllIn(XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE, MagixMessage::format.name, mf.format) - ?.let { - res = res.intersect(it) - } - transaction.findAllIn( + findAllIn( + XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE, + MagixMessage::format.name, + mf.format + )?.let { + res = res.intersect(it) + } + findAllIn( XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE, MagixMessage::sourceEndpoint.name, mf.source )?.let { res = res.intersect(it) } - transaction.findAllIn( + findAllIn( XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE, MagixMessage::targetEndpoint.name, - mf.target + mf.target?.filterNotNull() )?.let { res = res.intersect(it) } diff --git a/magix/magix-utils/build.gradle.kts b/magix/magix-utils/build.gradle.kts new file mode 100644 index 0000000..b22b5f0 --- /dev/null +++ b/magix/magix-utils/build.gradle.kts @@ -0,0 +1,27 @@ +import space.kscience.gradle.Maturity + +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +description = """ + Common utilities and services for Magix endpoints. +""".trimIndent() + +val dataforgeVersion: String by rootProject.extra + +kscience { + jvm() + js() + native() + useSerialization() + commonMain { + api(projects.magix.magixApi) + api("space.kscience:dataforge-meta:$dataforgeVersion") + } +} + +readme { + maturity = Maturity.EXPERIMENTAL +} \ No newline at end of file diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt similarity index 97% rename from magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt rename to magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt index 3272131..c1a9466 100644 --- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt +++ b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt @@ -127,11 +127,11 @@ public fun CoroutineScope.launchMagixRegistry( * * If [registryEndpoint] field is provided, send request only to given endpoint. * - * @param endpointName the name of endpoint requesting a property + * @param sourceEndpoint the name of endpoint requesting a property */ public suspend fun MagixEndpoint.getProperty( propertyName: String, - endpointName: String, + sourceEndpoint: String, user: JsonElement? = null, registryEndpoint: String? = null, ): Flow> = subscribe( @@ -146,7 +146,7 @@ public suspend fun MagixEndpoint.getProperty( send( MagixRegistryMessage.format, MagixRegistryRequestMessage(propertyName), - source = endpointName, + source = sourceEndpoint, target = registryEndpoint, user = user ) diff --git a/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/WatcherEndpointWrapper.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/WatcherEndpointWrapper.kt new file mode 100644 index 0000000..8560c79 --- /dev/null +++ b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/WatcherEndpointWrapper.kt @@ -0,0 +1,82 @@ +package space.kscience.magix.services + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onEach +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.string +import space.kscience.magix.api.MagixEndpoint +import space.kscience.magix.api.MagixMessage +import space.kscience.magix.api.MagixMessageFilter +import space.kscience.magix.api.send +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +public class WatcherEndpointWrapper( + private val scope: CoroutineScope, + private val endpointName: String, + private val endpoint: MagixEndpoint, + private val meta: Meta, +) : MagixEndpoint { + + private val watchDogJob: Job = scope.launch { + val filter = MagixMessageFilter( + format = listOf(MAGIX_WATCHDOG_FORMAT), + target = listOf(null, endpointName) + ) + endpoint.subscribe(filter).filter { + it.payload.jsonPrimitive.content == MAGIX_PING + }.onEach { request -> + endpoint.send( + MagixMessage( + MAGIX_WATCHDOG_FORMAT, + JsonPrimitive(MAGIX_PONG), + sourceEndpoint = endpointName, + targetEndpoint = request.sourceEndpoint, + parentId = request.id + ) + ) + }.collect() + } + + private val heartBeatDelay: Duration = meta["heartbeat.period"].string?.let { Duration.parse(it) } ?: 10.seconds + //TODO add update from registry + + private val heartBeatJob = scope.launch { + while (isActive){ + delay(heartBeatDelay) + endpoint.send( + MagixMessage( + MAGIX_HEARTBEAT_FORMAT, + JsonNull, //TODO consider adding timestamp + endpointName + ) + ) + } + } + + override fun subscribe(filter: MagixMessageFilter): Flow = endpoint.subscribe(filter) + + override suspend fun broadcast(message: MagixMessage) { + endpoint.broadcast(message) + } + + override fun close() { + endpoint.close() + watchDogJob.cancel() + heartBeatJob.cancel() + } + + public companion object { + public const val MAGIX_WATCHDOG_FORMAT: String = "magix.watchdog" + public const val MAGIX_PING: String = "ping" + public const val MAGIX_PONG: String = "pong" + public const val MAGIX_HEARTBEAT_FORMAT: String = "magix.heartbeat" + } +} \ No newline at end of file diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/converters.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/converters.kt similarity index 100% rename from magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/converters.kt rename to magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/converters.kt diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt similarity index 100% rename from magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt rename to magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt diff --git a/magix/magix-zmq/README.md b/magix/magix-zmq/README.md index 1338cce..b016e6b 100644 --- a/magix/magix-zmq/README.md +++ b/magix/magix-zmq/README.md @@ -6,18 +6,16 @@ ZMQ client endpoint for Magix ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-zmq:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-zmq:0.3.0`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-zmq:0.2.0") + implementation("space.kscience:magix-zmq:0.3.0") } ``` diff --git a/magix/magix-zmq/build.gradle.kts b/magix/magix-zmq/build.gradle.kts index cf4ee9b..20cc246 100644 --- a/magix/magix-zmq/build.gradle.kts +++ b/magix/magix-zmq/build.gradle.kts @@ -12,7 +12,7 @@ description = """ dependencies { api(projects.magix.magixApi) api("org.slf4j:slf4j-api:2.0.6") - api("org.zeromq:jeromq:0.5.2") + api("org.zeromq:jeromq:0.5.3") } readme { diff --git a/settings.gradle.kts b/settings.gradle.kts index 0753e55..ed33e53 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -60,11 +60,16 @@ include( ":controls-server", ":controls-opcua", ":controls-modbus", + ":controls-plc4x", // ":controls-mongo", ":controls-storage", ":controls-storage:controls-xodus", + ":controls-constructor", + ":controls-vision", + ":controls-jupyter", ":magix", ":magix:magix-api", + ":magix:magix-utils", ":magix:magix-server", ":magix:magix-rsocket", ":magix:magix-java-endpoint", @@ -80,5 +85,6 @@ include( ":demo:car", ":demo:motors", ":demo:echo", - ":demo:mks-pdr900" + ":demo:mks-pdr900", + ":demo:constructor" )