Merge branch 'dev' into support/update_dependencies

This commit is contained in:
InsanusMokrassar 2024-04-29 18:09:22 +06:00
commit 8e7277df69
161 changed files with 5291 additions and 1436 deletions

View File

@ -3,6 +3,53 @@
## Unreleased ## Unreleased
### Added ### 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 - Core interfaces for building a device server
- Magix service for binding controls devices (both as RPC client and 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 - 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. - A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes.
- Magix history database API - Magix history database API
- ZMQ client endpoint for Magix - ZMQ client endpoint for Magix
### Changed
### Deprecated
### Removed
### Fixed
### Security

View File

@ -1,5 +1,7 @@
[![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub) [![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
Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing. Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing.
@ -42,6 +44,11 @@ Example view of a demo:
## Modules ## Modules
### [controls-constructor](controls-constructor)
> A low-code constructor for composite devices simulation
>
> **Maturity**: PROTOTYPE
### [controls-core](controls-core) ### [controls-core](controls-core)
> Core interfaces for building a device server > 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 > - [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) ### [controls-magix](controls-magix)
> Magix service for binding controls devices (both as RPC client and server) > Magix service for binding controls devices (both as RPC client and server)
> >
@ -113,6 +124,11 @@ Automatically checks consistency.
> >
> **Maturity**: PROTOTYPE > **Maturity**: PROTOTYPE
### [controls-vision](controls-vision)
> Dashboard and visualization extensions for devices
>
> **Maturity**: PROTOTYPE
### [demo](demo) ### [demo](demo)
> >
> **Maturity**: EXPERIMENTAL > **Maturity**: EXPERIMENTAL
@ -134,6 +150,10 @@ Automatically checks consistency.
> >
> **Maturity**: EXPERIMENTAL > **Maturity**: EXPERIMENTAL
### [demo/constructor](demo/constructor)
>
> **Maturity**: EXPERIMENTAL
### [demo/echo](demo/echo) ### [demo/echo](demo/echo)
> >
> **Maturity**: EXPERIMENTAL > **Maturity**: EXPERIMENTAL

View File

@ -1,4 +1,3 @@
import space.kscience.gradle.isInDevelopment
import space.kscience.gradle.useApache2Licence import space.kscience.gradle.useApache2Licence
import space.kscience.gradle.useSPCTeam import space.kscience.gradle.useSPCTeam
@ -9,25 +8,19 @@ plugins {
allprojects { allprojects {
group = "space.kscience" group = "space.kscience"
version = "0.2.0" version = "0.3.1-dev-1"
repositories{ repositories{
maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
} }
} }
ksciencePublish { ksciencePublish {
pom("https://github.com/SciProgCentre/controls.kt") { pom("https://github.com/SciProgCentre/controls-kt") {
useApache2Licence() useApache2Licence()
useSPCTeam() useSPCTeam()
} }
github("controls.kt", "SciProgCentre") repository("spc","https://maven.sciprog.center/kscience")
space( sonatype("https://oss.sonatype.org")
if (isInDevelopment) {
"https://maven.pkg.jetbrains.space/spc/p/sci/dev"
} else {
"https://maven.pkg.jetbrains.space/spc/p/sci/maven"
}
)
} }
readme.readmeTemplate = file("docs/templates/README-TEMPLATE.md") readme.readmeTemplate = file("docs/templates/README-TEMPLATE.md")

View File

@ -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")
}
```

View File

@ -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
}

View File

@ -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 <D : Device> device(
factory: Factory<D>,
meta: Meta? = null,
nameOverride: Name? = null,
metaLocation: Name? = null,
): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> =
PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> ->
val name = nameOverride ?: property.name.asName()
val device = install(name, factory, meta, metaLocation ?: name)
ReadOnlyProperty { _: DeviceConstructor, _ ->
device
}
}
public fun <D : Device> device(
device: D,
nameOverride: Name? = null,
): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> =
PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> ->
val name = nameOverride ?: property.name.asName()
install(name, device)
ReadOnlyProperty { _: DeviceConstructor, _ ->
device
}
}
/**
* Register a property and provide a direct reader for it
*/
public fun <T, S: DeviceState<T>> property(
state: S,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
nameOverride: String? = null,
): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, S>> =
PropertyDelegateProvider { _: DeviceConstructor, property ->
val name = nameOverride ?: property.name
val descriptor = PropertyDescriptor(name).apply(descriptorBuilder)
registerProperty(descriptor, state)
ReadOnlyProperty { _: DeviceConstructor, _ ->
state
}
}
/**
* Register external state as a property
*/
public fun <T : Any> property(
metaConverter: MetaConverter<T>,
reader: suspend () -> T,
readInterval: Duration,
initialState: T,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
nameOverride: String? = null,
): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, DeviceState<T>>> = property(
DeviceState.external(this, metaConverter, readInterval, initialState, reader),
descriptorBuilder,
nameOverride,
)
/**
* Register a mutable external state as a property
*/
public fun <T : Any> mutableProperty(
metaConverter: MetaConverter<T>,
reader: suspend () -> T,
writer: suspend (T) -> Unit,
readInterval: Duration,
initialState: T,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
nameOverride: String? = null,
): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property(
DeviceState.external(this, metaConverter, readInterval, initialState, reader, writer),
descriptorBuilder,
nameOverride,
)
/**
* Create and register a virtual mutable property with optional [callback]
*/
public fun <T> virtualProperty(
metaConverter: MetaConverter<T>,
initialState: T,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
nameOverride: String? = null,
callback: (T) -> Unit = {},
): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property(
DeviceState.virtual(metaConverter, initialState, callback),
descriptorBuilder,
nameOverride,
)
}

View File

@ -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<DeviceMessage>()
override val messageFlow: Flow<DeviceMessage>
get() = sharedMessageFlow
@OptIn(ExperimentalCoroutinesApi::class)
override val coroutineContext: CoroutineContext = context.newCoroutineContext(
SupervisorJob(context.coroutineContext[Job]) +
CoroutineName("Device $id") +
CoroutineExceptionHandler { _, throwable ->
context.launch {
sharedMessageFlow.emit(
DeviceErrorMessage(
errorMessage = throwable.message,
errorType = throwable::class.simpleName,
errorStackTrace = throwable.stackTraceToString()
)
)
}
}
)
private val _devices = hashMapOf<NameToken, Device>()
override val devices: Map<NameToken, Device> = _devices
/**
* Register and initialize (synchronize child's lifecycle state with group state) a new device in this group
*/
@OptIn(DFExperimental::class)
public fun <D : Device> install(token: NameToken, device: D): D {
require(_devices[token] == null) { "A child device with name $token already exists" }
//start the child device if needed
if (lifecycleState == STARTED || lifecycleState == STARTING) launch { device.start() }
_devices[token] = device
return device
}
private val properties: MutableMap<Name, Property> = hashMapOf()
/**
* Register a new property based on [DeviceState]. Properties could be modified dynamically
*/
public 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<Name, Action> = hashMapOf()
override val propertyDescriptors: Collection<PropertyDescriptor>
get() = properties.values.map { it.descriptor }
override val actionDescriptors: Collection<ActionDescriptor>
get() = actions.values.map { it.descriptor }
override suspend fun readProperty(propertyName: String): Meta =
properties[propertyName.parseAsName()]?.state?.valueAsMeta
?: error("Property with name $propertyName not found")
override fun getProperty(propertyName: String): Meta? = properties[propertyName.parseAsName()]?.state?.valueAsMeta
override suspend fun invalidate(propertyName: String) {
//does nothing for this implementation
}
override suspend fun writeProperty(propertyName: String, value: Meta) {
val property = (properties[propertyName.parseAsName()]?.state as? MutableDeviceState)
?: error("Property with name $propertyName not found")
property.valueAsMeta = value
}
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 <D : Device> DeviceGroup.install(name: Name, device: D): D {
return when (name.length) {
0 -> error("Can't use empty name for a child device")
1 -> install(name.first(), device)
else -> getOrCreateGroup(name.cutLast()).install(name.tokens.last(), device)
}
}
public fun <D : Device> DeviceGroup.install(name: String, device: D): D =
install(name.parseAsName(), device)
public fun <D : Device> DeviceGroup.install(device: D): D =
install(device.id, device)
public fun <D : Device> Context.install(name: String, device: D): D = request(DeviceManager).install(name, device)
/**
* Add a device creating intermediate groups if necessary. If device with given [name] already exists, throws an error.
* @param name the name of the device in the group
* @param factory a factory used to create a device
* @param deviceMeta meta override for this specific device
* @param metaLocation location of the template meta in parent group meta
*/
public fun <D : Device> DeviceGroup.install(
name: Name,
factory: Factory<D>,
deviceMeta: Meta? = null,
metaLocation: Name = name,
): D {
val newDevice = factory.build(context, Laminate(deviceMeta, meta[metaLocation]))
install(name, newDevice)
return newDevice
}
public fun <D : Device> DeviceGroup.install(
name: String,
factory: Factory<D>,
metaLocation: Name = name.parseAsName(),
metaBuilder: (MutableMeta.() -> Unit)? = null,
): D = install(name.parseAsName(), factory, metaBuilder?.let { Meta(it) }, metaLocation)
/**
* Create or edit a group with a given [name].
*/
public fun DeviceGroup.registerDeviceGroup(name: Name, block: DeviceGroup.() -> Unit): DeviceGroup =
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 <T : Any> DeviceGroup.registerProperty(
name: String,
state: DeviceState<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
) {
registerProperty(
PropertyDescriptor(name).apply(descriptorBuilder),
state
)
}
/**
* Register a mutable property based on mutable [state]
*/
public fun <T : Any> DeviceGroup.registerMutableProperty(
name: String,
state: MutableDeviceState<T>,
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 <T : Any> DeviceGroup.registerVirtualProperty(
name: String,
initialValue: T,
converter: MetaConverter<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
callback: (T) -> Unit = {},
): MutableDeviceState<T> {
val state = DeviceState.virtual<T>(converter, initialValue, callback)
registerMutableProperty(name, state, descriptorBuilder)
return state
}

View File

@ -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<T> {
public val converter: MetaConverter<T>
public val value: T
public val valueFlow: Flow<T>
public companion object
}
public val <T> DeviceState<T>.metaFlow: Flow<Meta> get() = valueFlow.map(converter::convert)
public val <T> DeviceState<T>.valueAsMeta: Meta get() = converter.convert(value)
public operator fun <T> DeviceState<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value
/**
* Collect values in a given [scope]
*/
public fun <T> DeviceState<T>.collectValuesIn(scope: CoroutineScope, block: suspend (T)->Unit): Job =
valueFlow.onEach(block).launchIn(scope)
/**
* A mutable state of a device
*/
public interface MutableDeviceState<T> : DeviceState<T> {
override var value: T
}
public operator fun <T> MutableDeviceState<T>.setValue(thisRef: Any?, property: KProperty<*>, value: T) {
this.value = value
}
public var <T> MutableDeviceState<T>.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<T>(
override val converter: MetaConverter<T>,
initialValue: T,
private val callback: (T) -> Unit = {},
) : MutableDeviceState<T> {
private val flow = MutableStateFlow(initialValue)
override val valueFlow: Flow<T> get() = flow
override var value: T
get() = flow.value
set(value) {
flow.value = value
callback(value)
}
}
/**
* A [MutableDeviceState] that does not correspond to a physical state
*
* @param callback a synchronous callback that could be used without a scope
*/
public fun <T> DeviceState.Companion.virtual(
converter: MetaConverter<T>,
initialValue: T,
callback: (T) -> Unit = {},
): MutableDeviceState<T> = VirtualDeviceState(converter, initialValue, callback)
private class StateFlowAsState<T>(
override val converter: MetaConverter<T>,
val flow: MutableStateFlow<T>,
) : MutableDeviceState<T> {
override var value: T by flow::value
override val valueFlow: Flow<T> get() = flow
}
public fun <T> MutableStateFlow<T>.asDeviceState(converter: MetaConverter<T>): DeviceState<T> =
StateFlowAsState(converter, this)
private open class BoundDeviceState<T>(
override val converter: MetaConverter<T>,
val device: Device,
val propertyName: String,
initialValue: T,
) : DeviceState<T> {
override val valueFlow: StateFlow<T> = device.messageFlow.filterIsInstance<PropertyChangedMessage>().filter {
it.property == propertyName
}.mapNotNull {
converter.read(it.value)
}.stateIn(device.context, SharingStarted.Eagerly, initialValue)
override val value: T get() = valueFlow.value
}
/**
* Bind a read-only [DeviceState] to a [Device] property
*/
public suspend fun <T> Device.propertyAsState(
propertyName: String,
metaConverter: MetaConverter<T>,
): DeviceState<T> {
val initialValue = metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed")
return BoundDeviceState(metaConverter, this, propertyName, initialValue)
}
public suspend fun <D : Device, T> D.propertyAsState(
propertySpec: DevicePropertySpec<D, T>,
): DeviceState<T> = propertyAsState(propertySpec.name, propertySpec.converter)
public fun <T, R> DeviceState<T>.map(
converter: MetaConverter<R>, mapper: (T) -> R,
): DeviceState<R> = object : DeviceState<R> {
override val converter: MetaConverter<R> = converter
override val value: R
get() = mapper(this@map.value)
override val valueFlow: Flow<R> = this@map.valueFlow.map(mapper)
}
private class MutableBoundDeviceState<T>(
converter: MetaConverter<T>,
device: Device,
propertyName: String,
initialValue: T,
) : BoundDeviceState<T>(converter, device, propertyName, initialValue), MutableDeviceState<T> {
override var value: T
get() = valueFlow.value
set(newValue) {
device.launch {
device.writeProperty(propertyName, converter.convert(newValue))
}
}
}
public fun <T> Device.mutablePropertyAsState(
propertyName: String,
metaConverter: MetaConverter<T>,
initialValue: T,
): MutableDeviceState<T> = MutableBoundDeviceState(metaConverter, this, propertyName, initialValue)
public suspend fun <T> Device.mutablePropertyAsState(
propertyName: String,
metaConverter: MetaConverter<T>,
): MutableDeviceState<T> {
val initialValue = metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed")
return mutablePropertyAsState(propertyName, metaConverter, initialValue)
}
public suspend fun <D : Device, T> D.mutablePropertyAsState(
propertySpec: MutableDevicePropertySpec<D, T>,
): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter)
public fun <D : Device, T> D.mutablePropertyAsState(
propertySpec: MutableDevicePropertySpec<D, T>,
initialValue: T,
): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue)
private open class ExternalState<T>(
val scope: CoroutineScope,
override val converter: MetaConverter<T>,
val readInterval: Duration,
initialValue: T,
val reader: suspend () -> T,
) : DeviceState<T> {
protected val flow: StateFlow<T> = flow {
while (true) {
delay(readInterval)
emit(reader())
}
}.stateIn(scope, SharingStarted.Eagerly, initialValue)
override val value: T get() = flow.value
override val valueFlow: Flow<T> get() = flow
}
/**
* Create a [DeviceState] which is constructed by periodically reading external value
*/
public fun <T> DeviceState.Companion.external(
scope: CoroutineScope,
converter: MetaConverter<T>,
readInterval: Duration,
initialValue: T,
reader: suspend () -> T,
): DeviceState<T> = ExternalState(scope, converter, readInterval, initialValue, reader)
private class MutableExternalState<T>(
scope: CoroutineScope,
converter: MetaConverter<T>,
readInterval: Duration,
initialValue: T,
reader: suspend () -> T,
val writer: suspend (T) -> Unit,
) : ExternalState<T>(scope, converter, readInterval, initialValue, reader), MutableDeviceState<T> {
override var value: T
get() = super.value
set(value) {
scope.launch {
writer(value)
}
}
}
/**
* Create a [DeviceState] that regularly reads and caches an external value
*/
public fun <T> DeviceState.Companion.external(
scope: CoroutineScope,
converter: MetaConverter<T>,
readInterval: Duration,
initialValue: T,
reader: suspend () -> T,
writer: suspend (T) -> Unit,
): MutableDeviceState<T> = MutableExternalState(scope, converter, readInterval, initialValue, reader, writer)

View File

@ -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<Drive>() {
public val force: MutableDevicePropertySpec<Drive, Double> by Drive.mutableProperty(
MetaConverter.double,
Drive::force
)
public val position: DevicePropertySpec<Drive, Double> by doubleProperty { position }
}
}
/**
* A virtual drive
*/
public class VirtualDrive(
context: Context,
private val mass: Double,
public val positionState: MutableDeviceState<Double>,
) : Drive, DeviceBySpec<Drive>(Drive, context) {
private val dt = meta["time.step"].double?.milliseconds ?: 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<Double>,
): Factory<Drive> = Factory { context, _ ->
VirtualDrive(context, mass, positionState)
}
}
}
public suspend fun Drive.stateOfForce(): MutableDeviceState<Double> = mutablePropertyAsState(Drive.force)

View File

@ -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<LimitSwitch>() {
public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { locked }
public fun factory(lockedState: DeviceState<Boolean>): Factory<LimitSwitch> = Factory { context, _ ->
VirtualLimitSwitch(context, lockedState)
}
}
}
/**
* Virtual [LimitSwitch]
*/
public class VirtualLimitSwitch(
context: Context,
public val lockedState: DeviceState<Boolean>,
) : DeviceBySpec<LimitSwitch>(LimitSwitch, context), LimitSwitch {
init {
lockedState.valueFlow.onEach {
propertyChanged(LimitSwitch.locked, it)
}.launchIn(this)
}
override val locked: Boolean get() = lockedState.value
}

View File

@ -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>(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))

View File

@ -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<Regulator>() {
public val target: MutableDevicePropertySpec<Regulator, Double> by mutableProperty(MetaConverter.double, Regulator::target)
public val position: DevicePropertySpec<Regulator, Double> by doubleProperty { position }
}
}

View File

@ -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<Double>,
) : MutableDeviceState<Double> {
init {
require(initialValue in range) { "Initial value should be in range" }
}
override val converter: MetaConverter<Double> = MetaConverter.double
private val _valueFlow = MutableStateFlow(initialValue)
override var value: Double
get() = _valueFlow.value
set(newValue) {
_valueFlow.value = newValue.coerceIn(range)
}
override val valueFlow: StateFlow<Double> get() = _valueFlow
/**
* A state showing that the range is on its lower boundary
*/
public val atStartState: DeviceState<Boolean> = map(MetaConverter.boolean) { it <= range.start }
/**
* A state showing that the range is on its higher boundary
*/
public val atEndState: DeviceState<Boolean> = map(MetaConverter.boolean) { it >= range.endInclusive }
}
@Suppress("UnusedReceiverParameter")
public fun DeviceGroup.rangeState(
initialValue: Double,
range: ClosedFloatingPointRange<Double>,
): DoubleRangeState = DoubleRangeState(initialValue, range)

View File

@ -16,18 +16,16 @@ Core interfaces for building a device server
## Artifact: ## 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:** **Gradle Kotlin DSL:**
```kotlin ```kotlin
repositories { repositories {
maven("https://repo.kotlin.link") maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("space.kscience:controls-core:0.2.0") implementation("space.kscience:controls-core:0.3.0")
} }
``` ```

View File

@ -20,10 +20,14 @@ kscience {
json() json()
} }
useContextReceivers() useContextReceivers()
dependencies { commonMain {
api(libs.dataforge.io) api(libs.dataforge.io)
api(spclibs.kotlinx.datetime) api(spclibs.kotlinx.datetime)
} }
jvmTest{
implementation(spclibs.logback.classic)
}
} }

View File

@ -0,0 +1,40 @@
package space.kscience.controls.api
import kotlinx.coroutines.flow.Flow
/**
* A generic bidirectional asynchronous sender/receiver object
*/
public interface AsynchronousSocket<T> : AutoCloseable {
/**
* Send an object to the socket
*/
public suspend fun send(data: T)
/**
* Flow of objects received from socket
*/
public fun subscribe(): Flow<T>
/**
* Start socket operation
*/
public fun open()
/**
* Check if this socket is open
*/
public val isOpen: Boolean
}
/**
* Connect an input to this socket.
* Multiple inputs could be connected to the same [AsynchronousSocket].
*
* This method suspends indefinitely, so it should be started in a separate coroutine.
*/
public suspend fun <T> AsynchronousSocket<T>.sendFlow(flow: Flow<T>) {
flow.collect { send(it) }
}

View File

@ -3,26 +3,44 @@ package space.kscience.controls.api
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.filterIsInstance import kotlinx.serialization.Serializable
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import space.kscience.controls.api.Device.Companion.DEVICE_TARGET import space.kscience.controls.api.Device.Companion.DEVICE_TARGET
import space.kscience.dataforge.context.ContextAware import space.kscience.dataforge.context.ContextAware
import space.kscience.dataforge.context.info import space.kscience.dataforge.context.info
import space.kscience.dataforge.context.logger import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.Meta 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.DFExperimental
import space.kscience.dataforge.misc.Type import space.kscience.dataforge.misc.DfType
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.parseAsName
/** /**
* A lifecycle state of a device * A lifecycle state of a device
*/ */
public enum class DeviceLifecycleState{ @Serializable
INIT, public enum class DeviceLifecycleState {
OPEN,
CLOSED /**
* 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. * [Device] is a supervisor scope encompassing all operations on a device.
* When canceled, cancels all running processes. * When canceled, cancels all running processes.
*/ */
@Type(DEVICE_TARGET) @DfType(DEVICE_TARGET)
public interface Device : AutoCloseable, ContextAware, CoroutineScope { public interface Device : ContextAware, CoroutineScope {
/** /**
* Initial configuration meta for the device * Initial configuration meta for the device
*/ */
public val meta: Meta get() = Meta.EMPTY public val meta: Meta get() = Meta.EMPTY
/** /**
* List of supported property descriptors * List of supported property descriptors
*/ */
@ -54,18 +73,6 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope {
*/ */
public suspend fun readProperty(propertyName: String): Meta 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]. * 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. * 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? 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. * 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" } logger.info { "Device $this is closed" }
cancel("The device 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. * 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) getProperty(propertyName) ?: readProperty(propertyName)
} else {
readProperty(propertyName)
}
/** /**
* Get a snapshot of the device logical state * Get a snapshot of the device logical state
* *
*/ */
public fun Device.getAllProperties(): Meta = Meta { public fun CachingDevice.getAllProperties(): Meta = Meta {
for (descriptor in propertyDescriptors) { 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 * Subscribe on property changes for the whole device
*/ */
public fun Device.onPropertyChange(callback: suspend PropertyChangedMessage.() -> Unit): Job = public fun Device.onPropertyChange(
messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(this) scope: CoroutineScope = this,
callback: suspend PropertyChangedMessage.() -> Unit,
): Job = messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(scope)
/**
* A [Flow] of property change messages for specific property.
*/
public fun Device.propertyMessageFlow(propertyName: String): Flow<PropertyChangedMessage> = messageFlow
.filterIsInstance<PropertyChangedMessage>()
.filter { it.property == propertyName }

View File

@ -14,22 +14,27 @@ public interface DeviceHub : Provider {
override val defaultChainTarget: String get() = Device.DEVICE_TARGET override val defaultChainTarget: String get() = Device.DEVICE_TARGET
override fun content(target: String): Map<Name, Any> = if (target == Device.DEVICE_TARGET) { /**
buildMap { * List all devices, including sub-devices
fun putAll(prefix: Name, hub: DeviceHub) { */
hub.devices.forEach { public fun buildDeviceTree(): Map<Name, Device> = buildMap {
put(prefix + it.key, it.value) fun putAll(prefix: Name, hub: DeviceHub) {
} hub.devices.forEach {
} put(prefix + it.key, it.value)
devices.forEach {
val name = it.key.asName()
put(name, it.value)
(it.value as? DeviceHub)?.let { hub ->
putAll(name, hub)
}
} }
} }
devices.forEach {
val name = it.key.asName()
put(name, it.value)
(it.value as? DeviceHub)?.let { hub ->
putAll(name, hub)
}
}
}
override fun content(target: String): Map<Name, Any> = if (target == Device.DEVICE_TARGET) {
buildDeviceTree()
} else { } else {
emptyMap() emptyMap()
} }
@ -37,6 +42,7 @@ public interface DeviceHub : Provider {
public companion object public companion object
} }
public operator fun DeviceHub.get(nameToken: NameToken): Device = public operator fun DeviceHub.get(nameToken: NameToken): Device =
devices[nameToken] ?: error("Device with name $nameToken not found in $this") devices[nameToken] ?: error("Device with name $nameToken not found in $this")

View File

@ -22,10 +22,10 @@ public sealed class DeviceMessage {
public abstract val sourceDevice: Name? public abstract val sourceDevice: Name?
public abstract val targetDevice: Name? public abstract val targetDevice: Name?
public abstract val comment: String? 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 public abstract fun changeSource(block: (Name) -> Name): DeviceMessage
@ -59,7 +59,7 @@ public data class PropertyChangedMessage(
override val sourceDevice: Name = Name.EMPTY, override val sourceDevice: Name = Name.EMPTY,
override val targetDevice: Name? = null, override val targetDevice: Name? = null,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
} }
@ -71,11 +71,11 @@ public data class PropertyChangedMessage(
@SerialName("property.set") @SerialName("property.set")
public data class PropertySetMessage( public data class PropertySetMessage(
public val property: String, public val property: String,
public val value: Meta?, public val value: Meta,
override val sourceDevice: Name? = null, override val sourceDevice: Name? = null,
override val targetDevice: Name, override val targetDevice: Name,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) 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 sourceDevice: Name? = null,
override val targetDevice: Name, override val targetDevice: Name,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
} }
@ -103,9 +103,9 @@ public data class PropertyGetMessage(
@SerialName("description.get") @SerialName("description.get")
public data class GetDescriptionMessage( public data class GetDescriptionMessage(
override val sourceDevice: Name? = null, override val sourceDevice: Name? = null,
override val targetDevice: Name, override val targetDevice: Name? = null,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) 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 sourceDevice: Name,
override val targetDevice: Name? = null, override val targetDevice: Name? = null,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) 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 sourceDevice: Name? = null,
override val targetDevice: Name, override val targetDevice: Name,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) 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 sourceDevice: Name,
override val targetDevice: Name? = null, override val targetDevice: Name? = null,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) 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 sourceDevice: Name,
override val targetDevice: Name? = null, override val targetDevice: Name? = null,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) 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 sourceDevice: Name? = null,
override val targetDevice: Name? = null, override val targetDevice: Name? = null,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
} }
@ -203,12 +203,12 @@ public data class EmptyDeviceMessage(
public data class DeviceLogMessage( public data class DeviceLogMessage(
val message: String, val message: String,
val data: Meta? = null, val data: Meta? = null,
override val sourceDevice: Name? = null, override val sourceDevice: Name = Name.EMPTY,
override val targetDevice: Name? = null, override val targetDevice: Name? = null,
override val comment: String? = null, override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(), @EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() { ) : 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 errorMessage: String?,
public val errorType: String? = null, public val errorType: String? = null,
public val errorStackTrace: String? = null, public val errorStackTrace: String? = null,
override val sourceDevice: Name, override val sourceDevice: Name = Name.EMPTY,
override val targetDevice: Name? = null, override val targetDevice: Name? = null,
override val comment: String? = 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() { ) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
} }

View File

@ -1,33 +0,0 @@
package space.kscience.controls.api
import io.ktor.utils.io.core.Closeable
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch
/**
* A generic bidirectional sender/receiver object
*/
public interface Socket<T> : Closeable {
/**
* Send an object to the socket
*/
public suspend fun send(data: T)
/**
* Flow of objects received from socket
*/
public fun receiving(): Flow<T>
public fun isOpen(): Boolean
}
/**
* Connect an input to this socket using designated [scope] for it and return a handler [Job].
* Multiple inputs could be connected to the same [Socket].
*/
public fun <T> Socket<T>.connectInput(scope: CoroutineScope, flow: Flow<T>): Job = scope.launch {
flow.collect { send(it) }
}

View File

@ -12,10 +12,10 @@ import space.kscience.dataforge.meta.descriptors.MetaDescriptorBuilder
@Serializable @Serializable
public class PropertyDescriptor( public class PropertyDescriptor(
public val name: String, public val name: String,
public var info: String? = null, public var description: String? = null,
public var metaDescriptor: MetaDescriptor = MetaDescriptor(), public var metaDescriptor: MetaDescriptor = MetaDescriptor(),
public var readable: Boolean = true, public var readable: Boolean = true,
public var writable: Boolean = false public var mutable: Boolean = false
) )
public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Unit){ public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Unit){
@ -27,6 +27,6 @@ public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Un
*/ */
@Serializable @Serializable
public class ActionDescriptor(public val name: String) { public class ActionDescriptor(public val name: String) {
public var info: String? = null public var description: String? = null
} }

View File

@ -0,0 +1,25 @@
package space.kscience.controls.manager
import kotlinx.datetime.Clock
import space.kscience.dataforge.context.AbstractPlugin
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.PluginFactory
import space.kscience.dataforge.context.PluginTag
import space.kscience.dataforge.meta.Meta
public class ClockManager : AbstractPlugin() {
override val tag: PluginTag get() = DeviceManager.tag
public val clock: Clock by lazy {
//TODO add clock customization
Clock.System
}
public companion object : PluginFactory<ClockManager> {
override val tag: PluginTag = PluginTag("clock", group = PluginTag.DATAFORGE_GROUP)
override fun build(context: Context, meta: Meta): ClockManager = ClockManager()
}
}
public val Context.clock: Clock get() = plugins[ClockManager]?.clock ?: Clock.System

View File

@ -4,6 +4,7 @@ import kotlinx.coroutines.launch
import space.kscience.controls.api.Device import space.kscience.controls.api.Device
import space.kscience.controls.api.DeviceHub import space.kscience.controls.api.DeviceHub
import space.kscience.controls.api.getOrNull import space.kscience.controls.api.getOrNull
import space.kscience.controls.api.id
import space.kscience.dataforge.context.* import space.kscience.dataforge.context.*
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MutableMeta import space.kscience.dataforge.meta.MutableMeta
@ -40,11 +41,13 @@ public class DeviceManager : AbstractPlugin(), DeviceHub {
public fun <D : Device> DeviceManager.install(name: String, device: D): D { public fun <D : Device> DeviceManager.install(name: String, device: D): D {
registerDevice(NameToken(name), device) registerDevice(NameToken(name), device)
device.launch { device.launch {
device.open() device.start()
} }
return device return device
} }
public fun <D : Device> DeviceManager.install(device: D): D = install(device.id, device)
/** /**
* Register and start a device built by [factory] with current [Context] and [meta]. * Register and start a device built by [factory] with current [Context] and [meta].

View File

@ -1,10 +1,9 @@
package space.kscience.controls.manager package space.kscience.controls.manager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.merge
import space.kscience.controls.api.* import space.kscience.controls.api.*
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.plus import space.kscience.dataforge.names.plus
@ -24,11 +23,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
} }
is PropertySetMessage -> { is PropertySetMessage -> {
if (request.value == null) { writeProperty(request.property, request.value)
invalidate(request.property)
} else {
writeProperty(request.property, request.value)
}
PropertyChangedMessage( PropertyChangedMessage(
property = request.property, property = request.property,
value = getOrReadProperty(request.property), value = getOrReadProperty(request.property),
@ -64,6 +59,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
is DeviceErrorMessage, is DeviceErrorMessage,
is EmptyDeviceMessage, is EmptyDeviceMessage,
is DeviceLogMessage, is DeviceLogMessage,
is DeviceLifeCycleMessage,
-> null -> null
} }
} catch (ex: Exception) { } 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<DeviceMessage> {
return try { return try {
val targetName = request.targetDevice ?: return null val targetName = request.targetDevice
val device = getOrNull(targetName) ?: error("The device with name $targetName not found in $this") if (targetName == null) {
device.respondMessage(targetName, request) 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) { } 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. * Collect all messages from given [DeviceHub], applying proper relative names.
*/ */
public fun DeviceHub.hubMessageFlow(scope: CoroutineScope): Flow<DeviceMessage> { public fun DeviceHub.hubMessageFlow(): Flow<DeviceMessage> {
//TODO could we avoid using downstream scope? val deviceMessageFlow = if (this is Device) messageFlow else emptyFlow()
val outbox = MutableSharedFlow<DeviceMessage>()
if (this is Device) { val childrenFlows = devices.map { (token, childDevice) ->
messageFlow.onEach { if (childDevice is DeviceHub) {
outbox.emit(it) childDevice.hubMessageFlow()
}.launchIn(scope)
}
//TODO maybe better create map of all devices to limit copying
devices.forEach { (token, childDevice) ->
val flow = if (childDevice is DeviceHub) {
childDevice.hubMessageFlow(scope)
} else { } else {
childDevice.messageFlow 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())
} }

View File

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

View File

@ -0,0 +1,69 @@
package space.kscience.controls.misc
import kotlinx.datetime.Instant
import kotlinx.io.Sink
import kotlinx.io.Source
import space.kscience.dataforge.io.IOFormat
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaConverter
import space.kscience.dataforge.meta.get
/**
* A value coupled to a time it was obtained at
*/
public data class ValueWithTime<T>(val value: T, val time: Instant) {
public companion object {
/**
* Create a [ValueWithTime] format for given value value [IOFormat]
*/
public fun <T> ioFormat(
valueFormat: IOFormat<T>,
): IOFormat<ValueWithTime<T>> = ValueWithTimeIOFormat(valueFormat)
/**
* Create a [MetaConverter] with time for given value [MetaConverter]
*/
public fun <T> metaConverter(
valueConverter: MetaConverter<T>,
): MetaConverter<ValueWithTime<T>> = ValueWithTimeMetaConverter(valueConverter)
public const val META_TIME_KEY: String = "time"
public const val META_VALUE_KEY: String = "value"
}
}
private class ValueWithTimeIOFormat<T>(val valueFormat: IOFormat<T>) : IOFormat<ValueWithTime<T>> {
override fun readFrom(source: Source): ValueWithTime<T> {
val timestamp = InstantIOFormat.readFrom(source)
val value = valueFormat.readFrom(source)
return ValueWithTime(value, timestamp)
}
override fun writeTo(sink: Sink, obj: ValueWithTime<T>) {
InstantIOFormat.writeTo(sink, obj.time)
valueFormat.writeTo(sink, obj.value)
}
}
private class ValueWithTimeMetaConverter<T>(
val valueConverter: MetaConverter<T>,
) : MetaConverter<ValueWithTime<T>> {
override fun readOrNull(
source: Meta,
): ValueWithTime<T>? = valueConverter.read(source[ValueWithTime.META_VALUE_KEY] ?: Meta.EMPTY)?.let {
ValueWithTime(it, source[ValueWithTime.META_TIME_KEY]?.instant ?: Instant.DISTANT_PAST)
}
override fun convert(obj: ValueWithTime<T>): Meta = Meta {
ValueWithTime.META_TIME_KEY put obj.time.toMeta()
ValueWithTime.META_VALUE_KEY put valueConverter.convert(obj.value)
}
}
public fun <T : Any> MetaConverter<T>.withTime(): MetaConverter<ValueWithTime<T>> = ValueWithTimeMetaConverter(this)

View File

@ -0,0 +1,42 @@
package space.kscience.controls.misc
import kotlinx.datetime.Instant
import kotlinx.io.Sink
import kotlinx.io.Source
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.io.IOFormat
import space.kscience.dataforge.io.IOFormatFactory
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.string
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.asName
import kotlin.reflect.KType
import kotlin.reflect.typeOf
/**
* An [IOFormat] for [Instant]
*/
public object InstantIOFormat : IOFormat<Instant>, IOFormatFactory<Instant> {
override fun build(context: Context, meta: Meta): IOFormat<Instant> = this
override val name: Name = "instant".asName()
override val type: KType get() = typeOf<Instant>()
override fun writeTo(sink: Sink, obj: Instant) {
sink.writeLong(obj.epochSeconds)
sink.writeInt(obj.nanosecondsOfSecond)
}
override fun readFrom(source: Source): Instant {
val seconds = source.readLong()
val nanoseconds = source.readInt()
return Instant.fromEpochSeconds(seconds, nanoseconds)
}
}
public fun Instant.toMeta(): Meta = Meta(toString())
public val Meta.instant: Instant? get() = value?.string?.let { Instant.parse(it) }

View File

@ -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,
)

View File

@ -0,0 +1,125 @@
package space.kscience.controls.ports
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.io.Buffer
import kotlinx.io.Source
import space.kscience.controls.api.AsynchronousSocket
import space.kscience.dataforge.context.*
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.int
import space.kscience.dataforge.meta.string
import kotlin.coroutines.CoroutineContext
/**
* Raw [ByteArray] port
*/
public interface AsynchronousPort : ContextAware, AsynchronousSocket<ByteArray>
/**
* Capture [AsynchronousPort] output as kotlinx-io [Source].
* [scope] controls the consummation.
* If the scope is canceled, the source stops producing.
*/
public fun AsynchronousPort.receiveAsSource(scope: CoroutineScope): Source {
val buffer = Buffer()
subscribe().onEach {
buffer.write(it)
}.launchIn(scope)
return buffer
}
/**
* Common abstraction for [AsynchronousPort] based on [Channel]
*/
public abstract class AbstractAsynchronousPort(
override val context: Context,
public val meta: Meta,
coroutineContext: CoroutineContext = context.coroutineContext,
) : AsynchronousPort {
protected val scope: CoroutineScope by lazy {
CoroutineScope(
coroutineContext +
SupervisorJob(coroutineContext[Job]) +
CoroutineExceptionHandler { _, throwable -> logger.error(throwable) { throwable.stackTraceToString() } } +
CoroutineName(toString())
)
}
private val outgoing = Channel<ByteArray>(meta["outgoing.capacity"].int?:100)
private val incoming = Channel<ByteArray>(meta["incoming.capacity"].int?:100)
/**
* Internal method to synchronously send data
*/
protected abstract suspend fun write(data: ByteArray)
/**
* Internal method to receive data synchronously
*/
protected suspend fun receive(data: ByteArray) {
logger.debug { "$this RECEIVED: ${data.decodeToString()}" }
incoming.send(data)
}
private var sendJob: Job? = null
protected abstract fun onOpen()
final override fun open() {
if (!isOpen) {
sendJob = scope.launch {
for (data in outgoing) {
try {
write(data)
logger.debug { "${this@AbstractAsynchronousPort} SENT: ${data.decodeToString()}" }
} catch (ex: Exception) {
if (ex is CancellationException) throw ex
logger.error(ex) { "Error while writing data to the port" }
}
}
}
onOpen()
} else {
logger.warn { "$this already opened" }
}
}
/**
* Send a data packet via the port
*/
override suspend fun send(data: ByteArray) {
outgoing.send(data)
}
/**
* Raw flow of incoming data chunks. The chunks are not guaranteed to be complete phrases.
* To form phrases, some condition should be used on top of it.
* For example [stringsDelimitedIncoming] generates phrases with fixed delimiter.
*/
override fun subscribe(): Flow<ByteArray> = incoming.receiveAsFlow()
override fun close() {
outgoing.close()
incoming.close()
sendJob?.cancel()
}
override fun toString(): String = meta["name"].string?:"ChannelPort[${hashCode().toString(16)}]"
}
/**
* Send UTF-8 encoded string
*/
public suspend fun AsynchronousPort.send(string: String): Unit = send(string.encodeToByteArray())

View File

@ -1,100 +0,0 @@
package space.kscience.controls.ports
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.receiveAsFlow
import space.kscience.controls.api.Socket
import space.kscience.dataforge.context.*
import space.kscience.dataforge.misc.Type
import kotlin.coroutines.CoroutineContext
/**
* Raw [ByteArray] port
*/
public interface Port : ContextAware, Socket<ByteArray>
/**
* A specialized factory for [Port]
*/
@Type(PortFactory.TYPE)
public interface PortFactory : Factory<Port> {
public val type: String
public companion object {
public const val TYPE: String = "controls.port"
}
}
/**
* Common abstraction for [Port] based on [Channel]
*/
public abstract class AbstractPort(
override val context: Context,
coroutineContext: CoroutineContext = context.coroutineContext,
) : Port {
protected val scope: CoroutineScope = CoroutineScope(coroutineContext + SupervisorJob(coroutineContext[Job]))
private val outgoing = Channel<ByteArray>(100)
private val incoming = Channel<ByteArray>(Channel.CONFLATED)
init {
scope.coroutineContext[Job]?.invokeOnCompletion {
close()
}
}
/**
* Internal method to synchronously send data
*/
protected abstract suspend fun write(data: ByteArray)
/**
* Internal method to receive data synchronously
*/
protected suspend fun receive(data: ByteArray) {
logger.debug { "${this@AbstractPort} RECEIVED: ${data.decodeToString()}" }
incoming.send(data)
}
private val sendJob = scope.launch {
for (data in outgoing) {
try {
write(data)
logger.debug { "${this@AbstractPort} SENT: ${data.decodeToString()}" }
} catch (ex: Exception) {
if (ex is CancellationException) throw ex
logger.error(ex) { "Error while writing data to the port" }
}
}
}
/**
* Send a data packet via the port
*/
override suspend fun send(data: ByteArray) {
outgoing.send(data)
}
/**
* Raw flow of incoming data chunks. The chunks are not guaranteed to be complete phrases.
* In order to form phrases, some condition should be used on top of it.
* For example [stringsDelimitedIncoming] generates phrases with fixed delimiter.
*/
override fun receiving(): Flow<ByteArray> = incoming.receiveAsFlow()
override fun close() {
outgoing.close()
incoming.close()
sendJob.cancel()
scope.cancel()
}
override fun isOpen(): Boolean = scope.isActive
}
/**
* Send UTF-8 encoded string
*/
public suspend fun Port.send(string: String): Unit = send(string.encodeToByteArray())

View File

@ -1,64 +0,0 @@
package space.kscience.controls.ports
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import space.kscience.dataforge.context.*
/**
* A port that could be closed multiple times and opens automatically on request
*/
public class PortProxy(override val context: Context = Global, public val factory: suspend () -> Port) : Port, ContextAware {
private var actualPort: Port? = null
private val mutex: Mutex = Mutex()
private suspend fun port(): Port {
return mutex.withLock {
if (actualPort?.isOpen() == true) {
actualPort!!
} else {
factory().also {
actualPort = it
}
}
}
}
override suspend fun send(data: ByteArray) {
port().send(data)
}
@OptIn(ExperimentalCoroutinesApi::class)
override fun receiving(): Flow<ByteArray> = flow {
while (true) {
try {
//recreate port and Flow on connection problems
port().receiving().collect {
emit(it)
}
} catch (t: Throwable) {
logger.warn{"Port read failed: ${t.message}. Reconnecting."}
mutex.withLock {
actualPort?.close()
actualPort = null
}
}
}
}
// open by default
override fun isOpen(): Boolean = true
override fun close() {
context.launch {
mutex.withLock {
actualPort?.close()
actualPort = null
}
}
}
}

View File

@ -11,26 +11,43 @@ public class Ports : AbstractPlugin() {
override val tag: PluginTag get() = Companion.tag override val tag: PluginTag get() = Companion.tag
private val portFactories by lazy { private val synchronousPortFactories by lazy {
context.gather<PortFactory>(PortFactory.TYPE) context.gather<Factory<SynchronousPort>>(SYNCHRONOUS_PORT_TYPE)
} }
private val portCache = mutableMapOf<Meta, Port>() private val asynchronousPortFactories by lazy {
context.gather<Factory<AsynchronousPort>>(ASYNCHRONOUS_PORT_TYPE)
}
/** /**
* Create a new [Port] according to specification * Create a new [AsynchronousPort] according to specification
*/ */
public fun buildPort(meta: Meta): Port = portCache.getOrPut(meta) { public fun buildAsynchronousPort(meta: Meta): AsynchronousPort {
val type by meta.string { error("Port type is not defined") } val 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") ?: error("Port factory for type $type not found")
factory.build(context, meta) return factory.build(context, meta)
}
/**
* Create a [SynchronousPort] according to specification or wrap an asynchronous implementation
*/
public fun buildSynchronousPort(meta: Meta): SynchronousPort {
val type by meta.string { error("Port type is not defined") }
val factory = synchronousPortFactories.entries
.firstOrNull { it.key.toString() == type }?.value
?: return buildAsynchronousPort(meta).asSynchronousPort()
return factory.build(context, meta)
} }
public companion object : PluginFactory<Ports> { public companion object : PluginFactory<Ports> {
override val tag: PluginTag = PluginTag("controls.ports", group = PluginTag.DATAFORGE_GROUP) 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() override fun build(context: Context, meta: Meta): Ports = Ports()
} }

View File

@ -2,27 +2,86 @@ package space.kscience.controls.ports
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock 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. * A port handler for synchronous (request-response) communication with a port.
* The handler does not guarantee exclusive access to the port so the user mush ensure that no other controller handles port at the moment. * Only one request could be active at a time (others are suspended).
*/ */
public class SynchronousPort(public val port: Port, private val mutex: Mutex) : Port by port { public interface SynchronousPort : ContextAware, AutoCloseable {
public fun open()
public val isOpen: Boolean
/** /**
* Send a single message and wait for the flow of respond messages. * Send a single message and wait for the flow of response chunks.
* The consumer is responsible for calling a terminal operation on the flow.
*/ */
public suspend fun <R> respond(data: ByteArray, transform: suspend Flow<ByteArray>.() -> R): R = mutex.withLock { public suspend fun <R> respond(
port.send(data) request: ByteArray,
transform(port.receiving()) transform: suspend Flow<ByteArray>.() -> R,
): R
/**
* Synchronously read fixed size response to a given [request]. Discard additional response bytes.
*/
public suspend fun respondFixedMessageSize(
request: ByteArray,
responseSize: Int,
): ByteArray = respond(request) {
val buffer = Buffer()
takeWhile {
buffer.size < responseSize
}.collect {
buffer.write(it)
}
buffer.readByteArray(responseSize)
} }
} }
private class SynchronousOverAsynchronousPort(
val port: AsynchronousPort,
val mutex: Mutex,
) : SynchronousPort {
override val context: Context get() = port.context
override fun open() {
if (!port.isOpen) port.open()
}
override val isOpen: Boolean get() = port.isOpen
override fun close() {
if (port.isOpen) port.close()
}
override suspend fun <R> respond(
request: ByteArray,
transform: suspend Flow<ByteArray>.() -> R,
): R = mutex.withLock {
port.send(request)
transform(port.subscribe())
}
}
/** /**
* Provide a synchronous wrapper for a port * Provide a synchronous wrapper for an asynchronous port.
* Optionally provide external [mutex] for operation synchronization.
*
* If the [AsynchronousPort] is called directly, it could violate [SynchronousPort] contract
* of only one request running simultaneously.
*/ */
public fun Port.synchronous(mutex: Mutex = Mutex()): SynchronousPort = SynchronousPort(this, mutex) public fun AsynchronousPort.asSynchronousPort(mutex: Mutex = Mutex()): SynchronousPort =
SynchronousOverAsynchronousPort(this, mutex)
/** /**
* Send request and read incoming data blocks until the delimiter is encountered * Send request and read incoming data blocks until the delimiter is encountered

View File

@ -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() }

View File

@ -1,21 +1,27 @@
package space.kscience.controls.ports 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.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.transform 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. * Transform byte fragments into complete phrases using given delimiter. Not thread safe.
*
* TODO add type wrapper for phrases
*/ */
public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray> { public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray> {
require(delimiter.isNotEmpty()) { "Delimiter must not be empty" } require(delimiter.isNotEmpty()) { "Delimiter must not be empty" }
val output = BytePacketBuilder() val output = Buffer()
var matcherPosition = 0 var matcherPosition = 0
onCompletion {
output.close()
}
return transform { chunk -> return transform { chunk ->
chunk.forEach { byte -> chunk.forEach { byte ->
output.writeByte(byte) output.writeByte(byte)
@ -24,9 +30,8 @@ public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray>
matcherPosition++ matcherPosition++
if (matcherPosition == delimiter.size) { if (matcherPosition == delimiter.size) {
//full match achieved, sending result //full match achieved, sending result
val bytes = output.build() emit(output.readByteArray())
emit(bytes.readBytes()) output.clear()
output.reset()
matcherPosition = 0 matcherPosition = 0
} }
} else if (matcherPosition > 0) { } else if (matcherPosition > 0) {
@ -37,6 +42,31 @@ public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray>
} }
} }
private fun Flow<ByteArray>.withFixedMessageSize(messageSize: Int): Flow<ByteArray> {
require(messageSize > 0) { "Message size should be positive" }
val output = Buffer()
onCompletion {
output.close()
}
return transform { chunk ->
val remaining: Int = (messageSize - output.size).toInt()
if (chunk.size >= remaining) {
output.write(chunk, endIndex = remaining)
emit(output.readByteArray())
output.clear()
//write the remaining chunk fragment
if(chunk.size> remaining) {
output.write(chunk, startIndex = remaining)
}
} else {
output.write(chunk)
}
}
}
/** /**
* Transform byte fragments into utf-8 phrases using utf-8 delimiter * Transform byte fragments into utf-8 phrases using utf-8 delimiter
*/ */
@ -47,9 +77,9 @@ public fun Flow<ByteArray>.withStringDelimiter(delimiter: String): Flow<String>
/** /**
* A flow of delimited phrases * A flow of delimited phrases
*/ */
public fun Port.delimitedIncoming(delimiter: ByteArray): Flow<ByteArray> = receiving().withDelimiter(delimiter) public fun AsynchronousPort.delimitedIncoming(delimiter: ByteArray): Flow<ByteArray> = subscribe().withDelimiter(delimiter)
/** /**
* A flow of delimited phrases with string content * A flow of delimited phrases with string content
*/ */
public fun Port.stringsDelimitedIncoming(delimiter: String): Flow<String> = receiving().withStringDelimiter(delimiter) public fun AsynchronousPort.stringsDelimitedIncoming(delimiter: String): Flow<String> = subscribe().withStringDelimiter(delimiter)

View File

@ -1,36 +1,44 @@
package space.kscience.controls.spec package space.kscience.controls.spec
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import space.kscience.controls.api.* import space.kscience.controls.api.*
import space.kscience.dataforge.context.Context 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.context.logger
import space.kscience.dataforge.meta.Meta 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 space.kscience.dataforge.misc.DFExperimental
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
/**
* Write a meta [item] to [device]
*/
@OptIn(InternalDeviceAPI::class) @OptIn(InternalDeviceAPI::class)
private suspend fun <D : Device, T> WritableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) { private suspend fun <D : Device, T> MutableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) {
write(device, converter.metaToObject(item) ?: error("Meta $item could not be read with $converter")) write(device, converter.readOrNull(item) ?: error("Meta $item could not be read with $converter"))
} }
/**
* Read Meta item from the [device]
*/
@OptIn(InternalDeviceAPI::class) @OptIn(InternalDeviceAPI::class)
private suspend fun <D : Device, T> DevicePropertySpec<D, T>.readMeta(device: D): Meta? = private suspend fun <D : Device, T> DevicePropertySpec<D, T>.readMeta(device: D): Meta? =
read(device)?.let(converter::objectToMeta) read(device)?.let(converter::convert)
private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta( private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta(
device: D, device: D,
item: Meta, item: Meta,
): 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) val res = execute(device, arg)
return res?.let { outputConverter.objectToMeta(res) } return res?.let { outputConverter.convert(res) }
} }
@ -39,8 +47,8 @@ private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta
*/ */
public abstract class DeviceBase<D : Device>( public abstract class DeviceBase<D : Device>(
final override val context: Context, final override val context: Context,
override val meta: Meta = Meta.EMPTY, final override val meta: Meta = Meta.EMPTY,
) : Device { ) : CachingDevice {
/** /**
* Collection of property specifications * Collection of property specifications
@ -58,15 +66,27 @@ public abstract class DeviceBase<D : Device>(
override val actionDescriptors: Collection<ActionDescriptor> override val actionDescriptors: Collection<ActionDescriptor>
get() = actions.values.map { it.descriptor } get() = actions.values.map { it.descriptor }
override val coroutineContext: CoroutineContext by lazy {
context.newCoroutineContext( private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow(
SupervisorJob(context.coroutineContext[Job]) + replay = meta["message.buffer"].int ?: 1000,
CoroutineName("Device $this") + onBufferOverflow = BufferOverflow.DROP_OLDEST
CoroutineExceptionHandler { _, throwable -> )
logger.error(throwable) { "Exception in device $this job" }
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<D : Device>(
*/ */
private val logicalState: HashMap<String, Meta?> = HashMap() private val logicalState: HashMap<String, Meta?> = HashMap()
private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow()
public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
@ -87,7 +105,7 @@ public abstract class DeviceBase<D : Device>(
/** /**
* Update logical property state and notify listeners * 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]) { if (value != logicalState[propertyName]) {
stateLock.withLock { stateLock.withLock {
logicalState[propertyName] = value logicalState[propertyName] = value
@ -99,10 +117,10 @@ public abstract class DeviceBase<D : Device>(
} }
/** /**
* Update logical state using given [spec] and its convertor * Notify the device that a property with [spec] value is changed
*/ */
public suspend fun <T> updateLogical(spec: DevicePropertySpec<D, T>, value: T) { protected suspend fun <T> propertyChanged(spec: DevicePropertySpec<D, T>, value: T) {
updateLogical(spec.name, spec.converter.objectToMeta(value)) propertyChanged(spec.name, spec.converter.convert(value))
} }
/** /**
@ -112,7 +130,7 @@ public abstract class DeviceBase<D : Device>(
override suspend fun readProperty(propertyName: String): Meta { override suspend fun readProperty(propertyName: String): Meta {
val spec = properties[propertyName] ?: error("Property with name $propertyName not found") val spec = properties[propertyName] ?: error("Property with name $propertyName not found")
val meta = spec.readMeta(self) ?: error("Failed to read property $propertyName") val meta = spec.readMeta(self) ?: error("Failed to read property $propertyName")
updateLogical(propertyName, meta) propertyChanged(propertyName, meta)
return meta return meta
} }
@ -122,7 +140,7 @@ public abstract class DeviceBase<D : Device>(
public suspend fun readPropertyOrNull(propertyName: String): Meta? { public suspend fun readPropertyOrNull(propertyName: String): Meta? {
val spec = properties[propertyName] ?: return null val spec = properties[propertyName] ?: return null
val meta = spec.readMeta(self) ?: return null val meta = spec.readMeta(self) ?: return null
updateLogical(propertyName, meta) propertyChanged(propertyName, meta)
return meta return meta
} }
@ -135,15 +153,26 @@ public abstract class DeviceBase<D : Device>(
} }
override suspend fun writeProperty(propertyName: String, value: Meta): Unit { 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]) { when (val property = properties[propertyName]) {
null -> { null -> {
//If there is a physical property with a given name, invalidate logical property and write physical one //If there are no registered physical properties with given name, write a logical one.
updateLogical(propertyName, value) propertyChanged(propertyName, value)
} }
is WritableDevicePropertySpec -> { is MutableDevicePropertySpec -> {
//if there is a writeable property with a given name, invalidate logical and write physical
invalidate(propertyName) invalidate(propertyName)
property.writeMeta(self, value) 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 -> { else -> {
@ -158,21 +187,46 @@ public abstract class DeviceBase<D : Device>(
} }
@DFExperimental @DFExperimental
override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.INIT final override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STOPPED
protected set 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) @OptIn(DFExperimental::class)
override fun close() { final override suspend fun start() {
lifecycleState = DeviceLifecycleState.CLOSED if (lifecycleState == DeviceLifecycleState.STOPPED) {
super.close() 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 abstract override fun toString(): String
} }

View File

@ -16,15 +16,14 @@ public open class DeviceBySpec<D : Device>(
override val properties: Map<String, DevicePropertySpec<D, *>> get() = spec.properties override val properties: Map<String, DevicePropertySpec<D, *>> get() = spec.properties
override val actions: Map<String, DeviceActionSpec<D, *, *>> get() = spec.actions override val actions: Map<String, DeviceActionSpec<D, *, *>> get() = spec.actions
override suspend fun open(): Unit = with(spec) { override suspend fun onStart(): Unit = with(spec) {
super.open()
self.onOpen() self.onOpen()
} }
override fun close(): Unit = with(spec) { override fun onStop(): Unit = with(spec){
self.onClose() self.onClose()
super.close()
} }
override fun toString(): String = "Device(spec=$spec)" override fun toString(): String = "Device(spec=$spec)"
} }

View File

@ -3,9 +3,9 @@ package space.kscience.controls.spec
import space.kscience.controls.api.Device import space.kscience.controls.api.Device
import space.kscience.controls.api.PropertyDescriptor import space.kscience.controls.api.PropertyDescriptor
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.transformations.MetaConverter import space.kscience.dataforge.meta.MetaConverter
internal object DeviceMetaPropertySpec: DevicePropertySpec<Device,Meta> { internal object DeviceMetaPropertySpec : DevicePropertySpec<Device, Meta> {
override val descriptor: PropertyDescriptor = PropertyDescriptor("@meta") override val descriptor: PropertyDescriptor = PropertyDescriptor("@meta")
override val converter: MetaConverter<Meta> = MetaConverter.meta override val converter: MetaConverter<Meta> = MetaConverter.meta

View File

@ -4,11 +4,8 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import space.kscience.controls.api.ActionDescriptor import space.kscience.controls.api.*
import space.kscience.controls.api.Device import space.kscience.dataforge.meta.MetaConverter
import space.kscience.controls.api.PropertyChangedMessage
import space.kscience.controls.api.PropertyDescriptor
import space.kscience.dataforge.meta.transformations.MetaConverter
/** /**
@ -20,7 +17,7 @@ public annotation class InternalDeviceAPI
/** /**
* Specification for a device read-only property * Specification for a device read-only property
*/ */
public interface DevicePropertySpec<in D : Device, T> { public interface DevicePropertySpec<in D, T> {
/** /**
* Property descriptor * Property descriptor
*/ */
@ -44,7 +41,7 @@ public interface DevicePropertySpec<in D : Device, T> {
public val DevicePropertySpec<*, *>.name: String get() = descriptor.name public val DevicePropertySpec<*, *>.name: String get() = descriptor.name
public interface WritableDevicePropertySpec<in D : Device, T> : DevicePropertySpec<D, T> { public interface MutableDevicePropertySpec<in D : Device, T> : DevicePropertySpec<D, T> {
/** /**
* Write physical value to a device * Write physical value to a device
*/ */
@ -53,7 +50,7 @@ public interface WritableDevicePropertySpec<in D : Device, T> : DevicePropertySp
} }
public interface DeviceActionSpec<in D : Device, I, O> { public interface DeviceActionSpec<in D, I, O> {
/** /**
* Action descriptor * Action descriptor
*/ */
@ -75,30 +72,29 @@ public interface DeviceActionSpec<in D : Device, I, O> {
public val DeviceActionSpec<*, *, *>.name: String get() = descriptor.name public val DeviceActionSpec<*, *, *>.name: String get() = descriptor.name
public suspend fun <T, D : Device> D.read(propertySpec: DevicePropertySpec<D, T>): T = public suspend fun <T, D : Device> D.read(propertySpec: DevicePropertySpec<D, T>): T =
propertySpec.converter.metaToObject(readProperty(propertySpec.name)) ?: error("Property read result is not valid") propertySpec.converter.readOrNull(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
/** /**
* Read typed value and update/push event if needed. * Read typed value and update/push event if needed.
* Return null if property read is not successful or property is undefined. * Return null if property read is not successful or property is undefined.
*/ */
public suspend fun <T, D : DeviceBase<D>> D.readOrNull(propertySpec: DevicePropertySpec<D, T>): T? = public suspend fun <T, D : DeviceBase<D>> D.readOrNull(propertySpec: DevicePropertySpec<D, T>): T? =
readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::metaToObject) readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::readOrNull)
public suspend fun <T, D : Device> D.getOrRead(propertySpec: DevicePropertySpec<D, T>): T =
public operator fun <T, D : Device> D.get(propertySpec: DevicePropertySpec<D, T>): T? = propertySpec.converter.read(getOrReadProperty(propertySpec.name))
getProperty(propertySpec.name)?.let(propertySpec.converter::metaToObject)
/** /**
* Write typed property state and invalidate logical state * Write typed property state and invalidate logical state
*/ */
public suspend fun <T, D : Device> D.write(propertySpec: WritableDevicePropertySpec<D, T>, value: T) { public suspend fun <T, D : Device> D.write(propertySpec: MutableDevicePropertySpec<D, T>, value: T) {
writeProperty(propertySpec.name, propertySpec.converter.objectToMeta(value)) writeProperty(propertySpec.name, propertySpec.converter.convert(value))
} }
/** /**
* Fire and forget variant of property writing. Actual write is performed asynchronously on a [Device] scope * Fire and forget variant of property writing. Actual write is performed asynchronously on a [Device] scope
*/ */
public operator fun <T, D : Device> D.set(propertySpec: WritableDevicePropertySpec<D, T>, value: T): Job = launch { public fun <T, D : Device> D.writeAsync(propertySpec: MutableDevicePropertySpec<D, T>, value: T): Job = launch {
write(propertySpec, value) write(propertySpec, value)
} }
@ -108,37 +104,39 @@ public operator fun <T, D : Device> D.set(propertySpec: WritableDevicePropertySp
public fun <D : Device, T> D.propertyFlow(spec: DevicePropertySpec<D, T>): Flow<T> = messageFlow public fun <D : Device, T> D.propertyFlow(spec: DevicePropertySpec<D, T>): Flow<T> = messageFlow
.filterIsInstance<PropertyChangedMessage>() .filterIsInstance<PropertyChangedMessage>()
.filter { it.property == spec.name } .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]. * A type safe property change listener. Uses the device [CoroutineScope].
*/ */
public fun <D : Device, T> D.onPropertyChange( public fun <D : Device, T> D.onPropertyChange(
spec: DevicePropertySpec<D, T>, spec: DevicePropertySpec<D, T>,
scope: CoroutineScope = this,
callback: suspend PropertyChangedMessage.(T) -> Unit, callback: suspend PropertyChangedMessage.(T) -> Unit,
): Job = messageFlow ): Job = messageFlow
.filterIsInstance<PropertyChangedMessage>() .filterIsInstance<PropertyChangedMessage>()
.filter { it.property == spec.name } .filter { it.property == spec.name }
.onEach { change -> .onEach { change ->
val newValue = spec.converter.metaToObject(change.value) val newValue = spec.converter.read(change.value)
if (newValue != null) { if (newValue != null) {
change.callback(newValue) change.callback(newValue)
} }
}.launchIn(this) }.launchIn(scope)
/** /**
* Call [callback] on initial property value and each value change * Call [callback] on initial property value and each value change
*/ */
public fun <D : Device, T> D.useProperty( public fun <D : Device, T> D.useProperty(
spec: DevicePropertySpec<D, T>, spec: DevicePropertySpec<D, T>,
scope: CoroutineScope = this,
callback: suspend (T) -> Unit, callback: suspend (T) -> Unit,
): Job = launch { ): Job = scope.launch {
callback(read(spec)) callback(read(spec))
messageFlow messageFlow
.filterIsInstance<PropertyChangedMessage>() .filterIsInstance<PropertyChangedMessage>()
.filter { it.property == spec.name } .filter { it.property == spec.name }
.collect { change -> .collect { change ->
val newValue = spec.converter.metaToObject(change.value) val newValue = spec.converter.readOrNull(change.value)
if (newValue != null) { if (newValue != null) {
callback(newValue) callback(newValue)
} }
@ -149,7 +147,7 @@ public fun <D : Device, T> D.useProperty(
/** /**
* Reset the logical state of a property * Reset the logical state of a property
*/ */
public suspend fun <D : Device> D.invalidate(propertySpec: DevicePropertySpec<D, *>) { public suspend fun <D : CachingDevice> D.invalidate(propertySpec: DevicePropertySpec<D, *>) {
invalidate(propertySpec.name) invalidate(propertySpec.name)
} }

View File

@ -5,24 +5,23 @@ import space.kscience.controls.api.ActionDescriptor
import space.kscience.controls.api.Device import space.kscience.controls.api.Device
import space.kscience.controls.api.PropertyDescriptor import space.kscience.controls.api.PropertyDescriptor
import space.kscience.dataforge.meta.Meta 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.PropertyDelegateProvider
import kotlin.properties.ReadOnlyProperty import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
import kotlin.reflect.KProperty1
public object UnitMetaConverter: MetaConverter<Unit>{ public object UnitMetaConverter : MetaConverter<Unit> {
override fun metaToObject(meta: Meta): Unit = Unit
override fun objectToMeta(obj: Unit): Meta = Meta.EMPTY override fun readOrNull(source: Meta): Unit = Unit
override fun convert(obj: Unit): Meta = Meta.EMPTY
} }
public val MetaConverter.Companion.unit: MetaConverter<Unit> get() = UnitMetaConverter public val MetaConverter.Companion.unit: MetaConverter<Unit> get() = UnitMetaConverter
@OptIn(InternalDeviceAPI::class) @OptIn(InternalDeviceAPI::class)
public abstract class DeviceSpec<D : Device> { public abstract class DeviceSpec<D : Device> {
//initializing meta property for everyone //initializing the metadata property for everyone
private val _properties = hashMapOf<String, DevicePropertySpec<D, *>>( private val _properties = hashMapOf<String, DevicePropertySpec<D, *>>(
DeviceMetaPropertySpec.name to DeviceMetaPropertySpec DeviceMetaPropertySpec.name to DeviceMetaPropertySpec
) )
@ -44,72 +43,25 @@ public abstract class DeviceSpec<D : Device> {
return deviceProperty return deviceProperty
} }
public fun <T> property(
converter: MetaConverter<T>,
readOnlyProperty: KProperty1<D, T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, DevicePropertySpec<D, T>>> =
PropertyDelegateProvider { _, property ->
val deviceProperty = object : DevicePropertySpec<D, T> {
override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply {
//TODO add type from converter
writable = true
}.apply(descriptorBuilder)
override val converter: MetaConverter<T> = converter
override suspend fun read(device: D): T = withContext(device.coroutineContext) {
readOnlyProperty.get(device)
}
}
registerProperty(deviceProperty)
ReadOnlyProperty { _, _ ->
deviceProperty
}
}
public fun <T> mutableProperty(
converter: MetaConverter<T>,
readWriteProperty: KMutableProperty1<D, T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, WritableDevicePropertySpec<D, T>>> =
PropertyDelegateProvider { _, property ->
val deviceProperty = object : WritableDevicePropertySpec<D, T> {
override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply {
//TODO add the type from converter
writable = true
}.apply(descriptorBuilder)
override val converter: MetaConverter<T> = converter
override suspend fun read(device: D): T = withContext(device.coroutineContext) {
readWriteProperty.get(device)
}
override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) {
readWriteProperty.set(device, value)
}
}
registerProperty(deviceProperty)
ReadOnlyProperty { _, _ ->
deviceProperty
}
}
public fun <T> property( public fun <T> property(
converter: MetaConverter<T>, converter: MetaConverter<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> T?, read: suspend D.(propertyName: String) -> T?,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> =
PropertyDelegateProvider { _: DeviceSpec<D>, property -> PropertyDelegateProvider { _: DeviceSpec<D>, property ->
val propertyName = name ?: property.name val propertyName = name ?: property.name
val deviceProperty = object : DevicePropertySpec<D, T> { val deviceProperty = object : DevicePropertySpec<D, T> {
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
fromSpec(property)
descriptorBuilder()
}
override val converter: MetaConverter<T> = converter override val converter: MetaConverter<T> = converter
override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() } override suspend fun read(device: D): T? =
withContext(device.coroutineContext) { device.read(propertyName) }
} }
registerProperty(deviceProperty) registerProperty(deviceProperty)
ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>> { _, _ -> ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>> { _, _ ->
@ -121,23 +73,30 @@ public abstract class DeviceSpec<D : Device> {
converter: MetaConverter<T>, converter: MetaConverter<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> T?, read: suspend D.(propertyName: String) -> T?,
write: suspend D.(T) -> Unit, write: suspend D.(propertyName: String, value: T) -> Unit,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>>> = ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> -> PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
val propertyName = name ?: property.name val propertyName = name ?: property.name
val deviceProperty = object : WritableDevicePropertySpec<D, T> { val deviceProperty = object : MutableDevicePropertySpec<D, T> {
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder) override val descriptor: PropertyDescriptor = PropertyDescriptor(
propertyName,
mutable = true
).apply {
fromSpec(property)
descriptorBuilder()
}
override val converter: MetaConverter<T> = converter override val converter: MetaConverter<T> = converter
override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() } override suspend fun read(device: D): T? =
withContext(device.coroutineContext) { device.read(propertyName) }
override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) { override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) {
device.write(value) device.write(propertyName, value)
} }
} }
_properties[propertyName] = deviceProperty registerProperty(deviceProperty)
ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>> { _, _ -> ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>> { _, _ ->
deviceProperty deviceProperty
} }
} }
@ -155,10 +114,13 @@ public abstract class DeviceSpec<D : Device> {
name: String? = null, name: String? = null,
execute: suspend D.(I) -> O, execute: suspend D.(I) -> O,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, I, O>>> = ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, I, O>>> =
PropertyDelegateProvider { _: DeviceSpec<D>, property -> PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
val actionName = name ?: property.name val actionName = name ?: property.name
val deviceAction = object : DeviceActionSpec<D, I, O> { val deviceAction = object : DeviceActionSpec<D, I, O> {
override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply(descriptorBuilder) override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply {
fromSpec(property)
descriptorBuilder()
}
override val inputConverter: MetaConverter<I> = inputConverter override val inputConverter: MetaConverter<I> = inputConverter
override val outputConverter: MetaConverter<O> = outputConverter override val outputConverter: MetaConverter<O> = outputConverter
@ -173,68 +135,39 @@ public abstract class DeviceSpec<D : Device> {
} }
} }
/**
* An action that takes [Meta] and returns [Meta]. No conversions are done
*/
public fun metaAction(
descriptorBuilder: ActionDescriptor.() -> Unit = {},
name: String? = null,
execute: suspend D.(Meta) -> Meta,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> =
action(
MetaConverter.Companion.meta,
MetaConverter.Companion.meta,
descriptorBuilder,
name
) {
execute(it)
}
/**
* An action that takes no parameters and returns no values
*/
public fun unitAction(
descriptorBuilder: ActionDescriptor.() -> Unit = {},
name: String? = null,
execute: suspend D.() -> Unit,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Unit, Unit>>> =
action(
MetaConverter.Companion.unit,
MetaConverter.Companion.unit,
descriptorBuilder,
name
) {
execute()
}
} }
/**
* An action that takes no parameters and returns no values
*/
public fun <D : Device> DeviceSpec<D>.unitAction(
descriptorBuilder: ActionDescriptor.() -> Unit = {},
name: String? = null,
execute: suspend D.() -> Unit,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Unit, Unit>>> =
action(
MetaConverter.Companion.unit,
MetaConverter.Companion.unit,
descriptorBuilder,
name
) {
execute()
}
/** /**
* 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 <D : Device> DeviceSpec<D>.metaAction(
public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty( descriptorBuilder: ActionDescriptor.() -> Unit = {},
converter: MetaConverter<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, WritableDevicePropertySpec<D, T>>> = execute: suspend D.(Meta) -> Meta,
PropertyDelegateProvider { _, property -> ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> =
val deviceProperty = object : WritableDevicePropertySpec<D, T> { action(
val propertyName = name ?: property.name MetaConverter.Companion.meta,
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply { MetaConverter.Companion.meta,
//TODO add type from converter descriptorBuilder,
writable = true name
}.apply(descriptorBuilder) ) {
execute(it)
}
override val converter: MetaConverter<T> = converter
override suspend fun read(device: D): T? = device.getProperty(propertyName)?.let(converter::metaToObject)
override suspend fun write(device: D, value: T): Unit =
device.writeProperty(propertyName, converter.objectToMeta(value))
}
registerProperty(deviceProperty)
ReadOnlyProperty { _, _ ->
deviceProperty
}
}

View File

@ -12,7 +12,7 @@ import kotlin.time.Duration
/** /**
* Perform a recurring asynchronous read action and return a flow of results. * 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 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 * The flow is canceled when the device scope is canceled
*/ */

View File

@ -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<*>)

View File

@ -1,7 +1,6 @@
package space.kscience.controls.spec package space.kscience.controls.spec
import space.kscience.dataforge.meta.* import space.kscience.dataforge.meta.*
import space.kscience.dataforge.meta.transformations.MetaConverter
import kotlin.time.Duration import kotlin.time.Duration
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
import kotlin.time.toDuration import kotlin.time.toDuration
@ -10,14 +9,14 @@ public fun Double.asMeta(): Meta = Meta(asValue())
//TODO to be moved to DF //TODO to be moved to DF
public object DurationConverter : MetaConverter<Duration> { public object DurationConverter : MetaConverter<Duration> {
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 { ?: run {
val unit: DurationUnit = meta["unit"].enum<DurationUnit>() ?: DurationUnit.SECONDS val unit: DurationUnit = source["unit"].enum<DurationUnit>() ?: DurationUnit.SECONDS
val value = meta[Meta.VALUE_KEY].double ?: error("No value present for Duration") val value = source[Meta.VALUE_KEY].double ?: error("No value present for Duration")
return@run value.toDuration(unit) return@run value.toDuration(unit)
} }
override fun objectToMeta(obj: Duration): Meta = obj.toDouble(DurationUnit.SECONDS).asMeta() override fun convert(obj: Duration): Meta = obj.toDouble(DurationUnit.SECONDS).asMeta()
} }
public val MetaConverter.Companion.duration: MetaConverter<Duration> get() = DurationConverter public val MetaConverter.Companion.duration: MetaConverter<Duration> get() = DurationConverter

View File

@ -4,22 +4,71 @@ import space.kscience.controls.api.Device
import space.kscience.controls.api.PropertyDescriptor import space.kscience.controls.api.PropertyDescriptor
import space.kscience.controls.api.metaDescriptor import space.kscience.controls.api.metaDescriptor
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaConverter
import space.kscience.dataforge.meta.ValueType import space.kscience.dataforge.meta.ValueType
import space.kscience.dataforge.meta.transformations.MetaConverter
import kotlin.properties.PropertyDelegateProvider import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadOnlyProperty import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KProperty1
/**
* A read-only device property that delegates reading to a device [KProperty1]
*/
public fun <T, D : Device> DeviceSpec<D>.property(
converter: MetaConverter<T>,
readOnlyProperty: KProperty1<D, T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = property(
converter,
descriptorBuilder,
name = readOnlyProperty.name,
read = { readOnlyProperty.get(this) }
)
/**
* Mutable property that delegates reading and writing to a device [KMutableProperty1]
*/
public fun <T, D : Device> DeviceSpec<D>.mutableProperty(
converter: MetaConverter<T>,
readWriteProperty: KMutableProperty1<D, T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
mutableProperty(
converter,
descriptorBuilder,
readWriteProperty.name,
read = { _ -> readWriteProperty.get(this) },
write = { _, value: T -> readWriteProperty.set(this, value) }
)
/**
* Register a mutable logical property (without a corresponding physical state) for a device
*/
public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
converter: MetaConverter<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
mutableProperty(
converter,
descriptorBuilder,
name,
read = { propertyName -> getProperty(propertyName)?.let(converter::readOrNull) },
write = { propertyName, value -> writeProperty(propertyName, converter.convert(value)) }
)
//read only delegates //read only delegates
public fun <D : Device> DeviceSpec<D>.booleanProperty( public fun <D : Device> DeviceSpec<D>.booleanProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> Boolean? read: suspend D.(propertyName: String) -> Boolean?
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Boolean>>> = property( ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Boolean>>> = property(
MetaConverter.boolean, MetaConverter.boolean,
{ {
metaDescriptor { metaDescriptor {
type(ValueType.BOOLEAN) valueType(ValueType.BOOLEAN)
} }
descriptorBuilder() descriptorBuilder()
}, },
@ -31,15 +80,15 @@ private inline fun numberDescriptor(
crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {} crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {}
): PropertyDescriptor.() -> Unit = { ): PropertyDescriptor.() -> Unit = {
metaDescriptor { metaDescriptor {
type(ValueType.NUMBER) valueType(ValueType.NUMBER)
} }
descriptorBuilder() descriptorBuilder()
} }
public fun <D : Device> DeviceSpec<D>.numberProperty( public fun <D : Device> DeviceSpec<D>.numberProperty(
name: String? = null,
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
read: suspend D.() -> Number? name: String? = null,
read: suspend D.(propertyName: String) -> Number?
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Number>>> = property( ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Number>>> = property(
MetaConverter.number, MetaConverter.number,
numberDescriptor(descriptorBuilder), numberDescriptor(descriptorBuilder),
@ -50,7 +99,7 @@ public fun <D : Device> DeviceSpec<D>.numberProperty(
public fun <D : Device> DeviceSpec<D>.doubleProperty( public fun <D : Device> DeviceSpec<D>.doubleProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> Double? read: suspend D.(propertyName: String) -> Double?
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Double>>> = property( ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Double>>> = property(
MetaConverter.double, MetaConverter.double,
numberDescriptor(descriptorBuilder), numberDescriptor(descriptorBuilder),
@ -61,12 +110,12 @@ public fun <D : Device> DeviceSpec<D>.doubleProperty(
public fun <D : Device> DeviceSpec<D>.stringProperty( public fun <D : Device> DeviceSpec<D>.stringProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> String? read: suspend D.(propertyName: String) -> String?
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, String>>> = property( ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, String>>> = property(
MetaConverter.string, MetaConverter.string,
{ {
metaDescriptor { metaDescriptor {
type(ValueType.STRING) valueType(ValueType.STRING)
} }
descriptorBuilder() descriptorBuilder()
}, },
@ -77,12 +126,12 @@ public fun <D : Device> DeviceSpec<D>.stringProperty(
public fun <D : Device> DeviceSpec<D>.metaProperty( public fun <D : Device> DeviceSpec<D>.metaProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> Meta? read: suspend D.(propertyName: String) -> Meta?
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Meta>>> = property( ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Meta>>> = property(
MetaConverter.meta, MetaConverter.meta,
{ {
metaDescriptor { metaDescriptor {
type(ValueType.STRING) valueType(ValueType.STRING)
} }
descriptorBuilder() descriptorBuilder()
}, },
@ -95,14 +144,14 @@ public fun <D : Device> DeviceSpec<D>.metaProperty(
public fun <D : Device> DeviceSpec<D>.booleanProperty( public fun <D : Device> DeviceSpec<D>.booleanProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> Boolean?, read: suspend D.(propertyName: String) -> Boolean?,
write: suspend D.(Boolean) -> Unit write: suspend D.(propertyName: String, value: Boolean) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Boolean>>> = ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Boolean>>> =
mutableProperty( mutableProperty(
MetaConverter.boolean, MetaConverter.boolean,
{ {
metaDescriptor { metaDescriptor {
type(ValueType.BOOLEAN) valueType(ValueType.BOOLEAN)
} }
descriptorBuilder() descriptorBuilder()
}, },
@ -115,31 +164,31 @@ public fun <D : Device> DeviceSpec<D>.booleanProperty(
public fun <D : Device> DeviceSpec<D>.numberProperty( public fun <D : Device> DeviceSpec<D>.numberProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> Number, read: suspend D.(propertyName: String) -> Number,
write: suspend D.(Number) -> Unit write: suspend D.(propertyName: String, value: Number) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Number>>> = ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Number>>> =
mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write) mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write)
public fun <D : Device> DeviceSpec<D>.doubleProperty( public fun <D : Device> DeviceSpec<D>.doubleProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> Double, read: suspend D.(propertyName: String) -> Double,
write: suspend D.(Double) -> Unit write: suspend D.(propertyName: String, value: Double) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Double>>> = ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Double>>> =
mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write) mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write)
public fun <D : Device> DeviceSpec<D>.stringProperty( public fun <D : Device> DeviceSpec<D>.stringProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> String, read: suspend D.(propertyName: String) -> String,
write: suspend D.(String) -> Unit write: suspend D.(propertyName: String, value: String) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, String>>> = ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, String>>> =
mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write) mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write)
public fun <D : Device> DeviceSpec<D>.metaProperty( public fun <D : Device> DeviceSpec<D>.metaProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {}, descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null, name: String? = null,
read: suspend D.() -> Meta, read: suspend D.(propertyName: String) -> Meta,
write: suspend D.(Meta) -> Unit write: suspend D.(propertyName: String, value: Meta) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Meta>>> = ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Meta>>> =
mutableProperty(MetaConverter.meta, descriptorBuilder, name, read, write) mutableProperty(MetaConverter.meta, descriptorBuilder, name, read, write)

View File

@ -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<*>){}

View File

@ -1,19 +1,20 @@
package space.kscience.controls.ports package space.kscience.controls.ports
import kotlinx.coroutines.* import kotlinx.coroutines.*
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.*
import space.kscience.dataforge.context.error
import space.kscience.dataforge.context.info
import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.* import space.kscience.dataforge.meta.*
import java.net.InetSocketAddress import java.net.InetSocketAddress
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.channels.AsynchronousCloseException
import java.nio.channels.ByteChannel import java.nio.channels.ByteChannel
import java.nio.channels.DatagramChannel import java.nio.channels.DatagramChannel
import java.nio.channels.SocketChannel import java.nio.channels.SocketChannel
import kotlin.coroutines.CoroutineContext 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() rewind()
val response = ByteArray(limit) val response = ByteArray(limit)
get(response) get(response)
@ -26,32 +27,41 @@ public fun ByteBuffer.toArray(limit: Int = limit()): ByteArray {
*/ */
public class ChannelPort( public class ChannelPort(
context: Context, context: Context,
meta: Meta,
coroutineContext: CoroutineContext = context.coroutineContext, coroutineContext: CoroutineContext = context.coroutineContext,
channelBuilder: suspend () -> ByteChannel, channelBuilder: suspend () -> ByteChannel,
) : AbstractPort(context, coroutineContext), AutoCloseable { ) : AbstractAsynchronousPort(context, meta, coroutineContext), AutoCloseable {
private val futureChannel: Deferred<ByteChannel> = this.scope.async(Dispatchers.IO) {
channelBuilder()
}
/** /**
* A handler to await port connection * A handler to await port connection
*/ */
public val startJob: Job get() = futureChannel private val futureChannel: Deferred<ByteChannel> = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
channelBuilder()
}
private val listenerJob = this.scope.launch(Dispatchers.IO) { private var listenerJob: Job? = null
val channel = futureChannel.await()
val buffer = ByteBuffer.allocate(1024) override val isOpen: Boolean get() = listenerJob?.isActive == true
while (isActive) {
try { override fun onOpen() {
val num = channel.read(buffer) listenerJob = scope.launch(Dispatchers.IO) {
if (num > 0) { val channel = futureChannel.await()
receive(buffer.toArray(num)) 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) @OptIn(ExperimentalCoroutinesApi::class)
override fun close() { override fun close() {
listenerJob.cancel() listenerJob?.cancel()
if (futureChannel.isCompleted) { if (futureChannel.isCompleted) {
futureChannel.getCompleted().close() futureChannel.getCompleted().close()
} else {
futureChannel.cancel()
} }
super.close() super.close()
} }
} }
/** /**
* A [PortFactory] for TCP connections * A [Factory] for TCP connections
*/ */
public object TcpPort : PortFactory { public object TcpPort : Factory<AsynchronousPort> {
override val type: String = "tcp" public fun build(
context: Context,
host: String,
port: Int,
coroutineContext: CoroutineContext = context.coroutineContext,
): ChannelPort {
val meta = Meta {
"name" put "tcp://$host:$port"
"type" put "tcp"
"host" put host
"port" put port
}
return ChannelPort(context, meta, coroutineContext) {
SocketChannel.open(InetSocketAddress(host, port))
}
}
/**
* Create and open TCP port
*/
public fun open( public fun open(
context: Context, context: Context,
host: String, host: String,
port: Int, port: Int,
coroutineContext: CoroutineContext = context.coroutineContext, coroutineContext: CoroutineContext = context.coroutineContext,
): ChannelPort = ChannelPort(context, coroutineContext) { ): ChannelPort = build(context, host, port, coroutineContext).apply { open() }
SocketChannel.open(InetSocketAddress(host, port))
}
override fun build(context: Context, meta: Meta): ChannelPort { override fun build(context: Context, meta: Meta): ChannelPort {
val host = meta["host"].string ?: "localhost" val host = meta["host"].string ?: "localhost"
val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta") val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta")
return open(context, host, port) return build(context, host, port)
} }
} }
/** /**
* A [PortFactory] for UDP connections * A [Factory] for UDP connections
*/ */
public object UdpPort : PortFactory { public object UdpPort : Factory<AsynchronousPort> {
override val type: String = "udp" public fun build(
context: Context,
remoteHost: String,
remotePort: Int,
localPort: Int? = null,
localHost: String? = null,
coroutineContext: CoroutineContext = context.coroutineContext,
): ChannelPort {
val meta = Meta {
"name" put "udp://$remoteHost:$remotePort"
"type" put "udp"
"remoteHost" put remoteHost
"remotePort" put remotePort
localHost?.let { "localHost" put it }
localPort?.let { "localPort" put it }
}
return ChannelPort(context, meta, coroutineContext) {
DatagramChannel.open().apply {
//bind the channel to a local port to receive messages
localPort?.let { bind(InetSocketAddress(localHost ?: "localhost", it)) }
//connect to remote port to send messages
connect(InetSocketAddress(remoteHost, remotePort.toInt()))
context.logger.info { "Connected to UDP $remotePort on $remoteHost" }
}
}
}
/** /**
* Connect a datagram channel to a remote host/port. If [localPort] is provided, it is used to bind local port for receiving messages. * 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, remotePort: Int,
localPort: Int? = null, localPort: Int? = null,
localHost: String = "localhost", localHost: String = "localhost",
coroutineContext: CoroutineContext = context.coroutineContext, ): ChannelPort = build(context, remoteHost, remotePort, localPort, localHost).apply { open() }
): 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" }
}
}
override fun build(context: Context, meta: Meta): ChannelPort { override fun build(context: Context, meta: Meta): ChannelPort {
val remoteHost by meta.string { error("Remote host is not specified") } val remoteHost by meta.string { error("Remote host is not specified") }
val remotePort by meta.number { error("Remote port is not specified") } val remotePort by meta.number { error("Remote port is not specified") }
val localHost: String? by meta.string() val localHost: String? by meta.string()
val localPort: Int? by meta.int() val localPort: Int? by meta.int()
return open(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost") return build(context, remoteHost, remotePort.toInt(), localPort, localHost)
} }
} }

View File

@ -6,7 +6,7 @@ import space.kscience.dataforge.context.PluginFactory
import space.kscience.dataforge.context.PluginTag import space.kscience.dataforge.context.PluginTag
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.names.Name 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 * A plugin for loading JVM nio-based ports
@ -17,9 +17,9 @@ public class JvmPortsPlugin : AbstractPlugin() {
override val tag: PluginTag get() = Companion.tag override val tag: PluginTag get() = Companion.tag
override fun content(target: String): Map<Name, Any> = when(target){ override fun content(target: String): Map<Name, Any> = when(target){
PortFactory.TYPE -> mapOf( Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf(
TcpPort.type.parseAsName() to TcpPort, "tcp".asName() to TcpPort,
UdpPort.type.parseAsName() to UdpPort "udp".asName() to UdpPort
) )
else -> emptyMap() else -> emptyMap()
} }

View File

@ -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)
}
}

View File

@ -0,0 +1,18 @@
package space.kscience.controls.spec
import space.kscience.controls.api.ActionDescriptor
import space.kscience.controls.api.PropertyDescriptor
import kotlin.reflect.KProperty
import kotlin.reflect.full.findAnnotation
internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) {
property.findAnnotation<Description>()?.let {
description = it.content
}
}
internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){
property.findAnnotation<Description>()?.let {
description = it.content
}
}

View File

@ -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()
}
}

View File

@ -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())
}
}
}

View File

@ -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<*>){}

View File

@ -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")
}
```

View File

@ -0,0 +1,8 @@
public final class space/kscience/controls/jupyter/ControlsJupyter : space/kscience/visionforge/jupyter/VisionForgeIntegration {
public static final field Companion Lspace/kscience/controls/jupyter/ControlsJupyter$Companion;
public fun <init> ()V
}
public final class space/kscience/controls/jupyter/ControlsJupyter$Companion {
}

View File

@ -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)
}
}

View File

@ -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)
}

View File

@ -0,0 +1,71 @@
package space.kscience.controls.jupyter
import org.jetbrains.kotlinx.jupyter.api.declare
import org.jetbrains.kotlinx.jupyter.api.libraries.resources
import space.kscience.controls.manager.ClockManager
import space.kscience.controls.manager.DeviceManager
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.misc.DFExperimental
import space.kscience.plotly.Plot
import space.kscience.visionforge.jupyter.VisionForge
import space.kscience.visionforge.jupyter.VisionForgeIntegration
import space.kscience.visionforge.markup.MarkupPlugin
import space.kscience.visionforge.plotly.PlotlyPlugin
import space.kscience.visionforge.plotly.asVision
import space.kscience.visionforge.visionManager
@OptIn(DFExperimental::class)
public class ControlsJupyter : VisionForgeIntegration(CONTEXT.visionManager) {
override fun Builder.afterLoaded(vf: VisionForge) {
resources {
js("controls-jupyter") {
classPath("js/controls-jupyter.js")
}
}
onLoaded {
declare("context" to CONTEXT)
}
import(
"kotlin.time.*",
"kotlin.time.Duration.Companion.milliseconds",
"kotlin.time.Duration.Companion.seconds",
// "space.kscience.tables.*",
"space.kscience.dataforge.meta.*",
"space.kscience.dataforge.context.*",
"space.kscience.plotly.*",
"space.kscience.plotly.models.*",
"space.kscience.visionforge.plotly.*",
"space.kscience.controls.manager.*",
"space.kscience.controls.constructor.*",
"space.kscience.controls.vision.*",
"space.kscience.controls.spec.*"
)
// render<Table<*>> { table ->
// vf.produceHtml {
// vision { table.toVision() }
// }
// }
render<Plot> { plot ->
vf.produceHtml {
vision { plot.asVision() }
}
}
}
public companion object {
private val CONTEXT: Context = Context("controls-jupyter") {
plugin(DeviceManager)
plugin(ClockManager)
plugin(PlotlyPlugin)
// plugin(TableVisionPlugin)
plugin(MarkupPlugin)
}
}
}

View File

@ -12,18 +12,16 @@ Magix service for binding controls devices (both as RPC client and server)
## Artifact: ## 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:** **Gradle Kotlin DSL:**
```kotlin ```kotlin
repositories { repositories {
maven("https://repo.kotlin.link") maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("space.kscience:controls-magix:0.2.0") implementation("space.kscience:controls-magix:0.3.0")
} }
``` ```

View File

@ -12,6 +12,7 @@ description = """
kscience { kscience {
jvm() jvm()
js() js()
useCoroutines("1.8.0")
useSerialization { useSerialization {
json() json()
} }

View File

@ -1,12 +1,14 @@
package space.kscience.controls.client package space.kscience.controls.client
import com.benasher44.uuid.uuid4 import com.benasher44.uuid.uuid4
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.*
import kotlinx.coroutines.newCoroutineContext
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import space.kscience.controls.api.* import space.kscience.controls.api.*
import space.kscience.controls.manager.DeviceManager 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.context.Context
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.misc.DFExperimental import space.kscience.dataforge.misc.DFExperimental
@ -26,10 +28,10 @@ public class DeviceClient(
private val deviceName: Name, private val deviceName: Name,
incomingFlow: Flow<DeviceMessage>, incomingFlow: Flow<DeviceMessage>,
private val send: suspend (DeviceMessage) -> Unit, 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() private val mutex = Mutex()
@ -99,19 +101,82 @@ public class DeviceClient(
} }
@DFExperimental @DFExperimental
override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.OPEN override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.STARTED
} }
/** /**
* Connect to a remote device via this endpoint. * Connect to a remote device via this endpoint.
* *
* @param context a [Context] to run device in * @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 * @param deviceName the name of device within endpoint
*/ */
public fun MagixEndpoint.remoteDevice(context: Context, endpointName: String, deviceName: Name): DeviceClient { public fun MagixEndpoint.remoteDevice(
val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second } 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) { return DeviceClient(context, deviceName, subscription) {
send(DeviceManager.magixFormat, it, endpointName, id = stringUID()) send(
format = DeviceManager.magixFormat,
payload = it,
source = sourceEndpointName,
target = targetEndpointName,
id = stringUID()
)
} }
}
/**
* Subscribe on specific property of a device without creating a device
*/
public fun <T> MagixEndpoint.controlsPropertyFlow(
endpointName: String,
deviceName: Name,
propertySpec: DevicePropertySpec<*, T>,
): Flow<T> {
val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second }
return subscription.filterIsInstance<PropertyChangedMessage>()
.filter { message ->
message.sourceDevice == deviceName && message.property == propertySpec.name
}.map {
propertySpec.converter.read(it.value)
}
}
public suspend fun <T> MagixEndpoint.sendControlsPropertyChange(
sourceEndpointName: String,
targetEndpointName: String,
deviceName: Name,
propertySpec: DevicePropertySpec<*, T>,
value: T,
) {
val message = PropertySetMessage(
property = propertySpec.name,
value = propertySpec.converter.convert(value),
targetDevice = deviceName
)
send(DeviceManager.magixFormat, message, source = sourceEndpointName, target = targetEndpointName)
}
/**
* Subscribe on property change messages together with property values
*/
public fun <T> MagixEndpoint.controlsPropertyMessageFlow(
endpointName: String,
deviceName: Name,
propertySpec: DevicePropertySpec<*, T>,
): Flow<Pair<PropertyChangedMessage, T>> {
val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second }
return subscription.filterIsInstance<PropertyChangedMessage>()
.filter { message ->
message.sourceDevice == deviceName && message.property == propertySpec.name
}.map {
it to propertySpec.converter.read(it.value)
}
} }

View File

@ -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 <T> DeviceClient.read(propertySpec: DevicePropertySpec<*, T>): T =
propertySpec.converter.readOrNull(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
public suspend fun <T> DeviceClient.request(propertySpec: DevicePropertySpec<*, T>): T =
propertySpec.converter.read(getOrReadProperty(propertySpec.name))
public suspend fun <T> DeviceClient.write(propertySpec: MutableDevicePropertySpec<*, T>, value: T) {
writeProperty(propertySpec.name, propertySpec.converter.convert(value))
}
public fun <T> DeviceClient.writeAsync(propertySpec: MutableDevicePropertySpec<*, T>, value: T): Job = launch {
write(propertySpec, value)
}
public fun <T> DeviceClient.propertyFlow(spec: DevicePropertySpec<*, T>): Flow<T> = messageFlow
.filterIsInstance<PropertyChangedMessage>()
.filter { it.property == spec.name }
.mapNotNull { spec.converter.readOrNull(it.value) }
public fun <T> DeviceClient.onPropertyChange(
spec: DevicePropertySpec<*, T>,
scope: CoroutineScope = this,
callback: suspend PropertyChangedMessage.(T) -> Unit,
): Job = messageFlow
.filterIsInstance<PropertyChangedMessage>()
.filter { it.property == spec.name }
.onEach { change ->
val newValue = spec.converter.readOrNull(change.value)
if (newValue != null) {
change.callback(newValue)
}
}.launchIn(scope)
public fun <T> DeviceClient.useProperty(
spec: DevicePropertySpec<*, T>,
scope: CoroutineScope = this,
callback: suspend (T) -> Unit,
): Job = scope.launch {
callback(read(spec))
messageFlow
.filterIsInstance<PropertyChangedMessage>()
.filter { it.property == spec.name }
.collect { change ->
val newValue = spec.converter.readOrNull(change.value)
if (newValue != null) {
callback(newValue)
}
}
}
public suspend fun <I, O> DeviceClient.execute(actionSpec: DeviceActionSpec<*, I, O>, input: I): O {
val inputMeta = actionSpec.inputConverter.convert(input)
val res = execute(actionSpec.name, inputMeta)
return actionSpec.outputConverter.read(res ?: Meta.EMPTY)
}
public suspend fun <O> DeviceClient.execute(actionSpec: DeviceActionSpec<*, Unit, O>): O {
val res = execute(actionSpec.name, Meta.EMPTY)
return actionSpec.outputConverter.read(res ?: Meta.EMPTY)
}

View File

@ -12,6 +12,8 @@ import space.kscience.controls.manager.respondHubMessage
import space.kscience.dataforge.context.error import space.kscience.dataforge.context.error
import space.kscience.dataforge.context.logger import space.kscience.dataforge.context.logger
import space.kscience.magix.api.* import space.kscience.magix.api.*
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
internal val controlsMagixFormat: MagixFormat<DeviceMessage> = MagixFormat( internal val controlsMagixFormat: MagixFormat<DeviceMessage> = 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) * 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( public fun DeviceManager.launchMagixService(
endpoint: MagixEndpoint, endpoint: MagixEndpoint,
endpointID: String = controlsMagixFormat.defaultFormat, endpointID: String = controlsMagixFormat.defaultFormat,
): Job = context.launch { coroutineContext: CoroutineContext = EmptyCoroutineContext,
endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID)).onEach { (request, payload) -> ): Job = context.launch(coroutineContext) {
endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID, null)).onEach { (request, payload) ->
val responsePayload = respondHubMessage(payload) val responsePayload = respondHubMessage(payload)
if (responsePayload != null) { responsePayload.forEach {
endpoint.send( endpoint.send(
format = controlsMagixFormat, format = controlsMagixFormat,
payload = responsePayload, payload = it,
source = endpointID, source = endpointID,
target = request.sourceEndpoint, target = request.sourceEndpoint,
id = generateId(request), id = generateId(request),
@ -53,7 +58,7 @@ public fun DeviceManager.launchMagixService(
logger.error(error) { "Error while responding to message: ${error.message}" } logger.error(error) { "Error while responding to message: ${error.message}" }
}.launchIn(this) }.launchIn(this)
hubMessageFlow(this).onEach { payload -> hubMessageFlow().onEach { payload ->
endpoint.send( endpoint.send(
format = controlsMagixFormat, format = controlsMagixFormat,
payload = payload, payload = payload,

View File

@ -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>(TestDevice, context, meta) {
private val rng = Random(meta["seed"].int ?: 0)
private val randomValue get() = rng.nextDouble()
companion object : DeviceSpec<TestDevice>(), Factory<TestDevice> {
override fun build(context: Context, meta: Meta): TestDevice = TestDevice(context, meta)
val value by doubleProperty { randomValue }
override suspend fun TestDevice.onOpen() {
doRecurring((meta["delay"].int ?: 10).milliseconds) {
read(value)
}
}
}
}
@Test
fun wrapper() = runTest {
val context = Context {
plugin(DeviceManager)
}
val device = context.request(DeviceManager).install("test", TestDevice)
val virtualMagixEndpoint = object : MagixEndpoint {
override fun subscribe(filter: MagixMessageFilter): Flow<MagixMessage> = device.messageFlow.map {
MagixMessage(
format = DeviceManager.magixFormat.defaultFormat,
payload = MagixEndpoint.magixJson.encodeToJsonElement(DeviceManager.magixFormat.serializer, it),
sourceEndpoint = "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))
}
}

View File

@ -14,18 +14,16 @@ Automatically checks consistency.
## Artifact: ## 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:** **Gradle Kotlin DSL:**
```kotlin ```kotlin
repositories { repositories {
maven("https://repo.kotlin.link") maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("space.kscience:controls-modbus:0.2.0") implementation("space.kscience:controls-modbus:0.3.0")
} }
``` ```

View File

@ -1,15 +1,14 @@
package space.kscience.controls.modbus package space.kscience.controls.modbus
import com.ghgande.j2mod.modbus.procimg.* import com.ghgande.j2mod.modbus.procimg.*
import io.ktor.utils.io.core.buildPacket import kotlinx.coroutines.delay
import io.ktor.utils.io.core.readByteBuffer import kotlinx.coroutines.isActive
import io.ktor.utils.io.core.writeShort
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.io.Buffer
import space.kscience.controls.api.Device import space.kscience.controls.api.Device
import space.kscience.controls.spec.DevicePropertySpec import space.kscience.controls.ports.readShort
import space.kscience.controls.spec.WritableDevicePropertySpec import space.kscience.controls.spec.*
import space.kscience.controls.spec.set import space.kscience.dataforge.io.Binary
import space.kscience.controls.spec.useProperty
public class DeviceProcessImageBuilder<D : Device> internal constructor( public class DeviceProcessImageBuilder<D : Device> internal constructor(
@ -29,10 +28,10 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
public fun bind( public fun bind(
key: ModbusRegistryKey.Coil, key: ModbusRegistryKey.Coil,
propertySpec: WritableDevicePropertySpec<D, Boolean>, propertySpec: MutableDevicePropertySpec<D, Boolean>,
): ObservableDigitalOut = bind(key) { coil -> ): ObservableDigitalOut = bind(key) { coil ->
coil.addObserver { _, _ -> coil.addObserver { _, _ ->
device[propertySpec] = coil.isSet device.writeAsync(propertySpec, coil.isSet)
} }
device.useProperty(propertySpec) { value -> device.useProperty(propertySpec) { value ->
coil.set(value) coil.set(value)
@ -89,10 +88,10 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
public fun bind( public fun bind(
key: ModbusRegistryKey.HoldingRegister, key: ModbusRegistryKey.HoldingRegister,
propertySpec: WritableDevicePropertySpec<D, Short>, propertySpec: MutableDevicePropertySpec<D, Short>,
): ObservableRegister = bind(key) { register -> ): ObservableRegister = bind(key) { register ->
register.addObserver { _, _ -> register.addObserver { _, _ ->
device[propertySpec] = register.toShort() device.writeAsync(propertySpec, register.toShort())
} }
device.useProperty(propertySpec) { value -> device.useProperty(propertySpec) { value ->
register.setValue(value) register.setValue(value)
@ -109,37 +108,63 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
} }
device.useProperty(propertySpec) { value -> device.useProperty(propertySpec) { value ->
val packet = buildPacket { val binary = Binary {
key.format.writeObject(this, value) key.format.writeTo(this, value)
}.readByteBuffer() }
registers.forEachIndexed { index, register -> registers.forEachIndexed { index, register ->
register.setValue(packet.getShort(index * 2)) register.setValue(binary.readShort(index * 2))
} }
} }
} }
public fun <T> bind(key: ModbusRegistryKey.HoldingRange<T>, propertySpec: WritableDevicePropertySpec<D, T>) { /**
* Trigger [block] if one of register changes.
*/
private fun List<ObservableRegister>.onChange(block: suspend (Buffer) -> Unit) {
var ready = false
forEach { register ->
register.addObserver { _, _ ->
ready = true
}
}
device.launch {
val builder = Buffer()
while (isActive) {
delay(1)
if (ready) {
val packet = builder.apply {
forEach { value ->
writeShort(value.toShort())
}
}
block(packet)
ready = false
}
}
}
}
public fun <T> bind(key: ModbusRegistryKey.HoldingRange<T>, propertySpec: MutableDevicePropertySpec<D, T>) {
val registers = List(key.count) { val registers = List(key.count) {
ObservableRegister() ObservableRegister()
} }
registers.forEachIndexed { index, register -> 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) image.addRegister(key.address + index, register)
} }
registers.onChange { packet ->
device.write(propertySpec, key.format.readFrom(packet))
}
device.useProperty(propertySpec) { value -> device.useProperty(propertySpec) { value ->
val packet = buildPacket { val binary = Binary {
key.format.writeObject(this, value) key.format.writeTo(this, value)
}.readByteBuffer() }
registers.forEachIndexed { index, observableRegister -> registers.forEachIndexed { index, observableRegister ->
observableRegister.setValue(packet.getShort(index * 2)) observableRegister.setValue(binary.readShort(index * 2))
} }
} }
} }
@ -182,20 +207,17 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
val registers = List(key.count) { val registers = List(key.count) {
ObservableRegister() ObservableRegister()
} }
registers.forEachIndexed { index, register -> 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) image.addRegister(key.address + index, register)
} }
registers.onChange { packet ->
device.launch {
device.action(key.format.readFrom(packet))
}
}
return registers return registers
} }
@ -205,14 +227,16 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor(
* Bind the device to Modbus slave (server) image. * Bind the device to Modbus slave (server) image.
*/ */
public fun <D : Device> D.bindProcessImage( public fun <D : Device> D.bindProcessImage(
unitId: Int = 0,
openOnBind: Boolean = true, openOnBind: Boolean = true,
binding: DeviceProcessImageBuilder<D>.() -> Unit, binding: DeviceProcessImageBuilder<D>.() -> Unit,
): ProcessImage { ): ProcessImage {
val image = SimpleProcessImage() val image = SimpleProcessImage(unitId)
DeviceProcessImageBuilder(this, image).apply(binding) DeviceProcessImageBuilder(this, image).apply(binding)
image.setLocked(true)
if (openOnBind) { if (openOnBind) {
launch { launch {
open() start()
} }
} }
return image return image

View File

@ -5,11 +5,10 @@ import com.ghgande.j2mod.modbus.procimg.InputRegister
import com.ghgande.j2mod.modbus.procimg.Register import com.ghgande.j2mod.modbus.procimg.Register
import com.ghgande.j2mod.modbus.procimg.SimpleInputRegister import com.ghgande.j2mod.modbus.procimg.SimpleInputRegister
import com.ghgande.j2mod.modbus.util.BitVector import com.ghgande.j2mod.modbus.util.BitVector
import io.ktor.utils.io.core.ByteReadPacket import kotlinx.io.Buffer
import io.ktor.utils.io.core.buildPacket
import io.ktor.utils.io.core.readByteBuffer
import io.ktor.utils.io.core.writeShort
import space.kscience.controls.api.Device import space.kscience.controls.api.Device
import space.kscience.dataforge.io.Buffer
import space.kscience.dataforge.io.ByteArray
import java.nio.ByteBuffer import java.nio.ByteBuffer
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
@ -21,9 +20,9 @@ import kotlin.reflect.KProperty
public interface ModbusDevice : Device { 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 * The modubus master connector
@ -45,7 +44,7 @@ public interface ModbusDevice : Device {
public operator fun <T> ModbusRegistryKey.InputRange<T>.getValue(thisRef: Any?, property: KProperty<*>): T { public operator fun <T> ModbusRegistryKey.InputRange<T>.getValue(thisRef: Any?, property: KProperty<*>): T {
val packet = readInputRegistersToPacket(address, count) val packet = readInputRegistersToPacket(address, count)
return format.readObject(packet) return format.readFrom(packet)
} }
@ -61,8 +60,8 @@ public interface ModbusDevice : Device {
} }
public operator fun <T> ModbusRegistryKey.HoldingRange<T>.getValue(thisRef: Any?, property: KProperty<*>): T { public operator fun <T> ModbusRegistryKey.HoldingRange<T>.getValue(thisRef: Any?, property: KProperty<*>): T {
val packet = readInputRegistersToPacket(address, count) val packet = readHoldingRegistersToPacket(address, count)
return format.readObject(packet) return format.readFrom(packet)
} }
public operator fun <T> ModbusRegistryKey.HoldingRange<T>.setValue( public operator fun <T> ModbusRegistryKey.HoldingRange<T>.setValue(
@ -70,9 +69,9 @@ public interface ModbusDevice : Device {
property: KProperty<*>, property: KProperty<*>,
value: T, value: T,
) { ) {
val buffer = buildPacket { val buffer = ByteArray {
format.writeObject(this, value) format.writeTo(this, value)
}.readByteBuffer() }
writeHoldingRegisters(address, buffer) writeHoldingRegisters(address, buffer)
} }
@ -82,35 +81,35 @@ public interface ModbusDevice : Device {
* Read multiple sequential modbus coils (bit-values) * Read multiple sequential modbus coils (bit-values)
*/ */
public fun ModbusDevice.readCoils(address: Int, count: Int): BitVector = 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 = 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) { public fun ModbusDevice.writeCoils(address: Int, values: BooleanArray) {
val bitVector = BitVector(values.size) val bitVector = BitVector(values.size)
values.forEachIndexed { index, value -> values.forEachIndexed { index, value ->
bitVector.setBit(index, value) bitVector.setBit(index, value)
} }
master.writeMultipleCoils(clientId, address, bitVector) master.writeMultipleCoils(unitId, address, bitVector)
} }
public fun ModbusDevice.writeCoil(address: Int, value: Boolean) { 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) { 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 = 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 = public fun ModbusDevice.readInputDiscrete(address: Int): Boolean =
master.readInputDiscretes(clientId, address, 1).getBit(0) master.readInputDiscretes(unitId, address, 1).getBit(0)
public fun ModbusDevice.readInputRegisters(address: Int, count: Int): List<InputRegister> = public fun ModbusDevice.readInputRegisters(address: Int, count: Int): List<InputRegister> =
master.readInputRegisters(clientId, address, count).toList() master.readInputRegisters(unitId, address, count).toList()
private fun Array<out InputRegister>.toBuffer(): ByteBuffer { private fun Array<out InputRegister>.toBuffer(): ByteBuffer {
val buffer: ByteBuffer = ByteBuffer.allocate(size * 2) val buffer: ByteBuffer = ByteBuffer.allocate(size * 2)
@ -122,17 +121,17 @@ private fun Array<out InputRegister>.toBuffer(): ByteBuffer {
return buffer return buffer
} }
private fun Array<out InputRegister>.toPacket(): ByteReadPacket = buildPacket { private fun Array<out InputRegister>.toPacket(): Buffer = Buffer {
forEach { value -> forEach { value ->
writeShort(value.toShort()) writeShort(value.toShort())
} }
} }
public fun ModbusDevice.readInputRegistersToBuffer(address: Int, count: Int): ByteBuffer = 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 = public fun ModbusDevice.readInputRegistersToPacket(address: Int, count: Int): Buffer =
master.readInputRegisters(clientId, address, count).toPacket() master.readInputRegisters(unitId, address, count).toPacket()
public fun ModbusDevice.readDoubleInput(address: Int): Double = public fun ModbusDevice.readDoubleInput(address: Int): Double =
readInputRegistersToBuffer(address, Double.SIZE_BYTES).getDouble() readInputRegistersToBuffer(address, Double.SIZE_BYTES).getDouble()
@ -141,7 +140,7 @@ public fun ModbusDevice.readInputRegister(address: Int): Short =
readInputRegisters(address, 1).first().toShort() readInputRegisters(address, 1).first().toShort()
public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List<Register> = public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List<Register> =
master.readMultipleRegisters(clientId, address, count).toList() master.readMultipleRegisters(unitId, address, count).toList()
/** /**
* Read a number of registers to a [ByteBuffer] * Read a number of registers to a [ByteBuffer]
@ -149,10 +148,10 @@ public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List<Reg
* @param count number of 2-bytes registers to read. Buffer size is 2*[count] * @param count number of 2-bytes registers to read. Buffer size is 2*[count]
*/ */
public fun ModbusDevice.readHoldingRegistersToBuffer(address: Int, count: Int): ByteBuffer = public fun ModbusDevice.readHoldingRegistersToBuffer(address: Int, count: Int): ByteBuffer =
master.readMultipleRegisters(clientId, address, count).toBuffer() master.readMultipleRegisters(unitId, address, count).toBuffer()
public fun ModbusDevice.readHoldingRegistersToPacket(address: Int, count: Int): ByteReadPacket = public fun ModbusDevice.readHoldingRegistersToPacket(address: Int, count: Int): Buffer =
master.readMultipleRegisters(clientId, address, count).toPacket() master.readMultipleRegisters(unitId, address, count).toPacket()
public fun ModbusDevice.readDoubleRegister(address: Int): Double = public fun ModbusDevice.readDoubleRegister(address: Int): Double =
readHoldingRegistersToBuffer(address, Double.SIZE_BYTES).getDouble() readHoldingRegistersToBuffer(address, Double.SIZE_BYTES).getDouble()
@ -162,14 +161,14 @@ public fun ModbusDevice.readHoldingRegister(address: Int): Short =
public fun ModbusDevice.writeHoldingRegisters(address: Int, values: ShortArray): Int = public fun ModbusDevice.writeHoldingRegisters(address: Int, values: ShortArray): Int =
master.writeMultipleRegisters( master.writeMultipleRegisters(
clientId, unitId,
address, address,
Array<Register>(values.size) { SimpleInputRegister(values[it].toInt()) } Array<Register>(values.size) { SimpleInputRegister(values[it].toInt()) }
) )
public fun ModbusDevice.writeHoldingRegister(address: Int, value: Short): Int = public fun ModbusDevice.writeHoldingRegister(address: Int, value: Short): Int =
master.writeSingleRegister( master.writeSingleRegister(
clientId, unitId,
address, address,
SimpleInputRegister(value.toInt()) SimpleInputRegister(value.toInt())
) )
@ -183,8 +182,11 @@ public fun ModbusDevice.writeHoldingRegisters(address: Int, buffer: ByteBuffer):
return writeHoldingRegisters(address, array) return writeHoldingRegisters(address, array)
} }
public fun ModbusDevice.writeShortRegister(address: Int, value: Short) { public fun ModbusDevice.writeHoldingRegisters(address: Int, byteArray: ByteArray): Int {
master.writeSingleRegister(address, SimpleInputRegister(value.toInt())) 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( public fun ModbusDevice.modbusRegister(

View File

@ -15,21 +15,19 @@ import space.kscience.dataforge.names.NameToken
public open class ModbusDeviceBySpec<D: Device>( public open class ModbusDeviceBySpec<D: Device>(
context: Context, context: Context,
spec: DeviceSpec<D>, spec: DeviceSpec<D>,
override val clientId: Int, override val unitId: Int,
override val master: AbstractModbusMaster, override val master: AbstractModbusMaster,
private val disposeMasterOnClose: Boolean = true, private val disposeMasterOnClose: Boolean = true,
meta: Meta = Meta.EMPTY, meta: Meta = Meta.EMPTY,
) : ModbusDevice, DeviceBySpec<D>(spec, context, meta){ ) : ModbusDevice, DeviceBySpec<D>(spec, context, meta){
override suspend fun open() { override suspend fun onStart() {
master.connect() master.connect()
super<DeviceBySpec>.open()
} }
override fun close() { override fun onStop() {
if(disposeMasterOnClose){ if(disposeMasterOnClose){
master.disconnect() master.disconnect()
} }
super<ModbusDevice>.close()
} }
} }

View File

@ -1,8 +1,15 @@
package space.kscience.controls.modbus 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 import space.kscience.dataforge.io.IOFormat
/**
* Modbus registry key
*/
public sealed class ModbusRegistryKey { public sealed class ModbusRegistryKey {
public abstract val address: Int public abstract val address: Int
public open val count: Int = 1 public open val count: Int = 1
@ -25,6 +32,9 @@ public sealed class ModbusRegistryKey {
override fun toString(): String = "InputRegister(address=$address)" override fun toString(): String = "InputRegister(address=$address)"
} }
/**
* A range of read-only register encoding a single value
*/
public class InputRange<T>( public class InputRange<T>(
address: Int, address: Int,
override val count: 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() { public open class HoldingRegister(override val address: Int) : ModbusRegistryKey() {
override fun toString(): String = "HoldingRegister(address=$address)" override fun toString(): String = "HoldingRegister(address=$address)"
} }
/**
* A range of read-write registers encoding a single value
*/
public class HoldingRange<T>( public class HoldingRange<T>(
address: Int, address: Int,
override val count: Int, override val count: Int,
@ -52,6 +68,9 @@ public sealed class ModbusRegistryKey {
} }
} }
/**
* A base class for modbus registers
*/
public abstract class ModbusRegistryMap { public abstract class ModbusRegistryMap {
private val _entries: MutableMap<ModbusRegistryKey, String> = mutableMapOf<ModbusRegistryKey, String>() private val _entries: MutableMap<ModbusRegistryKey, String> = mutableMapOf<ModbusRegistryKey, String>()
@ -63,36 +82,56 @@ public abstract class ModbusRegistryMap {
return key return key
} }
/**
* Register a [ModbusRegistryKey.Coil] key and return it
*/
protected fun coil(address: Int, description: String = ""): ModbusRegistryKey.Coil = protected fun coil(address: Int, description: String = ""): ModbusRegistryKey.Coil =
register(ModbusRegistryKey.Coil(address), description) register(ModbusRegistryKey.Coil(address), description)
/**
* Register a [ModbusRegistryKey.DiscreteInput] key and return it
*/
protected fun discrete(address: Int, description: String = ""): ModbusRegistryKey.DiscreteInput = protected fun discrete(address: Int, description: String = ""): ModbusRegistryKey.DiscreteInput =
register(ModbusRegistryKey.DiscreteInput(address), description) register(ModbusRegistryKey.DiscreteInput(address), description)
/**
* Register a [ModbusRegistryKey.InputRegister] key and return it
*/
protected fun input(address: Int, description: String = ""): ModbusRegistryKey.InputRegister = protected fun input(address: Int, description: String = ""): ModbusRegistryKey.InputRegister =
register(ModbusRegistryKey.InputRegister(address), description) register(ModbusRegistryKey.InputRegister(address), description)
/**
* Register a [ModbusRegistryKey.InputRange] key and return it
*/
protected fun <T> input( protected fun <T> input(
address: Int, address: Int,
count: Int, count: Int,
reader: IOFormat<T>, reader: IOFormat<T>,
description: String = "", description: String = "",
): ModbusRegistryKey.InputRange<T> = ): ModbusRegistryKey.InputRange<T> = register(ModbusRegistryKey.InputRange(address, count, reader), description)
register(ModbusRegistryKey.InputRange(address, count, reader), description)
/**
* Register a [ModbusRegistryKey.HoldingRegister] key and return it
*/
protected fun register(address: Int, description: String = ""): ModbusRegistryKey.HoldingRegister = protected fun register(address: Int, description: String = ""): ModbusRegistryKey.HoldingRegister =
register(ModbusRegistryKey.HoldingRegister(address), description) register(ModbusRegistryKey.HoldingRegister(address), description)
/**
* Register a [ModbusRegistryKey.HoldingRange] key and return it
*/
protected fun <T> register( protected fun <T> register(
address: Int, address: Int,
count: Int, count: Int,
format: IOFormat<T>, format: IOFormat<T>,
description: String = "", description: String = "",
): ModbusRegistryKey.HoldingRange<T> = ): ModbusRegistryKey.HoldingRange<T> = register(ModbusRegistryKey.HoldingRange(address, count, format), description)
register(ModbusRegistryKey.HoldingRange(address, count, format), description)
public companion object { public companion object {
/**
* Validate the register map. Throw an error if the map is invalid
*/
public fun validate(map: ModbusRegistryMap) { public fun validate(map: ModbusRegistryMap) {
var lastCoil: ModbusRegistryKey.Coil? = null var lastCoil: ModbusRegistryKey.Coil? = null
var lastDiscreteInput: ModbusRegistryKey.DiscreteInput? = 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) { private val ModbusRegistryKey.sectionNumber
validate(map) get() = when (this) {
map.entries.entries is ModbusRegistryKey.Coil -> 1
.sortedWith( is ModbusRegistryKey.DiscreteInput -> 2
Comparator.comparingInt<Map.Entry<ModbusRegistryKey, String>?> { it.key.sectionNumber } is ModbusRegistryKey.HoldingRegister -> 4
.thenComparingInt { it.key.address } is ModbusRegistryKey.InputRegister -> 3
) }
.forEach { (key, description) ->
val typeString = when (key) { public fun ModbusRegistryMap.print(to: Appendable = System.out) {
is ModbusRegistryKey.Coil -> "Coil" ModbusRegistryMap.validate(this)
is ModbusRegistryKey.DiscreteInput -> "Discrete" entries.entries
is ModbusRegistryKey.HoldingRegister -> "Register" .sortedWith(
is ModbusRegistryKey.InputRegister -> "Input" Comparator.comparingInt<Map.Entry<ModbusRegistryKey, String>?> { it.key.sectionNumber }
} .thenComparingInt { it.key.address }
val rangeString = if (key.count == 1) { )
key.address.toString() .forEach { (key, description) ->
} else { val typeString = when (key) {
"${key.address} - ${key.address + key.count}" is ModbusRegistryKey.Coil -> "Coil"
} is ModbusRegistryKey.DiscreteInput -> "Discrete"
to.appendLine("${typeString}\t$rangeString\t$description") 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)
} }
} }

View File

@ -12,18 +12,16 @@ A client and server connectors for OPC-UA via Eclipse Milo
## Artifact: ## 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:** **Gradle Kotlin DSL:**
```kotlin ```kotlin
repositories { repositories {
maven("https://repo.kotlin.link") maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("space.kscience:controls-opcua:0.2.0") implementation("space.kscience:controls-opcua:0.3.0")
} }
``` ```

View File

@ -56,6 +56,7 @@ internal class MetaEnumCodec : OpcUaBinaryDataTypeCodec<Number> {
internal fun opcToMeta(value: Any?): Meta = when (value) { internal fun opcToMeta(value: Any?): Meta = when (value) {
null -> Meta(Null) null -> Meta(Null)
is Variant -> opcToMeta(value.value)
is Meta -> value is Meta -> value
is Value -> Meta(value) is Value -> Meta(value)
is Number -> when (value) { is Number -> when (value) {
@ -79,12 +80,17 @@ internal fun opcToMeta(value: Any?): Meta = when (value) {
"text" put value.text?.asValue() "text" put value.text?.asValue()
} }
is DataValue -> Meta { is DataValue -> Meta {
"value" put opcToMeta(value.value) // need SerializationContext to do that properly val variant= opcToMeta(value.value)
value.statusCode?.value?.let { "status" put Meta(it.asValue()) } update(variant)// need SerializationContext to do that properly
value.sourceTime?.javaInstant?.let { "sourceTime" put it.toKotlinInstant().toMeta() } //TODO remove after DF 0.7.2
value.sourcePicoseconds?.let { "sourcePicoseconds" put Meta(it.asValue()) } this.value = variant.value
value.serverTime?.javaInstant?.let { "serverTime" put it.toKotlinInstant().toMeta() } "@opc" put {
value.serverPicoseconds?.let { "serverPicoseconds" put Meta(it.asValue()) } 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 ByteString -> Meta(value.bytesOrEmpty().asValue())
is XmlElement -> Meta(value.fragment?.asValue() ?: Null) is XmlElement -> Meta(value.fragment?.asValue() ?: Null)
@ -107,7 +113,7 @@ internal class MetaStructureCodec(
override fun createStructure(name: String, members: LinkedHashMap<String, Meta>): Meta = Meta { override fun createStructure(name: String, members: LinkedHashMap<String, Meta>): Meta = Meta {
members.forEach { (property: String, value: 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() "Float" -> member.value?.numberOrNull?.toFloat()
"Double" -> member.value?.numberOrNull?.toDouble() "Double" -> member.value?.numberOrNull?.toDouble()
"String" -> member.string "String" -> member.string
"DateTime" -> DateTime(member.instant().toJavaInstant()) "DateTime" -> member.instant?.toJavaInstant()?.let { DateTime(it) }
"Guid" -> member.string?.let { UUID.fromString(it) } "Guid" -> member.string?.let { UUID.fromString(it) }
"ByteString" -> member.value?.list?.let { list -> "ByteString" -> member.value?.list?.let { list ->
ByteString(list.map { it.number.toByte() }.toByteArray()) ByteString(list.map { it.number.toByte() }.toByteArray())

View File

@ -9,8 +9,8 @@ import org.eclipse.milo.opcua.stack.core.types.builtin.*
import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn
import space.kscience.controls.api.Device import space.kscience.controls.api.Device
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaConverter
import space.kscience.dataforge.meta.MetaSerializer import space.kscience.dataforge.meta.MetaSerializer
import space.kscience.dataforge.meta.transformations.MetaConverter
import kotlin.properties.ReadWriteProperty import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
@ -43,7 +43,7 @@ public suspend inline fun <reified T: Any> OpcUaDevice.readOpcWithTime(
else -> error("Incompatible OPC property value $content") 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 return res to time
} }
@ -69,7 +69,7 @@ public suspend inline fun <reified T> OpcUaDevice.readOpc(
else -> error("Incompatible OPC property value $content") else -> error("Incompatible OPC property value $content")
} }
return converter.metaToObject(meta) ?: error("Meta $meta could not be converted to ${T::class}") return converter.readOrNull(meta) ?: error("Meta $meta could not be converted to ${T::class}")
} }
public suspend inline fun <reified T> OpcUaDevice.writeOpc( public suspend inline fun <reified T> OpcUaDevice.writeOpc(
@ -77,7 +77,7 @@ public suspend inline fun <reified T> OpcUaDevice.writeOpc(
converter: MetaConverter<T>, converter: MetaConverter<T>,
value: T value: T
): StatusCode { ): StatusCode {
val meta = converter.objectToMeta(value) val meta = converter.convert(value)
return client.writeValue(nodeId, DataValue(Variant(meta))).await() return client.writeValue(nodeId, DataValue(Variant(meta))).await()
} }

View File

@ -31,7 +31,7 @@ public class MiloConfiguration : Scheme() {
public var endpointUrl: String by string { error("Endpoint url is not defined") } 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) public var securityPolicy: SecurityPolicy by enum(SecurityPolicy.None)
@ -63,8 +63,7 @@ public open class OpcUaDeviceBySpec<D : Device>(
} }
} }
override fun close() { override fun onStop() {
client.disconnect() client.disconnect()
super<DeviceBySpec>.close()
} }
} }

View File

@ -2,7 +2,6 @@ package space.kscience.controls.opcua.server
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.toJavaInstant import kotlinx.datetime.toJavaInstant
import kotlinx.serialization.json.Json
import org.eclipse.milo.opcua.sdk.core.AccessLevel import org.eclipse.milo.opcua.sdk.core.AccessLevel
import org.eclipse.milo.opcua.sdk.core.Reference import org.eclipse.milo.opcua.sdk.core.Reference
import org.eclipse.milo.opcua.sdk.server.Lifecycle 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.Identifiers
import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime
import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText
import space.kscience.controls.api.Device import space.kscience.controls.api.*
import space.kscience.controls.api.DeviceHub
import space.kscience.controls.api.PropertyDescriptor
import space.kscience.controls.api.onPropertyChange
import space.kscience.controls.manager.DeviceManager import space.kscience.controls.manager.DeviceManager
import space.kscience.controls.opcua.client.opcToMeta
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaSerializer
import space.kscience.dataforge.meta.ValueType import space.kscience.dataforge.meta.ValueType
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.plus 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) 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( public class DeviceNameSpace(
server: OpcUaServer, server: OpcUaServer,
public val deviceManager: DeviceManager public val deviceManager: DeviceManager,
) : ManagedNamespaceWithLifecycle(server, NAMESPACE_URI) { ) : ManagedNamespaceWithLifecycle(server, NAMESPACE_URI) {
private val subscription = SubscriptionModel(server, this) 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) { private fun UaFolderNode.registerDeviceNodes(deviceName: Name, device: Device) {
val nodes = device.propertyDescriptors.associate { descriptor -> val nodes = device.propertyDescriptors.associate { descriptor ->
val propertyName = descriptor.name val propertyName = descriptor.name
@ -73,18 +52,21 @@ public class DeviceNameSpace(
//for now, use DF paths as ids //for now, use DF paths as ids
nodeId = newNodeId("${deviceName.tokens.joinToString("/")}/$propertyName") nodeId = newNodeId("${deviceName.tokens.joinToString("/")}/$propertyName")
when { when {
descriptor.readable && descriptor.writable -> { descriptor.readable && descriptor.mutable -> {
setAccessLevel(AccessLevel.READ_WRITE) setAccessLevel(AccessLevel.READ_WRITE)
setUserAccessLevel(AccessLevel.READ_WRITE) setUserAccessLevel(AccessLevel.READ_WRITE)
} }
descriptor.writable -> {
descriptor.mutable -> {
setAccessLevel(AccessLevel.WRITE_ONLY) setAccessLevel(AccessLevel.WRITE_ONLY)
setUserAccessLevel(AccessLevel.WRITE_ONLY) setUserAccessLevel(AccessLevel.WRITE_ONLY)
} }
descriptor.readable -> { descriptor.readable -> {
setAccessLevel(AccessLevel.READ_ONLY) setAccessLevel(AccessLevel.READ_ONLY)
setUserAccessLevel(AccessLevel.READ_ONLY) setUserAccessLevel(AccessLevel.READ_ONLY)
} }
else -> { else -> {
setAccessLevel(AccessLevel.NONE) setAccessLevel(AccessLevel.NONE)
setUserAccessLevel(AccessLevel.NONE) setUserAccessLevel(AccessLevel.NONE)
@ -93,7 +75,7 @@ public class DeviceNameSpace(
browseName = newQualifiedName(propertyName) browseName = newQualifiedName(propertyName)
displayName = LocalizedText.english(propertyName) displayName = LocalizedText.english(propertyName)
dataType = if (descriptor.metaDescriptor.children.isNotEmpty()) { dataType = if (descriptor.metaDescriptor.nodes.isNotEmpty()) {
Identifiers.String Identifiers.String
} else when (descriptor.metaDescriptor.valueTypes?.first()) { } else when (descriptor.metaDescriptor.valueTypes?.first()) {
null, ValueType.STRING, ValueType.NULL -> Identifiers.String null, ValueType.STRING, ValueType.NULL -> Identifiers.String
@ -106,25 +88,24 @@ public class DeviceNameSpace(
setTypeDefinition(Identifiers.BaseDataVariableType) setTypeDefinition(Identifiers.BaseDataVariableType)
}.build() }.build()
// Update initial value, but only if it is cached
device[descriptor]?.toOpc(sourceTime = null, serverTime = null)?.let { if (device is CachingDevice) {
node.value = it device[descriptor]?.toOpc(sourceTime = null, serverTime = null)?.let {
node.value = it
}
} }
/** if (descriptor.mutable) {
* Subscribe to node value changes
*/ /**
node.addAttributeObserver { _: UaNode, attributeId: AttributeId, value: Any -> * Subscribe to node value changes
if (attributeId == AttributeId.Value) { */
val meta: Meta = when (value) { node.addAttributeObserver { _: UaNode, attributeId: AttributeId, value: Any? ->
is Meta -> value if (attributeId == AttributeId.Value) {
is Boolean -> Meta(value) val meta: Meta = opcToMeta(value)
is Number -> Meta(value) deviceManager.context.launch {
is String -> Json.decodeFromString(MetaSerializer, value) device.writeProperty(propertyName, meta)
else -> return@addAttributeObserver //TODO("other types not implemented") }
}
deviceManager.context.launch {
device.writeProperty(propertyName, meta)
} }
} }
} }
@ -137,8 +118,11 @@ public class DeviceNameSpace(
//Subscribe on properties updates //Subscribe on properties updates
device.onPropertyChange { device.onPropertyChange {
nodes[property]?.let { node -> nodes[property]?.let { node ->
val sourceTime = time?.let { DateTime(it.toJavaInstant()) } val sourceTime = DateTime(time.toJavaInstant())
node.value = value.toOpc(sourceTime = sourceTime) val newValue = value.toOpc(sourceTime = sourceTime)
if (node.value.value != newValue.value) {
node.value = newValue
}
} }
} }
//recursively add sub-devices //recursively add sub-devices
@ -169,6 +153,24 @@ public class DeviceNameSpace(
} }
} }
init {
lifecycleManager.addLifecycle(subscription)
lifecycleManager.addStartupTask {
nodeContext.registerHub(deviceManager, Name.EMPTY)
}
lifecycleManager.addLifecycle(object : Lifecycle {
override fun startup() {
server.addressSpaceManager.register(this@DeviceNameSpace)
}
override fun shutdown() {
server.addressSpaceManager.unregister(this@DeviceNameSpace)
}
})
}
override fun onDataItemsCreated(dataItems: List<DataItem?>?) { override fun onDataItemsCreated(dataItems: List<DataItem?>?) {
subscription.onDataItemsCreated(dataItems) subscription.onDataItemsCreated(dataItems)
} }

View File

@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test
import space.kscience.controls.spec.DeviceSpec import space.kscience.controls.spec.DeviceSpec
import space.kscience.controls.spec.doubleProperty import space.kscience.controls.spec.doubleProperty
import space.kscience.controls.spec.read import space.kscience.controls.spec.read
import space.kscience.dataforge.meta.transformations.MetaConverter import space.kscience.dataforge.meta.MetaConverter
import kotlin.test.Ignore import kotlin.test.Ignore
class OpcUaClientTest { class OpcUaClientTest {
@ -29,7 +29,7 @@ class OpcUaClientTest {
return DemoOpcUaDevice(config) return DemoOpcUaDevice(config)
} }
val randomDouble by doubleProperty(read = DemoOpcUaDevice::readRandomDouble) val randomDouble by doubleProperty { readRandomDouble() }
} }
@ -40,9 +40,10 @@ class OpcUaClientTest {
@Test @Test
@Ignore @Ignore
fun testReadDouble() = runTest { fun testReadDouble() = runTest {
DemoOpcUaDevice.build().use{ val device = DemoOpcUaDevice.build()
println(it.read(DemoOpcUaDevice.randomDouble)) device.start()
} println(device.read(DemoOpcUaDevice.randomDouble))
device.stop()
} }
} }

View File

@ -6,18 +6,16 @@ Utils to work with controls-kt on Raspberry pi
## Artifact: ## 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:** **Gradle Kotlin DSL:**
```kotlin ```kotlin
repositories { repositories {
maven("https://repo.kotlin.link") maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("space.kscience:controls-pi:0.2.0") implementation("space.kscience:controls-pi:0.3.0")
} }
``` ```

View File

@ -1,6 +1,10 @@
public final class space/kscience/controls/pi/PiPlugin : space/kscience/dataforge/context/AbstractPlugin { 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 static final field Companion Lspace/kscience/controls/pi/PiPlugin$Companion;
public fun <init> ()V public fun <init> ()V
public fun content (Ljava/lang/String;)Ljava/util/Map;
public fun detach ()V
public final fun getDevices ()Lspace/kscience/controls/manager/DeviceManager;
public final fun getPiContext ()Lcom/pi4j/context/Context;
public final fun getPorts ()Lspace/kscience/controls/ports/Ports; public final fun getPorts ()Lspace/kscience/controls/ports/Ports;
public fun getTag ()Lspace/kscience/dataforge/context/PluginTag; 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 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 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 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 fun getTag ()Lspace/kscience/dataforge/context/PluginTag;
} }
public final class space/kscience/controls/pi/PiSerialPort : space/kscience/controls/ports/AbstractPort { 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 static final field Companion Lspace/kscience/controls/pi/PiSerialPort$Companion;
public fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;)V public fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;)V
public synthetic fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public synthetic fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V
public fun close ()V public 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 { public final class space/kscience/controls/pi/PiSerialPort$Companion : space/kscience/controls/ports/PortFactory {

View File

@ -0,0 +1,95 @@
package space.kscience.controls.pi
import com.pi4j.io.serial.Baud
import com.pi4j.io.serial.Serial
import com.pi4j.io.serial.SerialConfigBuilder
import com.pi4j.ktx.io.serial
import kotlinx.coroutines.*
import space.kscience.controls.ports.AbstractAsynchronousPort
import space.kscience.controls.ports.AsynchronousPort
import space.kscience.controls.ports.copyToArray
import space.kscience.dataforge.context.*
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.enum
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.string
import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext
public class AsynchronousPiPort(
context: Context,
meta: Meta,
private val serial: Serial,
coroutineContext: CoroutineContext = context.coroutineContext,
) : AbstractAsynchronousPort(context, meta, coroutineContext) {
private var listenerJob: Job? = null
override fun onOpen() {
serial.open()
listenerJob = this.scope.launch(Dispatchers.IO) {
val buffer = ByteBuffer.allocate(1024)
while (isActive) {
try {
val num = serial.read(buffer)
if (num > 0) {
receive(buffer.copyToArray(num))
}
if (num < 0) cancel("The input channel is exhausted")
} catch (ex: Exception) {
logger.error(ex) { "Channel read error" }
delay(1000)
}
}
}
}
override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) {
serial.write(data)
}
override val isOpen: Boolean get() = listenerJob?.isActive == true
override fun close() {
listenerJob?.cancel()
serial.close()
}
public companion object : Factory<AsynchronousPort> {
public fun build(
context: Context,
device: String,
block: SerialConfigBuilder.() -> Unit,
): AsynchronousPiPort {
val meta = Meta {
"name" put "pi://$device"
"type" put "serial"
}
val pi = context.request(PiPlugin)
val serial = pi.piContext.serial(device, block)
return AsynchronousPiPort(context, meta, serial)
}
public fun open(
context: Context,
device: String,
block: SerialConfigBuilder.() -> Unit,
): AsynchronousPiPort = build(context, device, block).apply { open() }
override fun build(context: Context, meta: Meta): AsynchronousPort {
val device: String = meta["device"].string ?: error("Device name not defined")
val baudRate: Baud = meta["baudRate"].enum<Baud>() ?: Baud._9600
val pi = context.request(PiPlugin)
val serial = pi.piContext.serial(device) {
baud8N1(baudRate)
}
return AsynchronousPiPort(context, meta, serial)
}
}
}

View File

@ -1,22 +1,49 @@
package space.kscience.controls.pi package space.kscience.controls.pi
import com.pi4j.Pi4J
import space.kscience.controls.manager.DeviceManager
import space.kscience.controls.ports.Ports import space.kscience.controls.ports.Ports
import space.kscience.dataforge.context.AbstractPlugin import space.kscience.dataforge.context.AbstractPlugin
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.PluginFactory import space.kscience.dataforge.context.PluginFactory
import space.kscience.dataforge.context.PluginTag import space.kscience.dataforge.context.PluginTag
import space.kscience.dataforge.meta.Meta 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 class PiPlugin : AbstractPlugin() {
public val ports: Ports by require(Ports) public val ports: Ports by require(Ports)
public val devices: DeviceManager by require(DeviceManager)
override val tag: PluginTag get() = Companion.tag override val tag: PluginTag get() = Companion.tag
public val piContext: PiContext by lazy { createPiContext(context, meta) }
override fun content(target: String): Map<Name, Any> = when (target) {
Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf(
"serial".asName() to AsynchronousPiPort,
)
Ports.SYNCHRONOUS_PORT_TYPE -> mapOf(
"serial".asName() to SynchronousPiPort,
)
else -> super.content(target)
}
override fun detach() {
piContext.shutdown()
super.detach()
}
public companion object : PluginFactory<PiPlugin> { public companion object : PluginFactory<PiPlugin> {
override val tag: PluginTag = PluginTag("controls.ports.pi", group = PluginTag.DATAFORGE_GROUP) override val tag: PluginTag = PluginTag("controls.ports.pi", group = PluginTag.DATAFORGE_GROUP)
override fun build(context: Context, meta: Meta): PiPlugin = PiPlugin() override fun build(context: Context, meta: Meta): PiPlugin = PiPlugin()
@Suppress("UNUSED_PARAMETER")
public fun createPiContext(context: Context, meta: Meta): PiContext = Pi4J.newAutoContext()
} }
} }

View File

@ -1,75 +0,0 @@
package space.kscience.controls.pi
import com.pi4j.Pi4J
import com.pi4j.io.serial.Baud
import com.pi4j.io.serial.Serial
import com.pi4j.io.serial.SerialConfigBuilder
import com.pi4j.ktx.io.serial
import kotlinx.coroutines.*
import space.kscience.controls.ports.AbstractPort
import space.kscience.controls.ports.Port
import space.kscience.controls.ports.PortFactory
import space.kscience.controls.ports.toArray
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.error
import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.enum
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.string
import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext
public class PiSerialPort(
context: Context,
coroutineContext: CoroutineContext = context.coroutineContext,
public val serialBuilder: () -> Serial,
) : AbstractPort(context, coroutineContext) {
private val serial: Serial by lazy { serialBuilder() }
private val listenerJob = this.scope.launch(Dispatchers.IO) {
val buffer = ByteBuffer.allocate(1024)
while (isActive) {
try {
val num = serial.read(buffer)
if (num > 0) {
receive(buffer.toArray(num))
}
if (num < 0) cancel("The input channel is exhausted")
} catch (ex: Exception) {
logger.error(ex) { "Channel read error" }
delay(1000)
}
}
}
override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) {
serial.write(data)
}
override fun close() {
listenerJob.cancel()
serial.close()
}
public companion object : PortFactory {
override val type: String get() = "pi"
public fun open(context: Context, device: String, block: SerialConfigBuilder.() -> Unit): PiSerialPort =
PiSerialPort(context) {
Pi4J.newAutoContext().serial(device, block)
}
override fun build(context: Context, meta: Meta): Port = PiSerialPort(context) {
val device: String = meta["device"].string ?: error("Device name not defined")
val baudRate: Baud = meta["baudRate"].enum<Baud>() ?: Baud._9600
Pi4J.newAutoContext().serial(device) {
baud8N1(baudRate)
}
}
}
}

View File

@ -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 <R> respond(
request: ByteArray,
transform: suspend Flow<ByteArray>.() -> R,
): R = mutex.withLock {
serial.drain()
serial.write(request)
flow<ByteArray> {
val buffer = ByteBuffer.allocate(1024)
while (isOpen) {
try {
val num = serial.read(buffer)
if (num > 0) {
emit(buffer.copyToArray(num))
}
if (num < 0) break
} catch (ex: Exception) {
logger.error(ex) { "Channel read error" }
delay(1000)
}
}
}.transform()
}
override 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<SynchronousPort> {
public fun build(
context: Context,
device: String,
block: SerialConfigBuilder.() -> Unit,
): SynchronousPiPort {
val meta = Meta {
"name" put "pi://$device"
"type" put "serial"
}
val pi = context.request(PiPlugin)
val serial = pi.piContext.serial(device, block)
return SynchronousPiPort(context, meta, serial)
}
public fun open(
context: Context,
device: String,
block: SerialConfigBuilder.() -> Unit,
): SynchronousPiPort = build(context, device, block).apply { open() }
override fun build(context: Context, meta: Meta): SynchronousPiPort {
val device: String = meta["device"].string ?: error("Device name not defined")
val baudRate: Baud = meta["baudRate"].enum<Baud>() ?: Baud._9600
val pi = context.request(PiPlugin)
val serial = pi.piContext.serial(device) {
baud8N1(baudRate)
}
return SynchronousPiPort(context, meta, serial)
}
}
}

View File

@ -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
}

View File

@ -0,0 +1,76 @@
package space.kscience.controls.plc4x
import kotlinx.coroutines.future.await
import org.apache.plc4x.java.api.PlcConnection
import org.apache.plc4x.java.api.messages.PlcBrowseItem
import org.apache.plc4x.java.api.messages.PlcTagResponse
import org.apache.plc4x.java.api.messages.PlcWriteRequest
import org.apache.plc4x.java.api.messages.PlcWriteResponse
import org.apache.plc4x.java.api.types.PlcResponseCode
import space.kscience.controls.api.Device
import space.kscience.dataforge.meta.Meta
private val PlcTagResponse.responseCodes: Map<String, PlcResponseCode>
get() = tagNames.associateWith { getResponseCode(it) }
private val Map<String, PlcResponseCode>.isOK get() = values.all { it == PlcResponseCode.OK }
public class PlcException(public val codes: Map<String, PlcResponseCode>) : Exception() {
override val message: String
get() = "Plc request unsuccessful:" + codes.entries.joinToString(prefix = "\n\t", separator = "\n\t") {
"${it.key}: ${it.value.name}"
}
}
private fun PlcTagResponse.throwOnFail() {
val codes = responseCodes
if (!codes.isOK) throw PlcException(codes)
}
public interface Plc4XDevice : Device {
public val connection: PlcConnection
}
/**
* Send ping request and suspend until it comes back
*/
public suspend fun Plc4XDevice.ping(): PlcResponseCode = connection.ping().await().responseCode
/**
* Send browse request to list available tags
*/
public suspend fun Plc4XDevice.browse(): Map<String, MutableList<PlcBrowseItem>> {
require(connection.metadata.isBrowseSupported){"Browse actions are not supported on connection"}
val request = connection.browseRequestBuilder().build()
val response = request.execute().await()
return response.queryNames.associateWith { response.getValues(it) }
}
/**
* Send read request and suspend until it returns. Throw a [PlcException] if at least one tag read fails.
*
* @throws PlcException
*/
public suspend fun Plc4XDevice.read(plc4xProperty: Plc4xProperty): Meta = with(plc4xProperty) {
require(connection.metadata.isReadSupported) {"Read actions are not supported on connections"}
val request = connection.readRequestBuilder().request().build()
val response = request.execute().await()
response.throwOnFail()
response.readProperty()
}
/**
* Send write request and suspend until it finishes. Throw a [PlcException] if at least one tag write fails.
*
* @throws PlcException
*/
public suspend fun Plc4XDevice.write(plc4xProperty: Plc4xProperty, value: Meta): Unit = with(plc4xProperty) {
require(connection.metadata.isWriteSupported){"Write actions are not supported on connection"}
val request: PlcWriteRequest = connection.writeRequestBuilder().writeProperty(value).build()
val response: PlcWriteResponse = request.execute().await()
response.throwOnFail()
}

View File

@ -0,0 +1,22 @@
package space.kscience.controls.plc4x
import org.apache.plc4x.java.api.PlcConnection
import space.kscience.controls.spec.DeviceActionSpec
import space.kscience.controls.spec.DeviceBase
import space.kscience.controls.spec.DevicePropertySpec
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.Meta
public class Plc4XDeviceBase(
context: Context,
meta: Meta,
override val connection: PlcConnection,
) : Plc4XDevice, DeviceBase<Plc4XDevice>(context, meta) {
override val properties: Map<String, DevicePropertySpec<Plc4XDevice, *>>
get() = TODO("Not yet implemented")
override val actions: Map<String, DeviceActionSpec<Plc4XDevice, *, *>> = emptyMap()
override fun toString(): String {
TODO("Not yet implemented")
}
}

View File

@ -0,0 +1,39 @@
package space.kscience.controls.plc4x
import org.apache.plc4x.java.api.messages.PlcReadRequest
import org.apache.plc4x.java.api.messages.PlcReadResponse
import org.apache.plc4x.java.api.messages.PlcWriteRequest
import org.apache.plc4x.java.api.types.PlcValueType
import space.kscience.dataforge.meta.Meta
public interface Plc4xProperty {
public val keys: Set<String>
public fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder
public fun PlcReadResponse.readProperty(): Meta
public fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder
}
private class DefaultPlc4xProperty(
private val address: String,
private val plcValueType: PlcValueType,
private val name: String = "@default",
) : Plc4xProperty {
override val keys: Set<String> = setOf(name)
override fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder =
addTagAddress(name, address)
override fun PlcReadResponse.readProperty(): Meta =
getPlcValue(name).toMeta()
override fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder =
addTagAddress(name, address, meta.toPlcValue(plcValueType))
}
public fun Plc4xProperty(address: String, plcValueType: PlcValueType, name: String = "@default"): Plc4xProperty =
DefaultPlc4xProperty(address, plcValueType, name)

View File

@ -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")
)
}

View File

@ -6,18 +6,16 @@ Implementation of byte ports on top os ktor-io asynchronous API
## Artifact: ## 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:** **Gradle Kotlin DSL:**
```kotlin ```kotlin
repositories { repositories {
maven("https://repo.kotlin.link") maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("space.kscience:controls-ports-ktor:0.2.0") implementation("space.kscience:controls-ports-ktor:0.3.0")
} }
``` ```

View File

@ -13,7 +13,7 @@ public class KtorPortsPlugin : AbstractPlugin() {
override val tag: PluginTag get() = Companion.tag override val tag: PluginTag get() = Companion.tag
override fun content(target: String): Map<Name, Any> = when (target) { override fun content(target: String): Map<Name, Any> = when (target) {
PortFactory.TYPE -> mapOf("tcp".asName() to KtorTcpPort, "udp".asName() to KtorUdpPort) Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf("tcp".asName() to KtorTcpPort, "udp".asName() to KtorUdpPort)
else -> emptyMap() else -> emptyMap()
} }

View File

@ -1,47 +1,53 @@
package space.kscience.controls.ports package space.kscience.controls.ports
import io.ktor.network.selector.ActorSelectorManager import io.ktor.network.selector.ActorSelectorManager
import io.ktor.network.sockets.SocketOptions
import io.ktor.network.sockets.aSocket import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel import io.ktor.network.sockets.openWriteChannel
import io.ktor.utils.io.consumeEachBufferRange import io.ktor.utils.io.consumeEachBufferRange
import io.ktor.utils.io.core.Closeable import io.ktor.utils.io.core.Closeable
import io.ktor.utils.io.writeAvailable import io.ktor.utils.io.writeAvailable
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.async
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.Factory
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.get import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.int import space.kscience.dataforge.meta.int
import space.kscience.dataforge.meta.string import space.kscience.dataforge.meta.string
import java.nio.ByteBuffer
import kotlin.coroutines.CoroutineContext import kotlin.coroutines.CoroutineContext
public class KtorTcpPort internal constructor( public class KtorTcpPort internal constructor(
context: Context, context: Context,
meta: Meta,
public val host: String, public val host: String,
public val port: Int, public val port: Int,
coroutineContext: CoroutineContext = context.coroutineContext, 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]" override fun toString(): String = "port[tcp:$host:$port]"
private val futureSocket = scope.async { private val futureSocket = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().connect(host, port) 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) futureSocket.await().openWriteChannel(true)
} }
private val listenerJob = scope.launch { private var listenerJob: Job? = null
val input = futureSocket.await().openReadChannel()
input.consumeEachBufferRange { buffer, _ -> override fun onOpen() {
val array = ByteArray(buffer.remaining()) listenerJob = scope.launch {
buffer.get(array) val input = futureSocket.await().openReadChannel()
receive(array) input.consumeEachBufferRange { buffer: ByteBuffer, last ->
isActive 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) writeChannel.await().writeAvailable(data)
} }
override val isOpen: Boolean
get() = listenerJob?.isActive == true
override fun close() { override fun close() {
listenerJob.cancel() listenerJob?.cancel()
futureSocket.cancel() futureSocket.cancel()
super.close() super.close()
} }
public companion object : PortFactory { public companion object : Factory<AsynchronousPort> {
override val type: String = "tcp" public fun build(
context: Context,
host: String,
port: Int,
coroutineContext: CoroutineContext = context.coroutineContext,
socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {},
): KtorTcpPort {
val meta = Meta {
"name" put "tcp://$host:$port"
"type" put "tcp"
"host" put host
"port" put port
}
return KtorTcpPort(context, meta, host, port, coroutineContext, socketOptions)
}
public fun open( public fun open(
context: Context, context: Context,
host: String, host: String,
port: Int, port: Int,
coroutineContext: CoroutineContext = context.coroutineContext, coroutineContext: CoroutineContext = context.coroutineContext,
): KtorTcpPort { socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {},
return KtorTcpPort(context, host, port, coroutineContext) ): 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 host = meta["host"].string ?: "localhost"
val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta") 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)
} }
} }
} }

View File

@ -1,18 +1,14 @@
package space.kscience.controls.ports package space.kscience.controls.ports
import io.ktor.network.selector.ActorSelectorManager import io.ktor.network.selector.ActorSelectorManager
import io.ktor.network.sockets.InetSocketAddress import io.ktor.network.sockets.*
import io.ktor.network.sockets.aSocket import io.ktor.utils.io.ByteWriteChannel
import io.ktor.network.sockets.openReadChannel
import io.ktor.network.sockets.openWriteChannel
import io.ktor.utils.io.consumeEachBufferRange import io.ktor.utils.io.consumeEachBufferRange
import io.ktor.utils.io.core.Closeable import io.ktor.utils.io.core.Closeable
import io.ktor.utils.io.writeAvailable import io.ktor.utils.io.writeAvailable
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.async
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.Factory
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.int import space.kscience.dataforge.meta.int
import space.kscience.dataforge.meta.number import space.kscience.dataforge.meta.number
@ -21,33 +17,40 @@ import kotlin.coroutines.CoroutineContext
public class KtorUdpPort internal constructor( public class KtorUdpPort internal constructor(
context: Context, context: Context,
meta: Meta,
public val remoteHost: String, public val remoteHost: String,
public val remotePort: Int, public val remotePort: Int,
public val localPort: Int? = null, public val localPort: Int? = null,
public val localHost: String = "localhost", public val localHost: String = "localhost",
coroutineContext: CoroutineContext = context.coroutineContext, 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]" 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( aSocket(ActorSelectorManager(Dispatchers.IO)).udp().connect(
remoteAddress = InetSocketAddress(remoteHost, remotePort), remoteAddress = InetSocketAddress(remoteHost, remotePort),
localAddress = localPort?.let { InetSocketAddress(localHost, localPort) } localAddress = localPort?.let { InetSocketAddress(localHost, localPort) },
configure = socketOptions
) )
} }
private val writeChannel = scope.async { private val writeChannel: Deferred<ByteWriteChannel> = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
futureSocket.await().openWriteChannel(true) futureSocket.await().openWriteChannel(true)
} }
private val listenerJob = scope.launch { private var listenerJob: Job? = null
val input = futureSocket.await().openReadChannel()
input.consumeEachBufferRange { buffer, _ -> override fun onOpen() {
val array = ByteArray(buffer.remaining()) listenerJob = scope.launch {
buffer.get(array) val input = futureSocket.await().openReadChannel()
receive(array) input.consumeEachBufferRange { buffer, last ->
isActive 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) writeChannel.await().writeAvailable(data)
} }
override val isOpen: Boolean
get() = listenerJob?.isActive == true
override fun close() { override fun close() {
listenerJob.cancel() listenerJob?.cancel()
futureSocket.cancel() futureSocket.cancel()
super.close() super.close()
} }
public companion object : PortFactory { public companion object : Factory<AsynchronousPort> {
override val type: String = "udp" public fun build(
context: Context,
remoteHost: String,
remotePort: Int,
localPort: Int? = null,
localHost: String? = null,
coroutineContext: CoroutineContext = context.coroutineContext,
socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {},
): KtorUdpPort {
val meta = Meta {
"name" put "udp://$remoteHost:$remotePort"
"type" put "udp"
"remoteHost" put remoteHost
"remotePort" put remotePort
localHost?.let { "localHost" put it }
localPort?.let { "localPort" put it }
}
return KtorUdpPort(
context = context,
meta = meta,
remoteHost = remoteHost,
remotePort = remotePort,
localPort = localPort,
localHost = localHost ?: "localhost",
coroutineContext = coroutineContext,
socketOptions = socketOptions
)
}
/**
* Create and open UDP port
*/
public fun open( public fun open(
context: Context, context: Context,
remoteHost: String, remoteHost: String,
@ -72,16 +108,23 @@ public class KtorUdpPort internal constructor(
localPort: Int? = null, localPort: Int? = null,
localHost: String = "localhost", localHost: String = "localhost",
coroutineContext: CoroutineContext = context.coroutineContext, coroutineContext: CoroutineContext = context.coroutineContext,
): KtorUdpPort { socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {},
return KtorUdpPort(context, remoteHost, remotePort, localPort, localHost, coroutineContext) ): 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 remoteHost by meta.string { error("Remote host is not specified") }
val remotePort by meta.number { error("Remote port is not specified") } val remotePort by meta.number { error("Remote port is not specified") }
val localHost: String? by meta.string() val localHost: String? by meta.string()
val localPort: Int? by meta.int() val localPort: Int? by meta.int()
return open(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost") return build(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost")
} }
} }
} }

View File

@ -6,18 +6,16 @@ Implementation of direct serial port communication with JSerialComm
## Artifact: ## 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:** **Gradle Kotlin DSL:**
```kotlin ```kotlin
repositories { repositories {
maven("https://repo.kotlin.link") maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("space.kscience:controls-serial:0.2.0") implementation("space.kscience:controls-serial:0.3.0")
} }
``` ```

View File

@ -0,0 +1,134 @@
package space.kscience.controls.serial
import com.fazecast.jSerialComm.SerialPort
import com.fazecast.jSerialComm.SerialPortDataListener
import com.fazecast.jSerialComm.SerialPortEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import space.kscience.controls.ports.AbstractAsynchronousPort
import space.kscience.controls.ports.AsynchronousPort
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.Factory
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.int
import space.kscience.dataforge.meta.string
import kotlin.coroutines.CoroutineContext
/**
* A port based on JSerialComm
*/
public class AsynchronousSerialPort(
context: Context,
meta: Meta,
private val comPort: SerialPort,
coroutineContext: CoroutineContext = context.coroutineContext,
) : AbstractAsynchronousPort(context, meta, coroutineContext) {
override fun toString(): String = "port[${comPort.descriptivePortName}]"
private val serialPortListener = object : SerialPortDataListener {
override fun getListeningEvents(): Int =
SerialPort.LISTENING_EVENT_DATA_RECEIVED and SerialPort.LISTENING_EVENT_DATA_AVAILABLE
override fun serialEvent(event: SerialPortEvent) {
when (event.eventType) {
SerialPort.LISTENING_EVENT_DATA_RECEIVED -> {
scope.launch { receive(event.receivedData) }
}
SerialPort.LISTENING_EVENT_DATA_AVAILABLE -> {
scope.launch(Dispatchers.IO) {
val available = comPort.bytesAvailable()
if (available > 0) {
val buffer = ByteArray(available)
comPort.readBytes(buffer, available)
receive(buffer)
}
}
}
}
}
}
override fun onOpen() {
comPort.openPort()
comPort.addDataListener(serialPortListener)
}
override val isOpen: Boolean get() = comPort.isOpen
override suspend fun write(data: ByteArray) {
comPort.writeBytes(data, data.size)
}
override fun close() {
comPort.removeDataListener()
if (comPort.isOpen) {
comPort.closePort()
}
super.close()
}
public companion object : Factory<AsynchronousPort> {
public fun build(
context: Context,
portName: String,
baudRate: Int = 9600,
dataBits: Int = 8,
stopBits: Int = SerialPort.ONE_STOP_BIT,
parity: Int = SerialPort.NO_PARITY,
coroutineContext: CoroutineContext = context.coroutineContext,
additionalConfig: SerialPort.() -> Unit = {},
): AsynchronousSerialPort {
val serialPort = SerialPort.getCommPort(portName).apply {
setComPortParameters(baudRate, dataBits, stopBits, parity)
additionalConfig()
}
val meta = Meta {
"name" put "com://$portName"
"type" put "serial"
"baudRate" put serialPort.baudRate
"dataBits" put serialPort.numDataBits
"stopBits" put serialPort.numStopBits
"parity" put serialPort.parity
}
return AsynchronousSerialPort(context, meta, serialPort, coroutineContext)
}
/**
* Construct ComPort with given parameters
*/
public fun open(
context: Context,
portName: String,
baudRate: Int = 9600,
dataBits: Int = 8,
stopBits: Int = SerialPort.ONE_STOP_BIT,
parity: Int = SerialPort.NO_PARITY,
coroutineContext: CoroutineContext = context.coroutineContext,
additionalConfig: SerialPort.() -> Unit = {},
): AsynchronousSerialPort = build(
context = context,
portName = portName,
baudRate = baudRate,
dataBits = dataBits,
stopBits = stopBits,
parity = parity,
coroutineContext = coroutineContext,
additionalConfig = additionalConfig
).apply { open() }
override fun build(context: Context, meta: Meta): AsynchronousPort {
val name by meta.string { error("Serial port name not defined") }
val baudRate by meta.int(9600)
val dataBits by meta.int(8)
val stopBits by meta.int(SerialPort.ONE_STOP_BIT)
val parity by meta.int(SerialPort.NO_PARITY)
return build(context, name, baudRate, dataBits, stopBits, parity)
}
}
}

View File

@ -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)
}
}
}

View File

@ -1,19 +1,27 @@
package space.kscience.controls.serial 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.AbstractPlugin
import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.PluginFactory import space.kscience.dataforge.context.PluginFactory
import space.kscience.dataforge.context.PluginTag import space.kscience.dataforge.context.PluginTag
import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.asName
public class SerialPortPlugin : AbstractPlugin() { public class SerialPortPlugin : AbstractPlugin() {
override val tag: PluginTag get() = Companion.tag override val tag: PluginTag get() = Companion.tag
override fun content(target: String): Map<Name, Any> = when(target){ override fun content(target: String): Map<Name, Any> = when (target) {
PortFactory.TYPE -> mapOf(Name.EMPTY to JSerialCommPort) Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf(
"serial".asName() to AsynchronousSerialPort,
)
Ports.SYNCHRONOUS_PORT_TYPE -> mapOf(
"serial".asName() to SynchronousSerialPort,
)
else -> emptyMap() else -> emptyMap()
} }

View File

@ -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 <R> respond(
request: ByteArray,
transform: suspend Flow<ByteArray>.() -> R,
): R = mutex.withLock {
comPort.flushIOBuffers()
comPort.writeBytes(request, request.size)
flow<ByteArray> {
while (isOpen) {
try {
val available = comPort.bytesAvailable()
if (available > 0) {
val buffer = ByteArray(available)
comPort.readBytes(buffer, available)
emit(buffer)
} else if (available < 0) break
} catch (ex: Exception) {
logger.error(ex) { "Channel read error" }
delay(1000)
}
}
}.transform()
}
override suspend fun respondFixedMessageSize(request: ByteArray, responseSize: Int): ByteArray = mutex.withLock {
runInterruptible {
comPort.flushIOBuffers()
comPort.writeBytes(request, request.size)
val buffer = ByteArray(responseSize)
comPort.readBytes(buffer, responseSize)
buffer
}
}
public companion object : Factory<SynchronousPort> {
public fun build(
context: Context,
portName: String,
baudRate: Int = 9600,
dataBits: Int = 8,
stopBits: Int = SerialPort.ONE_STOP_BIT,
parity: Int = SerialPort.NO_PARITY,
additionalConfig: SerialPort.() -> Unit = {},
): SynchronousSerialPort {
val serialPort = SerialPort.getCommPort(portName).apply {
setComPortParameters(baudRate, dataBits, stopBits, parity)
additionalConfig()
}
val meta = Meta {
"name" put "com://$portName"
"type" put "serial"
"baudRate" put serialPort.baudRate
"dataBits" put serialPort.numDataBits
"stopBits" put serialPort.numStopBits
"parity" put serialPort.parity
}
return SynchronousSerialPort(context, meta, serialPort)
}
/**
* Construct ComPort with given parameters
*/
public 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)
}
}
}

View File

@ -6,18 +6,16 @@ A combined Magix event loop server with web server for visualization.
## Artifact: ## 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:** **Gradle Kotlin DSL:**
```kotlin ```kotlin
repositories { repositories {
maven("https://repo.kotlin.link") maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("space.kscience:controls-server:0.2.0") implementation("space.kscience:controls-server:0.3.0")
} }
``` ```

View File

@ -1,7 +1,7 @@
import space.kscience.gradle.Maturity import space.kscience.gradle.Maturity
plugins { plugins {
id("space.kscience.gradle.jvm") id("space.kscience.gradle.mpp")
`maven-publish` `maven-publish`
} }

View File

@ -157,8 +157,8 @@ public fun Application.deviceManagerModule(
val body = call.receiveText() val body = call.receiveText()
val request: DeviceMessage = MagixEndpoint.magixJson.decodeFromString(DeviceMessage.serializer(), body) val request: DeviceMessage = MagixEndpoint.magixJson.decodeFromString(DeviceMessage.serializer(), body)
val response = manager.respondHubMessage(request) val response = manager.respondHubMessage(request)
if (response != null) { if (response.isNotEmpty()) {
call.respondMessage(response) call.respondMessages(response)
} else { } else {
call.respondText("No response") call.respondText("No response")
} }
@ -177,9 +177,9 @@ public fun Application.deviceManagerModule(
property = property, property = property,
) )
val response = manager.respondHubMessage(request) val responses = manager.respondHubMessage(request)
if (response != null) { if (responses.isNotEmpty()) {
call.respondMessage(response) call.respondMessages(responses)
} else { } else {
call.respond(HttpStatusCode.InternalServerError) call.respond(HttpStatusCode.InternalServerError)
} }
@ -197,9 +197,9 @@ public fun Application.deviceManagerModule(
value = json.toMeta() value = json.toMeta()
) )
val response = manager.respondHubMessage(request) val responses = manager.respondHubMessage(request)
if (response != null) { if (responses.isNotEmpty()) {
call.respondMessage(response) call.respondMessages(responses)
} else { } else {
call.respond(HttpStatusCode.InternalServerError) call.respond(HttpStatusCode.InternalServerError)
} }

View File

@ -5,6 +5,7 @@ import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.respondText import io.ktor.server.response.respondText
import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.JsonObjectBuilder
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.serializer
import space.kscience.controls.api.DeviceMessage import space.kscience.controls.api.DeviceMessage
import space.kscience.magix.api.MagixEndpoint import space.kscience.magix.api.MagixEndpoint
@ -25,7 +26,7 @@ internal suspend fun ApplicationCall.respondJson(builder: JsonObjectBuilder.() -
respondText(json.toString(), contentType = ContentType.Application.Json) respondText(json.toString(), contentType = ContentType.Application.Json)
} }
internal suspend fun ApplicationCall.respondMessage(message: DeviceMessage): Unit = respondText( internal suspend fun ApplicationCall.respondMessages(messages: List<DeviceMessage>): Unit = respondText(
MagixEndpoint.magixJson.encodeToString(DeviceMessage.serializer(), message), MagixEndpoint.magixJson.encodeToString(serializer<List<DeviceMessage>>(), messages),
contentType = ContentType.Application.Json contentType = ContentType.Application.Json
) )

View File

@ -6,18 +6,16 @@ An API for stand-alone Controls-kt device or a hub.
## Artifact: ## 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:** **Gradle Kotlin DSL:**
```kotlin ```kotlin
repositories { repositories {
maven("https://repo.kotlin.link") maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("space.kscience:controls-storage:0.2.0") implementation("space.kscience:controls-storage:0.3.0")
} }
``` ```

View File

@ -6,18 +6,16 @@ An implementation of controls-storage on top of JetBrains Xodus.
## Artifact: ## 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:** **Gradle Kotlin DSL:**
```kotlin ```kotlin
repositories { repositories {
maven("https://repo.kotlin.link") maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral() mavenCentral()
} }
dependencies { dependencies {
implementation("space.kscience:controls-xodus:0.2.0") implementation("space.kscience:controls-xodus:0.3.0")
} }
``` ```

Some files were not shown because too many files have changed in this diff Show More