diff --git a/.gitignore b/.gitignore index 3bf252e..5fab474 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Created by .ignore support plugin (hsz.mobi) .idea/ .gradle +.kotlin *.iws *.iml @@ -8,4 +9,7 @@ out/ build/ -!gradle-wrapper.jar \ No newline at end of file + +!gradle-wrapper.jar + +/demo/device-collective/mapCache/ diff --git a/.space.kts b/.space.kts deleted file mode 100644 index c5dd962..0000000 --- a/.space.kts +++ /dev/null @@ -1,45 +0,0 @@ -import kotlin.io.path.readText - -job("Build") { - gradlew("spc.registry.jetbrains.space/p/sci/containers/kotlin-ci:1.0.3", "build") -} - -job("Publish") { - startOn { - gitPush { enabled = false } - } - container("spc.registry.jetbrains.space/p/sci/containers/kotlin-ci:1.0.3") { - env["SPACE_USER"] = "{{ project:space_user }}" - env["SPACE_TOKEN"] = "{{ project:space_token }}" - kotlinScript { api -> - - val spaceUser = System.getenv("SPACE_USER") - val spaceToken = System.getenv("SPACE_TOKEN") - - // write the version to the build directory - api.gradlew("version") - - //read the version from build file - val version = java.nio.file.Path.of("build/project-version.txt").readText() - - val revisionSuffix = if (version.endsWith("SNAPSHOT")) { - "-" + api.gitRevision().take(7) - } else { - "" - } - - api.space().projects.automation.deployments.start( - project = api.projectIdentifier(), - targetIdentifier = TargetIdentifier.Key("maps-kt"), - version = version+revisionSuffix, - // automatically update deployment status based on the status of a job - syncWithAutomationJob = true - ) - api.gradlew( - "publishAllPublicationsToSpaceRepository", - "-Ppublishing.space.user=\"$spaceUser\"", - "-Ppublishing.space.token=\"$spaceToken\"", - ) - } - } -} \ No newline at end of file diff --git a/.space/CODEOWNERS b/.space/CODEOWNERS deleted file mode 100644 index 9f836ea..0000000 --- a/.space/CODEOWNERS +++ /dev/null @@ -1 +0,0 @@ -./space/* "Project Admin" diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f9d13..26cb25f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,64 @@ ## Unreleased ### Added +- Value averaging plot extension +- PLC4X bindings +- Shortcuts to access all Controls devices in a magix network. +- `DeviceClient` properly evaluates lifecycle and logs +- `PeerConnection` API for direct device-device binary sharing +- DeviceDrawable2D intermediate visualization implementation +- New interface `WithLifeCycle`. Change Port API to adhere to it. + +### Changed +- Constructor properties return `DeviceState` in order to be able to subscribe to them +- Refactored ports. Now we have `AsynchronousPort` as well as `SynchronousPort` +- `DeviceClient` now initializes property and action descriptors eagerly. +- `DeviceHub` now works with `Name` instead of `NameToken`. Tree-like structure is made using `Path`. Device messages no longer have access to sub-devices. +- Add some utility methods to ports. Synchronous port response could be now consumed as `Source`. +- `DeviceLifecycleState` is replaced by `LifecycleState`. + + +### Deprecated + +### Removed + +### Fixed +- Fix a problem with rsocket endpoint with no filter. + +### Security + +## 0.3.0 - 2024-03-04 + +### Added + +- Device lifecycle message +- Low-code constructor +- Automatic description generation for spec properties (JVM only) + +### Changed + +- Property caching moved from core `Device` to the `CachingDevice` +- `DeviceSpec` properties no explicitly pass property name to getters and setters. +- `DeviceHub.respondHubMessage` now returns a list of messages to allow querying multiple devices. Device server also returns an array. +- DataForge 0.8.0 + +### Fixed + +- Property writing does not trigger change if logical state already is the same as value to be set. +- Modbus-slave triggers only once for multi-register write. +- Removed unnecessary scope in hub messageFlow + +## 0.2.2-dev-1 - 2023-09-24 + +### Changed + +- updating logical state in `DeviceBase` is now protected and called `propertyChanged()` +- `DeviceBase` tries to read property after write if the writer does not set the value. + +## 0.2.1 - 2023-09-24 + +### Added + - Core interfaces for building a device server - Magix service for binding controls devices (both as RPC client and server) - A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols @@ -20,13 +78,3 @@ - A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes. - Magix history database API - ZMQ client endpoint for Magix - -### Changed - -### Deprecated - -### Removed - -### Fixed - -### Security diff --git a/README.md b/README.md index d5b40c3..256b87d 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ [](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub) +[](https://maven.sciprog.center/) + # Controls.kt Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing. @@ -42,6 +44,11 @@ Example view of a demo: ## Modules +### [controls-constructor](controls-constructor) +> A low-code constructor for composite devices simulation +> +> **Maturity**: PROTOTYPE + ### [controls-core](controls-core) > Core interfaces for building a device server > @@ -56,6 +63,10 @@ Example view of a demo: > - [ports](controls-core/src/commonMain/kotlin/space/kscience/controls/ports) : Working with asynchronous data sending and receiving raw byte arrays +### [controls-jupyter](controls-jupyter) +> +> **Maturity**: EXPERIMENTAL + ### [controls-magix](controls-magix) > Magix service for binding controls devices (both as RPC client and server) > @@ -93,6 +104,11 @@ Automatically checks consistency. > > **Maturity**: EXPERIMENTAL +### [controls-plc4x](controls-plc4x) +> A plugin for Controls-kt device server on top of plc4x library +> +> **Maturity**: EXPERIMENTAL + ### [controls-ports-ktor](controls-ports-ktor) > Implementation of byte ports on top os ktor-io asynchronous API > @@ -113,6 +129,16 @@ Automatically checks consistency. > > **Maturity**: PROTOTYPE +### [controls-vision](controls-vision) +> Dashboard and visualization extensions for devices +> +> **Maturity**: PROTOTYPE + +### [controls-visualisation-compose](controls-visualisation-compose) +> Visualisation extension using compose-multiplatform +> +> **Maturity**: PROTOTYPE + ### [demo](demo) > > **Maturity**: EXPERIMENTAL @@ -121,6 +147,15 @@ Automatically checks consistency. > > **Maturity**: EXPERIMENTAL +### [simulation-kt](simulation-kt) +> A framework for combination of asynchronous simulations. +> +> **Maturity**: PROTOTYPE +> +> **Features:** +> - [timeline](simulation-kt/#) : Timeline is an ordered discrete history containing TimeLineEvent + + ### [controls-storage/controls-xodus](controls-storage/controls-xodus) > An implementation of controls-storage on top of JetBrains Xodus. > @@ -134,6 +169,14 @@ Automatically checks consistency. > > **Maturity**: EXPERIMENTAL +### [demo/constructor](demo/constructor) +> +> **Maturity**: EXPERIMENTAL + +### [demo/device-collective](demo/device-collective) +> +> **Maturity**: EXPERIMENTAL + ### [demo/echo](demo/echo) > > **Maturity**: EXPERIMENTAL @@ -189,6 +232,11 @@ Automatically checks consistency. > > **Maturity**: PROTOTYPE +### [magix/magix-utils](magix/magix-utils) +> Common utilities and services for Magix endpoints. +> +> **Maturity**: EXPERIMENTAL + ### [magix/magix-zmq](magix/magix-zmq) > ZMQ client endpoint for Magix > diff --git a/build.gradle.kts b/build.gradle.kts index df7c664..7e06cfa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,37 +1,26 @@ -import space.kscience.gradle.isInDevelopment import space.kscience.gradle.useApache2Licence import space.kscience.gradle.useSPCTeam plugins { id("space.kscience.gradle.project") + alias(libs.plugins.versions) } -val dataforgeVersion: String by extra("0.6.2-dev-3") -val ktorVersion: String by extra(space.kscience.gradle.KScienceVersions.ktorVersion) -val rsocketVersion by extra("0.15.4") -val xodusVersion by extra("2.0.1") - allprojects { group = "space.kscience" - version = "0.2.0" + version = "0.4.0-dev-7" repositories{ - maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") + google() } } ksciencePublish { - pom("https://github.com/SciProgCentre/controls.kt") { + pom("https://github.com/SciProgCentre/controls-kt") { useApache2Licence() useSPCTeam() } - github("controls.kt", "SciProgCentre") - space( - if (isInDevelopment) { - "https://maven.pkg.jetbrains.space/spc/p/sci/dev" - } else { - "https://maven.pkg.jetbrains.space/spc/p/sci/maven" - } - ) + repository("spc","https://maven.sciprog.center/kscience") + sonatype("https://oss.sonatype.org") } readme.readmeTemplate = file("docs/templates/README-TEMPLATE.md") \ No newline at end of file diff --git a/controls-constructor/README.md b/controls-constructor/README.md new file mode 100644 index 0000000..f2186bd --- /dev/null +++ b/controls-constructor/README.md @@ -0,0 +1,21 @@ +# Module controls-constructor + +A low-code constructor for composite devices simulation + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:controls-constructor:0.4.0-dev-7`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:controls-constructor:0.4.0-dev-7") +} +``` diff --git a/controls-constructor/build.gradle.kts b/controls-constructor/build.gradle.kts new file mode 100644 index 0000000..1947f91 --- /dev/null +++ b/controls-constructor/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +description = """ + A low-code constructor for composite devices simulation +""".trimIndent() + +kscience{ + jvm() + js() + native() + wasm() + useCoroutines() + useSerialization() + commonMain { + api(projects.controlsCore) + } + + commonTest{ + implementation(spclibs.logback.classic) + } +} + +readme{ + maturity = space.kscience.gradle.Maturity.PROTOTYPE +} diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt new file mode 100644 index 0000000..8073689 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt @@ -0,0 +1,241 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.datetime.Instant +import space.kscience.controls.api.Device +import space.kscience.controls.manager.ClockManager +import space.kscience.dataforge.context.ContextAware +import space.kscience.dataforge.context.request +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +/** + * A binding that is used to describe device functionality + */ +public sealed interface ConstructorElement + +/** + * A binding that exposes device property as read-only state + */ +public class PropertyConstructorElement<T>( + public val device: Device, + public val propertyName: String, + public val state: DeviceState<T>, +) : ConstructorElement + +/** + * A binding for independent state like a timer + */ +public class StateConstructorElement<T>( + public val state: DeviceState<T>, +) : ConstructorElement + +public class ConnectionConstrucorElement( + public val reads: Collection<DeviceState<*>>, + public val writes: Collection<DeviceState<*>>, +) : ConstructorElement + +public class ModelConstructorElement( + public val model: ModelConstructor, +) : ConstructorElement + + +public interface StateContainer : ContextAware, CoroutineScope { + public val constructorElements: Set<ConstructorElement> + public fun registerElement(constructorElement: ConstructorElement) + public fun unregisterElement(constructorElement: ConstructorElement) + + + /** + * Bind an action to a [DeviceState]. [onChange] block is performed on each state change + * + * Optionally provide [writes] - a set of states that this change affects. + */ + public fun <T> DeviceState<T>.onNext( + writes: Collection<DeviceState<*>> = emptySet(), + reads: Collection<DeviceState<*>> = emptySet(), + onChange: suspend (T) -> Unit, + ): Job = valueFlow.onEach(onChange).launchIn(this@StateContainer).also { + registerElement(ConnectionConstrucorElement(reads + this, writes)) + } + + public fun <T> DeviceState<T>.onChange( + writes: Collection<DeviceState<*>> = emptySet(), + reads: Collection<DeviceState<*>> = emptySet(), + onChange: suspend (prev: T, next: T) -> Unit, + ): Job = valueFlow.runningFold(Pair(value, value)) { pair, next -> + Pair(pair.second, next) + }.onEach { pair -> + if (pair.first != pair.second) { + onChange(pair.first, pair.second) + } + }.launchIn(this@StateContainer).also { + registerElement(ConnectionConstrucorElement(reads + this, writes)) + } +} + +/** + * Register a [state] in this container. The state is not registered as a device property if [this] is a [DeviceConstructor] + */ +public fun <T, D : DeviceState<T>> StateContainer.registerState(state: D): D { + registerElement(StateConstructorElement(state)) + return state +} + +/** + * Create a register a [MutableDeviceState] + */ +public fun <T> StateContainer.stateOf(initialValue: T): MutableDeviceState<T> = registerState( + MutableDeviceState(initialValue) +) + +public fun <T : ModelConstructor> StateContainer.model(model: T): T { + registerElement(ModelConstructorElement(model)) + return model +} + +/** + * Create and register a timer state. + */ +public fun StateContainer.timer(tick: Duration): TimerState = + registerState(TimerState(context.request(ClockManager), tick)) + +/** + * Register a new timer and perform [block] on its change + */ +public fun StateContainer.onTimer( + tick: Duration, + writes: Collection<DeviceState<*>> = emptySet(), + reads: Collection<DeviceState<*>> = emptySet(), + block: suspend (prev: Instant, next: Instant) -> Unit, +): Job = timer(tick).onChange(writes = writes, reads = reads, onChange = block) + +public enum class DefaultTimer(public val duration: Duration){ + REALTIME(5.milliseconds), + VERY_FAST(10.milliseconds), + FAST(20.milliseconds), + MEDIUM(50.milliseconds), + SLOW(100.milliseconds), + VERY_SLOW(500.milliseconds), +} + +/** + * Perform an action on default timer + */ +public fun StateContainer.onTimer( + defaultTimer: DefaultTimer = DefaultTimer.FAST, + writes: Collection<DeviceState<*>> = emptySet(), + reads: Collection<DeviceState<*>> = emptySet(), + block: suspend (prev: Instant, next: Instant) -> Unit, +): Job = timer(defaultTimer.duration).onChange(writes = writes, reads = reads, onChange = block) +//TODO implement timer pooling + +public fun <T, R> StateContainer.mapState( + origin: DeviceState<T>, + transformation: (T) -> R, +): DeviceStateWithDependencies<R> = registerState(DeviceState.map(origin, transformation)) + + +public fun <T, R> StateContainer.flowState( + origin: DeviceState<T>, + initialValue: R, + transformation: suspend FlowCollector<R>.(T) -> Unit, +): DeviceStateWithDependencies<R> { + val state = MutableDeviceState(initialValue) + origin.valueFlow.transform(transformation).onEach { state.value = it }.launchIn(this) + return registerState(state.withDependencies(setOf(origin))) +} + +/** + * Create a new state by combining two existing ones + */ +public fun <T1, T2, R> StateContainer.combineState( + first: DeviceState<T1>, + second: DeviceState<T2>, + transformation: (T1, T2) -> R, +): DeviceState<R> = registerState(DeviceState.combine(first, second, transformation)) + +/** + * Create and start binding between [sourceState] and [targetState]. Changes made to [sourceState] are automatically + * transferred onto [targetState], but not vise versa. + * + * On resulting [Job] cancel the binding is unregistered + */ +public fun <T> StateContainer.bind(sourceState: DeviceState<T>, targetState: MutableDeviceState<T>): Job { + val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState)) + registerElement(descriptor) + return sourceState.valueFlow.onEach { + targetState.value = it + }.launchIn(this).apply { + invokeOnCompletion { + unregisterElement(descriptor) + } + } +} + +/** + * Create and start binding between [sourceState] and [targetState]. Changes made to [sourceState] are automatically + * transferred onto [targetState] via [transformation], but not vise versa. + * + * On resulting [Job] cancel the binding is unregistered + */ +public fun <T, R> StateContainer.transformTo( + sourceState: DeviceState<T>, + targetState: MutableDeviceState<R>, + transformation: suspend (T) -> R, +): Job { + val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState)) + registerElement(descriptor) + return sourceState.valueFlow.onEach { + targetState.value = transformation(it) + }.launchIn(this).apply { + invokeOnCompletion { + unregisterElement(descriptor) + } + } +} + +/** + * Register [ConstructorElement] that combines values from [sourceState1] and [sourceState2] using [transformation]. + * + * On resulting [Job] cancel the binding is unregistered + */ +public fun <T1, T2, R> StateContainer.combineTo( + sourceState1: DeviceState<T1>, + sourceState2: DeviceState<T2>, + targetState: MutableDeviceState<R>, + transformation: suspend (T1, T2) -> R, +): Job { + val descriptor = ConnectionConstrucorElement(setOf(sourceState1, sourceState2), setOf(targetState)) + registerElement(descriptor) + return kotlinx.coroutines.flow.combine(sourceState1.valueFlow, sourceState2.valueFlow, transformation).onEach { + targetState.value = it + }.launchIn(this).apply { + invokeOnCompletion { + unregisterElement(descriptor) + } + } +} + +/** + * Register [ConstructorElement] that combines values from [sourceStates] using [transformation]. + * + * On resulting [Job] cancel the binding is unregistered + */ +public inline fun <reified T, R> StateContainer.combineTo( + sourceStates: Collection<DeviceState<T>>, + targetState: MutableDeviceState<R>, + noinline transformation: suspend (Array<T>) -> R, +): Job { + val descriptor = ConnectionConstrucorElement(sourceStates, setOf(targetState)) + registerElement(descriptor) + return kotlinx.coroutines.flow.combine(sourceStates.map { it.valueFlow }, transformation).onEach { + targetState.value = it + }.launchIn(this).apply { + invokeOnCompletion { + unregisterElement(descriptor) + } + } +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt new file mode 100644 index 0000000..371e94f --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceConstructor.kt @@ -0,0 +1,150 @@ +package space.kscience.controls.constructor + +import space.kscience.controls.api.Device +import space.kscience.controls.api.PropertyDescriptor +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KProperty +import kotlin.time.Duration + +/** + * A base for strongly typed device constructor block. Has additional delegates for type-safe devices + */ +public abstract class DeviceConstructor( + context: Context, + meta: Meta = Meta.EMPTY, +) : DeviceGroup(context, meta), StateContainer { + private val _constructorElements: MutableSet<ConstructorElement> = mutableSetOf() + override val constructorElements: Set<ConstructorElement> get() = _constructorElements + + override fun registerElement(constructorElement: ConstructorElement) { + _constructorElements.add(constructorElement) + } + + override fun unregisterElement(constructorElement: ConstructorElement) { + _constructorElements.remove(constructorElement) + } + + override fun <T, S: DeviceState<T>> registerProperty( + converter: MetaConverter<T>, + descriptor: PropertyDescriptor, + state: S, + ): S { + val res = super.registerProperty(converter, descriptor, state) + registerElement(PropertyConstructorElement(this, descriptor.name, state)) + return res + } +} + +/** + * Register a device, provided by a given [factory] and + */ +public fun <D : Device> DeviceConstructor.device( + factory: Factory<D>, + meta: Meta? = null, + nameOverride: Name? = null, + metaLocation: Name? = null, +): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> = + PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> -> + val name = nameOverride ?: property.name.asName() + val device = install(name, factory, meta, metaLocation ?: name) + ReadOnlyProperty { _: DeviceConstructor, _ -> + device + } + } + +public fun <D : Device> DeviceConstructor.device( + device: D, + nameOverride: Name? = null, +): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, D>> = + PropertyDelegateProvider { _: DeviceConstructor, property: KProperty<*> -> + val name = nameOverride ?: property.name.asName() + install(name, device) + ReadOnlyProperty { _: DeviceConstructor, _ -> + device + } + } + +/** + * Register a property and provide a direct reader for it + */ +public fun <T, S : DeviceState<T>> DeviceConstructor.property( + converter: MetaConverter<T>, + state: S, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + nameOverride: String? = null, +): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, S>> = + PropertyDelegateProvider { _: DeviceConstructor, property -> + val name = nameOverride ?: property.name + val descriptor = PropertyDescriptor(name).apply(descriptorBuilder) + registerProperty(converter, descriptor, state) + ReadOnlyProperty { _: DeviceConstructor, _ -> + state + } + } + +/** + * Register external state as a property + */ +public fun <T : Any> DeviceConstructor.property( + metaConverter: MetaConverter<T>, + reader: suspend () -> T, + readInterval: Duration, + initialState: T, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + nameOverride: String? = null, +): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, DeviceState<T>>> = property( + metaConverter, + DeviceState.external(this, readInterval, initialState, reader), + descriptorBuilder, + nameOverride, +) + +/** + * Create and register a mutable external state as a property + */ +public fun <T : Any> DeviceConstructor.mutableProperty( + metaConverter: MetaConverter<T>, + reader: suspend () -> T, + writer: suspend (T) -> Unit, + readInterval: Duration, + initialState: T, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + nameOverride: String? = null, +): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property( + metaConverter, + DeviceState.external(this, readInterval, initialState, reader, writer), + descriptorBuilder, + nameOverride, +) + +/** + * Create and register a virtual mutable property with optional [callback] + */ +public fun <T> DeviceConstructor.virtualProperty( + metaConverter: MetaConverter<T>, + initialState: T, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + nameOverride: String? = null, + callback: (T) -> Unit = {}, +): PropertyDelegateProvider<DeviceConstructor, ReadOnlyProperty<DeviceConstructor, MutableDeviceState<T>>> = property( + metaConverter, + MutableDeviceState(initialState, callback), + descriptorBuilder, + nameOverride, +) + +public fun <T, S : DeviceState<T>> DeviceConstructor.registerAsProperty( + spec: DevicePropertySpec<*, T>, + state: S, +): S { + registerProperty(spec.converter, spec.descriptor, state) + return state +} diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt new file mode 100644 index 0000000..79ff2ed --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceGroup.kt @@ -0,0 +1,318 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import space.kscience.controls.api.* +import space.kscience.controls.api.LifecycleState.* +import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.manager.install +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.dataforge.context.* +import space.kscience.dataforge.meta.Laminate +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.meta.MutableMeta +import space.kscience.dataforge.misc.DFExperimental +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.get +import space.kscience.dataforge.names.parseAsName +import kotlin.collections.set +import kotlin.coroutines.CoroutineContext + + +/** + * A mutable group of devices and properties to be used for lightweight design and simulations. + */ +public open class DeviceGroup( + final override val context: Context, + override val meta: Meta, +) : DeviceHub, CachingDevice { + + private class Property<T>( + val state: DeviceState<T>, + val converter: MetaConverter<T>, + val descriptor: PropertyDescriptor, + ) { + val valueAsMeta get() = converter.convert(state.value) + + fun setMeta(meta: Meta) { + check(state is MutableDeviceState) { "Can't write to read-only property" } + + state.value = converter.read(meta) + } + } + + private class Action<T, R>( + val inputConverter: MetaConverter<T>, + val outputConverter: MetaConverter<R>, + val descriptor: ActionDescriptor, + val action: suspend (T) -> R, + ) { + suspend operator fun invoke(argument: Meta?): Meta? = argument?.let { inputConverter.readOrNull(it) } + ?.let { action(it)?.let { outputConverter.convert(it) } } + } + + + private val sharedMessageFlow = MutableSharedFlow<DeviceMessage>() + + override val messageFlow: Flow<DeviceMessage> + get() = sharedMessageFlow + + @OptIn(ExperimentalCoroutinesApi::class) + override val coroutineContext: CoroutineContext = context.newCoroutineContext( + SupervisorJob(context.coroutineContext[Job]) + + CoroutineName("Device $id") + + CoroutineExceptionHandler { _, throwable -> + context.launch { + sharedMessageFlow.emit( + DeviceErrorMessage( + errorMessage = throwable.message, + errorType = throwable::class.simpleName, + errorStackTrace = throwable.stackTraceToString() + ) + ) + } + logger.error(throwable) { "Exception in device $id" } + } + ) + + + private val _devices = hashMapOf<Name, Device>() + + override val devices: Map<Name, Device> = _devices + + /** + * Register and initialize (synchronize child's lifecycle state with group state) a new device in this group + */ + @OptIn(DFExperimental::class) + public open fun <D : Device> install(token: Name, device: D): D { + require(_devices[token] == null) { "A child device with name $token already exists" } + //start the child device if needed + if (lifecycleState == STARTED || lifecycleState == STARTING) launch { device.start() } + _devices[token] = device + return device + } + + private val properties: MutableMap<Name, Property<*>> = hashMapOf() + + /** + * Register a new property based on [DeviceState]. Properties could be modified dynamically + */ + public open fun <T, S : DeviceState<T>> registerProperty( + converter: MetaConverter<T>, + descriptor: PropertyDescriptor, + state: S, + ): S { + val name = descriptor.name.parseAsName() + require(properties[name] == null) { "Can't add property with name $name. It already exists." } + properties[name] = Property(state, converter, descriptor) + state.valueFlow.map(converter::convert).onEach { + sharedMessageFlow.emit( + PropertyChangedMessage( + descriptor.name, + it + ) + ) + }.launchIn(this) + return state + } + + private val actions: MutableMap<Name, Action<*, *>> = hashMapOf() + + public fun <T, R> registerAction( + inputConverter: MetaConverter<T>, + outputConverter: MetaConverter<R>, + descriptor: ActionDescriptor, + action: suspend (T) -> R, + ): suspend (T) -> R { + val name = descriptor.name.parseAsName() + require(actions[name] == null) { "Can't add action with name $name. It already exists." } + actions[name] = Action( + inputConverter = inputConverter, + outputConverter = outputConverter, + descriptor = descriptor, + action = action + ) + return { + action(it) + } + } + + override val propertyDescriptors: Collection<PropertyDescriptor> + get() = properties.values.map { it.descriptor } + + override val actionDescriptors: Collection<ActionDescriptor> + get() = actions.values.map { it.descriptor } + + override suspend fun readProperty(propertyName: String): Meta = + properties[propertyName.parseAsName()]?.valueAsMeta + ?: error("Property with name $propertyName not found") + + override fun getProperty(propertyName: String): Meta? = properties[propertyName.parseAsName()]?.valueAsMeta + + override suspend fun invalidate(propertyName: String) { + //does nothing for this implementation + } + + override suspend fun writeProperty(propertyName: String, value: Meta) { + val property = properties[propertyName.parseAsName()] ?: error("Property with name $propertyName not found") + property.setMeta(value) + } + + + override suspend fun execute(actionName: String, argument: Meta?): Meta? { + val action: Action<*, *> = actions[actionName] ?: error("Action with name $actionName not found") + return action(argument) + } + + final override var lifecycleState: LifecycleState = LifecycleState.STOPPED + private set + + + private suspend fun setLifecycleState(lifecycleState: LifecycleState) { + this.lifecycleState = lifecycleState + sharedMessageFlow.emit( + DeviceLifeCycleMessage(lifecycleState) + ) + } + + + override suspend fun start() { + setLifecycleState(STARTING) + super.start() + devices.values.forEach { + it.start() + } + setLifecycleState(STARTED) + } + + override suspend fun stop() { + devices.values.forEach { + it.stop() + } + setLifecycleState(STOPPED) + super.stop() + } + + public companion object { + + } +} + +public fun <T> DeviceGroup.registerAsProperty(propertySpec: DevicePropertySpec<*, T>, state: DeviceState<T>) { + registerProperty(propertySpec.converter, propertySpec.descriptor, state) +} + +public fun DeviceManager.registerDeviceGroup( + name: String = "@group", + meta: Meta = Meta.EMPTY, + block: DeviceGroup.() -> Unit, +): DeviceGroup { + val group = DeviceGroup(context, meta).apply(block) + install(name, group) + return group +} + +public fun Context.registerDeviceGroup( + name: String = "@group", + meta: Meta = Meta.EMPTY, + block: DeviceGroup.() -> Unit, +): DeviceGroup = request(DeviceManager).registerDeviceGroup(name, meta, block) + +///** +// * Register a device at given [path] path +// */ +//public fun <D : Device> DeviceGroup.install(path: Path, device: D): D { +// return when (path.length) { +// 0 -> error("Can't use empty path for a child device") +// 1 -> install(path.first().name, device) +// else -> getOrCreateGroup(path.cutLast()).install(path.tokens.last(), device) +// } +//} + +public fun <D : Device> DeviceGroup.install(name: String, device: D): D = install(name.parseAsName(), device) + +public fun <D : Device> DeviceGroup.install(device: D): D = install(device.id, device) + +/** + * Add a device creating intermediate groups if necessary. If device with given [name] already exists, throws an error. + * @param name the name of the device in the group + * @param factory a factory used to create a device + * @param deviceMeta meta override for this specific device + * @param metaLocation location of the template meta in parent group meta + */ +public fun <D : Device> DeviceGroup.install( + name: Name, + factory: Factory<D>, + deviceMeta: Meta? = null, + metaLocation: Name = name, +): D { + val newDevice = factory.build(context, Laminate(deviceMeta, meta[metaLocation])) + install(name, newDevice) + return newDevice +} + +public fun <D : Device> DeviceGroup.install( + name: String, + factory: Factory<D>, + metaLocation: Name = name.parseAsName(), + metaBuilder: (MutableMeta.() -> Unit)? = null, +): D = install(name.parseAsName(), factory, metaBuilder?.let { Meta(it) }, metaLocation) + +/** + * Create or edit a group with a given [name]. + */ +public fun DeviceGroup.registerDeviceGroup(name: Name, block: DeviceGroup.() -> Unit): DeviceGroup = + install(name, DeviceGroup(context, meta).apply(block)) + +public fun DeviceGroup.registerDeviceGroup(name: String, block: DeviceGroup.() -> Unit): DeviceGroup = + registerDeviceGroup(name.parseAsName(), block) + +/** + * Register read-only property based on [state] + */ +public fun <T : Any> DeviceGroup.registerAsProperty( + name: String, + converter: MetaConverter<T>, + state: DeviceState<T>, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, +) { + registerProperty( + converter, + PropertyDescriptor(name).apply(descriptorBuilder), + state + ) +} + +/** + * Register a mutable property based on mutable [state] + */ +public fun <T : Any> DeviceGroup.registerMutableProperty( + name: String, + converter: MetaConverter<T>, + state: MutableDeviceState<T>, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, +) { + registerProperty( + converter, + PropertyDescriptor(name).apply(descriptorBuilder), + state + ) +} + + +/** + * Create a new virtual mutable state and a property based on it. + * @return the mutable state used in property + */ +public fun <T : Any> DeviceGroup.registerVirtualProperty( + name: String, + initialValue: T, + converter: MetaConverter<T>, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + callback: (T) -> Unit = {}, +): MutableDeviceState<T> { + val state = MutableDeviceState<T>(initialValue, callback) + registerMutableProperty(name, converter, state, descriptorBuilder) + return state +} diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt new file mode 100644 index 0000000..5b547f7 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/DeviceState.kt @@ -0,0 +1,103 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.constructor.units.UnitsOfMeasurement +import kotlin.reflect.KProperty + +/** + * An observable state of a device + */ +public interface DeviceState<out T> { + public val value: T + + public val valueFlow: Flow<T> + + override fun toString(): String + + public companion object +} + + +public operator fun <T> DeviceState<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value + +/** + * Collect values in a given [scope] + */ +public fun <T> DeviceState<T>.collectValuesIn(scope: CoroutineScope, block: suspend (T) -> Unit): Job = + valueFlow.onEach(block).launchIn(scope) + +/** + * A mutable state of a device + */ +public interface MutableDeviceState<T> : DeviceState<T> { + override var value: T +} + +public operator fun <T> MutableDeviceState<T>.setValue(thisRef: Any?, property: KProperty<*>, value: T) { + this.value = value +} + +/** + * Device state with a value that depends on other device states + */ +public interface DeviceStateWithDependencies<T> : DeviceState<T> { + public val dependencies: Collection<DeviceState<*>> +} + +public fun <T> DeviceState<T>.withDependencies( + dependencies: Collection<DeviceState<*>>, +): DeviceStateWithDependencies<T> = object : DeviceStateWithDependencies<T>, DeviceState<T> by this { + override val dependencies: Collection<DeviceState<*>> = dependencies +} + +/** + * Create a new read-only [DeviceState] that mirrors receiver state by mapping the value with [mapper]. + */ +public fun <T, R> DeviceState.Companion.map( + state: DeviceState<T>, + mapper: (T) -> R, +): DeviceStateWithDependencies<R> = object : DeviceStateWithDependencies<R> { + override val dependencies = listOf(state) + + override val value: R get() = mapper(state.value) + + override val valueFlow: Flow<R> = state.valueFlow.map(mapper) + + override fun toString(): String = "DeviceState.map(state=${state})" +} + +public fun <T, R> DeviceState<T>.map(mapper: (T) -> R): DeviceStateWithDependencies<R> = DeviceState.map(this, mapper) + +public fun DeviceState<NumericalValue<out UnitsOfMeasurement>>.values(): DeviceState<Double> = + object : DeviceState<Double> { + override val value: Double + get() = this@values.value.value + + override val valueFlow: Flow<Double> + get() = this@values.valueFlow.map { it.value } + + override fun toString(): String = this@values.toString() + } + +/** + * Combine two device states into one read-only [DeviceState]. Only the latest value of each state is used. + */ +public fun <T1, T2, R> DeviceState.Companion.combine( + state1: DeviceState<T1>, + state2: DeviceState<T2>, + mapper: (T1, T2) -> R, +): DeviceStateWithDependencies<R> = object : DeviceStateWithDependencies<R> { + override val dependencies = listOf(state1, state2) + + override val value: R get() = mapper(state1.value, state2.value) + + override val valueFlow: Flow<R> = kotlinx.coroutines.flow.combine(state1.valueFlow, state2.valueFlow, mapper) + + override fun toString(): String = "DeviceState.combine(state1=$state1, state2=$state2)" +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ModelConstructor.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ModelConstructor.kt new file mode 100644 index 0000000..8fdc708 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ModelConstructor.kt @@ -0,0 +1,33 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.newCoroutineContext +import space.kscience.dataforge.context.Context +import kotlin.coroutines.CoroutineContext + +public abstract class ModelConstructor( + final override val context: Context, + vararg dependencies: DeviceState<*>, +) : StateContainer, CoroutineScope { + + @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) + override val coroutineContext: CoroutineContext = context.newCoroutineContext(SupervisorJob()) + + + private val _constructorElements: MutableSet<ConstructorElement> = mutableSetOf<ConstructorElement>().apply { + dependencies.forEach { + add(StateConstructorElement(it)) + } + } + + override val constructorElements: Set<ConstructorElement> get() = _constructorElements + + override fun registerElement(constructorElement: ConstructorElement) { + _constructorElements.add(constructorElement) + } + + override fun unregisterElement(constructorElement: ConstructorElement) { + _constructorElements.remove(constructorElement) + } +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt new file mode 100644 index 0000000..baf9646 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/TimerState.kt @@ -0,0 +1,39 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.datetime.Instant +import space.kscience.controls.manager.ClockManager +import kotlin.time.Duration + +/** + * A dedicated [DeviceState] that operates with time. + * The state changes with [tick] interval and always shows the time of the last update. + * + * Both [tick] and current time are computed by [clockManager] enabling time manipulation. + * + * The timer runs indefinitely until the parent context is closed + */ +public class TimerState( + public val clockManager: ClockManager, + public val tick: Duration, +) : DeviceState<Instant> { + + private val clock = MutableStateFlow(clockManager.clock.now()) + + private val updateJob = clockManager.context.launch(clockManager.asDispatcher()) { + while (isActive) { + delay(tick) + clock.value = clockManager.clock.now() + } + } + + override val valueFlow: Flow<Instant> get() = clock + + override val value: Instant get() = clock.value + + override fun toString(): String = "TimerState(tick=$tick)" +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt new file mode 100644 index 0000000..1c128fa --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/boundState.kt @@ -0,0 +1,103 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import space.kscience.controls.api.Device +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.api.id +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.MutableDevicePropertySpec +import space.kscience.controls.spec.name +import space.kscience.dataforge.meta.MetaConverter + + +/** + * A copy-free [DeviceState] bound to a device property + */ +private open class BoundDeviceState<T>( + val converter: MetaConverter<T>, + val device: Device, + val propertyName: String, + initialValue: T, +) : DeviceState<T> { + + override val valueFlow: StateFlow<T> = device.messageFlow.filterIsInstance<PropertyChangedMessage>().filter { + it.property == propertyName + }.mapNotNull { + converter.read(it.value) + }.stateIn(device.context, SharingStarted.Eagerly, initialValue) + + override val value: T get() = valueFlow.value + override fun toString(): String = + "BoundDeviceState(converter=$converter, device=${device.id}, propertyName='$propertyName')" + + +} + +public fun <T> Device.propertyAsState( + propertyName: String, + metaConverter: MetaConverter<T>, + initialValue: T, +): DeviceState<T> = BoundDeviceState(metaConverter, this, propertyName, initialValue) + +/** + * Bind a read-only [DeviceState] to a [Device] property + */ +public suspend fun <T> Device.propertyAsState( + propertyName: String, + metaConverter: MetaConverter<T>, +): DeviceState<T> = propertyAsState( + propertyName, + metaConverter, + metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed") +) + +public suspend fun <D : Device, T> D.propertyAsState( + propertySpec: DevicePropertySpec<D, T>, +): DeviceState<T> = propertyAsState(propertySpec.name, propertySpec.converter) + +public fun <D : Device, T> D.propertyAsState( + propertySpec: DevicePropertySpec<D, T>, + initialValue: T, +): DeviceState<T> = propertyAsState(propertySpec.name, propertySpec.converter, initialValue) + + +private class MutableBoundDeviceState<T>( + converter: MetaConverter<T>, + device: Device, + propertyName: String, + initialValue: T, +) : BoundDeviceState<T>(converter, device, propertyName, initialValue), MutableDeviceState<T> { + + override var value: T + get() = valueFlow.value + set(newValue) { + device.launch { + device.writeProperty(propertyName, converter.convert(newValue)) + } + } +} + +public fun <T> Device.mutablePropertyAsState( + propertyName: String, + metaConverter: MetaConverter<T>, + initialValue: T, +): MutableDeviceState<T> = MutableBoundDeviceState(metaConverter, this, propertyName, initialValue) + +public suspend fun <T> Device.mutablePropertyAsState( + propertyName: String, + metaConverter: MetaConverter<T>, +): MutableDeviceState<T> { + val initialValue = metaConverter.readOrNull(readProperty(propertyName)) ?: error("Conversion of property failed") + return mutablePropertyAsState(propertyName, metaConverter, initialValue) +} + +public suspend fun <D : Device, T> D.propertyAsState( + propertySpec: MutableDevicePropertySpec<D, T>, +): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter) + +public fun <D : Device, T> D.propertyAsState( + propertySpec: MutableDevicePropertySpec<D, T>, + initialValue: T, +): MutableDeviceState<T> = mutablePropertyAsState(propertySpec.name, propertySpec.converter, initialValue) + diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt new file mode 100644 index 0000000..9880508 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/Drive.kt @@ -0,0 +1,19 @@ +package space.kscience.controls.constructor.devices + +import space.kscience.controls.constructor.DeviceConstructor +import space.kscience.controls.constructor.MutableDeviceState +import space.kscience.controls.constructor.property +import space.kscience.controls.constructor.units.NewtonsMeters +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.constructor.units.numerical +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.MetaConverter + +//TODO use current as input + +public class Drive( + context: Context, + force: MutableDeviceState<NumericalValue<NewtonsMeters>> = MutableDeviceState(NumericalValue(0)), +) : DeviceConstructor(context) { + public val force: MutableDeviceState<NumericalValue<NewtonsMeters>> by property(MetaConverter.numerical(), force) +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/EncoderDevice.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/EncoderDevice.kt new file mode 100644 index 0000000..cb73cff --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/EncoderDevice.kt @@ -0,0 +1,20 @@ +package space.kscience.controls.constructor.devices + +import space.kscience.controls.constructor.DeviceConstructor +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.constructor.property +import space.kscience.controls.constructor.units.Degrees +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.constructor.units.numerical +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.MetaConverter + +/** + * An encoder that can read an angle + */ +public class EncoderDevice( + context: Context, + position: DeviceState<NumericalValue<Degrees>> +) : DeviceConstructor(context) { + public val position: DeviceState<NumericalValue<Degrees>> by property(MetaConverter.numerical<Degrees>(), position) +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt new file mode 100644 index 0000000..d06a385 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LimitSwitch.kt @@ -0,0 +1,44 @@ +package space.kscience.controls.constructor.devices + +import space.kscience.controls.constructor.DeviceConstructor +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.constructor.map +import space.kscience.controls.constructor.registerAsProperty +import space.kscience.controls.constructor.units.Direction +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.constructor.units.UnitsOfMeasurement +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.DeviceSpec +import space.kscience.controls.spec.booleanProperty +import space.kscience.dataforge.context.Context + + +/** + * A device that detects if a motor hits the end of its range + */ +public class LimitSwitch( + context: Context, + locked: DeviceState<Boolean>, +) : DeviceConstructor(context) { + + public val locked: DeviceState<Boolean> = registerAsProperty(LimitSwitch.locked, locked) + + public companion object : DeviceSpec<LimitSwitch>() { + public val locked: DevicePropertySpec<LimitSwitch, Boolean> by booleanProperty { locked.value } + } +} + +public fun <U : UnitsOfMeasurement, T : NumericalValue<U>> LimitSwitch( + context: Context, + limit: T, + boundary: Direction, + position: DeviceState<T>, +): LimitSwitch = LimitSwitch( + context, + DeviceState.map(position) { + when (boundary) { + Direction.UP -> it >= limit + Direction.DOWN -> it <= limit + } + } +) \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LinearDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LinearDrive.kt new file mode 100644 index 0000000..51039d1 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/LinearDrive.kt @@ -0,0 +1,38 @@ +package space.kscience.controls.constructor.devices + +import space.kscience.controls.constructor.* +import space.kscience.controls.constructor.models.PidParameters +import space.kscience.controls.constructor.models.PidRegulator +import space.kscience.controls.constructor.units.Meters +import space.kscience.controls.constructor.units.NewtonsMeters +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.constructor.units.numerical +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter + +public class LinearDrive( + drive: Drive, + start: LimitSwitch, + end: LimitSwitch, + position: DeviceState<NumericalValue<Meters>>, + pidParameters: PidParameters, + context: Context = drive.context, + meta: Meta = Meta.EMPTY, +) : DeviceConstructor(context, meta) { + + public val position: DeviceState<NumericalValue<Meters>> by property(MetaConverter.numerical(), position) + + public val drive: Drive by device(drive) + public val pid: PidRegulator<Meters, NewtonsMeters> = model( + PidRegulator( + context = context, + position = position, + output = drive.force, + pidParameters = pidParameters + ) + ) + + public val startLimit: LimitSwitch by device(start) + public val endLimit: LimitSwitch by device(end) +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt new file mode 100644 index 0000000..9d552c5 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/devices/StepDrive.kt @@ -0,0 +1,65 @@ +package space.kscience.controls.constructor.devices + +import space.kscience.controls.constructor.* +import space.kscience.controls.constructor.units.Degrees +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.constructor.units.plus +import space.kscience.controls.constructor.units.times +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.MetaConverter +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToLong +import kotlin.time.DurationUnit + +/** + * A step drive + * + * @param ticksPerSecond ticks per second + * @param target target ticks state + * @param writeTicks a hardware callback + */ +public class StepDrive( + context: Context, + ticksPerSecond: Double, + position: MutableDeviceState<Long> = MutableDeviceState(0), + private val writeTicks: suspend (ticks: Long, speed: Double) -> Unit = { _, _ -> }, +) : DeviceConstructor(context) { + + public val target: MutableDeviceState<Long> by property( + MetaConverter.long, + MutableDeviceState<Long>(position.value) + ) + + public val speed: MutableDeviceState<Double> by property( + MetaConverter.double, + MutableDeviceState<Double>(ticksPerSecond) + ) + + public val position: DeviceState<Long> by property(MetaConverter.long, position) + + //FIXME round to zero problem + private val ticker = onTimer(reads = setOf(target, position), writes = setOf(position)) { prev, next -> + val tickSpeed = speed.value + val timeDelta = (next - prev).toDouble(DurationUnit.SECONDS) + val ticksDelta: Long = target.value - position.value + val steps: Long = when { + ticksDelta > 0 -> min(ticksDelta, (timeDelta * tickSpeed).roundToLong()) + ticksDelta < 0 -> max(ticksDelta, -(timeDelta * tickSpeed).roundToLong()) + else -> return@onTimer + } + writeTicks(steps, tickSpeed) + position.value += steps + } +} + +/** + * Compute a state using given tick-to-angle transformation + */ +public fun StepDrive.angle( + step: NumericalValue<Degrees>, + zero: NumericalValue<Degrees> = NumericalValue(0), +): DeviceState<NumericalValue<Degrees>> = position.map { + zero + it * step +} + diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt new file mode 100644 index 0000000..9b4a15f --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/externalState.kt @@ -0,0 +1,65 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import kotlin.time.Duration + + +private open class ExternalState<T>( + val scope: CoroutineScope, + val readInterval: Duration, + initialValue: T, + val reader: suspend () -> T, +) : DeviceState<T> { + + protected val flow: StateFlow<T> = flow { + while (true) { + delay(readInterval) + emit(reader()) + } + }.stateIn(scope, SharingStarted.Eagerly, initialValue) + + override val value: T get() = flow.value + override val valueFlow: Flow<T> get() = flow + + override fun toString(): String = "ExternalState()" +} + +/** + * Create a [DeviceState] which is constructed by regularly reading external value + */ +public fun <T> DeviceState.Companion.external( + scope: CoroutineScope, + readInterval: Duration, + initialValue: T, + reader: suspend () -> T, +): DeviceState<T> = ExternalState(scope, readInterval, initialValue, reader) + +private class MutableExternalState<T>( + scope: CoroutineScope, + readInterval: Duration, + initialValue: T, + reader: suspend () -> T, + val writer: suspend (T) -> Unit, +) : ExternalState<T>(scope, readInterval, initialValue, reader), MutableDeviceState<T> { + override var value: T + get() = super.value + set(value) { + scope.launch { + writer(value) + } + } +} + +/** + * Create a [MutableDeviceState] which is constructed by regularly reading external value and allows writing + */ +public fun <T> DeviceState.Companion.external( + scope: CoroutineScope, + readInterval: Duration, + initialValue: T, + reader: suspend () -> T, + writer: suspend (T) -> Unit, +): MutableDeviceState<T> = MutableExternalState(scope, readInterval, initialValue, reader, writer) \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt new file mode 100644 index 0000000..34a4910 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/flowState.kt @@ -0,0 +1,20 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + + +private class StateFlowAsState<T>( + val flow: MutableStateFlow<T>, +) : MutableDeviceState<T> { + override var value: T by flow::value + override val valueFlow: Flow<T> get() = flow + + override fun toString(): String = "FlowAsState($value)" +} + +/** + * Create a read-only [DeviceState] that wraps [MutableStateFlow]. + * No data copy is performed. + */ +public fun <T> MutableStateFlow<T>.asDeviceState(): MutableDeviceState<T> = StateFlowAsState(this) \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt new file mode 100644 index 0000000..684f666 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/internalState.kt @@ -0,0 +1,53 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow + +/** + * A [MutableDeviceState] that does not correspond to a physical state + * + * @param callback a synchronous callback that could be used without a scope + */ +private class VirtualDeviceState<T>( + initialValue: T, + private val callback: (T) -> Unit = {} +) : MutableDeviceState<T> { + private val flow = MutableStateFlow(initialValue) + override val valueFlow: Flow<T> get() = flow + + override var value: T + get() = flow.value + set(value) { + flow.value = value + callback(value) + } + + override fun toString(): String = "VirtualDeviceState($value)" +} + + +/** + * A [MutableDeviceState] that does not correspond to a physical state + * + * @param callback a synchronous callback that could be used without a scope + */ +public fun <T> MutableDeviceState( + initialValue: T, + callback: (T) -> Unit = {} +): MutableDeviceState<T> = VirtualDeviceState(initialValue, callback) + + +/** + * Create a [DeviceState] with constant value + */ +public fun <T> DeviceState( + value: T +): DeviceState<T> = object : DeviceState<T> { + override val value: T get() = value + override val valueFlow: Flow<T> + get() = emptyFlow() + + override fun toString(): String = "ConstDeviceState($value)" + +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt new file mode 100644 index 0000000..1259151 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Inertia.kt @@ -0,0 +1,70 @@ +package space.kscience.controls.constructor.models + +import space.kscience.controls.constructor.* +import space.kscience.controls.constructor.units.* +import space.kscience.dataforge.context.Context +import kotlin.math.pow +import kotlin.time.DurationUnit + +/** + * A model for inertial movement. Both linear and angular + */ +public class Inertia<U : UnitsOfMeasurement, V : UnitsOfMeasurement>( + context: Context, + force: DeviceState<Double>, //TODO add system unit sets + inertia: Double, + public val position: MutableDeviceState<NumericalValue<U>>, + public val velocity: MutableDeviceState<NumericalValue<V>>, +) : ModelConstructor(context) { + + init { + registerState(position) + registerState(velocity) + } + + private var currentForce = force.value + + private val movement = onTimer(DefaultTimer.REALTIME) { prev, next -> + val dtSeconds = (next - prev).toDouble(DurationUnit.SECONDS) + + // compute new value based on velocity and acceleration from the previous step + position.value += NumericalValue(velocity.value.value * dtSeconds + currentForce / inertia * dtSeconds.pow(2) / 2) + + // compute new velocity based on acceleration on the previous step + velocity.value += NumericalValue(currentForce / inertia * dtSeconds) + currentForce = force.value + } + + public companion object { + /** + * Linear inertial model with [force] in newtons and [mass] in kilograms + */ + public fun linear( + context: Context, + force: DeviceState<NumericalValue<Newtons>>, + mass: NumericalValue<Kilograms>, + position: MutableDeviceState<NumericalValue<Meters>>, + velocity: MutableDeviceState<NumericalValue<MetersPerSecond>> = MutableDeviceState(NumericalValue(0.0)), + ): Inertia<Meters, MetersPerSecond> = Inertia( + context = context, + force = force.values(), + inertia = mass.value, + position = position, + velocity = velocity + ) + + public fun circular( + context: Context, + force: DeviceState<NumericalValue<NewtonsMeters>>, + momentOfInertia: NumericalValue<KgM2>, + position: MutableDeviceState<NumericalValue<Degrees>>, + velocity: MutableDeviceState<NumericalValue<DegreesPerSecond>> = MutableDeviceState(NumericalValue(0.0)), + ): Inertia<Degrees, DegreesPerSecond> = Inertia( + context = context, + force = force.values(), + inertia = momentOfInertia.value, + position = position, + velocity = velocity + ) + } +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Leadscrew.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Leadscrew.kt new file mode 100644 index 0000000..ebe0d30 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Leadscrew.kt @@ -0,0 +1,31 @@ +package space.kscience.controls.constructor.models + +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.constructor.ModelConstructor +import space.kscience.controls.constructor.map +import space.kscience.controls.constructor.units.* +import space.kscience.dataforge.context.Context +import kotlin.math.PI + +/** + * https://en.wikipedia.org/wiki/Leadscrew + */ +public class Leadscrew( + context: Context, + public val leverage: NumericalValue<Meters>, +) : ModelConstructor(context) { + + public fun torqueToForce( + stateOfTorque: DeviceState<NumericalValue<NewtonsMeters>>, + ): DeviceState<NumericalValue<Newtons>> = DeviceState.map(stateOfTorque) { torque -> + NumericalValue(torque.value / leverage.value ) + } + + public fun degreesToMeters( + stateOfAngle: DeviceState<NumericalValue<Degrees>>, + offset: NumericalValue<Meters> = NumericalValue(0), + ): DeviceState<NumericalValue<Meters>> = DeviceState.map(stateOfAngle) { degrees -> + offset + NumericalValue(degrees.value * 2 * PI / 360 * leverage.value ) + } + +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt new file mode 100644 index 0000000..cc44a85 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/MaterialPoint.kt @@ -0,0 +1,48 @@ +package space.kscience.controls.constructor.models + +import space.kscience.controls.constructor.* +import space.kscience.controls.constructor.units.* +import space.kscience.dataforge.context.Context +import kotlin.math.pow +import kotlin.time.DurationUnit + + +/** + * 3D material point + */ +public class MaterialPoint( + context: Context, + force: DeviceState<XYZ<Newtons>>, + mass: NumericalValue<Kilograms>, + public val position: MutableDeviceState<XYZ<Meters>>, + public val velocity: MutableDeviceState<XYZ<MetersPerSecond>>, +) : ModelConstructor(context) { + + init { + registerState(position) + registerState(velocity) + } + + private var currentForce = force.value + + private val movement = onTimer( + DefaultTimer.REALTIME, + reads = setOf(velocity, position), + writes = setOf(velocity, position) + ) { prev, next -> + val dtSeconds = (next - prev).toDouble(DurationUnit.SECONDS) + + // compute new value based on velocity and acceleration from the previous step + val deltaR = (velocity.value * dtSeconds).cast(Meters) + + (currentForce / mass.value * dtSeconds.pow(2) / 2).cast(Meters) + position.value += deltaR + + // compute new velocity based on acceleration on the previous step + val deltaV = (currentForce / mass.value * dtSeconds).cast(MetersPerSecond) + //TODO apply energy correction + //val work = deltaR.length.value * currentForce.length.value + velocity.value += deltaV + + currentForce = force.value + } +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/PidRegulator.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/PidRegulator.kt new file mode 100644 index 0000000..8c97594 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/PidRegulator.kt @@ -0,0 +1,73 @@ +package space.kscience.controls.constructor.models + +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import space.kscience.controls.constructor.* +import space.kscience.controls.constructor.units.* +import space.kscience.controls.manager.clock +import space.kscience.dataforge.context.Context +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.DurationUnit + + +/** + * Pid regulator parameters + */ +public data class PidParameters( + val kp: Double, + val ki: Double, + val kd: Double, + val timeStep: Duration = 10.milliseconds, +) + +/** + * A PID regulator + * + * @param P units of position values + * @param O units of output values + */ +public class PidRegulator<P : UnitsOfMeasurement, O : UnitsOfMeasurement>( + context: Context, + private val position: DeviceState<NumericalValue<P>>, + public var pidParameters: PidParameters, // TODO expose as property + output: MutableDeviceState<NumericalValue<O>> = MutableDeviceState(NumericalValue(0.0)), + private val convertOutput: (NumericalValue<P>) -> NumericalValue<O> = { NumericalValue(it.value) }, +) : ModelConstructor(context) { + + public val target: MutableDeviceState<NumericalValue<P>> = stateOf(NumericalValue(0.0)) + public val output: MutableDeviceState<NumericalValue<O>> = registerState(output) + + private val updateJob = launch { + var lastPosition: NumericalValue<P> = target.value + + var integral: NumericalValue<P> = NumericalValue(0.0) + + val mutex = Mutex() + + val clock = context.clock + + var lastTime = clock.now() + + while (isActive) { + delay(pidParameters.timeStep) + mutex.withLock { + val realTime = clock.now() + val delta: NumericalValue<P> = target.value - position.value + val dtSeconds = (realTime - lastTime).toDouble(DurationUnit.SECONDS) + integral += delta * dtSeconds + val derivative = (position.value - lastPosition) / dtSeconds + + //set last time and value to new values + lastTime = realTime + lastPosition = position.value + + output.value = + convertOutput(pidParameters.kp * delta + pidParameters.ki * integral + pidParameters.kd * derivative) + } + } + } +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt new file mode 100644 index 0000000..202fa71 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/RangeState.kt @@ -0,0 +1,70 @@ +package space.kscience.controls.constructor.models + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.constructor.MutableDeviceState +import space.kscience.controls.constructor.map +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.constructor.units.UnitsOfMeasurement + +/** + * A state describing a [T] value in the [range] + */ +public open class RangeState<T : Comparable<T>>( + private val input: DeviceState<T>, + public val range: ClosedRange<T>, +) : DeviceState<T> { + + override val valueFlow: Flow<T> get() = input.valueFlow.map { + it.coerceIn(range) + } + + override val value: T get() = input.value.coerceIn(range) + + /** + * A state showing that the range is on its lower boundary + */ + public val atStart: DeviceState<Boolean> = input.map { it <= range.start } + + /** + * A state showing that the range is on its higher boundary + */ + public val atEnd: DeviceState<Boolean> = input.map { it >= range.endInclusive } + + override fun toString(): String = "DoubleRangeState(value=${value},range=$range)" +} + +public class MutableRangeState<T : Comparable<T>>( + private val mutableInput: MutableDeviceState<T>, + range: ClosedRange<T>, +) : RangeState<T>(mutableInput, range), MutableDeviceState<T> { + override var value: T + get() = super.value + set(value) { + mutableInput.value = value.coerceIn(range) + } +} + +public fun <T : Comparable<T>> MutableRangeState( + initialValue: T, + range: ClosedRange<T>, +): MutableRangeState<T> = MutableRangeState<T>(MutableDeviceState(initialValue), range) + +public fun <U : UnitsOfMeasurement> MutableRangeState( + initialValue: Double, + range: ClosedRange<Double>, +): MutableRangeState<NumericalValue<U>> = MutableRangeState( + initialValue = NumericalValue(initialValue), + range = NumericalValue<U>(range.start)..NumericalValue<U>(range.endInclusive) +) + + +public fun <T : Comparable<T>> DeviceState<T>.coerceIn( + range: ClosedRange<T>, +): RangeState<T> = RangeState(this, range) + + +public fun <T : Comparable<T>> MutableDeviceState<T>.coerceIn( + range: ClosedRange<T>, +): MutableRangeState<T> = MutableRangeState(this, range) diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Reducer.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Reducer.kt new file mode 100644 index 0000000..caba5ff --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/models/Reducer.kt @@ -0,0 +1,25 @@ +package space.kscience.controls.constructor.models + +import space.kscience.controls.constructor.* +import space.kscience.controls.constructor.units.Degrees +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.constructor.units.times +import space.kscience.dataforge.context.Context + +/** + * A reducer device used for simulations only (no public properties) + */ +public class Reducer( + context: Context, + public val ratio: Double, + public val input: DeviceState<NumericalValue<Degrees>>, + public val output: MutableDeviceState<NumericalValue<Degrees>>, +) : ModelConstructor(context) { + init { + registerState(input) + registerState(output) + transformTo(input, output) { + it * ratio + } + } +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/Direction.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/Direction.kt new file mode 100644 index 0000000..3685c7e --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/Direction.kt @@ -0,0 +1,6 @@ +package space.kscience.controls.constructor.units + +public enum class Direction(public val coef: Int) { + UP(1), + DOWN(-1) +} \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt new file mode 100644 index 0000000..8e77001 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/NumericalValue.kt @@ -0,0 +1,60 @@ +package space.kscience.controls.constructor.units + +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.meta.double +import kotlin.jvm.JvmInline + + +/** + * A value without identity coupled to units of measurements. + */ +@JvmInline +public value class NumericalValue<U : UnitsOfMeasurement>(public val value: Double) : Comparable<NumericalValue<U>> { + override fun compareTo(other: NumericalValue<U>): Int = value.compareTo(other.value) + +} + +public fun <U : UnitsOfMeasurement> NumericalValue( + number: Number, +): NumericalValue<U> = NumericalValue(number.toDouble()) + +public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.plus( + other: NumericalValue<U>, +): NumericalValue<U> = NumericalValue(this.value + other.value) + +public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.minus( + other: NumericalValue<U>, +): NumericalValue<U> = NumericalValue(this.value - other.value) + +public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.times( + c: Number, +): NumericalValue<U> = NumericalValue(this.value * c.toDouble()) + +public operator fun <U : UnitsOfMeasurement> Number.times( + numericalValue: NumericalValue<U>, +): NumericalValue<U> = NumericalValue(numericalValue.value * toDouble()) + +public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.times( + c: Double, +): NumericalValue<U> = NumericalValue(this.value * c) + +public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.div( + c: Number, +): NumericalValue<U> = NumericalValue(this.value / c.toDouble()) + +public operator fun <U : UnitsOfMeasurement> NumericalValue<U>.div(other: NumericalValue<U>): Double = + value / other.value + +public operator fun <U: UnitsOfMeasurement> NumericalValue<U>.unaryMinus(): NumericalValue<U> = NumericalValue(-value) + + +private object NumericalValueMetaConverter : MetaConverter<NumericalValue<*>> { + override fun convert(obj: NumericalValue<*>): Meta = Meta(obj.value) + + override fun readOrNull(source: Meta): NumericalValue<*>? = source.double?.let { NumericalValue<Nothing>(it) } +} + +@Suppress("UNCHECKED_CAST") +public fun <U : UnitsOfMeasurement> MetaConverter.Companion.numerical(): MetaConverter<NumericalValue<U>> = + NumericalValueMetaConverter as MetaConverter<NumericalValue<U>> \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt new file mode 100644 index 0000000..1264423 --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/UnitsOfMeasurement.kt @@ -0,0 +1,60 @@ +package space.kscience.controls.constructor.units + + +public interface UnitsOfMeasurement + +/**/ + +public interface UnitsOfLength : UnitsOfMeasurement + +public data object Meters : UnitsOfLength + +/**/ + +public interface UnitsOfTime : UnitsOfMeasurement + +public data object Seconds : UnitsOfTime + +/**/ + +public interface UnitsOfVelocity : UnitsOfMeasurement + +public data object MetersPerSecond : UnitsOfVelocity + +/**/ + +public sealed interface UnitsOfAngles : UnitsOfMeasurement + +public data object Radians : UnitsOfAngles +public data object Degrees : UnitsOfAngles + +/**/ + +public sealed interface UnitsAngularOfVelocity : UnitsOfMeasurement + +public data object RadiansPerSecond : UnitsAngularOfVelocity + +public data object DegreesPerSecond : UnitsAngularOfVelocity + +/**/ +public interface UnitsOfForce: UnitsOfMeasurement + +public data object Newtons: UnitsOfForce + +/**/ + +public interface UnitsOfTorque: UnitsOfMeasurement + +public data object NewtonsMeters: UnitsOfTorque + +/**/ + +public interface UnitsOfMass: UnitsOfMeasurement + +public data object Kilograms : UnitsOfMass + +/**/ + +public interface UnitsOfMomentOfInertia: UnitsOfMeasurement + +public data object KgM2: UnitsOfMomentOfInertia \ No newline at end of file diff --git a/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt new file mode 100644 index 0000000..027dbbb --- /dev/null +++ b/controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/units/coordinates.kt @@ -0,0 +1,44 @@ +package space.kscience.controls.constructor.units + +import kotlin.math.pow +import kotlin.math.sqrt + +public data class XY<U : UnitsOfMeasurement>(val x: NumericalValue<U>, val y: NumericalValue<U>) + +public fun <U : UnitsOfMeasurement> XY(x: Number, y: Number): XY<U> = XY(NumericalValue(x), NumericalValue((y))) + +public operator fun <U : UnitsOfMeasurement> XY<U>.plus(other: XY<U>): XY<U> = + XY(x + other.x, y + other.y) + +public operator fun <U : UnitsOfMeasurement> XY<U>.times(c: Number): XY<U> = XY(x * c, y * c) +public operator fun <U : UnitsOfMeasurement> XY<U>.div(c: Number): XY<U> = XY(x / c, y / c) + +public operator fun <U : UnitsOfMeasurement> XY<U>.unaryMinus(): XY<U> = XY(-x, -y) + +public data class XYZ<U : UnitsOfMeasurement>( + val x: NumericalValue<U>, + val y: NumericalValue<U>, + val z: NumericalValue<U>, +) + +public val <U : UnitsOfMeasurement> XYZ<U>.length: NumericalValue<U> + get() = NumericalValue( + sqrt(x.value.pow(2) + y.value.pow(2) + z.value.pow(2)) + ) + +public fun <U : UnitsOfMeasurement> XYZ(x: Number, y: Number, z: Number): XYZ<U> = + XYZ(NumericalValue(x), NumericalValue((y)), NumericalValue(z)) + +@Suppress("UNCHECKED_CAST", "UNUSED_PARAMETER") +public fun <U : UnitsOfMeasurement, R : UnitsOfMeasurement> XYZ<U>.cast(units: R): XYZ<R> = this as XYZ<R> + +public operator fun <U : UnitsOfMeasurement> XYZ<U>.plus(other: XYZ<U>): XYZ<U> = + XYZ(x + other.x, y + other.y, z + other.z) + +public operator fun <U : UnitsOfMeasurement> XYZ<U>.minus(other: XYZ<U>): XYZ<U> = + XYZ(x - other.x, y - other.y, z - other.z) + +public operator fun <U : UnitsOfMeasurement> XYZ<U>.times(c: Number): XYZ<U> = XYZ(x * c, y * c, z * c) +public operator fun <U : UnitsOfMeasurement> XYZ<U>.div(c: Number): XYZ<U> = XYZ(x / c, y / c, z / c) + +public operator fun <U : UnitsOfMeasurement> XYZ<U>.unaryMinus(): XYZ<U> = XYZ(-x, -y, -z) \ No newline at end of file diff --git a/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt new file mode 100644 index 0000000..aa46133 --- /dev/null +++ b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/DeviceGroupTest.kt @@ -0,0 +1,43 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import space.kscience.controls.api.Device +import space.kscience.controls.api.DeviceLifeCycleMessage +import space.kscience.controls.api.LifecycleState +import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.manager.install +import space.kscience.controls.spec.doRecurring +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.context.Global +import space.kscience.dataforge.context.request +import space.kscience.dataforge.meta.Meta +import kotlin.test.Test +import kotlin.time.Duration.Companion.milliseconds + +class DeviceGroupTest { + + class TestDevice(context: Context) : DeviceConstructor(context) { + + companion object : Factory<Device> { + override fun build(context: Context, meta: Meta): Device = TestDevice(context) + } + } + + @Test + fun testRecurringRead() = runTest { + var counter = 10 + val testDevice = Global.request(DeviceManager).install("test", TestDevice) + testDevice.doRecurring(1.milliseconds) { + counter-- + println(counter) + if (counter <= 0) { + testDevice.stop() + } + error("Error!") + } + testDevice.messageFlow.first { it is DeviceLifeCycleMessage && it.state == LifecycleState.STOPPED } + println("stopped") + } +} \ No newline at end of file diff --git a/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt new file mode 100644 index 0000000..38f7d83 --- /dev/null +++ b/controls-constructor/src/commonTest/kotlin/space/kscience/controls/constructor/TimerTest.kt @@ -0,0 +1,23 @@ +package space.kscience.controls.constructor + +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.test.runTest +import space.kscience.controls.manager.ClockManager +import space.kscience.dataforge.context.Global +import space.kscience.dataforge.context.request +import kotlin.test.Test +import kotlin.time.Duration.Companion.milliseconds + +class TimerTest { + + @Test + fun timer() = runTest { + val timer = TimerState(Global.request(ClockManager), 10.milliseconds) + timer.valueFlow.take(100).onEach { + println(it) + }.collect() + + } +} \ No newline at end of file diff --git a/controls-core/README.md b/controls-core/README.md index b75961d..f7bc0bd 100644 --- a/controls-core/README.md +++ b/controls-core/README.md @@ -16,18 +16,16 @@ Core interfaces for building a device server ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-core:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-core:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-core:0.2.0") + implementation("space.kscience:controls-core:0.4.0-dev-7") } ``` diff --git a/controls-core/build.gradle.kts b/controls-core/build.gradle.kts index bbe32eb..868cdd0 100644 --- a/controls-core/build.gradle.kts +++ b/controls-core/build.gradle.kts @@ -9,21 +9,24 @@ description = """ Core interfaces for building a device server """.trimIndent() -val dataforgeVersion: String by rootProject.extra - kscience { jvm() js() native() + wasm() useCoroutines() useSerialization{ json() } useContextReceivers() - dependencies { - api("space.kscience:dataforge-io:$dataforgeVersion") + commonMain { + api(libs.dataforge.io) api(spclibs.kotlinx.datetime) } + + jvmTest{ + implementation(spclibs.logback.classic) + } } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt new file mode 100644 index 0000000..7c56084 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/AsynchronousSocket.kt @@ -0,0 +1,30 @@ +package space.kscience.controls.api + +import kotlinx.coroutines.flow.Flow + +/** + * A generic bidirectional asynchronous sender/receiver object + */ +public interface AsynchronousSocket<T> : WithLifeCycle { + /** + * Send an object to the socket + */ + public suspend fun send(data: T) + + /** + * Flow of objects received from socket + */ + public fun subscribe(): Flow<T> +} + +/** + * Connect an input to this socket. + * Multiple inputs could be connected to the same [AsynchronousSocket]. + * + * This method suspends indefinitely, so it should be started in a separate coroutine. + */ +public suspend fun <T> AsynchronousSocket<T>.sendFlow(flow: Flow<T>) { + flow.collect { send(it) } +} + + diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt index 4fc6365..1e941bb 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt @@ -3,41 +3,31 @@ package space.kscience.controls.api import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.* import space.kscience.controls.api.Device.Companion.DEVICE_TARGET import space.kscience.dataforge.context.ContextAware import space.kscience.dataforge.context.info import space.kscience.dataforge.context.logger import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.misc.DFExperimental -import space.kscience.dataforge.misc.Type -import space.kscience.dataforge.names.Name - -/** - * A lifecycle state of a device - */ -public enum class DeviceLifecycleState{ - INIT, - OPEN, - CLOSED -} +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.string +import space.kscience.dataforge.misc.DfType +import space.kscience.dataforge.names.parseAsName /** * General interface describing a managed Device. * [Device] is a supervisor scope encompassing all operations on a device. * When canceled, cancels all running processes. */ -@Type(DEVICE_TARGET) -public interface Device : AutoCloseable, ContextAware, CoroutineScope { +@DfType(DEVICE_TARGET) +public interface Device : ContextAware, WithLifeCycle, CoroutineScope { /** * Initial configuration meta for the device */ public val meta: Meta get() = Meta.EMPTY + /** * List of supported property descriptors */ @@ -54,18 +44,6 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope { */ public suspend fun readProperty(propertyName: String): Meta - /** - * Get the logical state of property or return null if it is invalid - */ - public fun getProperty(propertyName: String): Meta? - - /** - * Invalidate property (set logical state to invalid) - * - * This message is suspended to provide lock-free local property changes (they require coroutine context). - */ - public suspend fun invalidate(propertyName: String) - /** * Set property [value] for a property with name [propertyName]. * In rare cases could suspend if the [Device] supports command queue, and it is full at the moment. @@ -85,44 +63,86 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope { public suspend fun execute(actionName: String, argument: Meta? = null): Meta? /** - * Initialize the device. This function suspends until the device is finished initialization + * Initialize the device. This function suspends until the device is finished initialization. + * Does nothing if the device is started or is starting */ - public suspend fun open(): Unit = Unit + override suspend fun start(): Unit = Unit /** * Close and terminate the device. This function does not wait for the device to be closed. */ - override fun close() { + override suspend fun stop() { + coroutineContext[Job]?.cancel("The device is closed") logger.info { "Device $this is closed" } - cancel("The device is closed") } - @DFExperimental - public val lifecycleState: DeviceLifecycleState - public companion object { public const val DEVICE_TARGET: String = "device" } } +/** + * Inner id of a device. Not necessary corresponds to the name in the parent container + */ +public val Device.id: String get() = meta["id"].string ?: "device[${hashCode().toString(16)}]" + +/** + * Device that caches properties values + */ +public interface CachingDevice : Device { + + /** + * Immediately (without waiting) get the cached (logical) state of property or return null if it is invalid + */ + public fun getProperty(propertyName: String): Meta? + + /** + * Invalidate property (set logical state to invalid). + * + * This message is suspended to provide lock-free local property changes (they require coroutine context). + */ + public suspend fun invalidate(propertyName: String) +} + /** * Get the logical state of property or suspend to read the physical value. */ -public suspend fun Device.getOrReadProperty(propertyName: String): Meta = +public suspend fun Device.getOrReadProperty(propertyName: String): Meta = if (this is CachingDevice) { getProperty(propertyName) ?: readProperty(propertyName) +} else { + readProperty(propertyName) +} /** * Get a snapshot of the device logical state * */ -public fun Device.getAllProperties(): Meta = Meta { +public fun CachingDevice.getAllProperties(): Meta = Meta { for (descriptor in propertyDescriptors) { - setMeta(Name.parse(descriptor.name), getProperty(descriptor.name)) + set(descriptor.name.parseAsName(), getProperty(descriptor.name)) } } /** * Subscribe on property changes for the whole device */ -public fun Device.onPropertyChange(callback: suspend PropertyChangedMessage.() -> Unit): Job = - messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(this) +public fun Device.onPropertyChange( + scope: CoroutineScope = this, + callback: suspend PropertyChangedMessage.() -> Unit, +): Job = messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(scope) + +/** + * A [Flow] of property change messages for specific property. + */ +public fun Device.propertyMessageFlow(propertyName: String): Flow<PropertyChangedMessage> = messageFlow + .filterIsInstance<PropertyChangedMessage>() + .filter { it.property == propertyName } + +/** + * React on device lifecycle events + */ +public fun Device.onLifecycleEvent( + block: suspend (LifecycleState) -> Unit +): Job = messageFlow.filterIsInstance<DeviceLifeCycleMessage>().onEach { + block(it.state) +}.launchIn(this) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt index 8bd7452..4aef3ff 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceHub.kt @@ -1,73 +1,62 @@ package space.kscience.controls.api import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.names.* +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.provider.Path import space.kscience.dataforge.provider.Provider +import space.kscience.dataforge.provider.asPath +import space.kscience.dataforge.provider.plus /** * A hub that could locate multiple devices and redirect actions to them */ public interface DeviceHub : Provider { - public val devices: Map<NameToken, Device> + public val devices: Map<Name, Device> override val defaultTarget: String get() = Device.DEVICE_TARGET override val defaultChainTarget: String get() = Device.DEVICE_TARGET override fun content(target: String): Map<Name, Any> = if (target == Device.DEVICE_TARGET) { - buildMap { - fun putAll(prefix: Name, hub: DeviceHub) { - hub.devices.forEach { - put(prefix + it.key, it.value) - } - } - - devices.forEach { - val name = it.key.asName() - put(name, it.value) - (it.value as? DeviceHub)?.let { hub -> - putAll(name, hub) - } - } - } + devices } else { emptyMap() } + //TODO send message on device change public companion object } -public operator fun DeviceHub.get(nameToken: NameToken): Device = - devices[nameToken] ?: error("Device with name $nameToken not found in $this") - -public fun DeviceHub.getOrNull(name: Name): Device? = when { - name.isEmpty() -> this as? Device - name.length == 1 -> get(name.firstOrNull()!!) - else -> (get(name.firstOrNull()!!) as? DeviceHub)?.getOrNull(name.cutFirst()) +public fun DeviceHub(deviceMap: Map<Name, Device>): DeviceHub = object : DeviceHub { + override val devices: Map<Name, Device> + get() = deviceMap } -public operator fun DeviceHub.get(name: Name): Device = - getOrNull(name) ?: error("Device with name $name not found in $this") +/** + * List all devices, including sub-devices + */ +public fun DeviceHub.provideAllDevices(): Map<Path, Device> = buildMap { + fun putAll(prefix: Path, hub: DeviceHub) { + hub.devices.forEach { + put(prefix + it.key.asPath(), it.value) + } + } -public fun DeviceHub.getOrNull(nameString: String): Device? = getOrNull(Name.parse(nameString)) - -public operator fun DeviceHub.get(nameString: String): Device = - getOrNull(nameString) ?: error("Device with name $nameString not found in $this") + devices.forEach { + val name: Name = it.key + put(name.asPath(), it.value) + (it.value as? DeviceHub)?.let { hub -> + putAll(name.asPath(), hub) + } + } +} public suspend fun DeviceHub.readProperty(deviceName: Name, propertyName: String): Meta = - this[deviceName].readProperty(propertyName) + (devices[deviceName] ?: error("Device with name $deviceName not found in $this")).readProperty(propertyName) public suspend fun DeviceHub.writeProperty(deviceName: Name, propertyName: String, value: Meta) { - this[deviceName].writeProperty(propertyName, value) + (devices[deviceName] ?: error("Device with name $deviceName not found in $this")).writeProperty(propertyName, value) } public suspend fun DeviceHub.execute(deviceName: Name, command: String, argument: Meta?): Meta? = - this[deviceName].execute(command, argument) - - -//suspend fun DeviceHub.respond(request: Envelope): EnvelopeBuilder { -// val target = request.meta[DeviceMessage.TARGET_KEY].string ?: defaultTarget -// val device = this[target.toName()] -// -// return device.respond(device, target, request) -//} \ No newline at end of file + (devices[deviceName] ?: error("Device with name $deviceName not found in $this")).execute(command, argument) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt index 45d7f0b..e93fe7d 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/DeviceMessage.kt @@ -22,10 +22,10 @@ public sealed class DeviceMessage { public abstract val sourceDevice: Name? public abstract val targetDevice: Name? public abstract val comment: String? - public abstract val time: Instant? + public abstract val time: Instant /** - * Update the source device name for composition. If the original name is null, resulting name is also null. + * Update the source device name for composition. If the original name is null, the resulting name is also null. */ public abstract fun changeSource(block: (Name) -> Name): DeviceMessage @@ -59,7 +59,7 @@ public data class PropertyChangedMessage( override val sourceDevice: Name = Name.EMPTY, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } @@ -71,11 +71,11 @@ public data class PropertyChangedMessage( @SerialName("property.set") public data class PropertySetMessage( public val property: String, - public val value: Meta?, + public val value: Meta, override val sourceDevice: Name? = null, - override val targetDevice: Name, + override val targetDevice: Name?, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) } @@ -91,7 +91,7 @@ public data class PropertyGetMessage( override val sourceDevice: Name? = null, override val targetDevice: Name, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) } @@ -103,9 +103,9 @@ public data class PropertyGetMessage( @SerialName("description.get") public data class GetDescriptionMessage( override val sourceDevice: Name? = null, - override val targetDevice: Name, + override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) } @@ -122,7 +122,7 @@ public data class DescriptionMessage( override val sourceDevice: Name, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } @@ -141,7 +141,7 @@ public data class ActionExecuteMessage( override val sourceDevice: Name? = null, override val targetDevice: Name, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) } @@ -160,22 +160,28 @@ public data class ActionResultMessage( override val sourceDevice: Name, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } /** - * Notifies listeners that a new binary with given [binaryID] is available. The binary itself could not be provided via [DeviceMessage] API. + * Notifies listeners that a new binary with given [contentId] and [contentMeta] is available. + * + * [contentMeta] includes public information that could be shared with loop subscribers. It should not contain sensitive data. + * + * The binary itself could not be provided via [DeviceMessage] API. + * [space.kscience.controls.peer.PeerConnection] must be used instead */ @Serializable @SerialName("binary.notification") public data class BinaryNotificationMessage( - val binaryID: String, + val contentId: String, + val contentMeta: Meta, override val sourceDevice: Name, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } @@ -190,7 +196,7 @@ public data class EmptyDeviceMessage( override val sourceDevice: Name? = null, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) } @@ -203,12 +209,12 @@ public data class EmptyDeviceMessage( public data class DeviceLogMessage( val message: String, val data: Meta? = null, - override val sourceDevice: Name? = null, + override val sourceDevice: Name = Name.EMPTY, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { - override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block)) + override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } /** @@ -220,10 +226,25 @@ public data class DeviceErrorMessage( public val errorMessage: String?, public val errorType: String? = null, public val errorStackTrace: String? = null, - override val sourceDevice: Name, + override val sourceDevice: Name = Name.EMPTY, override val targetDevice: Name? = null, override val comment: String? = null, - @EncodeDefault override val time: Instant? = Clock.System.now(), + @EncodeDefault override val time: Instant = Clock.System.now(), +) : DeviceMessage() { + override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) +} + +/** + * Device [Device.lifecycleState] is changed + */ +@Serializable +@SerialName("lifecycle") +public data class DeviceLifeCycleMessage( + val state: LifecycleState, + override val sourceDevice: Name = Name.EMPTY, + override val targetDevice: Name? = null, + override val comment: String? = null, + @EncodeDefault override val time: Instant = Clock.System.now(), ) : DeviceMessage() { override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice)) } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt deleted file mode 100644 index 02598ba..0000000 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Socket.kt +++ /dev/null @@ -1,33 +0,0 @@ -package space.kscience.controls.api - -import io.ktor.utils.io.core.Closeable -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.launch - -/** - * A generic bidirectional sender/receiver object - */ -public interface Socket<T> : Closeable { - /** - * Send an object to the socket - */ - public suspend fun send(data: T) - - /** - * Flow of objects received from socket - */ - public fun receiving(): Flow<T> - public fun isOpen(): Boolean -} - -/** - * Connect an input to this socket using designated [scope] for it and return a handler [Job]. - * Multiple inputs could be connected to the same [Socket]. - */ -public fun <T> Socket<T>.connectInput(scope: CoroutineScope, flow: Flow<T>): Job = scope.launch { - flow.collect { send(it) } -} - - diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/WithLifeCycle.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/WithLifeCycle.kt new file mode 100644 index 0000000..631a66d --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/WithLifeCycle.kt @@ -0,0 +1,59 @@ +package space.kscience.controls.api + +import kotlinx.serialization.Serializable + +/** + * A lifecycle state of a device + */ +@Serializable +public enum class LifecycleState { + + /** + * Device is initializing + */ + STARTING, + + /** + * The Device is initialized and running + */ + STARTED, + + /** + * The Device is closed + */ + STOPPED, + + /** + * The device encountered irrecoverable error + */ + ERROR +} + + +/** + * An object that could be started or stopped functioning + */ +public interface WithLifeCycle { + + public suspend fun start() + + public suspend fun stop() + + public val lifecycleState: LifecycleState +} + +/** + * Bind this object lifecycle to a device lifecycle + * + * The starting and stopping are done in device scope + */ +public fun WithLifeCycle.bindToDeviceLifecycle(device: Device){ + device.onLifecycleEvent { + when(it){ + LifecycleState.STARTING -> start() + LifecycleState.STARTED -> {/*ignore*/} + LifecycleState.STOPPED -> stop() + LifecycleState.ERROR -> stop() + } + } +} \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt index 8e1705b..f3ffa93 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/descriptors.kt @@ -12,21 +12,26 @@ import space.kscience.dataforge.meta.descriptors.MetaDescriptorBuilder @Serializable public class PropertyDescriptor( public val name: String, - public var info: String? = null, + public var description: String? = null, public var metaDescriptor: MetaDescriptor = MetaDescriptor(), public var readable: Boolean = true, - public var writable: Boolean = false + public var mutable: Boolean = false, ) -public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Unit){ - metaDescriptor = MetaDescriptor(block) +public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.() -> Unit) { + metaDescriptor = MetaDescriptor { + from(metaDescriptor) + block() + } } /** * A descriptor for property */ @Serializable -public class ActionDescriptor(public val name: String) { - public var info: String? = null -} - +public class ActionDescriptor( + public val name: String, + public var description: String? = null, + public var inputMetaDescriptor: MetaDescriptor = MetaDescriptor(), + public var outputMetaDescriptor: MetaDescriptor = MetaDescriptor() +) diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt new file mode 100644 index 0000000..4204730 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/ClockManager.kt @@ -0,0 +1,109 @@ +package space.kscience.controls.manager + +import kotlinx.coroutines.* +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import space.kscience.controls.api.Device +import space.kscience.dataforge.context.* +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.double +import kotlin.coroutines.CoroutineContext +import kotlin.math.roundToLong +import kotlin.time.Duration + +@OptIn(InternalCoroutinesApi::class) +private class CompressedTimeDispatcher( + val clockManager: ClockManager, + val dispatcher: CoroutineDispatcher, + val compression: Double, +) : CoroutineDispatcher(), Delay { + + @InternalCoroutinesApi + override fun dispatchYield(context: CoroutineContext, block: Runnable) { + dispatcher.dispatchYield(context, block) + } + + override fun isDispatchNeeded(context: CoroutineContext): Boolean = dispatcher.isDispatchNeeded(context) + + @ExperimentalCoroutinesApi + override fun limitedParallelism(parallelism: Int): CoroutineDispatcher = dispatcher.limitedParallelism(parallelism) + + override fun dispatch(context: CoroutineContext, block: Runnable) { + dispatcher.dispatch(context, block) + } + + private val delay = ((dispatcher as? Delay) ?: (Dispatchers.Default as Delay)) + + override fun scheduleResumeAfterDelay(timeMillis: Long, continuation: CancellableContinuation<Unit>) { + delay.scheduleResumeAfterDelay((timeMillis / compression).roundToLong(), continuation) + } + + + override fun invokeOnTimeout(timeMillis: Long, block: Runnable, context: CoroutineContext): DisposableHandle { + return delay.invokeOnTimeout((timeMillis / compression).roundToLong(), block, context) + } +} + +private class CompressedClock( + val start: Instant, + val compression: Double, + val baseClock: Clock = Clock.System, +) : Clock { + override fun now(): Instant { + val elapsed = (baseClock.now() - start) + return start + elapsed / compression + } +} + +public class ClockManager : AbstractPlugin() { + override val tag: PluginTag get() = Companion.tag + + public val timeCompression: Double by meta.double(1.0) + + public val clock: Clock by lazy { + if (timeCompression == 1.0) { + Clock.System + } else { + CompressedClock(Clock.System.now(), timeCompression) + } + } + + /** + * Provide a [CoroutineDispatcher] with compressed time based on given [dispatcher] + */ + public fun asDispatcher( + dispatcher: CoroutineDispatcher = Dispatchers.Default, + ): CoroutineDispatcher = if (timeCompression == 1.0) { + dispatcher + } else { + CompressedTimeDispatcher(this, dispatcher, timeCompression) + } + + public fun scheduleWithFixedDelay(tick: Duration, block: suspend () -> Unit): Job = context.launch(asDispatcher()) { + while (isActive) { + delay(tick) + block() + } + } + + + public companion object : PluginFactory<ClockManager> { + override val tag: PluginTag = PluginTag("clock", group = PluginTag.DATAFORGE_GROUP) + + override fun build(context: Context, meta: Meta): ClockManager = ClockManager() + } +} + +public val Context.clock: Clock get() = plugins[ClockManager]?.clock ?: Clock.System + +public val Device.clock: Clock get() = context.clock + +public fun Device.getCoroutineDispatcher(dispatcher: CoroutineDispatcher = Dispatchers.Default): CoroutineDispatcher = + context.plugins[ClockManager]?.asDispatcher(dispatcher) ?: dispatcher + +public fun ContextBuilder.withTimeCompression(compression: Double) { + require(compression > 0.0) { "Time compression must be greater than zero." } + plugin(ClockManager) { + "timeCompression" put compression + } +} \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt index cc043c7..689cb90 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/DeviceManager.kt @@ -3,12 +3,13 @@ package space.kscience.controls.manager import kotlinx.coroutines.launch import space.kscience.controls.api.Device import space.kscience.controls.api.DeviceHub -import space.kscience.controls.api.getOrNull +import space.kscience.controls.api.id import space.kscience.dataforge.context.* import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.meta.MutableMeta import space.kscience.dataforge.names.Name -import space.kscience.dataforge.names.NameToken +import space.kscience.dataforge.names.get +import space.kscience.dataforge.names.parseAsName import kotlin.collections.set import kotlin.properties.ReadOnlyProperty @@ -21,11 +22,11 @@ public class DeviceManager : AbstractPlugin(), DeviceHub { /** * Actual list of connected devices */ - private val top = HashMap<NameToken, Device>() - override val devices: Map<NameToken, Device> get() = top + private val _devices = HashMap<Name, Device>() + override val devices: Map<Name, Device> get() = _devices - public fun registerDevice(name: NameToken, device: Device) { - top[name] = device + public fun registerDevice(name: Name, device: Device) { + _devices[name] = device } override fun content(target: String): Map<Name, Any> = super<DeviceHub>.content(target) @@ -38,13 +39,19 @@ public class DeviceManager : AbstractPlugin(), DeviceHub { } public fun <D : Device> DeviceManager.install(name: String, device: D): D { - registerDevice(NameToken(name), device) + registerDevice(name.parseAsName(), device) device.launch { - device.open() + device.start() } return device } +public fun <D : Device> DeviceManager.install(device: D): D = install(device.id, device) + + +public fun <D : Device> Context.install(name: String, device: D): D = request(DeviceManager).install(name, device) + +public fun <D : Device> Context.install(device: D): D = request(DeviceManager).install(device.id, device) /** * Register and start a device built by [factory] with current [Context] and [meta]. @@ -62,7 +69,7 @@ public inline fun <D : Device> DeviceManager.installing( val meta = Meta(builder) return ReadOnlyProperty { _, property -> val name = property.name - val current = getOrNull(name) + val current = devices[name] if (current == null) { install(name, factory, meta) } else if (current.meta != meta) { @@ -72,5 +79,4 @@ public inline fun <D : Device> DeviceManager.installing( current as D } } -} - +} \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt index 13d072c..3988c3c 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/manager/respondMessage.kt @@ -1,10 +1,9 @@ package space.kscience.controls.manager -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge import space.kscience.controls.api.* import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.plus @@ -24,11 +23,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess } is PropertySetMessage -> { - if (request.value == null) { - invalidate(request.property) - } else { - writeProperty(request.property, request.value) - } + writeProperty(request.property, request.value) PropertyChangedMessage( property = request.property, value = getOrReadProperty(request.property), @@ -64,6 +59,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess is DeviceErrorMessage, is EmptyDeviceMessage, is DeviceLogMessage, + is DeviceLifeCycleMessage, -> null } } catch (ex: Exception) { @@ -71,42 +67,41 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess } /** - * Process incoming [DeviceMessage], using hub naming to evaluate target. + * Process incoming [DeviceMessage], using hub naming to find target. + * If the `targetDevice` is `null`, then the message is sent to each device in this hub */ -public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): DeviceMessage? { +public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List<DeviceMessage> { return try { - val targetName = request.targetDevice ?: return null - val device = getOrNull(targetName) ?: error("The device with name $targetName not found in $this") - device.respondMessage(targetName, request) + val targetName = request.targetDevice + if (targetName == null) { + devices.mapNotNull { + it.value.respondMessage(it.key, request) + } + } else { + val device = devices[targetName] ?: error("The device with name $targetName not found in $this") + listOfNotNull(device.respondMessage(targetName, request)) + } } catch (ex: Exception) { - DeviceMessage.error(ex, sourceDevice = Name.EMPTY, targetDevice = request.sourceDevice) + listOf(DeviceMessage.error(ex, sourceDevice = Name.EMPTY, targetDevice = request.sourceDevice)) } } /** * Collect all messages from given [DeviceHub], applying proper relative names. */ -public fun DeviceHub.hubMessageFlow(scope: CoroutineScope): Flow<DeviceMessage> { - - //TODO could we avoid using downstream scope? - val outbox = MutableSharedFlow<DeviceMessage>() - if (this is Device) { - messageFlow.onEach { - outbox.emit(it) - }.launchIn(scope) - } - //TODO maybe better create map of all devices to limit copying - devices.forEach { (token, childDevice) -> - val flow = if (childDevice is DeviceHub) { - childDevice.hubMessageFlow(scope) +public fun DeviceHub.hubMessageFlow(): Flow<DeviceMessage> { + + val deviceMessageFlow = if (this is Device) messageFlow else emptyFlow() + + val childrenFlows = devices.map { (token, childDevice) -> + if (childDevice is DeviceHub) { + childDevice.hubMessageFlow() } else { childDevice.messageFlow + }.map { deviceMessage -> + deviceMessage.changeSource { token + it } } - flow.onEach { deviceMessage -> - outbox.emit( - deviceMessage.changeSource { token + it } - ) - }.launchIn(scope) } - return outbox + + return merge(deviceMessageFlow, *childrenFlows.toTypedArray()) } \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt new file mode 100644 index 0000000..092d9bb --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/PropertyHistory.kt @@ -0,0 +1,70 @@ +package space.kscience.controls.misc + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import space.kscience.controls.api.Device +import space.kscience.controls.api.DeviceMessage +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.name +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.names.Name + +/** + * An interface for device property history. + */ +public interface PropertyHistory<T> { + /** + * Flow property values filtered by a time range. The implementation could flow it as a chunk or provide paging. + * So the resulting flow is allowed to suspend. + * + * If [until] is in the future, the resulting flow is potentially unlimited. + * Theoretically, it could be also unlimited if the event source keeps producing new event with timestamp in a given range. + */ + public fun flowHistory( + from: Instant = Instant.DISTANT_PAST, + until: Instant = Clock.System.now(), + ): Flow<ValueWithTime<T>> +} + +/** + * An in-memory property values history collector + */ +public class CollectedPropertyHistory<T>( + public val scope: CoroutineScope, + eventFlow: Flow<DeviceMessage>, + public val deviceName: Name, + public val propertyName: String, + public val converter: MetaConverter<T>, + maxSize: Int = 1000, +) : PropertyHistory<T> { + + private val store: SharedFlow<ValueWithTime<T>> = eventFlow + .filterIsInstance<PropertyChangedMessage>() + .filter { it.sourceDevice == deviceName && it.property == propertyName } + .map { ValueWithTime(converter.read(it.value), it.time) } + .shareIn(scope, started = SharingStarted.Eagerly, replay = maxSize) + + override fun flowHistory(from: Instant, until: Instant): Flow<ValueWithTime<T>> = + store.filter { it.time in from..until } +} + +/** + * Collect and store in memory device property changes for a given property + */ +public fun <T> Device.collectPropertyHistory( + scope: CoroutineScope = this, + deviceName: Name, + propertyName: String, + converter: MetaConverter<T>, + maxSize: Int = 1000, +): PropertyHistory<T> = CollectedPropertyHistory(scope, messageFlow, deviceName, propertyName, converter, maxSize) + +public fun <D : Device, T> D.collectPropertyHistory( + scope: CoroutineScope = this, + deviceName: Name, + spec: DevicePropertySpec<D, T>, + maxSize: Int = 1000, +): PropertyHistory<T> = collectPropertyHistory(scope, deviceName, spec.name, spec.converter, maxSize) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt new file mode 100644 index 0000000..f25c4f4 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/ValueWithTime.kt @@ -0,0 +1,69 @@ +package space.kscience.controls.misc + +import kotlinx.datetime.Instant +import kotlinx.io.Sink +import kotlinx.io.Source +import space.kscience.dataforge.io.IOFormat +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.meta.get + +/** + * A value coupled to a time it was obtained at + */ +public data class ValueWithTime<T>(val value: T, val time: Instant) { + public companion object { + /** + * Create a [ValueWithTime] format for given value value [IOFormat] + */ + public fun <T> ioFormat( + valueFormat: IOFormat<T>, + ): IOFormat<ValueWithTime<T>> = ValueWithTimeIOFormat(valueFormat) + + /** + * Create a [MetaConverter] with time for given value [MetaConverter] + */ + public fun <T> metaConverter( + valueConverter: MetaConverter<T>, + ): MetaConverter<ValueWithTime<T>> = ValueWithTimeMetaConverter(valueConverter) + + + public const val META_TIME_KEY: String = "time" + public const val META_VALUE_KEY: String = "value" + } +} + +private class ValueWithTimeIOFormat<T>(val valueFormat: IOFormat<T>) : IOFormat<ValueWithTime<T>> { + + override fun readFrom(source: Source): ValueWithTime<T> { + val timestamp = InstantIOFormat.readFrom(source) + val value = valueFormat.readFrom(source) + return ValueWithTime(value, timestamp) + } + + override fun writeTo(sink: Sink, obj: ValueWithTime<T>) { + InstantIOFormat.writeTo(sink, obj.time) + valueFormat.writeTo(sink, obj.value) + } + +} + +private class ValueWithTimeMetaConverter<T>( + val valueConverter: MetaConverter<T>, +) : MetaConverter<ValueWithTime<T>> { + + + override fun readOrNull( + source: Meta, + ): ValueWithTime<T>? = valueConverter.read(source[ValueWithTime.META_VALUE_KEY] ?: Meta.EMPTY)?.let { + ValueWithTime(it, source[ValueWithTime.META_TIME_KEY]?.instant ?: Instant.DISTANT_PAST) + } + + override fun convert(obj: ValueWithTime<T>): Meta = Meta { + ValueWithTime.META_TIME_KEY put obj.time.toMeta() + ValueWithTime.META_VALUE_KEY put valueConverter.convert(obj.value) + } +} + + +public fun <T : Any> MetaConverter<T>.withTime(): MetaConverter<ValueWithTime<T>> = ValueWithTimeMetaConverter(this) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt new file mode 100644 index 0000000..c200758 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/converters.kt @@ -0,0 +1,62 @@ +package space.kscience.controls.misc + +import kotlinx.datetime.Instant +import space.kscience.dataforge.meta.* +import kotlin.time.Duration +import kotlin.time.DurationUnit +import kotlin.time.toDuration + +public fun Double.asMeta(): Meta = Meta(asValue()) + +/** + * Generate a nullable [MetaConverter] from non-nullable one + */ +public fun <T : Any> MetaConverter<T>.nullable(): MetaConverter<T?> = object : MetaConverter<T?> { + override fun convert(obj: T?): Meta = obj?.let { this@nullable.convert(it) } ?: Meta(Null) + + override fun readOrNull(source: Meta): T? = if (source.value == Null) null else this@nullable.readOrNull(source) + +} + +//TODO to be moved to DF +private object DurationConverter : MetaConverter<Duration> { + override fun readOrNull(source: Meta): Duration = source.value?.double?.toDuration(DurationUnit.SECONDS) + ?: run { + val unit: DurationUnit = source["unit"].enum<DurationUnit>() ?: DurationUnit.SECONDS + val value = source[Meta.VALUE_KEY].double ?: error("No value present for Duration") + return@run value.toDuration(unit) + } + + override fun convert(obj: Duration): Meta = obj.toDouble(DurationUnit.SECONDS).asMeta() +} + +public val MetaConverter.Companion.duration: MetaConverter<Duration> get() = DurationConverter + + +private object InstantConverter : MetaConverter<Instant> { + override fun readOrNull(source: Meta): Instant? = source.string?.let { Instant.parse(it) } + override fun convert(obj: Instant): Meta = Meta(obj.toString()) +} + +public val MetaConverter.Companion.instant: MetaConverter<Instant> get() = InstantConverter + +private object DoubleRangeConverter : MetaConverter<ClosedFloatingPointRange<Double>> { + override fun readOrNull(source: Meta): ClosedFloatingPointRange<Double>? = + source.value?.doubleArray?.let { (start, end) -> + start..end + } + + override fun convert( + obj: ClosedFloatingPointRange<Double>, + ): Meta = Meta(doubleArrayOf(obj.start, obj.endInclusive).asValue()) +} + +public val MetaConverter.Companion.doubleRange: MetaConverter<ClosedFloatingPointRange<Double>> get() = DoubleRangeConverter + +private object StringListConverter : MetaConverter<List<String>> { + override fun convert(obj: List<String>): Meta = Meta(obj.map { it.asValue() }.asValue()) + + override fun readOrNull(source: Meta): List<String>? = source.stringList ?: source["@jsonArray"]?.stringList +} + +public val MetaConverter.Companion.stringList: MetaConverter<List<String>> get() = StringListConverter diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt new file mode 100644 index 0000000..8686c67 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeIO.kt @@ -0,0 +1,42 @@ +package space.kscience.controls.misc + + +import kotlinx.datetime.Instant +import kotlinx.io.Sink +import kotlinx.io.Source +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.io.IOFormat +import space.kscience.dataforge.io.IOFormatFactory +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.string +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName +import kotlin.reflect.KType +import kotlin.reflect.typeOf + + +/** + * An [IOFormat] for [Instant] + */ +public object InstantIOFormat : IOFormat<Instant>, IOFormatFactory<Instant> { + override fun build(context: Context, meta: Meta): IOFormat<Instant> = this + + override val name: Name = "instant".asName() + + override val type: KType get() = typeOf<Instant>() + + override fun writeTo(sink: Sink, obj: Instant) { + sink.writeLong(obj.epochSeconds) + sink.writeInt(obj.nanosecondsOfSecond) + } + + override fun readFrom(source: Source): Instant { + val seconds = source.readLong() + val nanoseconds = source.readInt() + return Instant.fromEpochSeconds(seconds, nanoseconds) + } +} + +public fun Instant.toMeta(): Meta = Meta(toString()) + +public val Meta.instant: Instant? get() = value?.string?.let { Instant.parse(it) } \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt deleted file mode 100644 index 11683d9..0000000 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/misc/timeMeta.kt +++ /dev/null @@ -1,18 +0,0 @@ -package space.kscience.controls.misc - -import kotlinx.datetime.Instant -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.get -import space.kscience.dataforge.meta.long - -// TODO move to core - -public fun Instant.toMeta(): Meta = Meta { - "seconds" put epochSeconds - "nanos" put nanosecondsOfSecond -} - -public fun Meta.instant(): Instant = value?.long?.let { Instant.fromEpochMilliseconds(it) } ?: Instant.fromEpochSeconds( - get("seconds")?.long ?: 0L, - get("nanos")?.long ?: 0L, -) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt new file mode 100644 index 0000000..55624b7 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/peer/PeerConnection.kt @@ -0,0 +1,39 @@ +package space.kscience.controls.peer + +import space.kscience.dataforge.io.Envelope +import space.kscience.dataforge.meta.Meta + +/** + * A manager that allows direct synchronous sending and receiving binary data + */ +public interface PeerConnection { + /** + * Receive an [Envelope] from a device on a given [address] with given [contentId]. + * + * The address depends on the specifics of given [PeerConnection]. For example, it could be a TCP/IP port or + * magix endpoint name. + * + * Depending on [PeerConnection] implementation, the resulting [Envelope] could be lazy loaded + * + * Additional metadata in [requestMeta] could be required for authentication. + */ + public suspend fun receive( + address: String, + contentId: String, + requestMeta: Meta = Meta.EMPTY, + ): Envelope? + + /** + * Send an [envelope] to a device on a given [address] + * + * The address depends on the specifics of given [PeerConnection]. For example, it could be a TCP/IP port or + * magix endpoint name. + * + * Additional metadata in [requestMeta] could be required for authentication. + */ + public suspend fun send( + address: String, + envelope: Envelope, + requestMeta: Meta = Meta.EMPTY, + ) +} \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt new file mode 100644 index 0000000..29502f9 --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/AsynchronousPort.kt @@ -0,0 +1,116 @@ +package space.kscience.controls.ports + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.io.Source +import space.kscience.controls.api.AsynchronousSocket +import space.kscience.controls.api.LifecycleState +import space.kscience.dataforge.context.* +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.meta.string +import kotlin.coroutines.CoroutineContext + +/** + * Raw [ByteArray] port + */ +public interface AsynchronousPort : ContextAware, AsynchronousSocket<ByteArray> + +/** + * Capture [AsynchronousPort] output as kotlinx-io [Source]. + * [scope] controls the consummation. + * If the scope is canceled, the source stops producing. + */ +public fun AsynchronousPort.receiveAsSource(scope: CoroutineScope): Source = subscribe().consumeAsSource(scope) + + +/** + * Common abstraction for [AsynchronousPort] based on [Channel] + */ +public abstract class AbstractAsynchronousPort( + override val context: Context, + public val meta: Meta, + coroutineContext: CoroutineContext = context.coroutineContext, +) : AsynchronousPort { + + + protected val scope: CoroutineScope by lazy { + CoroutineScope( + coroutineContext + + SupervisorJob(coroutineContext[Job]) + + CoroutineExceptionHandler { _, throwable -> logger.error(throwable) { "Asynchronous port error: " + throwable.stackTraceToString() } } + + CoroutineName(toString()) + ) + } + + private val outgoing = Channel<ByteArray>(meta["outgoing.capacity"].int ?: 100) + private val incoming = Channel<ByteArray>(meta["incoming.capacity"].int ?: 100) + + /** + * Internal method to synchronously send data + */ + protected abstract suspend fun write(data: ByteArray) + + /** + * Internal method to receive data synchronously + */ + protected suspend fun receive(data: ByteArray) { + logger.debug { "$this RECEIVED: ${data.decodeToString()}" } + incoming.send(data) + } + + private var sendJob: Job? = null + + protected abstract fun onOpen() + + final override suspend fun start() { + if (lifecycleState == LifecycleState.STOPPED) { + sendJob = scope.launch { + for (data in outgoing) { + try { + write(data) + logger.debug { "${this@AbstractAsynchronousPort} SENT: ${data.decodeToString()}" } + } catch (ex: Exception) { + if (ex is CancellationException) throw ex + logger.error(ex) { "Error while writing data to the port" } + } + } + } + onOpen() + } else { + logger.warn { "$this already started" } + } + } + + + /** + * Send a data packet via the port + */ + override suspend fun send(data: ByteArray) { + check(lifecycleState == LifecycleState.STARTED) { "The port is not opened" } + outgoing.send(data) + } + + /** + * Raw flow of incoming data chunks. The chunks are not guaranteed to be complete phrases. + * To form phrases, some condition should be used on top of it. + * For example [stringsDelimitedIncoming] generates phrases with fixed delimiter. + */ + override fun subscribe(): Flow<ByteArray> = incoming.receiveAsFlow() + + override suspend fun stop() { + outgoing.close() + incoming.close() + sendJob?.cancel() + } + + override fun toString(): String = meta["name"].string ?: "ChannelPort[${hashCode().toString(16)}]" +} + +/** + * Send UTF-8 encoded string + */ +public suspend fun AsynchronousPort.send(string: String): Unit = send(string.encodeToByteArray()) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt deleted file mode 100644 index 1f07251..0000000 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Port.kt +++ /dev/null @@ -1,100 +0,0 @@ -package space.kscience.controls.ports - -import kotlinx.coroutines.* -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.receiveAsFlow -import space.kscience.controls.api.Socket -import space.kscience.dataforge.context.* -import space.kscience.dataforge.misc.Type -import kotlin.coroutines.CoroutineContext - -/** - * Raw [ByteArray] port - */ -public interface Port : ContextAware, Socket<ByteArray> - -/** - * A specialized factory for [Port] - */ -@Type(PortFactory.TYPE) -public interface PortFactory : Factory<Port> { - public val type: String - - public companion object { - public const val TYPE: String = "controls.port" - } -} - -/** - * Common abstraction for [Port] based on [Channel] - */ -public abstract class AbstractPort( - override val context: Context, - coroutineContext: CoroutineContext = context.coroutineContext, -) : Port { - - protected val scope: CoroutineScope = CoroutineScope(coroutineContext + SupervisorJob(coroutineContext[Job])) - - private val outgoing = Channel<ByteArray>(100) - private val incoming = Channel<ByteArray>(Channel.CONFLATED) - - init { - scope.coroutineContext[Job]?.invokeOnCompletion { - close() - } - } - - /** - * Internal method to synchronously send data - */ - protected abstract suspend fun write(data: ByteArray) - - /** - * Internal method to receive data synchronously - */ - protected suspend fun receive(data: ByteArray) { - logger.debug { "${this@AbstractPort} RECEIVED: ${data.decodeToString()}" } - incoming.send(data) - } - - private val sendJob = scope.launch { - for (data in outgoing) { - try { - write(data) - logger.debug { "${this@AbstractPort} SENT: ${data.decodeToString()}" } - } catch (ex: Exception) { - if (ex is CancellationException) throw ex - logger.error(ex) { "Error while writing data to the port" } - } - } - } - - /** - * Send a data packet via the port - */ - override suspend fun send(data: ByteArray) { - outgoing.send(data) - } - - /** - * Raw flow of incoming data chunks. The chunks are not guaranteed to be complete phrases. - * In order to form phrases, some condition should be used on top of it. - * For example [stringsDelimitedIncoming] generates phrases with fixed delimiter. - */ - override fun receiving(): Flow<ByteArray> = incoming.receiveAsFlow() - - override fun close() { - outgoing.close() - incoming.close() - sendJob.cancel() - scope.cancel() - } - - override fun isOpen(): Boolean = scope.isActive -} - -/** - * Send UTF-8 encoded string - */ -public suspend fun Port.send(string: String): Unit = send(string.encodeToByteArray()) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/PortProxy.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/PortProxy.kt deleted file mode 100644 index 4e51f6f..0000000 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/PortProxy.kt +++ /dev/null @@ -1,64 +0,0 @@ -package space.kscience.controls.ports - -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import space.kscience.dataforge.context.* - -/** - * A port that could be closed multiple times and opens automatically on request - */ -public class PortProxy(override val context: Context = Global, public val factory: suspend () -> Port) : Port, ContextAware { - - private var actualPort: Port? = null - private val mutex: Mutex = Mutex() - - private suspend fun port(): Port { - return mutex.withLock { - if (actualPort?.isOpen() == true) { - actualPort!! - } else { - factory().also { - actualPort = it - } - } - } - } - - override suspend fun send(data: ByteArray) { - port().send(data) - } - - @OptIn(ExperimentalCoroutinesApi::class) - override fun receiving(): Flow<ByteArray> = flow { - while (true) { - try { - //recreate port and Flow on connection problems - port().receiving().collect { - emit(it) - } - } catch (t: Throwable) { - logger.warn{"Port read failed: ${t.message}. Reconnecting."} - mutex.withLock { - actualPort?.close() - actualPort = null - } - } - } - } - - // open by default - override fun isOpen(): Boolean = true - - override fun close() { - context.launch { - mutex.withLock { - actualPort?.close() - actualPort = null - } - } - } -} \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt index 3d01e62..565caee 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/Ports.kt @@ -11,26 +11,43 @@ public class Ports : AbstractPlugin() { override val tag: PluginTag get() = Companion.tag - private val portFactories by lazy { - context.gather<PortFactory>(PortFactory.TYPE) + private val synchronousPortFactories by lazy { + context.gather<Factory<SynchronousPort>>(SYNCHRONOUS_PORT_TYPE) } - private val portCache = mutableMapOf<Meta, Port>() + private val asynchronousPortFactories by lazy { + context.gather<Factory<AsynchronousPort>>(ASYNCHRONOUS_PORT_TYPE) + } /** - * Create a new [Port] according to specification + * Create a new [AsynchronousPort] according to specification */ - public fun buildPort(meta: Meta): Port = portCache.getOrPut(meta) { + public fun buildAsynchronousPort(meta: Meta): AsynchronousPort { val type by meta.string { error("Port type is not defined") } - val factory = portFactories.values.firstOrNull { it.type == type } + val factory = asynchronousPortFactories.entries + .firstOrNull { it.key.toString() == type }?.value ?: error("Port factory for type $type not found") - factory.build(context, meta) + return factory.build(context, meta) + } + + /** + * Create a [SynchronousPort] according to specification or wrap an asynchronous implementation + */ + public fun buildSynchronousPort(meta: Meta): SynchronousPort { + val type by meta.string { error("Port type is not defined") } + val factory = synchronousPortFactories.entries + .firstOrNull { it.key.toString() == type }?.value + ?: return buildAsynchronousPort(meta).asSynchronousPort() + return factory.build(context, meta) } public companion object : PluginFactory<Ports> { override val tag: PluginTag = PluginTag("controls.ports", group = PluginTag.DATAFORGE_GROUP) + public const val ASYNCHRONOUS_PORT_TYPE: String = "controls.asynchronousPort" + public const val SYNCHRONOUS_PORT_TYPE: String = "controls.synchronousPort" + override fun build(context: Context, meta: Meta): Ports = Ports() } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt index 0ed4764..8427760 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/SynchronousPort.kt @@ -1,28 +1,103 @@ package space.kscience.controls.ports -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.io.Buffer +import kotlinx.io.Source +import kotlinx.io.readByteArray +import space.kscience.controls.api.LifecycleState +import space.kscience.controls.api.WithLifeCycle +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.ContextAware /** - * A port handler for synchronous (request-response) communication with a port. Only one request could be active at a time (others are suspended. - * The handler does not guarantee exclusive access to the port so the user mush ensure that no other controller handles port at the moment. + * A port handler for synchronous (request-response) communication with a port. + * Only one request could be active at a time (others are suspended). */ -public class SynchronousPort(public val port: Port, private val mutex: Mutex) : Port by port { +public interface SynchronousPort : ContextAware, WithLifeCycle { + /** - * Send a single message and wait for the flow of respond messages. + * Send a single message and wait for the flow of response chunks. + * The consumer is responsible for calling a terminal operation on the flow. */ - public suspend fun <R> respond(data: ByteArray, transform: suspend Flow<ByteArray>.() -> R): R = mutex.withLock { - port.send(data) - transform(port.receiving()) + public suspend fun <R> respond( + request: ByteArray, + transform: suspend Flow<ByteArray>.() -> R, + ): R + + /** + * Synchronously read fixed size response to a given [request]. Discard additional response bytes. + */ + public suspend fun respondFixedMessageSize( + request: ByteArray, + responseSize: Int, + ): ByteArray = respond(request) { + val buffer = Buffer() + takeWhile { + buffer.size < responseSize + }.collect { + buffer.write(it) + } + buffer.readByteArray(responseSize) } } /** - * Provide a synchronous wrapper for a port + * Read response to a given message using [Source] abstraction */ -public fun Port.synchronous(mutex: Mutex = Mutex()): SynchronousPort = SynchronousPort(this, mutex) +public suspend fun <R> SynchronousPort.respondAsSource( + request: ByteArray, + transform: suspend Source.() -> R, +): R = respond(request) { + //suspend until the response is fully read + coroutineScope { + val buffer = Buffer() + val collectJob = onEach { buffer.write(it) }.launchIn(this) + val res = transform(buffer) + //cancel collection when the result is achieved + collectJob.cancel() + res + } +} + +private class SynchronousOverAsynchronousPort( + val port: AsynchronousPort, + val mutex: Mutex, +) : SynchronousPort { + + override val context: Context get() = port.context + + override suspend fun start() { + if (port.lifecycleState == LifecycleState.STOPPED) port.start() + } + + override val lifecycleState: LifecycleState get() = port.lifecycleState + + override suspend fun stop() { + if (port.lifecycleState == LifecycleState.STARTED) port.stop() + } + + override suspend fun <R> respond( + request: ByteArray, + transform: suspend Flow<ByteArray>.() -> R, + ): R = mutex.withLock { + port.send(request) + transform(port.subscribe()) + } +} + + +/** + * Provide a synchronous wrapper for an asynchronous port. + * Optionally provide external [mutex] for operation synchronization. + * + * If the [AsynchronousPort] is called directly, it could violate [SynchronousPort] contract + * of only one request running simultaneously. + */ +public fun AsynchronousPort.asSynchronousPort(mutex: Mutex = Mutex()): SynchronousPort = + SynchronousOverAsynchronousPort(this, mutex) /** * Send request and read incoming data blocks until the delimiter is encountered diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt new file mode 100644 index 0000000..5d7d3bf --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/ioExtensions.kt @@ -0,0 +1,24 @@ +package space.kscience.controls.ports + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.io.Buffer +import kotlinx.io.Source +import space.kscience.dataforge.io.Binary + +public fun Binary.readShort(position: Int): Short = read(position) { readShort() } + +/** + * Consume given flow of [ByteArray] as [Source]. The subscription is canceled when [scope] is closed. + */ +public fun Flow<ByteArray>.consumeAsSource(scope: CoroutineScope): Source { + val buffer = Buffer() + //subscription is canceled when the scope is canceled + onEach { + buffer.write(it) + }.launchIn(scope) + + return buffer +} \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt index 1214d01..89bee8a 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/ports/phrases.kt @@ -1,21 +1,27 @@ package space.kscience.controls.ports -import io.ktor.utils.io.core.BytePacketBuilder -import io.ktor.utils.io.core.readBytes -import io.ktor.utils.io.core.reset import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.transform +import kotlinx.io.Buffer +import kotlinx.io.readByteArray /** * Transform byte fragments into complete phrases using given delimiter. Not thread safe. + * + * TODO add type wrapper for phrases */ public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray> { require(delimiter.isNotEmpty()) { "Delimiter must not be empty" } - val output = BytePacketBuilder() + val output = Buffer() var matcherPosition = 0 + onCompletion { + output.close() + } + return transform { chunk -> chunk.forEach { byte -> output.writeByte(byte) @@ -24,9 +30,8 @@ public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray> matcherPosition++ if (matcherPosition == delimiter.size) { //full match achieved, sending result - val bytes = output.build() - emit(bytes.readBytes()) - output.reset() + emit(output.readByteArray()) + output.clear() matcherPosition = 0 } } else if (matcherPosition > 0) { @@ -37,6 +42,31 @@ public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray> } } +private fun Flow<ByteArray>.withFixedMessageSize(messageSize: Int): Flow<ByteArray> { + require(messageSize > 0) { "Message size should be positive" } + + val output = Buffer() + + onCompletion { + output.close() + } + + return transform { chunk -> + val remaining: Int = (messageSize - output.size).toInt() + if (chunk.size >= remaining) { + output.write(chunk, endIndex = remaining) + emit(output.readByteArray()) + output.clear() + //write the remaining chunk fragment + if(chunk.size> remaining) { + output.write(chunk, startIndex = remaining) + } + } else { + output.write(chunk) + } + } +} + /** * Transform byte fragments into utf-8 phrases using utf-8 delimiter */ @@ -47,9 +77,9 @@ public fun Flow<ByteArray>.withStringDelimiter(delimiter: String): Flow<String> /** * A flow of delimited phrases */ -public fun Port.delimitedIncoming(delimiter: ByteArray): Flow<ByteArray> = receiving().withDelimiter(delimiter) +public fun AsynchronousPort.delimitedIncoming(delimiter: ByteArray): Flow<ByteArray> = subscribe().withDelimiter(delimiter) /** * A flow of delimited phrases with string content */ -public fun Port.stringsDelimitedIncoming(delimiter: String): Flow<String> = receiving().withStringDelimiter(delimiter) +public fun AsynchronousPort.stringsDelimitedIncoming(delimiter: String): Flow<String> = subscribe().withStringDelimiter(delimiter) diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt index 56f5aa9..a060f46 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt @@ -1,36 +1,44 @@ package space.kscience.controls.spec import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import space.kscience.controls.api.* import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.debug import space.kscience.dataforge.context.error import space.kscience.dataforge.context.logger import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.misc.DFExperimental +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.int import kotlin.coroutines.CoroutineContext - +/** + * Write a meta [item] to [device] + */ @OptIn(InternalDeviceAPI::class) -private suspend fun <D : Device, T> WritableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) { - write(device, converter.metaToObject(item) ?: error("Meta $item could not be read with $converter")) +private suspend fun <D : Device, T> MutableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) { + write(device, converter.readOrNull(item) ?: error("Meta $item could not be read with $converter")) } +/** + * Read Meta item from the [device] + */ @OptIn(InternalDeviceAPI::class) private suspend fun <D : Device, T> DevicePropertySpec<D, T>.readMeta(device: D): Meta? = - read(device)?.let(converter::objectToMeta) + read(device)?.let(converter::convert) private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta( device: D, item: Meta, ): Meta? { - val arg: I = inputConverter.metaToObject(item) ?: error("Failed to convert $item with $inputConverter") + val arg: I = inputConverter.readOrNull(item) ?: error("Failed to convert $item with $inputConverter") val res = execute(device, arg) - return res?.let { outputConverter.objectToMeta(res) } + return res?.let { outputConverter.convert(res) } } @@ -39,8 +47,8 @@ private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta */ public abstract class DeviceBase<D : Device>( final override val context: Context, - override val meta: Meta = Meta.EMPTY, -) : Device { + final override val meta: Meta = Meta.EMPTY, +) : CachingDevice { /** * Collection of property specifications @@ -58,15 +66,28 @@ public abstract class DeviceBase<D : Device>( override val actionDescriptors: Collection<ActionDescriptor> get() = actions.values.map { it.descriptor } - override val coroutineContext: CoroutineContext by lazy { - context.newCoroutineContext( - SupervisorJob(context.coroutineContext[Job]) + - CoroutineName("Device $this") + - CoroutineExceptionHandler { _, throwable -> - logger.error(throwable) { "Exception in device $this job" } + + private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow( + replay = meta["message.buffer"].int ?: 1000, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + + override val coroutineContext: CoroutineContext = context.newCoroutineContext( + SupervisorJob(context.coroutineContext[Job]) + + CoroutineName("Device $id") + + CoroutineExceptionHandler { _, throwable -> + launch { + sharedMessageFlow.emit( + DeviceErrorMessage( + errorMessage = throwable.message, + errorType = throwable::class.simpleName, + errorStackTrace = throwable.stackTraceToString() + ) + ) } - ) - } + logger.error(throwable) { "Exception in device $id" } + } + ) /** @@ -74,8 +95,6 @@ public abstract class DeviceBase<D : Device>( */ private val logicalState: HashMap<String, Meta?> = HashMap() - private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow() - public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow @Suppress("UNCHECKED_CAST") @@ -87,7 +106,7 @@ public abstract class DeviceBase<D : Device>( /** * Update logical property state and notify listeners */ - protected suspend fun updateLogical(propertyName: String, value: Meta?) { + protected suspend fun propertyChanged(propertyName: String, value: Meta?) { if (value != logicalState[propertyName]) { stateLock.withLock { logicalState[propertyName] = value @@ -99,10 +118,10 @@ public abstract class DeviceBase<D : Device>( } /** - * Update logical state using given [spec] and its convertor + * Notify the device that a property with [spec] value is changed */ - public suspend fun <T> updateLogical(spec: DevicePropertySpec<D, T>, value: T) { - updateLogical(spec.name, spec.converter.objectToMeta(value)) + protected suspend fun <T> propertyChanged(spec: DevicePropertySpec<D, T>, value: T) { + propertyChanged(spec.name, spec.converter.convert(value)) } /** @@ -112,7 +131,7 @@ public abstract class DeviceBase<D : Device>( override suspend fun readProperty(propertyName: String): Meta { val spec = properties[propertyName] ?: error("Property with name $propertyName not found") val meta = spec.readMeta(self) ?: error("Failed to read property $propertyName") - updateLogical(propertyName, meta) + propertyChanged(propertyName, meta) return meta } @@ -122,7 +141,7 @@ public abstract class DeviceBase<D : Device>( public suspend fun readPropertyOrNull(propertyName: String): Meta? { val spec = properties[propertyName] ?: return null val meta = spec.readMeta(self) ?: return null - updateLogical(propertyName, meta) + propertyChanged(propertyName, meta) return meta } @@ -135,15 +154,26 @@ public abstract class DeviceBase<D : Device>( } override suspend fun writeProperty(propertyName: String, value: Meta): Unit { + //bypass property setting if it already has that value + if (logicalState[propertyName] == value) { + logger.debug { "Skipping setting $propertyName to $value because value is already set" } + return + } when (val property = properties[propertyName]) { null -> { - //If there is a physical property with a given name, invalidate logical property and write physical one - updateLogical(propertyName, value) + //If there are no registered physical properties with given name, write a logical one. + propertyChanged(propertyName, value) } - is WritableDevicePropertySpec -> { + is MutableDevicePropertySpec -> { + //if there is a writeable property with a given name, invalidate logical and write physical invalidate(propertyName) property.writeMeta(self, value) + // perform read after writing if the writer did not set the value and the value is still in invalid state + if (logicalState[propertyName] == null) { + val meta = property.readMeta(self) + propertyChanged(propertyName, meta) + } } else -> { @@ -157,22 +187,43 @@ public abstract class DeviceBase<D : Device>( return spec.executeWithMeta(self, argument ?: Meta.EMPTY) } - @DFExperimental - override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.INIT - protected set + final override var lifecycleState: LifecycleState = LifecycleState.STOPPED + private set - @OptIn(DFExperimental::class) - override suspend fun open() { - super.open() - lifecycleState = DeviceLifecycleState.OPEN + + private suspend fun setLifecycleState(lifecycleState: LifecycleState) { + this.lifecycleState = lifecycleState + sharedMessageFlow.emit( + DeviceLifeCycleMessage(lifecycleState) + ) } - @OptIn(DFExperimental::class) - override fun close() { - lifecycleState = DeviceLifecycleState.CLOSED - super.close() + protected open suspend fun onStart() { + } + final override suspend fun start() { + if (lifecycleState == LifecycleState.STOPPED) { + super.start() + setLifecycleState(LifecycleState.STARTING) + onStart() + setLifecycleState(LifecycleState.STARTED) + } else { + logger.debug { "Device $this is already started" } + } + } + + protected open suspend fun onStop() { + + } + + final override suspend fun stop() { + onStop() + setLifecycleState(LifecycleState.STOPPED) + super.stop() + } + + abstract override fun toString(): String } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt index 9309224..f639fc7 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBySpec.kt @@ -16,15 +16,14 @@ public open class DeviceBySpec<D : Device>( override val properties: Map<String, DevicePropertySpec<D, *>> get() = spec.properties override val actions: Map<String, DeviceActionSpec<D, *, *>> get() = spec.actions - override suspend fun open(): Unit = with(spec) { - super.open() + override suspend fun onStart(): Unit = with(spec) { self.onOpen() } - override fun close(): Unit = with(spec) { + override suspend fun onStop(): Unit = with(spec){ self.onClose() - super.close() } + override fun toString(): String = "Device(spec=$spec)" } \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt index 809d940..c5fe8b5 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceMetaPropertySpec.kt @@ -3,9 +3,9 @@ package space.kscience.controls.spec import space.kscience.controls.api.Device import space.kscience.controls.api.PropertyDescriptor import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.dataforge.meta.MetaConverter -internal object DeviceMetaPropertySpec: DevicePropertySpec<Device,Meta> { +internal object DeviceMetaPropertySpec : DevicePropertySpec<Device, Meta> { override val descriptor: PropertyDescriptor = PropertyDescriptor("@meta") override val converter: MetaConverter<Meta> = MetaConverter.meta diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt index cf8c741..edf9f93 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt @@ -4,11 +4,8 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch -import space.kscience.controls.api.ActionDescriptor -import space.kscience.controls.api.Device -import space.kscience.controls.api.PropertyChangedMessage -import space.kscience.controls.api.PropertyDescriptor -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.controls.api.* +import space.kscience.dataforge.meta.MetaConverter /** @@ -20,7 +17,7 @@ public annotation class InternalDeviceAPI /** * Specification for a device read-only property */ -public interface DevicePropertySpec<in D : Device, T> { +public interface DevicePropertySpec<in D, T> { /** * Property descriptor */ @@ -44,7 +41,7 @@ public interface DevicePropertySpec<in D : Device, T> { public val DevicePropertySpec<*, *>.name: String get() = descriptor.name -public interface WritableDevicePropertySpec<in D : Device, T> : DevicePropertySpec<D, T> { +public interface MutableDevicePropertySpec<in D : Device, T> : DevicePropertySpec<D, T> { /** * Write physical value to a device */ @@ -53,7 +50,7 @@ public interface WritableDevicePropertySpec<in D : Device, T> : DevicePropertySp } -public interface DeviceActionSpec<in D : Device, I, O> { +public interface DeviceActionSpec<in D, I, O> { /** * Action descriptor */ @@ -75,30 +72,29 @@ public interface DeviceActionSpec<in D : Device, I, O> { public val DeviceActionSpec<*, *, *>.name: String get() = descriptor.name public suspend fun <T, D : Device> D.read(propertySpec: DevicePropertySpec<D, T>): T = - propertySpec.converter.metaToObject(readProperty(propertySpec.name)) ?: error("Property read result is not valid") + propertySpec.converter.readOrNull(readProperty(propertySpec.name)) ?: error("Property read result is not valid") /** * Read typed value and update/push event if needed. * Return null if property read is not successful or property is undefined. */ public suspend fun <T, D : DeviceBase<D>> D.readOrNull(propertySpec: DevicePropertySpec<D, T>): T? = - readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::metaToObject) + readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::readOrNull) - -public operator fun <T, D : Device> D.get(propertySpec: DevicePropertySpec<D, T>): T? = - getProperty(propertySpec.name)?.let(propertySpec.converter::metaToObject) +public suspend fun <T, D : Device> D.getOrRead(propertySpec: DevicePropertySpec<D, T>): T = + propertySpec.converter.read(getOrReadProperty(propertySpec.name)) /** * Write typed property state and invalidate logical state */ -public suspend fun <T, D : Device> D.write(propertySpec: WritableDevicePropertySpec<D, T>, value: T) { - writeProperty(propertySpec.name, propertySpec.converter.objectToMeta(value)) +public suspend fun <T, D : Device> D.write(propertySpec: MutableDevicePropertySpec<D, T>, value: T) { + writeProperty(propertySpec.name, propertySpec.converter.convert(value)) } /** * Fire and forget variant of property writing. Actual write is performed asynchronously on a [Device] scope */ -public operator fun <T, D : Device> D.set(propertySpec: WritableDevicePropertySpec<D, T>, value: T): Job = launch { +public fun <T, D : Device> D.writeAsync(propertySpec: MutableDevicePropertySpec<D, T>, value: T): Job = launch { write(propertySpec, value) } @@ -108,37 +104,39 @@ public operator fun <T, D : Device> D.set(propertySpec: WritableDevicePropertySp public fun <D : Device, T> D.propertyFlow(spec: DevicePropertySpec<D, T>): Flow<T> = messageFlow .filterIsInstance<PropertyChangedMessage>() .filter { it.property == spec.name } - .mapNotNull { spec.converter.metaToObject(it.value) } + .mapNotNull { spec.converter.read(it.value) } /** * A type safe property change listener. Uses the device [CoroutineScope]. */ public fun <D : Device, T> D.onPropertyChange( spec: DevicePropertySpec<D, T>, + scope: CoroutineScope = this, callback: suspend PropertyChangedMessage.(T) -> Unit, ): Job = messageFlow .filterIsInstance<PropertyChangedMessage>() .filter { it.property == spec.name } .onEach { change -> - val newValue = spec.converter.metaToObject(change.value) + val newValue = spec.converter.read(change.value) if (newValue != null) { change.callback(newValue) } - }.launchIn(this) + }.launchIn(scope) /** * Call [callback] on initial property value and each value change */ public fun <D : Device, T> D.useProperty( spec: DevicePropertySpec<D, T>, + scope: CoroutineScope = this, callback: suspend (T) -> Unit, -): Job = launch { +): Job = scope.launch { callback(read(spec)) messageFlow .filterIsInstance<PropertyChangedMessage>() .filter { it.property == spec.name } .collect { change -> - val newValue = spec.converter.metaToObject(change.value) + val newValue = spec.converter.readOrNull(change.value) if (newValue != null) { callback(newValue) } @@ -149,7 +147,7 @@ public fun <D : Device, T> D.useProperty( /** * Reset the logical state of a property */ -public suspend fun <D : Device> D.invalidate(propertySpec: DevicePropertySpec<D, *>) { +public suspend fun <D : CachingDevice> D.invalidate(propertySpec: DevicePropertySpec<D, *>) { invalidate(propertySpec.name) } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt index d05bf30..c6f8b0a 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt @@ -1,28 +1,26 @@ package space.kscience.controls.spec import kotlinx.coroutines.withContext -import space.kscience.controls.api.ActionDescriptor -import space.kscience.controls.api.Device -import space.kscience.controls.api.PropertyDescriptor +import space.kscience.controls.api.* import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.meta.descriptors.MetaDescriptor import kotlin.properties.PropertyDelegateProvider import kotlin.properties.ReadOnlyProperty -import kotlin.reflect.KMutableProperty1 import kotlin.reflect.KProperty -import kotlin.reflect.KProperty1 -public object UnitMetaConverter: MetaConverter<Unit>{ - override fun metaToObject(meta: Meta): Unit = Unit +public object UnitMetaConverter : MetaConverter<Unit> { - override fun objectToMeta(obj: Unit): Meta = Meta.EMPTY + override fun readOrNull(source: Meta): Unit = Unit + + override fun convert(obj: Unit): Meta = Meta.EMPTY } public val MetaConverter.Companion.unit: MetaConverter<Unit> get() = UnitMetaConverter @OptIn(InternalDeviceAPI::class) public abstract class DeviceSpec<D : Device> { - //initializing meta property for everyone + //initializing the metadata property for everyone private val _properties = hashMapOf<String, DevicePropertySpec<D, *>>( DeviceMetaPropertySpec.name to DeviceMetaPropertySpec ) @@ -35,7 +33,7 @@ public abstract class DeviceSpec<D : Device> { public open suspend fun D.onOpen() { } - public open fun D.onClose() { + public open suspend fun D.onClose() { } @@ -44,72 +42,30 @@ public abstract class DeviceSpec<D : Device> { return deviceProperty } - public fun <T> property( - converter: MetaConverter<T>, - readOnlyProperty: KProperty1<D, T>, - descriptorBuilder: PropertyDescriptor.() -> Unit = {}, - ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, DevicePropertySpec<D, T>>> = - PropertyDelegateProvider { _, property -> - val deviceProperty = object : DevicePropertySpec<D, T> { - override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply { - //TODO add type from converter - writable = true - }.apply(descriptorBuilder) - - override val converter: MetaConverter<T> = converter - - override suspend fun read(device: D): T = withContext(device.coroutineContext) { - readOnlyProperty.get(device) - } - } - registerProperty(deviceProperty) - ReadOnlyProperty { _, _ -> - deviceProperty - } - } - - public fun <T> mutableProperty( - converter: MetaConverter<T>, - readWriteProperty: KMutableProperty1<D, T>, - descriptorBuilder: PropertyDescriptor.() -> Unit = {}, - ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, WritableDevicePropertySpec<D, T>>> = - PropertyDelegateProvider { _, property -> - val deviceProperty = object : WritableDevicePropertySpec<D, T> { - - override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply { - //TODO add the type from converter - writable = true - }.apply(descriptorBuilder) - - override val converter: MetaConverter<T> = converter - - override suspend fun read(device: D): T = withContext(device.coroutineContext) { - readWriteProperty.get(device) - } - - override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) { - readWriteProperty.set(device, value) - } - } - registerProperty(deviceProperty) - ReadOnlyProperty { _, _ -> - deviceProperty - } - } - public fun <T> property( converter: MetaConverter<T>, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> T?, + read: suspend D.(propertyName: String) -> T?, ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = PropertyDelegateProvider { _: DeviceSpec<D>, property -> val propertyName = name ?: property.name val deviceProperty = object : DevicePropertySpec<D, T> { - override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder) + + override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply { + converter.descriptor?.let { converterDescriptor -> + metaDescriptor { + from(converterDescriptor) + } + } + fromSpec(property) + descriptorBuilder() + } + override val converter: MetaConverter<T> = converter - override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() } + override suspend fun read(device: D): T? = + withContext(device.coroutineContext) { device.read(propertyName) } } registerProperty(deviceProperty) ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>> { _, _ -> @@ -121,23 +77,35 @@ public abstract class DeviceSpec<D : Device> { converter: MetaConverter<T>, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> T?, - write: suspend D.(T) -> Unit, - ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>>> = + read: suspend D.(propertyName: String) -> T?, + write: suspend D.(propertyName: String, value: T) -> Unit, + ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> = PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> -> val propertyName = name ?: property.name - val deviceProperty = object : WritableDevicePropertySpec<D, T> { - override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder) + val deviceProperty = object : MutableDevicePropertySpec<D, T> { + override val descriptor: PropertyDescriptor = PropertyDescriptor( + propertyName, + mutable = true + ).apply { + converter.descriptor?.let { converterDescriptor -> + metaDescriptor { + from(converterDescriptor) + } + } + fromSpec(property) + descriptorBuilder() + } override val converter: MetaConverter<T> = converter - override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() } + override suspend fun read(device: D): T? = + withContext(device.coroutineContext) { device.read(propertyName) } override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) { - device.write(value) + device.write(propertyName, value) } } - _properties[propertyName] = deviceProperty - ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>> { _, _ -> + registerProperty(deviceProperty) + ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>> { _, _ -> deviceProperty } } @@ -155,10 +123,26 @@ public abstract class DeviceSpec<D : Device> { name: String? = null, execute: suspend D.(I) -> O, ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, I, O>>> = - PropertyDelegateProvider { _: DeviceSpec<D>, property -> + PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> -> val actionName = name ?: property.name val deviceAction = object : DeviceActionSpec<D, I, O> { - override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply(descriptorBuilder) + override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply { + inputConverter.descriptor?.let { converterDescriptor -> + inputMetaDescriptor = MetaDescriptor { + from(converterDescriptor) + from(inputMetaDescriptor) + } + } + outputConverter.descriptor?.let { converterDescriptor -> + outputMetaDescriptor = MetaDescriptor { + from(converterDescriptor) + from(outputMetaDescriptor) + } + } + + fromSpec(property) + descriptorBuilder() + } override val inputConverter: MetaConverter<I> = inputConverter override val outputConverter: MetaConverter<O> = outputConverter @@ -172,69 +156,52 @@ public abstract class DeviceSpec<D : Device> { deviceAction } } - - /** - * An action that takes [Meta] and returns [Meta]. No conversions are done - */ - public fun metaAction( - descriptorBuilder: ActionDescriptor.() -> Unit = {}, - name: String? = null, - execute: suspend D.(Meta) -> Meta, - ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> = - action( - MetaConverter.Companion.meta, - MetaConverter.Companion.meta, - descriptorBuilder, - name - ) { - execute(it) - } - - /** - * An action that takes no parameters and returns no values - */ - public fun unitAction( - descriptorBuilder: ActionDescriptor.() -> Unit = {}, - name: String? = null, - execute: suspend D.() -> Unit, - ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Unit, Unit>>> = - action( - MetaConverter.Companion.unit, - MetaConverter.Companion.unit, - descriptorBuilder, - name - ) { - execute() - } } +/** + * An action that takes no parameters and returns no values + */ +public fun <D : Device> DeviceSpec<D>.unitAction( + descriptorBuilder: ActionDescriptor.() -> Unit = {}, + name: String? = null, + execute: suspend D.() -> Unit, +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Unit, Unit>>> = + action( + MetaConverter.Companion.unit, + MetaConverter.Companion.unit, + descriptorBuilder, + name + ) { + execute() + } + +/** + * An action that takes [Meta] and returns [Meta]. No conversions are done + */ +public fun <D : Device> DeviceSpec<D>.metaAction( + descriptorBuilder: ActionDescriptor.() -> Unit = {}, + name: String? = null, + execute: suspend D.(Meta) -> Meta, +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> = + action( + MetaConverter.Companion.meta, + MetaConverter.Companion.meta, + descriptorBuilder, + name + ) { + execute(it) + } + /** - * Register a mutable logical property for a device + * Throw an exception if device does not have all properties and actions defined by this specification */ -@OptIn(InternalDeviceAPI::class) -public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty( - converter: MetaConverter<T>, - descriptorBuilder: PropertyDescriptor.() -> Unit = {}, - name: String? = null, -): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, WritableDevicePropertySpec<D, T>>> = - PropertyDelegateProvider { _, property -> - val deviceProperty = object : WritableDevicePropertySpec<D, T> { - val propertyName = name ?: property.name - override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply { - //TODO add type from converter - writable = true - }.apply(descriptorBuilder) +public fun DeviceSpec<*>.validate(device: Device) { + properties.map { it.value.descriptor }.forEach { specProperty -> + check(specProperty in device.propertyDescriptors) { "Property ${specProperty.name} not registered in ${device.id}" } + } - override val converter: MetaConverter<T> = converter - - override suspend fun read(device: D): T? = device.getProperty(propertyName)?.let(converter::metaToObject) - - override suspend fun write(device: D, value: T): Unit = - device.writeProperty(propertyName, converter.objectToMeta(value)) - } - registerProperty(deviceProperty) - ReadOnlyProperty { _, _ -> - deviceProperty - } - } \ No newline at end of file + actions.map { it.value.descriptor }.forEach { specAction -> + check(specAction in device.actionDescriptors) { "Action ${specAction.name} not registered in ${device.id}" } + } +} diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt index af42f60..3344407 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/deviceExtensions.kt @@ -1,38 +1,46 @@ package space.kscience.controls.spec -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay +import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import space.kscience.controls.api.Device +import space.kscience.controls.manager.getCoroutineDispatcher import kotlin.time.Duration /** - * Perform a recurring asynchronous read action and return a flow of results. - * The flow is lazy, so action is not performed unless flow is consumed. - * The flow uses called context. In order to call it on device context, use `flowOn(coroutineContext)`. - * - * The flow is canceled when the device scope is canceled + * Do a recurring (with a fixed delay) task on a device. */ -public fun <D : Device, R> D.readRecurring(interval: Duration, reader: suspend D.() -> R): Flow<R> = flow { - while (isActive) { - delay(interval) - launch { - emit(reader()) +public fun <D : Device> D.doRecurring( + interval: Duration, + debugTaskName: String? = null, + task: suspend D.() -> Unit, +): Job { + val taskName = debugTaskName ?: "task[${task.hashCode().toString(16)}]" + val dispatcher = getCoroutineDispatcher() + return launch(CoroutineName(taskName) + dispatcher) { + while (isActive) { + delay(interval) + //launch in parent scope to properly evaluate exceptions + this@doRecurring.launch(CoroutineName("$taskName-recurring") + dispatcher) { + task() + } } } } /** - * Do a recurring (with a fixed delay) task on a device. + * Perform a recurring asynchronous read action and return a flow of results. + * The flow is lazy, so action is not performed unless flow is consumed. + * The flow uses caller context. To call it on device context, use `flowOn(coroutineContext)`. + * + * The flow is canceled when the device scope is canceled */ -public fun <D : Device> D.doRecurring(interval: Duration, task: suspend D.() -> Unit): Job = launch { - while (isActive) { - delay(interval) - launch { - task() - } +public fun <D : Device, R> D.readRecurring( + interval: Duration, + debugTaskName: String? = null, + reader: suspend D.() -> R, +): Flow<R> = flow { + doRecurring(interval, debugTaskName) { + emit(reader()) } } \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt new file mode 100644 index 0000000..ad5862b --- /dev/null +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/fromSpec.kt @@ -0,0 +1,10 @@ +package space.kscience.controls.spec + +import space.kscience.controls.api.ActionDescriptor +import space.kscience.controls.api.PropertyDescriptor +import kotlin.reflect.KProperty + + +internal expect fun PropertyDescriptor.fromSpec(property: KProperty<*>) + +internal expect fun ActionDescriptor.fromSpec(property: KProperty<*>) \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt deleted file mode 100644 index e264212..0000000 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/misc.kt +++ /dev/null @@ -1,23 +0,0 @@ -package space.kscience.controls.spec - -import space.kscience.dataforge.meta.* -import space.kscience.dataforge.meta.transformations.MetaConverter -import kotlin.time.Duration -import kotlin.time.DurationUnit -import kotlin.time.toDuration - -public fun Double.asMeta(): Meta = Meta(asValue()) - -//TODO to be moved to DF -public object DurationConverter : MetaConverter<Duration> { - override fun metaToObject(meta: Meta): Duration = meta.value?.double?.toDuration(DurationUnit.SECONDS) - ?: run { - val unit: DurationUnit = meta["unit"].enum<DurationUnit>() ?: DurationUnit.SECONDS - val value = meta[Meta.VALUE_KEY].double ?: error("No value present for Duration") - return@run value.toDuration(unit) - } - - override fun objectToMeta(obj: Duration): Meta = obj.toDouble(DurationUnit.SECONDS).asMeta() -} - -public val MetaConverter.Companion.duration: MetaConverter<Duration> get() = DurationConverter \ No newline at end of file diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt index ff89662..efb66a7 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/propertySpecDelegates.kt @@ -4,22 +4,70 @@ import space.kscience.controls.api.Device import space.kscience.controls.api.PropertyDescriptor import space.kscience.controls.api.metaDescriptor import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter import space.kscience.dataforge.meta.ValueType -import space.kscience.dataforge.meta.transformations.MetaConverter import kotlin.properties.PropertyDelegateProvider import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KMutableProperty1 +import kotlin.reflect.KProperty1 + +/** + * A read-only device property that delegates reading to a device [KProperty1] + */ +public fun <T, D : Device> DeviceSpec<D>.property( + converter: MetaConverter<T>, + readOnlyProperty: KProperty1<D, T>, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = property( + converter, + descriptorBuilder, + name = readOnlyProperty.name, + read = { readOnlyProperty.get(this) } +) + +/** + * Mutable property that delegates reading and writing to a device [KMutableProperty1] + */ +public fun <T, D : Device> DeviceSpec<D>.mutableProperty( + converter: MetaConverter<T>, + readWriteProperty: KMutableProperty1<D, T>, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> = + mutableProperty( + converter, + descriptorBuilder, + readWriteProperty.name, + read = { _ -> readWriteProperty.get(this) }, + write = { _, value: T -> readWriteProperty.set(this, value) } + ) //read only delegates +/** + * Register a read-only logical property + * (without a corresponding physical state or with a state that is updated asynchronously) for a device + */ +public fun <T, D : DeviceBase<D>> DeviceSpec<D>.property( + converter: MetaConverter<T>, + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + name: String? = null, +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = + property( + converter, + descriptorBuilder, + name, + read = { propertyName -> getProperty(propertyName)?.let(converter::readOrNull) }, + ) + public fun <D : Device> DeviceSpec<D>.booleanProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Boolean? + read: suspend D.(propertyName: String) -> Boolean? ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Boolean>>> = property( MetaConverter.boolean, { metaDescriptor { - type(ValueType.BOOLEAN) + valueType(ValueType.BOOLEAN) } descriptorBuilder() }, @@ -31,15 +79,15 @@ private inline fun numberDescriptor( crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {} ): PropertyDescriptor.() -> Unit = { metaDescriptor { - type(ValueType.NUMBER) + valueType(ValueType.NUMBER) } descriptorBuilder() } public fun <D : Device> DeviceSpec<D>.numberProperty( - name: String? = null, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, - read: suspend D.() -> Number? + name: String? = null, + read: suspend D.(propertyName: String) -> Number? ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Number>>> = property( MetaConverter.number, numberDescriptor(descriptorBuilder), @@ -50,7 +98,7 @@ public fun <D : Device> DeviceSpec<D>.numberProperty( public fun <D : Device> DeviceSpec<D>.doubleProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Double? + read: suspend D.(propertyName: String) -> Double? ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Double>>> = property( MetaConverter.double, numberDescriptor(descriptorBuilder), @@ -61,12 +109,12 @@ public fun <D : Device> DeviceSpec<D>.doubleProperty( public fun <D : Device> DeviceSpec<D>.stringProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> String? + read: suspend D.(propertyName: String) -> String? ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, String>>> = property( MetaConverter.string, { metaDescriptor { - type(ValueType.STRING) + valueType(ValueType.STRING) } descriptorBuilder() }, @@ -77,12 +125,12 @@ public fun <D : Device> DeviceSpec<D>.stringProperty( public fun <D : Device> DeviceSpec<D>.metaProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Meta? + read: suspend D.(propertyName: String) -> Meta? ): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Meta>>> = property( MetaConverter.meta, { metaDescriptor { - type(ValueType.STRING) + valueType(ValueType.STRING) } descriptorBuilder() }, @@ -92,17 +140,35 @@ public fun <D : Device> DeviceSpec<D>.metaProperty( //read-write delegates -public fun <D : Device> DeviceSpec<D>.booleanProperty( + +/** + * Register a mutable logical property + * (without a corresponding physical state or with a state that is updated asynchronously) for a device + */ +public fun <T, D : DeviceBase<D>> DeviceSpec<D>.mutableProperty( + converter: MetaConverter<T>, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Boolean?, - write: suspend D.(Boolean) -> Unit -): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Boolean>>> = +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> = + mutableProperty( + converter, + descriptorBuilder, + name, + read = { propertyName -> getProperty(propertyName)?.let(converter::readOrNull) }, + write = { propertyName, value -> writeProperty(propertyName, converter.convert(value)) } + ) + +public fun <D : Device> DeviceSpec<D>.mutableBooleanProperty( + descriptorBuilder: PropertyDescriptor.() -> Unit = {}, + name: String? = null, + read: suspend D.(propertyName: String) -> Boolean?, + write: suspend D.(propertyName: String, value: Boolean) -> Unit +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Boolean>>> = mutableProperty( MetaConverter.boolean, { metaDescriptor { - type(ValueType.BOOLEAN) + valueType(ValueType.BOOLEAN) } descriptorBuilder() }, @@ -112,34 +178,34 @@ public fun <D : Device> DeviceSpec<D>.booleanProperty( ) -public fun <D : Device> DeviceSpec<D>.numberProperty( +public fun <D : Device> DeviceSpec<D>.mutableNumberProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Number, - write: suspend D.(Number) -> Unit -): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Number>>> = + read: suspend D.(propertyName: String) -> Number, + write: suspend D.(propertyName: String, value: Number) -> Unit +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Number>>> = mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write) -public fun <D : Device> DeviceSpec<D>.doubleProperty( +public fun <D : Device> DeviceSpec<D>.mutableDoubleProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Double, - write: suspend D.(Double) -> Unit -): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Double>>> = + read: suspend D.(propertyName: String) -> Double, + write: suspend D.(propertyName: String, value: Double) -> Unit +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Double>>> = mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write) -public fun <D : Device> DeviceSpec<D>.stringProperty( +public fun <D : Device> DeviceSpec<D>.mutableStringProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> String, - write: suspend D.(String) -> Unit -): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, String>>> = + read: suspend D.(propertyName: String) -> String, + write: suspend D.(propertyName: String, value: String) -> Unit +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, String>>> = mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write) -public fun <D : Device> DeviceSpec<D>.metaProperty( +public fun <D : Device> DeviceSpec<D>.mutableMetaProperty( descriptorBuilder: PropertyDescriptor.() -> Unit = {}, name: String? = null, - read: suspend D.() -> Meta, - write: suspend D.(Meta) -> Unit -): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Meta>>> = + read: suspend D.(propertyName: String) -> Meta, + write: suspend D.(propertyName: String, value: Meta) -> Unit +): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Meta>>> = mutableProperty(MetaConverter.meta, descriptorBuilder, name, read, write) \ No newline at end of file diff --git a/controls-core/src/commonTest/kotlin/space/kscience/controls/api/MessageTest.kt b/controls-core/src/commonTest/kotlin/space/kscience/controls/api/MessageTest.kt index 719738a..269b140 100644 --- a/controls-core/src/commonTest/kotlin/space/kscience/controls/api/MessageTest.kt +++ b/controls-core/src/commonTest/kotlin/space/kscience/controls/api/MessageTest.kt @@ -1,9 +1,8 @@ package space.kscience.controls.api -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import space.kscience.controls.spec.asMeta +import space.kscience.controls.misc.asMeta import kotlin.test.Test import kotlin.test.assertEquals diff --git a/controls-core/src/jsMain/kotlin/space/kscience/controls/spec/fromSpec.js.kt b/controls-core/src/jsMain/kotlin/space/kscience/controls/spec/fromSpec.js.kt new file mode 100644 index 0000000..cd77248 --- /dev/null +++ b/controls-core/src/jsMain/kotlin/space/kscience/controls/spec/fromSpec.js.kt @@ -0,0 +1,9 @@ +package space.kscience.controls.spec + +import space.kscience.controls.api.ActionDescriptor +import space.kscience.controls.api.PropertyDescriptor +import kotlin.reflect.KProperty + +internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>){} + +internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){} \ No newline at end of file diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt index d7983f2..e5430db 100644 --- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt +++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/ChannelPort.kt @@ -1,19 +1,21 @@ package space.kscience.controls.ports import kotlinx.coroutines.* -import space.kscience.dataforge.context.Context -import space.kscience.dataforge.context.error -import space.kscience.dataforge.context.info -import space.kscience.dataforge.context.logger +import space.kscience.controls.api.LifecycleState +import space.kscience.dataforge.context.* import space.kscience.dataforge.meta.* import java.net.InetSocketAddress import java.nio.ByteBuffer +import java.nio.channels.AsynchronousCloseException import java.nio.channels.ByteChannel import java.nio.channels.DatagramChannel import java.nio.channels.SocketChannel import kotlin.coroutines.CoroutineContext -public fun ByteBuffer.toArray(limit: Int = limit()): ByteArray { +/** + * Copy the contents of this buffer to an array + */ +public fun ByteBuffer.copyToArray(limit: Int = limit()): ByteArray { rewind() val response = ByteArray(limit) get(response) @@ -26,32 +28,42 @@ public fun ByteBuffer.toArray(limit: Int = limit()): ByteArray { */ public class ChannelPort( context: Context, + meta: Meta, coroutineContext: CoroutineContext = context.coroutineContext, channelBuilder: suspend () -> ByteChannel, -) : AbstractPort(context, coroutineContext), AutoCloseable { - - private val futureChannel: Deferred<ByteChannel> = this.scope.async(Dispatchers.IO) { - channelBuilder() - } +) : AbstractAsynchronousPort(context, meta, coroutineContext) { /** * A handler to await port connection */ - public val startJob: Job get() = futureChannel + private val futureChannel: Deferred<ByteChannel> = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { + channelBuilder() + } - private val listenerJob = this.scope.launch(Dispatchers.IO) { - val channel = futureChannel.await() - val buffer = ByteBuffer.allocate(1024) - while (isActive) { - try { - val num = channel.read(buffer) - if (num > 0) { - receive(buffer.toArray(num)) + private var listenerJob: Job? = null + + override val lifecycleState: LifecycleState + get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED + + override fun onOpen() { + listenerJob = scope.launch(Dispatchers.IO) { + val channel = futureChannel.await() + val buffer = ByteBuffer.allocate(1024) + while (isActive && channel.isOpen) { + try { + val num = channel.read(buffer) + if (num > 0) { + receive(buffer.copyToArray(num)) + } + if (num < 0) cancel("The input channel is exhausted") + } catch (ex: Exception) { + if (ex is AsynchronousCloseException) { + logger.info { "Channel $channel closed" } + } else { + logger.error(ex) { "Channel read error, retrying in 1 second" } + delay(1000) + } } - if (num < 0) cancel("The input channel is exhausted") - } catch (ex: Exception) { - logger.error(ex) { "Channel read error" } - delay(1000) } } } @@ -61,73 +73,105 @@ public class ChannelPort( } @OptIn(ExperimentalCoroutinesApi::class) - override fun close() { - listenerJob.cancel() + override suspend fun stop() { + listenerJob?.cancel() if (futureChannel.isCompleted) { futureChannel.getCompleted().close() - } else { - futureChannel.cancel() } - super.close() + super.stop() } } /** - * A [PortFactory] for TCP connections + * A [Factory] for TCP connections */ -public object TcpPort : PortFactory { +public object TcpPort : Factory<AsynchronousPort> { - override val type: String = "tcp" - - public fun open( + public fun build( context: Context, host: String, port: Int, coroutineContext: CoroutineContext = context.coroutineContext, - ): ChannelPort = ChannelPort(context, coroutineContext) { - SocketChannel.open(InetSocketAddress(host, port)) + ): ChannelPort { + val meta = Meta { + "name" put "tcp://$host:$port" + "type" put "tcp" + "host" put host + "port" put port + } + return ChannelPort(context, meta, coroutineContext) { + SocketChannel.open(InetSocketAddress(host, port)) + } } + /** + * Create and open TCP port + */ + public suspend fun start( + context: Context, + host: String, + port: Int, + coroutineContext: CoroutineContext = context.coroutineContext, + ): ChannelPort = build(context, host, port, coroutineContext).apply { start() } + override fun build(context: Context, meta: Meta): ChannelPort { val host = meta["host"].string ?: "localhost" val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta") - return open(context, host, port) + return build(context, host, port) } + } /** - * A [PortFactory] for UDP connections + * A [Factory] for UDP connections */ -public object UdpPort : PortFactory { +public object UdpPort : Factory<AsynchronousPort> { - override val type: String = "udp" + public fun build( + context: Context, + remoteHost: String, + remotePort: Int, + localPort: Int? = null, + localHost: String? = null, + coroutineContext: CoroutineContext = context.coroutineContext, + ): ChannelPort { + val meta = Meta { + "name" put "udp://$remoteHost:$remotePort" + "type" put "udp" + "remoteHost" put remoteHost + "remotePort" put remotePort + localHost?.let { "localHost" put it } + localPort?.let { "localPort" put it } + } + return ChannelPort(context, meta, coroutineContext) { + DatagramChannel.open().apply { + //bind the channel to a local port to receive messages + localPort?.let { bind(InetSocketAddress(localHost ?: "localhost", it)) } + //connect to remote port to send messages + connect(InetSocketAddress(remoteHost, remotePort.toInt())) + context.logger.info { "Connected to UDP $remotePort on $remoteHost" } + } + } + } /** * Connect a datagram channel to a remote host/port. If [localPort] is provided, it is used to bind local port for receiving messages. */ - public fun open( + public suspend fun start( context: Context, remoteHost: String, remotePort: Int, localPort: Int? = null, localHost: String = "localhost", - coroutineContext: CoroutineContext = context.coroutineContext, - ): ChannelPort = ChannelPort(context, coroutineContext) { - DatagramChannel.open().apply { - //bind the channel to a local port to receive messages - localPort?.let { bind(InetSocketAddress(localHost, localPort)) } - //connect to remote port to send messages - connect(InetSocketAddress(remoteHost, remotePort)) - context.logger.info { "Connected to UDP $remotePort on $remoteHost" } - } - } + ): ChannelPort = build(context, remoteHost, remotePort, localPort, localHost).apply { start() } + override fun build(context: Context, meta: Meta): ChannelPort { val remoteHost by meta.string { error("Remote host is not specified") } val remotePort by meta.number { error("Remote port is not specified") } val localHost: String? by meta.string() val localPort: Int? by meta.int() - return open(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost") + return build(context, remoteHost, remotePort.toInt(), localPort, localHost) } } \ No newline at end of file diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt index d9d87e2..82e1ac0 100644 --- a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt +++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/JvmPortsPlugin.kt @@ -6,7 +6,7 @@ import space.kscience.dataforge.context.PluginFactory import space.kscience.dataforge.context.PluginTag import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.names.Name -import space.kscience.dataforge.names.parseAsName +import space.kscience.dataforge.names.asName /** * A plugin for loading JVM nio-based ports @@ -17,9 +17,9 @@ public class JvmPortsPlugin : AbstractPlugin() { override val tag: PluginTag get() = Companion.tag override fun content(target: String): Map<Name, Any> = when(target){ - PortFactory.TYPE -> mapOf( - TcpPort.type.parseAsName() to TcpPort, - UdpPort.type.parseAsName() to UdpPort + Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf( + "tcp".asName() to TcpPort, + "udp".asName() to UdpPort ) else -> emptyMap() } diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt new file mode 100644 index 0000000..39d4c13 --- /dev/null +++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/ports/UdpSocketPort.kt @@ -0,0 +1,60 @@ +package space.kscience.controls.ports + +import kotlinx.coroutines.* +import space.kscience.controls.api.LifecycleState +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.Meta +import java.net.DatagramPacket +import java.net.DatagramSocket +import kotlin.coroutines.CoroutineContext + +/** + * A port based on [DatagramSocket] for cases, where [ChannelPort] does not work for some reason + */ +public class UdpSocketPort( + override val context: Context, + meta: Meta, + private val socket: DatagramSocket, + coroutineContext: CoroutineContext = context.coroutineContext, +) : AbstractAsynchronousPort(context, meta, coroutineContext) { + + private var listenerJob: Job? = null + + override fun onOpen() { + listenerJob = context.launch(Dispatchers.IO) { + while (isActive) { + val buf = ByteArray(socket.receiveBufferSize) + + val packet = DatagramPacket( + buf, + buf.size, + ) + socket.receive(packet) + + val bytes = packet.data.copyOfRange( + packet.offset, + packet.offset + packet.length + ) + receive(bytes) + } + } + } + + override suspend fun stop() { + listenerJob?.cancel() + super.stop() + } + + override val lifecycleState: LifecycleState + get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED + + override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) { + val packet = DatagramPacket( + data, + data.size, + socket.remoteSocketAddress + ) + socket.send(packet) + } + +} \ No newline at end of file diff --git a/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt new file mode 100644 index 0000000..cb64fc3 --- /dev/null +++ b/controls-core/src/jvmMain/kotlin/space/kscience/controls/spec/fromSpec.jvm.kt @@ -0,0 +1,19 @@ +package space.kscience.controls.spec + +import space.kscience.controls.api.ActionDescriptor +import space.kscience.controls.api.PropertyDescriptor +import space.kscience.dataforge.descriptors.Description +import kotlin.reflect.KProperty +import kotlin.reflect.full.findAnnotation + +internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) { + property.findAnnotation<Description>()?.let { + description = it.value + } +} + +internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){ + property.findAnnotation<Description>()?.let { + description = it.value + } +} \ No newline at end of file diff --git a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt new file mode 100644 index 0000000..6709f8c --- /dev/null +++ b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/AsynchronousPortIOTest.kt @@ -0,0 +1,50 @@ +package space.kscience.controls.ports + +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Test +import space.kscience.dataforge.context.Global +import kotlin.test.assertEquals + + +internal class AsynchronousPortIOTest { + + @Test + fun testDelimiteredByteArrayFlow() { + val flow = flowOf("bb?b", "ddd?", ":defgb?:ddf", "34fb?:--").map { it.encodeToByteArray() } + val chunked = flow.withDelimiter("?:".encodeToByteArray()) + runBlocking { + val result = chunked.toList() + assertEquals(3, result.size) + assertEquals("bb?bddd?:", result[0].decodeToString()) + assertEquals("defgb?:", result[1].decodeToString()) + assertEquals("ddf34fb?:", result[2].decodeToString()) + } + } + + @Test + fun testUdpCommunication() = runTest { + val receiver = UdpPort.start(Global, "localhost", 8811, localPort = 8812) + val sender = UdpPort.start(Global, "localhost", 8812, localPort = 8811) + + delay(30) + repeat(10) { + sender.send("Line number $it\n") + } + + val res = receiver + .subscribe() + .withStringDelimiter("\n") + .take(10) + .toList() + + assertEquals("Line number 3", res[3].trim()) + receiver.stop() + sender.stop() + } +} \ No newline at end of file diff --git a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt b/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt deleted file mode 100644 index bdf6891..0000000 --- a/controls-core/src/jvmTest/kotlin/space/kscience/controls/ports/PortIOTest.kt +++ /dev/null @@ -1,25 +0,0 @@ -package space.kscience.controls.ports - -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.runBlocking -import org.junit.jupiter.api.Test -import kotlin.test.assertEquals - - -internal class PortIOTest{ - - @Test - fun testDelimiteredByteArrayFlow(){ - val flow = flowOf("bb?b","ddd?",":defgb?:ddf","34fb?:--").map { it.encodeToByteArray() } - val chunked = flow.withDelimiter("?:".encodeToByteArray()) - runBlocking { - val result = chunked.toList() - assertEquals(3, result.size) - assertEquals("bb?bddd?:",result[0].decodeToString()) - assertEquals("defgb?:", result[1].decodeToString()) - assertEquals("ddf34fb?:", result[2].decodeToString()) - } - } -} \ No newline at end of file diff --git a/controls-core/src/nativeMain/kotlin/space/kscience/controls/spec/fromSpec.native.kt b/controls-core/src/nativeMain/kotlin/space/kscience/controls/spec/fromSpec.native.kt new file mode 100644 index 0000000..1d1ccc4 --- /dev/null +++ b/controls-core/src/nativeMain/kotlin/space/kscience/controls/spec/fromSpec.native.kt @@ -0,0 +1,9 @@ +package space.kscience.controls.spec + +import space.kscience.controls.api.ActionDescriptor +import space.kscience.controls.api.PropertyDescriptor +import kotlin.reflect.KProperty + +internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) {} + +internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){} \ No newline at end of file diff --git a/controls-core/src/wasmJsMain/kotlin/fromSpec.wasm.kt b/controls-core/src/wasmJsMain/kotlin/fromSpec.wasm.kt new file mode 100644 index 0000000..cd77248 --- /dev/null +++ b/controls-core/src/wasmJsMain/kotlin/fromSpec.wasm.kt @@ -0,0 +1,9 @@ +package space.kscience.controls.spec + +import space.kscience.controls.api.ActionDescriptor +import space.kscience.controls.api.PropertyDescriptor +import kotlin.reflect.KProperty + +internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>){} + +internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){} \ No newline at end of file diff --git a/controls-jupyter/README.md b/controls-jupyter/README.md new file mode 100644 index 0000000..045b8d0 --- /dev/null +++ b/controls-jupyter/README.md @@ -0,0 +1,21 @@ +# Module controls-jupyter + + + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:controls-jupyter:0.4.0-dev-7`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:controls-jupyter:0.4.0-dev-7") +} +``` diff --git a/controls-jupyter/api/controls-jupyter.api b/controls-jupyter/api/controls-jupyter.api new file mode 100644 index 0000000..726b523 --- /dev/null +++ b/controls-jupyter/api/controls-jupyter.api @@ -0,0 +1,8 @@ +public final class space/kscience/controls/jupyter/ControlsJupyter : space/kscience/visionforge/jupyter/VisionForgeIntegration { + public static final field Companion Lspace/kscience/controls/jupyter/ControlsJupyter$Companion; + public fun <init> ()V +} + +public final class space/kscience/controls/jupyter/ControlsJupyter$Companion { +} + diff --git a/controls-jupyter/build.gradle.kts b/controls-jupyter/build.gradle.kts new file mode 100644 index 0000000..21a3a82 --- /dev/null +++ b/controls-jupyter/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +kscience { + fullStack("js/controls-jupyter.js") + useKtor() + useContextReceivers() + jupyterLibrary("space.kscience.controls.jupyter.ControlsJupyter") + dependencies { + implementation(projects.controlsVision) + implementation(libs.visionforge.jupiter) + } + jvmMain { + implementation(spclibs.logback.classic) + } +} \ No newline at end of file diff --git a/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt b/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt new file mode 100644 index 0000000..388e1ab --- /dev/null +++ b/controls-jupyter/src/jsMain/kotlin/commonJupyter.kt @@ -0,0 +1,14 @@ +import space.kscience.visionforge.html.runVisionClient +import space.kscience.visionforge.jupyter.VFNotebookClient +import space.kscience.visionforge.markup.MarkupPlugin +import space.kscience.visionforge.plotly.PlotlyPlugin + +public fun main(): Unit = runVisionClient { +// plugin(DeviceManager) +// plugin(ClockManager) + plugin(PlotlyPlugin) + plugin(MarkupPlugin) +// plugin(TableVisionJsPlugin) + plugin(VFNotebookClient) +} + diff --git a/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt b/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt new file mode 100644 index 0000000..ec4bcee --- /dev/null +++ b/controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt @@ -0,0 +1,71 @@ +package space.kscience.controls.jupyter + +import org.jetbrains.kotlinx.jupyter.api.declare +import org.jetbrains.kotlinx.jupyter.api.libraries.resources +import space.kscience.controls.manager.ClockManager +import space.kscience.controls.manager.DeviceManager +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.misc.DFExperimental +import space.kscience.plotly.Plot +import space.kscience.visionforge.jupyter.VisionForge +import space.kscience.visionforge.jupyter.VisionForgeIntegration +import space.kscience.visionforge.markup.MarkupPlugin +import space.kscience.visionforge.plotly.PlotlyPlugin +import space.kscience.visionforge.plotly.asVision +import space.kscience.visionforge.visionManager + + +@OptIn(DFExperimental::class) +public class ControlsJupyter : VisionForgeIntegration(CONTEXT.visionManager) { + + override fun Builder.afterLoaded(vf: VisionForge) { + + resources { + js("controls-jupyter") { + classPath("js/controls-jupyter.js") + } + } + + onLoaded { + declare("context" to CONTEXT) + } + + import( + "kotlin.time.*", + "kotlin.time.Duration.Companion.milliseconds", + "kotlin.time.Duration.Companion.seconds", +// "space.kscience.tables.*", + "space.kscience.dataforge.meta.*", + "space.kscience.dataforge.context.*", + "space.kscience.plotly.*", + "space.kscience.plotly.models.*", + "space.kscience.visionforge.plotly.*", + "space.kscience.controls.manager.*", + "space.kscience.controls.constructor.*", + "space.kscience.controls.vision.*", + "space.kscience.controls.spec.*" + ) + +// render<Table<*>> { table -> +// vf.produceHtml { +// vision { table.toVision() } +// } +// } + + render<Plot> { plot -> + vf.produceHtml { + vision { plot.asVision() } + } + } + } + + public companion object { + private val CONTEXT: Context = Context("controls-jupyter") { + plugin(DeviceManager) + plugin(ClockManager) + plugin(PlotlyPlugin) +// plugin(TableVisionPlugin) + plugin(MarkupPlugin) + } + } +} diff --git a/controls-magix/README.md b/controls-magix/README.md index 5473f02..f2dd35f 100644 --- a/controls-magix/README.md +++ b/controls-magix/README.md @@ -12,18 +12,16 @@ Magix service for binding controls devices (both as RPC client and server) ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-magix:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-magix:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-magix:0.2.0") + implementation("space.kscience:controls-magix:0.4.0-dev-7") } ``` diff --git a/controls-magix/build.gradle.kts b/controls-magix/build.gradle.kts index 0296b8c..1425eb0 100644 --- a/controls-magix/build.gradle.kts +++ b/controls-magix/build.gradle.kts @@ -12,13 +12,26 @@ description = """ kscience { jvm() js() + native() + wasm() + useCoroutines() useSerialization { json() } - dependencies { + + commonMain { api(projects.magix.magixApi) api(projects.controlsCore) - api("com.benasher44:uuid:0.8.0") + api(libs.uuid) + } + + jvmTest{ + implementation(spclibs.logback.classic) + implementation(projects.magix.magixServer) + implementation(projects.magix.magixRsocket) + implementation(spclibs.ktor.server.cio) + implementation(spclibs.ktor.server.websockets) + implementation(spclibs.ktor.client.cio) } } diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt index 64e8a9e..045ee02 100644 --- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt +++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/DeviceClient.kt @@ -1,12 +1,17 @@ package space.kscience.controls.client import com.benasher44.uuid.uuid4 +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.* -import kotlinx.coroutines.newCoroutineContext +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import space.kscience.controls.api.* import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.name import space.kscience.dataforge.context.Context import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.misc.DFExperimental @@ -21,46 +26,41 @@ private fun stringUID() = uuid4().leastSignificantBits.toString(16) /** * A remote accessible device that relies on connection via Magix */ -public class DeviceClient( +public class DeviceClient internal constructor( override val context: Context, private val deviceName: Name, + propertyDescriptors: Collection<PropertyDescriptor>, + actionDescriptors: Collection<ActionDescriptor>, incomingFlow: Flow<DeviceMessage>, private val send: suspend (DeviceMessage) -> Unit, -) : Device { +) : CachingDevice { - @OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) - override val coroutineContext: CoroutineContext = newCoroutineContext(context.coroutineContext) + + override var actionDescriptors: Collection<ActionDescriptor> = actionDescriptors + internal set + + override var propertyDescriptors: Collection<PropertyDescriptor> = propertyDescriptors + internal set + + override val coroutineContext: CoroutineContext = context.coroutineContext + Job(context.coroutineContext[Job]) private val mutex = Mutex() private val propertyCache = HashMap<String, Meta>() - override var propertyDescriptors: Collection<PropertyDescriptor> = emptyList() - private set - - override var actionDescriptors: Collection<ActionDescriptor> = emptyList() - private set - private val flowInternal = incomingFlow.filter { it.sourceDevice == deviceName - }.shareIn(this, started = SharingStarted.Eagerly).also { - it.onEach { message -> - when (message) { - is PropertyChangedMessage -> mutex.withLock { - propertyCache[message.property] = message.value - } - - is DescriptionMessage -> mutex.withLock { - propertyDescriptors = message.properties - actionDescriptors = message.actions - } - - else -> { - //ignore - } + }.onEach { message -> + when (message) { + is PropertyChangedMessage -> mutex.withLock { + propertyCache[message.property] = message.value } - }.launchIn(this) - } + + else -> { + //ignore + } + } + }.shareIn(this, started = SharingStarted.Eagerly) override val messageFlow: Flow<DeviceMessage> get() = flowInternal @@ -69,7 +69,7 @@ public class DeviceClient( send( PropertyGetMessage(propertyName, targetDevice = deviceName) ) - return flowInternal.filterIsInstance<PropertyChangedMessage>().first { + return messageFlow.filterIsInstance<PropertyChangedMessage>().first { it.property == propertyName }.value } @@ -93,25 +93,181 @@ public class DeviceClient( send( ActionExecuteMessage(actionName, argument, id, targetDevice = deviceName) ) - return flowInternal.filterIsInstance<ActionResultMessage>().first { + return messageFlow.filterIsInstance<ActionResultMessage>().first { it.action == actionName && it.requestId == id }.result } + private val lifecycleStateFlow = messageFlow.filterIsInstance<DeviceLifeCycleMessage>() + .map { it.state }.stateIn(this, started = SharingStarted.Eagerly, LifecycleState.STARTED) + @DFExperimental - override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.OPEN + override val lifecycleState: LifecycleState get() = lifecycleStateFlow.value } /** * Connect to a remote device via this endpoint. * * @param context a [Context] to run device in - * @param endpointName the name of endpoint in Magix to connect to + * @param thisEndpoint the name of this endpoint + * @param deviceEndpoint the name of endpoint in Magix to connect to * @param deviceName the name of device within endpoint */ -public fun MagixEndpoint.remoteDevice(context: Context, endpointName: String, deviceName: Name): DeviceClient { - val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second } - return DeviceClient(context, deviceName, subscription) { - send(DeviceManager.magixFormat, it, endpointName, id = stringUID()) +public suspend fun MagixEndpoint.remoteDevice( + context: Context, + thisEndpoint: String, + deviceEndpoint: String, + deviceName: Name, +): DeviceClient = coroutineScope { + val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(deviceEndpoint)) + .map { it.second } + .filter { + it.sourceDevice == null || it.sourceDevice == deviceName + } + + val deferredDescriptorMessage = CompletableDeferred<DescriptionMessage>() + + launch { + deferredDescriptorMessage.complete( + subscription.filterIsInstance<DescriptionMessage>().first() + ) } + + send( + format = DeviceManager.magixFormat, + payload = GetDescriptionMessage(targetDevice = deviceName), + source = thisEndpoint, + target = deviceEndpoint, + id = stringUID() + ) + + + val descriptionMessage = deferredDescriptorMessage.await() + + DeviceClient( + context = context, + deviceName = deviceName, + propertyDescriptors = descriptionMessage.properties, + actionDescriptors = descriptionMessage.actions, + incomingFlow = subscription + ) { + send( + format = DeviceManager.magixFormat, + payload = it, + source = thisEndpoint, + target = deviceEndpoint, + id = stringUID() + ) + } +} + +/** + * Create a dynamic [DeviceHub] from incoming messages + */ +public suspend fun MagixEndpoint.remoteDeviceHub( + context: Context, + thisEndpoint: String, + deviceEndpoint: String, +): DeviceHub { + val devices = mutableMapOf<Name, DeviceClient>() + val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(deviceEndpoint)).map { it.second } + subscription.filterIsInstance<DescriptionMessage>().onEach { descriptionMessage -> + devices.getOrPut(descriptionMessage.sourceDevice) { + DeviceClient( + context = context, + deviceName = descriptionMessage.sourceDevice, + propertyDescriptors = descriptionMessage.properties, + actionDescriptors = descriptionMessage.actions, + incomingFlow = subscription + ) { + send( + format = DeviceManager.magixFormat, + payload = it, + source = thisEndpoint, + target = deviceEndpoint, + id = stringUID() + ) + } + }.run { + propertyDescriptors = descriptionMessage.properties + } + }.launchIn(context) + + + send( + format = DeviceManager.magixFormat, + payload = GetDescriptionMessage(targetDevice = null), + source = thisEndpoint, + target = deviceEndpoint, + id = stringUID() + ) + + return DeviceHub(devices) +} + +/** + * Request a description update for all devices on an endpoint + */ +public suspend fun MagixEndpoint.requestDeviceUpdate( + thisEndpoint: String, + deviceEndpoint: String, +) { + send( + format = DeviceManager.magixFormat, + payload = GetDescriptionMessage(), + source = thisEndpoint, + target = deviceEndpoint, + id = stringUID() + ) +} + +/** + * Subscribe on specific property of a device without creating a device + */ +public fun <T> MagixEndpoint.controlsPropertyFlow( + endpointName: String, + deviceName: Name, + propertySpec: DevicePropertySpec<*, T>, +): Flow<T> { + val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second } + + return subscription.filterIsInstance<PropertyChangedMessage>() + .filter { message -> + message.sourceDevice == deviceName && message.property == propertySpec.name + }.map { + propertySpec.converter.read(it.value) + } +} + +public suspend fun <T> MagixEndpoint.sendControlsPropertyChange( + sourceEndpointName: String, + targetEndpointName: String, + deviceName: Name, + propertySpec: DevicePropertySpec<*, T>, + value: T, +) { + val message = PropertySetMessage( + property = propertySpec.name, + value = propertySpec.converter.convert(value), + targetDevice = deviceName + ) + send(DeviceManager.magixFormat, message, source = sourceEndpointName, target = targetEndpointName) +} + +/** + * Subscribe on property change messages together with property values + */ +public fun <T> MagixEndpoint.controlsPropertyMessageFlow( + endpointName: String, + deviceName: Name, + propertySpec: DevicePropertySpec<*, T>, +): Flow<Pair<PropertyChangedMessage, T>> { + val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second } + + return subscription.filterIsInstance<PropertyChangedMessage>() + .filter { message -> + message.sourceDevice == deviceName && message.property == propertySpec.name + }.map { + it to propertySpec.converter.read(it.value) + } } \ No newline at end of file diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt new file mode 100644 index 0000000..10b1196 --- /dev/null +++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/clientPropertyAccess.kt @@ -0,0 +1,82 @@ +package space.kscience.controls.client + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.api.getOrReadProperty +import space.kscience.controls.spec.DeviceActionSpec +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.MutableDevicePropertySpec +import space.kscience.controls.spec.name +import space.kscience.dataforge.meta.Meta + + +/** + * An accessor that allows DeviceClient to connect to any property without type checks + */ +public suspend fun <T> DeviceClient.read(propertySpec: DevicePropertySpec<*, T>): T = + propertySpec.converter.readOrNull(readProperty(propertySpec.name)) ?: error("Property read result is not valid") + +public suspend fun <T> DeviceClient.request(propertySpec: DevicePropertySpec<*, T>): T = + propertySpec.converter.read(getOrReadProperty(propertySpec.name)) + +public fun <T> DeviceClient.getCached(propertySpec: DevicePropertySpec<*, T>): T? = + getProperty(propertySpec.name)?.let { propertySpec.converter.read(it) } + + +public suspend fun <T> DeviceClient.write(propertySpec: MutableDevicePropertySpec<*, T>, value: T) { + writeProperty(propertySpec.name, propertySpec.converter.convert(value)) +} + +public fun <T> DeviceClient.writeAsync(propertySpec: MutableDevicePropertySpec<*, T>, value: T): Job = launch { + write(propertySpec, value) +} + +public fun <T> DeviceClient.propertyFlow(spec: DevicePropertySpec<*, T>): Flow<T> = messageFlow + .filterIsInstance<PropertyChangedMessage>() + .filter { it.property == spec.name } + .mapNotNull { spec.converter.readOrNull(it.value) } + +public fun <T> DeviceClient.onPropertyChange( + spec: DevicePropertySpec<*, T>, + scope: CoroutineScope = this, + callback: suspend PropertyChangedMessage.(T) -> Unit, +): Job = messageFlow + .filterIsInstance<PropertyChangedMessage>() + .filter { it.property == spec.name } + .onEach { change -> + val newValue = spec.converter.readOrNull(change.value) + if (newValue != null) { + change.callback(newValue) + } + }.launchIn(scope) + +public fun <T> DeviceClient.useProperty( + spec: DevicePropertySpec<*, T>, + scope: CoroutineScope = this, + callback: suspend (T) -> Unit, +): Job = scope.launch { + callback(read(spec)) + messageFlow + .filterIsInstance<PropertyChangedMessage>() + .filter { it.property == spec.name } + .collect { change -> + val newValue = spec.converter.readOrNull(change.value) + if (newValue != null) { + callback(newValue) + } + } +} + +public suspend fun <I, O> DeviceClient.execute(actionSpec: DeviceActionSpec<*, I, O>, input: I): O { + val inputMeta = actionSpec.inputConverter.convert(input) + val res = execute(actionSpec.name, inputMeta) + return actionSpec.outputConverter.read(res ?: Meta.EMPTY) +} + +public suspend fun <O> DeviceClient.execute(actionSpec: DeviceActionSpec<*, Unit, O>): O { + val res = execute(actionSpec.name, Meta.EMPTY) + return actionSpec.outputConverter.read(res ?: Meta.EMPTY) +} \ No newline at end of file diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt index ed69bff..2ffad2a 100644 --- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt +++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/controlsMagix.kt @@ -1,5 +1,7 @@ package space.kscience.controls.client +import com.benasher44.uuid.uuid4 +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Job import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.launchIn @@ -12,6 +14,8 @@ import space.kscience.controls.manager.respondHubMessage import space.kscience.dataforge.context.error import space.kscience.dataforge.context.logger import space.kscience.magix.api.* +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext internal val controlsMagixFormat: MagixFormat<DeviceMessage> = MagixFormat( @@ -27,22 +31,25 @@ public val DeviceManager.Companion.magixFormat: MagixFormat<DeviceMessage> get() internal fun generateId(request: MagixMessage): String = if (request.id != null) { "${request.id}.response" } else { - "controls[${request.payload.hashCode().toString(16)}" + uuid4().leastSignificantBits.toULong().toString(16) } /** * Communicate with server in [Magix format](https://github.com/waltz-controls/rfc/tree/master/1) + * + * Accepts messages with target that equals [endpointID] or null (broadcast messages) */ public fun DeviceManager.launchMagixService( endpoint: MagixEndpoint, - endpointID: String = controlsMagixFormat.defaultFormat, -): Job = context.launch { - endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID)).onEach { (request, payload) -> - val responsePayload = respondHubMessage(payload) - if (responsePayload != null) { + endpointID: String, + coroutineContext: CoroutineContext = EmptyCoroutineContext, +): Job = context.launch(coroutineContext) { + endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID, null)).onEach { (request, payload) -> + val responsePayload: List<DeviceMessage> = respondHubMessage(payload) + responsePayload.forEach { endpoint.send( format = controlsMagixFormat, - payload = responsePayload, + payload = it, source = endpointID, target = request.sourceEndpoint, id = generateId(request), @@ -50,10 +57,10 @@ public fun DeviceManager.launchMagixService( ) } }.catch { error -> - logger.error(error) { "Error while responding to message: ${error.message}" } + if (error !is CancellationException) logger.error(error) { "Error while responding to message: ${error.message}" } }.launchIn(this) - hubMessageFlow(this).onEach { payload -> + hubMessageFlow().onEach { payload -> endpoint.send( format = controlsMagixFormat, payload = payload, diff --git a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt index d0bdda4..8f3e742 100644 --- a/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt +++ b/controls-magix/src/commonMain/kotlin/space/kscience/controls/client/tangoMagix.kt @@ -5,12 +5,12 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import space.kscience.controls.api.get import space.kscience.controls.api.getOrReadProperty import space.kscience.controls.manager.DeviceManager import space.kscience.dataforge.context.error import space.kscience.dataforge.context.logger import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.names.get import space.kscience.magix.api.* public const val TANGO_MAGIX_FORMAT: String = "tango" @@ -88,7 +88,7 @@ public fun DeviceManager.launchTangoMagix( return context.launch { endpoint.subscribe(tangoMagixFormat).onEach { (request, payload) -> try { - val device = get(payload.device) + val device = devices[payload.device] ?: error("Device ${payload.device} not found") when (payload.action) { TangoAction.read -> { val value = device.getOrReadProperty(payload.name) @@ -99,6 +99,7 @@ public fun DeviceManager.launchTangoMagix( ) } } + TangoAction.write -> { payload.value?.let { value -> device.writeProperty(payload.name, value) @@ -112,6 +113,7 @@ public fun DeviceManager.launchTangoMagix( ) } } + TangoAction.exec -> { val result = device.execute(payload.name, payload.argin) respond(request, payload) { requestPayload -> @@ -121,6 +123,7 @@ public fun DeviceManager.launchTangoMagix( ) } } + TangoAction.pipe -> TODO("Pipe not implemented") } } catch (ex: Exception) { diff --git a/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt new file mode 100644 index 0000000..18a3dfc --- /dev/null +++ b/controls-magix/src/commonTest/kotlin/space/kscience/controls/client/RemoteDeviceConnect.kt @@ -0,0 +1,130 @@ +package space.kscience.controls.client + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.merge +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import space.kscience.controls.api.DeviceHub +import space.kscience.controls.api.DeviceMessage +import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.manager.hubMessageFlow +import space.kscience.controls.manager.install +import space.kscience.controls.manager.respondHubMessage +import space.kscience.controls.spec.* +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.context.request +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.names.asName +import space.kscience.magix.api.MagixEndpoint +import space.kscience.magix.api.MagixMessage +import space.kscience.magix.api.MagixMessageFilter +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.milliseconds + +class VirtualMagixEndpoint(val hub: DeviceHub) : MagixEndpoint { + + private val additionalMessages = MutableSharedFlow<DeviceMessage>(1) + + override fun subscribe( + filter: MagixMessageFilter, + ): Flow<MagixMessage> = merge(hub.hubMessageFlow(), additionalMessages).map { + MagixMessage( + format = DeviceManager.magixFormat.defaultFormat, + payload = MagixEndpoint.magixJson.encodeToJsonElement(DeviceManager.magixFormat.serializer, it), + sourceEndpoint = "device", + ) + } + + override suspend fun broadcast(message: MagixMessage) { + hub.respondHubMessage( + Json.decodeFromJsonElement(DeviceManager.magixFormat.serializer, message.payload) + ).forEach { + additionalMessages.emit(it) + } + } + + override fun close() { + // + } +} + + +internal class RemoteDeviceConnect { + + class TestDevice(context: Context, meta: Meta) : DeviceBySpec<TestDevice>(TestDevice, context, meta) { + private val rng = Random(meta["seed"].int ?: 0) + + private val randomValue get() = rng.nextDouble() + + companion object : DeviceSpec<TestDevice>(), Factory<TestDevice> { + + override fun build(context: Context, meta: Meta): TestDevice = TestDevice(context, meta) + + val value by doubleProperty { randomValue } + + override suspend fun TestDevice.onOpen() { + doRecurring((meta["delay"].int ?: 10).milliseconds) { + read(value) + } + } + } + } + + @Test + fun deviceClient() = runTest { + val context = Context { + plugin(DeviceManager) + } + val deviceManager = context.request(DeviceManager) + + deviceManager.install("test", TestDevice) + + val virtualMagixEndpoint = VirtualMagixEndpoint(deviceManager) + + val remoteDevice: DeviceClient = virtualMagixEndpoint.remoteDevice(context, "client", "device", "test".asName()) + + assertContains(0.0..1.0, remoteDevice.read(TestDevice.value)) + + } + + @Test + fun deviceHub() = runTest { + val context = Context { + plugin(DeviceManager) + } + val deviceManager = context.request(DeviceManager) + + launch { + delay(50) + repeat(10) { + deviceManager.install("test[$it]", TestDevice) + } + } + + val virtualMagixEndpoint = VirtualMagixEndpoint(deviceManager) + + val remoteHub = virtualMagixEndpoint.remoteDeviceHub(context, "client", "device") + + assertEquals(0, remoteHub.devices.size) + + delay(60) + //switch context to use actual delay + withContext(Dispatchers.Default) { + virtualMagixEndpoint.requestDeviceUpdate("client", "device") + delay(30) + assertEquals(10, remoteHub.devices.size) + } + } +} \ No newline at end of file diff --git a/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt new file mode 100644 index 0000000..31f73fc --- /dev/null +++ b/controls-magix/src/jvmTest/kotlin/space/kscience/controls/client/MagixLoopTest.kt @@ -0,0 +1,56 @@ +package space.kscience.controls.client + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import space.kscience.controls.client.RemoteDeviceConnect.TestDevice +import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.manager.install +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.request +import space.kscience.magix.api.MagixEndpoint +import space.kscience.magix.rsocket.rSocketWithWebSockets +import space.kscience.magix.server.startMagixServer +import kotlin.test.Test +import kotlin.test.assertEquals + +class MagixLoopTest { + + @Test + fun realDeviceHub() = runTest { + val context = Context { + coroutineContext(Dispatchers.Default) + plugin(DeviceManager) + } + + val server = context.startMagixServer() + + val deviceManager = context.request(DeviceManager) + + val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") + + deviceManager.launchMagixService(deviceEndpoint, "device") + + val trigger = CompletableDeferred<Unit>() + + context.launch { + repeat(10) { + deviceManager.install("test[$it]", TestDevice) + } + delay(100) + trigger.complete(Unit) + } + + val clientEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") + + val remoteHub = clientEndpoint.remoteDeviceHub(context, "client", "device") + + assertEquals(0, remoteHub.devices.size) + clientEndpoint.requestDeviceUpdate("client", "device") + trigger.join() + assertEquals(10, remoteHub.devices.size) + server.stop() + } +} \ No newline at end of file diff --git a/controls-modbus/README.md b/controls-modbus/README.md index 78d515a..ef52403 100644 --- a/controls-modbus/README.md +++ b/controls-modbus/README.md @@ -14,18 +14,16 @@ Automatically checks consistency. ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-modbus:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-modbus:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-modbus:0.2.0") + implementation("space.kscience:controls-modbus:0.4.0-dev-7") } ``` diff --git a/controls-modbus/build.gradle.kts b/controls-modbus/build.gradle.kts index aee64d5..8ef9ca8 100644 --- a/controls-modbus/build.gradle.kts +++ b/controls-modbus/build.gradle.kts @@ -1,7 +1,7 @@ import space.kscience.gradle.Maturity plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` } @@ -9,10 +9,12 @@ description = """ A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols """.trimIndent() - -dependencies { - api(projects.controlsCore) - api("com.ghgande:j2mod:3.1.1") +kscience { + jvm() + jvmMain { + api(projects.controlsCore) + api(libs.j2mod) + } } readme{ diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt similarity index 73% rename from controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt rename to controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt index 2f56a8a..3b3f91b 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt +++ b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt @@ -1,15 +1,14 @@ package space.kscience.controls.modbus import com.ghgande.j2mod.modbus.procimg.* -import io.ktor.utils.io.core.buildPacket -import io.ktor.utils.io.core.readByteBuffer -import io.ktor.utils.io.core.writeShort +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.io.Buffer import space.kscience.controls.api.Device -import space.kscience.controls.spec.DevicePropertySpec -import space.kscience.controls.spec.WritableDevicePropertySpec -import space.kscience.controls.spec.set -import space.kscience.controls.spec.useProperty +import space.kscience.controls.ports.readShort +import space.kscience.controls.spec.* +import space.kscience.dataforge.io.Binary public class DeviceProcessImageBuilder<D : Device> internal constructor( @@ -29,10 +28,10 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor( public fun bind( key: ModbusRegistryKey.Coil, - propertySpec: WritableDevicePropertySpec<D, Boolean>, + propertySpec: MutableDevicePropertySpec<D, Boolean>, ): ObservableDigitalOut = bind(key) { coil -> coil.addObserver { _, _ -> - device[propertySpec] = coil.isSet + device.writeAsync(propertySpec, coil.isSet) } device.useProperty(propertySpec) { value -> coil.set(value) @@ -89,10 +88,10 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor( public fun bind( key: ModbusRegistryKey.HoldingRegister, - propertySpec: WritableDevicePropertySpec<D, Short>, + propertySpec: MutableDevicePropertySpec<D, Short>, ): ObservableRegister = bind(key) { register -> register.addObserver { _, _ -> - device[propertySpec] = register.toShort() + device.writeAsync(propertySpec, register.toShort()) } device.useProperty(propertySpec) { value -> register.setValue(value) @@ -109,37 +108,63 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor( } device.useProperty(propertySpec) { value -> - val packet = buildPacket { - key.format.writeObject(this, value) - }.readByteBuffer() + val binary = Binary { + key.format.writeTo(this, value) + } registers.forEachIndexed { index, register -> - register.setValue(packet.getShort(index * 2)) + register.setValue(binary.readShort(index * 2)) } } } - public fun <T> bind(key: ModbusRegistryKey.HoldingRange<T>, propertySpec: WritableDevicePropertySpec<D, T>) { + /** + * Trigger [block] if one of register changes. + */ + private fun List<ObservableRegister>.onChange(block: suspend (Buffer) -> Unit) { + var ready = false + + forEach { register -> + register.addObserver { _, _ -> + ready = true + } + } + + device.launch { + val builder = Buffer() + while (isActive) { + delay(1) + if (ready) { + val packet = builder.apply { + forEach { value -> + writeShort(value.toShort()) + } + } + block(packet) + ready = false + } + } + } + } + + public fun <T> bind(key: ModbusRegistryKey.HoldingRange<T>, propertySpec: MutableDevicePropertySpec<D, T>) { val registers = List(key.count) { ObservableRegister() } + registers.forEachIndexed { index, register -> - register.addObserver { _, _ -> - val packet = buildPacket { - registers.forEach { value -> - writeShort(value.toShort()) - } - } - device[propertySpec] = key.format.readObject(packet) - } image.addRegister(key.address + index, register) } + registers.onChange { packet -> + device.write(propertySpec, key.format.readFrom(packet)) + } + device.useProperty(propertySpec) { value -> - val packet = buildPacket { - key.format.writeObject(this, value) - }.readByteBuffer() + val binary = Binary { + key.format.writeTo(this, value) + } registers.forEachIndexed { index, observableRegister -> - observableRegister.setValue(packet.getShort(index * 2)) + observableRegister.setValue(binary.readShort(index * 2)) } } } @@ -182,20 +207,17 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor( val registers = List(key.count) { ObservableRegister() } + registers.forEachIndexed { index, register -> - register.addObserver { _, _ -> - val packet = buildPacket { - registers.forEach { value -> - writeShort(value.toShort()) - } - } - device.launch { - device.action(key.format.readObject(packet)) - } - } image.addRegister(key.address + index, register) } + registers.onChange { packet -> + device.launch { + device.action(key.format.readFrom(packet)) + } + } + return registers } @@ -205,14 +227,16 @@ public class DeviceProcessImageBuilder<D : Device> internal constructor( * Bind the device to Modbus slave (server) image. */ public fun <D : Device> D.bindProcessImage( + unitId: Int = 0, openOnBind: Boolean = true, binding: DeviceProcessImageBuilder<D>.() -> Unit, ): ProcessImage { - val image = SimpleProcessImage() + val image = SimpleProcessImage(unitId) DeviceProcessImageBuilder(this, image).apply(binding) + image.setLocked(true) if (openOnBind) { launch { - open() + start() } } return image diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDevice.kt similarity index 79% rename from controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt rename to controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDevice.kt index ea4330c..2585302 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt +++ b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDevice.kt @@ -5,11 +5,10 @@ import com.ghgande.j2mod.modbus.procimg.InputRegister import com.ghgande.j2mod.modbus.procimg.Register import com.ghgande.j2mod.modbus.procimg.SimpleInputRegister import com.ghgande.j2mod.modbus.util.BitVector -import io.ktor.utils.io.core.ByteReadPacket -import io.ktor.utils.io.core.buildPacket -import io.ktor.utils.io.core.readByteBuffer -import io.ktor.utils.io.core.writeShort +import kotlinx.io.Buffer import space.kscience.controls.api.Device +import space.kscience.dataforge.io.Buffer +import space.kscience.dataforge.io.ByteArray import java.nio.ByteBuffer import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty @@ -21,9 +20,9 @@ import kotlin.reflect.KProperty public interface ModbusDevice : Device { /** - * Client id for this specific device + * Unit id for this specific device */ - public val clientId: Int + public val unitId: Int /** * The modubus master connector @@ -45,7 +44,7 @@ public interface ModbusDevice : Device { public operator fun <T> ModbusRegistryKey.InputRange<T>.getValue(thisRef: Any?, property: KProperty<*>): T { val packet = readInputRegistersToPacket(address, count) - return format.readObject(packet) + return format.readFrom(packet) } @@ -61,8 +60,8 @@ public interface ModbusDevice : Device { } public operator fun <T> ModbusRegistryKey.HoldingRange<T>.getValue(thisRef: Any?, property: KProperty<*>): T { - val packet = readInputRegistersToPacket(address, count) - return format.readObject(packet) + val packet = readHoldingRegistersToPacket(address, count) + return format.readFrom(packet) } public operator fun <T> ModbusRegistryKey.HoldingRange<T>.setValue( @@ -70,9 +69,9 @@ public interface ModbusDevice : Device { property: KProperty<*>, value: T, ) { - val buffer = buildPacket { - format.writeObject(this, value) - }.readByteBuffer() + val buffer = ByteArray { + format.writeTo(this, value) + } writeHoldingRegisters(address, buffer) } @@ -82,35 +81,35 @@ public interface ModbusDevice : Device { * Read multiple sequential modbus coils (bit-values) */ public fun ModbusDevice.readCoils(address: Int, count: Int): BitVector = - master.readCoils(clientId, address, count) + master.readCoils(unitId, address, count) public fun ModbusDevice.readCoil(address: Int): Boolean = - master.readCoils(clientId, address, 1).getBit(0) + master.readCoils(unitId, address, 1).getBit(0) public fun ModbusDevice.writeCoils(address: Int, values: BooleanArray) { val bitVector = BitVector(values.size) values.forEachIndexed { index, value -> bitVector.setBit(index, value) } - master.writeMultipleCoils(clientId, address, bitVector) + master.writeMultipleCoils(unitId, address, bitVector) } public fun ModbusDevice.writeCoil(address: Int, value: Boolean) { - master.writeCoil(clientId, address, value) + master.writeCoil(unitId, address, value) } public fun ModbusDevice.writeCoil(key: ModbusRegistryKey.Coil, value: Boolean) { - master.writeCoil(clientId, key.address, value) + master.writeCoil(unitId, key.address, value) } public fun ModbusDevice.readInputDiscretes(address: Int, count: Int): BitVector = - master.readInputDiscretes(clientId, address, count) + master.readInputDiscretes(unitId, address, count) public fun ModbusDevice.readInputDiscrete(address: Int): Boolean = - master.readInputDiscretes(clientId, address, 1).getBit(0) + master.readInputDiscretes(unitId, address, 1).getBit(0) public fun ModbusDevice.readInputRegisters(address: Int, count: Int): List<InputRegister> = - master.readInputRegisters(clientId, address, count).toList() + master.readInputRegisters(unitId, address, count).toList() private fun Array<out InputRegister>.toBuffer(): ByteBuffer { val buffer: ByteBuffer = ByteBuffer.allocate(size * 2) @@ -122,17 +121,17 @@ private fun Array<out InputRegister>.toBuffer(): ByteBuffer { return buffer } -private fun Array<out InputRegister>.toPacket(): ByteReadPacket = buildPacket { +private fun Array<out InputRegister>.toPacket(): Buffer = Buffer { forEach { value -> writeShort(value.toShort()) } } public fun ModbusDevice.readInputRegistersToBuffer(address: Int, count: Int): ByteBuffer = - master.readInputRegisters(clientId, address, count).toBuffer() + master.readInputRegisters(unitId, address, count).toBuffer() -public fun ModbusDevice.readInputRegistersToPacket(address: Int, count: Int): ByteReadPacket = - master.readInputRegisters(clientId, address, count).toPacket() +public fun ModbusDevice.readInputRegistersToPacket(address: Int, count: Int): Buffer = + master.readInputRegisters(unitId, address, count).toPacket() public fun ModbusDevice.readDoubleInput(address: Int): Double = readInputRegistersToBuffer(address, Double.SIZE_BYTES).getDouble() @@ -141,7 +140,7 @@ public fun ModbusDevice.readInputRegister(address: Int): Short = readInputRegisters(address, 1).first().toShort() public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List<Register> = - master.readMultipleRegisters(clientId, address, count).toList() + master.readMultipleRegisters(unitId, address, count).toList() /** * Read a number of registers to a [ByteBuffer] @@ -149,10 +148,10 @@ public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List<Reg * @param count number of 2-bytes registers to read. Buffer size is 2*[count] */ public fun ModbusDevice.readHoldingRegistersToBuffer(address: Int, count: Int): ByteBuffer = - master.readMultipleRegisters(clientId, address, count).toBuffer() + master.readMultipleRegisters(unitId, address, count).toBuffer() -public fun ModbusDevice.readHoldingRegistersToPacket(address: Int, count: Int): ByteReadPacket = - master.readMultipleRegisters(clientId, address, count).toPacket() +public fun ModbusDevice.readHoldingRegistersToPacket(address: Int, count: Int): Buffer = + master.readMultipleRegisters(unitId, address, count).toPacket() public fun ModbusDevice.readDoubleRegister(address: Int): Double = readHoldingRegistersToBuffer(address, Double.SIZE_BYTES).getDouble() @@ -162,14 +161,14 @@ public fun ModbusDevice.readHoldingRegister(address: Int): Short = public fun ModbusDevice.writeHoldingRegisters(address: Int, values: ShortArray): Int = master.writeMultipleRegisters( - clientId, + unitId, address, Array<Register>(values.size) { SimpleInputRegister(values[it].toInt()) } ) public fun ModbusDevice.writeHoldingRegister(address: Int, value: Short): Int = master.writeSingleRegister( - clientId, + unitId, address, SimpleInputRegister(value.toInt()) ) @@ -183,8 +182,11 @@ public fun ModbusDevice.writeHoldingRegisters(address: Int, buffer: ByteBuffer): return writeHoldingRegisters(address, array) } -public fun ModbusDevice.writeShortRegister(address: Int, value: Short) { - master.writeSingleRegister(address, SimpleInputRegister(value.toInt())) +public fun ModbusDevice.writeHoldingRegisters(address: Int, byteArray: ByteArray): Int { + val buffer = ByteBuffer.wrap(byteArray) + val array: ShortArray = ShortArray(buffer.limit().floorDiv(2)) { buffer.getShort(it * 2) } + + return writeHoldingRegisters(address, array) } public fun ModbusDevice.modbusRegister( diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt similarity index 79% rename from controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt rename to controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt index 916187f..8557f9d 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt +++ b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt @@ -7,7 +7,7 @@ import space.kscience.controls.spec.DeviceBySpec import space.kscience.controls.spec.DeviceSpec import space.kscience.dataforge.context.Context import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.names.NameToken +import space.kscience.dataforge.names.Name /** * A variant of [DeviceBySpec] that includes Modbus RTU/TCP/UDP client @@ -15,21 +15,19 @@ import space.kscience.dataforge.names.NameToken public open class ModbusDeviceBySpec<D: Device>( context: Context, spec: DeviceSpec<D>, - override val clientId: Int, + override val unitId: Int, override val master: AbstractModbusMaster, private val disposeMasterOnClose: Boolean = true, meta: Meta = Meta.EMPTY, ) : ModbusDevice, DeviceBySpec<D>(spec, context, meta){ - override suspend fun open() { + override suspend fun onStart() { master.connect() - super<DeviceBySpec>.open() } - override fun close() { + override suspend fun onStop() { if(disposeMasterOnClose){ master.disconnect() } - super<ModbusDevice>.close() } } @@ -37,12 +35,12 @@ public open class ModbusDeviceBySpec<D: Device>( public class ModbusHub( public val context: Context, public val masterBuilder: () -> AbstractModbusMaster, - public val specs: Map<NameToken, Pair<Int, DeviceSpec<*>>>, + public val specs: Map<Name, Pair<Int, DeviceSpec<*>>>, ) : DeviceHub, AutoCloseable { public val master: AbstractModbusMaster by lazy(masterBuilder) - override val devices: Map<NameToken, ModbusDevice> by lazy { + override val devices: Map<Name, ModbusDevice> by lazy { specs.mapValues { (_, pair) -> ModbusDeviceBySpec( context, diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt similarity index 58% rename from controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt rename to controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt index a6fc894..1d81d1b 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt +++ b/controls-modbus/src/jvmMain/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt @@ -1,8 +1,15 @@ package space.kscience.controls.modbus +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put import space.kscience.dataforge.io.IOFormat +/** + * Modbus registry key + */ public sealed class ModbusRegistryKey { public abstract val address: Int public open val count: Int = 1 @@ -25,6 +32,9 @@ public sealed class ModbusRegistryKey { override fun toString(): String = "InputRegister(address=$address)" } + /** + * A range of read-only register encoding a single value + */ public class InputRange<T>( address: Int, override val count: Int, @@ -36,10 +46,16 @@ public sealed class ModbusRegistryKey { } + /** + * A single read-write register + */ public open class HoldingRegister(override val address: Int) : ModbusRegistryKey() { override fun toString(): String = "HoldingRegister(address=$address)" } + /** + * A range of read-write registers encoding a single value + */ public class HoldingRange<T>( address: Int, override val count: Int, @@ -52,6 +68,9 @@ public sealed class ModbusRegistryKey { } } +/** + * A base class for modbus registers + */ public abstract class ModbusRegistryMap { private val _entries: MutableMap<ModbusRegistryKey, String> = mutableMapOf<ModbusRegistryKey, String>() @@ -63,36 +82,56 @@ public abstract class ModbusRegistryMap { return key } + /** + * Register a [ModbusRegistryKey.Coil] key and return it + */ protected fun coil(address: Int, description: String = ""): ModbusRegistryKey.Coil = register(ModbusRegistryKey.Coil(address), description) + /** + * Register a [ModbusRegistryKey.DiscreteInput] key and return it + */ protected fun discrete(address: Int, description: String = ""): ModbusRegistryKey.DiscreteInput = register(ModbusRegistryKey.DiscreteInput(address), description) + /** + * Register a [ModbusRegistryKey.InputRegister] key and return it + */ protected fun input(address: Int, description: String = ""): ModbusRegistryKey.InputRegister = register(ModbusRegistryKey.InputRegister(address), description) + /** + * Register a [ModbusRegistryKey.InputRange] key and return it + */ protected fun <T> input( address: Int, count: Int, reader: IOFormat<T>, description: String = "", - ): ModbusRegistryKey.InputRange<T> = - register(ModbusRegistryKey.InputRange(address, count, reader), description) + ): ModbusRegistryKey.InputRange<T> = register(ModbusRegistryKey.InputRange(address, count, reader), description) + /** + * Register a [ModbusRegistryKey.HoldingRegister] key and return it + */ protected fun register(address: Int, description: String = ""): ModbusRegistryKey.HoldingRegister = register(ModbusRegistryKey.HoldingRegister(address), description) + /** + * Register a [ModbusRegistryKey.HoldingRange] key and return it + */ protected fun <T> register( address: Int, count: Int, format: IOFormat<T>, description: String = "", - ): ModbusRegistryKey.HoldingRange<T> = - register(ModbusRegistryKey.HoldingRange(address, count, format), description) + ): ModbusRegistryKey.HoldingRange<T> = register(ModbusRegistryKey.HoldingRange(address, count, format), description) public companion object { + + /** + * Validate the register map. Throw an error if the map is invalid + */ public fun validate(map: ModbusRegistryMap) { var lastCoil: ModbusRegistryKey.Coil? = null var lastDiscreteInput: ModbusRegistryKey.DiscreteInput? = null @@ -127,36 +166,62 @@ public abstract class ModbusRegistryMap { } } - private val ModbusRegistryKey.sectionNumber - get() = when (this) { - is ModbusRegistryKey.Coil -> 1 - is ModbusRegistryKey.DiscreteInput -> 2 - is ModbusRegistryKey.HoldingRegister -> 4 - is ModbusRegistryKey.InputRegister -> 3 - } + } +} - public fun print(map: ModbusRegistryMap, to: Appendable = System.out) { - validate(map) - map.entries.entries - .sortedWith( - Comparator.comparingInt<Map.Entry<ModbusRegistryKey, String>?> { it.key.sectionNumber } - .thenComparingInt { it.key.address } - ) - .forEach { (key, description) -> - val typeString = when (key) { - is ModbusRegistryKey.Coil -> "Coil" - is ModbusRegistryKey.DiscreteInput -> "Discrete" - is ModbusRegistryKey.HoldingRegister -> "Register" - is ModbusRegistryKey.InputRegister -> "Input" - } - val rangeString = if (key.count == 1) { - key.address.toString() - } else { - "${key.address} - ${key.address + key.count}" - } - to.appendLine("${typeString}\t$rangeString\t$description") - } +private val ModbusRegistryKey.sectionNumber + get() = when (this) { + is ModbusRegistryKey.Coil -> 1 + is ModbusRegistryKey.DiscreteInput -> 2 + is ModbusRegistryKey.HoldingRegister -> 4 + is ModbusRegistryKey.InputRegister -> 3 + } + +public fun ModbusRegistryMap.print(to: Appendable = System.out) { + ModbusRegistryMap.validate(this) + entries.entries + .sortedWith( + Comparator.comparingInt<Map.Entry<ModbusRegistryKey, String>?> { it.key.sectionNumber } + .thenComparingInt { it.key.address } + ) + .forEach { (key, description) -> + val typeString = when (key) { + is ModbusRegistryKey.Coil -> "Coil" + is ModbusRegistryKey.DiscreteInput -> "Discrete" + is ModbusRegistryKey.HoldingRegister -> "Register" + is ModbusRegistryKey.InputRegister -> "Input" + } + val rangeString = if (key.count == 1) { + key.address.toString() + } else { + "${key.address} - ${key.address + key.count - 1}" + } + to.appendLine("${typeString}\t$rangeString\t$description") } +} + +public fun ModbusRegistryMap.toJson(): JsonArray = buildJsonArray { + ModbusRegistryMap.validate(this@toJson) + entries.forEach { (key, description) -> + + val entry = buildJsonObject { + put( + "type", + when (key) { + is ModbusRegistryKey.Coil -> "Coil" + is ModbusRegistryKey.DiscreteInput -> "Discrete" + is ModbusRegistryKey.HoldingRegister -> "Register" + is ModbusRegistryKey.InputRegister -> "Input" + } + ) + put("address", key.address) + if (key.count > 1) { + put("count", key.count) + } + put("description", description) + } + + add(entry) } } diff --git a/controls-opcua/README.md b/controls-opcua/README.md index 8cdd373..1d65c9d 100644 --- a/controls-opcua/README.md +++ b/controls-opcua/README.md @@ -12,18 +12,16 @@ A client and server connectors for OPC-UA via Eclipse Milo ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-opcua:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-opcua:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-opcua:0.2.0") + implementation("space.kscience:controls-opcua:0.4.0-dev-7") } ``` diff --git a/controls-opcua/build.gradle.kts b/controls-opcua/build.gradle.kts index e7e1da8..686ae62 100644 --- a/controls-opcua/build.gradle.kts +++ b/controls-opcua/build.gradle.kts @@ -11,15 +11,13 @@ description = """ val ktorVersion: String by rootProject.extra -val miloVersion: String = "0.6.10" - dependencies { api(projects.controlsCore) api(spclibs.kotlinx.coroutines.jdk8) - api("org.eclipse.milo:sdk-client:$miloVersion") - api("org.eclipse.milo:bsd-parser:$miloVersion") - api("org.eclipse.milo:sdk-server:$miloVersion") + api(libs.milo.client) + api(libs.milo.parser) + api(libs.milo.server) testImplementation(spclibs.kotlinx.coroutines.test) } diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt index 574343e..48aa6dc 100644 --- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt +++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/MetaBsdParser.kt @@ -56,6 +56,7 @@ internal class MetaEnumCodec : OpcUaBinaryDataTypeCodec<Number> { internal fun opcToMeta(value: Any?): Meta = when (value) { null -> Meta(Null) + is Variant -> opcToMeta(value.value) is Meta -> value is Value -> Meta(value) is Number -> when (value) { @@ -79,12 +80,17 @@ internal fun opcToMeta(value: Any?): Meta = when (value) { "text" put value.text?.asValue() } is DataValue -> Meta { - "value" put opcToMeta(value.value) // need SerializationContext to do that properly - value.statusCode?.value?.let { "status" put Meta(it.asValue()) } - value.sourceTime?.javaInstant?.let { "sourceTime" put it.toKotlinInstant().toMeta() } - value.sourcePicoseconds?.let { "sourcePicoseconds" put Meta(it.asValue()) } - value.serverTime?.javaInstant?.let { "serverTime" put it.toKotlinInstant().toMeta() } - value.serverPicoseconds?.let { "serverPicoseconds" put Meta(it.asValue()) } + val variant= opcToMeta(value.value) + update(variant)// need SerializationContext to do that properly + //TODO remove after DF 0.7.2 + this.value = variant.value + "@opc" put { + value.statusCode?.value?.let { "status" put Meta(it.asValue()) } + value.sourceTime?.javaInstant?.let { "sourceTime" put it.toKotlinInstant().toMeta() } + value.sourcePicoseconds?.let { "sourcePicoseconds" put Meta(it.asValue()) } + value.serverTime?.javaInstant?.let { "serverTime" put it.toKotlinInstant().toMeta() } + value.serverPicoseconds?.let { "serverPicoseconds" put Meta(it.asValue()) } + } } is ByteString -> Meta(value.bytesOrEmpty().asValue()) is XmlElement -> Meta(value.fragment?.asValue() ?: Null) @@ -107,7 +113,7 @@ internal class MetaStructureCodec( override fun createStructure(name: String, members: LinkedHashMap<String, Meta>): Meta = Meta { members.forEach { (property: String, value: Meta?) -> - setMeta(Name.parse(property), value) + set(Name.parse(property), value) } } @@ -147,7 +153,7 @@ internal class MetaStructureCodec( "Float" -> member.value?.numberOrNull?.toFloat() "Double" -> member.value?.numberOrNull?.toDouble() "String" -> member.string - "DateTime" -> DateTime(member.instant().toJavaInstant()) + "DateTime" -> member.instant?.toJavaInstant()?.let { DateTime(it) } "Guid" -> member.string?.let { UUID.fromString(it) } "ByteString" -> member.value?.list?.let { list -> ByteString(list.map { it.number.toByte() }.toByteArray()) diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt index dd83e58..41369dc 100644 --- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt +++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDevice.kt @@ -9,8 +9,8 @@ import org.eclipse.milo.opcua.stack.core.types.builtin.* import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn import space.kscience.controls.api.Device import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter import space.kscience.dataforge.meta.MetaSerializer -import space.kscience.dataforge.meta.transformations.MetaConverter import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty @@ -34,7 +34,7 @@ public suspend inline fun <reified T: Any> OpcUaDevice.readOpcWithTime( converter: MetaConverter<T>, magAge: Double = 500.0 ): Pair<T, DateTime> { - val data = client.readValue(magAge, TimestampsToReturn.Server, nodeId).await() + val data: DataValue = client.readValue(magAge, TimestampsToReturn.Server, nodeId).await() val time = data.serverTime ?: error("No server time provided") val meta: Meta = when (val content = data.value.value) { is T -> return content to time @@ -43,7 +43,7 @@ public suspend inline fun <reified T: Any> OpcUaDevice.readOpcWithTime( else -> error("Incompatible OPC property value $content") } - val res: T = converter.metaToObject(meta) ?: error("Meta $meta could not be converted to ${T::class}") + val res: T = converter.read(meta) return res to time } @@ -69,7 +69,7 @@ public suspend inline fun <reified T> OpcUaDevice.readOpc( else -> error("Incompatible OPC property value $content") } - return converter.metaToObject(meta) ?: error("Meta $meta could not be converted to ${T::class}") + return converter.readOrNull(meta) ?: error("Meta $meta could not be converted to ${T::class}") } public suspend inline fun <reified T> OpcUaDevice.writeOpc( @@ -77,7 +77,7 @@ public suspend inline fun <reified T> OpcUaDevice.writeOpc( converter: MetaConverter<T>, value: T ): StatusCode { - val meta = converter.objectToMeta(value) + val meta = converter.convert(value) return client.writeValue(nodeId, DataValue(Variant(meta))).await() } diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt index eb9b688..ea85719 100644 --- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt +++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/client/OpcUaDeviceBySpec.kt @@ -31,7 +31,7 @@ public class MiloConfiguration : Scheme() { public var endpointUrl: String by string { error("Endpoint url is not defined") } - public var username: MiloUsername? by specOrNull(MiloUsername) + public var username: MiloUsername? by schemeOrNull(MiloUsername) public var securityPolicy: SecurityPolicy by enum(SecurityPolicy.None) @@ -63,8 +63,7 @@ public open class OpcUaDeviceBySpec<D : Device>( } } - override fun close() { + override suspend fun onStop() { client.disconnect() - super<DeviceBySpec>.close() } } diff --git a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt index 010c2c0..aa73890 100644 --- a/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt +++ b/controls-opcua/src/main/kotlin/space/kscience/controls/opcua/server/DeviceNameSpace.kt @@ -2,7 +2,6 @@ package space.kscience.controls.opcua.server import kotlinx.coroutines.launch import kotlinx.datetime.toJavaInstant -import kotlinx.serialization.json.Json import org.eclipse.milo.opcua.sdk.core.AccessLevel import org.eclipse.milo.opcua.sdk.core.Reference import org.eclipse.milo.opcua.sdk.server.Lifecycle @@ -19,19 +18,17 @@ import org.eclipse.milo.opcua.stack.core.AttributeId import org.eclipse.milo.opcua.stack.core.Identifiers import org.eclipse.milo.opcua.stack.core.types.builtin.DateTime import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText -import space.kscience.controls.api.Device -import space.kscience.controls.api.DeviceHub -import space.kscience.controls.api.PropertyDescriptor -import space.kscience.controls.api.onPropertyChange +import space.kscience.controls.api.* import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.opcua.client.opcToMeta import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.MetaSerializer import space.kscience.dataforge.meta.ValueType import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.plus -public operator fun Device.get(propertyDescriptor: PropertyDescriptor): Meta? = getProperty(propertyDescriptor.name) +public operator fun CachingDevice.get(propertyDescriptor: PropertyDescriptor): Meta? = + getProperty(propertyDescriptor.name) public suspend fun Device.read(propertyDescriptor: PropertyDescriptor): Meta = readProperty(propertyDescriptor.name) @@ -41,29 +38,11 @@ https://github.com/eclipse/milo/blob/master/milo-examples/server-examples/src/ma public class DeviceNameSpace( server: OpcUaServer, - public val deviceManager: DeviceManager + public val deviceManager: DeviceManager, ) : ManagedNamespaceWithLifecycle(server, NAMESPACE_URI) { private val subscription = SubscriptionModel(server, this) - init { - lifecycleManager.addLifecycle(subscription) - - lifecycleManager.addStartupTask { - nodeContext.registerHub(deviceManager, Name.EMPTY) - } - - lifecycleManager.addLifecycle(object : Lifecycle { - override fun startup() { - server.addressSpaceManager.register(this@DeviceNameSpace) - } - - override fun shutdown() { - server.addressSpaceManager.unregister(this@DeviceNameSpace) - } - }) - } - private fun UaFolderNode.registerDeviceNodes(deviceName: Name, device: Device) { val nodes = device.propertyDescriptors.associate { descriptor -> val propertyName = descriptor.name @@ -73,18 +52,21 @@ public class DeviceNameSpace( //for now, use DF paths as ids nodeId = newNodeId("${deviceName.tokens.joinToString("/")}/$propertyName") when { - descriptor.readable && descriptor.writable -> { + descriptor.readable && descriptor.mutable -> { setAccessLevel(AccessLevel.READ_WRITE) setUserAccessLevel(AccessLevel.READ_WRITE) } - descriptor.writable -> { + + descriptor.mutable -> { setAccessLevel(AccessLevel.WRITE_ONLY) setUserAccessLevel(AccessLevel.WRITE_ONLY) } + descriptor.readable -> { setAccessLevel(AccessLevel.READ_ONLY) setUserAccessLevel(AccessLevel.READ_ONLY) } + else -> { setAccessLevel(AccessLevel.NONE) setUserAccessLevel(AccessLevel.NONE) @@ -93,7 +75,7 @@ public class DeviceNameSpace( browseName = newQualifiedName(propertyName) displayName = LocalizedText.english(propertyName) - dataType = if (descriptor.metaDescriptor.children.isNotEmpty()) { + dataType = if (descriptor.metaDescriptor.nodes.isNotEmpty()) { Identifiers.String } else when (descriptor.metaDescriptor.valueTypes?.first()) { null, ValueType.STRING, ValueType.NULL -> Identifiers.String @@ -106,25 +88,24 @@ public class DeviceNameSpace( setTypeDefinition(Identifiers.BaseDataVariableType) }.build() - - device[descriptor]?.toOpc(sourceTime = null, serverTime = null)?.let { - node.value = it + // Update initial value, but only if it is cached + if (device is CachingDevice) { + device[descriptor]?.toOpc(sourceTime = null, serverTime = null)?.let { + node.value = it + } } - /** - * Subscribe to node value changes - */ - node.addAttributeObserver { _: UaNode, attributeId: AttributeId, value: Any -> - if (attributeId == AttributeId.Value) { - val meta: Meta = when (value) { - is Meta -> value - is Boolean -> Meta(value) - is Number -> Meta(value) - is String -> Json.decodeFromString(MetaSerializer, value) - else -> return@addAttributeObserver //TODO("other types not implemented") - } - deviceManager.context.launch { - device.writeProperty(propertyName, meta) + if (descriptor.mutable) { + + /** + * Subscribe to node value changes + */ + node.addAttributeObserver { _: UaNode, attributeId: AttributeId, value: Any? -> + if (attributeId == AttributeId.Value) { + val meta: Meta = opcToMeta(value) + deviceManager.context.launch { + device.writeProperty(propertyName, meta) + } } } } @@ -137,8 +118,11 @@ public class DeviceNameSpace( //Subscribe on properties updates device.onPropertyChange { nodes[property]?.let { node -> - val sourceTime = time?.let { DateTime(it.toJavaInstant()) } - node.value = value.toOpc(sourceTime = sourceTime) + val sourceTime = DateTime(time.toJavaInstant()) + val newValue = value.toOpc(sourceTime = sourceTime) + if (node.value.value != newValue.value) { + node.value = newValue + } } } //recursively add sub-devices @@ -169,6 +153,24 @@ public class DeviceNameSpace( } } + init { + lifecycleManager.addLifecycle(subscription) + + lifecycleManager.addStartupTask { + nodeContext.registerHub(deviceManager, Name.EMPTY) + } + + lifecycleManager.addLifecycle(object : Lifecycle { + override fun startup() { + server.addressSpaceManager.register(this@DeviceNameSpace) + } + + override fun shutdown() { + server.addressSpaceManager.unregister(this@DeviceNameSpace) + } + }) + } + override fun onDataItemsCreated(dataItems: List<DataItem?>?) { subscription.onDataItemsCreated(dataItems) } diff --git a/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt b/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt index eedafe7..b4d63ad 100644 --- a/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt +++ b/controls-opcua/src/test/kotlin/space/kscience/controls/opcua/client/OpcUaClientTest.kt @@ -7,7 +7,7 @@ import org.junit.jupiter.api.Test import space.kscience.controls.spec.DeviceSpec import space.kscience.controls.spec.doubleProperty import space.kscience.controls.spec.read -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.dataforge.meta.MetaConverter import kotlin.test.Ignore class OpcUaClientTest { @@ -29,7 +29,7 @@ class OpcUaClientTest { return DemoOpcUaDevice(config) } - val randomDouble by doubleProperty(read = DemoOpcUaDevice::readRandomDouble) + val randomDouble by doubleProperty { readRandomDouble() } } @@ -40,9 +40,10 @@ class OpcUaClientTest { @Test @Ignore fun testReadDouble() = runTest { - DemoOpcUaDevice.build().use{ - println(it.read(DemoOpcUaDevice.randomDouble)) - } + val device = DemoOpcUaDevice.build() + device.start() + println(device.read(DemoOpcUaDevice.randomDouble)) + device.stop() } } \ No newline at end of file diff --git a/controls-pi/README.md b/controls-pi/README.md index cd9ee0a..9c36e60 100644 --- a/controls-pi/README.md +++ b/controls-pi/README.md @@ -6,18 +6,16 @@ Utils to work with controls-kt on Raspberry pi ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-pi:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-pi:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-pi:0.2.0") + implementation("space.kscience:controls-pi:0.4.0-dev-7") } ``` diff --git a/controls-pi/api/controls-pi.api b/controls-pi/api/controls-pi.api index 2fdaf2d..34bfd53 100644 --- a/controls-pi/api/controls-pi.api +++ b/controls-pi/api/controls-pi.api @@ -1,6 +1,10 @@ public final class space/kscience/controls/pi/PiPlugin : space/kscience/dataforge/context/AbstractPlugin { public static final field Companion Lspace/kscience/controls/pi/PiPlugin$Companion; public fun <init> ()V + public fun content (Ljava/lang/String;)Ljava/util/Map; + public fun detach ()V + public final fun getDevices ()Lspace/kscience/controls/manager/DeviceManager; + public final fun getPiContext ()Lcom/pi4j/context/Context; public final fun getPorts ()Lspace/kscience/controls/ports/Ports; public fun getTag ()Lspace/kscience/dataforge/context/PluginTag; } @@ -8,15 +12,16 @@ public final class space/kscience/controls/pi/PiPlugin : space/kscience/dataforg public final class space/kscience/controls/pi/PiPlugin$Companion : space/kscience/dataforge/context/PluginFactory { public synthetic fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object; public fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/pi/PiPlugin; + public final fun createPiContext (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lcom/pi4j/context/Context; public fun getTag ()Lspace/kscience/dataforge/context/PluginTag; } public final class space/kscience/controls/pi/PiSerialPort : space/kscience/controls/ports/AbstractPort { public static final field Companion Lspace/kscience/controls/pi/PiSerialPort$Companion; - public fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;)V - public synthetic fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function0;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;)V + public synthetic fun <init> (Lspace/kscience/dataforge/context/Context;Lkotlin/coroutines/CoroutineContext;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public fun close ()V - public final fun getSerialBuilder ()Lkotlin/jvm/functions/Function0; + public final fun getSerialBuilder ()Lkotlin/jvm/functions/Function1; } public final class space/kscience/controls/pi/PiSerialPort$Companion : space/kscience/controls/ports/PortFactory { diff --git a/controls-pi/build.gradle.kts b/controls-pi/build.gradle.kts index a763396..850173d 100644 --- a/controls-pi/build.gradle.kts +++ b/controls-pi/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` } @@ -7,10 +7,15 @@ description = """ Utils to work with controls-kt on Raspberry pi """.trimIndent() -dependencies{ - api(project(":controls-core")) - api("com.pi4j:pi4j-ktx:2.4.0") // Kotlin DSL - api("com.pi4j:pi4j-core:2.3.0") - api("com.pi4j:pi4j-plugin-raspberrypi:2.3.0") - api("com.pi4j:pi4j-plugin-pigpio:2.3.0") +kscience { + jvm() + + + jvmMain { + api(project(":controls-core")) + api(libs.pi4j.ktx) // Kotlin DSL + api(libs.pi4j.core) + api(libs.pi4j.plugin.raspberrypi) + api(libs.pi4j.plugin.pigpio) + } } \ No newline at end of file diff --git a/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt new file mode 100644 index 0000000..019ac9e --- /dev/null +++ b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/AsynchronousPiPort.kt @@ -0,0 +1,97 @@ +package space.kscience.controls.pi + +import com.pi4j.io.serial.Baud +import com.pi4j.io.serial.Serial +import com.pi4j.io.serial.SerialConfigBuilder +import com.pi4j.ktx.io.serial +import kotlinx.coroutines.* +import space.kscience.controls.api.LifecycleState +import space.kscience.controls.ports.AbstractAsynchronousPort +import space.kscience.controls.ports.AsynchronousPort +import space.kscience.controls.ports.copyToArray +import space.kscience.dataforge.context.* +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.enum +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.string +import java.nio.ByteBuffer +import kotlin.coroutines.CoroutineContext + +public class AsynchronousPiPort( + context: Context, + meta: Meta, + private val serial: Serial, + coroutineContext: CoroutineContext = context.coroutineContext, +) : AbstractAsynchronousPort(context, meta, coroutineContext) { + + + private var listenerJob: Job? = null + override fun onOpen() { + serial.open() + listenerJob = this.scope.launch(Dispatchers.IO) { + val buffer = ByteBuffer.allocate(1024) + while (isActive) { + try { + val num = serial.read(buffer) + if (num > 0) { + receive(buffer.copyToArray(num)) + } + if (num < 0) cancel("The input channel is exhausted") + } catch (ex: Exception) { + logger.error(ex) { "Channel read error" } + delay(1000) + } + } + } + } + + override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) { + serial.write(data) + } + + + override val lifecycleState: LifecycleState + get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED + + override suspend fun stop() { + listenerJob?.cancel() + serial.close() + } + + public companion object : Factory<AsynchronousPort> { + + + public fun build( + context: Context, + device: String, + block: SerialConfigBuilder.() -> Unit, + ): AsynchronousPiPort { + val meta = Meta { + "name" put "pi://$device" + "type" put "serial" + } + val pi = context.request(PiPlugin) + + val serial = pi.piContext.serial(device, block) + return AsynchronousPiPort(context, meta, serial) + } + + public suspend fun start( + context: Context, + device: String, + block: SerialConfigBuilder.() -> Unit, + ): AsynchronousPiPort = build(context, device, block).apply { start() } + + override fun build(context: Context, meta: Meta): AsynchronousPort { + val device: String = meta["device"].string ?: error("Device name not defined") + val baudRate: Baud = meta["baudRate"].enum<Baud>() ?: Baud._9600 + val pi = context.request(PiPlugin) + val serial = pi.piContext.serial(device) { + baud8N1(baudRate) + } + return AsynchronousPiPort(context, meta, serial) + } + + } +} + diff --git a/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/PiPlugin.kt b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/PiPlugin.kt new file mode 100644 index 0000000..0f2bb02 --- /dev/null +++ b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/PiPlugin.kt @@ -0,0 +1,49 @@ +package space.kscience.controls.pi + +import com.pi4j.Pi4J +import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.ports.Ports +import space.kscience.dataforge.context.AbstractPlugin +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.PluginFactory +import space.kscience.dataforge.context.PluginTag +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName +import com.pi4j.context.Context as PiContext + +public class PiPlugin : AbstractPlugin() { + public val ports: Ports by require(Ports) + public val devices: DeviceManager by require(DeviceManager) + + override val tag: PluginTag get() = Companion.tag + + public val piContext: PiContext by lazy { createPiContext(context, meta) } + + override fun content(target: String): Map<Name, Any> = when (target) { + Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf( + "serial".asName() to AsynchronousPiPort, + ) + Ports.SYNCHRONOUS_PORT_TYPE -> mapOf( + "serial".asName() to SynchronousPiPort, + ) + + else -> super.content(target) + } + + override fun detach() { + piContext.shutdown() + super.detach() + } + + public companion object : PluginFactory<PiPlugin> { + + override val tag: PluginTag = PluginTag("controls.ports.pi", group = PluginTag.DATAFORGE_GROUP) + + override fun build(context: Context, meta: Meta): PiPlugin = PiPlugin() + + @Suppress("UNUSED_PARAMETER") + public fun createPiContext(context: Context, meta: Meta): PiContext = Pi4J.newAutoContext() + + } +} \ No newline at end of file diff --git a/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt new file mode 100644 index 0000000..d91cd6c --- /dev/null +++ b/controls-pi/src/jvmMain/kotlin/space/kscience/controls/pi/SynchronousPiPort.kt @@ -0,0 +1,110 @@ +package space.kscience.controls.pi + +import com.pi4j.io.serial.Baud +import com.pi4j.io.serial.Serial +import com.pi4j.io.serial.SerialConfigBuilder +import com.pi4j.ktx.io.serial +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import space.kscience.controls.api.LifecycleState +import space.kscience.controls.ports.SynchronousPort +import space.kscience.controls.ports.copyToArray +import space.kscience.dataforge.context.* +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.enum +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.string +import java.nio.ByteBuffer + +public class SynchronousPiPort( + override val context: Context, + public val meta: Meta, + private val serial: Serial, + private val mutex: Mutex = Mutex(), +) : SynchronousPort { + + private val pi = context.request(PiPlugin) + + override suspend fun start() { + serial.open() + } + + override val lifecycleState: LifecycleState + get() = if(serial.isOpen) LifecycleState.STARTED else LifecycleState.STOPPED + + override suspend fun <R> respond( + request: ByteArray, + transform: suspend Flow<ByteArray>.() -> R, + ): R = mutex.withLock { + serial.drain() + serial.write(request) + flow<ByteArray> { + val buffer = ByteBuffer.allocate(1024) + while (serial.isOpen) { + try { + val num = serial.read(buffer) + if (num > 0) { + emit(buffer.copyToArray(num)) + } + if (num < 0) break + } catch (ex: Exception) { + logger.error(ex) { "Channel read error" } + delay(1000) + } + } + }.transform() + } + + override suspend fun respondFixedMessageSize(request: ByteArray, responseSize: Int): ByteArray = mutex.withLock { + runInterruptible { + serial.drain() + serial.write(request) + serial.readNBytes(responseSize) + } + } + + override suspend fun stop() { + serial.close() + } + + public companion object : Factory<SynchronousPort> { + + + public fun build( + context: Context, + device: String, + block: SerialConfigBuilder.() -> Unit, + ): SynchronousPiPort { + val meta = Meta { + "name" put "pi://$device" + "type" put "serial" + } + val pi = context.request(PiPlugin) + + val serial = pi.piContext.serial(device, block) + return SynchronousPiPort(context, meta, serial) + } + + public suspend fun start( + context: Context, + device: String, + block: SerialConfigBuilder.() -> Unit, + ): SynchronousPiPort = build(context, device, block).apply { start() } + + override fun build(context: Context, meta: Meta): SynchronousPiPort { + val device: String = meta["device"].string ?: error("Device name not defined") + val baudRate: Baud = meta["baudRate"].enum<Baud>() ?: Baud._9600 + val pi = context.request(PiPlugin) + val serial = pi.piContext.serial(device) { + baud8N1(baudRate) + } + return SynchronousPiPort(context, meta, serial) + } + + } +} + diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt deleted file mode 100644 index 547a142..0000000 --- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiPlugin.kt +++ /dev/null @@ -1,22 +0,0 @@ -package space.kscience.controls.pi - -import space.kscience.controls.ports.Ports -import space.kscience.dataforge.context.AbstractPlugin -import space.kscience.dataforge.context.Context -import space.kscience.dataforge.context.PluginFactory -import space.kscience.dataforge.context.PluginTag -import space.kscience.dataforge.meta.Meta - -public class PiPlugin : AbstractPlugin() { - public val ports: Ports by require(Ports) - - override val tag: PluginTag get() = Companion.tag - - public companion object : PluginFactory<PiPlugin> { - - override val tag: PluginTag = PluginTag("controls.ports.pi", group = PluginTag.DATAFORGE_GROUP) - - override fun build(context: Context, meta: Meta): PiPlugin = PiPlugin() - - } -} \ No newline at end of file diff --git a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt b/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt deleted file mode 100644 index 4924b8d..0000000 --- a/controls-pi/src/main/kotlin/space/kscience/controls/pi/PiSerialPort.kt +++ /dev/null @@ -1,75 +0,0 @@ -package space.kscience.controls.pi - -import com.pi4j.Pi4J -import com.pi4j.io.serial.Baud -import com.pi4j.io.serial.Serial -import com.pi4j.io.serial.SerialConfigBuilder -import com.pi4j.ktx.io.serial -import kotlinx.coroutines.* -import space.kscience.controls.ports.AbstractPort -import space.kscience.controls.ports.Port -import space.kscience.controls.ports.PortFactory -import space.kscience.controls.ports.toArray -import space.kscience.dataforge.context.Context -import space.kscience.dataforge.context.error -import space.kscience.dataforge.context.logger -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.enum -import space.kscience.dataforge.meta.get -import space.kscience.dataforge.meta.string -import java.nio.ByteBuffer -import kotlin.coroutines.CoroutineContext - -public class PiSerialPort( - context: Context, - coroutineContext: CoroutineContext = context.coroutineContext, - public val serialBuilder: () -> Serial, -) : AbstractPort(context, coroutineContext) { - - private val serial: Serial by lazy { serialBuilder() } - - - private val listenerJob = this.scope.launch(Dispatchers.IO) { - val buffer = ByteBuffer.allocate(1024) - while (isActive) { - try { - val num = serial.read(buffer) - if (num > 0) { - receive(buffer.toArray(num)) - } - if (num < 0) cancel("The input channel is exhausted") - } catch (ex: Exception) { - logger.error(ex) { "Channel read error" } - delay(1000) - } - } - } - - override suspend fun write(data: ByteArray): Unit = withContext(Dispatchers.IO) { - serial.write(data) - } - - override fun close() { - listenerJob.cancel() - serial.close() - } - - public companion object : PortFactory { - override val type: String get() = "pi" - - public fun open(context: Context, device: String, block: SerialConfigBuilder.() -> Unit): PiSerialPort = - PiSerialPort(context) { - Pi4J.newAutoContext().serial(device, block) - } - - override fun build(context: Context, meta: Meta): Port = PiSerialPort(context) { - val device: String = meta["device"].string ?: error("Device name not defined") - val baudRate: Baud = meta["baudRate"].enum<Baud>() ?: Baud._9600 - Pi4J.newAutoContext().serial(device) { - baud8N1(baudRate) - } - } - - } -} - diff --git a/controls-plc4x/README.md b/controls-plc4x/README.md new file mode 100644 index 0000000..21cf398 --- /dev/null +++ b/controls-plc4x/README.md @@ -0,0 +1,21 @@ +# Module controls-plc4x + +A plugin for Controls-kt device server on top of plc4x library + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:controls-plc4x:0.4.0-dev-7`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:controls-plc4x:0.4.0-dev-7") +} +``` diff --git a/controls-plc4x/build.gradle.kts b/controls-plc4x/build.gradle.kts new file mode 100644 index 0000000..01f7f68 --- /dev/null +++ b/controls-plc4x/build.gradle.kts @@ -0,0 +1,22 @@ +import space.kscience.gradle.Maturity + +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +description = """ + A plugin for Controls-kt device server on top of plc4x library +""".trimIndent() + +kscience { + jvm() + jvmMain { + api(projects.controlsCore) + api(libs.plc4j.spi) + } +} + +readme { + maturity = Maturity.EXPERIMENTAL +} \ No newline at end of file diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt new file mode 100644 index 0000000..9b08538 --- /dev/null +++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDevice.kt @@ -0,0 +1,76 @@ +package space.kscience.controls.plc4x + +import kotlinx.coroutines.future.await +import org.apache.plc4x.java.api.PlcConnection +import org.apache.plc4x.java.api.messages.PlcBrowseItem +import org.apache.plc4x.java.api.messages.PlcTagResponse +import org.apache.plc4x.java.api.messages.PlcWriteRequest +import org.apache.plc4x.java.api.messages.PlcWriteResponse +import org.apache.plc4x.java.api.types.PlcResponseCode +import space.kscience.controls.api.Device +import space.kscience.dataforge.meta.Meta + +private val PlcTagResponse.responseCodes: Map<String, PlcResponseCode> + get() = tagNames.associateWith { getResponseCode(it) } + +private val Map<String, PlcResponseCode>.isOK get() = values.all { it == PlcResponseCode.OK } + +public class PlcException(public val codes: Map<String, PlcResponseCode>) : Exception() { + override val message: String + get() = "Plc request unsuccessful:" + codes.entries.joinToString(prefix = "\n\t", separator = "\n\t") { + "${it.key}: ${it.value.name}" + } +} + +private fun PlcTagResponse.throwOnFail() { + val codes = responseCodes + if (!codes.isOK) throw PlcException(codes) +} + + +public interface Plc4XDevice : Device { + public val connection: PlcConnection +} + + +/** + * Send ping request and suspend until it comes back + */ +public suspend fun Plc4XDevice.ping(): PlcResponseCode = connection.ping().await().responseCode + +/** + * Send browse request to list available tags + */ +public suspend fun Plc4XDevice.browse(): Map<String, MutableList<PlcBrowseItem>> { + require(connection.metadata.isBrowseSupported){"Browse actions are not supported on connection"} + val request = connection.browseRequestBuilder().build() + val response = request.execute().await() + + return response.queryNames.associateWith { response.getValues(it) } +} + +/** + * Send read request and suspend until it returns. Throw a [PlcException] if at least one tag read fails. + * + * @throws PlcException + */ +public suspend fun Plc4XDevice.read(plc4xProperty: Plc4xProperty): Meta = with(plc4xProperty) { + require(connection.metadata.isReadSupported) {"Read actions are not supported on connections"} + val request = connection.readRequestBuilder().request().build() + val response = request.execute().await() + response.throwOnFail() + response.readProperty() +} + + +/** + * Send write request and suspend until it finishes. Throw a [PlcException] if at least one tag write fails. + * + * @throws PlcException + */ +public suspend fun Plc4XDevice.write(plc4xProperty: Plc4xProperty, value: Meta): Unit = with(plc4xProperty) { + require(connection.metadata.isWriteSupported){"Write actions are not supported on connection"} + val request: PlcWriteRequest = connection.writeRequestBuilder().writeProperty(value).build() + val response: PlcWriteResponse = request.execute().await() + response.throwOnFail() +} \ No newline at end of file diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDeviceBase.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDeviceBase.kt new file mode 100644 index 0000000..e25a001 --- /dev/null +++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4XDeviceBase.kt @@ -0,0 +1,22 @@ +package space.kscience.controls.plc4x + +import org.apache.plc4x.java.api.PlcConnection +import space.kscience.controls.spec.DeviceActionSpec +import space.kscience.controls.spec.DeviceBase +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.Meta + +public class Plc4XDeviceBase( + context: Context, + meta: Meta, + override val connection: PlcConnection, +) : Plc4XDevice, DeviceBase<Plc4XDevice>(context, meta) { + override val properties: Map<String, DevicePropertySpec<Plc4XDevice, *>> + get() = TODO("Not yet implemented") + override val actions: Map<String, DeviceActionSpec<Plc4XDevice, *, *>> = emptyMap() + + override fun toString(): String { + TODO("Not yet implemented") + } +} \ No newline at end of file diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt new file mode 100644 index 0000000..cfeedd2 --- /dev/null +++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/Plc4xProperty.kt @@ -0,0 +1,39 @@ +package space.kscience.controls.plc4x + +import org.apache.plc4x.java.api.messages.PlcReadRequest +import org.apache.plc4x.java.api.messages.PlcReadResponse +import org.apache.plc4x.java.api.messages.PlcWriteRequest +import org.apache.plc4x.java.api.types.PlcValueType +import space.kscience.dataforge.meta.Meta + +public interface Plc4xProperty { + + public val keys: Set<String> + + public fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder + + public fun PlcReadResponse.readProperty(): Meta + + public fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder +} + +private class DefaultPlc4xProperty( + private val address: String, + private val plcValueType: PlcValueType, + private val name: String = "@default", +) : Plc4xProperty { + + override val keys: Set<String> = setOf(name) + + override fun PlcReadRequest.Builder.request(): PlcReadRequest.Builder = + addTagAddress(name, address) + + override fun PlcReadResponse.readProperty(): Meta = + getPlcValue(name).toMeta() + + override fun PlcWriteRequest.Builder.writeProperty(meta: Meta): PlcWriteRequest.Builder = + addTagAddress(name, address, meta.toPlcValue(plcValueType)) +} + +public fun Plc4xProperty(address: String, plcValueType: PlcValueType, name: String = "@default"): Plc4xProperty = + DefaultPlc4xProperty(address, plcValueType, name) \ No newline at end of file diff --git a/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/plc4xConnector.kt b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/plc4xConnector.kt new file mode 100644 index 0000000..5d0b2de --- /dev/null +++ b/controls-plc4x/src/jvmMain/kotlin/space/kscience/controls/plc4x/plc4xConnector.kt @@ -0,0 +1,123 @@ +package space.kscience.controls.plc4x + +import org.apache.plc4x.java.api.types.PlcValueType +import org.apache.plc4x.java.api.value.PlcValue +import org.apache.plc4x.java.spi.values.* +import space.kscience.dataforge.meta.* +import space.kscience.dataforge.names.asName +import java.math.BigInteger + +internal fun PlcValue.toMeta(): Meta = Meta { + when (plcValueType) { + null, PlcValueType.NULL -> value = Null + PlcValueType.BOOL -> value = this@toMeta.boolean.asValue() + PlcValueType.BYTE -> this@toMeta.byte.asValue() + PlcValueType.WORD -> this@toMeta.short.asValue() + PlcValueType.DWORD -> this@toMeta.int.asValue() + PlcValueType.LWORD -> this@toMeta.long.asValue() + PlcValueType.USINT -> this@toMeta.short.asValue() + PlcValueType.UINT -> this@toMeta.int.asValue() + PlcValueType.UDINT -> this@toMeta.long.asValue() + PlcValueType.ULINT -> this@toMeta.bigInteger.asValue() + PlcValueType.SINT -> this@toMeta.byte.asValue() + PlcValueType.INT -> this@toMeta.short.asValue() + PlcValueType.DINT -> this@toMeta.int.asValue() + PlcValueType.LINT -> this@toMeta.long.asValue() + PlcValueType.REAL -> this@toMeta.float.asValue() + PlcValueType.LREAL -> this@toMeta.double.asValue() + PlcValueType.CHAR -> this@toMeta.int.asValue() + PlcValueType.WCHAR -> this@toMeta.short.asValue() + PlcValueType.STRING -> this@toMeta.string.asValue() + PlcValueType.WSTRING -> this@toMeta.string.asValue() + PlcValueType.TIME -> this@toMeta.duration.toString().asValue() + PlcValueType.LTIME -> this@toMeta.duration.toString().asValue() + PlcValueType.DATE -> this@toMeta.date.toString().asValue() + PlcValueType.LDATE -> this@toMeta.date.toString().asValue() + PlcValueType.TIME_OF_DAY -> this@toMeta.time.toString().asValue() + PlcValueType.LTIME_OF_DAY -> this@toMeta.time.toString().asValue() + PlcValueType.DATE_AND_TIME -> this@toMeta.dateTime.toString().asValue() + PlcValueType.DATE_AND_LTIME -> this@toMeta.dateTime.toString().asValue() + PlcValueType.LDATE_AND_TIME -> this@toMeta.dateTime.toString().asValue() + PlcValueType.Struct -> this@toMeta.struct.forEach { (name, item) -> + set(name, item.toMeta()) + } + + PlcValueType.List -> { + val listOfMeta = this@toMeta.list.map { it.toMeta() } + if (listOfMeta.all { it.items.isEmpty() }) { + value = listOfMeta.map { it.value ?: Null }.asValue() + } else { + setIndexed("@list".asName(), list.map { it.toMeta() }) + } + } + + PlcValueType.RAW_BYTE_ARRAY -> this@toMeta.raw.asValue() + } +} + +private fun Value.toPlcValue(): PlcValue = when (type) { + ValueType.NUMBER -> when (val number = number) { + is Short -> PlcINT(number.toShort()) + is Int -> PlcDINT(number.toInt()) + is Long -> PlcLINT(number.toLong()) + is Float -> PlcREAL(number.toFloat()) + else -> PlcLREAL(number.toDouble()) + } + + ValueType.STRING -> PlcSTRING(string) + ValueType.BOOLEAN -> PlcBOOL(boolean) + ValueType.NULL -> PlcNull() + ValueType.LIST -> TODO() +} + +internal fun Meta.toPlcValue(hint: PlcValueType): PlcValue = when (hint) { + PlcValueType.Struct -> PlcStruct( + items.entries.associate { (token, item) -> + token.toString() to item.toPlcValue(PlcValueType.Struct) + } + ) + + PlcValueType.NULL -> PlcNull() + PlcValueType.BOOL -> PlcBOOL(boolean) + PlcValueType.BYTE -> PlcBYTE(int) + PlcValueType.WORD -> PlcWORD(int) + PlcValueType.DWORD -> PlcDWORD(int) + PlcValueType.LWORD -> PlcLWORD(long) + PlcValueType.USINT -> PlcLWORD(short) + PlcValueType.UINT -> PlcUINT(int) + PlcValueType.UDINT -> PlcDINT(long) + PlcValueType.ULINT -> (number as? BigInteger)?.let { PlcULINT(it) } ?: PlcULINT(long) + PlcValueType.SINT -> PlcSINT(int) + PlcValueType.INT -> PlcINT(int) + PlcValueType.DINT -> PlcDINT(int) + PlcValueType.LINT -> PlcLINT(long) + PlcValueType.REAL -> PlcREAL(float) + PlcValueType.LREAL -> PlcLREAL(double) + PlcValueType.CHAR -> PlcCHAR(int) + PlcValueType.WCHAR -> PlcWCHAR(short) + PlcValueType.STRING -> PlcSTRING(string) + PlcValueType.WSTRING -> PlcWSTRING(string) + PlcValueType.TIME -> PlcTIME(string?.let { java.time.Duration.parse(it) }) + PlcValueType.LTIME -> PlcLTIME(string?.let { java.time.Duration.parse(it) }) + PlcValueType.DATE -> PlcDATE(string?.let { java.time.LocalDate.parse(it) }) + PlcValueType.LDATE -> PlcLDATE(string?.let { java.time.LocalDate.parse(it) }) + PlcValueType.TIME_OF_DAY -> PlcTIME_OF_DAY(string?.let { java.time.LocalTime.parse(it) }) + PlcValueType.LTIME_OF_DAY -> PlcLTIME_OF_DAY(string?.let { java.time.LocalTime.parse(it) }) + PlcValueType.DATE_AND_TIME -> PlcDATE_AND_TIME(string?.let { java.time.LocalDateTime.parse(it) }) + PlcValueType.DATE_AND_LTIME -> PlcDATE_AND_LTIME(string?.let { java.time.LocalDateTime.parse(it) }) + PlcValueType.LDATE_AND_TIME -> PlcLDATE_AND_TIME(string?.let { java.time.LocalDateTime.parse(it) }) + PlcValueType.List -> PlcList().apply { + value?.list?.forEach { add(it.toPlcValue()) } + getIndexed("@list").forEach { (_, meta) -> + if (meta.items.isEmpty()) { + meta.value?.let { add(it.toPlcValue()) } + } else { + add(meta.toPlcValue(PlcValueType.Struct)) + } + } + } + + PlcValueType.RAW_BYTE_ARRAY -> PlcRawByteArray( + value?.list?.map { it.number.toByte() }?.toByteArray() ?: error("The meta content is not byte array") + ) +} \ No newline at end of file diff --git a/controls-ports-ktor/README.md b/controls-ports-ktor/README.md index 7f935f9..376bd13 100644 --- a/controls-ports-ktor/README.md +++ b/controls-ports-ktor/README.md @@ -6,18 +6,16 @@ Implementation of byte ports on top os ktor-io asynchronous API ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-ports-ktor:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-ports-ktor:0.2.0") + implementation("space.kscience:controls-ports-ktor:0.4.0-dev-7") } ``` diff --git a/controls-ports-ktor/build.gradle.kts b/controls-ports-ktor/build.gradle.kts index 4c8ad9a..c225097 100644 --- a/controls-ports-ktor/build.gradle.kts +++ b/controls-ports-ktor/build.gradle.kts @@ -1,7 +1,7 @@ import space.kscience.gradle.Maturity plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` } @@ -9,11 +9,12 @@ description = """ Implementation of byte ports on top os ktor-io asynchronous API """.trimIndent() -val ktorVersion: String by rootProject.extra - -dependencies { - api(projects.controlsCore) - api("io.ktor:ktor-network:$ktorVersion") +kscience { + jvm() + jvmMain { + api(projects.controlsCore) + api(spclibs.ktor.network) + } } readme{ diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt similarity index 88% rename from controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt rename to controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt index 9c6dbba..6e3041c 100644 --- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt +++ b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorPortsPlugin.kt @@ -13,7 +13,7 @@ public class KtorPortsPlugin : AbstractPlugin() { override val tag: PluginTag get() = Companion.tag override fun content(target: String): Map<Name, Any> = when (target) { - PortFactory.TYPE -> mapOf("tcp".asName() to KtorTcpPort, "udp".asName() to KtorUdpPort) + Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf("tcp".asName() to KtorTcpPort, "udp".asName() to KtorUdpPort) else -> emptyMap() } diff --git a/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt new file mode 100644 index 0000000..d853e46 --- /dev/null +++ b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorTcpPort.kt @@ -0,0 +1,99 @@ +package space.kscience.controls.ports + +import io.ktor.network.selector.ActorSelectorManager +import io.ktor.network.sockets.SocketOptions +import io.ktor.network.sockets.aSocket +import io.ktor.network.sockets.openReadChannel +import io.ktor.network.sockets.openWriteChannel +import io.ktor.utils.io.consumeEachBufferRange +import io.ktor.utils.io.writeAvailable +import kotlinx.coroutines.* +import space.kscience.controls.api.LifecycleState +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.meta.string +import java.nio.ByteBuffer +import kotlin.coroutines.CoroutineContext + +public class KtorTcpPort internal constructor( + context: Context, + meta: Meta, + public val host: String, + public val port: Int, + coroutineContext: CoroutineContext = context.coroutineContext, + socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {}, +) : AbstractAsynchronousPort(context, meta, coroutineContext) { + + override fun toString(): String = "port[tcp:$host:$port]" + + private val futureSocket = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { + aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().connect(host, port, socketOptions) + } + + private val writeChannel = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { + futureSocket.await().openWriteChannel(true) + } + + private var listenerJob: Job? = null + + override fun onOpen() { + listenerJob = scope.launch { + val input = futureSocket.await().openReadChannel() + input.consumeEachBufferRange { buffer: ByteBuffer, last -> + val array = ByteArray(buffer.remaining()) + buffer.get(array) + receive(array) + !last && isActive + } + } + } + + override suspend fun write(data: ByteArray) { + writeChannel.await().writeAvailable(data) + } + + override val lifecycleState: LifecycleState + get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED + + override suspend fun stop() { + listenerJob?.cancel() + futureSocket.cancel() + super.stop() + } + + public companion object : Factory<AsynchronousPort> { + + public fun build( + context: Context, + host: String, + port: Int, + coroutineContext: CoroutineContext = context.coroutineContext, + socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {}, + ): KtorTcpPort { + val meta = Meta { + "name" put "tcp://$host:$port" + "type" put "tcp" + "host" put host + "port" put port + } + return KtorTcpPort(context, meta, host, port, coroutineContext, socketOptions) + } + + public suspend fun start( + context: Context, + host: String, + port: Int, + coroutineContext: CoroutineContext = context.coroutineContext, + socketOptions: SocketOptions.TCPClientSocketOptions.() -> Unit = {}, + ): KtorTcpPort = build(context, host, port, coroutineContext, socketOptions).apply { start() } + + override fun build(context: Context, meta: Meta): AsynchronousPort { + val host = meta["host"].string ?: "localhost" + val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta") + return build(context, host, port) + } + } +} \ No newline at end of file diff --git a/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorUdpPort.kt b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorUdpPort.kt new file mode 100644 index 0000000..267daa4 --- /dev/null +++ b/controls-ports-ktor/src/jvmMain/kotlin/space/kscience/controls/ports/KtorUdpPort.kt @@ -0,0 +1,130 @@ +package space.kscience.controls.ports + +import io.ktor.network.selector.ActorSelectorManager +import io.ktor.network.sockets.* +import io.ktor.utils.io.ByteWriteChannel +import io.ktor.utils.io.consumeEachBufferRange +import io.ktor.utils.io.writeAvailable +import kotlinx.coroutines.* +import space.kscience.controls.api.LifecycleState +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.meta.number +import space.kscience.dataforge.meta.string +import kotlin.coroutines.CoroutineContext + +public class KtorUdpPort internal constructor( + context: Context, + meta: Meta, + public val remoteHost: String, + public val remotePort: Int, + public val localPort: Int? = null, + public val localHost: String = "localhost", + coroutineContext: CoroutineContext = context.coroutineContext, + socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {}, +) : AbstractAsynchronousPort(context, meta, coroutineContext) { + + override fun toString(): String = "port[udp:$remoteHost:$remotePort]" + + private val futureSocket = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { + aSocket(ActorSelectorManager(Dispatchers.IO)).udp().connect( + remoteAddress = InetSocketAddress(remoteHost, remotePort), + localAddress = localPort?.let { InetSocketAddress(localHost, localPort) }, + configure = socketOptions + ) + } + + private val writeChannel: Deferred<ByteWriteChannel> = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) { + futureSocket.await().openWriteChannel(true) + } + + private var listenerJob: Job? = null + + override fun onOpen() { + listenerJob = scope.launch { + val input = futureSocket.await().openReadChannel() + input.consumeEachBufferRange { buffer, last -> + val array = ByteArray(buffer.remaining()) + buffer.get(array) + receive(array) + !last && isActive + } + } + } + + override suspend fun write(data: ByteArray) { + writeChannel.await().writeAvailable(data) + } + + override val lifecycleState: LifecycleState + get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED + + override suspend fun stop() { + listenerJob?.cancel() + futureSocket.cancel() + super.stop() + } + + public companion object : Factory<AsynchronousPort> { + + public fun build( + context: Context, + remoteHost: String, + remotePort: Int, + localPort: Int? = null, + localHost: String? = null, + coroutineContext: CoroutineContext = context.coroutineContext, + socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {}, + ): KtorUdpPort { + val meta = Meta { + "name" put "udp://$remoteHost:$remotePort" + "type" put "udp" + "remoteHost" put remoteHost + "remotePort" put remotePort + localHost?.let { "localHost" put it } + localPort?.let { "localPort" put it } + } + return KtorUdpPort( + context = context, + meta = meta, + remoteHost = remoteHost, + remotePort = remotePort, + localPort = localPort, + localHost = localHost ?: "localhost", + coroutineContext = coroutineContext, + socketOptions = socketOptions + ) + } + + /** + * Create and open UDP port + */ + public suspend fun start( + context: Context, + remoteHost: String, + remotePort: Int, + localPort: Int? = null, + localHost: String = "localhost", + coroutineContext: CoroutineContext = context.coroutineContext, + socketOptions: SocketOptions.UDPSocketOptions.() -> Unit = {}, + ): KtorUdpPort = build( + context, + remoteHost, + remotePort, + localPort, + localHost, + coroutineContext, + socketOptions + ).apply { start() } + + override fun build(context: Context, meta: Meta): AsynchronousPort { + val remoteHost by meta.string { error("Remote host is not specified") } + val remotePort by meta.number { error("Remote port is not specified") } + val localHost: String? by meta.string() + val localPort: Int? by meta.int() + return build(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost") + } + } +} \ No newline at end of file diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt deleted file mode 100644 index 7f906d3..0000000 --- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorTcpPort.kt +++ /dev/null @@ -1,77 +0,0 @@ -package space.kscience.controls.ports - -import io.ktor.network.selector.ActorSelectorManager -import io.ktor.network.sockets.aSocket -import io.ktor.network.sockets.openReadChannel -import io.ktor.network.sockets.openWriteChannel -import io.ktor.utils.io.consumeEachBufferRange -import io.ktor.utils.io.core.Closeable -import io.ktor.utils.io.writeAvailable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import space.kscience.dataforge.context.Context -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.get -import space.kscience.dataforge.meta.int -import space.kscience.dataforge.meta.string -import kotlin.coroutines.CoroutineContext - -public class KtorTcpPort internal constructor( - context: Context, - public val host: String, - public val port: Int, - coroutineContext: CoroutineContext = context.coroutineContext, -) : AbstractPort(context, coroutineContext), Closeable { - - override fun toString(): String = "port[tcp:$host:$port]" - - private val futureSocket = scope.async { - aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().connect(host, port) - } - - private val writeChannel = scope.async { - futureSocket.await().openWriteChannel(true) - } - - private val listenerJob = scope.launch { - val input = futureSocket.await().openReadChannel() - input.consumeEachBufferRange { buffer, _ -> - val array = ByteArray(buffer.remaining()) - buffer.get(array) - receive(array) - isActive - } - } - - override suspend fun write(data: ByteArray) { - writeChannel.await().writeAvailable(data) - } - - override fun close() { - listenerJob.cancel() - futureSocket.cancel() - super.close() - } - - public companion object : PortFactory { - - override val type: String = "tcp" - - public fun open( - context: Context, - host: String, - port: Int, - coroutineContext: CoroutineContext = context.coroutineContext, - ): KtorTcpPort { - return KtorTcpPort(context, host, port, coroutineContext) - } - - override fun build(context: Context, meta: Meta): Port { - val host = meta["host"].string ?: "localhost" - val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta") - return open(context, host, port) - } - } -} \ No newline at end of file diff --git a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt b/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt deleted file mode 100644 index 8b8446c..0000000 --- a/controls-ports-ktor/src/main/kotlin/space/kscience/controls/ports/KtorUdpPort.kt +++ /dev/null @@ -1,87 +0,0 @@ -package space.kscience.controls.ports - -import io.ktor.network.selector.ActorSelectorManager -import io.ktor.network.sockets.InetSocketAddress -import io.ktor.network.sockets.aSocket -import io.ktor.network.sockets.openReadChannel -import io.ktor.network.sockets.openWriteChannel -import io.ktor.utils.io.consumeEachBufferRange -import io.ktor.utils.io.core.Closeable -import io.ktor.utils.io.writeAvailable -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import space.kscience.dataforge.context.Context -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.int -import space.kscience.dataforge.meta.number -import space.kscience.dataforge.meta.string -import kotlin.coroutines.CoroutineContext - -public class KtorUdpPort internal constructor( - context: Context, - public val remoteHost: String, - public val remotePort: Int, - public val localPort: Int? = null, - public val localHost: String = "localhost", - coroutineContext: CoroutineContext = context.coroutineContext, -) : AbstractPort(context, coroutineContext), Closeable { - - override fun toString(): String = "port[udp:$remoteHost:$remotePort]" - - private val futureSocket = scope.async { - aSocket(ActorSelectorManager(Dispatchers.IO)).udp().connect( - remoteAddress = InetSocketAddress(remoteHost, remotePort), - localAddress = localPort?.let { InetSocketAddress(localHost, localPort) } - ) - } - - private val writeChannel = scope.async { - futureSocket.await().openWriteChannel(true) - } - - private val listenerJob = scope.launch { - val input = futureSocket.await().openReadChannel() - input.consumeEachBufferRange { buffer, _ -> - val array = ByteArray(buffer.remaining()) - buffer.get(array) - receive(array) - isActive - } - } - - override suspend fun write(data: ByteArray) { - writeChannel.await().writeAvailable(data) - } - - override fun close() { - listenerJob.cancel() - futureSocket.cancel() - super.close() - } - - public companion object : PortFactory { - - override val type: String = "udp" - - public fun open( - context: Context, - remoteHost: String, - remotePort: Int, - localPort: Int? = null, - localHost: String = "localhost", - coroutineContext: CoroutineContext = context.coroutineContext, - ): KtorUdpPort { - return KtorUdpPort(context, remoteHost, remotePort, localPort, localHost, coroutineContext) - } - - override fun build(context: Context, meta: Meta): Port { - val remoteHost by meta.string { error("Remote host is not specified") } - val remotePort by meta.number { error("Remote port is not specified") } - val localHost: String? by meta.string() - val localPort: Int? by meta.int() - return open(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost") - } - } -} \ No newline at end of file diff --git a/controls-serial/README.md b/controls-serial/README.md index 209055d..44598d0 100644 --- a/controls-serial/README.md +++ b/controls-serial/README.md @@ -6,18 +6,16 @@ Implementation of direct serial port communication with JSerialComm ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-serial:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-serial:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-serial:0.2.0") + implementation("space.kscience:controls-serial:0.4.0-dev-7") } ``` diff --git a/controls-serial/build.gradle.kts b/controls-serial/build.gradle.kts index a9afc41..75bf017 100644 --- a/controls-serial/build.gradle.kts +++ b/controls-serial/build.gradle.kts @@ -1,15 +1,18 @@ import space.kscience.gradle.Maturity plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` } description = "Implementation of direct serial port communication with JSerialComm" -dependencies{ - api(project(":controls-core")) - implementation("com.fazecast:jSerialComm:2.10.3") +kscience { + jvm() + jvmMain { + api(project(":controls-core")) + implementation(libs.jSerialComm) + } } readme{ diff --git a/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt new file mode 100644 index 0000000..b581405 --- /dev/null +++ b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/AsynchronousSerialPort.kt @@ -0,0 +1,137 @@ +package space.kscience.controls.serial + +import com.fazecast.jSerialComm.SerialPort +import com.fazecast.jSerialComm.SerialPortDataListener +import com.fazecast.jSerialComm.SerialPortEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import space.kscience.controls.api.LifecycleState +import space.kscience.controls.ports.AbstractAsynchronousPort +import space.kscience.controls.ports.AsynchronousPort +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.meta.string +import kotlin.coroutines.CoroutineContext + +/** + * A port based on JSerialComm + */ +public class AsynchronousSerialPort( + context: Context, + meta: Meta, + private val comPort: SerialPort, + coroutineContext: CoroutineContext = context.coroutineContext, +) : AbstractAsynchronousPort(context, meta, coroutineContext) { + + override fun toString(): String = "port[${comPort.descriptivePortName}]" + + private val serialPortListener = object : SerialPortDataListener { + override fun getListeningEvents(): Int = + SerialPort.LISTENING_EVENT_DATA_RECEIVED or SerialPort.LISTENING_EVENT_DATA_AVAILABLE + + override fun serialEvent(event: SerialPortEvent) { + when (event.eventType) { + SerialPort.LISTENING_EVENT_DATA_RECEIVED -> { + scope.launch { receive(event.receivedData) } + } + + SerialPort.LISTENING_EVENT_DATA_AVAILABLE -> { + scope.launch(Dispatchers.IO) { + val available = comPort.bytesAvailable() + if (available > 0) { + val buffer = ByteArray(available) + comPort.readBytes(buffer, available) + receive(buffer) + } + } + } + } + } + } + + override fun onOpen() { + comPort.openPort() + comPort.addDataListener(serialPortListener) + } + + override val lifecycleState: LifecycleState + get() = if(comPort.isOpen) LifecycleState.STARTED else LifecycleState.STOPPED + + + override suspend fun write(data: ByteArray) { + comPort.writeBytes(data, data.size) + } + + override suspend fun stop() { + comPort.removeDataListener() + if (comPort.isOpen) { + comPort.closePort() + } + super.stop() + } + + public companion object : Factory<AsynchronousPort> { + + public fun build( + context: Context, + portName: String, + baudRate: Int = 9600, + dataBits: Int = 8, + stopBits: Int = SerialPort.ONE_STOP_BIT, + parity: Int = SerialPort.NO_PARITY, + coroutineContext: CoroutineContext = context.coroutineContext, + additionalConfig: SerialPort.() -> Unit = {}, + ): AsynchronousSerialPort { + val serialPort = SerialPort.getCommPort(portName).apply { + setComPortParameters(baudRate, dataBits, stopBits, parity) + additionalConfig() + } + val meta = Meta { + "name" put "com://$portName" + "type" put "serial" + "baudRate" put serialPort.baudRate + "dataBits" put serialPort.numDataBits + "stopBits" put serialPort.numStopBits + "parity" put serialPort.parity + } + return AsynchronousSerialPort(context, meta, serialPort, coroutineContext) + } + + + /** + * Construct ComPort with given parameters + */ + public suspend fun start( + context: Context, + portName: String, + baudRate: Int = 9600, + dataBits: Int = 8, + stopBits: Int = SerialPort.ONE_STOP_BIT, + parity: Int = SerialPort.NO_PARITY, + coroutineContext: CoroutineContext = context.coroutineContext, + additionalConfig: SerialPort.() -> Unit = {}, + ): AsynchronousSerialPort = build( + context = context, + portName = portName, + baudRate = baudRate, + dataBits = dataBits, + stopBits = stopBits, + parity = parity, + coroutineContext = coroutineContext, + additionalConfig = additionalConfig + ).apply { start() } + + + override fun build(context: Context, meta: Meta): AsynchronousPort { + val name by meta.string { error("Serial port name not defined") } + val baudRate by meta.int(9600) + val dataBits by meta.int(8) + val stopBits by meta.int(SerialPort.ONE_STOP_BIT) + val parity by meta.int(SerialPort.NO_PARITY) + return build(context, name, baudRate, dataBits, stopBits, parity) + } + } + +} \ No newline at end of file diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt similarity index 63% rename from controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt rename to controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt index ae9d4fc..b43fc00 100644 --- a/controls-serial/src/main/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt +++ b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SerialPortPlugin.kt @@ -1,19 +1,29 @@ package space.kscience.controls.serial -import space.kscience.controls.ports.PortFactory +import space.kscience.controls.ports.Ports import space.kscience.dataforge.context.AbstractPlugin import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.PluginFactory import space.kscience.dataforge.context.PluginTag import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName public class SerialPortPlugin : AbstractPlugin() { + public val ports: Ports by require(Ports) + override val tag: PluginTag get() = Companion.tag - override fun content(target: String): Map<Name, Any> = when(target){ - PortFactory.TYPE -> mapOf(Name.EMPTY to JSerialCommPort) + override fun content(target: String): Map<Name, Any> = when (target) { + Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf( + "serial".asName() to AsynchronousSerialPort, + ) + + Ports.SYNCHRONOUS_PORT_TYPE -> mapOf( + "serial".asName() to SynchronousSerialPort, + ) + else -> emptyMap() } diff --git a/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt new file mode 100644 index 0000000..b9fc091 --- /dev/null +++ b/controls-serial/src/jvmMain/kotlin/space/kscience/controls/serial/SynchronousSerialPort.kt @@ -0,0 +1,142 @@ +package space.kscience.controls.serial + +import com.fazecast.jSerialComm.SerialPort +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runInterruptible +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import space.kscience.controls.api.LifecycleState +import space.kscience.controls.ports.SynchronousPort +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Factory +import space.kscience.dataforge.context.error +import space.kscience.dataforge.context.logger +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.meta.string + +/** + * A port based on JSerialComm + */ +public class SynchronousSerialPort( + override val context: Context, + public val meta: Meta, + private val comPort: SerialPort, +) : SynchronousPort { + + override fun toString(): String = "port[${comPort.descriptivePortName}]" + + + override suspend fun start() { + if (!comPort.isOpen) { + comPort.openPort() + } + } + + override val lifecycleState: LifecycleState + get() = if(comPort.isOpen) LifecycleState.STARTED else LifecycleState.STOPPED + + + override suspend fun stop() { + if (comPort.isOpen) { + comPort.closePort() + } + } + + private val mutex = Mutex() + + override suspend fun <R> respond( + request: ByteArray, + transform: suspend Flow<ByteArray>.() -> R, + ): R = mutex.withLock { + comPort.flushIOBuffers() + comPort.writeBytes(request, request.size) + flow<ByteArray> { + while (comPort.isOpen) { + try { + val available = comPort.bytesAvailable() + if (available > 0) { + val buffer = ByteArray(available) + comPort.readBytes(buffer, available) + emit(buffer) + } else if (available < 0) break + } catch (ex: Exception) { + logger.error(ex) { "Channel read error" } + delay(1000) + } + } + }.transform() + } + + override suspend fun respondFixedMessageSize(request: ByteArray, responseSize: Int): ByteArray = mutex.withLock { + runInterruptible { + comPort.flushIOBuffers() + comPort.writeBytes(request, request.size) + val buffer = ByteArray(responseSize) + comPort.readBytes(buffer, responseSize) + buffer + } + } + + public companion object : Factory<SynchronousPort> { + + public fun build( + context: Context, + portName: String, + baudRate: Int = 9600, + dataBits: Int = 8, + stopBits: Int = SerialPort.ONE_STOP_BIT, + parity: Int = SerialPort.NO_PARITY, + additionalConfig: SerialPort.() -> Unit = {}, + ): SynchronousSerialPort { + val serialPort = SerialPort.getCommPort(portName).apply { + setComPortParameters(baudRate, dataBits, stopBits, parity) + additionalConfig() + } + val meta = Meta { + "name" put "com://$portName" + "type" put "serial" + "baudRate" put serialPort.baudRate + "dataBits" put serialPort.numDataBits + "stopBits" put serialPort.numStopBits + "parity" put serialPort.parity + } + return SynchronousSerialPort(context, meta, serialPort) + } + + + /** + * Construct ComPort with given parameters + */ + public suspend fun start( + context: Context, + portName: String, + baudRate: Int = 9600, + dataBits: Int = 8, + stopBits: Int = SerialPort.ONE_STOP_BIT, + parity: Int = SerialPort.NO_PARITY, + additionalConfig: SerialPort.() -> Unit = {}, + ): SynchronousSerialPort = build( + context = context, + portName = portName, + baudRate = baudRate, + dataBits = dataBits, + stopBits = stopBits, + parity = parity, + additionalConfig = additionalConfig + ).apply { start() } + + + override fun build(context: Context, meta: Meta): SynchronousPort { + val name by meta.string { error("Serial port name not defined") } + val baudRate by meta.int(9600) + val dataBits by meta.int(8) + val stopBits by meta.int(SerialPort.ONE_STOP_BIT) + val parity by meta.int(SerialPort.NO_PARITY) + return build(context, name, baudRate, dataBits, stopBits, parity) + } + } + +} \ No newline at end of file diff --git a/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt b/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt deleted file mode 100644 index 3e0601c..0000000 --- a/controls-serial/src/main/kotlin/space/kscience/controls/serial/JSerialCommPort.kt +++ /dev/null @@ -1,87 +0,0 @@ -package space.kscience.controls.serial - -import com.fazecast.jSerialComm.SerialPort -import com.fazecast.jSerialComm.SerialPortDataListener -import com.fazecast.jSerialComm.SerialPortEvent -import kotlinx.coroutines.launch -import space.kscience.controls.ports.AbstractPort -import space.kscience.controls.ports.Port -import space.kscience.controls.ports.PortFactory -import space.kscience.dataforge.context.Context -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.int -import space.kscience.dataforge.meta.string -import kotlin.coroutines.CoroutineContext - -/** - * A port based on JSerialComm - */ -public class JSerialCommPort( - context: Context, - private val comPort: SerialPort, - coroutineContext: CoroutineContext = context.coroutineContext, -) : AbstractPort(context, coroutineContext) { - - override fun toString(): String = "port[${comPort.descriptivePortName}]" - - private val serialPortListener = object : SerialPortDataListener { - override fun getListeningEvents(): Int = SerialPort.LISTENING_EVENT_DATA_AVAILABLE - - override fun serialEvent(event: SerialPortEvent) { - if (event.eventType == SerialPort.LISTENING_EVENT_DATA_AVAILABLE) { - scope.launch { receive(event.receivedData) } - } - } - } - - init { - comPort.addDataListener(serialPortListener) - } - - override suspend fun write(data: ByteArray) { - comPort.writeBytes(data, data.size) - } - - override fun close() { - comPort.removeDataListener() - if (comPort.isOpen) { - comPort.closePort() - } - super.close() - } - - public companion object : PortFactory { - - override val type: String = "com" - - - /** - * Construct ComPort with given parameters - */ - public fun open( - context: Context, - portName: String, - baudRate: Int = 9600, - dataBits: Int = 8, - stopBits: Int = SerialPort.ONE_STOP_BIT, - parity: Int = SerialPort.NO_PARITY, - coroutineContext: CoroutineContext = context.coroutineContext, - ): JSerialCommPort { - val serialPort = SerialPort.getCommPort(portName).apply { - setComPortParameters(baudRate, dataBits, stopBits, parity) - openPort() - } - return JSerialCommPort(context, serialPort, coroutineContext) - } - - override fun build(context: Context, meta: Meta): Port { - val name by meta.string { error("Serial port name not defined") } - val baudRate by meta.int(9600) - val dataBits by meta.int(8) - val stopBits by meta.int(SerialPort.ONE_STOP_BIT) - val parity by meta.int(SerialPort.NO_PARITY) - return open(context, name, baudRate, dataBits, stopBits, parity) - } - } - -} \ No newline at end of file diff --git a/controls-server/README.md b/controls-server/README.md index 83408e8..f143d50 100644 --- a/controls-server/README.md +++ b/controls-server/README.md @@ -6,18 +6,16 @@ A combined Magix event loop server with web server for visualization. ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-server:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-server:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-server:0.2.0") + implementation("space.kscience:controls-server:0.4.0-dev-7") } ``` diff --git a/controls-server/build.gradle.kts b/controls-server/build.gradle.kts index 43a9d61..7a57d57 100644 --- a/controls-server/build.gradle.kts +++ b/controls-server/build.gradle.kts @@ -1,7 +1,7 @@ import space.kscience.gradle.Maturity plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` } @@ -9,19 +9,20 @@ description = """ A combined Magix event loop server with web server for visualization. """.trimIndent() -val dataforgeVersion: String by rootProject.extra -val ktorVersion: String by rootProject.extra -dependencies { - implementation(projects.controlsCore) - implementation(projects.controlsPortsKtor) - implementation(projects.magix.magixServer) - implementation("io.ktor:ktor-server-cio:$ktorVersion") - implementation("io.ktor:ktor-server-websockets:$ktorVersion") - implementation("io.ktor:ktor-server-content-negotiation:$ktorVersion") - implementation("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") - implementation("io.ktor:ktor-server-html-builder:$ktorVersion") - implementation("io.ktor:ktor-server-status-pages:$ktorVersion") +kscience { + jvm() + dependencies { + implementation(projects.controlsCore) + implementation(projects.controlsPortsKtor) + implementation(projects.magix.magixServer) + implementation(spclibs.ktor.server.cio) + implementation(spclibs.ktor.server.websockets) + implementation(spclibs.ktor.server.content.negotiation) + implementation(spclibs.ktor.serialization.kotlinx.json) + implementation(spclibs.ktor.server.html.builder) + implementation(spclibs.ktor.server.status.pages) + } } readme{ diff --git a/controls-server/src/main/kotlin/space/kscience/controls/server/deviceWebServer.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt similarity index 90% rename from controls-server/src/main/kotlin/space/kscience/controls/server/deviceWebServer.kt rename to controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt index ba63583..8fd104e 100644 --- a/controls-server/src/main/kotlin/space/kscience/controls/server/deviceWebServer.kt +++ b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/deviceWebServer.kt @@ -29,19 +29,19 @@ import kotlinx.serialization.json.put import space.kscience.controls.api.DeviceMessage import space.kscience.controls.api.PropertyGetMessage import space.kscience.controls.api.PropertySetMessage -import space.kscience.controls.api.getOrNull import space.kscience.controls.manager.DeviceManager import space.kscience.controls.manager.respondHubMessage import space.kscience.dataforge.meta.toMeta import space.kscience.dataforge.names.Name import space.kscience.dataforge.names.asName +import space.kscience.dataforge.names.get import space.kscience.magix.api.MagixEndpoint import space.kscience.magix.api.MagixFlowPlugin import space.kscience.magix.api.MagixMessage +import space.kscience.magix.api.start import space.kscience.magix.server.magixModule - private fun Application.deviceServerModule(manager: DeviceManager) { install(StatusPages) { exception<IllegalArgumentException> { call, cause -> @@ -100,10 +100,9 @@ public fun Application.deviceManagerModule( h1 { +"Device server dashboard" } - deviceNames.forEach { deviceName -> - val device = - manager.getOrNull(deviceName) - ?: error("The device with name $deviceName not found in $manager") + deviceNames.forEach { deviceName: String -> + val device = manager.devices[deviceName] + ?: error("The device with name $deviceName not found in $manager") div { id = deviceName h2 { +deviceName } @@ -157,8 +156,8 @@ public fun Application.deviceManagerModule( val body = call.receiveText() val request: DeviceMessage = MagixEndpoint.magixJson.decodeFromString(DeviceMessage.serializer(), body) val response = manager.respondHubMessage(request) - if (response != null) { - call.respondMessage(response) + if (response.isNotEmpty()) { + call.respondMessages(response) } else { call.respondText("No response") } @@ -177,9 +176,9 @@ public fun Application.deviceManagerModule( property = property, ) - val response = manager.respondHubMessage(request) - if (response != null) { - call.respondMessage(response) + val responses = manager.respondHubMessage(request) + if (responses.isNotEmpty()) { + call.respondMessages(responses) } else { call.respond(HttpStatusCode.InternalServerError) } @@ -197,9 +196,9 @@ public fun Application.deviceManagerModule( value = json.toMeta() ) - val response = manager.respondHubMessage(request) - if (response != null) { - call.respondMessage(response) + val responses = manager.respondHubMessage(request) + if (responses.isNotEmpty()) { + call.respondMessages(responses) } else { call.respond(HttpStatusCode.InternalServerError) } @@ -217,5 +216,6 @@ public fun Application.deviceManagerModule( plugins.forEach { it.start(this, magixFlow) } + magixModule(magixFlow) } \ No newline at end of file diff --git a/controls-server/src/main/kotlin/space/kscience/controls/server/responses.kt b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt similarity index 79% rename from controls-server/src/main/kotlin/space/kscience/controls/server/responses.kt rename to controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt index 93d11c4..ffe489f 100644 --- a/controls-server/src/main/kotlin/space/kscience/controls/server/responses.kt +++ b/controls-server/src/jvmMain/kotlin/space/kscience/controls/server/responses.kt @@ -5,6 +5,7 @@ import io.ktor.server.application.ApplicationCall import io.ktor.server.response.respondText import kotlinx.serialization.json.JsonObjectBuilder import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.serializer import space.kscience.controls.api.DeviceMessage import space.kscience.magix.api.MagixEndpoint @@ -25,7 +26,7 @@ internal suspend fun ApplicationCall.respondJson(builder: JsonObjectBuilder.() - respondText(json.toString(), contentType = ContentType.Application.Json) } -internal suspend fun ApplicationCall.respondMessage(message: DeviceMessage): Unit = respondText( - MagixEndpoint.magixJson.encodeToString(DeviceMessage.serializer(), message), +internal suspend fun ApplicationCall.respondMessages(messages: List<DeviceMessage>): Unit = respondText( + MagixEndpoint.magixJson.encodeToString(serializer<List<DeviceMessage>>(), messages), contentType = ContentType.Application.Json ) \ No newline at end of file diff --git a/controls-storage/README.md b/controls-storage/README.md index 51cdbcb..116792d 100644 --- a/controls-storage/README.md +++ b/controls-storage/README.md @@ -6,18 +6,16 @@ An API for stand-alone Controls-kt device or a hub. ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-storage:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-storage:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-storage:0.2.0") + implementation("space.kscience:controls-storage:0.4.0-dev-7") } ``` diff --git a/controls-storage/controls-xodus/README.md b/controls-storage/controls-xodus/README.md index 790b356..e206f1e 100644 --- a/controls-storage/controls-xodus/README.md +++ b/controls-storage/controls-xodus/README.md @@ -6,18 +6,16 @@ An implementation of controls-storage on top of JetBrains Xodus. ## Artifact: -The Maven coordinates of this project are `space.kscience:controls-xodus:0.2.0`. +The Maven coordinates of this project are `space.kscience:controls-xodus:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:controls-xodus:0.2.0") + implementation("space.kscience:controls-xodus:0.4.0-dev-7") } ``` diff --git a/controls-storage/controls-xodus/build.gradle.kts b/controls-storage/controls-xodus/build.gradle.kts index 329f3ce..04be46a 100644 --- a/controls-storage/controls-xodus/build.gradle.kts +++ b/controls-storage/controls-xodus/build.gradle.kts @@ -1,21 +1,24 @@ plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` } -val xodusVersion: String by rootProject.extra - description = """ An implementation of controls-storage on top of JetBrains Xodus. """.trimIndent() -dependencies { - api(projects.controlsStorage) - implementation("org.jetbrains.xodus:xodus-entity-store:$xodusVersion") +kscience { + jvm() + jvmMain { + api(projects.controlsStorage) + implementation(libs.xodus.entity.store) // implementation("org.jetbrains.xodus:xodus-environment:$xodusVersion") // implementation("org.jetbrains.xodus:xodus-vfs:$xodusVersion") - testImplementation(spclibs.kotlinx.coroutines.test) + } + jvmTest{ + implementation(spclibs.kotlinx.coroutines.test) + } } readme{ diff --git a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt b/controls-storage/controls-xodus/src/jvmMain/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt similarity index 81% rename from controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt rename to controls-storage/controls-xodus/src/jvmMain/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt index e5d2e4a..559d2a0 100644 --- a/controls-storage/controls-xodus/src/main/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt +++ b/controls-storage/controls-xodus/src/jvmMain/kotlin/space/kscience/controls/xodus/XodusDeviceMessageStorage.kt @@ -4,9 +4,9 @@ import jetbrains.exodus.entitystore.Entity import jetbrains.exodus.entitystore.PersistentEntityStore import jetbrains.exodus.entitystore.PersistentEntityStores import jetbrains.exodus.entitystore.StoreTransaction +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.asFlow import kotlinx.datetime.Instant -import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.descriptors.serialDescriptor import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonObject @@ -19,7 +19,6 @@ import space.kscience.dataforge.context.request import space.kscience.dataforge.io.IOPlugin import space.kscience.dataforge.io.workDirectory import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.get import space.kscience.dataforge.meta.string import space.kscience.dataforge.misc.DFExperimental import space.kscience.dataforge.names.Name @@ -39,9 +38,7 @@ internal fun StoreTransaction.writeMessage(message: DeviceMessage): Unit { message.targetDevice?.let { entity.setProperty(DeviceMessage::targetDevice.name, it.toString()) } - message.time?.let { - entity.setProperty(DeviceMessage::targetDevice.name, it.toString()) - } + entity.setProperty(DeviceMessage::targetDevice.name, message.time.toString()) entity.setBlobString("json", Json.encodeToString(json)) } @@ -68,7 +65,7 @@ public class XodusDeviceMessageStorage( } } - override suspend fun readAll(): List<DeviceMessage> = entityStore.computeInReadonlyTransaction { transaction -> + override fun readAll(): Flow<DeviceMessage> = entityStore.computeInReadonlyTransaction { transaction -> transaction.sort( DEVICE_MESSAGE_ENTITY_TYPE, DeviceMessage::time.name, @@ -79,19 +76,19 @@ public class XodusDeviceMessageStorage( it.getBlobString("json") ?: error("No json content found") ) } - } + }.asFlow() - override suspend fun read( + override fun read( eventType: String, range: ClosedRange<Instant>?, sourceDevice: Name?, targetDevice: Name?, - ): List<DeviceMessage> = entityStore.computeInReadonlyTransaction { transaction -> + ): Flow<DeviceMessage> = entityStore.computeInReadonlyTransaction { transaction -> transaction.find( DEVICE_MESSAGE_ENTITY_TYPE, "type", eventType - ).asSequence().filter { + ).filter { it.timeInRange(range) && it.propertyMatchesName(DeviceMessage::sourceDevice.name, sourceDevice) && it.propertyMatchesName(DeviceMessage::targetDevice.name, targetDevice) @@ -100,8 +97,8 @@ public class XodusDeviceMessageStorage( DeviceMessage.serializer(), it.getBlobString("json") ?: error("No json content found") ) - }.sortedBy { it.time }.toList() - } + } + }.asFlow() override fun close() { entityStore.close() @@ -123,17 +120,4 @@ public class XodusDeviceMessageStorage( return XodusDeviceMessageStorage(entityStore) } } -} - -/** - * Query all messages of given type - */ -@OptIn(ExperimentalSerializationApi::class) -public suspend inline fun <reified T : DeviceMessage> XodusDeviceMessageStorage.query( - range: ClosedRange<Instant>? = null, - sourceDevice: Name? = null, - targetDevice: Name? = null, -): List<T> = read(serialDescriptor<T>().serialName, range, sourceDevice, targetDevice).map { - //Check that all types are correct - it as T -} +} \ No newline at end of file diff --git a/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt b/controls-storage/controls-xodus/src/jvmTest/kotlin/PropertyHistoryTest.kt similarity index 94% rename from controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt rename to controls-storage/controls-xodus/src/jvmTest/kotlin/PropertyHistoryTest.kt index 1724079..e7017b1 100644 --- a/controls-storage/controls-xodus/src/test/kotlin/PropertyHistoryTest.kt +++ b/controls-storage/controls-xodus/src/jvmTest/kotlin/PropertyHistoryTest.kt @@ -1,5 +1,6 @@ import jetbrains.exodus.entitystore.PersistentEntityStores import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.datetime.Instant import org.junit.jupiter.api.AfterAll @@ -7,8 +8,8 @@ import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.Test import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.storage.read import space.kscience.controls.xodus.XodusDeviceMessageStorage -import space.kscience.controls.xodus.query import space.kscience.controls.xodus.writeMessage import space.kscience.dataforge.meta.Meta import space.kscience.dataforge.names.Name @@ -67,7 +68,7 @@ internal class PropertyHistoryTest { XodusDeviceMessageStorage(entityStore).use { storage -> assertEquals( propertyChangedMessages[0], - storage.query<PropertyChangedMessage>( + storage.read<PropertyChangedMessage>( sourceDevice = "virtual-car".asName() ).first { it.property == "speed" } ) diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt index 87f4b74..b9cc84f 100644 --- a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt +++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/DeviceMessageStorage.kt @@ -1,6 +1,10 @@ package space.kscience.controls.storage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.datetime.Instant +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.descriptors.serialDescriptor import space.kscience.controls.api.DeviceMessage import space.kscience.dataforge.names.Name @@ -10,14 +14,34 @@ import space.kscience.dataforge.names.Name public interface DeviceMessageStorage { public suspend fun write(event: DeviceMessage) - public suspend fun readAll(): List<DeviceMessage> + /** + * Return all messages in a storage as a flow + */ + public fun readAll(): Flow<DeviceMessage> - public suspend fun read( + /** + * Flow messages with given [eventType] and filters by [range], [sourceDevice] and [targetDevice]. + * Null in filters means that there is not filtering for this field. + */ + public fun read( eventType: String, range: ClosedRange<Instant>? = null, sourceDevice: Name? = null, targetDevice: Name? = null, - ): List<DeviceMessage> + ): Flow<DeviceMessage> public fun close() +} + +/** + * Query all messages of given type + */ +@OptIn(ExperimentalSerializationApi::class) +public inline fun <reified T : DeviceMessage> DeviceMessageStorage.read( + range: ClosedRange<Instant>? = null, + sourceDevice: Name? = null, + targetDevice: Name? = null, +): Flow<T> = read(serialDescriptor<T>().serialName, range, sourceDevice, targetDevice).map { + //Check that all types are correct + it as T } \ No newline at end of file diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt new file mode 100644 index 0000000..b52ed2b --- /dev/null +++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/propertyHistory.kt @@ -0,0 +1,20 @@ +package space.kscience.controls.storage + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map +import kotlinx.datetime.Instant +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.misc.PropertyHistory +import space.kscience.controls.misc.ValueWithTime +import space.kscience.dataforge.meta.MetaConverter + +public fun <T> DeviceMessageStorage.propertyHistory( + propertyName: String, + converter: MetaConverter<T>, +): PropertyHistory<T> = object : PropertyHistory<T> { + override fun flowHistory(from: Instant, until: Instant): Flow<ValueWithTime<T>> = + read<PropertyChangedMessage>(from..until) + .filter { it.property == propertyName } + .map { ValueWithTime(converter.read(it.value), it.time) } +} \ No newline at end of file diff --git a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt index 2a453cf..ed96efc 100644 --- a/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt +++ b/controls-storage/src/commonMain/kotlin/space/kscience/controls/storage/storageCommon.kt @@ -12,7 +12,7 @@ import space.kscience.dataforge.context.Factory import space.kscience.dataforge.context.debug import space.kscience.dataforge.context.logger -//TODO replace by plugin? + public fun DeviceManager.storage( factory: Factory<DeviceMessageStorage>, ): DeviceMessageStorage = factory.build(context, meta) @@ -31,7 +31,7 @@ public fun DeviceManager.storeMessages( val storage = factory.build(context, meta) logger.debug { "Message storage with meta = $meta created" } - return hubMessageFlow(context).filter(filterCondition).onEach { message -> + return hubMessageFlow().filter(filterCondition).onEach { message -> storage.write(message) }.onCompletion { storage.close() @@ -39,26 +39,4 @@ public fun DeviceManager.storeMessages( }.launchIn(context) } -///** -// * @return the list of deviceMessages that describes changes of specified property of specified device sorted by time -// * @param sourceDeviceName a name of device, history of which property we want to get -// * @param propertyName a name of property, history of which we want to get -// * @param factory a factory that produce mongo clients -// */ -//public suspend fun getPropertyHistory( -// sourceDeviceName: String, -// propertyName: String, -// factory: Factory<EventStorage>, -// meta: Meta = Meta.EMPTY, -//): List<PropertyChangedMessage> { -// return factory(meta).use { -// it.getPropertyHistory(sourceDeviceName, propertyName) -// } -//} -// -// -//public enum class StorageKind { -// DEVICE_HUB, -// MAGIX_SERVER -//} diff --git a/controls-vision/README.md b/controls-vision/README.md new file mode 100644 index 0000000..750f60f --- /dev/null +++ b/controls-vision/README.md @@ -0,0 +1,21 @@ +# Module controls-vision + +Dashboard and visualization extensions for devices + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:controls-vision:0.4.0-dev-7`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:controls-vision:0.4.0-dev-7") +} +``` diff --git a/controls-vision/build.gradle.kts b/controls-vision/build.gradle.kts new file mode 100644 index 0000000..09c644b --- /dev/null +++ b/controls-vision/build.gradle.kts @@ -0,0 +1,32 @@ +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +description = """ + Dashboard and visualization extensions for devices +""".trimIndent() + +kscience { + fullStack("js/controls-vision.js") + useKtor() + useSerialization() + useContextReceivers() + commonMain { + api(projects.controlsCore) + api(projects.controlsConstructor) + api(libs.visionforge.plotly) + api(libs.visionforge.markdown) +// api("space.kscience:tables-kt:0.2.1") +// api("space.kscience:visionforge-tables:$visionforgeVersion") + } + + jvmMain{ + api(libs.visionforge.server) + api(spclibs.ktor.server.cio) + } +} + +readme { + maturity = space.kscience.gradle.Maturity.PROTOTYPE +} \ No newline at end of file diff --git a/controls-vision/docs/README-TEMPLATE.md b/controls-vision/docs/README-TEMPLATE.md new file mode 100644 index 0000000..63e6d31 --- /dev/null +++ b/controls-vision/docs/README-TEMPLATE.md @@ -0,0 +1,15 @@ +# Module ${name} + +${description} + +<#if features?has_content> +## Features + +${features} + +</#if> +<#if published> +## Usage + +${artifact} +</#if> diff --git a/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt new file mode 100644 index 0000000..b51d15c --- /dev/null +++ b/controls-vision/src/commonMain/kotlin/ControlVisionPlugin.kt @@ -0,0 +1,27 @@ +package space.kscience.controls.vision + +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.PluginFactory +import space.kscience.dataforge.context.PluginTag +import space.kscience.dataforge.meta.Meta +import space.kscience.visionforge.Vision +import space.kscience.visionforge.VisionPlugin + +public expect class ControlVisionPlugin: VisionPlugin{ + override val tag: PluginTag + override val visionSerializersModule: SerializersModule + public companion object: PluginFactory<ControlVisionPlugin>{ + override val tag: PluginTag + override fun build(context: Context, meta: Meta): ControlVisionPlugin + } +} + +internal val controlsVisionSerializersModule = SerializersModule { + polymorphic(Vision::class) { + subclass(IndicatorVision.serializer()) + subclass(SliderVision.serializer()) + } +} \ No newline at end of file diff --git a/controls-vision/src/commonMain/kotlin/controlsVisions.kt b/controls-vision/src/commonMain/kotlin/controlsVisions.kt new file mode 100644 index 0000000..1b633ed --- /dev/null +++ b/controls-vision/src/commonMain/kotlin/controlsVisions.kt @@ -0,0 +1,35 @@ +package space.kscience.controls.vision + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import space.kscience.controls.misc.doubleRange +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.meta.convertable +import space.kscience.dataforge.meta.double +import space.kscience.dataforge.meta.string +import space.kscience.visionforge.AbstractControlVision +import space.kscience.visionforge.AbstractVision +import space.kscience.visionforge.Vision + +/** + * A [Vision] that shows a colored indicator + */ +@Serializable +@SerialName("controls.indicator") +public class IndicatorVision : AbstractVision() { + public val color: String? by properties.string() +} + +@Serializable +@SerialName("controls.slider") +public class SliderVision : AbstractControlVision() { + public var position: Double? by properties.double() + public var range: ClosedFloatingPointRange<Double>? by properties.convertable(MetaConverter.doubleRange) +} + +///** +// * A [Vision] that allows both showing the value and changing it +// */ +//public interface RegulatorVision: IndicatorVision{ +// +//} \ No newline at end of file diff --git a/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt b/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt new file mode 100644 index 0000000..8be10df --- /dev/null +++ b/controls-vision/src/commonMain/kotlin/koalaPlotExtensions.kt @@ -0,0 +1,239 @@ +@file:OptIn(FlowPreview::class, FlowPreview::class) + +package space.kscience.controls.vision + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import space.kscience.controls.api.Device +import space.kscience.controls.api.propertyMessageFlow +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.manager.clock +import space.kscience.controls.misc.ValueWithTime +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.name +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.* +import space.kscience.dataforge.misc.DFExperimental +import space.kscience.plotly.Plot +import space.kscience.plotly.bar +import space.kscience.plotly.models.Bar +import space.kscience.plotly.models.Scatter +import space.kscience.plotly.models.Trace +import space.kscience.plotly.models.TraceValues +import space.kscience.plotly.scatter +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + +private var TraceValues.values: List<Value> + get() = value?.list ?: emptyList() + set(newValues) { + value = ListValue(newValues) + } + + +private var TraceValues.times: List<Instant> + get() = value?.list?.map { Instant.parse(it.string) } ?: emptyList() + set(newValues) { + value = ListValue(newValues.map { it.toString().asValue() }) + } + + +private class TimeData(private var points: MutableList<ValueWithTime<Value>> = mutableListOf()) { + private val mutex = Mutex() + + suspend fun append(time: Instant, value: Value) = mutex.withLock { + points.add(ValueWithTime(value, time)) + } + + suspend fun trim(maxAge: Duration, maxPoints: Int = 800, minPoints: Int = 400) { + require(maxPoints > 2) + require(minPoints > 0) + require(maxPoints > minPoints) + val now = Clock.System.now() + // filter old points + points.removeAll { now - it.time > maxAge } + + if (points.size > maxPoints) { + val durationBetweenPoints = maxAge / minPoints + val markedForRemoval = buildList<ValueWithTime<Value>> { + var lastTime: Instant? = null + points.forEach { point -> + if (lastTime?.let { point.time - it < durationBetweenPoints } == true) { + add(point) + } else { + lastTime = point.time + } + } + } + points.removeAll(markedForRemoval) + } + } + + suspend fun fillPlot(x: TraceValues, y: TraceValues) = mutex.withLock { + x.strings = points.map { it.time.toString() } + y.values = points.map { it.value } + } +} + +private val defaultMaxAge get() = 10.minutes +private val defaultMaxPoints get() = 800 +private val defaultMinPoints get() = 400 +private val defaultSampling get() = 1.seconds + +/** + * Add a trace that shows a [Device] property change over time. Show only latest [maxPoints] . + * @return a [Job] that handles the listener + */ +public fun Plot.plotDeviceProperty( + device: Device, + propertyName: String, + extractValue: Meta.() -> Value = { value ?: Null }, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + coroutineScope: CoroutineScope = device.context, + configuration: Scatter.() -> Unit = {}, +): Job = scatter(configuration).run { + val data = TimeData() + device.propertyMessageFlow(propertyName).sample(sampling).transform { + data.append(it.time, it.value.extractValue()) + data.trim(maxAge, maxPoints, minPoints) + emit(data) + }.onEach { + it.fillPlot(x, y) + }.launchIn(coroutineScope) +} + +public fun Plot.plotDeviceProperty( + device: Device, + property: DevicePropertySpec<*, Number>, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + coroutineScope: CoroutineScope = device.context, + configuration: Scatter.() -> Unit = {}, +): Job = plotDeviceProperty( + device, property.name, { value ?: Null }, maxAge, maxPoints, minPoints, sampling, coroutineScope, configuration +) + +private fun <T> Trace.updateFromState( + context: Context, + state: DeviceState<T>, + extractValue: T.() -> Value, + maxAge: Duration, + maxPoints: Int, + minPoints: Int, + sampling: Duration, +): Job { + val clock = context.clock + val data = TimeData() + return state.valueFlow.sample(sampling).transform<T, TimeData> { + data.append(clock.now(), it.extractValue()) + data.trim(maxAge, maxPoints, minPoints) + }.onEach { + it.fillPlot(x, y) + }.launchIn(context) +} + +public fun <T> Plot.plotDeviceState( + context: Context, + state: DeviceState<T>, + extractValue: (T) -> Value = { Value.of(it) }, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + configuration: Scatter.() -> Unit = {}, +): Job = scatter(configuration).run { + updateFromState(context, state, extractValue, maxAge, maxPoints, minPoints, sampling) +} + + +public fun Plot.plotNumberState( + context: Context, + state: DeviceState<Number>, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + configuration: Scatter.() -> Unit = {}, +): Job = scatter(configuration).run { + updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, sampling) +} + + +public fun Plot.plotBooleanState( + context: Context, + state: DeviceState<Boolean>, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + configuration: Bar.() -> Unit = {}, +): Job = bar(configuration).run { + updateFromState(context, state, { asValue() }, maxAge, maxPoints, minPoints, sampling) +} + +private fun <T> Flow<T>.chunkedByPeriod(duration: Duration): Flow<List<T>> { + val collector: ArrayDeque<T> = ArrayDeque<T>() + return channelFlow { + launch { + while (isActive) { + delay(duration) + send(ArrayList(collector)) + collector.clear() + } + } + this@chunkedByPeriod.collect { + collector.add(it) + } + } +} + +private fun List<Instant>.averageTime(): Instant { + val min = min() + val max = max() + val duration = max - min + return min + duration / 2 +} + +/** + * Average property value by [averagingInterval]. Return [startValue] on each sample interval if no events arrived. + */ +@DFExperimental +public fun Plot.plotAveragedDeviceProperty( + device: Device, + propertyName: String, + startValue: Double = 0.0, + extractValue: Meta.() -> Double = { value?.double ?: startValue }, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + averagingInterval: Duration = defaultSampling, + coroutineScope: CoroutineScope = device.context, + configuration: Scatter.() -> Unit = {}, +): Job = scatter(configuration).run { + val data = TimeData() + var lastValue = startValue + device.propertyMessageFlow(propertyName).chunkedByPeriod(averagingInterval).transform { eventList -> + if (eventList.isEmpty()) { + data.append(Clock.System.now(), lastValue.asValue()) + } else { + val time = eventList.map { it.time }.averageTime() + val value = eventList.map { extractValue(it.value) }.average() + data.append(time, value.asValue()) + lastValue = value + } + data.trim(maxAge, maxPoints, minPoints) + emit(data) + }.onEach { + it.fillPlot(x, y) + }.launchIn(coroutineScope) +} diff --git a/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt new file mode 100644 index 0000000..f75630b --- /dev/null +++ b/controls-vision/src/jsMain/kotlin/ControlsVisionPlugin.js.kt @@ -0,0 +1,65 @@ +package space.kscience.controls.vision + +import kotlinx.serialization.modules.SerializersModule +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.PluginFactory +import space.kscience.dataforge.context.PluginTag +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName +import space.kscience.visionforge.VisionPlugin +import space.kscience.visionforge.html.ElementVisionRenderer + + +private val indicatorRenderer = ElementVisionRenderer<IndicatorVision> { name, vision: IndicatorVision, meta -> +// val ledSize = vision.properties["size"].int ?: 15 +// val color = vision.color ?: "LightGray" +// div("controls-indicator") { +// style = """ +// +// @keyframes blink { +// 0% { box-shadow: 0 0 10px; } +// 50% { box-shadow: 0 0 30px; } +// 100% { box-shadow: 0 0 10px; } +// } +// +// display: inline-block; +// margin: ${ledSize}px; +// width: ${ledSize}px; +// height: ${ledSize}px; +// border-radius: 50%; +// +// background: $color; +// border: 1px solid darken($color,5%); +// color: $color; +// animation: blink 3s infinite; +// """.trimIndent() +// } +} + +private val sliderRenderer = ElementVisionRenderer<SliderVision> { name, vision: SliderVision, meta -> + +} + + +public actual class ControlVisionPlugin : VisionPlugin() { + actual override val tag: PluginTag get() = Companion.tag + + actual override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule + + override fun content(target: String): Map<Name, Any> = when (target) { + ElementVisionRenderer.TYPE -> mapOf( + "indicator".asName() to indicatorRenderer, + "slider".asName() to sliderRenderer + ) + + else -> super.content(target) + } + + public actual companion object : PluginFactory<ControlVisionPlugin> { + actual override val tag: PluginTag = PluginTag("controls.vision") + + actual override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin() + + } +} \ No newline at end of file diff --git a/controls-vision/src/jsMain/kotlin/client.kt b/controls-vision/src/jsMain/kotlin/client.kt new file mode 100644 index 0000000..78da2e8 --- /dev/null +++ b/controls-vision/src/jsMain/kotlin/client.kt @@ -0,0 +1,11 @@ +package space.kscience.controls.vision + +import space.kscience.visionforge.html.runVisionClient +import space.kscience.visionforge.markup.MarkupPlugin +import space.kscience.visionforge.plotly.PlotlyPlugin + +public fun main(): Unit = runVisionClient { + plugin(PlotlyPlugin) + plugin(MarkupPlugin) +// plugin(TableVisionJsPlugin) +} \ No newline at end of file diff --git a/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt b/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt new file mode 100644 index 0000000..53ada2e --- /dev/null +++ b/controls-vision/src/jvmMain/kotlin/ControlsVisionPlugin.jvm.kt @@ -0,0 +1,21 @@ +package space.kscience.controls.vision + +import kotlinx.serialization.modules.SerializersModule +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.PluginFactory +import space.kscience.dataforge.context.PluginTag +import space.kscience.dataforge.meta.Meta +import space.kscience.visionforge.VisionPlugin + +public actual class ControlVisionPlugin : VisionPlugin() { + actual override val tag: PluginTag get() = Companion.tag + + actual override val visionSerializersModule: SerializersModule get() = controlsVisionSerializersModule + + public actual companion object : PluginFactory<ControlVisionPlugin> { + actual override val tag: PluginTag = PluginTag("controls.vision") + + actual override fun build(context: Context, meta: Meta): ControlVisionPlugin = ControlVisionPlugin() + + } +} \ No newline at end of file diff --git a/controls-vision/src/jvmMain/kotlin/dashboard.kt b/controls-vision/src/jvmMain/kotlin/dashboard.kt new file mode 100644 index 0000000..1b64b15 --- /dev/null +++ b/controls-vision/src/jvmMain/kotlin/dashboard.kt @@ -0,0 +1,72 @@ +package space.kscience.controls.vision + +import io.ktor.server.cio.CIO +import io.ktor.server.engine.ApplicationEngine +import io.ktor.server.engine.embeddedServer +import io.ktor.server.http.content.staticResources +import io.ktor.server.routing.Routing +import io.ktor.server.routing.routing +import kotlinx.html.TagConsumer +import space.kscience.dataforge.context.Context +import space.kscience.plotly.Plot +import space.kscience.plotly.PlotlyConfig +import space.kscience.visionforge.html.HtmlVisionFragment +import space.kscience.visionforge.html.VisionPage +import space.kscience.visionforge.html.VisionTagConsumer +import space.kscience.visionforge.markup.MarkupPlugin +import space.kscience.visionforge.plotly.PlotlyPlugin +import space.kscience.visionforge.plotly.plotly +import space.kscience.visionforge.server.VisionRoute +import space.kscience.visionforge.server.close +import space.kscience.visionforge.server.openInBrowser +import space.kscience.visionforge.server.visionPage +import space.kscience.visionforge.visionManager + +public fun Context.showDashboard( + port: Int = 7777, + routes: Routing.() -> Unit = {}, + configurationBuilder: VisionRoute.() -> Unit = {}, + visionFragment: HtmlVisionFragment, +): ApplicationEngine { + //create a sub-context for visualization + val visualisationContext = buildContext { + plugin(PlotlyPlugin) + plugin(ControlVisionPlugin) + plugin(MarkupPlugin) + } + + return visualisationContext.embeddedServer(CIO, port = port) { + routing { + staticResources("", null, null) + routes() + } + + visionPage( + visualisationContext.visionManager, + VisionPage.scriptHeader("js/controls-vision.js"), + configurationBuilder = configurationBuilder, + visionFragment = visionFragment + ) + }.also { + it.start(false) + it.openInBrowser() + + + println("Enter 'exit' to close server") + while (readlnOrNull() != "exit") { + // + } + + it.close() + } +} + +context(VisionTagConsumer<*>) +public fun TagConsumer<*>.plot( + config: PlotlyConfig = PlotlyConfig(), + block: Plot.() -> Unit, +) { + vision { + plotly(config, block) + } +} diff --git a/controls-visualisation-compose/README.md b/controls-visualisation-compose/README.md new file mode 100644 index 0000000..da0d9ef --- /dev/null +++ b/controls-visualisation-compose/README.md @@ -0,0 +1,21 @@ +# Module controls-visualisation-compose + +Visualisation extension using compose-multiplatform + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:controls-visualisation-compose:0.4.0-dev-7`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:controls-visualisation-compose:0.4.0-dev-7") +} +``` diff --git a/controls-visualisation-compose/build.gradle.kts b/controls-visualisation-compose/build.gradle.kts new file mode 100644 index 0000000..0c68988 --- /dev/null +++ b/controls-visualisation-compose/build.gradle.kts @@ -0,0 +1,32 @@ +import org.jetbrains.compose.ExperimentalComposeLibrary + +plugins { + id("space.kscience.gradle.mpp") + alias(spclibs.plugins.compose.compiler) + alias(spclibs.plugins.compose.jb) + `maven-publish` +} + +description = """ + Visualisation extension using compose-multiplatform +""".trimIndent() + +kscience { + jvm() + useKtor() + useSerialization() + useContextReceivers() + commonMain { + api(projects.controlsConstructor) + api(libs.koala.plots) + api(compose.foundation) + api(compose.material3) + @OptIn(ExperimentalComposeLibrary::class) + api(compose.desktop.components.splitPane) + } +} + + +readme { + maturity = space.kscience.gradle.Maturity.PROTOTYPE +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt new file mode 100644 index 0000000..464514b --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2D.kt @@ -0,0 +1,64 @@ +package space.kscience.controls.compose + +import androidx.compose.runtime.Immutable +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.rotate + +/** + * A single 2D drawable + */ +@Immutable +public sealed interface DeviceDrawable2D { + + public fun DrawScope.draw() + + override fun equals(other: Any?): Boolean +} + +@Immutable +public data class CircleDrawable2D(val position: Offset, val radius: Float, val color: Color) : DeviceDrawable2D { + override fun DrawScope.draw() { + drawCircle(color, radius = radius, center = position) + } +} + +@Drawable2DBuilder +public fun DeviceDrawable2DStore.circle(id: String, position: Offset, radius: Float, color: Color) { + emit(id, CircleDrawable2D(position, radius, color)) +} + +@Immutable +public data class RectangleDrawable2D( + val position: Offset, + val rectangleSize: Size, + val color: Color, + val rotateDegrees: Float = 0f, +) : DeviceDrawable2D { + override fun DrawScope.draw() { + rotate(rotateDegrees) { + drawRect( + color = color, + topLeft = Offset( + (position.x - rectangleSize.width / 2), + (position.y - rectangleSize.height / 2) + ), + size = Size(rectangleSize.width, rectangleSize.height) + ) + } + } +} + +@Drawable2DBuilder +public fun DeviceDrawable2DStore.rectangle( + id: String, + position: Offset, + rectangleSize: Size, + color: Color, + rotateDegrees: Float = 0f, +) { + emit(id, RectangleDrawable2D(position, rectangleSize, color, rotateDegrees)) +} + diff --git a/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt new file mode 100644 index 0000000..94bf76c --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/DeviceDrawable2DStore.kt @@ -0,0 +1,94 @@ +package space.kscience.controls.compose + +import androidx.compose.foundation.Canvas +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.toSize +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch +import space.kscience.controls.api.Device +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.propertyFlow + +@DslMarker +public annotation class Drawable2DBuilder + +@Drawable2DBuilder +public class DeviceDrawable2DStore(public val scope: CoroutineScope, public val size: Size) { + public val drawableFlow: MutableStateFlow<Map<String, DeviceDrawable2D>> = MutableStateFlow(emptyMap()) +} + +public fun DeviceDrawable2DStore.emit(id: String, drawable2D: DeviceDrawable2D) { + drawableFlow.value += (id to drawable2D) +} + +public fun DeviceDrawable2DStore.emitAll(drawables: Map<String, DeviceDrawable2D>) { + drawableFlow.value += drawables +} + + +/** + * Fill drawables from a flow + */ +public fun DeviceDrawable2DStore.observe(id: String, flow: Flow<DeviceDrawable2D>): Job = flow.onEach { + drawableFlow.value += (id to it) +}.launchIn(scope) + +/** + * Observe single [DeviceState] + */ +public fun <T> DeviceDrawable2DStore.observeState( + state: DeviceState<T>, + id: String = state.toString(), + transform: suspend DeviceDrawable2DStore.(T) -> DeviceDrawable2D, +): Job = observe(id, state.valueFlow.map { transform(this, it) }) + +/** + * Observe a single [Device] property + */ +public fun <T, D : Device, P : DevicePropertySpec<D, T>> DeviceDrawable2DStore.observeProperty( + device: D, + devicePropertySpec: DevicePropertySpec<D, T>, + id: String = devicePropertySpec.toString(), + transform: suspend DeviceDrawable2DStore.(T) -> DeviceDrawable2D, +): Job = observe(id, device.propertyFlow(devicePropertySpec).map { transform(this, it) }) + +@Composable +public fun Device2DCanvas( + modifier: Modifier = Modifier, + onDraw: DrawScope.() -> Unit = {}, + flowBuilder: suspend DeviceDrawable2DStore.() -> Unit, +) { + val coroutineScope = rememberCoroutineScope() + var canvasSize by remember { mutableStateOf(Size(100f, 100f)) } + + val store = remember(canvasSize) { + DeviceDrawable2DStore(coroutineScope, canvasSize).apply { + coroutineScope.launch { + flowBuilder() + } + } + } + + val drawables by store.drawableFlow.collectAsState() + + key(store) { + Canvas(modifier.onGloballyPositioned { + canvasSize = it.size.toSize() + }) { + clipRect { + drawables.values.forEach { + with(it) { draw() } + } + onDraw() + } + } + } +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt b/controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt new file mode 100644 index 0000000..3ab10c6 --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/NumberTextField.kt @@ -0,0 +1,59 @@ +package space.kscience.controls.compose + +import androidx.compose.foundation.layout.Row +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle + +@Composable +public fun NumberTextField( + value: Number, + onValueChange: (Number) -> Unit, + step: Double = 0.0, + formatter: (Number) -> String = { it.toString() }, + modifier: Modifier = Modifier, + enabled: Boolean = true, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors(), +) { + var isError by remember { mutableStateOf(false) } + + Row (verticalAlignment = Alignment.CenterVertically, modifier = modifier) { + step.takeIf { it > 0.0 }?.let { + IconButton({ onValueChange(value.toDouble() - step) }, enabled = enabled) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowLeft, "decrease value") + } + } + TextField( + value = formatter(value), + onValueChange = { stringValue: String -> + val number = stringValue.toDoubleOrNull() + number?.let { onValueChange(number) } + isError = number == null + }, + isError = isError, + enabled = enabled, + textStyle = textStyle, + label = label, + supportingText = supportingText, + singleLine = true, + shape = shape, + colors = colors, + modifier = Modifier.weight(1f) + ) + step.takeIf { it > 0.0 }?.let { + IconButton({ onValueChange(value.toDouble() + step) }, enabled = enabled) { + Icon(Icons.AutoMirrored.Filled.KeyboardArrowRight, "increase value") + } + } + } +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt b/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt new file mode 100644 index 0000000..65ce8d9 --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/TimeAxisModel.kt @@ -0,0 +1,42 @@ +package space.kscience.controls.compose + +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import io.github.koalaplot.core.xygraph.AxisModel +import io.github.koalaplot.core.xygraph.TickValues +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlin.math.floor +import kotlin.time.Duration +import kotlin.time.times + +public class TimeAxisModel( + override val minimumMajorTickSpacing: Dp = 50.dp, + private val rangeProvider: () -> ClosedRange<Instant>, +) : AxisModel<Instant> { + + override fun computeTickValues(axisLength: Dp): TickValues<Instant> { + val currentRange = rangeProvider() + val rangeLength = currentRange.endInclusive - currentRange.start + val numTicks = floor(axisLength / minimumMajorTickSpacing).toInt() + return object : TickValues<Instant> { + override val majorTickValues: List<Instant> = List(numTicks) { + currentRange.start + it.toDouble() / (numTicks - 1) * rangeLength + } + + override val minorTickValues: List<Instant> = emptyList() + } + } + + override fun computeOffset(point: Instant): Float { + val currentRange = rangeProvider() + return ((point - currentRange.start) / (currentRange.endInclusive - currentRange.start)).toFloat() + } + + public companion object { + public fun recent(duration: Duration, clock: Clock = Clock.System): TimeAxisModel = TimeAxisModel { + val now = clock.now() + (now - duration)..now + } + } +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/composeState.kt b/controls-visualisation-compose/src/commonMain/kotlin/composeState.kt new file mode 100644 index 0000000..5b793d6 --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/composeState.kt @@ -0,0 +1,31 @@ +package space.kscience.controls.compose + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.snapshotFlow +import kotlinx.coroutines.flow.Flow +import space.kscience.controls.constructor.DeviceState +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + + +/** + * Represent this [DeviceState] as Compose multiplatform [State] + */ +@Composable +public fun <T> DeviceState<T>.asComposeState( + coroutineContext: CoroutineContext = EmptyCoroutineContext, +): State<T> = valueFlow.collectAsState(value, coroutineContext) + + +/** + * Represent this Compose [State] as [DeviceState] + */ +public fun <T> State<T>.asDeviceState(): DeviceState<T> = object : DeviceState<T> { + override val value: T get() = this@asDeviceState.value + + override val valueFlow: Flow<T> get() = snapshotFlow { this@asDeviceState.value } + + override fun toString(): String = "ComposeState(value=$value)" +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/indicators.kt b/controls-visualisation-compose/src/commonMain/kotlin/indicators.kt new file mode 100644 index 0000000..ada0f10 --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/indicators.kt @@ -0,0 +1,2 @@ +package space.kscience.controls.compose + diff --git a/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt new file mode 100644 index 0000000..87a9fca --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/koalaPlots.kt @@ -0,0 +1,244 @@ +@file:OptIn(FlowPreview::class) + +package space.kscience.controls.compose + +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.SolidColor +import io.github.koalaplot.core.line.LinePlot +import io.github.koalaplot.core.style.LineStyle +import io.github.koalaplot.core.xygraph.DefaultPoint +import io.github.koalaplot.core.xygraph.XYGraphScope +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.* +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import space.kscience.controls.api.Device +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.api.propertyMessageFlow +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.constructor.values +import space.kscience.controls.manager.clock +import space.kscience.controls.misc.ValueWithTime +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.name +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.double +import kotlin.time.Duration +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds + + +private val defaultMaxAge get() = 10.minutes +private val defaultMaxPoints get() = 800 +private val defaultMinPoints get() = 400 +private val defaultSampling get() = 1.seconds + + +internal fun <T> Flow<ValueWithTime<T>>.collectAndTrim( + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + clock: Clock = Clock.System, +): Flow<List<ValueWithTime<T>>> { + require(maxPoints > 2) + require(minPoints > 0) + require(maxPoints > minPoints) + val points = mutableListOf<ValueWithTime<T>>() + return transform { newPoint -> + points.add(newPoint) + val now = clock.now() + // filter old points + points.removeAll { now - it.time > maxAge } + + if (points.size > maxPoints) { + val durationBetweenPoints = maxAge / minPoints + val markedForRemoval = buildList { + var lastTime: Instant? = null + points.forEach { point -> + if (lastTime?.let { point.time - it < durationBetweenPoints } == true) { + add(point) + } else { + lastTime = point.time + } + } + } + + points.removeAll(markedForRemoval) + } + //return a protective copy + emit(ArrayList(points)) + } +} + +private val defaultLineStyle: LineStyle = LineStyle(SolidColor(androidx.compose.ui.graphics.Color.Black)) + + +@Composable +private fun <T> XYGraphScope<Instant, T>.PlotTimeSeries( + data: List<ValueWithTime<T>>, + lineStyle: LineStyle = defaultLineStyle, +) { + LinePlot( + data = data.map { DefaultPoint(it.time, it.value) }, + lineStyle = lineStyle + ) +} + + +/** + * Add a trace that shows a [Device] property change over time. Show only latest [maxPoints] . + * @return a [Job] that handles the listener + */ +@Composable +public fun XYGraphScope<Instant, Double>.PlotDeviceProperty( + device: Device, + propertyName: String, + extractValue: Meta.() -> Double = { value?.double ?: Double.NaN }, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + lineStyle: LineStyle = defaultLineStyle, +) { + var points by remember { mutableStateOf<List<ValueWithTime<Double>>>(emptyList()) } + + LaunchedEffect(device, propertyName, maxAge, maxPoints, minPoints, sampling) { + device.propertyMessageFlow(propertyName) + .sample(sampling) + .map { ValueWithTime(it.value.extractValue(), it.time) } + .collectAndTrim(maxAge, maxPoints, minPoints, device.clock) + .onEach { points = it } + .launchIn(this) + } + + + PlotTimeSeries(points, lineStyle) +} + +@Composable +public fun XYGraphScope<Instant, Double>.PlotDeviceProperty( + device: Device, + property: DevicePropertySpec<*, out Number>, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + lineStyle: LineStyle = LineStyle(SolidColor(androidx.compose.ui.graphics.Color.Black)), +): Unit = PlotDeviceProperty( + device = device, + propertyName = property.name, + extractValue = { property.converter.readOrNull(this)?.toDouble() ?: Double.NaN }, + maxAge = maxAge, + maxPoints = maxPoints, + minPoints = minPoints, + sampling = sampling, + lineStyle = lineStyle +) + +@Composable +public fun XYGraphScope<Instant, Double>.PlotNumberState( + context: Context, + state: DeviceState<Number>, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + lineStyle: LineStyle = defaultLineStyle, +): Unit { + var points by remember { mutableStateOf<List<ValueWithTime<Double>>>(emptyList()) } + + + LaunchedEffect(context, state, maxAge, maxPoints, minPoints, sampling) { + val clock = context.clock + + state.valueFlow.sample(sampling) + .map { ValueWithTime(it.toDouble(), clock.now()) } + .collectAndTrim(maxAge, maxPoints, minPoints, clock) + .onEach { points = it } + .launchIn(this) + } + + + PlotTimeSeries(points, lineStyle) +} + +@Composable +public fun XYGraphScope<Instant, Double>.PlotNumericState( + context: Context, + state: DeviceState<NumericalValue<*>>, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + sampling: Duration = defaultSampling, + lineStyle: LineStyle = defaultLineStyle, +): Unit { + PlotNumberState(context, state.values(), maxAge, maxPoints, minPoints, sampling, lineStyle) +} + + +private fun List<Instant>.averageTime(): Instant { + val min = min() + val max = max() + val duration = max - min + return min + duration / 2 +} + +private fun <T> Flow<T>.chunkedByPeriod(duration: Duration): Flow<List<T>> { + val collector: ArrayDeque<T> = ArrayDeque<T>() + return channelFlow { + launch { + while (isActive) { + delay(duration) + send(ArrayList(collector)) + collector.clear() + } + } + this@chunkedByPeriod.collect { + collector.add(it) + } + } +} + + +/** + * Average property value by [averagingInterval]. Return [startValue] on each sample interval if no events arrived. + */ +@Composable +public fun XYGraphScope<Instant, Double>.PlotAveragedDeviceProperty( + device: Device, + propertyName: String, + startValue: Double = 0.0, + extractValue: Meta.() -> Double = { value?.double ?: startValue }, + maxAge: Duration = defaultMaxAge, + maxPoints: Int = defaultMaxPoints, + minPoints: Int = defaultMinPoints, + averagingInterval: Duration = defaultSampling, + lineStyle: LineStyle = defaultLineStyle, +) { + + var points by remember { mutableStateOf<List<ValueWithTime<Double>>>(emptyList()) } + + LaunchedEffect(device, propertyName, startValue, maxAge, maxPoints, minPoints, averagingInterval) { + val clock = device.clock + var lastValue = startValue + device.propertyMessageFlow(propertyName) + .chunkedByPeriod(averagingInterval) + .transform<List<PropertyChangedMessage>, ValueWithTime<Double>> { eventList -> + if (eventList.isEmpty()) { + ValueWithTime(lastValue, clock.now()) + } else { + val time = eventList.map { it.time }.averageTime() + val value = eventList.map { extractValue(it.value) }.average() + ValueWithTime(value, time).also { + lastValue = value + } + } + }.collectAndTrim(maxAge, maxPoints, minPoints, clock) + .onEach { points = it } + .launchIn(this) + } + + PlotTimeSeries(points, lineStyle) +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/misc.kt b/controls-visualisation-compose/src/commonMain/kotlin/misc.kt new file mode 100644 index 0000000..caf21e3 --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/misc.kt @@ -0,0 +1,12 @@ +package space.kscience.controls.compose + +import androidx.compose.ui.Modifier + +public inline fun Modifier.conditional( + condition: Boolean, + modifier: Modifier.() -> Modifier, +): Modifier = if (condition) { + then(modifier(Modifier)) +} else { + this +} \ No newline at end of file diff --git a/controls-visualisation-compose/src/commonMain/kotlin/sliders.kt b/controls-visualisation-compose/src/commonMain/kotlin/sliders.kt new file mode 100644 index 0000000..4adf952 --- /dev/null +++ b/controls-visualisation-compose/src/commonMain/kotlin/sliders.kt @@ -0,0 +1,51 @@ +package space.kscience.controls.compose + +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material3.SliderColors +import androidx.compose.material3.SliderDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.constructor.MutableDeviceState + +@Composable +public fun Slider( + deviceState: MutableDeviceState<Number>, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueRange: ClosedFloatingPointRange<Float> = 0f..1f, + steps: Int = 0, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + colors: SliderColors = SliderDefaults.colors(), +) { + androidx.compose.material3.Slider( + value = deviceState.value.toFloat(), + onValueChange = { deviceState.value = it }, + modifier = modifier, + enabled = enabled, + valueRange = valueRange, + steps = steps, + interactionSource = interactionSource, + colors = colors, + ) +} + +@Composable +public fun SliderIndicator( + deviceState: DeviceState<Number>, + modifier: Modifier = Modifier, + valueRange: ClosedFloatingPointRange<Float> = 0f..1f, + steps: Int = 0, + colors: SliderColors = SliderDefaults.colors(), +) { + androidx.compose.material3.Slider( + value = deviceState.value.toFloat(), + onValueChange = { /*do nothing*/ }, + modifier = modifier, + enabled = false, + valueRange = valueRange, + steps = steps, + colors = colors, + ) +} \ No newline at end of file diff --git a/demo/all-things/api/all-things.api b/demo/all-things/api/all-things.api index 4d283e5..1a1b140 100644 --- a/demo/all-things/api/all-things.api +++ b/demo/all-things/api/all-things.api @@ -47,11 +47,12 @@ public final class space/kscience/controls/demo/DemoDevice$Companion : space/ksc public fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/demo/DemoDevice; public final fun getCoordinates ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getCos ()Lspace/kscience/controls/spec/DevicePropertySpec; - public final fun getCosScale ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getCosScale ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public final fun getResetScale ()Lspace/kscience/controls/spec/DeviceActionSpec; + public final fun getSetSinScale ()Lspace/kscience/controls/spec/DeviceActionSpec; public final fun getSin ()Lspace/kscience/controls/spec/DevicePropertySpec; - public final fun getSinScale ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; - public final fun getTimeScale ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getSinScale ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; + public final fun getTimeScale ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public synthetic fun onOpen (Lspace/kscience/controls/api/Device;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun onOpen (Lspace/kscience/controls/demo/IDemoDevice;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/demo/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts index 05d7395..077e55d 100644 --- a/demo/all-things/build.gradle.kts +++ b/demo/all-things/build.gradle.kts @@ -1,7 +1,7 @@ plugins { kotlin("jvm") - id("org.openjfx.javafxplugin") version "0.0.13" - application + alias(spclibs.plugins.compose.compiler) + alias(spclibs.plugins.compose.jb) } @@ -10,9 +10,6 @@ repositories { maven("https://repo.kotlin.link") } -val ktorVersion: String by rootProject.extra -val rsocketVersion: String by rootProject.extra - dependencies { implementation(projects.controlsCore) //implementation(projects.controlsServer) @@ -22,29 +19,47 @@ dependencies { implementation(projects.magix.magixZmq) implementation(projects.controlsOpcua) - implementation("io.ktor:ktor-client-cio:$ktorVersion") - implementation("no.tornado:tornadofx:1.7.20") - implementation("space.kscience:plotlykt-server:0.5.3") + implementation(spclibs.ktor.client.cio) + implementation(libs.plotlykt.server) // implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6") + + implementation(compose.runtime) + implementation(compose.desktop.currentOs) + implementation(compose.material3) +// implementation("org.pushing-pixels:aurora-window:1.3.0") +// implementation("org.pushing-pixels:aurora-component:1.3.0") +// implementation("org.pushing-pixels:aurora-theming:1.3.0") + implementation(spclibs.logback.classic) } kotlin{ - jvmToolchain(11) -} - - -tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn") + jvmToolchain(17) + compilerOptions { + freeCompilerArgs.addAll("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn") } } -javafx { - version = "17" - modules("javafx.controls") +compose{ + desktop{ + application{ + mainClass = "space.kscience.controls.demo.DemoControllerViewKt" + } + } } - -application { - mainClass.set("space.kscience.controls.demo.DemoControllerViewKt") -} \ No newline at end of file +// +// +//tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { +// kotlinOptions { +// freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn") +// } +//} +// +//javafx { +// version = "17" +// modules("javafx.controls") +//} +// +//application { +// mainClass.set("space.kscience.controls.demo.DemoControllerViewKt") +//} \ No newline at end of file diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt index 94815fa..fc72520 100644 --- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt +++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoControllerView.kt @@ -1,17 +1,26 @@ package space.kscience.controls.demo +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState import io.ktor.server.engine.ApplicationEngine -import javafx.scene.Parent -import javafx.scene.control.Slider -import javafx.scene.layout.Priority -import javafx.stage.Stage +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import org.eclipse.milo.opcua.sdk.server.OpcUaServer import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +import space.kscience.controls.api.GetDescriptionMessage +import space.kscience.controls.api.PropertyChangedMessage import space.kscience.controls.client.launchMagixService -import space.kscience.controls.demo.DemoDevice.Companion.cosScale -import space.kscience.controls.demo.DemoDevice.Companion.sinScale -import space.kscience.controls.demo.DemoDevice.Companion.timeScale +import space.kscience.controls.client.magixFormat import space.kscience.controls.manager.DeviceManager import space.kscience.controls.manager.install import space.kscience.controls.opcua.server.OpcUaServer @@ -20,21 +29,26 @@ import space.kscience.controls.opcua.server.serveDevices import space.kscience.controls.spec.write import space.kscience.dataforge.context.* import space.kscience.magix.api.MagixEndpoint +import space.kscience.magix.api.MagixMessage +import space.kscience.magix.api.send +import space.kscience.magix.api.subscribe import space.kscience.magix.rsocket.rSocketWithTcp import space.kscience.magix.rsocket.rSocketWithWebSockets import space.kscience.magix.server.RSocketMagixFlowPlugin import space.kscience.magix.server.startMagixServer import space.kscince.magix.zmq.ZmqMagixFlowPlugin -import tornadofx.* import java.awt.Desktop import java.net.URI -class DemoController : Controller(), ContextAware { + +private val json = Json { prettyPrint = true } + +class DemoController : ContextAware { var device: DemoDevice? = null var magixServer: ApplicationEngine? = null var visualizer: ApplicationEngine? = null - var opcUaServer: OpcUaServer = OpcUaServer { + val opcUaServer: OpcUaServer = OpcUaServer { setApplicationName(LocalizedText.english("space.kscience.controls.opcua")) endpoint { @@ -49,28 +63,46 @@ class DemoController : Controller(), ContextAware { private val deviceManager = context.request(DeviceManager) - fun init() { - context.launch { - device = deviceManager.install("demo", DemoDevice) - //starting magix event loop - magixServer = startMagixServer( - RSocketMagixFlowPlugin(), //TCP rsocket support - ZmqMagixFlowPlugin() //ZMQ support - ) - //Launch a device client and connect it to the server - val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") - deviceManager.launchMagixService(deviceEndpoint) - //connect visualization to a magix endpoint - val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") - visualizer = startDemoDeviceServer(visualEndpoint) - //serve devices as OPC-UA namespace - opcUaServer.startup() - opcUaServer.serveDevices(deviceManager) - } + fun start(): Job = context.launch { + device = deviceManager.install("demo", DemoDevice) + //starting magix event loop + magixServer = startMagixServer( + RSocketMagixFlowPlugin(), //TCP rsocket support + ZmqMagixFlowPlugin() //ZMQ support + ) + //Launch a device client and connect it to the server + val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") + deviceManager.launchMagixService(deviceEndpoint, "demoDevice") + //connect visualization to a magix endpoint + val visualEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") + visualizer = startDemoDeviceServer(visualEndpoint) + + //serve devices as OPC-UA namespace + opcUaServer.startup() + opcUaServer.serveDevices(deviceManager) + + //create a remote listener endpoint + val listenerEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") + + // subscribe remote endpoint + listenerEndpoint.subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) -> + // print all messages that are not property change message + if (deviceMessage !is PropertyChangedMessage) { + println(">> ${json.encodeToString(MagixMessage.serializer(), magixMessage)}") + } + }.launchIn(this) + + // send description request + listenerEndpoint.send( + format = DeviceManager.magixFormat, + payload = GetDescriptionMessage(), + source = "listener", +// target = "demoDevice" + ) } - fun shutdown() { + fun shutdown(): Job = context.launch { logger.info { "Shutting down..." } opcUaServer.shutdown() logger.info { "OpcUa server stopped" } @@ -78,92 +110,99 @@ class DemoController : Controller(), ContextAware { logger.info { "Visualization server stopped" } magixServer?.stop(1000, 5000) logger.info { "Magix server stopped" } - device?.close() + device?.stop() logger.info { "Device server stopped" } - context.close() } } +@Composable +fun DemoControls(controller: DemoController) { + var timeScale by remember { mutableStateOf(5000f) } + var xScale by remember { mutableStateOf(1f) } + var yScale by remember { mutableStateOf(1f) } -class DemoControllerView : View(title = " Demo controller remote") { - private val controller: DemoController by inject() - private var timeScaleSlider: Slider by singleAssign() - private var xScaleSlider: Slider by singleAssign() - private var yScaleSlider: Slider by singleAssign() - - override val root: Parent = vbox { - hbox { - label("Time scale") - pane { - hgrow = Priority.ALWAYS + Surface(Modifier.padding(5.dp)) { + Column { + Row(Modifier.fillMaxWidth()) { + Text("Time Scale", modifier = Modifier.align(Alignment.CenterVertically).width(100.dp)) + TextField( + String.format("%.2f", timeScale), + {}, + enabled = false, + modifier = Modifier.align(Alignment.CenterVertically).width(100.dp) + ) + Slider(timeScale, onValueChange = { timeScale = it }, steps = 20, valueRange = 1000f..5000f) } - timeScaleSlider = slider(1000..10000, 5000) { - isShowTickLabels = true - isShowTickMarks = true + Row(Modifier.fillMaxWidth()) { + Text("X scale", modifier = Modifier.align(Alignment.CenterVertically).width(100.dp)) + TextField( + String.format("%.2f", xScale), + {}, + enabled = false, + modifier = Modifier.align(Alignment.CenterVertically).width(100.dp) + ) + Slider(xScale, onValueChange = { xScale = it }, steps = 20, valueRange = 0.1f..2.0f) } - } - hbox { - label("X scale") - pane { - hgrow = Priority.ALWAYS + Row(Modifier.fillMaxWidth()) { + Text("Y scale", modifier = Modifier.align(Alignment.CenterVertically).width(100.dp)) + TextField( + String.format("%.2f", yScale), + {}, + enabled = false, + modifier = Modifier.align(Alignment.CenterVertically).width(100.dp) + ) + Slider(yScale, onValueChange = { yScale = it }, steps = 20, valueRange = 0.1f..2.0f) } - xScaleSlider = slider(0.1..2.0, 1.0) { - isShowTickLabels = true - isShowTickMarks = true + Row(Modifier.fillMaxWidth()) { + Button( + onClick = { + controller.device?.run { + launch { + write(DemoDevice.timeScale, timeScale.toDouble()) + write(DemoDevice.sinScale, xScale.toDouble()) + write(DemoDevice.cosScale, yScale.toDouble()) + } + } + }, + Modifier.fillMaxWidth() + ) { + Text("Submit") + } } - } - hbox { - label("Y scale") - pane { - hgrow = Priority.ALWAYS - } - yScaleSlider = slider(0.1..2.0, 1.0) { - isShowTickLabels = true - isShowTickMarks = true - } - } - button("Submit") { - useMaxWidth = true - action { - controller.device?.run { - launch { - write(timeScale, timeScaleSlider.value) - write(sinScale, xScaleSlider.value) - write(cosScale, yScaleSlider.value) - } + Row(Modifier.fillMaxWidth()) { + Button( + onClick = { + controller.visualizer?.run { + val host = "localhost"//environment.connectors.first().host + val port = environment.connectors.first().port + val uri = URI("http", null, host, port, "/", null, null) + Desktop.getDesktop().browse(uri) + } + }, + Modifier.fillMaxWidth() + ) { + Text("Show plots") } } } - button("Show plots") { - useMaxWidth = true - action { - controller.visualizer?.run { - val host = "localhost"//environment.connectors.first().host - val port = environment.connectors.first().port - val uri = URI("http", null, host, port, "/", null, null) - Desktop.getDesktop().browse(uri) - } - } + + } +} + + +fun main() = application { + val controller = remember { DemoController().apply { start() } } + + Window( + title = "All things control", + onCloseRequest = { + controller.shutdown() + exitApplication() + }, + state = rememberWindowState(width = 400.dp, height = 320.dp) + ) { + MaterialTheme { + DemoControls(controller) } } -} - - -class DemoControllerApp : App(DemoControllerView::class) { - private val controller: DemoController by inject() - - override fun start(stage: Stage) { - super.start(stage) - controller.init() - } - - override fun stop() { - controller.shutdown() - super.stop() - } -} - - -fun main() { - launch<DemoControllerApp>() } \ No newline at end of file diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt index dd65b78..5cf1b28 100644 --- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt +++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/DemoDevice.kt @@ -7,16 +7,16 @@ import space.kscience.controls.spec.* import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Factory import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter import space.kscience.dataforge.meta.ValueType import space.kscience.dataforge.meta.descriptors.value -import space.kscience.dataforge.meta.transformations.MetaConverter import java.time.Instant import kotlin.math.cos import kotlin.math.sin import kotlin.time.Duration.Companion.milliseconds -interface IDemoDevice: Device { +interface IDemoDevice : Device { var timeScaleState: Double var sinScaleState: Double var cosScaleState: Double @@ -42,16 +42,21 @@ class DemoDevice(context: Context, meta: Meta) : DeviceBySpec<IDemoDevice>(Compa // register virtual properties based on actual object state val timeScale by mutableProperty(MetaConverter.double, IDemoDevice::timeScaleState) { metaDescriptor { - type(ValueType.NUMBER) + valueType(ValueType.NUMBER) } - info = "Real to virtual time scale" + description = "Real to virtual time scale" } - val sinScale by mutableProperty(MetaConverter.double, IDemoDevice::sinScaleState) + val sinScale by mutableProperty(MetaConverter.double, IDemoDevice::sinScaleState){ + description = "The scale of sin plot" + metaDescriptor { + valueType(ValueType.NUMBER) + } + } val cosScale by mutableProperty(MetaConverter.double, IDemoDevice::cosScaleState) - val sin by doubleProperty(read = IDemoDevice::sinValue) - val cos by doubleProperty(read = IDemoDevice::cosValue) + val sin by doubleProperty { sinValue() } + val cos by doubleProperty { cosValue() } val coordinates by metaProperty( descriptorBuilder = { @@ -74,6 +79,10 @@ class DemoDevice(context: Context, meta: Meta) : DeviceBySpec<IDemoDevice>(Compa write(cosScale, 1.0) } + val setSinScale by action(MetaConverter.double, MetaConverter.unit){ value: Double -> + write(sinScale, value) + } + override suspend fun IDemoDevice.onOpen() { launch { read(sinScale) diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt index fda8ead..5ff1287 100644 --- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt +++ b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/demoDeviceServer.kt @@ -57,15 +57,15 @@ fun CoroutineScope.startDemoDeviceServer(magixEndpoint: MagixEndpoint): Applicat //share subscription to a parse message only once val subscription = magixEndpoint.subscribe(DeviceManager.magixFormat).shareIn(this, SharingStarted.Lazily) - val sinFlow = subscription.mapNotNull { (_, payload) -> + val sinFlow = subscription.mapNotNull { (_, payload) -> (payload as? PropertyChangedMessage)?.takeIf { it.property == DemoDevice.sin.name } }.map { it.value } - val cosFlow = subscription.mapNotNull { (_, payload) -> + val cosFlow = subscription.mapNotNull { (_, payload) -> (payload as? PropertyChangedMessage)?.takeIf { it.property == DemoDevice.cos.name } }.map { it.value } - val sinCosFlow = subscription.mapNotNull { (_, payload) -> + val sinCosFlow = subscription.mapNotNull { (_, payload) -> (payload as? PropertyChangedMessage)?.takeIf { it.property == DemoDevice.coordinates.name } }.map { it.value } diff --git a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/generateMessageSchema.kt b/demo/all-things/src/main/kotlin/space/kscience/controls/demo/generateMessageSchema.kt deleted file mode 100644 index d50ec2c..0000000 --- a/demo/all-things/src/main/kotlin/space/kscience/controls/demo/generateMessageSchema.kt +++ /dev/null @@ -1,10 +0,0 @@ -package space.kscience.controls.demo - -//import com.github.ricky12awesome.jss.encodeToSchema -//import com.github.ricky12awesome.jss.globalJson -//import space.kscience.controls.api.DeviceMessage - -//fun main() { -// val schema = globalJson.encodeToSchema(DeviceMessage.serializer(), generateDefinitions = false) -// println(schema) -//} \ No newline at end of file diff --git a/demo/car/api/car.api b/demo/car/api/car.api index 5b1940f..f5bda95 100644 --- a/demo/car/api/car.api +++ b/demo/car/api/car.api @@ -9,7 +9,7 @@ public abstract interface class space/kscience/controls/demo/car/IVirtualCar : s } public final class space/kscience/controls/demo/car/IVirtualCar$Companion : space/kscience/controls/spec/DeviceSpec { - public final fun getAcceleration ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getAcceleration ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public final fun getLocation ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getSpeed ()Lspace/kscience/controls/spec/DevicePropertySpec; } @@ -17,7 +17,6 @@ public final class space/kscience/controls/demo/car/IVirtualCar$Companion : spac public final class space/kscience/controls/demo/car/MagixVirtualCar : space/kscience/controls/demo/car/VirtualCar { public static final field Companion Lspace/kscience/controls/demo/car/MagixVirtualCar$Companion; public fun <init> (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)V - public fun open (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } public final class space/kscience/controls/demo/car/MagixVirtualCar$Companion : space/kscience/dataforge/context/Factory { @@ -45,11 +44,11 @@ public final class space/kscience/controls/demo/car/Vector2D : space/kscience/da public fun toString ()Ljava/lang/String; } -public final class space/kscience/controls/demo/car/Vector2D$CoordinatesMetaConverter : space/kscience/dataforge/meta/transformations/MetaConverter { - public synthetic fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object; - public fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/demo/car/Vector2D; - public synthetic fun objectToMeta (Ljava/lang/Object;)Lspace/kscience/dataforge/meta/Meta; - public fun objectToMeta (Lspace/kscience/controls/demo/car/Vector2D;)Lspace/kscience/dataforge/meta/Meta; +public final class space/kscience/controls/demo/car/Vector2D$CoordinatesMetaConverter : space/kscience/dataforge/meta/MetaConverter { + public synthetic fun convert (Ljava/lang/Object;)Lspace/kscience/dataforge/meta/Meta; + public fun convert (Lspace/kscience/controls/demo/car/Vector2D;)Lspace/kscience/dataforge/meta/Meta; + public synthetic fun readOrNull (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object; + public fun readOrNull (Lspace/kscience/dataforge/meta/Meta;)Lspace/kscience/controls/demo/car/Vector2D; } public class space/kscience/controls/demo/car/VirtualCar : space/kscience/controls/spec/DeviceBySpec, space/kscience/controls/demo/car/IVirtualCar { @@ -59,7 +58,7 @@ public class space/kscience/controls/demo/car/VirtualCar : space/kscience/contro public fun getAccelerationState ()Lspace/kscience/controls/demo/car/Vector2D; public fun getLocationState ()Lspace/kscience/controls/demo/car/Vector2D; public fun getSpeedState ()Lspace/kscience/controls/demo/car/Vector2D; - public fun open (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + protected fun onStart (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; public fun setAccelerationState (Lspace/kscience/controls/demo/car/Vector2D;)V public fun setLocationState (Lspace/kscience/controls/demo/car/Vector2D;)V public fun setSpeedState (Lspace/kscience/controls/demo/car/Vector2D;)V diff --git a/demo/car/build.gradle.kts b/demo/car/build.gradle.kts index 5d53f11..967b87d 100644 --- a/demo/car/build.gradle.kts +++ b/demo/car/build.gradle.kts @@ -10,9 +10,6 @@ repositories { maven("https://repo.kotlin.link") } -val ktorVersion: String by rootProject.extra -val rsocketVersion: String by rootProject.extra - dependencies { implementation(projects.controlsCore) implementation(projects.magix.magixApi) @@ -24,14 +21,14 @@ dependencies { implementation(projects.magix.magixStorage.magixStorageXodus) // implementation(projects.controlsMongo) - implementation("io.ktor:ktor-client-cio:$ktorVersion") - implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.3.1") - implementation("no.tornado:tornadofx:1.7.20") - implementation("space.kscience:plotlykt-server:0.5.0") - implementation("ch.qos.logback:logback-classic:1.2.11") - implementation("org.jetbrains.xodus:xodus-entity-store:1.3.232") - implementation("org.jetbrains.xodus:xodus-environment:1.3.232") - implementation("org.jetbrains.xodus:xodus-vfs:1.3.232") + implementation(spclibs.ktor.client.cio) + implementation(spclibs.kotlinx.datetime) + implementation(libs.tornadofx) + implementation(libs.plotlykt.server) + implementation(libs.logback.classic) + implementation(libs.xodus.entity.store) + implementation(libs.xodus.environment) + implementation(libs.xodus.vfs) // implementation("org.litote.kmongo:kmongo-coroutine-serialization:4.4.0") } @@ -40,8 +37,8 @@ kotlin{ } tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn") + compilerOptions { + freeCompilerArgs.addAll("-Xjvm-default=all") } } diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt similarity index 87% rename from demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt rename to demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt index 3bb8c79..c61041b 100644 --- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt +++ b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/IVirtualCar.kt @@ -2,6 +2,8 @@ package space.kscience.controls.demo.car import space.kscience.controls.api.Device import space.kscience.controls.spec.DeviceSpec +import space.kscience.controls.spec.mutableProperty +import space.kscience.controls.spec.property interface IVirtualCar : Device { var speedState: Vector2D diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt similarity index 85% rename from demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt rename to demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt index 03c781b..27ea243 100644 --- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt +++ b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/MagixVirtualCar.kt @@ -14,7 +14,6 @@ import space.kscience.dataforge.names.Name import space.kscience.magix.api.MagixEndpoint import space.kscience.magix.api.subscribe import space.kscience.magix.rsocket.rSocketWithWebSockets -import kotlin.time.ExperimentalTime class MagixVirtualCar(context: Context, meta: Meta) : VirtualCar(context, meta) { @@ -23,7 +22,7 @@ class MagixVirtualCar(context: Context, meta: Meta) : VirtualCar(context, meta) (payload as? PropertyChangedMessage)?.let { message -> if (message.sourceDevice == Name.parse("virtual-car")) { when (message.property) { - "acceleration" -> write(IVirtualCar.acceleration, Vector2D.metaToObject(message.value)) + "acceleration" -> write(IVirtualCar.acceleration, Vector2D.read(message.value)) } } } @@ -31,17 +30,13 @@ class MagixVirtualCar(context: Context, meta: Meta) : VirtualCar(context, meta) } - @OptIn(ExperimentalTime::class) - override suspend fun open() { - super.open() + override suspend fun onStart() { val magixEndpoint = MagixEndpoint.rSocketWithWebSockets( meta["magixServerHost"].string ?: "localhost", ) - launch { - magixEndpoint.launchMagixVirtualCarUpdate() - } + magixEndpoint.launchMagixVirtualCarUpdate() } companion object : Factory<MagixVirtualCar> { diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCar.kt similarity index 78% rename from demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt rename to demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCar.kt index 1f1dc69..86564a5 100644 --- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCar.kt +++ b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCar.kt @@ -4,18 +4,14 @@ package space.kscience.controls.demo.car import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.datetime.Clock import kotlinx.datetime.Instant +import space.kscience.controls.manager.clock import space.kscience.controls.spec.DeviceBySpec import space.kscience.controls.spec.doRecurring import space.kscience.controls.spec.read import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Factory -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.MetaRepr -import space.kscience.dataforge.meta.double -import space.kscience.dataforge.meta.get -import space.kscience.dataforge.meta.transformations.MetaConverter +import space.kscience.dataforge.meta.* import kotlin.math.pow import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds @@ -23,24 +19,28 @@ import kotlin.time.ExperimentalTime data class Vector2D(var x: Double = 0.0, var y: Double = 0.0) : MetaRepr { - override fun toMeta(): Meta = objectToMeta(this) + override fun toMeta(): Meta = convert(this) operator fun div(arg: Double): Vector2D = Vector2D(x / arg, y / arg) companion object CoordinatesMetaConverter : MetaConverter<Vector2D> { - override fun metaToObject(meta: Meta): Vector2D = Vector2D( - meta["x"].double ?: 0.0, - meta["y"].double ?: 0.0 + + override fun readOrNull(source: Meta): Vector2D = Vector2D( + source["x"].double ?: 0.0, + source["y"].double ?: 0.0 ) - override fun objectToMeta(obj: Vector2D): Meta = Meta { + override fun convert(obj: Vector2D): Meta = Meta { "x" put obj.x "y" put obj.y } } } -open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec<VirtualCar>(IVirtualCar, context, meta), IVirtualCar { +open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec<VirtualCar>(IVirtualCar, context, meta), + IVirtualCar { + private val clock = context.clock + private val timeScale = 1e-3 private val mass by meta.double(1000.0) // mass in kilograms @@ -57,7 +57,7 @@ open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec<VirtualCar>(I private var timeState: Instant? = null - private fun update(newTime: Instant = Clock.System.now()) { + private fun update(newTime: Instant = clock.now()) { //initialize time if it is not initialized if (timeState == null) { timeState = newTime @@ -100,10 +100,9 @@ open class VirtualCar(context: Context, meta: Meta) : DeviceBySpec<VirtualCar>(I } @OptIn(ExperimentalTime::class) - override suspend fun open() { - super<DeviceBySpec>.open() + override suspend fun onStart() { //initializing the clock - timeState = Clock.System.now() + timeState = clock.now() //starting regular updates doRecurring(100.milliseconds) { update() diff --git a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt similarity index 94% rename from demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt rename to demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt index 7a170ac..a598d4b 100644 --- a/demo/car/src/main/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt +++ b/demo/car/src/jvmMain/kotlin/space/kscience/controls/demo/car/VirtualCarController.kt @@ -8,6 +8,7 @@ import javafx.scene.layout.Priority import javafx.stage.Stage import kotlinx.coroutines.Job import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import space.kscience.controls.client.launchMagixService import space.kscience.controls.demo.car.IVirtualCar.Companion.acceleration import space.kscience.controls.manager.DeviceManager @@ -63,17 +64,17 @@ class VirtualCarController : Controller(), ContextAware { //mongoStorageJob = deviceManager.storeMessages(DefaultAsynchronousMongoClientFactory) //Launch device client and connect it to the server val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") - deviceManager.launchMagixService(deviceEndpoint) + deviceManager.launchMagixService(deviceEndpoint, "car") } } - fun shutdown() { + suspend fun shutdown() { logger.info { "Shutting down..." } magixServer?.stop(1000, 5000) logger.info { "Magix server stopped" } - magixVirtualCar?.close() + magixVirtualCar?.stop() logger.info { "Magix virtual car server stopped" } - virtualCar?.close() + virtualCar?.stop() logger.info { "Virtual car server stopped" } context.close() } @@ -137,7 +138,9 @@ class VirtualCarControllerApp : App(VirtualCarControllerView::class) { } override fun stop() { - controller.shutdown() + runBlocking { + controller.shutdown() + } super.stop() } } diff --git a/demo/constructor/README.md b/demo/constructor/README.md new file mode 100644 index 0000000..3bd1e74 --- /dev/null +++ b/demo/constructor/README.md @@ -0,0 +1,4 @@ +# Module constructor + + + diff --git a/demo/constructor/api/constructor.api b/demo/constructor/api/constructor.api new file mode 100644 index 0000000..7206553 --- /dev/null +++ b/demo/constructor/api/constructor.api @@ -0,0 +1,27 @@ +public final class space/kscience/controls/demo/constructor/ComposableSingletons$MainKt { + public static final field INSTANCE Lspace/kscience/controls/demo/constructor/ComposableSingletons$MainKt; + public static field lambda-1 Lkotlin/jvm/functions/Function3; + public static field lambda-2 Lkotlin/jvm/functions/Function3; + public fun <init> ()V + public final fun getLambda-1$constructor ()Lkotlin/jvm/functions/Function3; + public final fun getLambda-2$constructor ()Lkotlin/jvm/functions/Function3; +} + +public final class space/kscience/controls/demo/constructor/LinearDrive : space/kscience/controls/constructor/DeviceConstructor { + public static final field $stable I + public fun <init> (Lspace/kscience/dataforge/context/Context;Lspace/kscience/controls/constructor/DoubleRangeState;DLspace/kscience/controls/constructor/PidParameters;Lspace/kscience/dataforge/meta/Meta;)V + public synthetic fun <init> (Lspace/kscience/dataforge/context/Context;Lspace/kscience/controls/constructor/DoubleRangeState;DLspace/kscience/controls/constructor/PidParameters;Lspace/kscience/dataforge/meta/Meta;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getDrive ()Lspace/kscience/controls/constructor/Drive; + public final fun getEnd ()Lspace/kscience/controls/constructor/LimitSwitch; + public final fun getPid ()Lspace/kscience/controls/constructor/PidRegulator; + public final fun getPositionState ()Lspace/kscience/controls/constructor/DoubleRangeState; + public final fun getStart ()Lspace/kscience/controls/constructor/LimitSwitch; + public final fun getTarget ()D + public final fun setTarget (D)V +} + +public final class space/kscience/controls/demo/constructor/MainKt { + public static final fun main ()V + public static synthetic fun main ([Ljava/lang/String;)V +} + diff --git a/demo/constructor/build.gradle.kts b/demo/constructor/build.gradle.kts new file mode 100644 index 0000000..6d6d7f0 --- /dev/null +++ b/demo/constructor/build.gradle.kts @@ -0,0 +1,54 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode + +plugins { + id("space.kscience.gradle.mpp") + alias(spclibs.plugins.compose.compiler) + alias(spclibs.plugins.compose.jb) +} + +kscience { + jvm() + useKtor() + useSerialization() + useContextReceivers() + commonMain { + implementation(projects.controlsVisualisationCompose) +// implementation(projects.controlsVision) + implementation(projects.controlsConstructor) +// implementation("io.github.koalaplot:koalaplot-core:0.6.0") + } + jvmMain { +// implementation("io.ktor:ktor-server-cio") + implementation(spclibs.logback.classic) + } +} + +kotlin { + sourceSets { + jvmMain { + dependencies { + implementation(compose.desktop.currentOs) + } + } + } +} + +//application { +// mainClass.set("space.kscience.controls.demo.constructor.MainKt") +//} + +kotlin.explicitApi = ExplicitApiMode.Disabled + + +compose.desktop { + application { + mainClass = "space.kscience.controls.demo.constructor.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Exe) + packageName = "PidConstructor" + packageVersion = "1.0.0" + } + } +} \ No newline at end of file diff --git a/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt new file mode 100644 index 0000000..af6da52 --- /dev/null +++ b/demo/constructor/src/jvmMain/kotlin/BodyOnSprings.kt @@ -0,0 +1,129 @@ +package space.kscience.controls.demo.constructor + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.MaterialTheme +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import space.kscience.controls.compose.asComposeState +import space.kscience.controls.constructor.* +import space.kscience.controls.constructor.models.MaterialPoint +import space.kscience.controls.constructor.units.* +import space.kscience.dataforge.context.Context +import java.awt.Dimension + + +private class Spring( + context: Context, + val k: Double, + val l0: NumericalValue<Meters>, + val begin: DeviceState<XYZ<Meters>>, + val end: DeviceState<XYZ<Meters>>, +) : ModelConstructor(context) { + + /** + * Tension at the beginning point + */ + val tension: DeviceState<XYZ<Newtons>> = combineState(begin, end) { begin: XYZ<Meters>, end: XYZ<Meters> -> + val delta = end - begin + val l = delta.length.value + ((delta / l) * k * (l - l0.value)).cast(Newtons) + } +} + + +private class BodyOnSprings( + context: Context, + mass: NumericalValue<Kilograms>, + k: Double, + startPosition: XYZ<Meters>, + l0: NumericalValue<Meters> = NumericalValue(1.0), + val xLeft: Double = -1.0, + val xRight: Double = 1.0, + val yBottom: Double = -1.0, + val yTop: Double = 1.0, +) : DeviceConstructor(context) { + + val width = xRight - xLeft + val height = yTop - yBottom + + val position = stateOf(startPosition) + val velocity: MutableDeviceState<XYZ<MetersPerSecond>> = stateOf(XYZ(0, 0, 0)) + + private val leftAnchor = stateOf(XYZ<Meters>(xLeft, (yTop + yBottom) / 2, 0.0)) + + val leftSpring = model( + Spring(context, k, l0, leftAnchor, position) + ) + + private val rightAnchor = stateOf(XYZ<Meters>(xRight, (yTop + yBottom) / 2, 0.0)) + + val rightSpring = model( + Spring(context, k, l0, rightAnchor, position) + ) + + val force: DeviceState<XYZ<Newtons>> = + combineState(leftSpring.tension, rightSpring.tension) { left: XYZ<Newtons>, right -> + -left - right + } + + + val body = model( + MaterialPoint( + context = context, + mass = mass, + force = force, + position = position, + velocity = velocity + ) + ) +} + +fun main() = application { + val initialState = XYZ<Meters>(0.05, 0.4, 0) + + Window(title = "Ball on springs", onCloseRequest = ::exitApplication) { + window.minimumSize = Dimension(400, 400) + MaterialTheme { + val context = remember { + Context("simulation") + } + + val model = remember { + BodyOnSprings(context, NumericalValue(10.0), 100.0, initialState) + } + + //TODO add ability to freeze model + +// LaunchedEffect(Unit){ +// model.position.valueFlow.onEach { +// model.position.value = it.copy(y = model.position.value.y.coerceIn(-1.0..1.0)) +// }.collect() +// } + + val position: XYZ<Meters> by model.body.position.asComposeState() + Canvas(modifier = Modifier.fillMaxSize()) { + fun XYZ<Meters>.toOffset() = Offset( + ((x.value - model.xLeft) / model.width * size.width).toFloat(), + ((y.value - model.yBottom) / model.height * size.height).toFloat() + + ) + + drawCircle( + Color.Red, 10f, center = position.toOffset() + ) + drawLine(Color.Blue, model.leftSpring.begin.value.toOffset(), model.leftSpring.end.value.toOffset()) + drawLine( + Color.Blue, + model.rightSpring.begin.value.toOffset(), + model.rightSpring.end.value.toOffset() + ) + } + } + } +} \ No newline at end of file diff --git a/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt b/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt new file mode 100644 index 0000000..02192b9 --- /dev/null +++ b/demo/constructor/src/jvmMain/kotlin/LinearDriveCalibration.kt @@ -0,0 +1,77 @@ +package space.kscience.controls.demo.constructor + +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import space.kscience.controls.constructor.DeviceConstructor +import space.kscience.controls.constructor.collectValuesIn +import space.kscience.controls.constructor.device +import space.kscience.controls.constructor.devices.LimitSwitch +import space.kscience.controls.constructor.devices.StepDrive +import space.kscience.controls.constructor.models.MutableRangeState +import space.kscience.controls.manager.DeviceManager +import space.kscience.dataforge.context.Context +import kotlin.time.Duration.Companion.seconds + +private val ticksPerSecond = 3000.0 + +class LinearStepDrive( + context: Context, + drive: StepDrive, + atStart: LimitSwitch, + atEnd: LimitSwitch, +) : DeviceConstructor(context) { + val drive by device(drive) + val atStart by device(atStart) + val atEnd by device(atEnd) +} + + +fun LinearStepDrive( + context: Context, + position: MutableRangeState<Long>, +): LinearStepDrive = LinearStepDrive( + context = context, + drive = StepDrive(context, ticksPerSecond, position), + atStart = LimitSwitch(context, position.atStart), + atEnd = LimitSwitch(context, position.atEnd) +) + +suspend fun LinearStepDrive.calibrate(step: Long = 10): ClosedRange<Long> = coroutineScope { + do { + ensureActive() + drive.target.value -= step + delay((step / ticksPerSecond).seconds) + } while (!atStart.locked.value) + + val start = drive.position.value + + + do { + ensureActive() + drive.target.value += step + delay((step / ticksPerSecond).seconds) + } while (!atEnd.locked.value) + + val end = drive.position.value + + return@coroutineScope start..end +} + +suspend fun main() = coroutineScope { + val context = Context { + plugin(DeviceManager) + } + + val positionModel = MutableRangeState<Long>(0L, -1000L..1012L) + + val linearStepDrive = LinearStepDrive(context, positionModel) + + val printJob = linearStepDrive.drive.target.collectValuesIn(this){ + println("Move to $it") + } + + println(linearStepDrive.calibrate()) + + printJob.cancel() +} \ No newline at end of file diff --git a/demo/constructor/src/jvmMain/kotlin/PidDemo.kt b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt new file mode 100644 index 0000000..9411321 --- /dev/null +++ b/demo/constructor/src/jvmMain/kotlin/PidDemo.kt @@ -0,0 +1,317 @@ +package space.kscience.controls.demo.constructor + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import io.github.koalaplot.core.ChartLayout +import io.github.koalaplot.core.legend.FlowLegend +import io.github.koalaplot.core.style.LineStyle +import io.github.koalaplot.core.util.ExperimentalKoalaPlotApi +import io.github.koalaplot.core.util.toString +import io.github.koalaplot.core.xygraph.XYGraph +import io.github.koalaplot.core.xygraph.rememberDoubleLinearAxisModel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.datetime.Instant +import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi +import org.jetbrains.compose.splitpane.HorizontalSplitPane +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.compose.NumberTextField +import space.kscience.controls.compose.PlotNumericState +import space.kscience.controls.compose.TimeAxisModel +import space.kscience.controls.constructor.DeviceConstructor +import space.kscience.controls.constructor.MutableDeviceState +import space.kscience.controls.constructor.devices.Drive +import space.kscience.controls.constructor.devices.LimitSwitch +import space.kscience.controls.constructor.devices.LinearDrive +import space.kscience.controls.constructor.models.Inertia +import space.kscience.controls.constructor.models.Leadscrew +import space.kscience.controls.constructor.models.MutableRangeState +import space.kscience.controls.constructor.models.PidParameters +import space.kscience.controls.constructor.onTimer +import space.kscience.controls.constructor.units.Kilograms +import space.kscience.controls.constructor.units.Meters +import space.kscience.controls.constructor.units.NumericalValue +import space.kscience.controls.manager.* +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.request +import java.awt.Dimension +import kotlin.math.PI +import kotlin.math.sin +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.DurationUnit + + +class Modulator( + context: Context, + target: MutableDeviceState<NumericalValue<Meters>>, + var timeStep: Duration = 5.milliseconds, + var freq: Double = 0.1, +) : DeviceConstructor(context) { + private val clockStart = clock.now() + + private val modulation = onTimer(timeStep) { _, next -> + val timeFromStart = next - clockStart + val t = timeFromStart.toDouble(DurationUnit.SECONDS) + target.value = NumericalValue( + 5 * sin(2.0 * PI * freq * t) + + sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / timeStep)) + ) + } +} + + +private val mass = NumericalValue<Kilograms>(1) + +private val leverage = NumericalValue<Meters>(1.0) + +private val maxAge = 10.seconds + +private val range = -6.0..6.0 + +/** + * The whole physical model is here + */ +internal fun createLinearDriveModel( + context: Context, + pidParameters: PidParameters, + mass: NumericalValue<Kilograms>, + leverage: NumericalValue<Meters>, + position: MutableRangeState<NumericalValue<Meters>>, +): LinearDrive { + + //create a drive model with zero starting force + val drive = Drive(context) + + //a screw drive to convert a rotational moment into a force + val leadscrew = Leadscrew(context, leverage) + + + /** + * Create an inertia model. + * The inertia uses drive force as input. Position is used as both input and output + * + * Force is the input parameter, position is output parameter + * + */ + val inertiaModel = Inertia.linear( + context = context, + force = leadscrew.torqueToForce(drive.force), + mass = mass, + position = position + ) + + /** + * Create a limit switches from physical position + */ + val startLimitSwitch = LimitSwitch(context, position.atStart) + val endLimitSwitch = LimitSwitch(context, position.atEnd) + + /** + * Install the resulting device + */ + return LinearDrive(drive, startLimitSwitch, endLimitSwitch, position, pidParameters) + +} + +private fun createModulator(linearDrive: LinearDrive): Modulator = linearDrive.context.install( + "modulator", + Modulator(linearDrive.context, linearDrive.pid.target) +) + +private val startPid = PidParameters(kp = 250.0, ki = 0.0, kd = -20.0, timeStep = 20.milliseconds) + +@OptIn(ExperimentalSplitPaneApi::class, ExperimentalKoalaPlotApi::class) +fun main() = application { + val context = remember { + Context { + plugin(DeviceManager) + plugin(ClockManager) + } + } + + var pidParameters by remember { + mutableStateOf(startPid) + } + + val linearDrive: LinearDrive = remember { + context.install( + "linearDrive", + createLinearDriveModel( + context = context, + pidParameters = pidParameters, + mass = mass, + leverage = leverage, + // Create a physical position coerced in a given range + position = MutableRangeState<Meters>(0.0, range) + ) + ) + } + + val modulator = remember { + context.install("modulator", createModulator(linearDrive)) + } + + //bind pid parameters + LaunchedEffect(Unit) { + + // start listening to local device hub + context.request(DeviceManager).hubMessageFlow() + .filterIsInstance<PropertyChangedMessage>() // filter only property change messages + //.filter { it.sourceDevice == "linearDrive".asName()} //optionally filter by device name + .onEach { + println("${it.sourceDevice} >> ${it.property} changed to ${it.value}") + }.launchIn(this) + + snapshotFlow { + pidParameters + }.onEach { + linearDrive.pid.pidParameters = pidParameters + }.collect() + } + + val clock = remember { context.clock } + + Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) { + window.minimumSize = Dimension(800, 400) + MaterialTheme { + HorizontalSplitPane { + first(400.dp) { + Column(modifier = Modifier.background(color = Color.LightGray).fillMaxHeight()) { + Row { + Text("kp:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) + NumberTextField( + value = pidParameters.kp, + onValueChange = { pidParameters = pidParameters.copy(kp = it.toDouble()) }, + formatter = { String.format("%.3f", it.toDouble()) }, + step = 0.01, + modifier = Modifier.width(200.dp), + ) + Slider( + pidParameters.kp.toFloat(), + { pidParameters = pidParameters.copy(kp = it.toDouble()) }, + valueRange = 0f..100f, + steps = 100 + ) + } + Row { + Text("ki:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) + NumberTextField( + value = pidParameters.ki, + onValueChange = { pidParameters = pidParameters.copy(ki = it.toDouble()) }, + formatter = { String.format("%.3f", it.toDouble()) }, + step = 0.01, + modifier = Modifier.width(200.dp), + ) + + Slider( + pidParameters.ki.toFloat(), + { pidParameters = pidParameters.copy(ki = it.toDouble()) }, + valueRange = -10f..10f, + steps = 100 + ) + } + Row { + Text("kd:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) + NumberTextField( + value = pidParameters.kd, + onValueChange = { pidParameters = pidParameters.copy(kd = it.toDouble()) }, + formatter = { String.format("%.3f", it.toDouble()) }, + step = 0.01, + modifier = Modifier.width(200.dp), + ) + + Slider( + pidParameters.kd.toFloat(), + { pidParameters = pidParameters.copy(kd = it.toDouble()) }, + valueRange = -10f..10f, + steps = 100 + ) + } + + Row { + Text("dt:", Modifier.align(Alignment.CenterVertically).width(50.dp).padding(5.dp)) + TextField( + pidParameters.timeStep.toString(DurationUnit.MILLISECONDS), + { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) }, + Modifier.width(200.dp), + enabled = false + ) + + Slider( + pidParameters.timeStep.toDouble(DurationUnit.MILLISECONDS).toFloat(), + { pidParameters = pidParameters.copy(timeStep = it.toDouble().milliseconds) }, + valueRange = 1f..100f, + steps = 100 + ) + } + Row { + Button({ + pidParameters = startPid + }) { + Text("Reset") + } + } + } + } + second(400.dp) { + ChartLayout { + XYGraph<Instant, Double>( + xAxisModel = remember { TimeAxisModel.recent(maxAge, clock) }, + yAxisModel = rememberDoubleLinearAxisModel((range.start - 1.0)..(range.endInclusive + 1.0)), + xAxisTitle = { Text("Time in seconds relative to current") }, + xAxisLabels = { it: Instant -> + Text( + (clock.now() - it).toDouble( + DurationUnit.SECONDS + ).toString(2) + ) + }, + yAxisLabels = { it: Double -> Text(it.toString(2)) } + ) { + PlotNumericState( + context = context, + state = linearDrive.position, + maxAge = maxAge, + sampling = 50.milliseconds, + lineStyle = LineStyle(SolidColor(Color.Blue)) + ) + PlotNumericState( + context = context, + state = linearDrive.pid.target, + maxAge = maxAge, + sampling = 50.milliseconds, + lineStyle = LineStyle(SolidColor(Color.Red)) + ) + } + Surface { + FlowLegend(3, label = { + when (it) { + 0 -> { + Text("Body position", color = Color.Blue) + } + + 1 -> { + Text("Regulator target", color = Color.Red) + } + } + }) + } + } + } + } + } + } +} \ No newline at end of file diff --git a/demo/constructor/src/jvmMain/kotlin/Plotter.kt b/demo/constructor/src/jvmMain/kotlin/Plotter.kt new file mode 100644 index 0000000..16006d6 --- /dev/null +++ b/demo/constructor/src/jvmMain/kotlin/Plotter.kt @@ -0,0 +1,242 @@ +package space.kscience.controls.demo.constructor + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi +import org.jetbrains.compose.splitpane.HorizontalSplitPane +import space.kscience.controls.compose.* +import space.kscience.controls.constructor.* +import space.kscience.controls.constructor.devices.LimitSwitch +import space.kscience.controls.constructor.devices.StepDrive +import space.kscience.controls.constructor.devices.angle +import space.kscience.controls.constructor.models.Leadscrew +import space.kscience.controls.constructor.models.coerceIn +import space.kscience.controls.constructor.units.* +import space.kscience.controls.manager.ClockManager +import space.kscience.controls.manager.DeviceManager +import space.kscience.dataforge.context.Context +import java.awt.Dimension +import kotlin.random.Random + + +private class Plotter( + context: Context, + xDrive: StepDrive, + yDrive: StepDrive, + xStartLimit: LimitSwitch, + xEndLimit: LimitSwitch, + yStartLimit: LimitSwitch, + yEndLimit: LimitSwitch, + val paint: suspend (Color) -> Unit, +) : DeviceConstructor(context) { + val xDrive by device(xDrive) + val yDrive by device(yDrive) + val xStartLimit by device(xStartLimit) + val xEndLimit by device(xEndLimit) + val yStartLimit by device(yStartLimit) + val yEndLimit by device(yEndLimit) + + public fun moveToXY(x: Number, y: Number) { + xDrive.target.value = x.toLong() + yDrive.target.value = y.toLong() + } + + val ticks = combineState(xDrive.position, yDrive.position) { x, y -> + x to y + } + + //TODO add calibration + + // TODO add draw as action +} + +private suspend fun Plotter.modernArt(xRange: IntRange, yRange: IntRange) { + while (isActive) { + val randomX = Random.nextInt(xRange.first, xRange.last) + val randomY = Random.nextInt(yRange.first, yRange.last) + moveToXY(randomX, randomY) + //TODO wait for position instead of custom delay + delay(500) + paint(Color(Random.nextInt())) + } +} + +private suspend fun Plotter.square(xRange: IntRange, yRange: IntRange) { + while (isActive) { + moveToXY(xRange.first, yRange.first) + delay(1000) + paint(Color.Red) + + moveToXY(xRange.first, yRange.last) + delay(1000) + paint(Color.Red) + + moveToXY(xRange.last, yRange.last) + delay(1000) + paint(Color.Red) + + moveToXY(xRange.last, yRange.first) + delay(1000) + paint(Color.Red) + } +} + +private val xRange = NumericalValue<Meters>(-0.5)..NumericalValue<Meters>(0.5) +private val yRange = NumericalValue<Meters>(-0.5)..NumericalValue<Meters>(0.5) +private const val ticksPerSecond = 3000.0 +private val step = NumericalValue<Degrees>(1.8) + + +private data class PlotterPoint( + val x: NumericalValue<Meters>, + val y: NumericalValue<Meters>, + val color: Color = Color.Black, +) + +private class PlotterModel( + context: Context, + val callback: (PlotterPoint) -> Unit, +) : ModelConstructor(context) { + + private val xDrive = StepDrive(context, ticksPerSecond) + private val xTransmission = Leadscrew(context, NumericalValue(0.01)) + val x = xTransmission.degreesToMeters(xDrive.angle(step)).coerceIn(xRange) + + private val yDrive = StepDrive(context, ticksPerSecond) + private val yTransmission = Leadscrew(context, NumericalValue(0.01)) + val y = yTransmission.degreesToMeters(yDrive.angle(step)).coerceIn(yRange) + + val xy: DeviceState<XY<Meters>> = combineState(x, y) { x, y -> XY(x, y) } + + val plotter = Plotter( + context = context, + xDrive = xDrive, + yDrive = yDrive, + xStartLimit = LimitSwitch(context, x.atStart), + xEndLimit = LimitSwitch(context, x.atEnd), + yStartLimit = LimitSwitch(context, x.atStart), + yEndLimit = LimitSwitch(context, x.atEnd), + ) { color -> + println("Point X: ${x.value.value}, Y: ${y.value.value}, color: $color") + callback(PlotterPoint(x.value, y.value, color)) + } +} + +private val range = -1000..1000 + +@OptIn(ExperimentalSplitPaneApi::class) +suspend fun main() = application { + Window(title = "Pid regulator simulator", onCloseRequest = ::exitApplication) { + window.minimumSize = Dimension(400, 400) + + val scope = rememberCoroutineScope() + + var updateJob: Job? = remember { null } + + var points by remember { mutableStateOf<List<PlotterPoint>>(emptyList()) } + + val plotterModel = remember { + val context = Context { + plugin(DeviceManager) + plugin(ClockManager) + } + + /* Here goes the device definition block */ + + PlotterModel(context) { plotterPoint -> + points += plotterPoint + } + } + + /* Here goes the visualization block */ + + MaterialTheme { + HorizontalSplitPane { + first(200.dp) { + Column(modifier = Modifier.fillMaxHeight()) { + Button({ + updateJob?.cancel() + updateJob = scope.launch { + plotterModel.plotter.square(range, range) + } + }, modifier = Modifier.fillMaxWidth()) { + Text("Rectangle") + } + Button({ + updateJob?.cancel() + updateJob = scope.launch { + plotterModel.plotter.modernArt(range, range) + } + }, modifier = Modifier.fillMaxWidth()) { + Text("Modern Art") + } + Button({ + updateJob?.cancel() + }, modifier = Modifier.fillMaxWidth()) { + Text("Stop") + } + } + + } + second { + Device2DCanvas(modifier = Modifier.fillMaxSize()) { + fun xToPx(x: NumericalValue<Meters>): Float = + ((x - xRange.start) / (xRange.endInclusive - xRange.start) * size.width).toFloat() + + fun yToPx(y: NumericalValue<Meters>): Float = + ((y - yRange.start) / (yRange.endInclusive - yRange.start) * size.height).toFloat() + + + fun toOffset(xy: XY<Meters>): Offset = Offset(xToPx(xy.x), yToPx(xy.y)) + + observeState(plotterModel.y, "beam") { y -> + RectangleDrawable2D( + position = Offset(size.width / 2, yToPx(y)), + rectangleSize = Size(size.width, 10f), + color = Color.LightGray + ) + } + + observeState(plotterModel.xy, "head") { xy -> + CircleDrawable2D( + position = toOffset(xy), + radius = 10f, + color = Color.Black + ) + } + + snapshotFlow { points }.onEach { + it.forEachIndexed { index, plotterPoint -> + circle( + "point[$index]", + Offset(xToPx(plotterPoint.x), yToPx(plotterPoint.y)), + radius = 5f, + color = plotterPoint.color + ) + } + }.launchIn(scope) + } + } + } + } + } +} \ No newline at end of file diff --git a/demo/constructor/src/jvmMain/resources/logback.xml b/demo/constructor/src/jvmMain/resources/logback.xml new file mode 100644 index 0000000..3865b14 --- /dev/null +++ b/demo/constructor/src/jvmMain/resources/logback.xml @@ -0,0 +1,11 @@ +<configuration> + <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> + <encoder> + <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> + </encoder> + </appender> + + <root level="INFO"> + <appender-ref ref="STDOUT"/> + </root> +</configuration> \ No newline at end of file diff --git a/demo/device-collective/README.md b/demo/device-collective/README.md new file mode 100644 index 0000000..433a46f --- /dev/null +++ b/demo/device-collective/README.md @@ -0,0 +1,4 @@ +# Module device-collective + + + diff --git a/demo/device-collective/build.gradle.kts b/demo/device-collective/build.gradle.kts new file mode 100644 index 0000000..3290a0d --- /dev/null +++ b/demo/device-collective/build.gradle.kts @@ -0,0 +1,44 @@ +import org.jetbrains.kotlin.gradle.dsl.ExplicitApiMode + +plugins { + id("space.kscience.gradle.mpp") + alias(spclibs.plugins.compose.compiler) + alias(spclibs.plugins.compose.jb) +} + +kscience { + jvm() + useSerialization() + useContextReceivers() + commonMain { + implementation(projects.controlsVisualisationCompose) + implementation(projects.controlsConstructor) + implementation(projects.magix.magixServer) + implementation(projects.magix.magixRsocket) + implementation(projects.controlsMagix) + } + jvmMain { +// implementation("io.ktor:ktor-server-cio") + implementation(spclibs.logback.classic) + implementation(libs.sciprog.maps.compose) + } +} + +kotlin { + sourceSets { + jvmMain { + dependencies { + implementation(compose.desktop.currentOs) + } + } + } +} + +kotlin.explicitApi = ExplicitApiMode.Disabled + + +compose.desktop { + application { + mainClass = "space.kscience.controls.demo.collective.MainKt" + } +} \ No newline at end of file diff --git a/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt new file mode 100644 index 0000000..c550830 --- /dev/null +++ b/demo/device-collective/src/jvmMain/kotlin/CollectiveDevice.kt @@ -0,0 +1,118 @@ +@file:OptIn(DFExperimental::class) + +package space.kscience.controls.demo.collective + +import space.kscience.controls.api.Device +import space.kscience.controls.constructor.* +import space.kscience.controls.misc.stringList +import space.kscience.controls.peer.PeerConnection +import space.kscience.controls.spec.DeviceSpec +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.meta.Scheme +import space.kscience.dataforge.meta.int +import space.kscience.dataforge.meta.string +import space.kscience.dataforge.misc.DFExperimental +import space.kscience.maps.coordinates.Gmc +import space.kscience.maps.coordinates.GmcCurve +import kotlin.time.Duration.Companion.milliseconds + +typealias CollectiveDeviceId = String + +class CollectiveDeviceConfiguration(deviceId: CollectiveDeviceId) : Scheme() { + var deviceId by string(deviceId) + var description by string() + var reportInterval by int(500) + var radioFrequency by string(default = DEFAULT_FREQUENCY) + + companion object { + const val DEFAULT_FREQUENCY = "169 MHz" + } +} + +typealias CollectiveDeviceRoster = Map<CollectiveDeviceId, CollectiveDeviceConfiguration> + +interface CollectiveDevice : Device { + + public val id: CollectiveDeviceId + + public val peerConnection: PeerConnection + + suspend fun getPosition(): Gmc + + suspend fun getVelocity(): GmcVelocity + + suspend fun setVelocity(value: GmcVelocity) + + suspend fun listVisible(): Collection<CollectiveDeviceId> + + companion object : DeviceSpec<CollectiveDevice>() { + val position by property<Gmc>( + converter = MetaConverter.serializable(), + read = { getPosition() } + ) + + val velocity by mutableProperty<GmcVelocity>( + converter = MetaConverter.serializable(), + read = { getVelocity() }, + write = { _, value -> setVelocity(value) } + ) + + val visibleNeighbors by property( + MetaConverter.stringList, + read = { + listVisible().toList() + } + ) + +// val listVisible by action(MetaConverter.unit, MetaConverter.valueList<String> { it.string }) { +// listVisible().toList() +// } + } +} + + +class CollectiveDeviceConstructor( + context: Context, + val configuration: CollectiveDeviceConfiguration, + position: MutableDeviceState<Gmc>, + velocity: MutableDeviceState<GmcVelocity>, + override val peerConnection: PeerConnection, + private val observation: suspend () -> Map<CollectiveDeviceId, GmcCurve>, +) : DeviceConstructor(context, configuration.meta), CollectiveDevice { + + override val id: CollectiveDeviceId get() = configuration.deviceId + + val position = registerAsProperty( + CollectiveDevice.position, + position.debounce(configuration.reportInterval.milliseconds) + ) + + val velocity = registerAsProperty( + CollectiveDevice.velocity, + velocity.debounce(configuration.reportInterval.milliseconds) + ) + + private val _visibleNeighbors: MutableDeviceState<Collection<CollectiveDeviceId>> = stateOf(emptyList()) + + val visibleNeighbors = registerAsProperty( + CollectiveDevice.visibleNeighbors, + _visibleNeighbors.map { it.toList() } + ) + + init { + position.onNext { + _visibleNeighbors.value = observation.invoke().keys + } + } + + override suspend fun getPosition(): Gmc = position.value + + override suspend fun getVelocity(): GmcVelocity = velocity.value + + override suspend fun setVelocity(value: GmcVelocity) { + velocity.value = value + } + + override suspend fun listVisible(): Collection<CollectiveDeviceId> = observation.invoke().keys +} diff --git a/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt b/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt new file mode 100644 index 0000000..ac699e6 --- /dev/null +++ b/demo/device-collective/src/jvmMain/kotlin/DebounceDeviceState.kt @@ -0,0 +1,36 @@ +package space.kscience.controls.demo.collective + +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.sample +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.constructor.MutableDeviceState +import kotlin.time.Duration + +@OptIn(FlowPreview::class) +class DebounceDeviceState<T>( + val origin: DeviceState<T>, + val interval: Duration, +) : DeviceState<T> { + override val value: T by origin::value + override val valueFlow: Flow<T> get() = origin.valueFlow.debounce(interval) + + override fun toString(): String = "DebounceDeviceState($value, interval=$interval)" +} + + +fun <T> DeviceState<T>.debounce(interval: Duration) = DebounceDeviceState(this, interval) + +@OptIn(FlowPreview::class) +class MutableDebounceDeviceState<T>( + val origin: MutableDeviceState<T>, + val interval: Duration, +) : MutableDeviceState<T> { + override var value: T by origin::value + override val valueFlow: Flow<T> get() = origin.valueFlow.sample(interval) + + override fun toString(): String = "DebounceDeviceState($value, interval=$interval)" +} + +fun <T> MutableDeviceState<T>.debounce(interval: Duration) = MutableDebounceDeviceState(this, interval) \ No newline at end of file diff --git a/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt new file mode 100644 index 0000000..b5e67e4 --- /dev/null +++ b/demo/device-collective/src/jvmMain/kotlin/DeviceCollectiveModel.kt @@ -0,0 +1,255 @@ +package space.kscience.controls.demo.collective + +import kotlinx.coroutines.* +import kotlinx.io.writeString +import kotlinx.serialization.json.Json +import space.kscience.controls.api.DeviceMessage +import space.kscience.controls.api.PropertySetMessage +import space.kscience.controls.client.DeviceClient +import space.kscience.controls.client.launchMagixService +import space.kscience.controls.client.write +import space.kscience.controls.constructor.DeviceState +import space.kscience.controls.constructor.ModelConstructor +import space.kscience.controls.constructor.MutableDeviceState +import space.kscience.controls.constructor.onTimer +import space.kscience.controls.manager.DeviceManager +import space.kscience.controls.manager.install +import space.kscience.controls.manager.respondMessage +import space.kscience.controls.peer.PeerConnection +import space.kscience.controls.spec.name +import space.kscience.controls.spec.write +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.request +import space.kscience.dataforge.io.Envelope +import space.kscience.dataforge.io.toByteArray +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.names.parseAsName +import space.kscience.kmath.geometry.degrees +import space.kscience.kmath.geometry.radians +import space.kscience.magix.api.MagixEndpoint +import space.kscience.magix.rsocket.rSocketWithWebSockets +import space.kscience.magix.server.startMagixServer +import space.kscience.maps.coordinates.* +import kotlin.math.PI +import kotlin.random.Random +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + + + +private val deviceVelocity = 0.1.kilometers + +private val center = Gmc.ofDegrees(55.925, 37.514) +private val radius = 0.01.degrees + +private val json = Json { + ignoreUnknownKeys = true + prettyPrint = true +} + +internal data class CollectiveDeviceState( + val id: CollectiveDeviceId, + val configuration: CollectiveDeviceConfiguration, + val position: MutableDeviceState<Gmc>, + val velocity: MutableDeviceState<GmcVelocity>, +) + +internal fun CollectiveDeviceState( + id: CollectiveDeviceId, + position: Gmc, + configuration: CollectiveDeviceConfiguration.() -> Unit = {}, +) = CollectiveDeviceState( + id, + CollectiveDeviceConfiguration(id).apply(configuration), + MutableDeviceState(position), + MutableDeviceState(GmcVelocity.zero) +) + +internal class DeviceCollectiveModel( + context: Context, + val deviceStates: Collection<CollectiveDeviceState>, + val visibilityRange: Distance = 0.5.kilometers, + val radioRange: Distance = 1.kilometers, +) : ModelConstructor(context) { + + /** + * Propagate movement + */ + private val movement = onTimer { prev, next -> + val delta = (next - prev) + deviceStates.forEach { state -> + state.position.value = state.position.value.moveWith(state.velocity.value, delta) + } + } + + private fun locateVisible(id: CollectiveDeviceId): Map<CollectiveDeviceId, GmcCurve> { + val coordinatesSnapshot = deviceStates.associate { it.id to it.position.value } + + val selected = coordinatesSnapshot[id] ?: error("Can't find device with id $id") + + val allCurves = coordinatesSnapshot + .filterKeys { it != id } + .mapValues { GeoEllipsoid.WGS84.curveBetween(selected, it.value) } + + return allCurves.filterValues { it.distance in 0.kilometers..visibilityRange } + } + + inner class RadioPeerConnectionModel(private val position: DeviceState<Gmc>) : PeerConnection { + override suspend fun receive(address: String, contentId: String, requestMeta: Meta): Envelope? = null + + override suspend fun send(address: String, envelope: Envelope, requestMeta: Meta) { + devices.values.filter { it.configuration.radioFrequency == address }.filter { + GeoEllipsoid.WGS84.curveBetween(position.value, it.position.value).distance < radioRange + }.forEach { target -> + check(envelope.data != null) { "Envelope data is empty" } + val message = json.decodeFromString( + DeviceMessage.serializer(), + envelope.data?.toByteArray()?.decodeToString() ?: "" + ) + target.respondMessage(target.configuration.deviceId.parseAsName(), message) + } + } + } + + val devices = deviceStates.associate { state -> + val device = CollectiveDeviceConstructor( + context = context, + configuration = state.configuration, + position = state.position, + velocity = state.velocity, + peerConnection = RadioPeerConnectionModel(state.position), + ) { + locateVisible(state.id) + } + state.id to device + } + + internal fun createTrawler(position: Gmc, id: CollectiveDeviceId = "trawler"): CollectiveDeviceConstructor { + val state = CollectiveDeviceState( + id = id, + configuration = CollectiveDeviceConfiguration(id), + position = MutableDeviceState(position), + velocity = MutableDeviceState(GmcVelocity.zero) + ) + + val result = CollectiveDeviceConstructor( + context = context, + configuration = state.configuration, + position = state.position, + velocity = state.velocity, + peerConnection = RadioPeerConnectionModel(state.position), + ) { + locateVisible(state.id) + } + + // TODO move to CollectiveDeviceState + onTimer { prev, next -> + val delta = (next - prev) + state.position.value = state.position.value.moveWith(state.velocity.value, delta) + } + + result.onTimer(1.seconds) { _, _ -> + val envelope = Envelope { + data { + writeString( + json.encodeToString( + DeviceMessage.serializer(), + PropertySetMessage( + property = CollectiveDevice.velocity.name, + value = gmcVelocityMetaConverter.convert(state.velocity.value), + targetDevice = null + ) + ) + ) + } + } + + result.peerConnection.send( + CollectiveDeviceConfiguration.DEFAULT_FREQUENCY, + envelope + ) + } + + return result + } + + val roster = deviceStates.associate { it.id to it.configuration } +} + + +internal fun CoroutineScope.launchCollectiveMagixServer( + collectiveModel: DeviceCollectiveModel, +): Job = launch(Dispatchers.IO) { + val server = startMagixServer( +// RSocketMagixFlowPlugin() + ) + val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") + + collectiveModel.devices.forEach { (id, device) -> + val deviceContext = collectiveModel.context.buildContext(id.parseAsName()) { + coroutineContext(coroutineContext) + plugin(DeviceManager) + } + + deviceContext.install(id, device) + +// val deviceEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") + + deviceContext.request(DeviceManager).launchMagixService(deviceEndpoint, id) + } +} + + +internal fun generateModel( + context: Context, + size: Int = 50, + reportInterval: Duration = 500.milliseconds, + additionalConfiguration: CollectiveDeviceConfiguration.() -> Unit = {}, +): DeviceCollectiveModel { + val devices: List<CollectiveDeviceState> = List(size) { index -> + val id = "device[$index]" + + CollectiveDeviceState( + id = id, + Gmc( + center.latitude + radius * Random.nextDouble(), + center.longitude + radius * Random.nextDouble() + ) + ) { + deviceId = id + description = "Virtual remote device $id" + this.reportInterval = reportInterval.inWholeMilliseconds.toInt() + additionalConfiguration() + } + } + + val model = DeviceCollectiveModel(context, devices) + + return model +} + +fun DeviceClient.moveInCircles(scope: CoroutineScope = this): Job = scope.launch { + var bearing = Random.nextDouble(-PI, PI).radians + write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity)) + while (isActive) { + delay(500) + bearing += 5.degrees + write(CollectiveDevice.velocity, GmcVelocity(bearing, deviceVelocity)) + } +} + + +internal fun CollectiveDeviceConstructor.moveTo( + targetPosition: Gmc, + speedLimit: Distance = deviceVelocity, + scope: CoroutineScope = this, +): Job = scope.launch { + do { + val curve = GeoEllipsoid.WGS84.curveBetween(position.value, targetPosition) + write(CollectiveDevice.velocity, GmcVelocity(curve.forward.bearing, speedLimit)) + delay(1.seconds) + } while (curve.distance > 0.1.kilometers) + write(CollectiveDevice.velocity, GmcVelocity.zero) + +} \ No newline at end of file diff --git a/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt b/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt new file mode 100644 index 0000000..9d356c6 --- /dev/null +++ b/demo/device-collective/src/jvmMain/kotlin/GmcVelocity.kt @@ -0,0 +1,24 @@ +package space.kscience.controls.demo.collective + +import kotlinx.serialization.Serializable +import space.kscience.kmath.geometry.Angle +import space.kscience.maps.coordinates.* +import kotlin.time.Duration +import kotlin.time.DurationUnit + +@Serializable +data class GmcVelocity(val bearing: Angle, val velocity: Distance, val elevation: Distance = 0.kilometers){ + companion object{ + val zero = GmcVelocity(Angle.zero, 0.kilometers) + } +} + + +fun Gmc.moveWith(velocity: GmcVelocity, duration: Duration): Gmc { + val seconds = duration.toDouble(DurationUnit.SECONDS) + + return GeoEllipsoid.WGS84.curveInDirection( + GmcPose(this, velocity.bearing), + velocity.velocity * seconds, + ).backward.coordinates +} \ No newline at end of file diff --git a/demo/device-collective/src/jvmMain/kotlin/main.kt b/demo/device-collective/src/jvmMain/kotlin/main.kt new file mode 100644 index 0000000..f92b0eb --- /dev/null +++ b/demo/device-collective/src/jvmMain/kotlin/main.kt @@ -0,0 +1,303 @@ +@file:OptIn(ExperimentalFoundationApi::class, ExperimentalSplitPaneApi::class) + +package space.kscience.controls.demo.collective + +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.* +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.isSecondaryPressed +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import io.ktor.client.HttpClient +import io.ktor.client.engine.cio.CIO +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.sample +import org.jetbrains.compose.splitpane.ExperimentalSplitPaneApi +import org.jetbrains.compose.splitpane.HorizontalSplitPane +import org.jetbrains.compose.splitpane.rememberSplitPaneState +import space.kscience.controls.api.PropertyChangedMessage +import space.kscience.controls.client.* +import space.kscience.controls.compose.conditional +import space.kscience.controls.manager.DeviceManager +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.ContextBuilder +import space.kscience.dataforge.meta.MetaConverter +import space.kscience.dataforge.names.parseAsName +import space.kscience.magix.api.MagixEndpoint +import space.kscience.magix.api.subscribe +import space.kscience.magix.rsocket.rSocketWithWebSockets +import space.kscience.maps.compose.MapView +import space.kscience.maps.compose.OpenStreetMapTileProvider +import space.kscience.maps.coordinates.Gmc +import space.kscience.maps.coordinates.meters +import space.kscience.maps.features.* +import java.nio.file.Path +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + + +@Composable +fun rememberContext(name: String, contextBuilder: ContextBuilder.() -> Unit = {}): Context = remember { + Context(name, contextBuilder) +} + +internal val gmcMetaConverter = MetaConverter.serializable<Gmc>() +internal val gmcVelocityMetaConverter = MetaConverter.serializable<GmcVelocity>() + +@Composable +fun App() { + val scope = rememberCoroutineScope() + + val parentContext = rememberContext("Parent") { + plugin(DeviceManager) + } + + val collectiveModel = remember { + generateModel(parentContext, 100, reportInterval = 1.seconds) + } + + val roster = remember { + collectiveModel.roster + } + + val client = remember { CompletableDeferred<MagixEndpoint>() } + + val devices = remember { mutableStateMapOf<CollectiveDeviceId, DeviceClient>() } + + LaunchedEffect(collectiveModel) { + launchCollectiveMagixServer(collectiveModel) + + withContext(Dispatchers.IO) { + val magixClient = MagixEndpoint.rSocketWithWebSockets("localhost") + + client.complete(magixClient) + + collectiveModel.roster.forEach { (id, config) -> + scope.launch { + val deviceClient = magixClient.remoteDevice(parentContext, "listener", id, id.parseAsName()) + devices[id] = deviceClient + } + } + } + + } + + var selectedDeviceId by remember { mutableStateOf<CollectiveDeviceId?>(null) } + + var currentPosition by remember { mutableStateOf<Gmc?>(null) } + + LaunchedEffect(selectedDeviceId, devices) { + selectedDeviceId?.let { devices[it] }?.propertyFlow(CollectiveDevice.position)?.collect { + currentPosition = it + } + } + + var showOnlyVisible by remember { mutableStateOf(false) } + + var movementProgram: Job? by remember { mutableStateOf(null) } + + val trawler: CollectiveDeviceConstructor = remember { + collectiveModel.createTrawler(Gmc.ofDegrees(55.925, 37.50)) + } + + HorizontalSplitPane( + splitPaneState = rememberSplitPaneState(0.9f) + ) { + first(400.dp) { + var clickPoint by remember { mutableStateOf<Gmc?>(null) } + + CursorDropdownMenu(clickPoint != null, { clickPoint = null }) { + clickPoint?.let { point -> + TextButton({ + trawler.moveTo(point) + clickPoint = null + }) { + Text("Move trawler here") + } + } + } + + MapView( + mapTileProvider = remember { + OpenStreetMapTileProvider( + client = HttpClient(CIO), + cacheDirectory = Path.of("mapCache") + ) + }, + config = ViewConfig( + onClick = { event, point -> + if (event.buttons.isSecondaryPressed) { + clickPoint = point.focus + } + } + ) + ) { + //draw real positions + collectiveModel.deviceStates.forEach { device -> + circle(device.position.value, id = device.id + ".position").color(Color.Red) + device.position.valueFlow.sample(50.milliseconds).onEach { + val activeDevice = selectedDeviceId?.let { devices[it] } + val color = if (selectedDeviceId == device.id) { + Color.Magenta + } else if ( + showOnlyVisible && + activeDevice != null && + device.id in activeDevice.request(CollectiveDevice.visibleNeighbors) + ) { + Color.Cyan + } else { + Color.Red + } + + circle( + device.position.value, + id = device.id + ".position", + size = if (selectedDeviceId == device.id) 6.dp else 3.dp + ) + .color(color) + .modifyAttribute(ZAttribute, 10f) + .modifyAttribute(AlphaAttribute, if (selectedDeviceId == device.id) 1f else 0.5f) + .modifyAttribute(AlphaAttribute, 0.5f) // does not work right now + }.launchIn(scope) + } + + //draw received data + scope.launch { + client.await().subscribe(DeviceManager.magixFormat).onEach { (magixMessage, deviceMessage) -> + if (deviceMessage is PropertyChangedMessage && deviceMessage.property == "position") { + val id = magixMessage.sourceEndpoint + val position = gmcMetaConverter.read(deviceMessage.value) + + rectangle( + position, + id = id, + ).color(Color.Blue).onClick { selectedDeviceId = id } + } + }.launchIn(scope) + + } + + // draw trawler + + trawler.position.valueFlow.onEach { + circle(it, id = "trawler").color(Color.Black) + }.launchIn(scope) + } + } + second(200.dp) { + + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Button( + onClick = { + if (movementProgram == null) { + //start movement program + movementProgram = parentContext.launch { + devices.values.forEach { device -> + device.moveInCircles(this) + } + } + } else { + movementProgram?.cancel() + parentContext.launch { + devices.values.forEach { device -> + device.write(CollectiveDevice.velocity, GmcVelocity.zero) + } + } + movementProgram = null + } + }, + modifier = Modifier.fillMaxWidth() + ) { + if (movementProgram == null) { + Text("Move") + } else { + Text("Stop") + } + } + + collectiveModel.roster.forEach { (id, _) -> + Card( + elevation = 16.dp, + modifier = Modifier.padding(8.dp).onClick { + selectedDeviceId = id + }.conditional(id == selectedDeviceId) { + border(2.dp, Color.Blue) + }, + ) { + Column( + modifier = Modifier.padding(8.dp) + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (devices[id] == null) { + CircularProgressIndicator() + } + Text( + text = id, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(10.dp).fillMaxWidth(), + ) + } + if (id == selectedDeviceId) { + roster[id]?.let { + Text("Meta:", color = Color.Blue, fontWeight = FontWeight.Bold) + Card(elevation = 16.dp, modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Text(it.toString()) + } + } + + currentPosition?.let { currentPosition -> + Text( + "Широта: ${String.format("%.3f", currentPosition.latitude.toDegrees().value)}" + ) + Text( + "Долгота: ${String.format("%.3f", currentPosition.longitude.toDegrees().value)}" + ) + currentPosition.elevation?.let { + Text("Высота: ${String.format("%.1f", it.meters)} м") + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + Text("Показать только видимые") + Checkbox(showOnlyVisible, { showOnlyVisible = it }) + } + } + } + } + } + } + + } + } +} + + +fun main() = application { +// System.setProperty(IO_PARALLELISM_PROPERTY_NAME, 300.toString()) + Window(onCloseRequest = ::exitApplication, title = "Maps-kt demo", icon = painterResource("SPC-logo.png")) { + MaterialTheme { + App() + } + } +} \ No newline at end of file diff --git a/demo/device-collective/src/jvmMain/resources/SPC-logo.png b/demo/device-collective/src/jvmMain/resources/SPC-logo.png new file mode 100644 index 0000000..953de16 Binary files /dev/null and b/demo/device-collective/src/jvmMain/resources/SPC-logo.png differ diff --git a/demo/echo/build.gradle.kts b/demo/echo/build.gradle.kts index 5563ba3..ed722bf 100644 --- a/demo/echo/build.gradle.kts +++ b/demo/echo/build.gradle.kts @@ -8,24 +8,21 @@ repositories { maven("https://repo.kotlin.link") } -val ktorVersion: String by rootProject.extra -val rsocketVersion: String by rootProject.extra - dependencies { implementation(projects.magix.magixServer) implementation(projects.magix.magixRsocket) implementation(projects.magix.magixZmq) - implementation("io.ktor:ktor-client-cio:$ktorVersion") + implementation(spclibs.ktor.client.cio) - implementation("ch.qos.logback:logback-classic:1.2.11") + implementation(libs.logback.classic) } kotlin{ jvmToolchain(11) } tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn") + compilerOptions { + freeCompilerArgs.addAll("-Xjvm-default=all") } } diff --git a/demo/magix-demo/src/main/kotlin/zmq.kt b/demo/magix-demo/src/jvmMain/kotlin/zmq.kt similarity index 100% rename from demo/magix-demo/src/main/kotlin/zmq.kt rename to demo/magix-demo/src/jvmMain/kotlin/zmq.kt diff --git a/demo/many-devices/build.gradle.kts b/demo/many-devices/build.gradle.kts index 7248e42..5743341 100644 --- a/demo/many-devices/build.gradle.kts +++ b/demo/many-devices/build.gradle.kts @@ -9,17 +9,14 @@ repositories { maven("https://repo.kotlin.link") } -val ktorVersion: String by rootProject.extra -val rsocketVersion: String by rootProject.extra - dependencies { implementation(projects.magix.magixServer) implementation(projects.controlsMagix) implementation(projects.magix.magixRsocket) implementation(projects.magix.magixZmq) - implementation("io.ktor:ktor-client-cio:$ktorVersion") - implementation("space.kscience:plotlykt-server:0.6.0") + implementation(spclibs.ktor.client.cio) + implementation(libs.plotlykt.server) implementation(spclibs.logback.classic) } @@ -29,8 +26,8 @@ kotlin{ tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach { - kotlinOptions { - freeCompilerArgs = freeCompilerArgs + listOf("-Xjvm-default=all", "-Xopt-in=kotlin.RequiresOptIn") + compilerOptions { + freeCompilerArgs.addAll("-Xjvm-default=all") } } diff --git a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt index 5c89c26..53c4c7f 100644 --- a/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt +++ b/demo/many-devices/src/main/kotlin/space/kscience/controls/demo/MassDevice.kt @@ -1,8 +1,11 @@ package space.kscience.controls.demo -import kotlinx.coroutines.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock @@ -19,18 +22,19 @@ import space.kscience.dataforge.meta.get import space.kscience.dataforge.meta.int import space.kscience.magix.api.MagixEndpoint import space.kscience.magix.api.subscribe -import space.kscience.magix.rsocket.rSocketWithTcp -import space.kscience.magix.rsocket.rSocketWithWebSockets +import space.kscience.magix.rsocket.rSocketStreamWithWebSockets import space.kscience.magix.server.RSocketMagixFlowPlugin import space.kscience.magix.server.startMagixServer import space.kscience.plotly.Plotly -import space.kscience.plotly.bar +import space.kscience.plotly.PlotlyConfig import space.kscience.plotly.layout +import space.kscience.plotly.models.Bar import space.kscience.plotly.plot import space.kscience.plotly.server.PlotlyUpdateMode import space.kscience.plotly.server.serve import space.kscience.plotly.server.show import space.kscince.magix.zmq.ZmqMagixFlowPlugin +import space.kscince.magix.zmq.zmq import kotlin.random.Random import kotlin.time.Duration import kotlin.time.Duration.Companion.ZERO @@ -49,14 +53,13 @@ class MassDevice(context: Context, meta: Meta) : DeviceBySpec<MassDevice>(MassDe val value by doubleProperty { randomValue } override suspend fun MassDevice.onOpen() { - doRecurring((meta["delay"].int ?: 10).milliseconds) { + doRecurring((meta["delay"].int ?: 5).milliseconds) { read(value) } } } } -@OptIn(DelicateCoroutinesApi::class) suspend fun main() { val context = Context("Mass") @@ -65,65 +68,68 @@ suspend fun main() { ZmqMagixFlowPlugin() ) - val numDevices = 100 + val numDevices = 50 + repeat(numDevices) { - context.launch(newFixedThreadPoolContext(2, "Device${it}")) { - delay(1) - val deviceContext = Context("Device${it}") { - plugin(DeviceManager) + delay(1) + val deviceContext = Context("Device${it}") { + plugin(DeviceManager) + } + + val deviceManager = deviceContext.request(DeviceManager) + + deviceManager.install("device$it", MassDevice) + + val endpointId = "device$it" + val deviceEndpoint = MagixEndpoint.rSocketStreamWithWebSockets("localhost") + deviceManager.launchMagixService(deviceEndpoint, endpointId) + } + + val trace = Bar { + context.launch(Dispatchers.IO) { + val monitorEndpoint = MagixEndpoint.zmq("localhost") + + val mutex = Mutex() + + val latest = HashMap<String, Duration>() + val max = HashMap<String, Duration>() + + monitorEndpoint.subscribe(DeviceManager.magixFormat).onEach { (magixMessage, payload) -> + mutex.withLock { + val delay = Clock.System.now() - payload.time + latest[magixMessage.sourceEndpoint] = Clock.System.now() - payload.time + max[magixMessage.sourceEndpoint] = + maxOf(delay, max[magixMessage.sourceEndpoint] ?: ZERO) + } + }.launchIn(this) + + while (isActive) { + delay(200) + mutex.withLock { + val sorted = max.mapKeys { it.key.substring(6).toInt() }.toSortedMap() + latest.clear() + max.clear() + x.numbers = sorted.keys + y.numbers = sorted.values.map { it.inWholeMicroseconds / 1000.0 + 0.0001 } + } } - - val deviceManager = deviceContext.request(DeviceManager) - - deviceManager.install("device$it", MassDevice) - - val endpointId = "device$it" - val deviceEndpoint = MagixEndpoint.rSocketWithTcp("localhost") - deviceManager.launchMagixService(deviceEndpoint, endpointId) } } - val application = Plotly.serve(port = 9091, scope = context) { + val application = Plotly.serve(port = 9091) { updateMode = PlotlyUpdateMode.PUSH updateInterval = 1000 + page { container -> - plot(renderer = container) { + plot(renderer = container, config = PlotlyConfig { saveAsSvg() }) { layout { - title = "Latest event" +// title = "Latest event" + xaxis.title = "Device number" yaxis.title = "Maximum latency in ms" } - bar { - launch(Dispatchers.IO) { - val monitorEndpoint = MagixEndpoint.rSocketWithWebSockets("localhost") - - val mutex = Mutex() - - val latest = HashMap<String, Duration>() - val max = HashMap<String, Duration>() - - monitorEndpoint.subscribe(DeviceManager.magixFormat).onEach { (magixMessage, payload) -> - mutex.withLock { - val delay = Clock.System.now() - payload.time!! - latest[magixMessage.sourceEndpoint] = Clock.System.now() - payload.time!! - max[magixMessage.sourceEndpoint] = - maxOf(delay, max[magixMessage.sourceEndpoint] ?: ZERO) - } - }.launchIn(this) - - while (isActive) { - delay(200) - mutex.withLock { - val sorted = max.mapKeys { it.key.substring(6).toInt() }.toSortedMap() - latest.clear() - max.clear() - x.numbers = sorted.keys - y.numbers = sorted.values.map { it.inWholeMilliseconds / 1000.0 + 0.0001 } - } - } - } - } + traces(trace) } } } diff --git a/demo/mks-pdr900/api/mks-pdr900.api b/demo/mks-pdr900/api/mks-pdr900.api index a9c9ecb..35e5f3c 100644 --- a/demo/mks-pdr900/api/mks-pdr900.api +++ b/demo/mks-pdr900/api/mks-pdr900.api @@ -10,19 +10,11 @@ public final class center/sciprog/devices/mks/MksPdr900Device : space/kscience/c public final class center/sciprog/devices/mks/MksPdr900Device$Companion : space/kscience/controls/spec/DeviceSpec, space/kscience/dataforge/context/Factory { public fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Lcenter/sciprog/devices/mks/MksPdr900Device; public synthetic fun build (Lspace/kscience/dataforge/context/Context;Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object; - public final fun getChannel ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; - public final fun getError ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; - public final fun getPowerOn ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getChannel ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; + public final fun getError ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; + public final fun getPowerOn ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public final fun getValue ()Lspace/kscience/controls/spec/DevicePropertySpec; public fun onClose (Lcenter/sciprog/devices/mks/MksPdr900Device;)V public synthetic fun onClose (Lspace/kscience/controls/api/Device;)V } -public final class center/sciprog/devices/mks/NullableStringMetaConverter : space/kscience/dataforge/meta/transformations/MetaConverter { - public static final field INSTANCE Lcenter/sciprog/devices/mks/NullableStringMetaConverter; - public synthetic fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/Object; - public fun metaToObject (Lspace/kscience/dataforge/meta/Meta;)Ljava/lang/String; - public synthetic fun objectToMeta (Ljava/lang/Object;)Lspace/kscience/dataforge/meta/Meta; - public fun objectToMeta (Ljava/lang/String;)Lspace/kscience/dataforge/meta/Meta; -} - diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt index 949ced9..892d465 100644 --- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt +++ b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt @@ -4,15 +4,14 @@ import kotlinx.coroutines.withTimeoutOrNull import space.kscience.controls.ports.Ports import space.kscience.controls.ports.SynchronousPort import space.kscience.controls.ports.respondStringWithDelimiter -import space.kscience.controls.ports.synchronous import space.kscience.controls.spec.* import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Factory import space.kscience.dataforge.context.request import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.MetaConverter import space.kscience.dataforge.meta.get import space.kscience.dataforge.meta.int -import space.kscience.dataforge.meta.transformations.MetaConverter //TODO this device is not tested @@ -22,7 +21,7 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi private val portDelegate = lazy { val ports = context.request(Ports) - ports.buildPort(meta["port"] ?: error("Port is not defined in device configuration")).synchronous() + ports.buildSynchronousPort(meta["port"] ?: error("Port is not defined in device configuration")) } private val port: SynchronousPort by portDelegate @@ -49,16 +48,16 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi if (powerOnValue) { val ans = talk("FP!ON") if (ans == "ON") { - updateLogical(powerOn, true) + propertyChanged(powerOn, true) } else { - updateLogical(error, "Failed to set power state") + propertyChanged(error, "Failed to set power state") } } else { val ans = talk("FP!OFF") if (ans == "OFF") { - updateLogical(powerOn, false) + propertyChanged(powerOn, false) } else { - updateLogical(error, "Failed to set power state") + propertyChanged(error, "Failed to set power state") } } } @@ -68,13 +67,13 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi invalidate(error) return if (answer.isNullOrEmpty()) { // updateState(PortSensor.CONNECTED_STATE, false) - updateLogical(error, "No connection") + propertyChanged(error, "No connection") null } else { val res = answer.toDouble() if (res <= 0) { - updateLogical(powerOn, false) - updateLogical(error, "No power") + propertyChanged(powerOn, false) + propertyChanged(error, "No power") null } else { res @@ -89,20 +88,20 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec<MksPdr900Devi override fun build(context: Context, meta: Meta): MksPdr900Device = MksPdr900Device(context, meta) - val powerOn by booleanProperty(read = MksPdr900Device::readPowerOn, write = MksPdr900Device::writePowerOn) + val powerOn by mutableBooleanProperty(read = { readPowerOn() }, write = { _, value -> writePowerOn(value) }) - val channel by logicalProperty(MetaConverter.int) + val channel by property(MetaConverter.int) val value by doubleProperty(read = { - readChannelData(get(channel) ?: DEFAULT_CHANNEL) + readChannelData(getOrRead(channel)) }) - val error by logicalProperty(MetaConverter.string) + val error by property(MetaConverter.string) - override fun MksPdr900Device.onClose() { + override suspend fun MksPdr900Device.onClose() { if (portDelegate.isInitialized()) { - port.close() + port.stop() } } } diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/NullableStringMetaConverter.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/NullableStringMetaConverter.kt deleted file mode 100644 index 40c20ed..0000000 --- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/NullableStringMetaConverter.kt +++ /dev/null @@ -1,10 +0,0 @@ -package center.sciprog.devices.mks - -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.string -import space.kscience.dataforge.meta.transformations.MetaConverter - -object NullableStringMetaConverter : MetaConverter<String?> { - override fun metaToObject(meta: Meta): String? = meta.string - override fun objectToMeta(obj: String?): Meta = Meta {} -} \ No newline at end of file diff --git a/demo/motors/api/motors.api b/demo/motors/api/motors.api index 506d7ee..05f430a 100644 --- a/demo/motors/api/motors.api +++ b/demo/motors/api/motors.api @@ -1,6 +1,6 @@ public final class ru/mipt/npm/devices/pimotionmaster/FxDevicePropertiesKt { public static final fun fxProperty (Lspace/kscience/controls/api/Device;Lspace/kscience/controls/spec/DevicePropertySpec;)Ljavafx/beans/property/ReadOnlyProperty; - public static final fun fxProperty (Lspace/kscience/controls/api/Device;Lspace/kscience/controls/spec/WritableDevicePropertySpec;)Ljavafx/beans/property/Property; + public static final fun fxProperty (Lspace/kscience/controls/api/Device;Lspace/kscience/controls/spec/MutableDevicePropertySpec;)Ljavafx/beans/property/Property; } public final class ru/mipt/npm/devices/pimotionmaster/PiDebugServerKt { @@ -50,19 +50,19 @@ public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Axis } public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Axis$Companion : space/kscience/controls/spec/DeviceSpec { - public final fun getClosedLoop ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; - public final fun getEnabled ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getClosedLoop ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; + public final fun getEnabled ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public final fun getHalt ()Lspace/kscience/controls/spec/DeviceActionSpec; public final fun getMaxPosition ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getMinPosition ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getMove ()Lspace/kscience/controls/spec/DeviceActionSpec; public final fun getMoveToReference ()Lspace/kscience/controls/spec/DeviceActionSpec; public final fun getOnTarget ()Lspace/kscience/controls/spec/DevicePropertySpec; - public final fun getOpenLoopTarget ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getOpenLoopTarget ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; public final fun getPosition ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getReference ()Lspace/kscience/controls/spec/DevicePropertySpec; - public final fun getTargetPosition ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; - public final fun getVelocity ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getTargetPosition ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; + public final fun getVelocity ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; } public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Companion : space/kscience/controls/spec/DeviceSpec, space/kscience/dataforge/context/Factory { @@ -75,7 +75,7 @@ public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice$Compa public final fun getIdentity ()Lspace/kscience/controls/spec/DevicePropertySpec; public final fun getInitialize ()Lspace/kscience/controls/spec/DeviceActionSpec; public final fun getStop ()Lspace/kscience/controls/spec/DeviceActionSpec; - public final fun getTimeout ()Lspace/kscience/controls/spec/WritableDevicePropertySpec; + public final fun getTimeout ()Lspace/kscience/controls/spec/MutableDevicePropertySpec; } public final class ru/mipt/npm/devices/pimotionmaster/PiMotionMasterView : tornadofx/View { diff --git a/demo/motors/build.gradle.kts b/demo/motors/build.gradle.kts index 8626c72..774df46 100644 --- a/demo/motors/build.gradle.kts +++ b/demo/motors/build.gradle.kts @@ -1,18 +1,7 @@ plugins { id("space.kscience.gradle.jvm") - application - id("org.openjfx.javafxplugin") -} - -//TODO to be moved to a separate project - -javafx { - version = "17" - modules = listOf("javafx.controls") -} - -application{ - mainClass.set("ru.mipt.npm.devices.pimotionmaster.PiMotionMasterAppKt") + alias(spclibs.plugins.compose.compiler) + alias(spclibs.plugins.compose.jb) } kotlin{ @@ -25,5 +14,17 @@ val dataforgeVersion: String by extra dependencies { implementation(project(":controls-ports-ktor")) implementation(projects.controlsMagix) - implementation("no.tornado:tornadofx:1.7.20") + + implementation(compose.runtime) + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(spclibs.logback.classic) +} + +compose{ + desktop{ + application{ + mainClass = "ru.mipt.npm.devices.pimotionmaster.PiMotionMasterAppKt" + } + } } diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt index 840b1d9..b64cd2d 100644 --- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt +++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterApp.kt @@ -1,31 +1,195 @@ package ru.mipt.npm.devices.pimotionmaster -import javafx.beans.property.ReadOnlyProperty -import javafx.beans.property.SimpleIntegerProperty -import javafx.beans.property.SimpleObjectProperty -import javafx.beans.property.SimpleStringProperty -import javafx.geometry.Pos -import javafx.scene.Parent -import javafx.scene.layout.Priority -import javafx.scene.layout.VBox -import kotlinx.coroutines.CoroutineScope + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.Button +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Slider +import androidx.compose.material.Text +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState import kotlinx.coroutines.Job import kotlinx.coroutines.launch -import ru.mipt.npm.devices.pimotionmaster.PiMotionMasterDevice.Axis.Companion.maxPosition -import ru.mipt.npm.devices.pimotionmaster.PiMotionMasterDevice.Axis.Companion.minPosition -import ru.mipt.npm.devices.pimotionmaster.PiMotionMasterDevice.Axis.Companion.position import space.kscience.controls.manager.DeviceManager import space.kscience.controls.manager.installing import space.kscience.controls.spec.read import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.request -import tornadofx.* -class PiMotionMasterApp : App(PiMotionMasterView::class) +//class PiMotionMasterApp : App(PiMotionMasterView::class) +// +//class PiMotionMasterController : Controller() { +// //initialize context +// val context = Context("piMotionMaster") { +// plugin(DeviceManager) +// } +// +// //initialize deviceManager plugin +// val deviceManager: DeviceManager = context.request(DeviceManager) +// +// // install device +// val motionMaster: PiMotionMasterDevice by deviceManager.installing(PiMotionMasterDevice) +//} -class PiMotionMasterController : Controller() { - //initialize context - val context = Context("piMotionMaster"){ +@Composable +fun ColumnScope.piMotionMasterAxis( + axisName: String, + axis: PiMotionMasterDevice.Axis, +) { + var min by remember { mutableStateOf(0f) } + var max by remember { mutableStateOf(1f) } + var targetPosition by remember { mutableStateOf(0f) } + val position: Double by axis.composeState(PiMotionMasterDevice.Axis.position, 0.0) + + val scope = rememberCoroutineScope() + + LaunchedEffect(axis) { + min = axis.read(PiMotionMasterDevice.Axis.minPosition).toFloat() + max = axis.read(PiMotionMasterDevice.Axis.maxPosition).toFloat() + targetPosition = axis.read(PiMotionMasterDevice.Axis.position).toFloat() + } + + + Row { + Text(axisName) + + Column { + Slider( + value = position.toFloat(), + enabled = false, + onValueChange = { }, + valueRange = min..max + ) + Slider( + value = targetPosition, + onValueChange = { newPosition -> + targetPosition = newPosition + scope.launch { + axis.move(newPosition.toDouble()) + } + }, + valueRange = min..max + ) + + } + } +} + +@Composable +fun AxisPane(axes: Map<String, PiMotionMasterDevice.Axis>) { + Column { + axes.forEach { (name, axis) -> + this.piMotionMasterAxis(name, axis) + } + } +} + + +@Composable +fun PiMotionMasterApp(device: PiMotionMasterDevice) { + +// val scope = rememberCoroutineScope() + val connected by device.composeState(PiMotionMasterDevice.connected, false) + var debugServerJob by remember { mutableStateOf<Job?>(null) } + var axes by remember { mutableStateOf<Map<String, PiMotionMasterDevice.Axis>?>(null) } + //private val axisList = FXCollections.observableArrayList<Map.Entry<String, PiMotionMasterDevice.Axis>>() + var host by remember { mutableStateOf("127.0.0.1") } + var port by remember { mutableStateOf(10024) } + + Scaffold { + Column { + + + Text("Address:") + Row { + OutlinedTextField( + value = host, + onValueChange = { host = it }, + label = { Text("Host") }, + enabled = debugServerJob == null, + modifier = Modifier.weight(1f) + ) + var portError by remember { mutableStateOf(false) } + OutlinedTextField( + value = port.toString(), + onValueChange = { + it.toIntOrNull()?.let { value -> + port = value + portError = false + } ?: run { + portError = true + } + }, + label = { Text("Port") }, + enabled = debugServerJob == null, + isError = portError, + modifier = Modifier.weight(1f), + ) + } + Row { + Button( + onClick = { + if (debugServerJob == null) { + debugServerJob = device.context.launchPiDebugServer(port, listOf("1", "2", "3", "4")) + } else { + debugServerJob?.cancel() + debugServerJob = null + } + }, + modifier = Modifier.fillMaxWidth() + ) { + if (debugServerJob == null) { + Text("Start debug server") + } else { + Text("Stop debug server") + } + } + } + Row { + Button( + onClick = { + if (!connected) { + device.launch { + device.connect(host, port) + axes = device.axes + } + } else { + device.launch { + device.disconnect() + axes = null + } + } + }, + modifier = Modifier.fillMaxWidth() + ) { + if (!connected) { + Text("Connect") + } else { + Text("Disconnect") + } + } + } + + axes?.let { axes -> + AxisPane(axes) + } + } + } +} + + +fun main() = application { + + val context = Context("piMotionMaster") { plugin(DeviceManager) } @@ -34,131 +198,14 @@ class PiMotionMasterController : Controller() { // install device val motionMaster: PiMotionMasterDevice by deviceManager.installing(PiMotionMasterDevice) -} -fun VBox.piMotionMasterAxis( - axisName: String, - axis: PiMotionMasterDevice.Axis, - coroutineScope: CoroutineScope, -) = hbox { - alignment = Pos.CENTER - label(axisName) - coroutineScope.launch { - with(axis) { - val min: Double = read(minPosition) - val max: Double = read(maxPosition) - val positionProperty = fxProperty(position) - val startPosition = read(position) - runLater { - vbox { - hgrow = Priority.ALWAYS - slider(min..max, startPosition) { - minWidth = 300.0 - isShowTickLabels = true - isShowTickMarks = true - minorTickCount = 10 - majorTickUnit = 1.0 - valueProperty().onChange { - coroutineScope.launch { - axis.move(value) - } - } - } - slider(min..max) { - isDisable = true - valueProperty().bind(positionProperty) - } - } - } + Window( + title = "Pi motion master demo", + onCloseRequest = { exitApplication() }, + state = rememberWindowState(width = 400.dp, height = 300.dp) + ) { + MaterialTheme { + PiMotionMasterApp(motionMaster) } } -} - -fun Parent.axisPane(axes: Map<String, PiMotionMasterDevice.Axis>, coroutineScope: CoroutineScope) { - vbox { - axes.forEach { (name, axis) -> - this.piMotionMasterAxis(name, axis, coroutineScope) - } - } -} - - -class PiMotionMasterView : View() { - - private val controller: PiMotionMasterController by inject() - val device = controller.motionMaster - - private val connectedProperty: ReadOnlyProperty<Boolean> = device.fxProperty(PiMotionMasterDevice.connected) - private val debugServerJobProperty = SimpleObjectProperty<Job>() - private val debugServerStarted = debugServerJobProperty.booleanBinding { it != null } - //private val axisList = FXCollections.observableArrayList<Map.Entry<String, PiMotionMasterDevice.Axis>>() - - override val root: Parent = borderpane { - top { - form { - val host = SimpleStringProperty("127.0.0.1") - val port = SimpleIntegerProperty(10024) - fieldset("Address:") { - field("Host:") { - textfield(host) { - enableWhen(debugServerStarted.not()) - } - } - field("Port:") { - textfield(port) { - stripNonNumeric() - } - button { - hgrow = Priority.ALWAYS - textProperty().bind(debugServerStarted.stringBinding { - if (it != true) { - "Start debug server" - } else { - "Stop debug server" - } - }) - action { - if (!debugServerStarted.get()) { - debugServerJobProperty.value = - controller.context.launchPiDebugServer(port.get(), listOf("1", "2", "3", "4")) - } else { - debugServerJobProperty.get().cancel() - debugServerJobProperty.value = null - } - } - } - } - } - - button { - hgrow = Priority.ALWAYS - textProperty().bind(connectedProperty.stringBinding { - if (it == false) { - "Connect" - } else { - "Disconnect" - } - }) - action { - if (!connectedProperty.value) { - device.connect(host.get(), port.get()) - center { - axisPane(device.axes,controller.context) - } - } else { - this@borderpane.center = null - device.disconnect() - } - } - } - - - } - } - - } -} - -fun main() { - launch<PiMotionMasterApp>() } \ No newline at end of file diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt index 4eff6c4..4324b9b 100644 --- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt +++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterDevice.kt @@ -7,40 +7,37 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.transformWhile -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withTimeout import space.kscience.controls.api.DeviceHub import space.kscience.controls.api.PropertyDescriptor -import space.kscience.controls.ports.* +import space.kscience.controls.misc.asMeta +import space.kscience.controls.misc.duration +import space.kscience.controls.ports.AsynchronousPort +import space.kscience.controls.ports.KtorTcpPort +import space.kscience.controls.ports.send +import space.kscience.controls.ports.withStringDelimiter import space.kscience.controls.spec.* import space.kscience.dataforge.context.* -import space.kscience.dataforge.meta.Meta -import space.kscience.dataforge.meta.asValue -import space.kscience.dataforge.meta.double -import space.kscience.dataforge.meta.get -import space.kscience.dataforge.meta.transformations.MetaConverter -import space.kscience.dataforge.names.NameToken -import kotlin.collections.component1 -import kotlin.collections.component2 +import space.kscience.dataforge.meta.* +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.parseAsName import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds class PiMotionMasterDevice( context: Context, - private val portFactory: PortFactory = KtorTcpPort, + private val portFactory: Factory<AsynchronousPort> = KtorTcpPort, ) : DeviceBySpec<PiMotionMasterDevice>(PiMotionMasterDevice, context), DeviceHub { - private var port: Port? = null + private var port: AsynchronousPort? = null //TODO make proxy work //PortProxy { portFactory(address ?: error("The device is not connected"), context) } - fun disconnect() { - runBlocking { - execute(disconnect) - } + suspend fun disconnect() { + execute(disconnect) } var timeoutValue: Duration = 200.milliseconds @@ -51,20 +48,18 @@ class PiMotionMasterDevice( var axes: Map<String, Axis> = emptyMap() private set - override val devices: Map<NameToken, Axis> = axes.mapKeys { (key, _) -> NameToken(key) } + override val devices: Map<Name, Axis> = axes.mapKeys { (key, _) -> key.parseAsName() } private suspend fun failIfError(message: (Int) -> String = { "Failed with error code $it" }) { val errorCode = getErrorCode() if (errorCode != 0) error(message(errorCode)) } - fun connect(host: String, port: Int) { - runBlocking { - execute(connect, Meta { - "host" put host - "port" put port - }) - } + suspend fun connect(host: String, port: Int) { + execute(connect, Meta { + "host" put host + "port" put port + }) } private val mutex = Mutex() @@ -87,7 +82,7 @@ class PiMotionMasterDevice( suspend fun getErrorCode(): Int = mutex.withLock { withTimeout(timeoutValue) { sendCommandInternal("ERR?") - val errorString = port?.receiving()?.withStringDelimiter("\n")?.first() ?: error("Not connected to device") + val errorString = port?.subscribe()?.withStringDelimiter("\n")?.first() ?: error("Not connected to device") errorString.trim().toInt() } } @@ -100,14 +95,14 @@ class PiMotionMasterDevice( try { withTimeout(timeoutValue) { sendCommandInternal(command, *arguments) - val phrases = port?.receiving()?.withStringDelimiter("\n") ?: error("Not connected to device") + val phrases = port?.subscribe()?.withStringDelimiter("\n") ?: error("Not connected to device") phrases.transformWhile { line -> emit(line) line.endsWith(" \n") }.toList() } } catch (ex: Throwable) { - logger.warn { "Error during PIMotionMaster request. Requesting error code." } + logger.error(ex) { "Error during PIMotionMaster request. Requesting error code." } val errorCode = getErrorCode() dispatchError(errorCode) logger.warn { "Error code $errorCode" } @@ -138,7 +133,7 @@ class PiMotionMasterDevice( override fun build(context: Context, meta: Meta): PiMotionMasterDevice = PiMotionMasterDevice(context) val connected by booleanProperty(descriptorBuilder = { - info = "True if the connection address is defined and the device is initialized" + description = "True if the connection address is defined and the device is initialized" }) { port != null } @@ -157,13 +152,13 @@ class PiMotionMasterDevice( } val stop by unitAction({ - info = "Stop all axis" + description = "Stop all axis" }) { send("STP") } val connect by action(MetaConverter.meta, MetaConverter.unit, descriptorBuilder = { - info = "Connect to specific port and initialize axis" + description = "Connect to specific port and initialize axis" }) { portSpec -> //Clear current actions if present if (port != null) { @@ -171,12 +166,12 @@ class PiMotionMasterDevice( } //Update port //address = portSpec.node - port = portFactory(portSpec, context) - updateLogical(connected, true) + port = portFactory(portSpec, context).apply { start() } // connector.open() //Initialize axes val idn = read(identity) failIfError { "Can't connect to $portSpec. Error code: $it" } + propertyChanged(connected, true) logger.info { "Connected to $idn on $portSpec" } val ids = request("SAI?").map { it.trim() } if (ids != axes.keys.toList()) { @@ -189,19 +184,19 @@ class PiMotionMasterDevice( } val disconnect by unitAction({ - info = "Disconnect the program from the device if it is connected" + description = "Disconnect the program from the device if it is connected" }) { port?.let { execute(stop) - it.close() + it.stop() } port = null - updateLogical(connected, false) + propertyChanged(connected, false) } val timeout by mutableProperty(MetaConverter.duration, PiMotionMasterDevice::timeoutValue) { - info = "Timeout" + description = "Timeout" } } @@ -241,12 +236,12 @@ class PiMotionMasterDevice( private fun axisBooleanProperty( command: String, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, - ) = booleanProperty( + ) = mutableBooleanProperty( read = { readAxisBoolean("$command?") }, - write = { - writeAxisBoolean(command, it) + write = { _, value -> + writeAxisBoolean(command, value) }, descriptorBuilder = descriptorBuilder ) @@ -254,12 +249,12 @@ class PiMotionMasterDevice( private fun axisNumberProperty( command: String, descriptorBuilder: PropertyDescriptor.() -> Unit = {}, - ) = doubleProperty( + ) = mutableDoubleProperty( read = { mm.requestAndParse("$command?", axisId)[axisId]?.toDoubleOrNull() ?: error("Malformed $command response. Should include float value for $axisId") }, - write = { newValue -> + write = { _, newValue -> mm.send(command, axisId, newValue.toString()) mm.failIfError() }, @@ -267,7 +262,7 @@ class PiMotionMasterDevice( ) val enabled by axisBooleanProperty("EAX") { - info = "Motor enable state." + description = "Motor enable state." } val halt by unitAction { @@ -275,20 +270,20 @@ class PiMotionMasterDevice( } val targetPosition by axisNumberProperty("MOV") { - info = """ + description = """ Sets a new absolute target position for the specified axis. Servo mode must be switched on for the commanded axis prior to using this command (closed-loop operation). """.trimIndent() } val onTarget by booleanProperty({ - info = "Queries the on-target state of the specified axis." + description = "Queries the on-target state of the specified axis." }) { readAxisBoolean("ONT?") } val reference by booleanProperty({ - info = "Get Referencing Result" + description = "Get Referencing Result" }) { readAxisBoolean("FRF?") } @@ -298,36 +293,36 @@ class PiMotionMasterDevice( } val minPosition by doubleProperty({ - info = "Minimal position value for the axis" + description = "Minimal position value for the axis" }) { mm.requestAndParse("TMN?", axisId)[axisId]?.toDoubleOrNull() ?: error("Malformed `TMN?` response. Should include float value for $axisId") } val maxPosition by doubleProperty({ - info = "Maximal position value for the axis" + description = "Maximal position value for the axis" }) { mm.requestAndParse("TMX?", axisId)[axisId]?.toDoubleOrNull() ?: error("Malformed `TMX?` response. Should include float value for $axisId") } val position by doubleProperty({ - info = "The current axis position." + description = "The current axis position." }) { mm.requestAndParse("POS?", axisId)[axisId]?.toDoubleOrNull() ?: error("Malformed `POS?` response. Should include float value for $axisId") } val openLoopTarget by axisNumberProperty("OMA") { - info = "Position for open-loop operation." + description = "Position for open-loop operation." } val closedLoop by axisBooleanProperty("SVO") { - info = "Servo closed loop mode" + description = "Servo closed loop mode" } val velocity by axisNumberProperty("VEL") { - info = "Velocity value for closed-loop operation" + description = "Velocity value for closed-loop operation" } val move by action(MetaConverter.meta, MetaConverter.unit) { diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt index 8efe4e9..4ec21de 100644 --- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt +++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/PiMotionMasterVirtualDevice.kt @@ -5,14 +5,16 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import space.kscience.controls.api.Socket -import space.kscience.controls.ports.AbstractPort +import space.kscience.controls.api.AsynchronousSocket +import space.kscience.controls.api.LifecycleState +import space.kscience.controls.ports.AbstractAsynchronousPort import space.kscience.controls.ports.withDelimiter import space.kscience.dataforge.context.* +import space.kscience.dataforge.meta.Meta import kotlin.math.abs import kotlin.time.Duration -abstract class VirtualDevice(val scope: CoroutineScope) : Socket<ByteArray> { +abstract class VirtualDevice(val scope: CoroutineScope) : AsynchronousSocket<ByteArray> { protected abstract suspend fun evaluateRequest(request: ByteArray) @@ -40,34 +42,43 @@ abstract class VirtualDevice(val scope: CoroutineScope) : Socket<ByteArray> { toRespond.send(response) } - override fun receiving(): Flow<ByteArray> = toRespond.receiveAsFlow() + override fun subscribe(): Flow<ByteArray> = toRespond.receiveAsFlow() protected fun respondInFuture(delay: Duration, response: suspend () -> ByteArray): Job = scope.launch { delay(delay) respond(response()) } - override fun isOpen(): Boolean = scope.isActive + override val lifecycleState: LifecycleState + get() = if(scope.isActive) LifecycleState.STARTED else LifecycleState.STOPPED - override fun close() = scope.cancel() + override suspend fun stop() = scope.cancel() } -class VirtualPort(private val device: VirtualDevice, context: Context) : AbstractPort(context) { +class VirtualPort(private val device: VirtualDevice, context: Context) : AbstractAsynchronousPort(context, Meta.EMPTY) { - private val respondJob = device.receiving().onEach { - receive(it) - }.catch { - it.printStackTrace() - }.launchIn(scope) + private var respondJob: Job? = null + + override fun onOpen() { + respondJob = device.subscribe().onEach { + receive(it) + }.catch { + it.printStackTrace() + }.launchIn(scope) + + } override suspend fun write(data: ByteArray) { device.send(data) } - override fun close() { - respondJob.cancel() - super.close() + override val lifecycleState: LifecycleState + get() = if(respondJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED + + override suspend fun stop() { + respondJob?.cancel() + super.stop() } } @@ -78,7 +89,7 @@ class PiMotionMasterVirtualDevice( scope: CoroutineScope = context, ) : VirtualDevice(scope), ContextAware { - init { + override suspend fun start() { //add asynchronous send logic here } @@ -102,9 +113,11 @@ class PiMotionMasterVirtualDevice( abs(distance) < proposedStep -> { position = targetPosition } + targetPosition > position -> { position += proposedStep } + else -> { position -= proposedStep } @@ -180,8 +193,10 @@ class PiMotionMasterVirtualDevice( when (command) { "XXX" -> { } + "IDN?", "*IDN?" -> respond("(c)2015 Physik Instrumente(PI) Karlsruhe, C-885.M1 TCP-IP Master,0,1.0.0.1") - "VER?" -> respond(""" + "VER?" -> respond( + """ 2: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550039, 00.039 3: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550040, 00.039 4: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550041, 00.039 @@ -195,8 +210,11 @@ class PiMotionMasterVirtualDevice( 12: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550049, 00.039 13: (c)2017 Physik Instrumente (PI) GmbH & Co. KG, C-663.12C885, 018550051, 00.039 FW_ARM: V1.0.0.1 - """.trimIndent()) - "HLP?" -> respond(""" + """.trimIndent() + ) + + "HLP?" -> respond( + """ The following commands are valid: #4 Request Status Register #5 Request Motion Status @@ -235,11 +253,14 @@ class PiMotionMasterVirtualDevice( VEL? [{<AxisID>}] Get Closed-Loop Velocity VER? Get Versions Of Firmware And Drivers end of help - """.trimIndent()) + """.trimIndent() + ) + "ERR?" -> { respond(errorCode.toString()) errorCode = 0 } + "SAI?" -> respond(axisState.keys.joinToString(separator = " \n")) "CST?" -> respondForAllAxis(axisIds) { "L-220.20SG" } "RON?" -> respondForAllAxis(axisIds) { referenceMode } @@ -255,15 +276,19 @@ class PiMotionMasterVirtualDevice( "SVO" -> doForEachAxis(parts) { key, value -> axisState[key]?.servoMode = value.toInt() } + "MOV" -> doForEachAxis(parts) { key, value -> axisState[key]?.targetPosition = value.toDouble() } + "VEL" -> doForEachAxis(parts) { key, value -> axisState[key]?.velocity = value.toDouble() } + "INI" -> { logger.info { "Axes initialized!" } } + else -> { logger.warn { "Unknown command: $command in message ${String(request)}" } errorCode = 2 diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/deviceProperties.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/deviceProperties.kt new file mode 100644 index 0000000..00a4c55 --- /dev/null +++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/deviceProperties.kt @@ -0,0 +1,15 @@ +package ru.mipt.npm.devices.pimotionmaster + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import space.kscience.controls.api.Device +import space.kscience.controls.spec.DevicePropertySpec +import space.kscience.controls.spec.propertyFlow + + +@Composable +fun <D : Device, T : Any> D.composeState( + spec: DevicePropertySpec<D, T>, + initialState: T, +): State<T> = propertyFlow(spec).collectAsState(initialState) \ No newline at end of file diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt deleted file mode 100644 index 0328631..0000000 --- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/fxDeviceProperties.kt +++ /dev/null @@ -1,58 +0,0 @@ -package ru.mipt.npm.devices.pimotionmaster - -import javafx.beans.property.ObjectPropertyBase -import javafx.beans.property.Property -import javafx.beans.property.ReadOnlyProperty -import space.kscience.controls.api.Device -import space.kscience.controls.spec.* -import space.kscience.dataforge.context.info -import space.kscience.dataforge.context.logger -import tornadofx.* - -/** - * Bind a FX property to a device property with a given [spec] - */ -fun <D : Device, T : Any> D.fxProperty( - spec: DevicePropertySpec<D, T>, -): ReadOnlyProperty<T> = object : ObjectPropertyBase<T>() { - override fun getBean(): Any = this - override fun getName(): String = spec.name - - init { - //Read incoming changes - onPropertyChange(spec) { - runLater { - try { - set(it) - } catch (ex: Throwable) { - logger.info { "Failed to set property $name to $it" } - } - } - } - } -} - -fun <D : Device, T : Any> D.fxProperty(spec: WritableDevicePropertySpec<D, T>): Property<T> = - object : ObjectPropertyBase<T>() { - override fun getBean(): Any = this - override fun getName(): String = spec.name - - init { - //Read incoming changes - onPropertyChange(spec) { - runLater { - try { - set(it) - } catch (ex: Throwable) { - logger.info { "Failed to set property $name to $it" } - } - } - } - - onChange { newValue -> - if (newValue != null) { - set(spec, newValue) - } - } - } - } diff --git a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt index 021dff5..4c2b350 100644 --- a/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt +++ b/demo/motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster/piDebugServer.kt @@ -8,6 +8,8 @@ import io.ktor.util.InternalAPI import io.ktor.util.moveToByteArray import io.ktor.utils.io.writeAvailable import kotlinx.coroutines.* +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach import space.kscience.dataforge.context.Context import space.kscience.dataforge.context.Global @@ -18,41 +20,40 @@ val exceptionHandler = CoroutineExceptionHandler { _, throwable -> @OptIn(InternalAPI::class) fun Context.launchPiDebugServer(port: Int, axes: List<String>): Job = launch(exceptionHandler) { val virtualDevice = PiMotionMasterVirtualDevice(this@launchPiDebugServer, axes) - val server = aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().bind("localhost", port) - println("Started virtual port server at ${server.localAddress}") + aSocket(ActorSelectorManager(Dispatchers.IO)).tcp().bind("localhost", port).use { server -> + println("Started virtual port server at ${server.localAddress}") - while (isActive) { - val socket = server.accept() - launch(SupervisorJob(coroutineContext[Job])) { - println("Socket accepted: ${socket.remoteAddress}") - val input = socket.openReadChannel() - val output = socket.openWriteChannel() + while (isActive) { + val socket = server.accept() + launch(SupervisorJob(coroutineContext[Job])) { + println("Socket accepted: ${socket.remoteAddress}") + val input = socket.openReadChannel() + val output = socket.openWriteChannel() - val sendJob = launch { - virtualDevice.receiving().collect { + val sendJob = virtualDevice.subscribe().onEach { //println("Sending: ${it.decodeToString()}") output.writeAvailable(it) output.flush() - } - } + }.launchIn(this) - try { - while (isActive) { - input.read { buffer -> - val array = buffer.moveToByteArray() - launch { - virtualDevice.send(array) + + try { + while (isActive) { + input.read { buffer -> + val array = buffer.moveToByteArray() + launch { + virtualDevice.send(array) + } } } + } catch (e: Throwable) { + e.printStackTrace() + sendJob.cancel() + socket.close() + } finally { + println("Client socket closed") } - } catch (e: Throwable) { - e.printStackTrace() - sendJob.cancel() - socket.close() - } finally { - println("Socket closed") } - } } } diff --git a/demo/notebooks/constructor.ipynb b/demo/notebooks/constructor.ipynb new file mode 100644 index 0000000..3b4d73e --- /dev/null +++ b/demo/notebooks/constructor.ipynb @@ -0,0 +1,195 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "//import space.kscience.controls.jupyter.ControlsJupyter\n", + "\n", + "//USE(ControlsJupyter())\n", + "USE{\n", + " repositories{\n", + " maven(\"https://repo.kotlin.link\")\n", + " }\n", + " dependencies{\n", + " implementation(\"space.kscience:controls-jupyter-jvm:0.3.0-dev-2\")\n", + " }\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "class LinearDrive(\n", + " context: Context,\n", + " state: DoubleRangeState,\n", + " mass: Double,\n", + " pidParameters: PidParameters,\n", + " meta: Meta = Meta.EMPTY,\n", + ") : DeviceConstructor(context.request(DeviceManager), meta) {\n", + "\n", + " val drive by device(VirtualDrive.factory(mass, state))\n", + " val pid by device(PidRegulator(drive, pidParameters))\n", + "\n", + " val start by device(LimitSwitch.factory(state.atStartState))\n", + " val end by device(LimitSwitch.factory(state.atEndState))\n", + "\n", + "\n", + " val position by property(state)\n", + " var target by mutableProperty(pid.mutablePropertyAsState(Regulator.target, 0.0))\n", + "}\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "import kotlin.time.Duration.Companion.milliseconds\n", + "import kotlin.time.Duration.Companion.seconds\n", + "\n", + "val state = DoubleRangeState(0.0, -5.0..5.0)\n", + "\n", + "val pidParameters = PidParameters(\n", + " kp = 2.5,\n", + " ki = 0.0,\n", + " kd = -0.1,\n", + " timeStep = 0.005.seconds\n", + ")\n", + "\n", + "val device = context.install(\"device\", LinearDrive(context, state, 0.005, pidParameters))" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "\n", + "val job = device.run {\n", + " val clock = context.clock\n", + " val clockStart = clock.now()\n", + " doRecurring(10.milliseconds) {\n", + " val timeFromStart = clock.now() - clockStart\n", + " val t = timeFromStart.toDouble(DurationUnit.SECONDS)\n", + " val freq = 0.1\n", + "\n", + " target = 5 * sin(2.0 * PI * freq * t) +\n", + " sin(2 * PI * 21 * freq * t + 0.02 * (timeFromStart / pidParameters.timeStep))\n", + " }\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "val maxAge = 10.seconds\n", + "\n", + "\n", + "VisionForge.fragment {\n", + " vision {\n", + " plotly {\n", + " \n", + " plotDeviceProperty(device.pid, Regulator.target.name, maxAge = maxAge) {\n", + " name = \"target\"\n", + " }\n", + " \n", + " plotNumberState(context, state, maxAge = maxAge) {\n", + " name = \"real position\"\n", + " }\n", + " \n", + " plotDeviceProperty(device.pid, Regulator.position.name, maxAge = maxAge) {\n", + " name = \"read position\"\n", + " }\n", + " }\n", + " }\n", + "\n", + " vision {\n", + " plotly {\n", + " plotDeviceProperty(device.start, LimitSwitch.locked.name, maxAge = maxAge) {\n", + " name = \"start measured\"\n", + " mode = ScatterMode.markers\n", + " }\n", + " plotDeviceProperty(device.end, LimitSwitch.locked.name, maxAge = maxAge) {\n", + " name = \"end measured\"\n", + " mode = ScatterMode.markers\n", + " }\n", + " }\n", + " }\n", + "}" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [ + "import kotlinx.coroutines.cancel\n", + "\n", + "job.cancel()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": { + "collapsed": false + }, + "outputs": [], + "source": [] + }, + { + "cell_type": "code", + "execution_count": null, + "outputs": [], + "source": [], + "metadata": { + "collapsed": false + } + } + ], + "metadata": { + "kernelspec": { + "display_name": "Kotlin", + "language": "kotlin", + "name": "kotlin" + }, + "ktnbPluginMetadata": { + "projectDependencies": [ + "controls-kt.controls-jupyter.jvmMain" + ] + }, + "language_info": { + "codemirror_mode": "text/x-kotlin", + "file_extension": ".kt", + "mimetype": "text/x-kotlin", + "name": "kotlin", + "nbconvert_exporter": "", + "pygments_lexer": "kotlin", + "version": "1.8.20" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/docs/templates/ARTIFACT-TEMPLATE.md b/docs/templates/ARTIFACT-TEMPLATE.md deleted file mode 100644 index a3e47e6..0000000 --- a/docs/templates/ARTIFACT-TEMPLATE.md +++ /dev/null @@ -1,30 +0,0 @@ -## Artifact: - -The Maven coordinates of this project are `${group}:${name}:${version}`. - -**Gradle:** -```groovy -repositories { - maven { url 'https://repo.kotlin.link' } - mavenCentral() - // development and snapshot versions - maven { url 'https://maven.pkg.jetbrains.space/spc/p/sci/dev' } -} - -dependencies { - implementation '${group}:${name}:${version}' -} -``` -**Gradle Kotlin DSL:** -```kotlin -repositories { - maven("https://repo.kotlin.link") - mavenCentral() - // development and snapshot versions - maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") -} - -dependencies { - implementation("${group}:${name}:${version}") -} -``` \ No newline at end of file diff --git a/docs/templates/README-TEMPLATE.md b/docs/templates/README-TEMPLATE.md index 11e0905..ef2cb74 100644 --- a/docs/templates/README-TEMPLATE.md +++ b/docs/templates/README-TEMPLATE.md @@ -1,5 +1,7 @@ [](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub) +[](https://maven.sciprog.center/) + # Controls.kt Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing. diff --git a/gradle.properties b/gradle.properties index 5b956f1..99c39d0 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,10 +4,7 @@ kotlin.native.ignoreDisabledTargets=true org.gradle.parallel=true -publishing.github=false -publishing.sonatype=false - org.gradle.configureondemand=true org.gradle.jvmargs=-Xmx4096m -toolsVersion=0.14.10-kotlin-1.9.0 \ No newline at end of file +toolsVersion=0.15.4-kotlin-2.0.0 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..daf1fd6 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,91 @@ +[versions] + +dataforge = "0.9.0" +rsocket = "0.15.4" +xodus = "2.0.1" + +uuid = "0.8.0" + +fazecast = "2.10.3" + +tornadofx = "1.7.20" + +plotlykt = "0.7.2" + +logback = "1.2.11" + +hivemq = "1.3.1" + +rabbitmq = "5.14.2" + +kmongo = "4.5.1" + +j2mod = "3.2.1" + +milo = "0.6.12" + +pi4j = "2.3.0" +pi4j-ktx = "2.4.0" + +plc4j = "0.12.0" + +visionforge = "0.4.2" + +[libraries] + +dataforge-io = { module = "space.kscience:dataforge-io", version.ref = "dataforge" } +dataforge-meta = { module = "space.kscience:dataforge-meta", version.ref = "dataforge" } + +uuid = { module = "com.benasher44:uuid", version.ref = "uuid" } + +xodus-entity-store = { module = "org.jetbrains.xodus:xodus-entity-store", version.ref = "xodus" } +xodus-environment = { module = "org.jetbrains.xodus:xodus-environment", version.ref = "xodus" } +xodus-vfs = { module = "org.jetbrains.xodus:xodus-vfs", version.ref = "xodus" } + +rsocket-ktor-client = { module = "io.rsocket.kotlin:rsocket-ktor-client", version.ref = "rsocket" } +rsocket-ktor-server = { module = "io.rsocket.kotlin:rsocket-ktor-server", version.ref = "rsocket" } +rsocket-transport-ktor-tcp = { module = "io.rsocket.kotlin:rsocket-transport-ktor-tcp", version.ref = "rsocket" } + +jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "fazecast" } + +tornadofx = { module = "no.tornado:tornadofx", version.ref = "tornadofx" } + +plotlykt-server = { module = "space.kscience:plotlykt-server", version.ref = "plotlykt" } + +logback-classic = { module = "ch.qos.logback:logback-classic", version.ref = "logback" } + +hivemq-mqtt-client = { module = "com.hivemq:hivemq-mqtt-client", version.ref = "hivemq" } + +rabbitmq-amqp-client = { module = "com.rabbitmq:amqp-client", version.ref = "rabbitmq" } + +j2mod = { module = "com.ghgande:j2mod", version.ref = "j2mod" } + +kmongo-coroutine-serialization = { module = "org.litote.kmongo:kmongo-coroutine-serialization", version.ref = "kmongo" } + +milo-client = { module = "org.eclipse.milo:sdk-client", version.ref = "milo" } +milo-parser = { module = "org.eclipse.milo:bsd-parser", version.ref = "milo" } +milo-server = { module = "org.eclipse.milo:sdk-server", version.ref = "milo" } + +pi4j-ktx = { module = "com.pi4j:pi4j-ktx", version.ref = "pi4j-ktx" } +pi4j-core = { module = "com.pi4j:pi4j-core", version.ref = "pi4j" } +pi4j-plugin-raspberrypi = { module = "com.pi4j:pi4j-plugin-raspberrypi", version.ref = "pi4j" } +pi4j-plugin-pigpio = { module = "com.pi4j:pi4j-plugin-pigpio", version.ref = "pi4j" } + +plc4j-spi = { module = "org.apache.plc4x:plc4j-spi", version.ref = "plc4j" } + +visionforge-jupiter = { module = "space.kscience:visionforge-jupyter", version.ref = "visionforge" } +visionforge-plotly = { module = "space.kscience:visionforge-plotly", version.ref = "visionforge" } +visionforge-markdown = { module = "space.kscience:visionforge-markdown", version.ref = "visionforge" } +visionforge-server = { module = "space.kscience:visionforge-server", version.ref = "visionforge" } +visionforge-compose-html = { module = "space.kscience:visionforge-compose-html", version.ref = "visionforge" } + +sciprog-maps-compose = { module = "space.kscience:maps-kt-compose", version = "0.3.0" } + +koala-plots = { module = "io.github.koalaplot:koalaplot-core", version = "0.6.1" } + +# Buildscript + +[plugins] + +versions = "com.github.ben-manes.versions:0.51.0" +versions-update = "nl.littlerobots.version-catalog-update:0.8.4" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index fae0804..81aa1c0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradle/yarn.lock b/gradle/yarn.lock new file mode 100644 index 0000000..d909f26 --- /dev/null +++ b/gradle/yarn.lock @@ -0,0 +1,2042 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@colors/colors@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" + integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== + +"@discoveryjs/json-ext@^0.5.0": + version "0.5.7" + resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" + integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== + +"@jridgewell/gen-mapping@^0.3.0": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz#dcce6aff74bdf6dad1a95802b69b04a2fcb1fb36" + integrity sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg== + dependencies: + "@jridgewell/set-array" "^1.2.1" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/trace-mapping" "^0.3.24" + +"@jridgewell/resolve-uri@^3.1.0": + version "3.1.2" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz#7a0ee601f60f99a20c7c7c5ff0c80388c1189bd6" + integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== + +"@jridgewell/set-array@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.2.1.tgz#558fb6472ed16a4c850b889530e6b36438c49280" + integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== + +"@jridgewell/source-map@^0.3.3": + version "0.3.5" + resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.5.tgz#a3bb4d5c6825aab0d281268f47f6ad5853431e91" + integrity sha512-UTYAUj/wviwdsMfzoSJspJxbkH5o1snzwX0//0ENX1u/55kkZZkcTZP6u9bwKGkv+dkk9at4m1Cpt0uY80kcpQ== + dependencies: + "@jridgewell/gen-mapping" "^0.3.0" + "@jridgewell/trace-mapping" "^0.3.9" + +"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14": + version "1.4.15" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz#d7c6e6755c78567a951e04ab52ef0fd26de59f32" + integrity sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg== + +"@jridgewell/trace-mapping@^0.3.20", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.9": + version "0.3.25" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz#15f190e98895f3fc23276ee14bc76b675c2e50f0" + integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + +"@js-joda/core@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273" + integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg== + +"@socket.io/component-emitter@~3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz#96116f2a912e0c02817345b3c10751069920d553" + integrity sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg== + +"@types/cookie@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d" + integrity sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q== + +"@types/cors@^2.8.12": + version "2.8.17" + resolved "https://registry.yarnpkg.com/@types/cors/-/cors-2.8.17.tgz#5d718a5e494a8166f569d986794e49c48b216b2b" + integrity sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA== + dependencies: + "@types/node" "*" + +"@types/eslint-scope@^3.7.3": + version "3.7.7" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" + integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + +"@types/eslint@*": + version "8.56.5" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-8.56.5.tgz#94b88cab77588fcecdd0771a6d576fa1c0af9d02" + integrity sha512-u5/YPJHo1tvkSF2CE0USEkxon82Z5DBy2xR+qfyYNszpX9qcs4sT6uq2kBbj4BXY1+DBGDPnrhMZV3pKWGNukw== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*", "@types/estree@^1.0.0": + version "1.0.5" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.5.tgz#a6ce3e556e00fd9895dd872dd172ad0d4bd687f4" + integrity sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw== + +"@types/json-schema@*", "@types/json-schema@^7.0.8": + version "7.0.15" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" + integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== + +"@types/node@*", "@types/node@>=10.0.0": + version "20.11.24" + resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.24.tgz#cc207511104694e84e9fb17f9a0c4c42d4517792" + integrity sha512-Kza43ewS3xoLgCEpQrsT+xRo/EJej1y0kVYGiLFE1NEODXGzTfwiC6tXTLMQskn1X4/Rjlh0MQUvx9W+L9long== + dependencies: + undici-types "~5.26.4" + +"@webassemblyjs/ast@1.11.6", "@webassemblyjs/ast@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.11.6.tgz#db046555d3c413f8966ca50a95176a0e2c642e24" + integrity sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q== + dependencies: + "@webassemblyjs/helper-numbers" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + +"@webassemblyjs/floating-point-hex-parser@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" + integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== + +"@webassemblyjs/helper-api-error@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" + integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== + +"@webassemblyjs/helper-buffer@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz#b66d73c43e296fd5e88006f18524feb0f2c7c093" + integrity sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA== + +"@webassemblyjs/helper-numbers@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" + integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g== + dependencies: + "@webassemblyjs/floating-point-hex-parser" "1.11.6" + "@webassemblyjs/helper-api-error" "1.11.6" + "@xtuc/long" "4.2.2" + +"@webassemblyjs/helper-wasm-bytecode@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" + integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== + +"@webassemblyjs/helper-wasm-section@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz#ff97f3863c55ee7f580fd5c41a381e9def4aa577" + integrity sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" + +"@webassemblyjs/ieee754@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" + integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg== + dependencies: + "@xtuc/ieee754" "^1.2.0" + +"@webassemblyjs/leb128@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" + integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ== + dependencies: + "@xtuc/long" "4.2.2" + +"@webassemblyjs/utf8@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" + integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== + +"@webassemblyjs/wasm-edit@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz#c72fa8220524c9b416249f3d94c2958dfe70ceab" + integrity sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/helper-wasm-section" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" + "@webassemblyjs/wasm-opt" "1.11.6" + "@webassemblyjs/wasm-parser" "1.11.6" + "@webassemblyjs/wast-printer" "1.11.6" + +"@webassemblyjs/wasm-gen@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz#fb5283e0e8b4551cc4e9c3c0d7184a65faf7c268" + integrity sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/ieee754" "1.11.6" + "@webassemblyjs/leb128" "1.11.6" + "@webassemblyjs/utf8" "1.11.6" + +"@webassemblyjs/wasm-opt@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz#d9a22d651248422ca498b09aa3232a81041487c2" + integrity sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-buffer" "1.11.6" + "@webassemblyjs/wasm-gen" "1.11.6" + "@webassemblyjs/wasm-parser" "1.11.6" + +"@webassemblyjs/wasm-parser@1.11.6", "@webassemblyjs/wasm-parser@^1.11.5": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz#bb85378c527df824004812bbdb784eea539174a1" + integrity sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@webassemblyjs/helper-api-error" "1.11.6" + "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/ieee754" "1.11.6" + "@webassemblyjs/leb128" "1.11.6" + "@webassemblyjs/utf8" "1.11.6" + +"@webassemblyjs/wast-printer@1.11.6": + version "1.11.6" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz#a7bf8dd7e362aeb1668ff43f35cb849f188eff20" + integrity sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A== + dependencies: + "@webassemblyjs/ast" "1.11.6" + "@xtuc/long" "4.2.2" + +"@webpack-cli/configtest@^2.1.0": + version "2.1.1" + resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-2.1.1.tgz#3b2f852e91dac6e3b85fb2a314fb8bef46d94646" + integrity sha512-wy0mglZpDSiSS0XHrVR+BAdId2+yxPSoJW8fsna3ZpYSlufjvxnP4YbKTCBZnNIcGN4r6ZPXV55X4mYExOfLmw== + +"@webpack-cli/info@^2.0.1": + version "2.0.2" + resolved "https://registry.yarnpkg.com/@webpack-cli/info/-/info-2.0.2.tgz#cc3fbf22efeb88ff62310cf885c5b09f44ae0fdd" + integrity sha512-zLHQdI/Qs1UyT5UBdWNqsARasIA+AaF8t+4u2aS2nEpBQh2mWIVb8qAklq0eUENnC5mOItrIB4LiS9xMtph18A== + +"@webpack-cli/serve@^2.0.3": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@webpack-cli/serve/-/serve-2.0.5.tgz#325db42395cd49fe6c14057f9a900e427df8810e" + integrity sha512-lqaoKnRYBdo1UgDX8uF24AfGMifWK19TxPmM5FHc2vAGxrJ/qtyUyFBWoY1tISZdelsQ5fBcOusifo5o5wSJxQ== + +"@xtuc/ieee754@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" + integrity sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA== + +"@xtuc/long@4.2.2": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@xtuc/long/-/long-4.2.2.tgz#d291c6a4e97989b5c61d9acf396ae4fe133a718d" + integrity sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ== + +abab@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" + integrity sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA== + +abort-controller@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/abort-controller/-/abort-controller-3.0.0.tgz#eaf54d53b62bae4138e809ca225c8439a6efb392" + integrity sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg== + dependencies: + event-target-shim "^5.0.0" + +accepts@~1.3.4: + version "1.3.8" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + +acorn-import-assertions@^1.7.6: + version "1.9.0" + resolved "https://registry.yarnpkg.com/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz#507276249d684797c84e0734ef84860334cfb1ac" + integrity sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA== + +acorn@^8.7.1, acorn@^8.8.2: + version "8.11.3" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" + integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== + +ajv-keywords@^3.5.2: + version "3.5.2" + resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" + integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== + +ajv@^6.12.5: + version "6.12.6" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" + integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== + dependencies: + fast-deep-equal "^3.1.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + +ansi-colors@4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" + integrity sha512-JoX0apGbHaUJBNl6yF+p6JAFYZ666/hhCGKN5t9QFjbJQKUU/g8MNbFDbvfrgKXvI1QpZplPOnwIo99lX/AAmA== + +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937" + integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== + dependencies: + color-convert "^2.0.1" + +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + +argparse@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" + integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== + +balanced-match@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" + integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== + +base64id@2.0.0, base64id@~2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/base64id/-/base64id-2.0.0.tgz#2770ac6bc47d312af97a8bf9a634342e0cd25cb6" + integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== + +binary-extensions@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + +body-parser@^1.19.0: + version "1.20.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.2.tgz#6feb0e21c4724d06de7ff38da36dad4f57a747fd" + integrity sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA== + dependencies: + bytes "3.1.2" + content-type "~1.0.5" + debug "2.6.9" + depd "2.0.0" + destroy "1.2.0" + http-errors "2.0.0" + iconv-lite "0.4.24" + on-finished "2.4.1" + qs "6.11.0" + raw-body "2.5.2" + type-is "~1.6.18" + unpipe "1.0.0" + +brace-expansion@^1.1.7: + version "1.1.11" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd" + integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + dependencies: + balanced-match "^1.0.0" + concat-map "0.0.1" + +brace-expansion@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" + integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + dependencies: + balanced-match "^1.0.0" + +braces@^3.0.2, braces@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" + integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== + dependencies: + fill-range "^7.0.1" + +browser-stdout@1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/browser-stdout/-/browser-stdout-1.3.1.tgz#baa559ee14ced73452229bad7326467c61fabd60" + integrity sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw== + +browserslist@^4.14.5: + version "4.23.0" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.23.0.tgz#8f3acc2bbe73af7213399430890f86c63a5674ab" + integrity sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ== + dependencies: + caniuse-lite "^1.0.30001587" + electron-to-chromium "^1.4.668" + node-releases "^2.0.14" + update-browserslist-db "^1.0.13" + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +bytes@3.1.2: + version "3.1.2" + resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" + integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== + +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + +camelcase@^6.0.0: + version "6.3.0" + resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-6.3.0.tgz#5685b95eb209ac9c0c177467778c9c84df58ba9a" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +caniuse-lite@^1.0.30001587: + version "1.0.30001594" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001594.tgz#bea552414cd52c2d0c985ed9206314a696e685f5" + integrity sha512-VblSX6nYqyJVs8DKFMldE2IVCJjZ225LW00ydtUWwh5hk9IfkTOffO6r8gJNsH0qqqeAF8KrbMYA2VEwTlGW5g== + +chalk@^4.1.0: + version "4.1.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chokidar@3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chokidar@^3.5.1: + version "3.6.0" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" + integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + +chrome-trace-event@^1.0.2: + version "1.0.3" + resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" + integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== + +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + +clone-deep@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" + integrity sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ== + dependencies: + is-plain-object "^2.0.4" + kind-of "^6.0.2" + shallow-clone "^3.0.0" + +color-convert@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3" + integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ== + dependencies: + color-name "~1.1.4" + +color-name@~1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" + integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== + +colorette@^2.0.14: + version "2.0.20" + resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" + integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== + +commander@^10.0.1: + version "10.0.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" + integrity sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug== + +commander@^2.20.0: + version "2.20.3" + resolved "https://registry.yarnpkg.com/commander/-/commander-2.20.3.tgz#fd485e84c03eb4881c20722ba48035e8531aeb33" + integrity sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ== + +concat-map@0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" + integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== + +connect@^3.7.0: + version "3.7.0" + resolved "https://registry.yarnpkg.com/connect/-/connect-3.7.0.tgz#5d49348910caa5e07a01800b030d0c35f20484f8" + integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== + dependencies: + debug "2.6.9" + finalhandler "1.1.2" + parseurl "~1.3.3" + utils-merge "1.0.1" + +content-type@~1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" + integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== + +cookie@~0.4.1: + version "0.4.2" + resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.2.tgz#0e41f24de5ecf317947c82fc789e06a884824432" + integrity sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA== + +cors@~2.8.5: + version "2.8.5" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" + integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== + dependencies: + object-assign "^4" + vary "^1" + +cross-spawn@^7.0.3: + version "7.0.3" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" + integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== + dependencies: + path-key "^3.1.0" + shebang-command "^2.0.0" + which "^2.0.1" + +custom-event@~1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/custom-event/-/custom-event-1.0.1.tgz#5d02a46850adf1b4a317946a3928fccb5bfd0425" + integrity sha512-GAj5FOq0Hd+RsCGVJxZuKaIDXDf3h6GQoNEjFgbLLI/trgtavwUbSnZ5pVfg27DVCaWjIohryS0JFwIJyT2cMg== + +date-format@^4.0.14: + version "4.0.14" + resolved "https://registry.yarnpkg.com/date-format/-/date-format-4.0.14.tgz#7a8e584434fb169a521c8b7aa481f355810d9400" + integrity sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg== + +debug@2.6.9: + version "2.6.9" + resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4.3.4, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + +decamelize@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-4.0.0.tgz#aa472d7bf660eb15f3494efd531cab7f2a709837" + integrity sha512-9iE1PgSik9HeIIw2JO94IidnE3eBoQrFJ3w7sFuzSX4DpmZ3v5sZpUiV5Swcf6mQEF+Y0ru8Neo+p+nyh2J+hQ== + +define-data-property@^1.1.2: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + +depd@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" + integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== + +destroy@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" + integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== + +di@^0.0.1: + version "0.0.1" + resolved "https://registry.yarnpkg.com/di/-/di-0.0.1.tgz#806649326ceaa7caa3306d75d985ea2748ba913c" + integrity sha512-uJaamHkagcZtHPqCIHZxnFrXlunQXgBOsZSUOWwFw31QJCAbyTBoHMW75YOTur5ZNx8pIeAKgf6GWIgaqqiLhA== + +diff@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b" + integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w== + +dom-serialize@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/dom-serialize/-/dom-serialize-2.2.1.tgz#562ae8999f44be5ea3076f5419dcd59eb43ac95b" + integrity sha512-Yra4DbvoW7/Z6LBN560ZwXMjoNOSAN2wRsKFGc4iBeso+mpIA6qj1vfdf9HpMaKAqG6wXTy+1SYEzmNpKXOSsQ== + dependencies: + custom-event "~1.0.0" + ent "~2.2.0" + extend "^3.0.0" + void-elements "^2.0.0" + +ee-first@1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" + integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== + +electron-to-chromium@^1.4.668: + version "1.4.692" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.692.tgz#82139d20585a4b2318a02066af7593a3e6bec993" + integrity sha512-d5rZRka9n2Y3MkWRN74IoAsxR0HK3yaAt7T50e3iT9VZmCCQDT3geXUO5ZRMhDToa1pkCeQXuNo+0g+NfDOVPA== + +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +encodeurl@~1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" + integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== + +engine.io-parser@~5.2.1: + version "5.2.2" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49" + integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw== + +engine.io@~6.5.2: + version "6.5.4" + resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.4.tgz#6822debf324e781add2254e912f8568508850cdc" + integrity sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg== + dependencies: + "@types/cookie" "^0.4.1" + "@types/cors" "^2.8.12" + "@types/node" ">=10.0.0" + accepts "~1.3.4" + base64id "2.0.0" + cookie "~0.4.1" + cors "~2.8.5" + debug "~4.3.1" + engine.io-parser "~5.2.1" + ws "~8.11.0" + +enhanced-resolve@^5.13.0: + version "5.15.1" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.15.1.tgz#384391e025f099e67b4b00bfd7f0906a408214e1" + integrity sha512-3d3JRbwsCLJsYgvb6NuWEG44jjPSOMuS73L/6+7BZuoKm3W+qXnSoIYVHi8dG7Qcg4inAY4jbzkZ7MnskePeDg== + dependencies: + graceful-fs "^4.2.4" + tapable "^2.2.0" + +ent@~2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/ent/-/ent-2.2.0.tgz#e964219325a21d05f44466a2f686ed6ce5f5dd1d" + integrity sha512-GHrMyVZQWvTIdDtpiEXdHZnFQKzeO09apj8Cbl4pKWy4i0Oprcq17usfDt5aO63swf0JOeMWjWQE/LzgSRuWpA== + +envinfo@^7.7.3: + version "7.11.1" + resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.11.1.tgz#2ffef77591057081b0129a8fd8cf6118da1b94e1" + integrity sha512-8PiZgZNIB4q/Lw4AhOvAfB/ityHAd2bli3lESSWmWSzSsl5dKpy5N1d1Rfkd2teq/g9xN90lc6o98DOjMeYHpg== + +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + +es-module-lexer@^1.2.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.4.1.tgz#41ea21b43908fe6a287ffcbe4300f790555331f5" + integrity sha512-cXLGjP0c4T3flZJKQSuziYoq7MlT+rnvfZjfp7h+I7K9BNX54kP9nyWvdbwjQ4u1iWbOL4u96fgeZLToQlZC7w== + +escalade@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.2.tgz#54076e9ab29ea5bf3d8f1ed62acffbb88272df27" + integrity sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA== + +escape-html@~1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" + integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== + +escape-string-regexp@4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34" + integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA== + +eslint-scope@5.1.1: + version "5.1.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-5.1.1.tgz#e786e59a66cb92b3f6c1fb0d508aab174848f48c" + integrity sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw== + dependencies: + esrecurse "^4.3.0" + estraverse "^4.1.1" + +esrecurse@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921" + integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag== + dependencies: + estraverse "^5.2.0" + +estraverse@^4.1.1: + version "4.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-4.3.0.tgz#398ad3f3c5a24948be7725e83d11a7de28cdbd1d" + integrity sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw== + +estraverse@^5.2.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123" + integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA== + +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + +eventemitter3@^4.0.0: + version "4.0.7" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" + integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== + +events@^3.2.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" + integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== + +extend@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== + +fast-deep-equal@^3.1.1: + version "3.1.3" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525" + integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== + +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fastest-levenshtein@^1.0.12: + version "1.0.16" + resolved "https://registry.yarnpkg.com/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz#210e61b6ff181de91ea9b3d1b84fdedd47e034e5" + integrity sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg== + +fill-range@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" + integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== + dependencies: + to-regex-range "^5.0.1" + +finalhandler@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" + integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== + dependencies: + debug "2.6.9" + encodeurl "~1.0.2" + escape-html "~1.0.3" + on-finished "~2.3.0" + parseurl "~1.3.3" + statuses "~1.5.0" + unpipe "~1.0.0" + +find-up@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" + integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== + dependencies: + locate-path "^6.0.0" + path-exists "^4.0.0" + +find-up@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/find-up/-/find-up-4.1.0.tgz#97afe7d6cdc0bc5928584b7c8d7b16e8a9aa5d19" + integrity sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw== + dependencies: + locate-path "^5.0.0" + path-exists "^4.0.0" + +flat@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/flat/-/flat-5.0.2.tgz#8ca6fe332069ffa9d324c327198c598259ceb241" + integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== + +flatted@^3.2.7: + version "3.3.1" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.1.tgz#21db470729a6734d4997002f439cb308987f567a" + integrity sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw== + +follow-redirects@^1.0.0: + version "1.15.5" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.5.tgz#54d4d6d062c0fa7d9d17feb008461550e3ba8020" + integrity sha512-vSFWUON1B+yAw1VN4xMfxgn5fTUiaOzAJCKBwIIgT/+7CuGy9+r+5gITvP62j3RmaD5Ph65UaERdOSRGUzZtgw== + +format-util@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/format-util/-/format-util-1.0.5.tgz#1ffb450c8a03e7bccffe40643180918cc297d271" + integrity sha512-varLbTj0e0yVyRpqQhuWV+8hlePAgaoFRhNFj50BNjEIrw1/DphHSObtqwskVCPWNgzwPoQrZAbfa/SBiicNeg== + +fs-extra@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + +fs.realpath@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" + integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== + +fsevents@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +function-bind@^1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.2.tgz#2c02d864d97f3ea6c8830c464cbd11ab6eab7a1c" + integrity sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA== + +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + +get-intrinsic@^1.1.3, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob-to-regexp@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" + integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== + +glob@7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023" + integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.1.3, glob@^7.1.7: + version "7.2.3" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.3.tgz#b8df0fb802bbfa8e89bd1d938b4e16578ed44f2b" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +gopd@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.0.1.tgz#29ff76de69dac7489b7c0918a5788e56477c332c" + integrity sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA== + dependencies: + get-intrinsic "^1.1.3" + +graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.10, graceful-fs@^4.2.4, graceful-fs@^4.2.6, graceful-fs@^4.2.9: + version "4.2.11" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +has-flag@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b" + integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ== + +has-property-descriptors@^1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + +has-proto@^1.0.1: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" + integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== + +has-symbols@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" + integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== + +hasown@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.1.tgz#26f48f039de2c0f8d3356c223fb8d50253519faa" + integrity sha512-1/th4MHjnwncwXsIW6QMzlvYL9kG5e/CpVvLRZe4XPa8TOUNbCELqmvhDmnkNsAjwaG4+I8gJJL0JBvTTLO9qA== + dependencies: + function-bind "^1.1.2" + +he@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" + integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== + +http-errors@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" + integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== + dependencies: + depd "2.0.0" + inherits "2.0.4" + setprototypeof "1.2.0" + statuses "2.0.1" + toidentifier "1.0.1" + +http-proxy@^1.18.1: + version "1.18.1" + resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" + integrity sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ== + dependencies: + eventemitter3 "^4.0.0" + follow-redirects "^1.0.0" + requires-port "^1.0.0" + +iconv-lite@0.4.24: + version "0.4.24" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" + integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== + dependencies: + safer-buffer ">= 2.1.2 < 3" + +iconv-lite@^0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +import-local@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/import-local/-/import-local-3.1.0.tgz#b4479df8a5fd44f6cdce24070675676063c95cb4" + integrity sha512-ASB07uLtnDs1o6EHjKpX34BKYDSqnFerfTOJL2HvMqF70LnxpjkzDB8J44oT9pu4AMPkQwf8jl6szgvNd2tRIg== + dependencies: + pkg-dir "^4.2.0" + resolve-cwd "^3.0.0" + +inflight@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9" + integrity sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA== + dependencies: + once "^1.3.0" + wrappy "1" + +inherits@2, inherits@2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +interpret@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/interpret/-/interpret-3.1.1.tgz#5be0ceed67ca79c6c4bc5cf0d7ee843dcea110c4" + integrity sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ== + +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + +is-core-module@^2.13.0: + version "2.13.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.13.1.tgz#ad0d7532c6fea9da1ebdc82742d74525c6273384" + integrity sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw== + dependencies: + hasown "^2.0.0" + +is-extglob@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" + integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== + +is-fullwidth-code-point@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz#f116f8064fe90b3f7844a38997c0b75051269f1d" + integrity sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg== + +is-glob@^4.0.1, is-glob@~4.0.1: + version "4.0.3" + resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" + integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== + dependencies: + is-extglob "^2.1.1" + +is-number@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" + integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== + +is-plain-obj@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-2.1.0.tgz#45e42e37fccf1f40da8e5f76ee21515840c09287" + integrity sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA== + +is-plain-object@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-2.0.4.tgz#2c163b3fafb1b606d9d17928f05c2a1c38e07677" + integrity sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og== + dependencies: + isobject "^3.0.1" + +is-unicode-supported@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" + integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== + +isbinaryfile@^4.0.8: + version "4.0.10" + resolved "https://registry.yarnpkg.com/isbinaryfile/-/isbinaryfile-4.0.10.tgz#0c5b5e30c2557a2f06febd37b7322946aaee42b3" + integrity sha512-iHrqe5shvBUcFbmZq9zOQHBoeOhZJu6RQGrDpBgenUm/Am+F3JM2MgQj+rK3Z601fzrL5gLZWtAPH2OBaSVcyw== + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +isobject@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df" + integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== + +jest-worker@^27.4.5: + version "27.5.1" + resolved "https://registry.yarnpkg.com/jest-worker/-/jest-worker-27.5.1.tgz#8d146f0900e8973b106b6f73cc1e9a8cb86f8db0" + integrity sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg== + dependencies: + "@types/node" "*" + merge-stream "^2.0.0" + supports-color "^8.0.0" + +js-yaml@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602" + integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA== + dependencies: + argparse "^2.0.1" + +json-parse-even-better-errors@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" + integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg== + +jsonfile@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" + integrity sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg== + optionalDependencies: + graceful-fs "^4.1.6" + +karma-chrome-launcher@3.2.0: + version "3.2.0" + resolved "https://registry.yarnpkg.com/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz#eb9c95024f2d6dfbb3748d3415ac9b381906b9a9" + integrity sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q== + dependencies: + which "^1.2.1" + +karma-mocha@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/karma-mocha/-/karma-mocha-2.0.1.tgz#4b0254a18dfee71bdbe6188d9a6861bf86b0cd7d" + integrity sha512-Tzd5HBjm8his2OA4bouAsATYEpZrp9vC7z5E5j4C5Of5Rrs1jY67RAwXNcVmd/Bnk1wgvQRou0zGVLey44G4tQ== + dependencies: + minimist "^1.2.3" + +karma-sourcemap-loader@0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/karma-sourcemap-loader/-/karma-sourcemap-loader-0.4.0.tgz#b01d73f8f688f533bcc8f5d273d43458e13b5488" + integrity sha512-xCRL3/pmhAYF3I6qOrcn0uhbQevitc2DERMPH82FMnG+4WReoGcGFZb1pURf2a5apyrOHRdvD+O6K7NljqKHyA== + dependencies: + graceful-fs "^4.2.10" + +karma-webpack@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/karma-webpack/-/karma-webpack-5.0.0.tgz#2a2c7b80163fe7ffd1010f83f5507f95ef39f840" + integrity sha512-+54i/cd3/piZuP3dr54+NcFeKOPnys5QeM1IY+0SPASwrtHsliXUiCL50iW+K9WWA7RvamC4macvvQ86l3KtaA== + dependencies: + glob "^7.1.3" + minimatch "^3.0.4" + webpack-merge "^4.1.5" + +karma@6.4.2: + version "6.4.2" + resolved "https://registry.yarnpkg.com/karma/-/karma-6.4.2.tgz#a983f874cee6f35990c4b2dcc3d274653714de8e" + integrity sha512-C6SU/53LB31BEgRg+omznBEMY4SjHU3ricV6zBcAe1EeILKkeScr+fZXtaI5WyDbkVowJxxAI6h73NcFPmXolQ== + dependencies: + "@colors/colors" "1.5.0" + body-parser "^1.19.0" + braces "^3.0.2" + chokidar "^3.5.1" + connect "^3.7.0" + di "^0.0.1" + dom-serialize "^2.2.1" + glob "^7.1.7" + graceful-fs "^4.2.6" + http-proxy "^1.18.1" + isbinaryfile "^4.0.8" + lodash "^4.17.21" + log4js "^6.4.1" + mime "^2.5.2" + minimatch "^3.0.4" + mkdirp "^0.5.5" + qjobs "^1.2.0" + range-parser "^1.2.1" + rimraf "^3.0.2" + socket.io "^4.4.1" + source-map "^0.6.1" + tmp "^0.2.1" + ua-parser-js "^0.7.30" + yargs "^16.1.1" + +kind-of@^6.0.2: + version "6.0.3" + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== + +loader-runner@^4.2.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" + integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== + +locate-path@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" + integrity sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g== + dependencies: + p-locate "^4.1.0" + +locate-path@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-6.0.0.tgz#55321eb309febbc59c4801d931a72452a681d286" + integrity sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw== + dependencies: + p-locate "^5.0.0" + +lodash@^4.17.15, lodash@^4.17.21: + version "4.17.21" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" + integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== + +log-symbols@4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" + integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== + dependencies: + chalk "^4.1.0" + is-unicode-supported "^0.1.0" + +log4js@^6.4.1: + version "6.9.1" + resolved "https://registry.yarnpkg.com/log4js/-/log4js-6.9.1.tgz#aba5a3ff4e7872ae34f8b4c533706753709e38b6" + integrity sha512-1somDdy9sChrr9/f4UlzhdaGfDR2c/SaD2a4T7qEkG4jTS57/B3qmnjLYePwQ8cqWnUHZI0iAKxMBpCZICiZ2g== + dependencies: + date-format "^4.0.14" + debug "^4.3.4" + flatted "^3.2.7" + rfdc "^1.3.0" + streamroller "^3.1.5" + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +merge-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" + integrity sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@^2.1.27, mime-types@~2.1.24, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +mime@^2.5.2: + version "2.6.0" + resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" + integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== + +minimatch@5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b" + integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^3.0.4, minimatch@^3.1.1: + version "3.1.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b" + integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== + dependencies: + brace-expansion "^1.1.7" + +minimist@^1.2.3, minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mkdirp@^0.5.5: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +mocha@10.2.0: + version "10.2.0" + resolved "https://registry.yarnpkg.com/mocha/-/mocha-10.2.0.tgz#1fd4a7c32ba5ac372e03a17eef435bd00e5c68b8" + integrity sha512-IDY7fl/BecMwFHzoqF2sg/SHHANeBoMMXFlS9r0OXKDssYE1M5O43wUY/9BVPeIvfH2zmEbBfseqN9gBQZzXkg== + dependencies: + ansi-colors "4.1.1" + browser-stdout "1.3.1" + chokidar "3.5.3" + debug "4.3.4" + diff "5.0.0" + escape-string-regexp "4.0.0" + find-up "5.0.0" + glob "7.2.0" + he "1.2.0" + js-yaml "4.1.0" + log-symbols "4.1.0" + minimatch "5.0.1" + ms "2.1.3" + nanoid "3.3.3" + serialize-javascript "6.0.0" + strip-json-comments "3.1.1" + supports-color "8.1.1" + workerpool "6.2.1" + yargs "16.2.0" + yargs-parser "20.2.4" + yargs-unparser "2.0.0" + +ms@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" + integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== + +ms@2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" + integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== + +ms@2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + +nanoid@3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" + integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== + +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + +neo-async@^2.6.2: + version "2.6.2" + resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" + integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw== + +node-fetch@2.6.7: + version "2.6.7" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.7.tgz#24de9fba827e3b4ae44dc8b20256a379160052ad" + integrity sha512-ZjMPFEfVx5j+y2yF35Kzx5sF7kDzxuDj6ziH4FFbOp87zKDZNx8yExJIb05OGF4Nlt9IHFIMBkRl41VdvcNdbQ== + dependencies: + whatwg-url "^5.0.0" + +node-releases@^2.0.14: + version "2.0.14" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.14.tgz#2ffb053bceb8b2be8495ece1ab6ce600c4461b0b" + integrity sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw== + +normalize-path@^3.0.0, normalize-path@~3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" + integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== + +object-assign@^4: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +object-inspect@^1.13.1: + version "1.13.1" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" + integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== + +on-finished@2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" + integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== + dependencies: + ee-first "1.1.1" + +on-finished@~2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" + integrity sha512-ikqdkGAAyf/X/gPhXGvfgAytDZtDbr+bkNUJ0N9h5MI/dmdgCs3l6hoHrcUv41sRKew3jIwrp4qQDXiK99Utww== + dependencies: + ee-first "1.1.1" + +once@^1.3.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1" + integrity sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w== + dependencies: + wrappy "1" + +p-limit@^2.2.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-2.3.0.tgz#3dd33c647a214fdfffd835933eb086da0dc21db1" + integrity sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w== + dependencies: + p-try "^2.0.0" + +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-locate@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" + integrity sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A== + dependencies: + p-limit "^2.2.0" + +p-locate@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-5.0.0.tgz#83c8315c6785005e3bd021839411c9e110e6d834" + integrity sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw== + dependencies: + p-limit "^3.0.2" + +p-try@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" + integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== + +parseurl@~1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" + integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== + +path-exists@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" + integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== + +path-is-absolute@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f" + integrity sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg== + +path-key@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" + integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q== + +path-parse@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" + integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== + +picocolors@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" + integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== + +picomatch@^2.0.4, picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + +pkg-dir@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3" + integrity sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ== + dependencies: + find-up "^4.0.0" + +punycode@^2.1.0: + version "2.3.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" + integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== + +qjobs@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/qjobs/-/qjobs-1.2.0.tgz#c45e9c61800bd087ef88d7e256423bdd49e5d071" + integrity sha512-8YOJEHtxpySA3fFDyCRxA+UUV+fA+rTWnuWvylOK/NCjhY+b4ocCtmu8TtsWb+mYeU+GCHf/S66KZF/AsteKHg== + +qs@6.11.0: + version "6.11.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" + integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== + dependencies: + side-channel "^1.0.4" + +randombytes@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" + integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== + dependencies: + safe-buffer "^5.1.0" + +range-parser@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" + integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== + +raw-body@2.5.2: + version "2.5.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.2.tgz#99febd83b90e08975087e8f1f9419a149366b68a" + integrity sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA== + dependencies: + bytes "3.1.2" + http-errors "2.0.0" + iconv-lite "0.4.24" + unpipe "1.0.0" + +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + +rechoir@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/rechoir/-/rechoir-0.8.0.tgz#49f866e0d32146142da3ad8f0eff352b3215ff22" + integrity sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ== + dependencies: + resolve "^1.20.0" + +require-directory@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/require-directory/-/require-directory-2.1.1.tgz#8c64ad5fd30dab1c976e2344ffe7f792a6a6df42" + integrity sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q== + +requires-port@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" + integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== + +resolve-cwd@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-3.0.0.tgz#0f0075f1bb2544766cf73ba6a6e2adfebcb13f2d" + integrity sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg== + dependencies: + resolve-from "^5.0.0" + +resolve-from@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-5.0.0.tgz#c35225843df8f776df21c57557bc087e9dfdfc69" + integrity sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw== + +resolve@^1.20.0: + version "1.22.8" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" + integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== + dependencies: + is-core-module "^2.13.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + +rfdc@^1.3.0: + version "1.3.1" + resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.3.1.tgz#2b6d4df52dffe8bb346992a10ea9451f24373a8f" + integrity sha512-r5a3l5HzYlIC68TpmYKlxWjmOP6wiPJ1vWv2HeLhNsRZMrCkxeqxiHlQ21oXmQ4F3SiryXBHhAD7JZqvOJjFmg== + +rimraf@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a" + integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA== + dependencies: + glob "^7.1.3" + +safe-buffer@^5.1.0: + version "5.2.1" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" + integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== + +"safer-buffer@>= 2.1.2 < 3", "safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + +schema-utils@^3.1.1, schema-utils@^3.1.2: + version "3.3.0" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" + integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== + dependencies: + "@types/json-schema" "^7.0.8" + ajv "^6.12.5" + ajv-keywords "^3.5.2" + +serialize-javascript@6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.0.tgz#efae5d88f45d7924141da8b5c3a7a7e663fefeb8" + integrity sha512-Qr3TosvguFt8ePWqsvRfrKyQXIiW+nGbYpy8XK24NQHE83caxWt+mIymTT19DGFbNWNLfEwsrkSmN64lVWB9ag== + dependencies: + randombytes "^2.1.0" + +serialize-javascript@^6.0.1: + version "6.0.2" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" + integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== + dependencies: + randombytes "^2.1.0" + +set-function-length@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.1.tgz#47cc5945f2c771e2cf261c6737cf9684a2a5e425" + integrity sha512-j4t6ccc+VsKwYHso+kElc5neZpjtq9EnRICFZtWyBsLojhmeF/ZBd/elqm22WJh/BziDe/SBiOeAt0m2mfLD0g== + dependencies: + define-data-property "^1.1.2" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.3" + gopd "^1.0.1" + has-property-descriptors "^1.0.1" + +setprototypeof@1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" + integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== + +shallow-clone@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/shallow-clone/-/shallow-clone-3.0.1.tgz#8f2981ad92531f55035b01fb230769a40e02efa3" + integrity sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA== + dependencies: + kind-of "^6.0.2" + +shebang-command@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" + integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA== + dependencies: + shebang-regex "^3.0.0" + +shebang-regex@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" + integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== + +side-channel@^1.0.4: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + +socket.io-adapter@~2.5.2: + version "2.5.4" + resolved "https://registry.yarnpkg.com/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz#4fdb1358667f6d68f25343353bd99bd11ee41006" + integrity sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg== + dependencies: + debug "~4.3.4" + ws "~8.11.0" + +socket.io-parser@~4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83" + integrity sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew== + dependencies: + "@socket.io/component-emitter" "~3.1.0" + debug "~4.3.1" + +socket.io@^4.4.1: + version "4.7.4" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.4.tgz#2401a2d7101e4bdc64da80b140d5d8b6a8c7738b" + integrity sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw== + dependencies: + accepts "~1.3.4" + base64id "~2.0.0" + cors "~2.8.5" + debug "~4.3.2" + engine.io "~6.5.2" + socket.io-adapter "~2.5.2" + socket.io-parser "~4.2.4" + +source-map-js@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" + integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== + +source-map-loader@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/source-map-loader/-/source-map-loader-4.0.1.tgz#72f00d05f5d1f90f80974eda781cbd7107c125f2" + integrity sha512-oqXpzDIByKONVY8g1NUPOTQhe0UTU5bWUl32GSkqK2LjJj0HmwTMVKxcUip0RgAYhY1mqgOxjbQM48a0mmeNfA== + dependencies: + abab "^2.0.6" + iconv-lite "^0.6.3" + source-map-js "^1.0.2" + +source-map-support@~0.5.20: + version "0.5.21" + resolved "https://registry.yarnpkg.com/source-map-support/-/source-map-support-0.5.21.tgz#04fe7c7f9e1ed2d662233c28cb2b35b9f63f6e4f" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + +source-map@^0.6.0, source-map@^0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +statuses@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" + integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== + +statuses@~1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== + +streamroller@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/streamroller/-/streamroller-3.1.5.tgz#1263182329a45def1ffaef58d31b15d13d2ee7ff" + integrity sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw== + dependencies: + date-format "^4.0.14" + debug "^4.3.4" + fs-extra "^8.1.0" + +string-width@^4.1.0, string-width@^4.2.0: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-json-comments@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006" + integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== + +supports-color@8.1.1, supports-color@^8.0.0: + version "8.1.1" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-8.1.1.tgz#cd6fc17e28500cff56c1b86c0a7fd4a54a73005c" + integrity sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q== + dependencies: + has-flag "^4.0.0" + +supports-color@^7.1.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da" + integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw== + dependencies: + has-flag "^4.0.0" + +supports-preserve-symlinks-flag@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" + integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== + +tapable@^2.1.1, tapable@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" + integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== + +terser-webpack-plugin@^5.3.7: + version "5.3.10" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199" + integrity sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w== + dependencies: + "@jridgewell/trace-mapping" "^0.3.20" + jest-worker "^27.4.5" + schema-utils "^3.1.1" + serialize-javascript "^6.0.1" + terser "^5.26.0" + +terser@^5.26.0: + version "5.28.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.28.1.tgz#bf00f7537fd3a798c352c2d67d67d65c915d1b28" + integrity sha512-wM+bZp54v/E9eRRGXb5ZFDvinrJIOaTapx3WUokyVGZu5ucVCK55zEgGd5Dl2fSr3jUo5sDiERErUWLY6QPFyA== + dependencies: + "@jridgewell/source-map" "^0.3.3" + acorn "^8.8.2" + commander "^2.20.0" + source-map-support "~0.5.20" + +tmp@^0.2.1: + version "0.2.3" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.3.tgz#eb783cc22bc1e8bebd0671476d46ea4eb32a79ae" + integrity sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w== + +to-regex-range@^5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" + integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== + dependencies: + is-number "^7.0.0" + +toidentifier@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" + integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== + +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + +type-is@~1.6.18: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typescript@5.0.4: + version "5.0.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.0.4.tgz#b217fd20119bd61a94d4011274e0ab369058da3b" + integrity sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw== + +ua-parser-js@^0.7.30: + version "0.7.37" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.37.tgz#e464e66dac2d33a7a1251d7d7a99d6157ec27832" + integrity sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA== + +undici-types@~5.26.4: + version "5.26.5" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617" + integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA== + +universalify@^0.1.0: + version "0.1.2" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" + integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== + +unpipe@1.0.0, unpipe@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" + integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== + +update-browserslist-db@^1.0.13: + version "1.0.13" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz#3c5e4f5c083661bd38ef64b6328c26ed6c8248c4" + integrity sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg== + dependencies: + escalade "^3.1.1" + picocolors "^1.0.0" + +uri-js@^4.2.2: + version "4.4.1" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e" + integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg== + dependencies: + punycode "^2.1.0" + +utils-merge@1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" + integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== + +vary@^1: + version "1.1.2" + resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" + integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== + +void-elements@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-2.0.1.tgz#c066afb582bb1cb4128d60ea92392e94d5e9dbec" + integrity sha512-qZKX4RnBzH2ugr8Lxa7x+0V6XD9Sb/ouARtiasEQCHB1EVU4NXtmHsDDrx1dO4ne5fc3J6EW05BP1Dl0z0iung== + +watchpack@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.0.tgz#fa33032374962c78113f93c7f2fb4c54c9862a5d" + integrity sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg== + dependencies: + glob-to-regexp "^0.4.1" + graceful-fs "^4.1.2" + +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + +webpack-cli@5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/webpack-cli/-/webpack-cli-5.1.0.tgz#abc4b1f44b50250f2632d8b8b536cfe2f6257891" + integrity sha512-a7KRJnCxejFoDpYTOwzm5o21ZXMaNqtRlvS183XzGDUPRdVEzJNImcQokqYZ8BNTnk9DkKiuWxw75+DCCoZ26w== + dependencies: + "@discoveryjs/json-ext" "^0.5.0" + "@webpack-cli/configtest" "^2.1.0" + "@webpack-cli/info" "^2.0.1" + "@webpack-cli/serve" "^2.0.3" + colorette "^2.0.14" + commander "^10.0.1" + cross-spawn "^7.0.3" + envinfo "^7.7.3" + fastest-levenshtein "^1.0.12" + import-local "^3.0.2" + interpret "^3.1.1" + rechoir "^0.8.0" + webpack-merge "^5.7.3" + +webpack-merge@^4.1.5: + version "4.2.2" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-4.2.2.tgz#a27c52ea783d1398afd2087f547d7b9d2f43634d" + integrity sha512-TUE1UGoTX2Cd42j3krGYqObZbOD+xF7u28WB7tfUordytSjbWTIjK/8V0amkBfTYN4/pB/GIDlJZZ657BGG19g== + dependencies: + lodash "^4.17.15" + +webpack-merge@^5.7.3: + version "5.10.0" + resolved "https://registry.yarnpkg.com/webpack-merge/-/webpack-merge-5.10.0.tgz#a3ad5d773241e9c682803abf628d4cd62b8a4177" + integrity sha512-+4zXKdx7UnO+1jaN4l2lHVD+mFvnlZQP/6ljaJVb4SZiwIKeUnrT5l0gkT8z+n4hKpC+jpOv6O9R+gLtag7pSA== + dependencies: + clone-deep "^4.0.1" + flat "^5.0.2" + wildcard "^2.0.0" + +webpack-sources@^3.2.3: + version "3.2.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" + integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== + +webpack@5.82.0: + version "5.82.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.82.0.tgz#3c0d074dec79401db026b4ba0fb23d6333f88e7d" + integrity sha512-iGNA2fHhnDcV1bONdUu554eZx+XeldsaeQ8T67H6KKHl2nUSwX8Zm7cmzOA46ox/X1ARxf7Bjv8wQ/HsB5fxBg== + dependencies: + "@types/eslint-scope" "^3.7.3" + "@types/estree" "^1.0.0" + "@webassemblyjs/ast" "^1.11.5" + "@webassemblyjs/wasm-edit" "^1.11.5" + "@webassemblyjs/wasm-parser" "^1.11.5" + acorn "^8.7.1" + acorn-import-assertions "^1.7.6" + browserslist "^4.14.5" + chrome-trace-event "^1.0.2" + enhanced-resolve "^5.13.0" + es-module-lexer "^1.2.1" + eslint-scope "5.1.1" + events "^3.2.0" + glob-to-regexp "^0.4.1" + graceful-fs "^4.2.9" + json-parse-even-better-errors "^2.3.1" + loader-runner "^4.2.0" + mime-types "^2.1.27" + neo-async "^2.6.2" + schema-utils "^3.1.2" + tapable "^2.1.1" + terser-webpack-plugin "^5.3.7" + watchpack "^2.4.0" + webpack-sources "^3.2.3" + +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + +which@^1.2.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" + +which@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" + integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== + dependencies: + isexe "^2.0.0" + +wildcard@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" + integrity sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ== + +workerpool@6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/workerpool/-/workerpool-6.2.1.tgz#46fc150c17d826b86a008e5a4508656777e9c343" + integrity sha512-ILEIE97kDZvF9Wb9f6h5aXK4swSlKGUcOEGiIYb2OOu/IrDU9iwj0fD//SsA6E5ibwJxpEvhullJY4Sl4GcpAw== + +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + +wrappy@1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" + integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== + +ws@8.5.0: + version "8.5.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.5.0.tgz#bfb4be96600757fe5382de12c670dab984a1ed4f" + integrity sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg== + +ws@~8.11.0: + version "8.11.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" + integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== + +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + +yargs-parser@20.2.4: + version "20.2.4" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.4.tgz#b42890f14566796f85ae8e3a25290d205f154a54" + integrity sha512-WOkpgNhPTlE73h4VFAFsOnomJVaovO8VqLDzy5saChRBFQFBoMYirowyW+Q9HB4HFF4Z7VZTiG3iSzJJA29yRA== + +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + +yargs-unparser@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/yargs-unparser/-/yargs-unparser-2.0.0.tgz#f131f9226911ae5d9ad38c432fe809366c2325eb" + integrity sha512-7pRTIA9Qc1caZ0bZ6RYRGbHJthJWuakf+WmHK0rVeLkNrrGhfoabBNdue6kdINI6r4if7ocq9aD/n7xwKOdzOA== + dependencies: + camelcase "^6.0.0" + decamelize "^4.0.0" + flat "^5.0.2" + is-plain-obj "^2.1.0" + +yargs@16.2.0, yargs@^16.1.1: + version "16.2.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-16.2.0.tgz#1c82bf0f6b6a66eafce7ef30e376f49a12477f66" + integrity sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + +yocto-queue@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" + integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== diff --git a/magix/README.md b/magix/README.md new file mode 100644 index 0000000..de1d7a3 --- /dev/null +++ b/magix/README.md @@ -0,0 +1,4 @@ +# Module magix + + + diff --git a/magix/magix-api/README.md b/magix/magix-api/README.md index ee00c53..0feb75d 100644 --- a/magix/magix-api/README.md +++ b/magix/magix-api/README.md @@ -6,18 +6,16 @@ A kotlin API for magix standard and some zero-dependency magix services ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-api:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-api:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-api:0.2.0") + implementation("space.kscience:magix-api:0.4.0-dev-7") } ``` diff --git a/magix/magix-api/build.gradle.kts b/magix/magix-api/build.gradle.kts index 159989d..253f3e3 100644 --- a/magix/magix-api/build.gradle.kts +++ b/magix/magix-api/build.gradle.kts @@ -13,10 +13,15 @@ kscience { jvm() js() native() + wasm() useCoroutines() useSerialization{ json() } + + commonMain{ + implementation(spclibs.atomicfu) + } } readme{ diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFlowPlugin.kt b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFlowPlugin.kt index 83c95cc..1a3dd75 100644 --- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFlowPlugin.kt +++ b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFlowPlugin.kt @@ -21,9 +21,10 @@ public fun interface MagixFlowPlugin { sendMessage: suspend (MagixMessage) -> Unit, ): Job - /** - * Use the same [MutableSharedFlow] to send and receive messages. Could be a bottleneck in case of many plugins. - */ - public fun start(scope: CoroutineScope, magixFlow: MutableSharedFlow<MagixMessage>): Job = - start(scope, magixFlow) { magixFlow.emit(it) } -} \ No newline at end of file +} + +/** + * Use the same [MutableSharedFlow] to send and receive messages. Could be a bottleneck in case of many plugins. + */ +public fun MagixFlowPlugin.start(scope: CoroutineScope, magixFlow: MutableSharedFlow<MagixMessage>): Job = + start(scope, magixFlow) { magixFlow.emit(it) } \ No newline at end of file diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt index 115fcff..b0316e3 100644 --- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt +++ b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixFormat.kt @@ -26,7 +26,7 @@ public data class MagixFormat<T>( public fun <T> MagixEndpoint.subscribe( format: MagixFormat<T>, originFilter: Collection<String>? = null, - targetFilter: Collection<String>? = null, + targetFilter: Collection<String?>? = null, ): Flow<Pair<MagixMessage, T>> = subscribe( MagixMessageFilter(format = format.formats, source = originFilter, target = targetFilter) ).map { @@ -56,6 +56,6 @@ public suspend fun <T> MagixEndpoint.send( parentId = parentId, user = user ) - broadcast(message) + send(message) } diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt index 4bd4746..72c7f7f 100644 --- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt +++ b/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/api/MagixMessageFilter.kt @@ -11,7 +11,7 @@ import kotlinx.serialization.Serializable public data class MagixMessageFilter( val format: Collection<String>? = null, val source: Collection<String>? = null, - val target: Collection<String>? = null, + val target: Collection<String?>? = null, ) { public fun accepts(message: MagixMessage): Boolean = diff --git a/magix/magix-java-endpoint/README.md b/magix/magix-java-endpoint/README.md index abcaa6f..5b269a0 100644 --- a/magix/magix-java-endpoint/README.md +++ b/magix/magix-java-endpoint/README.md @@ -6,18 +6,16 @@ Java API to work with magix endpoints without Kotlin ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-java-endpoint:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-java-endpoint:0.2.0") + implementation("space.kscience:magix-java-endpoint:0.4.0-dev-7") } ``` diff --git a/magix/magix-java-endpoint/build.gradle.kts b/magix/magix-java-endpoint/build.gradle.kts index ff51835..68c7075 100644 --- a/magix/magix-java-endpoint/build.gradle.kts +++ b/magix/magix-java-endpoint/build.gradle.kts @@ -1,5 +1,3 @@ -import org.jetbrains.kotlin.gradle.tasks.KotlinCompile -import space.kscience.gradle.KScienceVersions import space.kscience.gradle.Maturity plugins { @@ -14,22 +12,9 @@ description = """ dependencies { implementation(project(":magix:magix-rsocket")) - implementation("org.jetbrains.kotlinx:kotlinx-coroutines-jdk9:${KScienceVersions.coroutinesVersion}") + implementation(spclibs.kotlinx.coroutines.jdk9) } -//java { -// sourceCompatibility = KScienceVersions.JVM_TARGET -// targetCompatibility = KScienceVersions.JVM_TARGET -//} - - -//FIXME https://youtrack.jetbrains.com/issue/KT-52815/Compiler-option-Xjdk-release-fails-to-compile-mixed-projects -tasks.withType<KotlinCompile>{ - kotlinOptions { - freeCompilerArgs -= "-Xjdk-release=11" - } -} - -readme{ +readme { maturity = Maturity.EXPERIMENTAL } \ No newline at end of file diff --git a/magix/magix-mqtt/README.md b/magix/magix-mqtt/README.md index 6c34fdc..3661749 100644 --- a/magix/magix-mqtt/README.md +++ b/magix/magix-mqtt/README.md @@ -6,18 +6,16 @@ MQTT client magix endpoint ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-mqtt:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-mqtt:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-mqtt:0.2.0") + implementation("space.kscience:magix-mqtt:0.4.0-dev-7") } ``` diff --git a/magix/magix-mqtt/build.gradle.kts b/magix/magix-mqtt/build.gradle.kts index e7037e6..9241929 100644 --- a/magix/magix-mqtt/build.gradle.kts +++ b/magix/magix-mqtt/build.gradle.kts @@ -1,5 +1,5 @@ plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` } @@ -7,12 +7,15 @@ description = """ MQTT client magix endpoint """.trimIndent() -dependencies { - api(projects.magix.magixApi) - implementation("com.hivemq:hivemq-mqtt-client:1.3.1") - implementation(spclibs.kotlinx.coroutines.jdk8) +kscience { + jvm() + jvmMain { + api(projects.magix.magixApi) + implementation(libs.hivemq.mqtt.client) + implementation(spclibs.kotlinx.coroutines.jdk8) + } } -readme{ +readme { maturity = space.kscience.gradle.Maturity.PROTOTYPE } diff --git a/magix/magix-rabbit/README.md b/magix/magix-rabbit/README.md index 7fc42ad..db0de2c 100644 --- a/magix/magix-rabbit/README.md +++ b/magix/magix-rabbit/README.md @@ -6,18 +6,16 @@ RabbitMQ client magix endpoint ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-rabbit:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-rabbit:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-rabbit:0.2.0") + implementation("space.kscience:magix-rabbit:0.4.0-dev-7") } ``` diff --git a/magix/magix-rabbit/build.gradle.kts b/magix/magix-rabbit/build.gradle.kts index 3d7bc4d..d5472a5 100644 --- a/magix/magix-rabbit/build.gradle.kts +++ b/magix/magix-rabbit/build.gradle.kts @@ -9,7 +9,7 @@ description = """ dependencies { api(projects.magix.magixApi) - implementation("com.rabbitmq:amqp-client:5.14.2") + implementation(libs.rabbitmq.amqp.client) } readme{ diff --git a/magix/magix-rabbit/src/main/kotlin/space/kscience/magix/rabbit/RabbitMQMagixEndpoint.kt b/magix/magix-rabbit/src/jvmMain/kotlin/space/kscience/magix/rabbit/RabbitMQMagixEndpoint.kt similarity index 100% rename from magix/magix-rabbit/src/main/kotlin/space/kscience/magix/rabbit/RabbitMQMagixEndpoint.kt rename to magix/magix-rabbit/src/jvmMain/kotlin/space/kscience/magix/rabbit/RabbitMQMagixEndpoint.kt diff --git a/magix/magix-rsocket/README.md b/magix/magix-rsocket/README.md index 799717d..b3cf1ba 100644 --- a/magix/magix-rsocket/README.md +++ b/magix/magix-rsocket/README.md @@ -6,18 +6,16 @@ Magix endpoint (client) based on RSocket ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-rsocket:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-rsocket:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-rsocket:0.2.0") + implementation("space.kscience:magix-rsocket:0.4.0-dev-7") } ``` diff --git a/magix/magix-rsocket/build.gradle.kts b/magix/magix-rsocket/build.gradle.kts index b2046bb..ca016ce 100644 --- a/magix/magix-rsocket/build.gradle.kts +++ b/magix/magix-rsocket/build.gradle.kts @@ -10,7 +10,6 @@ description = """ """.trimIndent() val ktorVersion: String by rootProject.extra -val rsocketVersion: String by rootProject.extra kscience { jvm() @@ -21,11 +20,11 @@ kscience { } dependencies { api(projects.magix.magixApi) - implementation("io.ktor:ktor-client-core:$ktorVersion") - implementation("io.rsocket.kotlin:rsocket-ktor-client:$rsocketVersion") + implementation(spclibs.ktor.client.core) + implementation(libs.rsocket.ktor.client) } dependencies(jvmMain) { - implementation("io.rsocket.kotlin:rsocket-transport-ktor-tcp:$rsocketVersion") + implementation(libs.rsocket.transport.ktor.tcp) } } @@ -33,7 +32,7 @@ kotlin { sourceSets { getByName("linuxX64Main") { dependencies { - implementation("io.rsocket.kotlin:rsocket-transport-ktor-tcp:$rsocketVersion") + implementation(libs.rsocket.transport.ktor.tcp) } } } diff --git a/magix/magix-server/README.md b/magix/magix-server/README.md index 27d97e0..4e0968e 100644 --- a/magix/magix-server/README.md +++ b/magix/magix-server/README.md @@ -6,18 +6,16 @@ A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket route ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-server:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-server:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-server:0.2.0") + implementation("space.kscience:magix-server:0.4.0-dev-7") } ``` diff --git a/magix/magix-server/build.gradle.kts b/magix/magix-server/build.gradle.kts index cb63049..28fe492 100644 --- a/magix/magix-server/build.gradle.kts +++ b/magix/magix-server/build.gradle.kts @@ -1,36 +1,37 @@ import space.kscience.gradle.Maturity plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` - application } description = """ A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes. """.trimIndent() +val dataforgeVersion: String by rootProject.extra +val ktorVersion: String = space.kscience.gradle.KScienceVersions.ktorVersion + kscience { + jvm() useSerialization{ json() } + + jvmMain{ + api(projects.magix.magixApi) + api("io.ktor:ktor-server-cio:$ktorVersion") + api("io.ktor:ktor-server-websockets:$ktorVersion") + api("io.ktor:ktor-server-content-negotiation:$ktorVersion") + api("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") + api("io.ktor:ktor-server-html-builder:$ktorVersion") + + api(libs.rsocket.ktor.server) + api(libs.rsocket.transport.ktor.tcp) + } + } -val dataforgeVersion: String by rootProject.extra -val rsocketVersion: String by rootProject.extra -val ktorVersion: String = space.kscience.gradle.KScienceVersions.ktorVersion - -dependencies{ - api(projects.magix.magixApi) - api("io.ktor:ktor-server-cio:$ktorVersion") - api("io.ktor:ktor-server-websockets:$ktorVersion") - api("io.ktor:ktor-server-content-negotiation:$ktorVersion") - api("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion") - api("io.ktor:ktor-server-html-builder:$ktorVersion") - - api("io.rsocket.kotlin:rsocket-ktor-server:$rsocketVersion") - api("io.rsocket.kotlin:rsocket-transport-ktor-tcp:$rsocketVersion") -} readme{ maturity = Maturity.EXPERIMENTAL diff --git a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt similarity index 89% rename from magix/magix-server/src/main/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt rename to magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt index a00c33f..699a281 100644 --- a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt +++ b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/RSocketMagixFlowPlugin.kt @@ -59,9 +59,12 @@ public class RSocketMagixFlowPlugin( RSocketRequestHandler(coroutineScope.coroutineContext) { //handler for request/stream requestStream { request: Payload -> - val filter = MagixEndpoint.magixJson.decodeFromString( + val requestText = request.data.readText() + val filter = if(requestText.isBlank()) { + MagixMessageFilter.ALL + } else MagixEndpoint.magixJson.decodeFromString( MagixMessageFilter.serializer(), - request.data.readText() + requestText ) receive.filter(filter).map { message -> @@ -89,12 +92,12 @@ public class RSocketMagixFlowPlugin( ) }.launchIn(this) - val filterText = request.use { it.data.readText() } + val filterText = request.data.readText() - val filter = if (filterText.isNotBlank()) { - MagixEndpoint.magixJson.decodeFromString(MagixMessageFilter.serializer(), filterText) + val filter = if (filterText.isBlank()) { + MagixMessageFilter.ALL } else { - MagixMessageFilter() + MagixEndpoint.magixJson.decodeFromString(MagixMessageFilter.serializer(), filterText) } receive.filter(filter).map { message -> diff --git a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/magixModule.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/magixModule.kt similarity index 65% rename from magix/magix-server/src/main/kotlin/space/kscience/magix/server/magixModule.kt rename to magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/magixModule.kt index dc197ad..a1edec5 100644 --- a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/magixModule.kt +++ b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/magixModule.kt @@ -10,7 +10,9 @@ import io.ktor.server.util.getValue import io.ktor.server.websocket.WebSockets import io.rsocket.kotlin.ktor.server.RSocketSupport import io.rsocket.kotlin.ktor.server.rSocket +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.map import kotlinx.html.* import kotlinx.serialization.encodeToString @@ -42,7 +44,11 @@ private fun ApplicationCall.buildFilter(): MagixMessageFilter { /** * Attach magix http/sse and websocket-based rsocket event loop + statistics page to existing [MutableSharedFlow] */ -public fun Application.magixModule(magixFlow: MutableSharedFlow<MagixMessage>, route: String = "/") { +public fun Application.magixModule( + magixFlow: Flow<MagixMessage>, + send: suspend (MagixMessage) -> Unit, + route: String = "/", +) { if (pluginOrNull(WebSockets) == null) { install(WebSockets) } @@ -62,27 +68,31 @@ public fun Application.magixModule(magixFlow: MutableSharedFlow<MagixMessage>, r routing { route(route) { - install(ContentNegotiation){ + install(ContentNegotiation) { json() } - get("state") { - call.respondHtml { - head { - meta { - httpEquiv = "refresh" - content = "2" + if (magixFlow is SharedFlow) { + get("state") { + call.respondHtml { + head { + meta { + httpEquiv = "refresh" + content = "2" + } } - } - body { - h1 { +"Magix loop statistics" } - h2 { +"Number of subscribers: ${magixFlow.subscriptionCount.value}" } - h3 { +"Replay cache size: ${magixFlow.replayCache.size}" } - h3 { +"Replay cache:" } - ol { - magixFlow.replayCache.forEach { message -> - li { - code { - +magixJson.encodeToString(message) + body { + h1 { +"Magix loop statistics" } + if (magixFlow is MutableSharedFlow) { + h2 { +"Number of subscribers: ${magixFlow.subscriptionCount.value}" } + } + h3 { +"Replay cache size: ${magixFlow.replayCache.size}" } + h3 { +"Replay cache:" } + ol { + magixFlow.replayCache.forEach { message -> + li { + code { + +magixJson.encodeToString(message) + } } } } @@ -102,17 +112,22 @@ public fun Application.magixModule(magixFlow: MutableSharedFlow<MagixMessage>, r } post("broadcast") { val message = call.receive<MagixMessage>() - magixFlow.emit(message) + send(message) } //rSocket WS server. Filter from Payload rSocket( "rsocket", - acceptor = RSocketMagixFlowPlugin.acceptor(application, magixFlow) { magixFlow.emit(it) } + acceptor = RSocketMagixFlowPlugin.acceptor(application, magixFlow) { send(it) } ) } } } +public fun Application.magixModule( + magixFlow: MutableSharedFlow<MagixMessage>, + route: String = "/", +): Unit = magixModule(magixFlow, { magixFlow.emit(it) }, route) + /** * Create a new loop [MutableSharedFlow] with given [buffer] and setup magix module based on it */ diff --git a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/server.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/server.kt similarity index 96% rename from magix/magix-server/src/main/kotlin/space/kscience/magix/server/server.kt rename to magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/server.kt index 2396e25..aa26bee 100644 --- a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/server.kt +++ b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/server.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import space.kscience.magix.api.MagixEndpoint.Companion.DEFAULT_MAGIX_HTTP_PORT import space.kscience.magix.api.MagixFlowPlugin import space.kscience.magix.api.MagixMessage +import space.kscience.magix.api.start /** @@ -22,7 +23,6 @@ public fun CoroutineScope.startMagixServer( val magixFlow = MutableSharedFlow<MagixMessage>( replay = buffer, - extraBufferCapacity = buffer, onBufferOverflow = BufferOverflow.DROP_OLDEST ) diff --git a/magix/magix-server/src/main/kotlin/space/kscience/magix/server/sse.kt b/magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/sse.kt similarity index 100% rename from magix/magix-server/src/main/kotlin/space/kscience/magix/server/sse.kt rename to magix/magix-server/src/jvmMain/kotlin/space/kscience/magix/server/sse.kt diff --git a/magix/magix-storage/README.md b/magix/magix-storage/README.md index fa367b4..f8d3c09 100644 --- a/magix/magix-storage/README.md +++ b/magix/magix-storage/README.md @@ -6,18 +6,16 @@ Magix history database API ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-storage:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-storage:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-storage:0.2.0") + implementation("space.kscience:magix-storage:0.4.0-dev-7") } ``` diff --git a/magix/magix-storage/magix-storage-mongo/build.gradle.kts b/magix/magix-storage/magix-storage-mongo/build.gradle.kts index 51ed9f4..b72a2ec 100644 --- a/magix/magix-storage/magix-storage-mongo/build.gradle.kts +++ b/magix/magix-storage/magix-storage-mongo/build.gradle.kts @@ -3,11 +3,9 @@ plugins { `maven-publish` } -val kmongoVersion = "4.5.1" - dependencies { implementation(projects.controlsStorage) - implementation("org.litote.kmongo:kmongo-coroutine-serialization:$kmongoVersion") + implementation(libs.kmongo.coroutine.serialization) } readme{ diff --git a/magix/magix-storage/magix-storage-xodus/README.md b/magix/magix-storage/magix-storage-xodus/README.md index b3d871b..3a4a469 100644 --- a/magix/magix-storage/magix-storage-xodus/README.md +++ b/magix/magix-storage/magix-storage-xodus/README.md @@ -6,18 +6,16 @@ ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-storage-xodus:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-storage-xodus:0.2.0") + implementation("space.kscience:magix-storage-xodus:0.4.0-dev-7") } ``` diff --git a/magix/magix-storage/magix-storage-xodus/build.gradle.kts b/magix/magix-storage/magix-storage-xodus/build.gradle.kts index ed1dc32..6c35cef 100644 --- a/magix/magix-storage/magix-storage-xodus/build.gradle.kts +++ b/magix/magix-storage/magix-storage-xodus/build.gradle.kts @@ -1,22 +1,24 @@ plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` } -val xodusVersion: String by rootProject.extra - kscience { + jvm() useCoroutines() -} - -dependencies { - api(projects.magix.magixStorage) - implementation("org.jetbrains.xodus:xodus-entity-store:$xodusVersion") + jvmMain { + api(projects.magix.magixStorage) + implementation(libs.xodus.entity.store) // implementation("org.jetbrains.xodus:dnq:2.0.0") - testImplementation(spclibs.kotlinx.coroutines.test) + } + + jvmTest{ + implementation(spclibs.kotlinx.coroutines.test) + } } + readme { maturity = space.kscience.gradle.Maturity.PROTOTYPE } diff --git a/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt b/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt index 4a3efa3..8d0d852 100644 --- a/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt +++ b/magix/magix-storage/magix-storage-xodus/src/main/kotlin/space/kscience/magix/storage/xodus/XodusMagixStorage.kt @@ -39,9 +39,8 @@ public class XodusMagixHistory(private val store: PersistentEntityStore) : Write setBlobString(MagixMessage::payload.name, magixJson.encodeToString(message.payload)) - message.targetEndpoint?.let { - setProperty(MagixMessage::targetEndpoint.name, it) - } + setProperty(MagixMessage::targetEndpoint.name, (message.targetEndpoint ?: "")) + message.id?.let { setProperty(MagixMessage::id.name, it) } @@ -68,14 +67,14 @@ public class XodusMagixHistory(private val store: PersistentEntityStore) : Write ): Unit = store.executeInReadonlyTransaction { transaction -> val all = transaction.getAll(XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE) - fun StoreTransaction.findAllIn( + fun findAllIn( entityType: String, field: String, - values: Collection<String>?, + values: Collection<String?>?, ): EntityIterable? { var union: EntityIterable? = null values?.forEach { - val filter = transaction.find(entityType, field, it) + val filter = transaction.find(entityType, field, it ?: "") union = union?.union(filter) ?: filter } return union @@ -84,21 +83,24 @@ public class XodusMagixHistory(private val store: PersistentEntityStore) : Write // filter by magix filter val filteredByMagix: EntityIterable = magixFilter?.let { mf -> var res = all - transaction.findAllIn(XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE, MagixMessage::format.name, mf.format) - ?.let { - res = res.intersect(it) - } - transaction.findAllIn( + findAllIn( + XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE, + MagixMessage::format.name, + mf.format + )?.let { + res = res.intersect(it) + } + findAllIn( XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE, MagixMessage::sourceEndpoint.name, mf.source )?.let { res = res.intersect(it) } - transaction.findAllIn( + findAllIn( XodusMagixStorage.MAGIC_MESSAGE_ENTITY_TYPE, MagixMessage::targetEndpoint.name, - mf.target + mf.target?.filterNotNull() )?.let { res = res.intersect(it) } diff --git a/magix/magix-utils/README.md b/magix/magix-utils/README.md new file mode 100644 index 0000000..95f51a9 --- /dev/null +++ b/magix/magix-utils/README.md @@ -0,0 +1,21 @@ +# Module magix-utils + +Common utilities and services for Magix endpoints. + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:magix-utils:0.4.0-dev-7`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:magix-utils:0.4.0-dev-7") +} +``` diff --git a/magix/magix-utils/build.gradle.kts b/magix/magix-utils/build.gradle.kts new file mode 100644 index 0000000..9bec805 --- /dev/null +++ b/magix/magix-utils/build.gradle.kts @@ -0,0 +1,25 @@ +import space.kscience.gradle.Maturity + +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +description = """ + Common utilities and services for Magix endpoints. +""".trimIndent() + +kscience { + jvm() + js() + native() + useSerialization() + commonMain { + api(projects.magix.magixApi) + api(libs.dataforge.meta) + } +} + +readme { + maturity = Maturity.EXPERIMENTAL +} \ No newline at end of file diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt similarity index 97% rename from magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt rename to magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt index 3272131..c1a9466 100644 --- a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt +++ b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/MagixRegistry.kt @@ -127,11 +127,11 @@ public fun CoroutineScope.launchMagixRegistry( * * If [registryEndpoint] field is provided, send request only to given endpoint. * - * @param endpointName the name of endpoint requesting a property + * @param sourceEndpoint the name of endpoint requesting a property */ public suspend fun MagixEndpoint.getProperty( propertyName: String, - endpointName: String, + sourceEndpoint: String, user: JsonElement? = null, registryEndpoint: String? = null, ): Flow<Pair<String, JsonElement>> = subscribe( @@ -146,7 +146,7 @@ public suspend fun MagixEndpoint.getProperty( send( MagixRegistryMessage.format, MagixRegistryRequestMessage(propertyName), - source = endpointName, + source = sourceEndpoint, target = registryEndpoint, user = user ) diff --git a/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/WatcherEndpointWrapper.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/WatcherEndpointWrapper.kt new file mode 100644 index 0000000..8560c79 --- /dev/null +++ b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/WatcherEndpointWrapper.kt @@ -0,0 +1,82 @@ +package space.kscience.magix.services + +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.onEach +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonPrimitive +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.string +import space.kscience.magix.api.MagixEndpoint +import space.kscience.magix.api.MagixMessage +import space.kscience.magix.api.MagixMessageFilter +import space.kscience.magix.api.send +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +public class WatcherEndpointWrapper( + private val scope: CoroutineScope, + private val endpointName: String, + private val endpoint: MagixEndpoint, + private val meta: Meta, +) : MagixEndpoint { + + private val watchDogJob: Job = scope.launch { + val filter = MagixMessageFilter( + format = listOf(MAGIX_WATCHDOG_FORMAT), + target = listOf(null, endpointName) + ) + endpoint.subscribe(filter).filter { + it.payload.jsonPrimitive.content == MAGIX_PING + }.onEach { request -> + endpoint.send( + MagixMessage( + MAGIX_WATCHDOG_FORMAT, + JsonPrimitive(MAGIX_PONG), + sourceEndpoint = endpointName, + targetEndpoint = request.sourceEndpoint, + parentId = request.id + ) + ) + }.collect() + } + + private val heartBeatDelay: Duration = meta["heartbeat.period"].string?.let { Duration.parse(it) } ?: 10.seconds + //TODO add update from registry + + private val heartBeatJob = scope.launch { + while (isActive){ + delay(heartBeatDelay) + endpoint.send( + MagixMessage( + MAGIX_HEARTBEAT_FORMAT, + JsonNull, //TODO consider adding timestamp + endpointName + ) + ) + } + } + + override fun subscribe(filter: MagixMessageFilter): Flow<MagixMessage> = endpoint.subscribe(filter) + + override suspend fun broadcast(message: MagixMessage) { + endpoint.broadcast(message) + } + + override fun close() { + endpoint.close() + watchDogJob.cancel() + heartBeatJob.cancel() + } + + public companion object { + public const val MAGIX_WATCHDOG_FORMAT: String = "magix.watchdog" + public const val MAGIX_PING: String = "ping" + public const val MAGIX_PONG: String = "pong" + public const val MAGIX_HEARTBEAT_FORMAT: String = "magix.heartbeat" + } +} \ No newline at end of file diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/converters.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/converters.kt similarity index 100% rename from magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/converters.kt rename to magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/converters.kt diff --git a/magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt b/magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt similarity index 100% rename from magix/magix-api/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt rename to magix/magix-utils/src/commonMain/kotlin/space/kscience/magix/services/magixPortal.kt diff --git a/magix/magix-zmq/README.md b/magix/magix-zmq/README.md index 1338cce..23c17c7 100644 --- a/magix/magix-zmq/README.md +++ b/magix/magix-zmq/README.md @@ -6,18 +6,16 @@ ZMQ client endpoint for Magix ## Artifact: -The Maven coordinates of this project are `space.kscience:magix-zmq:0.2.0`. +The Maven coordinates of this project are `space.kscience:magix-zmq:0.4.0-dev-7`. **Gradle Kotlin DSL:** ```kotlin repositories { maven("https://repo.kotlin.link") - //uncomment to access development builds - //maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") mavenCentral() } dependencies { - implementation("space.kscience:magix-zmq:0.2.0") + implementation("space.kscience:magix-zmq:0.4.0-dev-7") } ``` diff --git a/magix/magix-zmq/build.gradle.kts b/magix/magix-zmq/build.gradle.kts index cf4ee9b..7eedafa 100644 --- a/magix/magix-zmq/build.gradle.kts +++ b/magix/magix-zmq/build.gradle.kts @@ -1,7 +1,7 @@ import space.kscience.gradle.Maturity plugins { - id("space.kscience.gradle.jvm") + id("space.kscience.gradle.mpp") `maven-publish` } @@ -9,10 +9,13 @@ description = """ ZMQ client endpoint for Magix """.trimIndent() -dependencies { - api(projects.magix.magixApi) - api("org.slf4j:slf4j-api:2.0.6") - api("org.zeromq:jeromq:0.5.2") +kscience { + jvm() + jvmMain { + api(projects.magix.magixApi) + api("org.slf4j:slf4j-api:2.0.6") + api("org.zeromq:jeromq:0.5.3") + } } readme { diff --git a/magix/magix-zmq/src/main/kotlin/space/kscince/magix/zmq/ZmqMagixEndpoint.kt b/magix/magix-zmq/src/jvmMain/kotlin/space/kscince/magix/zmq/ZmqMagixEndpoint.kt similarity index 100% rename from magix/magix-zmq/src/main/kotlin/space/kscince/magix/zmq/ZmqMagixEndpoint.kt rename to magix/magix-zmq/src/jvmMain/kotlin/space/kscince/magix/zmq/ZmqMagixEndpoint.kt diff --git a/magix/magix-zmq/src/main/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt b/magix/magix-zmq/src/jvmMain/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt similarity index 97% rename from magix/magix-zmq/src/main/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt rename to magix/magix-zmq/src/jvmMain/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt index 7813b8c..c35aa39 100644 --- a/magix/magix-zmq/src/main/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt +++ b/magix/magix-zmq/src/jvmMain/kotlin/space/kscince/magix/zmq/ZmqMagixFlowPlugin.kt @@ -4,7 +4,6 @@ import kotlinx.coroutines.* import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import org.slf4j.LoggerFactory import org.zeromq.SocketType diff --git a/settings.gradle.kts b/settings.gradle.kts index 20ac44e..1c6b8b2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -18,10 +18,13 @@ pluginManagement { id("space.kscience.gradle.mpp") version toolsVersion id("space.kscience.gradle.jvm") version toolsVersion id("space.kscience.gradle.js") version toolsVersion - id("org.openjfx.javafxplugin") version "0.0.13" } } +plugins { + id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0" +} + dependencyResolutionManagement { val toolsVersion: String by extra @@ -35,11 +38,25 @@ dependencyResolutionManagement { versionCatalogs { create("spclibs") { from("space.kscience:version-catalog:$toolsVersion") + + library("kotlinx-coroutines-jdk9", "org.jetbrains.kotlinx", "kotlinx-coroutines-jdk9").versionRef("kotlinx-coroutines") + + library("ktor-client-core", "io.ktor", "ktor-client-core").versionRef("ktor") + library("ktor-client-cio", "io.ktor", "ktor-client-cio").versionRef("ktor") + library("ktor-network", "io.ktor", "ktor-network").versionRef("ktor") + library("ktor-serialization-kotlinx-json", "io.ktor", "ktor-serialization-kotlinx-json").versionRef("ktor") + + library("ktor-server-cio", "io.ktor", "ktor-server-cio").versionRef("ktor") + library("ktor-server-websockets", "io.ktor", "ktor-server-websockets").versionRef("ktor") + library("ktor-server-content-negotiation", "io.ktor", "ktor-server-content-negotiation").versionRef("ktor") + library("ktor-server-html-builder", "io.ktor", "ktor-server-html-builder").versionRef("ktor") + library("ktor-server-status-pages", "io.ktor", "ktor-server-status-pages").versionRef("ktor") } } } include( + ":simulation-kt", ":controls-core", ":controls-ports-ktor", ":controls-serial", @@ -47,11 +64,17 @@ include( ":controls-server", ":controls-opcua", ":controls-modbus", + ":controls-plc4x", // ":controls-mongo", ":controls-storage", ":controls-storage:controls-xodus", + ":controls-constructor", + ":controls-visualisation-compose", + ":controls-vision", + ":controls-jupyter", ":magix", ":magix:magix-api", + ":magix:magix-utils", ":magix:magix-server", ":magix:magix-rsocket", ":magix:magix-java-endpoint", @@ -67,5 +90,7 @@ include( ":demo:car", ":demo:motors", ":demo:echo", - ":demo:mks-pdr900" + ":demo:mks-pdr900", + ":demo:constructor", + ":demo:device-collective" ) diff --git a/simulation-kt/README.md b/simulation-kt/README.md new file mode 100644 index 0000000..ed4308e --- /dev/null +++ b/simulation-kt/README.md @@ -0,0 +1,26 @@ +# Module simulation-kt + + + +## Features + + - [timeline](#) : Timeline is an ordered discrete history containing TimeLineEvent + + +## Usage + +## Artifact: + +The Maven coordinates of this project are `space.kscience:simulation-kt:0.4.0-dev-7`. + +**Gradle Kotlin DSL:** +```kotlin +repositories { + maven("https://repo.kotlin.link") + mavenCentral() +} + +dependencies { + implementation("space.kscience:simulation-kt:0.4.0-dev-7") +} +``` diff --git a/simulation-kt/build.gradle.kts b/simulation-kt/build.gradle.kts new file mode 100644 index 0000000..a80b1df --- /dev/null +++ b/simulation-kt/build.gradle.kts @@ -0,0 +1,33 @@ +import space.kscience.gradle.Maturity + +plugins { + id("space.kscience.gradle.mpp") + `maven-publish` +} + +kscience { + jvm() + js() + native() + wasm() + useCoroutines() + useContextReceivers() + + commonMain { + api(spclibs.kotlinx.datetime) + } + + jvmTest { + implementation(spclibs.logback.classic) + } +} + + +readme { + maturity = Maturity.PROTOTYPE + description = """ + A framework for combination of asynchronous simulations. + """.trimIndent() + + feature("timeline") { "Timeline is an ordered discrete history containing TimeLineEvent" } +} \ No newline at end of file diff --git a/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt new file mode 100644 index 0000000..0698b45 --- /dev/null +++ b/simulation-kt/src/commonMain/kotlin/GeneratingTimeline.kt @@ -0,0 +1,66 @@ +package space.kscience.simulation + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.* +import kotlinx.datetime.Instant +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext +import kotlin.time.Duration + +/** + * Suspend the collection of this [Flow] until event time is lower that threshold + */ +public fun <E : TimelineEvent> Flow<E>.withTimeThreshold( + threshold: Flow<Instant> +): Flow<E> = transform { event -> + threshold.first { it > event.time } + emit(event) +} + +/** + * @param lookaheadInterval an interval for generated events ahead of the last observed event. + */ +public class GeneratingTimeline<E : TimelineEvent>( + origin: E, + private val lookaheadInterval: Duration, + coroutineContext: CoroutineContext = EmptyCoroutineContext, + private val generator: suspend FlowCollector<E>.(E) -> Unit +) : ProducerTimeline<E>(origin.time, coroutineContext) { + + private val startEventFlow = MutableStateFlow(origin) + + private data class EventWithOrigin<E : TimelineEvent>(val origin: E, val event: E) : TimelineEvent { + override val time: Instant get() = event.time + } + + private val events: SharedFlow<E> = flow { + coroutineScope { + startEventFlow.collect { startEvent -> + emitAll( + flow { generator(startEvent) }.takeWhile { startEvent == startEventFlow.value }.map { + EventWithOrigin(startEvent, it) + } + ) + } + } + }.withTimeThreshold( + threshold = time.map { it + lookaheadInterval } + ).buffer(Channel.UNLIMITED).mapNotNull { + //a barrier to avoid leaking stale events after interruption from buffer + it.takeIf { it.origin == startEventFlow.value }?.event + }.shareIn( + scope = timelineScope, + started = SharingStarted.Lazily, + ) + + override fun events(): Flow<E> = events + + public suspend fun interrupt(newStart: E) { + check(newStart.time >= time.value) { + "Can't interrupt generating timeline after observed event" + } + startTime = newStart.time + startEventFlow.emit(newStart) + } +} \ No newline at end of file diff --git a/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt new file mode 100644 index 0000000..4614fbb --- /dev/null +++ b/simulation-kt/src/commonMain/kotlin/MergedTimeline.kt @@ -0,0 +1,82 @@ +package space.kscience.simulation + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Instant +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + + +public class MergedTimeline<E : TimelineEvent>( + private val timelines: List<Timeline<E>>, + coroutineContext: CoroutineContext = EmptyCoroutineContext +) : Timeline<E> { + + protected val timelineScope: CoroutineScope = CoroutineScope( + coroutineContext + + SupervisorJob(coroutineContext[Job]) + + CoroutineExceptionHandler{ _, throwable -> throwable.printStackTrace() } + + CoroutineName("MergedTimeline") + ) + + override val time: StateFlow<Instant> = combine(timelines.map { it.time }){ array-> + array.max() + }.stateIn(timelineScope, SharingStarted.Lazily, timelines.maxOf { it.time.value }) + + override suspend fun advance(toTime: Instant) { + observers.forEach { + it.collect(toTime) + } + } + + private val observers: MutableSet<TimelineObserver> = mutableSetOf() + + override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver { + val context = currentCoroutineContext() + val buffer = mutableListOf<E>() + + val timelineObservers = timelines.map { + it.observeEach { event -> + buffer.add(event) + } + } + + val observer = object : TimelineObserver { + + private val channel = Channel<E>() + + override val time = MutableStateFlow(this@MergedTimeline.time.value) + + private val collectJob = timelineScope.launch(context) { + channel.consumeAsFlow().onEach { + time.emit(it.time) + }.collector() + } + + private val mutex = Mutex() + + override suspend fun collect(upTo: Instant) = mutex.withLock{ + timelineObservers.forEach { + it.collect(upTo) + } + buffer.sortedBy { it.time }.forEach { + channel.send(it) + buffer.remove(it) + } + } + + override fun close() { + collectJob.cancel() + observers.remove(this) + } + + } + + observers.add(observer) + return observer + } + +} \ No newline at end of file diff --git a/simulation-kt/src/commonMain/kotlin/ProducerTimeline.kt b/simulation-kt/src/commonMain/kotlin/ProducerTimeline.kt new file mode 100644 index 0000000..a201d92 --- /dev/null +++ b/simulation-kt/src/commonMain/kotlin/ProducerTimeline.kt @@ -0,0 +1,83 @@ +package space.kscience.simulation + +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.datetime.Instant +import kotlin.coroutines.CoroutineContext + +public abstract class ProducerTimeline<E : TimelineEvent>( + protected var startTime: Instant, + coroutineContext: CoroutineContext +) : Timeline<E>, AutoCloseable { + + protected val timelineScope: CoroutineScope = CoroutineScope( + coroutineContext + + SupervisorJob(coroutineContext[Job]) + + CoroutineExceptionHandler{ _, throwable -> throwable.printStackTrace() } + + CoroutineName("Timeline") + ) + + private val observers: MutableSet<TimelineObserver> = mutableSetOf() + + private val feedbackChannel = Channel<Unit>(onBufferOverflow = BufferOverflow.DROP_OLDEST) + + override val time: StateFlow<Instant> = feedbackChannel.consumeAsFlow().map { + maxOf(startTime,observers.maxOfOrNull { it.time.value } ?: startTime) + }.stateIn(timelineScope, SharingStarted.Lazily, startTime) + + override suspend fun advance(toTime: Instant) { + observers.forEach { + it.collect(toTime) + } + } + + /** + * Flow unobserved events starting at [time]. The flow could be interrupted if timeline changes + */ + protected abstract fun events(): Flow<E> + + override suspend fun observe(collector: suspend Flow<E>.() -> Unit): TimelineObserver { + val context = currentCoroutineContext() + val observer = object : TimelineObserver { + // observed time + override val time = MutableStateFlow(startTime) + + private val channel = Channel<E>() + + private val collectJob = timelineScope.launch(context) { + channel.consumeAsFlow().onEach { + time.emit(it.time) + feedbackChannel.send(Unit) + }.collector() + } + + private val mutex = Mutex() + + override suspend fun collect(upTo: Instant) = mutex.withLock { + require(upTo >= time.value) { "Requested time $upTo is lower than observed ${time.value}" } + events().takeWhile { + it.time <= upTo + }.collect { + channel.send(it) + } + } + + override fun close() { + collectJob.cancel() + observers.remove(this) + } + + } + observers.add(observer) + return observer + } + + override fun close() { + observers.forEach { it.close() } + timelineScope.cancel() + } +} \ No newline at end of file diff --git a/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt new file mode 100644 index 0000000..051524e --- /dev/null +++ b/simulation-kt/src/commonMain/kotlin/SharedTimeline.kt @@ -0,0 +1,31 @@ +package space.kscience.simulation + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.datetime.Instant +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * A manually mutable [Timeline] that could be modified via [emit] method by multiple + */ +public class SharedTimeline<E : TimelineEvent>( + startTime: Instant, + coroutineContext: CoroutineContext = EmptyCoroutineContext +) : ProducerTimeline<E>(startTime, coroutineContext) { + + private val events = MutableSharedFlow<E>(replay = Channel.UNLIMITED) + + override fun events(): Flow<E> = events + + /** + * Emit new event to the timeline + */ + public suspend fun emit(event: E) { + if (event.time < (events.replayCache.lastOrNull()?.time ?: time.value)) { + error("Can't emit event $event because timeline monotony is broken") + } + events.emit(event) + } +} \ No newline at end of file diff --git a/simulation-kt/src/commonMain/kotlin/Timeline.kt b/simulation-kt/src/commonMain/kotlin/Timeline.kt new file mode 100644 index 0000000..20a0746 --- /dev/null +++ b/simulation-kt/src/commonMain/kotlin/Timeline.kt @@ -0,0 +1,81 @@ +package space.kscience.simulation + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.datetime.Instant +import kotlin.time.Duration + + +public interface TimelineEvent { + public val time: Instant +} + +public interface TimelineInterval : TimelineEvent { + public val startTime: Instant + public val duration: Duration + + override val time: Instant + get() = startTime + duration +} + +public data class SimpleTimelineEvent<T>(override val time: Instant, val value: T) : TimelineEvent + +public interface TimelineObserver : AutoCloseable { + /** + * The subjective time of this observer (last observed time) + */ + public val time: StateFlow<Instant> + + /** + * Collect all uncollected events from [time] to [upTo]. + * + * By default, collects all events. + */ + public suspend fun collect(upTo: Instant = Instant.DISTANT_FUTURE) +} + +/** + * Collect events for a fixed [duration] since last observed time + */ +public suspend fun TimelineObserver.collect(duration: Duration): Unit = collect(time.value + duration) + +/** + * A time-ordered sequence of events of type [E]. There time of events is strictly monotonic, meaning that the time of + * the next event is greater than the previous event time. + * + * Timeline guarantees that all collectors could read all events when they need. Meaning that all unread events are cached. + * + * Timeline guarantees that already read events won't change, but unread events could change. + */ +public interface Timeline<E : TimelineEvent> { + /** + * A subjective time of this timeline. The subjective time is the last observed time. + */ + public val time: StateFlow<Instant> + + + /** + * Attach observer to this [Timeline]. The observer collection is not triggered right away, but only on demand. + * + * Each collection shifts [TimelineObserver.time] for this observer. + */ + public suspend fun observe( + collector: suspend Flow<E>.() -> Unit + ): TimelineObserver + + /** + * Advance simulation time to [toTime]. This method forces all observers to collect all events in the given range. + * + * This method suspends until all advancement is done + */ + public suspend fun advance(toTime: Instant) +} + +/** + * Perform [collector] action on each event + */ +public suspend fun <E : TimelineEvent> Timeline<E>.observeEach( + collector: suspend (E) -> Unit +): TimelineObserver = observe { + collect(collector) +} \ No newline at end of file diff --git a/simulation-kt/src/commonMain/kotlin/notNullUtils.kt b/simulation-kt/src/commonMain/kotlin/notNullUtils.kt new file mode 100644 index 0000000..5e7d64b --- /dev/null +++ b/simulation-kt/src/commonMain/kotlin/notNullUtils.kt @@ -0,0 +1,35 @@ +package space.kscience.simulation + +internal inline fun <T, R : Comparable<R>> Iterable<T>.minOfNotNullOrNull(selector: (T) -> R?): R? { + val iterator = iterator() + if (!iterator.hasNext()) return null + var minValue = selector(iterator.next()) + while (iterator.hasNext()) { + val v = selector(iterator.next()) + when { + minValue == null -> minValue = v + v == null -> {/*do nothing*/} + minValue > v -> { + minValue = v + } + } + } + return minValue +} + +internal inline fun <T, R : Comparable<R>> Iterable<T>.maxOfNotNullOrNull(selector: (T) -> R?): R? { + val iterator = iterator() + if (!iterator.hasNext()) return null + var maxValue = selector(iterator.next()) + while (iterator.hasNext()) { + val v = selector(iterator.next()) + when { + maxValue == null -> maxValue = v + v == null -> {/*do nothing*/} + maxValue < v -> { + maxValue = v + } + } + } + return maxValue +} \ No newline at end of file diff --git a/simulation-kt/src/commonTest/kotlin/TimelineTests.kt b/simulation-kt/src/commonTest/kotlin/TimelineTests.kt new file mode 100644 index 0000000..b9f53dd --- /dev/null +++ b/simulation-kt/src/commonTest/kotlin/TimelineTests.kt @@ -0,0 +1,48 @@ +package space.kscience.simulation + +import kotlinx.coroutines.isActive +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import kotlin.test.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +class TimelineTests { + + + @Test + fun testGeneration() = runTest(timeout = 1.seconds) { + val startTime = Instant.parse("2020-01-01T00:00:00.000Z") + + val generation = GeneratingTimeline( + origin = SimpleTimelineEvent(startTime, Unit), + lookaheadInterval = 1.seconds + ) { event -> + var time = event.time + while (isActive) { + time += 0.1.seconds + println("Emit: ${time - startTime}") + emit(SimpleTimelineEvent(time, Unit)) + } + } + + val result = mutableListOf<Duration>() + + val observer = generation.observeEach { + println("Consume: ${it.time - startTime}") + result.add(it.time - startTime) + } + + observer.collect(2.seconds) + println("First collection complete") + observer.collect(2.seconds) + println("Second collection complete") + println("Interrupt") + generation.interrupt(SimpleTimelineEvent(startTime + 6.seconds, Unit)) + println("Collecting after interruption") + observer.collect(startTime + 6.seconds + 2.5.seconds) + println(result) + generation.close() + + } +} \ No newline at end of file