Compare commits
125 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
b6f7963f68 | ||
|
3f09d20465 | ||
89d78c43bb | |||
c12f1ce1cd | |||
47327aef19 | |||
f0f9d0e174 | |||
fbf79f0a37 | |||
dc4f2c6126 | |||
a9d58bfac2 | |||
8c7c017ab4 | |||
f13b7268d6 | |||
92c4355f48 | |||
eb126a6090 | |||
a5bb42706b | |||
60a693b1b3 | |||
c55ce2cf9a | |||
e9bde68674 | |||
13b80be884 | |||
5c7d3d8a7a | |||
a2b5880da9 | |||
c63c2db651 | |||
91f860adf6 | |||
1799a9a909 | |||
9f21a14f96 | |||
4a5f5fab8c | |||
a2b7d1ecb0 | |||
d0e3faea88 | |||
54e915ef10 | |||
9edde7bdbd | |||
f72d7aa3fa | |||
05757aefdc | |||
55bcb08668 | |||
673a7c89a6 | |||
a66e411848 | |||
4a10c3c443 | |||
207064cd45 | |||
e5088ac8e4 | |||
ee83f81a04 | |||
5921978122 | |||
a9592d0372 | |||
44514cd477 | |||
24b6856f15 | |||
381da970bf | |||
4b05f46fa7 | |||
f974483a41 | |||
e729cb1a79 | |||
8e7277df69 | |||
23bceed89d | |||
9eb583dfc6 | |||
977500223d | |||
58675f72f5 | |||
85c2910ee9 | |||
d91296c47d | |||
8965629151 | |||
9a40d4f340 | |||
78dade4b49 | |||
70ab60f98c | |||
53cc4dc0df | |||
f28e9dc226 | |||
29af4dfb2c | |||
4835376c0d | |||
4639fdb558 | |||
2946f23a4b | |||
e8c6e90a0f | |||
2a700a5a2a | |||
dbacdbc7cf | |||
28ec2bc8b8 | |||
cfd9eb053c | |||
9edf3b13ef | |||
57e9df140b | |||
231f1bc858 | |||
8bd9bcc6a6 | |||
b1121d61cb | |||
fa2414ef47 | |||
7579ddfad4 | |||
aa52b4b927 | |||
34f9108ef7 | |||
bec075328b | |||
701ea8cf57 | |||
5e64b79b77 | |||
a12cf440e8 | |||
606c2cf5b1 | |||
fb03fcc982 | |||
cf129b6242 | |||
827eb6e4c1 | |||
81d6b672cf | |||
07cc41c645 | |||
0c647cff30 | |||
b539c2046a | |||
afee2f0a02 | |||
fb8ee59f14 | |||
74301afb42 | |||
fe98a836f8 | |||
0c128bce36 | |||
4e17c9051c | |||
0f687c3c51 | |||
53fc240c75 | |||
825f1a4d04 | |||
0443fdc3c0 | |||
78b18ebda6 | |||
0e963a7b13 | |||
2698cee80b | |||
811477a636 | |||
984e7f12ef | |||
1414cf5a2f | |||
1fcdbdc9f4 | |||
4f028ccee8 | |||
1619fdadf2 | |||
7f71d0c9e9 | |||
290010fc8c | |||
80cc62e25b | |||
f1b63c3951 | |||
01606af307 | |||
2cc0a5bcbc | |||
efe9a2e842 | |||
34e7dd2c6d | |||
a337daee93 | |||
a51510606f | |||
aef94767c5 | |||
8b6a6abd92 | |||
bc5037b256 | |||
036bef1adb | |||
cc36ef805b | |||
0f610a5e19 | |||
4c93b5c9b3 |
6
.gitignore
vendored
6
.gitignore
vendored
@ -1,6 +1,7 @@
|
|||||||
# Created by .ignore support plugin (hsz.mobi)
|
# Created by .ignore support plugin (hsz.mobi)
|
||||||
.idea/
|
.idea/
|
||||||
.gradle
|
.gradle
|
||||||
|
.kotlin
|
||||||
|
|
||||||
*.iws
|
*.iws
|
||||||
*.iml
|
*.iml
|
||||||
@ -8,4 +9,7 @@
|
|||||||
|
|
||||||
out/
|
out/
|
||||||
build/
|
build/
|
||||||
!gradle-wrapper.jar
|
|
||||||
|
!gradle-wrapper.jar
|
||||||
|
|
||||||
|
/demo/device-collective/mapCache/
|
||||||
|
45
.space.kts
45
.space.kts
@ -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\"",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -1 +0,0 @@
|
|||||||
./space/* "Project Admin"
|
|
68
CHANGELOG.md
68
CHANGELOG.md
@ -3,6 +3,64 @@
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Added
|
### 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
|
- Core interfaces for building a device server
|
||||||
- Magix service for binding controls devices (both as RPC client and server)
|
- Magix service for binding controls devices (both as RPC client and server)
|
||||||
- A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols
|
- A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols
|
||||||
@ -20,13 +78,3 @@
|
|||||||
- A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes.
|
- A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes.
|
||||||
- Magix history database API
|
- Magix history database API
|
||||||
- ZMQ client endpoint for Magix
|
- ZMQ client endpoint for Magix
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
### Deprecated
|
|
||||||
|
|
||||||
### Removed
|
|
||||||
|
|
||||||
### Fixed
|
|
||||||
|
|
||||||
### Security
|
|
||||||
|
39
README.md
39
README.md
@ -1,5 +1,7 @@
|
|||||||
[![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub)
|
[![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub)
|
||||||
|
|
||||||
|
[![](https://maven.sciprog.center/api/badge/latest/kscience/space/kscience/controls-core-jvm?color=40c14a&name=repo.kotlin.link&prefix=v)](https://maven.sciprog.center/)
|
||||||
|
|
||||||
# Controls.kt
|
# Controls.kt
|
||||||
|
|
||||||
Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing.
|
Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing.
|
||||||
@ -42,6 +44,11 @@ Example view of a demo:
|
|||||||
## Modules
|
## Modules
|
||||||
|
|
||||||
|
|
||||||
|
### [controls-constructor](controls-constructor)
|
||||||
|
> A low-code constructor for composite devices simulation
|
||||||
|
>
|
||||||
|
> **Maturity**: PROTOTYPE
|
||||||
|
|
||||||
### [controls-core](controls-core)
|
### [controls-core](controls-core)
|
||||||
> Core interfaces for building a device server
|
> Core interfaces for building a device server
|
||||||
>
|
>
|
||||||
@ -56,6 +63,10 @@ Example view of a demo:
|
|||||||
> - [ports](controls-core/src/commonMain/kotlin/space/kscience/controls/ports) : Working with asynchronous data sending and receiving raw byte arrays
|
> - [ports](controls-core/src/commonMain/kotlin/space/kscience/controls/ports) : Working with asynchronous data sending and receiving raw byte arrays
|
||||||
|
|
||||||
|
|
||||||
|
### [controls-jupyter](controls-jupyter)
|
||||||
|
>
|
||||||
|
> **Maturity**: EXPERIMENTAL
|
||||||
|
|
||||||
### [controls-magix](controls-magix)
|
### [controls-magix](controls-magix)
|
||||||
> Magix service for binding controls devices (both as RPC client and server)
|
> Magix service for binding controls devices (both as RPC client and server)
|
||||||
>
|
>
|
||||||
@ -93,6 +104,11 @@ Automatically checks consistency.
|
|||||||
>
|
>
|
||||||
> **Maturity**: EXPERIMENTAL
|
> **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)
|
### [controls-ports-ktor](controls-ports-ktor)
|
||||||
> Implementation of byte ports on top os ktor-io asynchronous API
|
> Implementation of byte ports on top os ktor-io asynchronous API
|
||||||
>
|
>
|
||||||
@ -113,6 +129,16 @@ Automatically checks consistency.
|
|||||||
>
|
>
|
||||||
> **Maturity**: PROTOTYPE
|
> **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)
|
### [demo](demo)
|
||||||
>
|
>
|
||||||
> **Maturity**: EXPERIMENTAL
|
> **Maturity**: EXPERIMENTAL
|
||||||
@ -134,6 +160,14 @@ Automatically checks consistency.
|
|||||||
>
|
>
|
||||||
> **Maturity**: EXPERIMENTAL
|
> **Maturity**: EXPERIMENTAL
|
||||||
|
|
||||||
|
### [demo/constructor](demo/constructor)
|
||||||
|
>
|
||||||
|
> **Maturity**: EXPERIMENTAL
|
||||||
|
|
||||||
|
### [demo/device-collective](demo/device-collective)
|
||||||
|
>
|
||||||
|
> **Maturity**: EXPERIMENTAL
|
||||||
|
|
||||||
### [demo/echo](demo/echo)
|
### [demo/echo](demo/echo)
|
||||||
>
|
>
|
||||||
> **Maturity**: EXPERIMENTAL
|
> **Maturity**: EXPERIMENTAL
|
||||||
@ -189,6 +223,11 @@ Automatically checks consistency.
|
|||||||
>
|
>
|
||||||
> **Maturity**: PROTOTYPE
|
> **Maturity**: PROTOTYPE
|
||||||
|
|
||||||
|
### [magix/magix-utils](magix/magix-utils)
|
||||||
|
> Common utilities and services for Magix endpoints.
|
||||||
|
>
|
||||||
|
> **Maturity**: EXPERIMENTAL
|
||||||
|
|
||||||
### [magix/magix-zmq](magix/magix-zmq)
|
### [magix/magix-zmq](magix/magix-zmq)
|
||||||
> ZMQ client endpoint for Magix
|
> ZMQ client endpoint for Magix
|
||||||
>
|
>
|
||||||
|
@ -1,37 +1,26 @@
|
|||||||
import space.kscience.gradle.isInDevelopment
|
|
||||||
import space.kscience.gradle.useApache2Licence
|
import space.kscience.gradle.useApache2Licence
|
||||||
import space.kscience.gradle.useSPCTeam
|
import space.kscience.gradle.useSPCTeam
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("space.kscience.gradle.project")
|
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 {
|
allprojects {
|
||||||
group = "space.kscience"
|
group = "space.kscience"
|
||||||
version = "0.2.0"
|
version = "0.4.0-dev-6"
|
||||||
repositories{
|
repositories{
|
||||||
maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
|
google()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ksciencePublish {
|
ksciencePublish {
|
||||||
pom("https://github.com/SciProgCentre/controls.kt") {
|
pom("https://github.com/SciProgCentre/controls-kt") {
|
||||||
useApache2Licence()
|
useApache2Licence()
|
||||||
useSPCTeam()
|
useSPCTeam()
|
||||||
}
|
}
|
||||||
github("controls.kt", "SciProgCentre")
|
repository("spc","https://maven.sciprog.center/kscience")
|
||||||
space(
|
sonatype("https://oss.sonatype.org")
|
||||||
if (isInDevelopment) {
|
|
||||||
"https://maven.pkg.jetbrains.space/spc/p/sci/dev"
|
|
||||||
} else {
|
|
||||||
"https://maven.pkg.jetbrains.space/spc/p/sci/maven"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
readme.readmeTemplate = file("docs/templates/README-TEMPLATE.md")
|
readme.readmeTemplate = file("docs/templates/README-TEMPLATE.md")
|
21
controls-constructor/README.md
Normal file
21
controls-constructor/README.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Module controls-constructor
|
||||||
|
|
||||||
|
A low-code constructor for composite devices simulation
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
## Artifact:
|
||||||
|
|
||||||
|
The Maven coordinates of this project are `space.kscience:controls-constructor:0.4.0-dev-4`.
|
||||||
|
|
||||||
|
**Gradle Kotlin DSL:**
|
||||||
|
```kotlin
|
||||||
|
repositories {
|
||||||
|
maven("https://repo.kotlin.link")
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("space.kscience:controls-constructor:0.4.0-dev-4")
|
||||||
|
}
|
||||||
|
```
|
32
controls-constructor/build.gradle.kts
Normal file
32
controls-constructor/build.gradle.kts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
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)
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||||
|
implementation("org.jetbrains.kotlinx:multik-core:0.2.3")
|
||||||
|
}
|
||||||
|
|
||||||
|
commonTest{
|
||||||
|
implementation(spclibs.logback.classic)
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.1")
|
||||||
|
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
readme{
|
||||||
|
maturity = space.kscience.gradle.Maturity.PROTOTYPE
|
||||||
|
}
|
@ -0,0 +1,253 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and register a derived state.
|
||||||
|
*/
|
||||||
|
public fun <T> derivedState(
|
||||||
|
dependencies: List<DeviceState<*>>,
|
||||||
|
computeValue: () -> T,
|
||||||
|
): DeviceStateWithDependencies<T> {
|
||||||
|
val state = DeviceState.derived(this, dependencies, computeValue)
|
||||||
|
registerElement(StateConstructorElement(state))
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 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 combine(sourceStates.map { it.valueFlow }, transformation).onEach {
|
||||||
|
targetState.value = it
|
||||||
|
}.launchIn(this).apply {
|
||||||
|
invokeOnCompletion {
|
||||||
|
unregisterElement(descriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
@ -0,0 +1,149 @@
|
|||||||
|
package space.kscience.controls.constructor
|
||||||
|
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.flow.*
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeviceState with dependencies on other DeviceStates.
|
||||||
|
*/
|
||||||
|
public interface DeviceStateWithDependencies<T> : DeviceState<T> {
|
||||||
|
public val dependencies: Collection<DeviceState<*>>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function to create a DerivedDeviceState.
|
||||||
|
*/
|
||||||
|
public fun <T> DeviceState.Companion.derived(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
dependencies: List<DeviceState<*>>,
|
||||||
|
computeValue: () -> T
|
||||||
|
): DeviceStateWithDependencies<T> = DerivedDeviceState(scope, dependencies, computeValue)
|
||||||
|
|
||||||
|
public fun <T> DeviceState<T>.withDependencies(
|
||||||
|
dependencies: Collection<DeviceState<*>>,
|
||||||
|
): DeviceStateWithDependencies<T> = object : DeviceStateWithDependencies<T>, DeviceState<T> by this {
|
||||||
|
override val dependencies: Collection<DeviceState<*>> = dependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public fun <T> DeviceState.Companion.fromFlow(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
flow: Flow<T>,
|
||||||
|
initialValue: T
|
||||||
|
): DeviceState<T> {
|
||||||
|
val stateFlow = flow.stateIn(scope, SharingStarted.Eagerly, initialValue)
|
||||||
|
return object : DeviceState<T> {
|
||||||
|
override val value: T get() = stateFlow.value
|
||||||
|
override val valueFlow: Flow<T> get() = stateFlow
|
||||||
|
override fun toString(): String {
|
||||||
|
return "DeviceState.fromFlow(scope=$scope, flow=$flow, initialValue=$initialValue)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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)"
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A DeviceState that derives its value from other DeviceStates.
|
||||||
|
*/
|
||||||
|
public class DerivedDeviceState<T>(
|
||||||
|
scope: CoroutineScope,
|
||||||
|
override val dependencies: List<DeviceState<*>>,
|
||||||
|
computeValue: () -> T
|
||||||
|
) : DeviceStateWithDependencies<T> {
|
||||||
|
private val _valueFlow: StateFlow<T>
|
||||||
|
|
||||||
|
init {
|
||||||
|
val flows = dependencies.map { it.valueFlow }
|
||||||
|
_valueFlow = combine(flows) {
|
||||||
|
computeValue()
|
||||||
|
}.stateIn(scope, SharingStarted.Eagerly, computeValue())
|
||||||
|
}
|
||||||
|
|
||||||
|
override val value: T get() = _valueFlow.value
|
||||||
|
override val valueFlow: Flow<T> get() = _valueFlow
|
||||||
|
override fun toString(): String {
|
||||||
|
TODO("Not yet implemented")
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
@ -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)"
|
||||||
|
}
|
@ -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)
|
||||||
|
|
@ -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.NewtonMeters
|
||||||
|
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<NewtonMeters>> = MutableDeviceState(NumericalValue(0)),
|
||||||
|
) : DeviceConstructor(context) {
|
||||||
|
public val force: MutableDeviceState<NumericalValue<NewtonMeters>> by property(MetaConverter.numerical(), force)
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
@ -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.NewtonMeters
|
||||||
|
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, NewtonMeters> = 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)
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
@ -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)
|
@ -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)"
|
||||||
|
|
||||||
|
}
|
@ -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<NewtonMeters>>,
|
||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -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<NewtonMeters>>,
|
||||||
|
): 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 )
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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)
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
package space.kscience.controls.constructor.units
|
||||||
|
|
||||||
|
public enum class Direction(public val coef: Int) {
|
||||||
|
UP(1),
|
||||||
|
DOWN(-1)
|
||||||
|
}
|
@ -0,0 +1,59 @@
|
|||||||
|
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>>
|
@ -0,0 +1,244 @@
|
|||||||
|
package space.kscience.controls.constructor.units
|
||||||
|
|
||||||
|
import kotlin.math.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a unit of measurement.
|
||||||
|
* Provides methods to convert values to and from the base unit.
|
||||||
|
*/
|
||||||
|
public interface UnitsOfMeasurement {
|
||||||
|
/**
|
||||||
|
* Symbol representing the unit (e.g., "m" for meters).
|
||||||
|
*/
|
||||||
|
public val symbol: String
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a value from this unit to the base unit.
|
||||||
|
*/
|
||||||
|
public fun toBase(value: Double): Double
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a value from the base unit to this unit.
|
||||||
|
*/
|
||||||
|
public fun fromBase(value: Double): Double
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base unit, where conversion to base unit is identity.
|
||||||
|
*/
|
||||||
|
public open class BaseUnit(
|
||||||
|
override val symbol: String,
|
||||||
|
) : UnitsOfMeasurement {
|
||||||
|
override fun toBase(value: Double): Double = value
|
||||||
|
|
||||||
|
override fun fromBase(value: Double): Double = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived unit with a conversion factor to the base unit.
|
||||||
|
*/
|
||||||
|
public class DerivedUnit(
|
||||||
|
override val symbol: String,
|
||||||
|
private val conversionFactor: Double, // Factor to convert to base unit.
|
||||||
|
) : UnitsOfMeasurement {
|
||||||
|
override fun toBase(value: Double): Double = value * conversionFactor
|
||||||
|
|
||||||
|
override fun fromBase(value: Double): Double = value / conversionFactor
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enumeration of SI prefixes with their symbols and factors.
|
||||||
|
*/
|
||||||
|
public enum class SIPrefix(
|
||||||
|
public val symbol: String,
|
||||||
|
public val factor: Double
|
||||||
|
) {
|
||||||
|
YOTTA("Y", 1e24),
|
||||||
|
ZETTA("Z", 1e21),
|
||||||
|
EXA("E", 1e18),
|
||||||
|
PETA("P", 1e15),
|
||||||
|
TERA("T", 1e12),
|
||||||
|
GIGA("G", 1e9),
|
||||||
|
MEGA("M", 1e6),
|
||||||
|
KILO("k", 1e3),
|
||||||
|
HECTO("h", 1e2),
|
||||||
|
DECA("da", 1e1),
|
||||||
|
NONE("", 1.0),
|
||||||
|
DECI("d", 1e-1),
|
||||||
|
CENTI("c", 1e-2),
|
||||||
|
MILLI("m", 1e-3),
|
||||||
|
MICRO("μ", 1e-6),
|
||||||
|
NANO("n", 1e-9),
|
||||||
|
PICO("p", 1e-12),
|
||||||
|
FEMTO("f", 1e-15),
|
||||||
|
ATTO("a", 1e-18),
|
||||||
|
ZEPTO("z", 1e-21),
|
||||||
|
YOCTO("y", 1e-24),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new unit by applying an SI prefix to the current unit.
|
||||||
|
*/
|
||||||
|
public fun <U : UnitsOfMeasurement> U.withPrefix(prefix: SIPrefix): UnitsOfMeasurement {
|
||||||
|
val prefixedSymbol = prefix.symbol + this.symbol
|
||||||
|
val prefixedFactor = prefix.factor
|
||||||
|
|
||||||
|
return object : UnitsOfMeasurement {
|
||||||
|
override val symbol: String = prefixedSymbol
|
||||||
|
|
||||||
|
override fun toBase(value: Double): Double = this@withPrefix.toBase(value * prefixedFactor)
|
||||||
|
|
||||||
|
override fun fromBase(value: Double): Double = this@withPrefix.fromBase(value) / prefixedFactor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for units of length.
|
||||||
|
*/
|
||||||
|
public interface UnitsOfLength : UnitsOfMeasurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base unit for length: Meter.
|
||||||
|
*/
|
||||||
|
public data object Meters : UnitsOfLength {
|
||||||
|
override val symbol: String = "m"
|
||||||
|
override fun toBase(value: Double): Double = value // Base unit
|
||||||
|
override fun fromBase(value: Double): Double = value
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for units of time.
|
||||||
|
*/
|
||||||
|
public interface UnitsOfTime : UnitsOfMeasurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base unit for time: Second.
|
||||||
|
*/
|
||||||
|
public data object Seconds : UnitsOfTime {
|
||||||
|
override val symbol: String = "s"
|
||||||
|
override fun toBase(value: Double): Double = value // Base unit
|
||||||
|
override fun fromBase(value: Double): Double = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for units of velocity.
|
||||||
|
*/
|
||||||
|
public interface UnitsOfVelocity : UnitsOfMeasurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derived unit for velocity: Meters per Second.
|
||||||
|
*/
|
||||||
|
public data object MetersPerSecond : UnitsOfVelocity {
|
||||||
|
override val symbol: String = "m/s"
|
||||||
|
override fun toBase(value: Double): Double = value // Base unit for velocity
|
||||||
|
override fun fromBase(value: Double): Double = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed interface for units of angles.
|
||||||
|
*/
|
||||||
|
public sealed interface UnitsOfAngles : UnitsOfMeasurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base unit for angles: Radian.
|
||||||
|
*/
|
||||||
|
public data object Radians : UnitsOfAngles {
|
||||||
|
override val symbol: String = "rad"
|
||||||
|
override fun toBase(value: Double): Double = value // Base unit
|
||||||
|
override fun fromBase(value: Double): Double = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit for angles: Degree.
|
||||||
|
*/
|
||||||
|
public data object Degrees : UnitsOfAngles {
|
||||||
|
override val symbol: String = "deg"
|
||||||
|
override fun toBase(value: Double): Double = value * (PI / 180.0)
|
||||||
|
override fun fromBase(value: Double): Double = value * (180.0 / PI)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sealed interface for units of angular velocity.
|
||||||
|
*/
|
||||||
|
public sealed interface UnitsAngularOfVelocity : UnitsOfMeasurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base unit for angular velocity: Radians per Second.
|
||||||
|
*/
|
||||||
|
public data object RadiansPerSecond : UnitsAngularOfVelocity {
|
||||||
|
override val symbol: String = "rad/s"
|
||||||
|
override fun toBase(value: Double): Double = value // Base unit
|
||||||
|
override fun fromBase(value: Double): Double = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit for angular velocity: Degrees per Second.
|
||||||
|
*/
|
||||||
|
public data object DegreesPerSecond : UnitsAngularOfVelocity {
|
||||||
|
override val symbol: String = "deg/s"
|
||||||
|
override fun toBase(value: Double): Double = value * (PI / 180.0)
|
||||||
|
override fun fromBase(value: Double): Double = value * (180.0 / PI)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for units of force.
|
||||||
|
*/
|
||||||
|
public interface UnitsOfForce : UnitsOfMeasurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base unit for force: Newton.
|
||||||
|
*/
|
||||||
|
public data object Newtons : UnitsOfForce {
|
||||||
|
override val symbol: String = "N"
|
||||||
|
override fun toBase(value: Double): Double = value // Base unit
|
||||||
|
override fun fromBase(value: Double): Double = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for units of torque.
|
||||||
|
*/
|
||||||
|
public interface UnitsOfTorque : UnitsOfMeasurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base unit for torque: Newton Meter.
|
||||||
|
*/
|
||||||
|
public data object NewtonMeters : UnitsOfTorque {
|
||||||
|
override val symbol: String = "N·m"
|
||||||
|
override fun toBase(value: Double): Double = value // Base unit
|
||||||
|
override fun fromBase(value: Double): Double = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for units of mass.
|
||||||
|
*/
|
||||||
|
public interface UnitsOfMass : UnitsOfMeasurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base unit for mass: Kilogram.
|
||||||
|
*/
|
||||||
|
public data object Kilograms : UnitsOfMass {
|
||||||
|
override val symbol: String = "kg"
|
||||||
|
override fun toBase(value: Double): Double = value // Base unit
|
||||||
|
override fun fromBase(value: Double): Double = value
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface for units of moment of inertia.
|
||||||
|
*/
|
||||||
|
public interface UnitsOfMomentOfInertia : UnitsOfMeasurement
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base unit for moment of inertia: Kilogram Meter Squared.
|
||||||
|
*/
|
||||||
|
public data object KgM2 : UnitsOfMomentOfInertia {
|
||||||
|
override val symbol: String = "kg·m²"
|
||||||
|
override fun toBase(value: Double): Double = value // Base unit
|
||||||
|
override fun fromBase(value: Double): Double = value
|
||||||
|
}
|
@ -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)
|
@ -0,0 +1,806 @@
|
|||||||
|
package space.kscience.controls.constructor
|
||||||
|
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import space.kscience.controls.api.LifecycleState
|
||||||
|
import space.kscience.controls.spec.*
|
||||||
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.meta.*
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
// Спецификация шагового мотора
|
||||||
|
object StepperMotorSpec : DeviceSpec<StepperMotorDevice>() {
|
||||||
|
|
||||||
|
val position by intProperty(
|
||||||
|
read = { propertyName -> getPosition() },
|
||||||
|
write = { propertyName, value -> setPosition(value) }
|
||||||
|
)
|
||||||
|
|
||||||
|
val maxPosition by intProperty(
|
||||||
|
read = { propertyName -> maxPosition }
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): StepperMotorDevice {
|
||||||
|
return StepperMotorDevice(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реализация устройства шагового мотора
|
||||||
|
class StepperMotorDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<StepperMotorDevice>(StepperMotorSpec, context, meta) {
|
||||||
|
|
||||||
|
private var _position: Int = 0
|
||||||
|
val maxPosition: Int = meta["maxPosition"].int ?: 100
|
||||||
|
|
||||||
|
// Получить текущую позицию мотора
|
||||||
|
suspend fun getPosition(): Int = _position
|
||||||
|
|
||||||
|
// Установить позицию мотора
|
||||||
|
suspend fun setPosition(value: Int) {
|
||||||
|
if (value in 0..maxPosition) {
|
||||||
|
_position = value
|
||||||
|
println("StepperMotorDevice: Перемещен в позицию $_position")
|
||||||
|
delay(100) // Имитация времени на перемещение
|
||||||
|
} else {
|
||||||
|
println("StepperMotorDevice: Неверная позиция $value (макс: $maxPosition)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "StepperMotorDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спецификация клапана
|
||||||
|
object ValveSpec : DeviceSpec<ValveDevice>() {
|
||||||
|
|
||||||
|
val state by booleanProperty(
|
||||||
|
read = { propertyName -> getState() },
|
||||||
|
write = { propertyName, value -> setState(value) }
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): ValveDevice {
|
||||||
|
return ValveDevice(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реализация устройства клапана
|
||||||
|
class ValveDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<ValveDevice>(ValveSpec, context, meta) {
|
||||||
|
|
||||||
|
private var _state: Boolean = false
|
||||||
|
|
||||||
|
// Получить состояние клапана
|
||||||
|
suspend fun getState(): Boolean = _state
|
||||||
|
|
||||||
|
// Установить состояние клапана
|
||||||
|
suspend fun setState(value: Boolean) {
|
||||||
|
_state = value
|
||||||
|
val stateStr = if (_state) "открыт" else "закрыт"
|
||||||
|
println("ValveDevice: Клапан теперь $stateStr")
|
||||||
|
delay(50) // Имитация времени на изменение состояния
|
||||||
|
}
|
||||||
|
|
||||||
|
// Метод для щелчка клапана
|
||||||
|
suspend fun click() {
|
||||||
|
println("ValveDevice: Клик клапана...")
|
||||||
|
setState(true)
|
||||||
|
delay(50)
|
||||||
|
setState(false)
|
||||||
|
println("ValveDevice: Клик клапана завершен")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "ValveDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спецификация камеры давления
|
||||||
|
object PressureChamberSpec : DeviceSpec<PressureChamberDevice>() {
|
||||||
|
|
||||||
|
val pressure by doubleProperty(
|
||||||
|
read = { propertyName -> getPressure() },
|
||||||
|
write = { propertyName, value -> setPressure(value) }
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): PressureChamberDevice {
|
||||||
|
return PressureChamberDevice(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реализация устройства камеры давления
|
||||||
|
class PressureChamberDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<PressureChamberDevice>(PressureChamberSpec, context, meta) {
|
||||||
|
|
||||||
|
private var _pressure: Double = 0.0
|
||||||
|
|
||||||
|
// Получить текущее давление
|
||||||
|
suspend fun getPressure(): Double = _pressure
|
||||||
|
|
||||||
|
// Установить давление
|
||||||
|
suspend fun setPressure(value: Double) {
|
||||||
|
_pressure = value
|
||||||
|
println("PressureChamberDevice: Давление установлено на $_pressure")
|
||||||
|
delay(50)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "PressureChamberDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спецификация шприцевого насоса
|
||||||
|
object SyringePumpSpec : DeviceSpec<SyringePumpDevice>() {
|
||||||
|
|
||||||
|
val volume by doubleProperty(
|
||||||
|
read = { propertyName -> getVolume() },
|
||||||
|
write = { propertyName, value -> setVolume(value) }
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): SyringePumpDevice {
|
||||||
|
return SyringePumpDevice(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реализация устройства шприцевого насоса
|
||||||
|
class SyringePumpDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<SyringePumpDevice>(SyringePumpSpec, context, meta) {
|
||||||
|
|
||||||
|
private var _volume: Double = 0.0
|
||||||
|
val maxVolume: Double = meta["maxVolume"].double ?: 5.0
|
||||||
|
|
||||||
|
// Получить текущий объем
|
||||||
|
suspend fun getVolume(): Double = _volume
|
||||||
|
|
||||||
|
// Установить объем
|
||||||
|
suspend fun setVolume(value: Double) {
|
||||||
|
if (value in 0.0..maxVolume) {
|
||||||
|
_volume = value
|
||||||
|
println("SyringePumpDevice: Объем установлен на $_volume мл")
|
||||||
|
delay(100)
|
||||||
|
} else {
|
||||||
|
println("SyringePumpDevice: Неверный объем $value (макс: $maxVolume)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "SyringePumpDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спецификация датчика реагента
|
||||||
|
object ReagentSensorSpec : DeviceSpec<ReagentSensorDevice>() {
|
||||||
|
|
||||||
|
val isPresent by booleanProperty(
|
||||||
|
read = { propertyName -> checkReagent() }
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): ReagentSensorDevice {
|
||||||
|
return ReagentSensorDevice(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реализация устройства датчика реагента
|
||||||
|
class ReagentSensorDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<ReagentSensorDevice>(ReagentSensorSpec, context, meta) {
|
||||||
|
|
||||||
|
// Проверить наличие реагента
|
||||||
|
suspend fun checkReagent(): Boolean {
|
||||||
|
println("ReagentSensorDevice: Проверка наличия реагента...")
|
||||||
|
delay(100) // Имитация времени на проверку
|
||||||
|
val isPresent = true // Предполагаем, что реагент присутствует
|
||||||
|
println("ReagentSensorDevice: Реагент ${if (isPresent) "обнаружен" else "не обнаружен"}")
|
||||||
|
return isPresent
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "ReagentSensorDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спецификация иглы
|
||||||
|
object NeedleSpec : DeviceSpec<NeedleDevice>() {
|
||||||
|
|
||||||
|
val mode by enumProperty(
|
||||||
|
enumValues = NeedleDevice.Mode.entries.toTypedArray(),
|
||||||
|
read = { propertyName -> getMode() },
|
||||||
|
write = { propertyName, value -> setMode(value) }
|
||||||
|
)
|
||||||
|
|
||||||
|
val position by doubleProperty(
|
||||||
|
read = { propertyName -> getPosition() },
|
||||||
|
write = { propertyName, value -> setPosition(value) }
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): NeedleDevice {
|
||||||
|
return NeedleDevice(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реализация устройства иглы
|
||||||
|
class NeedleDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<NeedleDevice>(NeedleSpec, context, meta) {
|
||||||
|
|
||||||
|
// Режимы работы иглы
|
||||||
|
enum class Mode { SAMPLING, WASHING }
|
||||||
|
|
||||||
|
private var mode: Mode = Mode.WASHING
|
||||||
|
private var position: Double = 0.0 // в мм
|
||||||
|
|
||||||
|
// Получить текущий режим
|
||||||
|
suspend fun getMode(): Mode = mode
|
||||||
|
|
||||||
|
// Установить режим
|
||||||
|
suspend fun setMode(value: Mode) {
|
||||||
|
mode = value
|
||||||
|
println("NeedleDevice: Режим установлен на $mode")
|
||||||
|
delay(50)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить текущую позицию
|
||||||
|
suspend fun getPosition(): Double = position
|
||||||
|
|
||||||
|
// Установить позицию
|
||||||
|
suspend fun setPosition(value: Double) {
|
||||||
|
if (value in 0.0..100.0) {
|
||||||
|
position = value
|
||||||
|
println("NeedleDevice: Перемещена в позицию $position мм")
|
||||||
|
delay(100)
|
||||||
|
} else {
|
||||||
|
println("NeedleDevice: Неверная позиция $value мм")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выполнить промывку
|
||||||
|
suspend fun performWashing(duration: Int) {
|
||||||
|
println("NeedleDevice: Промывка в течение $duration секунд")
|
||||||
|
delay(duration * 1000L) // Имитация промывки (1 секунда = 1000 мс)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выполнить забор пробы
|
||||||
|
suspend fun performSampling() {
|
||||||
|
println("NeedleDevice: Забор пробы в позиции $position мм")
|
||||||
|
delay(500) // Имитация забора пробы
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "NeedleDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спецификация шейкера
|
||||||
|
object ShakerSpec : DeviceSpec<ShakerDevice>() {
|
||||||
|
|
||||||
|
val verticalMotor by device(StepperMotorSpec, name = "verticalMotor")
|
||||||
|
val horizontalMotor by device(StepperMotorSpec, name = "horizontalMotor")
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): ShakerDevice {
|
||||||
|
return ShakerDevice(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реализация устройства шейкера
|
||||||
|
class ShakerDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<ShakerDevice>(ShakerSpec, context, meta) {
|
||||||
|
|
||||||
|
val verticalMotor: StepperMotorDevice
|
||||||
|
get() = nestedDevices["verticalMotor"] as StepperMotorDevice
|
||||||
|
|
||||||
|
val horizontalMotor: StepperMotorDevice
|
||||||
|
get() = nestedDevices["horizontalMotor"] as StepperMotorDevice
|
||||||
|
|
||||||
|
// Метод встряхивания
|
||||||
|
suspend fun shake(cycles: Int) {
|
||||||
|
println("ShakerDevice: Начало встряхивания, циклов: $cycles")
|
||||||
|
repeat(cycles) {
|
||||||
|
verticalMotor.setPosition(3)
|
||||||
|
verticalMotor.setPosition(1)
|
||||||
|
}
|
||||||
|
println("ShakerDevice: Встряхивание завершено")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "ShakerDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спецификация системы транспортировки
|
||||||
|
object TransportationSystemSpec : DeviceSpec<TransportationSystem>() {
|
||||||
|
|
||||||
|
val slideMotor by device(StepperMotorSpec, name = "slideMotor")
|
||||||
|
val pushMotor by device(StepperMotorSpec, name = "pushMotor")
|
||||||
|
val receiveMotor by device(StepperMotorSpec, name = "receiveMotor")
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): TransportationSystem {
|
||||||
|
return TransportationSystem(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реализация системы транспортировки
|
||||||
|
class TransportationSystem(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<TransportationSystem>(TransportationSystemSpec, context, meta) {
|
||||||
|
|
||||||
|
val slideMotor: StepperMotorDevice
|
||||||
|
get() = nestedDevices["slideMotor"] as StepperMotorDevice
|
||||||
|
|
||||||
|
val pushMotor: StepperMotorDevice
|
||||||
|
get() = nestedDevices["pushMotor"] as StepperMotorDevice
|
||||||
|
|
||||||
|
val receiveMotor: StepperMotorDevice
|
||||||
|
get() = nestedDevices["receiveMotor"] as StepperMotorDevice
|
||||||
|
|
||||||
|
override fun toString(): String = "TransportationSystem"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Спецификация анализатора
|
||||||
|
object AnalyzerSpec : DeviceSpec<AnalyzerDevice>() {
|
||||||
|
|
||||||
|
val transportationSystem by device(TransportationSystemSpec, name = "transportationSystem")
|
||||||
|
val shakerDevice by device(ShakerSpec, name = "shakerDevice")
|
||||||
|
val needleDevice by device(NeedleSpec, name = "needleDevice")
|
||||||
|
|
||||||
|
val valveV20 by device(ValveSpec, name = "valveV20")
|
||||||
|
val valveV17 by device(ValveSpec, name = "valveV17")
|
||||||
|
val valveV18 by device(ValveSpec, name = "valveV18")
|
||||||
|
val valveV35 by device(ValveSpec, name = "valveV35")
|
||||||
|
|
||||||
|
val pressureChamberHigh by device(PressureChamberSpec, name = "pressureChamberHigh")
|
||||||
|
val pressureChamberLow by device(PressureChamberSpec, name = "pressureChamberLow")
|
||||||
|
|
||||||
|
val syringePumpMA100 by device(SyringePumpSpec, name = "syringePumpMA100")
|
||||||
|
val syringePumpMA25 by device(SyringePumpSpec, name = "syringePumpMA25")
|
||||||
|
|
||||||
|
val reagentSensor1 by device(ReagentSensorSpec, name = "reagentSensor1")
|
||||||
|
val reagentSensor2 by device(ReagentSensorSpec, name = "reagentSensor2")
|
||||||
|
val reagentSensor3 by device(ReagentSensorSpec, name = "reagentSensor3")
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): AnalyzerDevice {
|
||||||
|
return AnalyzerDevice(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реализация анализатора
|
||||||
|
class AnalyzerDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<AnalyzerDevice>(AnalyzerSpec, context, meta) {
|
||||||
|
|
||||||
|
val transportationSystem: TransportationSystem
|
||||||
|
get() = nestedDevices["transportationSystem"] as TransportationSystem
|
||||||
|
|
||||||
|
val shakerDevice: ShakerDevice
|
||||||
|
get() = nestedDevices["shakerDevice"] as ShakerDevice
|
||||||
|
|
||||||
|
val needleDevice: NeedleDevice
|
||||||
|
get() = nestedDevices["needleDevice"] as NeedleDevice
|
||||||
|
|
||||||
|
val valveV20: ValveDevice
|
||||||
|
get() = nestedDevices["valveV20"] as ValveDevice
|
||||||
|
|
||||||
|
val valveV17: ValveDevice
|
||||||
|
get() = nestedDevices["valveV17"] as ValveDevice
|
||||||
|
|
||||||
|
val valveV18: ValveDevice
|
||||||
|
get() = nestedDevices["valveV18"] as ValveDevice
|
||||||
|
|
||||||
|
val valveV35: ValveDevice
|
||||||
|
get() = nestedDevices["valveV35"] as ValveDevice
|
||||||
|
|
||||||
|
val pressureChamberHigh: PressureChamberDevice
|
||||||
|
get() = nestedDevices["pressureChamberHigh"] as PressureChamberDevice
|
||||||
|
|
||||||
|
val pressureChamberLow: PressureChamberDevice
|
||||||
|
get() = nestedDevices["pressureChamberLow"] as PressureChamberDevice
|
||||||
|
|
||||||
|
val syringePumpMA100: SyringePumpDevice
|
||||||
|
get() = nestedDevices["syringePumpMA100"] as SyringePumpDevice
|
||||||
|
|
||||||
|
val syringePumpMA25: SyringePumpDevice
|
||||||
|
get() = nestedDevices["syringePumpMA25"] as SyringePumpDevice
|
||||||
|
|
||||||
|
val reagentSensor1: ReagentSensorDevice
|
||||||
|
get() = nestedDevices["reagentSensor1"] as ReagentSensorDevice
|
||||||
|
|
||||||
|
val reagentSensor2: ReagentSensorDevice
|
||||||
|
get() = nestedDevices["reagentSensor2"] as ReagentSensorDevice
|
||||||
|
|
||||||
|
val reagentSensor3: ReagentSensorDevice
|
||||||
|
get() = nestedDevices["reagentSensor3"] as ReagentSensorDevice
|
||||||
|
|
||||||
|
// Процесс обработки проб
|
||||||
|
suspend fun processSample() {
|
||||||
|
println("Начало процесса забора пробы")
|
||||||
|
|
||||||
|
// Шаг 1: Открыть клапан V20 и начать забор пробы с помощью шприца MA 100 мкл
|
||||||
|
valveV20.setState(true)
|
||||||
|
syringePumpMA100.setVolume(0.1)
|
||||||
|
delay(500) // Имитация времени для забора жидкости
|
||||||
|
valveV20.setState(false)
|
||||||
|
|
||||||
|
// Шаг 2: Открыть клапан V17 и начать подачу лизиса в WBC с помощью MA 2.5 мл
|
||||||
|
valveV17.setState(true)
|
||||||
|
syringePumpMA25.setVolume(2.5)
|
||||||
|
delay(500) // Имитация времени для подачи лизиса
|
||||||
|
valveV17.setState(false)
|
||||||
|
|
||||||
|
// Шаг 3: Очистка системы
|
||||||
|
syringePumpMA100.setVolume(0.0)
|
||||||
|
syringePumpMA25.setVolume(0.0)
|
||||||
|
|
||||||
|
println("Процесс забора пробы завершен")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Реализация рецепта калибровки
|
||||||
|
suspend fun calibrate() {
|
||||||
|
println("Начало калибровки...")
|
||||||
|
|
||||||
|
// Шаг 1: Откалибровать положения всех двигателей
|
||||||
|
val motors = listOf(
|
||||||
|
transportationSystem.slideMotor,
|
||||||
|
transportationSystem.pushMotor,
|
||||||
|
transportationSystem.receiveMotor,
|
||||||
|
shakerDevice.verticalMotor,
|
||||||
|
shakerDevice.horizontalMotor
|
||||||
|
)
|
||||||
|
|
||||||
|
for (motor in motors) {
|
||||||
|
for (position in 0..motor.maxPosition) {
|
||||||
|
motor.setPosition(position)
|
||||||
|
}
|
||||||
|
motor.setPosition(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 2: Щелкнуть всеми клапанами и перевести их в нулевое положение
|
||||||
|
val valves = listOf(valveV20, valveV17, valveV18, valveV35)
|
||||||
|
for (valve in valves) {
|
||||||
|
valve.click()
|
||||||
|
valve.setState(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Шаг 3: Провести накачку давления в камеру повышенного давления
|
||||||
|
pressureChamberHigh.setPressure(2.0)
|
||||||
|
|
||||||
|
// Шаг 4: Провести откачку камеры пониженного давления
|
||||||
|
pressureChamberLow.setPressure(-1.0)
|
||||||
|
|
||||||
|
// Шаг 5: Заполнить гидравлическую систему
|
||||||
|
|
||||||
|
// 5.1 Проверить наличие всех реагентов
|
||||||
|
val sensors = listOf(reagentSensor1, reagentSensor2, reagentSensor3)
|
||||||
|
for (sensor in sensors) {
|
||||||
|
sensor.checkReagent()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5.2 Прокачать все шприцевые дозаторы (5 раз движение между крайними положениями)
|
||||||
|
val pumps = listOf(syringePumpMA100, syringePumpMA25)
|
||||||
|
for (pump in pumps) {
|
||||||
|
repeat(5) {
|
||||||
|
pump.setVolume(pump.maxVolume)
|
||||||
|
pump.setVolume(0.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5.3 Провести промывку иглы в положении промывки для забора пробы
|
||||||
|
needleDevice.setPosition(0.0)
|
||||||
|
needleDevice.setMode(NeedleDevice.Mode.WASHING)
|
||||||
|
needleDevice.performWashing(5)
|
||||||
|
|
||||||
|
println("Калибровка завершена")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рецепт 1 - Подача пробирки на измерение
|
||||||
|
suspend fun executeRecipe1() {
|
||||||
|
println("Выполнение Рецепта 1")
|
||||||
|
|
||||||
|
// Шаг 1: Сдвинуть планшет на одну позицию
|
||||||
|
val currentSlidePosition = transportationSystem.slideMotor.getPosition()
|
||||||
|
transportationSystem.slideMotor.setPosition(currentSlidePosition + 1)
|
||||||
|
println("Сдвинуто планшет на позицию ${currentSlidePosition + 1}")
|
||||||
|
|
||||||
|
// Шаг 2: Захват пробирки для смешивания
|
||||||
|
println("Захват пробирки для смешивания")
|
||||||
|
|
||||||
|
// 2.1 - 2.10: Управление шейкером и двигателями
|
||||||
|
shakerDevice.verticalMotor.setPosition(1)
|
||||||
|
shakerDevice.horizontalMotor.setPosition(1)
|
||||||
|
println("Шейкер: вертикальный - 1, горизонтальный - 1")
|
||||||
|
|
||||||
|
shakerDevice.horizontalMotor.setPosition(2)
|
||||||
|
println("Шейкер: горизонтальный - 2")
|
||||||
|
|
||||||
|
shakerDevice.verticalMotor.setPosition(2)
|
||||||
|
println("Шейкер: вертикальный - 2")
|
||||||
|
|
||||||
|
// Встряхивание 5 циклов
|
||||||
|
repeat(5) {
|
||||||
|
shakerDevice.verticalMotor.setPosition(3)
|
||||||
|
shakerDevice.verticalMotor.setPosition(1)
|
||||||
|
println("Шейкер: цикл ${it + 1}")
|
||||||
|
}
|
||||||
|
|
||||||
|
shakerDevice.verticalMotor.setPosition(2)
|
||||||
|
shakerDevice.horizontalMotor.setPosition(1)
|
||||||
|
println("Шейкер: окончание движения")
|
||||||
|
|
||||||
|
// Шаг 3: Забор и измерение пробы
|
||||||
|
executeSampling()
|
||||||
|
needleDevice.setPosition(0.0)
|
||||||
|
println("Игла вернулась в исходное положение")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция для выполнения забора пробы
|
||||||
|
suspend fun executeSampling() {
|
||||||
|
println("Забор и измерение пробы")
|
||||||
|
|
||||||
|
needleDevice.setPosition(0.0)
|
||||||
|
needleDevice.setMode(NeedleDevice.Mode.WASHING)
|
||||||
|
needleDevice.performWashing(5)
|
||||||
|
|
||||||
|
needleDevice.setPosition(10.0)
|
||||||
|
needleDevice.setMode(NeedleDevice.Mode.SAMPLING)
|
||||||
|
needleDevice.performSampling()
|
||||||
|
|
||||||
|
needleDevice.setPosition(0.0)
|
||||||
|
needleDevice.setMode(NeedleDevice.Mode.WASHING)
|
||||||
|
needleDevice.performWashing(5)
|
||||||
|
|
||||||
|
needleDevice.setPosition(20.0)
|
||||||
|
println("Игла в положении WOC")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рецепт 2 - Автоматическое измерение
|
||||||
|
suspend fun executeRecipe2() {
|
||||||
|
println("Выполнение Рецепта 2 - Автоматическое измерение")
|
||||||
|
|
||||||
|
transportationSystem.receiveMotor.setPosition(transportationSystem.receiveMotor.getPosition() + 1)
|
||||||
|
println("Сдвинуто податчик на 1 позицию")
|
||||||
|
|
||||||
|
// Проверка лотка
|
||||||
|
if (!checkTrayInPushSystem()) {
|
||||||
|
println("Лоток отсутствует. Повторный сдвиг")
|
||||||
|
transportationSystem.receiveMotor.setPosition(transportationSystem.receiveMotor.getPosition() + 1)
|
||||||
|
} else {
|
||||||
|
executeSampling()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если достигнута последняя позиция, меняем планшет
|
||||||
|
if (transportationSystem.receiveMotor.getPosition() >= transportationSystem.receiveMotor.maxPosition) {
|
||||||
|
println("Смена планшета. Возврат податчика в исходное положение")
|
||||||
|
transportationSystem.receiveMotor.setPosition(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
println("Рецепт 2 завершен")
|
||||||
|
needleDevice.setPosition(0.0)
|
||||||
|
println("Игла вернулась в исходное положение")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Рецепт 3 - Одиночное измерение
|
||||||
|
suspend fun executeRecipe3() {
|
||||||
|
println("Выполнение Рецепта 3 - Одиночное измерение")
|
||||||
|
executeSampling()
|
||||||
|
println("Рецепт 3 завершен")
|
||||||
|
needleDevice.setPosition(0.0)
|
||||||
|
println("Игла вернулась в исходное положение")
|
||||||
|
}
|
||||||
|
|
||||||
|
// функция проверки наличия лотка
|
||||||
|
private suspend fun checkTrayInPushSystem(): Boolean {
|
||||||
|
println("Проверка наличия лотка в системе проталкивания")
|
||||||
|
delay(200)
|
||||||
|
return true // Имитация наличия лотка
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------
|
||||||
|
// Тестирование анализатора
|
||||||
|
// -----------------------
|
||||||
|
class AnalyzerTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testAnalyzerInitialization() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val analyzer = AnalyzerDevice(context)
|
||||||
|
|
||||||
|
analyzer.start()
|
||||||
|
|
||||||
|
// Проверка состояния устройств
|
||||||
|
assertEquals(LifecycleState.STARTED, analyzer.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STARTED, analyzer.transportationSystem.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STARTED, analyzer.shakerDevice.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STARTED, analyzer.needleDevice.lifecycleState)
|
||||||
|
|
||||||
|
// Проверка начальных положений двигателей
|
||||||
|
assertEquals(0, analyzer.transportationSystem.slideMotor.getPosition())
|
||||||
|
assertEquals(0, analyzer.transportationSystem.pushMotor.getPosition())
|
||||||
|
assertEquals(0, analyzer.transportationSystem.receiveMotor.getPosition())
|
||||||
|
assertEquals(0, analyzer.shakerDevice.verticalMotor.getPosition())
|
||||||
|
assertEquals(0, analyzer.shakerDevice.horizontalMotor.getPosition())
|
||||||
|
assertEquals(0.0, analyzer.needleDevice.getPosition())
|
||||||
|
|
||||||
|
// Проверка состояния клапанов
|
||||||
|
assertFalse(analyzer.valveV20.getState())
|
||||||
|
assertFalse(analyzer.valveV17.getState())
|
||||||
|
assertFalse(analyzer.valveV18.getState())
|
||||||
|
assertFalse(analyzer.valveV35.getState())
|
||||||
|
|
||||||
|
analyzer.stop()
|
||||||
|
assertEquals(LifecycleState.STOPPED, analyzer.lifecycleState)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCalibration() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val analyzer = AnalyzerDevice(context)
|
||||||
|
|
||||||
|
analyzer.start()
|
||||||
|
|
||||||
|
// Запуск калибровки
|
||||||
|
analyzer.calibrate()
|
||||||
|
|
||||||
|
// Проверка состояния двигателей после калибровки
|
||||||
|
assertEquals(0, analyzer.transportationSystem.slideMotor.getPosition())
|
||||||
|
assertEquals(0, analyzer.transportationSystem.pushMotor.getPosition())
|
||||||
|
assertEquals(0, analyzer.transportationSystem.receiveMotor.getPosition())
|
||||||
|
assertEquals(0, analyzer.shakerDevice.verticalMotor.getPosition())
|
||||||
|
assertEquals(0, analyzer.shakerDevice.horizontalMotor.getPosition())
|
||||||
|
|
||||||
|
// Проверка давления после калибровки
|
||||||
|
assertEquals(2.0, analyzer.pressureChamberHigh.getPressure())
|
||||||
|
assertEquals(-1.0, analyzer.pressureChamberLow.getPressure())
|
||||||
|
|
||||||
|
// Проверка состояния реагентных сенсоров
|
||||||
|
assertTrue(analyzer.reagentSensor1.checkReagent())
|
||||||
|
assertTrue(analyzer.reagentSensor2.checkReagent())
|
||||||
|
assertTrue(analyzer.reagentSensor3.checkReagent())
|
||||||
|
|
||||||
|
analyzer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRecipe1() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val analyzer = AnalyzerDevice(context)
|
||||||
|
|
||||||
|
analyzer.start()
|
||||||
|
|
||||||
|
// Выполнение рецепта 1 (подачи пробирки)
|
||||||
|
analyzer.executeRecipe1()
|
||||||
|
|
||||||
|
// Проверка конечных состояний после рецепта
|
||||||
|
assertEquals(1, analyzer.transportationSystem.slideMotor.getPosition())
|
||||||
|
assertEquals(1, analyzer.shakerDevice.verticalMotor.getPosition())
|
||||||
|
assertEquals(1, analyzer.shakerDevice.horizontalMotor.getPosition())
|
||||||
|
|
||||||
|
// Проверка положения иглы после забора пробы
|
||||||
|
assertEquals(0.0, analyzer.needleDevice.getPosition())
|
||||||
|
|
||||||
|
analyzer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRecipe2() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val analyzer = AnalyzerDevice(context)
|
||||||
|
|
||||||
|
analyzer.start()
|
||||||
|
|
||||||
|
// Выполнение рецепта 2 (автоматическое измерение)
|
||||||
|
analyzer.executeRecipe2()
|
||||||
|
|
||||||
|
// Проверка конечного положения двигателей
|
||||||
|
assertTrue(analyzer.transportationSystem.receiveMotor.getPosition() > 0)
|
||||||
|
|
||||||
|
// Проверка иглы после выполнения рецепта
|
||||||
|
assertEquals(0.0, analyzer.needleDevice.getPosition())
|
||||||
|
|
||||||
|
analyzer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testRecipe3() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val analyzer = AnalyzerDevice(context)
|
||||||
|
|
||||||
|
analyzer.start()
|
||||||
|
|
||||||
|
// Выполнение рецепта 3 (одиночное измерение)
|
||||||
|
analyzer.executeRecipe3()
|
||||||
|
|
||||||
|
// Проверка иглы после одиночного измерения
|
||||||
|
assertEquals(0.0, analyzer.needleDevice.getPosition())
|
||||||
|
|
||||||
|
// Проверка завершения одиночного измерения
|
||||||
|
println("Одиночное измерение завершено")
|
||||||
|
|
||||||
|
analyzer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDetailedMotorMovements() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val analyzer = AnalyzerDevice(context)
|
||||||
|
|
||||||
|
analyzer.start()
|
||||||
|
|
||||||
|
// Проверка движения двигателей при выполнении задач
|
||||||
|
analyzer.transportationSystem.slideMotor.setPosition(2)
|
||||||
|
assertEquals(2, analyzer.transportationSystem.slideMotor.getPosition())
|
||||||
|
|
||||||
|
analyzer.shakerDevice.verticalMotor.setPosition(1)
|
||||||
|
analyzer.shakerDevice.horizontalMotor.setPosition(3)
|
||||||
|
assertEquals(1, analyzer.shakerDevice.verticalMotor.getPosition())
|
||||||
|
assertEquals(3, analyzer.shakerDevice.horizontalMotor.getPosition())
|
||||||
|
|
||||||
|
analyzer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testValveStates() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val analyzer = AnalyzerDevice(context)
|
||||||
|
|
||||||
|
analyzer.start()
|
||||||
|
|
||||||
|
// Проверка работы клапанов
|
||||||
|
analyzer.valveV20.setState(true)
|
||||||
|
assertTrue(analyzer.valveV20.getState())
|
||||||
|
|
||||||
|
analyzer.valveV17.setState(false)
|
||||||
|
assertFalse(analyzer.valveV17.getState())
|
||||||
|
|
||||||
|
analyzer.valveV18.click() // Щелчок клапаном
|
||||||
|
assertFalse(analyzer.valveV18.getState())
|
||||||
|
|
||||||
|
analyzer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSyringePumpOperations() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val analyzer = AnalyzerDevice(context)
|
||||||
|
|
||||||
|
analyzer.start()
|
||||||
|
|
||||||
|
// Проверка работы шприцевого насоса
|
||||||
|
analyzer.syringePumpMA100.setVolume(0.05)
|
||||||
|
assertEquals(0.05, analyzer.syringePumpMA100.getVolume())
|
||||||
|
|
||||||
|
analyzer.syringePumpMA100.setVolume(0.0)
|
||||||
|
assertEquals(0.0, analyzer.syringePumpMA100.getVolume())
|
||||||
|
|
||||||
|
analyzer.syringePumpMA25.setVolume(2.5)
|
||||||
|
assertEquals(2.5, analyzer.syringePumpMA25.getVolume())
|
||||||
|
|
||||||
|
analyzer.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNeedleOperations() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val analyzer = AnalyzerDevice(context)
|
||||||
|
|
||||||
|
analyzer.start()
|
||||||
|
|
||||||
|
// Проверка работы иглы в режиме промывки и забора проб
|
||||||
|
analyzer.needleDevice.setMode(NeedleDevice.Mode.WASHING)
|
||||||
|
analyzer.needleDevice.setPosition(0.0)
|
||||||
|
assertEquals(0.0, analyzer.needleDevice.getPosition())
|
||||||
|
assertEquals(NeedleDevice.Mode.WASHING, analyzer.needleDevice.getMode())
|
||||||
|
|
||||||
|
analyzer.needleDevice.performWashing(5) // Промывка иглы
|
||||||
|
|
||||||
|
analyzer.needleDevice.setMode(NeedleDevice.Mode.SAMPLING)
|
||||||
|
analyzer.needleDevice.setPosition(10.0)
|
||||||
|
assertEquals(10.0, analyzer.needleDevice.getPosition())
|
||||||
|
assertEquals(NeedleDevice.Mode.SAMPLING, analyzer.needleDevice.getMode())
|
||||||
|
|
||||||
|
analyzer.needleDevice.performSampling() // Забор пробы
|
||||||
|
|
||||||
|
analyzer.stop()
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,182 @@
|
|||||||
|
package space.kscience.controls.constructor
|
||||||
|
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import space.kscience.controls.api.*
|
||||||
|
import space.kscience.controls.spec.*
|
||||||
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.meta.*
|
||||||
|
|
||||||
|
class CompositeDeviceTest {
|
||||||
|
|
||||||
|
// simple MotorDevice and MotorSpec
|
||||||
|
class MotorSpec : DeviceSpec<MotorDevice>() {
|
||||||
|
val speed by doubleProperty(
|
||||||
|
name = "speed",
|
||||||
|
read = { propertyName ->
|
||||||
|
getSpeed()
|
||||||
|
},
|
||||||
|
write = { propertyName, value ->
|
||||||
|
setSpeed(value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val position by doubleProperty(
|
||||||
|
name = "position",
|
||||||
|
read = { propertyName ->
|
||||||
|
getPosition()
|
||||||
|
}
|
||||||
|
// Assuming position is read-only
|
||||||
|
)
|
||||||
|
|
||||||
|
val reset by unitAction(
|
||||||
|
name = "reset",
|
||||||
|
execute = {
|
||||||
|
resetMotor()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): MotorDevice {
|
||||||
|
return MotorDevice(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class MotorDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<MotorDevice>(MotorSpec(), context, meta) {
|
||||||
|
private var _speed: Double = 0.0
|
||||||
|
private var _position: Double = 0.0
|
||||||
|
|
||||||
|
suspend fun getSpeed(): Double = _speed
|
||||||
|
suspend fun setSpeed(value: Double) {
|
||||||
|
_speed = value
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPosition(): Double = _position
|
||||||
|
suspend fun resetMotor() {
|
||||||
|
_speed = 0.0
|
||||||
|
_position = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "MotorDevice"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Position(val base: Double, val elbow: Double, val wrist: Double)
|
||||||
|
|
||||||
|
object PositionConverter : MetaConverter<Position> {
|
||||||
|
override fun readOrNull(source: Meta): Position? {
|
||||||
|
val base = source["base"]?.value?.number?.toDouble() ?: return null
|
||||||
|
val elbow = source["elbow"]?.value?.number?.toDouble() ?: return null
|
||||||
|
val wrist = source["wrist"]?.value?.number?.toDouble() ?: return null
|
||||||
|
return Position(base, elbow, wrist)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convert(obj: Position): Meta = Meta {
|
||||||
|
"base" put obj.base
|
||||||
|
"elbow" put obj.elbow
|
||||||
|
"wrist" put obj.wrist
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
object RobotArmSpec : DeviceSpec<RobotArmDevice>() {
|
||||||
|
val baseMotorSpec = MotorSpec()
|
||||||
|
val elbowMotorSpec = MotorSpec()
|
||||||
|
val wristMotorSpec = MotorSpec()
|
||||||
|
|
||||||
|
val baseMotor by device(baseMotorSpec)
|
||||||
|
val elbowMotor by device(elbowMotorSpec)
|
||||||
|
val wristMotor by device(wristMotorSpec)
|
||||||
|
|
||||||
|
val moveToPosition by action(
|
||||||
|
inputConverter = PositionConverter,
|
||||||
|
outputConverter = MetaConverter.unit,
|
||||||
|
name = "moveToPosition",
|
||||||
|
execute = { input ->
|
||||||
|
baseMotor.writeProperty("speed", Meta(input.base))
|
||||||
|
elbowMotor.writeProperty("speed", Meta(input.elbow))
|
||||||
|
wristMotor.writeProperty("speed", Meta(input.wrist))
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun createDevice(context: Context, meta: Meta): RobotArmDevice {
|
||||||
|
return RobotArmDevice(context, meta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class RobotArmDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<RobotArmDevice>(RobotArmSpec, context, meta) {
|
||||||
|
|
||||||
|
val baseMotor: MotorDevice get() = nestedDevices["baseMotor"] as MotorDevice
|
||||||
|
val elbowMotor: MotorDevice get() = nestedDevices["elbowMotor"] as MotorDevice
|
||||||
|
val wristMotor: MotorDevice get() = nestedDevices["wristMotor"] as MotorDevice
|
||||||
|
|
||||||
|
override suspend fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "RobotArmDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testNestedDeviceInitialization() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val robotArm = RobotArmDevice(context)
|
||||||
|
|
||||||
|
// Start the robot arm device
|
||||||
|
robotArm.start()
|
||||||
|
|
||||||
|
// Check that all motors are started
|
||||||
|
assertEquals(LifecycleState.STARTED, robotArm.baseMotor.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STARTED, robotArm.elbowMotor.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STARTED, robotArm.wristMotor.lifecycleState)
|
||||||
|
|
||||||
|
// Stop the robot arm device
|
||||||
|
robotArm.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCompositeDevicePropertyAndActionAccess() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val robotArm = RobotArmDevice(context)
|
||||||
|
|
||||||
|
robotArm.start()
|
||||||
|
|
||||||
|
robotArm.baseMotor.writeProperty("speed", Meta(500.0))
|
||||||
|
|
||||||
|
val speedMeta = robotArm.baseMotor.getProperty("speed")
|
||||||
|
val speed = speedMeta?.value?.number?.toDouble()
|
||||||
|
assertEquals(500.0, speed)
|
||||||
|
|
||||||
|
// Stop the robot arm device
|
||||||
|
robotArm.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testCompositeDeviceLifecycleManagement() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val robotArm = RobotArmDevice(context)
|
||||||
|
|
||||||
|
// Start the robot arm device
|
||||||
|
robotArm.start()
|
||||||
|
assertEquals(LifecycleState.STARTED, robotArm.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STARTED, robotArm.baseMotor.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STARTED, robotArm.elbowMotor.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STARTED, robotArm.wristMotor.lifecycleState)
|
||||||
|
|
||||||
|
// Stop the robot arm device
|
||||||
|
robotArm.stop()
|
||||||
|
assertEquals(LifecycleState.STOPPED, robotArm.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STOPPED, robotArm.baseMotor.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STOPPED, robotArm.elbowMotor.lifecycleState)
|
||||||
|
assertEquals(LifecycleState.STOPPED, robotArm.wristMotor.lifecycleState)
|
||||||
|
}
|
||||||
|
}
|
@ -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")
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,168 @@
|
|||||||
|
package space.kscience.controls.constructor
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import space.kscience.controls.api.LifecycleState
|
||||||
|
import space.kscience.controls.spec.DeviceActionSpec
|
||||||
|
import space.kscience.controls.spec.DeviceBase
|
||||||
|
import space.kscience.controls.spec.DeviceBySpec
|
||||||
|
import space.kscience.controls.spec.DevicePropertySpec
|
||||||
|
import space.kscience.controls.spec.DeviceSpec
|
||||||
|
import space.kscience.controls.spec.doubleProperty
|
||||||
|
import space.kscience.controls.spec.unitAction
|
||||||
|
import space.kscience.controls.spec.validate
|
||||||
|
|
||||||
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.number
|
||||||
|
|
||||||
|
class DeviceSpecTest {
|
||||||
|
|
||||||
|
class MotorSpec : DeviceSpec<MotorDevice>() {
|
||||||
|
val speed by doubleProperty(
|
||||||
|
name = "speed",
|
||||||
|
read = { propertyName ->
|
||||||
|
getSpeed()
|
||||||
|
},
|
||||||
|
write = { propertyName, value ->
|
||||||
|
setSpeed(value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val position by doubleProperty(
|
||||||
|
name = "position",
|
||||||
|
read = { propertyName ->
|
||||||
|
getPosition()
|
||||||
|
}
|
||||||
|
// Assuming position is read-only
|
||||||
|
)
|
||||||
|
|
||||||
|
val reset by unitAction(
|
||||||
|
name = "reset",
|
||||||
|
execute = {
|
||||||
|
resetMotor()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class MotorDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<MotorDevice>(MotorSpec(), context, meta) {
|
||||||
|
private var _speed: Double = 0.0
|
||||||
|
private var _position: Double = 0.0
|
||||||
|
|
||||||
|
suspend fun getSpeed(): Double = _speed
|
||||||
|
suspend fun setSpeed(value: Double) {
|
||||||
|
_speed = value
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun getPosition(): Double = _position
|
||||||
|
suspend fun resetMotor() {
|
||||||
|
_speed = 0.0
|
||||||
|
_position = 0.0
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "MotorDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDevicePropertyDefinitionAndAccess() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val motor = MotorDevice(context)
|
||||||
|
|
||||||
|
motor.start()
|
||||||
|
|
||||||
|
val speedMeta = Meta(1000.0)
|
||||||
|
motor.writeProperty("speed", speedMeta)
|
||||||
|
|
||||||
|
val speedMetaRead = motor.readProperty("speed")
|
||||||
|
val speed = speedMetaRead.value?.number?.toDouble()
|
||||||
|
assertEquals(1000.0, speed)
|
||||||
|
|
||||||
|
val positionMeta = motor.readProperty("position")
|
||||||
|
val position = positionMeta.value?.number?.toDouble()
|
||||||
|
assertEquals(0.0, position)
|
||||||
|
|
||||||
|
motor.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDeviceActionDefinitionAndExecution() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val motor = MotorDevice(context)
|
||||||
|
|
||||||
|
motor.start()
|
||||||
|
|
||||||
|
val speedMeta = Meta(1000.0)
|
||||||
|
motor.writeProperty("speed", speedMeta)
|
||||||
|
|
||||||
|
motor.execute("reset", Meta.EMPTY)
|
||||||
|
|
||||||
|
val speedMetaRead = motor.readProperty("speed")
|
||||||
|
val speed = speedMetaRead.value?.number?.toDouble()
|
||||||
|
assertEquals(0.0, speed)
|
||||||
|
|
||||||
|
val positionMeta = motor.readProperty("position")
|
||||||
|
val position = positionMeta.value?.number?.toDouble()
|
||||||
|
assertEquals(0.0, position)
|
||||||
|
|
||||||
|
motor.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDeviceLifecycleManagement() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val motor = MotorDevice(context)
|
||||||
|
|
||||||
|
assertEquals(LifecycleState.STOPPED, motor.lifecycleState)
|
||||||
|
|
||||||
|
motor.start()
|
||||||
|
assertEquals(LifecycleState.STARTED, motor.lifecycleState)
|
||||||
|
|
||||||
|
motor.stop()
|
||||||
|
assertEquals(LifecycleState.STOPPED, motor.lifecycleState)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDeviceErrorHandling() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val motor = MotorDevice(context)
|
||||||
|
|
||||||
|
motor.start()
|
||||||
|
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
motor.readProperty("nonExistentProperty")
|
||||||
|
}
|
||||||
|
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
motor.execute("nonExistentAction", Meta.EMPTY)
|
||||||
|
}
|
||||||
|
|
||||||
|
motor.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDeviceSpecValidation() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val motor = MotorDevice(context)
|
||||||
|
|
||||||
|
motor.spec.validate(motor)
|
||||||
|
|
||||||
|
val invalidMotor = object : DeviceBase<MotorDevice>(context, Meta.EMPTY) {
|
||||||
|
override val properties: Map<String, DevicePropertySpec<MotorDevice, *>>
|
||||||
|
get() = emptyMap()
|
||||||
|
override val actions: Map<String, DeviceActionSpec<MotorDevice, *, *>>
|
||||||
|
get() = emptyMap()
|
||||||
|
|
||||||
|
override fun toString(): String = "InvalidMotorDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Expect validation to fail
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
motor.spec.validate(invalidMotor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,76 @@
|
|||||||
|
package space.kscience.controls.constructor
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import space.kscience.controls.constructor.CompositeDeviceTest.Position
|
||||||
|
import space.kscience.controls.constructor.CompositeDeviceTest.PositionConverter
|
||||||
|
import space.kscience.controls.constructor.CompositeDeviceTest.RobotArmDevice
|
||||||
|
import space.kscience.controls.spec.*
|
||||||
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.number
|
||||||
|
|
||||||
|
class IntegrationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testFullDeviceSimulation() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val robotArm = RobotArmDevice(context)
|
||||||
|
|
||||||
|
robotArm.start()
|
||||||
|
|
||||||
|
val position = Position(base = 90.0, elbow = 45.0, wrist = 30.0)
|
||||||
|
robotArm.execute("moveToPosition", PositionConverter.convert(position))
|
||||||
|
|
||||||
|
val baseSpeedMeta = robotArm.baseMotor.readProperty("speed")
|
||||||
|
val baseSpeed = baseSpeedMeta.value?.number?.toDouble()
|
||||||
|
assertEquals(90.0, baseSpeed)
|
||||||
|
|
||||||
|
val elbowSpeedMeta = robotArm.elbowMotor.readProperty("speed")
|
||||||
|
val elbowSpeed = elbowSpeedMeta.value?.number?.toDouble()
|
||||||
|
assertEquals(45.0, elbowSpeed)
|
||||||
|
|
||||||
|
val wristSpeedMeta = robotArm.wristMotor.readProperty("speed")
|
||||||
|
val wristSpeed = wristSpeedMeta.value?.number?.toDouble()
|
||||||
|
assertEquals(30.0, wristSpeed)
|
||||||
|
|
||||||
|
robotArm.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
class SensorDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<SensorDevice>(SensorSpec, context, meta) {
|
||||||
|
private var _pressure: Double = 101325.0 // Atmospheric pressure in Pascals
|
||||||
|
|
||||||
|
suspend fun getPressure(): Double = _pressure
|
||||||
|
|
||||||
|
override fun toString(): String = "SensorDevice"
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
object SensorSpec : DeviceSpec<SensorDevice>() {
|
||||||
|
val pressure by doubleProperty(
|
||||||
|
name = "pressure",
|
||||||
|
read = { propertyName ->
|
||||||
|
getPressure()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testUnitsInDeviceProperties() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
|
||||||
|
val sensor = SensorDevice(context)
|
||||||
|
|
||||||
|
sensor.start()
|
||||||
|
|
||||||
|
val pressureMeta = sensor.readProperty("pressure")
|
||||||
|
val pressureValue = pressureMeta.value?.number?.toDouble()
|
||||||
|
assertEquals(101325.0, pressureValue)
|
||||||
|
|
||||||
|
sensor.stop()
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,18 @@
|
|||||||
|
package space.kscience.controls.constructor
|
||||||
|
|
||||||
|
import space.kscience.controls.constructor.units.Degrees
|
||||||
|
import space.kscience.controls.constructor.units.Meters
|
||||||
|
import space.kscience.controls.constructor.units.NumericalValue
|
||||||
|
import space.kscience.controls.constructor.units.Radians
|
||||||
|
import space.kscience.controls.constructor.units.SIPrefix
|
||||||
|
import space.kscience.controls.constructor.units.Seconds
|
||||||
|
import space.kscience.controls.constructor.units.UnitsOfMeasurement
|
||||||
|
import space.kscience.controls.constructor.units.withPrefix
|
||||||
|
import kotlin.math.PI
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
|
||||||
|
class UnitsOfMeasurementTest {
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,88 @@
|
|||||||
|
package space.kscience.controls.constructor
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import space.kscience.controls.spec.*
|
||||||
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.boolean
|
||||||
|
import space.kscience.dataforge.meta.number
|
||||||
|
|
||||||
|
|
||||||
|
class UtilitiesTest {
|
||||||
|
|
||||||
|
class SensorSpec : DeviceSpec<SensorDevice>() {
|
||||||
|
val temperature by doubleProperty(
|
||||||
|
name = "temperature",
|
||||||
|
read = { propertyName ->
|
||||||
|
getTemperature()
|
||||||
|
},
|
||||||
|
write = { propertyName, value ->
|
||||||
|
setTemperature(value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
val isActive by booleanProperty(
|
||||||
|
name = "isActive",
|
||||||
|
read = { propertyName ->
|
||||||
|
isActive()
|
||||||
|
},
|
||||||
|
write = { propertyName, value ->
|
||||||
|
setActive(value)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
class SensorDevice(
|
||||||
|
context: Context,
|
||||||
|
meta: Meta = Meta.EMPTY
|
||||||
|
) : DeviceBySpec<SensorDevice>(SensorSpec(), context, meta) {
|
||||||
|
private var _temperature: Double = 25.0
|
||||||
|
private var _isActive: Boolean = true
|
||||||
|
|
||||||
|
suspend fun getTemperature(): Double = _temperature
|
||||||
|
suspend fun setTemperature(value: Double) {
|
||||||
|
_temperature = value
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun isActive(): Boolean = _isActive
|
||||||
|
suspend fun setActive(value: Boolean) {
|
||||||
|
_isActive = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun toString(): String = "SensorDevice"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testDoublePropertyUtility() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val sensor = SensorDevice(context)
|
||||||
|
|
||||||
|
sensor.start()
|
||||||
|
|
||||||
|
sensor.writeProperty("temperature", Meta(30.0))
|
||||||
|
|
||||||
|
val tempMeta = sensor.readProperty("temperature")
|
||||||
|
val temperature = tempMeta.value?.number?.toDouble()
|
||||||
|
assertEquals(30.0, temperature)
|
||||||
|
|
||||||
|
sensor.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBooleanPropertyUtility() = runTest {
|
||||||
|
val context = Context("TestContext")
|
||||||
|
val sensor = SensorDevice(context)
|
||||||
|
|
||||||
|
sensor.start()
|
||||||
|
|
||||||
|
sensor.writeProperty("isActive", Meta(false))
|
||||||
|
|
||||||
|
val activeMeta = sensor.readProperty("isActive")
|
||||||
|
val isActive = activeMeta.value?.boolean
|
||||||
|
assertEquals(false, isActive)
|
||||||
|
|
||||||
|
sensor.stop()
|
||||||
|
}
|
||||||
|
}
|
@ -16,18 +16,16 @@ Core interfaces for building a device server
|
|||||||
|
|
||||||
## Artifact:
|
## Artifact:
|
||||||
|
|
||||||
The Maven coordinates of this project are `space.kscience:controls-core:0.2.0`.
|
The Maven coordinates of this project are `space.kscience:controls-core:0.4.0-dev-4`.
|
||||||
|
|
||||||
**Gradle Kotlin DSL:**
|
**Gradle Kotlin DSL:**
|
||||||
```kotlin
|
```kotlin
|
||||||
repositories {
|
repositories {
|
||||||
maven("https://repo.kotlin.link")
|
maven("https://repo.kotlin.link")
|
||||||
//uncomment to access development builds
|
|
||||||
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
|
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("space.kscience:controls-core:0.2.0")
|
implementation("space.kscience:controls-core:0.4.0-dev-4")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@ -9,21 +9,24 @@ description = """
|
|||||||
Core interfaces for building a device server
|
Core interfaces for building a device server
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val dataforgeVersion: String by rootProject.extra
|
|
||||||
|
|
||||||
kscience {
|
kscience {
|
||||||
jvm()
|
jvm()
|
||||||
js()
|
js()
|
||||||
native()
|
native()
|
||||||
|
wasm()
|
||||||
useCoroutines()
|
useCoroutines()
|
||||||
useSerialization{
|
useSerialization{
|
||||||
json()
|
json()
|
||||||
}
|
}
|
||||||
useContextReceivers()
|
useContextReceivers()
|
||||||
dependencies {
|
commonMain {
|
||||||
api("space.kscience:dataforge-io:$dataforgeVersion")
|
api(libs.dataforge.io)
|
||||||
api(spclibs.kotlinx.datetime)
|
api(spclibs.kotlinx.datetime)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
jvmTest{
|
||||||
|
implementation(spclibs.logback.classic)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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) }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -3,41 +3,31 @@ package space.kscience.controls.api
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.flow.filterIsInstance
|
|
||||||
import kotlinx.coroutines.flow.launchIn
|
|
||||||
import kotlinx.coroutines.flow.onEach
|
|
||||||
import space.kscience.controls.api.Device.Companion.DEVICE_TARGET
|
import space.kscience.controls.api.Device.Companion.DEVICE_TARGET
|
||||||
import space.kscience.dataforge.context.ContextAware
|
import space.kscience.dataforge.context.ContextAware
|
||||||
import space.kscience.dataforge.context.info
|
import space.kscience.dataforge.context.info
|
||||||
import space.kscience.dataforge.context.logger
|
import space.kscience.dataforge.context.logger
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.Meta
|
||||||
import space.kscience.dataforge.misc.DFExperimental
|
import space.kscience.dataforge.meta.get
|
||||||
import space.kscience.dataforge.misc.Type
|
import space.kscience.dataforge.meta.string
|
||||||
import space.kscience.dataforge.names.Name
|
import space.kscience.dataforge.misc.DfType
|
||||||
|
import space.kscience.dataforge.names.parseAsName
|
||||||
/**
|
|
||||||
* A lifecycle state of a device
|
|
||||||
*/
|
|
||||||
public enum class DeviceLifecycleState{
|
|
||||||
INIT,
|
|
||||||
OPEN,
|
|
||||||
CLOSED
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* General interface describing a managed Device.
|
* General interface describing a managed Device.
|
||||||
* [Device] is a supervisor scope encompassing all operations on a device.
|
* [Device] is a supervisor scope encompassing all operations on a device.
|
||||||
* When canceled, cancels all running processes.
|
* When canceled, cancels all running processes.
|
||||||
*/
|
*/
|
||||||
@Type(DEVICE_TARGET)
|
@DfType(DEVICE_TARGET)
|
||||||
public interface Device : AutoCloseable, ContextAware, CoroutineScope {
|
public interface Device : ContextAware, WithLifeCycle, CoroutineScope {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initial configuration meta for the device
|
* Initial configuration meta for the device
|
||||||
*/
|
*/
|
||||||
public val meta: Meta get() = Meta.EMPTY
|
public val meta: Meta get() = Meta.EMPTY
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* List of supported property descriptors
|
* List of supported property descriptors
|
||||||
*/
|
*/
|
||||||
@ -54,18 +44,6 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope {
|
|||||||
*/
|
*/
|
||||||
public suspend fun readProperty(propertyName: String): Meta
|
public suspend fun readProperty(propertyName: String): Meta
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the logical state of property or return null if it is invalid
|
|
||||||
*/
|
|
||||||
public fun getProperty(propertyName: String): Meta?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Invalidate property (set logical state to invalid)
|
|
||||||
*
|
|
||||||
* This message is suspended to provide lock-free local property changes (they require coroutine context).
|
|
||||||
*/
|
|
||||||
public suspend fun invalidate(propertyName: String)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set property [value] for a property with name [propertyName].
|
* Set property [value] for a property with name [propertyName].
|
||||||
* In rare cases could suspend if the [Device] supports command queue, and it is full at the moment.
|
* In rare cases could suspend if the [Device] supports command queue, and it is full at the moment.
|
||||||
@ -85,44 +63,86 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope {
|
|||||||
public suspend fun execute(actionName: String, argument: Meta? = null): Meta?
|
public suspend fun execute(actionName: String, argument: Meta? = null): Meta?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the device. This function suspends until the device is finished initialization
|
* Initialize the device. This function suspends until the device is finished initialization.
|
||||||
|
* Does nothing if the device is started or is starting
|
||||||
*/
|
*/
|
||||||
public suspend fun open(): Unit = Unit
|
override suspend fun start(): Unit = Unit
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Close and terminate the device. This function does not wait for the device to be closed.
|
* Close and terminate the device. This function does not wait for the device to be closed.
|
||||||
*/
|
*/
|
||||||
override fun close() {
|
override suspend fun stop() {
|
||||||
|
coroutineContext[Job]?.cancel("The device is closed")
|
||||||
logger.info { "Device $this is closed" }
|
logger.info { "Device $this is closed" }
|
||||||
cancel("The device is closed")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@DFExperimental
|
|
||||||
public val lifecycleState: DeviceLifecycleState
|
|
||||||
|
|
||||||
public companion object {
|
public companion object {
|
||||||
public const val DEVICE_TARGET: String = "device"
|
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.
|
* Get the logical state of property or suspend to read the physical value.
|
||||||
*/
|
*/
|
||||||
public suspend fun Device.getOrReadProperty(propertyName: String): Meta =
|
public suspend fun Device.getOrReadProperty(propertyName: String): Meta = if (this is CachingDevice) {
|
||||||
getProperty(propertyName) ?: readProperty(propertyName)
|
getProperty(propertyName) ?: readProperty(propertyName)
|
||||||
|
} else {
|
||||||
|
readProperty(propertyName)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a snapshot of the device logical state
|
* Get a snapshot of the device logical state
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
public fun Device.getAllProperties(): Meta = Meta {
|
public fun CachingDevice.getAllProperties(): Meta = Meta {
|
||||||
for (descriptor in propertyDescriptors) {
|
for (descriptor in propertyDescriptors) {
|
||||||
setMeta(Name.parse(descriptor.name), getProperty(descriptor.name))
|
set(descriptor.name.parseAsName(), getProperty(descriptor.name))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subscribe on property changes for the whole device
|
* Subscribe on property changes for the whole device
|
||||||
*/
|
*/
|
||||||
public fun Device.onPropertyChange(callback: suspend PropertyChangedMessage.() -> Unit): Job =
|
public fun Device.onPropertyChange(
|
||||||
messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(this)
|
scope: CoroutineScope = this,
|
||||||
|
callback: suspend PropertyChangedMessage.() -> Unit,
|
||||||
|
): Job = messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(scope)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A [Flow] of property change messages for specific property.
|
||||||
|
*/
|
||||||
|
public fun Device.propertyMessageFlow(propertyName: String): Flow<PropertyChangedMessage> = messageFlow
|
||||||
|
.filterIsInstance<PropertyChangedMessage>()
|
||||||
|
.filter { it.property == propertyName }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React on device lifecycle events
|
||||||
|
*/
|
||||||
|
public fun Device.onLifecycleEvent(
|
||||||
|
block: suspend (LifecycleState) -> Unit
|
||||||
|
): Job = messageFlow.filterIsInstance<DeviceLifeCycleMessage>().onEach {
|
||||||
|
block(it.state)
|
||||||
|
}.launchIn(this)
|
@ -1,73 +1,62 @@
|
|||||||
package space.kscience.controls.api
|
package space.kscience.controls.api
|
||||||
|
|
||||||
import space.kscience.dataforge.meta.Meta
|
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.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
|
* A hub that could locate multiple devices and redirect actions to them
|
||||||
*/
|
*/
|
||||||
public interface DeviceHub : Provider {
|
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 defaultTarget: String get() = Device.DEVICE_TARGET
|
||||||
|
|
||||||
override val defaultChainTarget: String get() = Device.DEVICE_TARGET
|
override val defaultChainTarget: String get() = Device.DEVICE_TARGET
|
||||||
|
|
||||||
override fun content(target: String): Map<Name, Any> = if (target == Device.DEVICE_TARGET) {
|
override fun content(target: String): Map<Name, Any> = if (target == Device.DEVICE_TARGET) {
|
||||||
buildMap {
|
devices
|
||||||
fun putAll(prefix: Name, hub: DeviceHub) {
|
|
||||||
hub.devices.forEach {
|
|
||||||
put(prefix + it.key, it.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
devices.forEach {
|
|
||||||
val name = it.key.asName()
|
|
||||||
put(name, it.value)
|
|
||||||
(it.value as? DeviceHub)?.let { hub ->
|
|
||||||
putAll(name, hub)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
|
//TODO send message on device change
|
||||||
|
|
||||||
public companion object
|
public companion object
|
||||||
}
|
}
|
||||||
|
|
||||||
public operator fun DeviceHub.get(nameToken: NameToken): Device =
|
public fun DeviceHub(deviceMap: Map<Name, Device>): DeviceHub = object : DeviceHub {
|
||||||
devices[nameToken] ?: error("Device with name $nameToken not found in $this")
|
override val devices: Map<Name, Device>
|
||||||
|
get() = deviceMap
|
||||||
public fun DeviceHub.getOrNull(name: Name): Device? = when {
|
|
||||||
name.isEmpty() -> this as? Device
|
|
||||||
name.length == 1 -> get(name.firstOrNull()!!)
|
|
||||||
else -> (get(name.firstOrNull()!!) as? DeviceHub)?.getOrNull(name.cutFirst())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public operator fun DeviceHub.get(name: Name): Device =
|
/**
|
||||||
getOrNull(name) ?: error("Device with name $name not found in $this")
|
* 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))
|
devices.forEach {
|
||||||
|
val name: Name = it.key
|
||||||
public operator fun DeviceHub.get(nameString: String): Device =
|
put(name.asPath(), it.value)
|
||||||
getOrNull(nameString) ?: error("Device with name $nameString not found in $this")
|
(it.value as? DeviceHub)?.let { hub ->
|
||||||
|
putAll(name.asPath(), hub)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public suspend fun DeviceHub.readProperty(deviceName: Name, propertyName: String): Meta =
|
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) {
|
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? =
|
public suspend fun DeviceHub.execute(deviceName: Name, command: String, argument: Meta?): Meta? =
|
||||||
this[deviceName].execute(command, argument)
|
(devices[deviceName] ?: error("Device with name $deviceName not found in $this")).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)
|
|
||||||
//}
|
|
@ -22,10 +22,10 @@ public sealed class DeviceMessage {
|
|||||||
public abstract val sourceDevice: Name?
|
public abstract val sourceDevice: Name?
|
||||||
public abstract val targetDevice: Name?
|
public abstract val targetDevice: Name?
|
||||||
public abstract val comment: String?
|
public abstract val comment: String?
|
||||||
public abstract val time: Instant?
|
public abstract val time: Instant
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the source device name for composition. If the original name is null, resulting name is also null.
|
* Update the source device name for composition. If the original name is null, the resulting name is also null.
|
||||||
*/
|
*/
|
||||||
public abstract fun changeSource(block: (Name) -> Name): DeviceMessage
|
public abstract fun changeSource(block: (Name) -> Name): DeviceMessage
|
||||||
|
|
||||||
@ -59,7 +59,7 @@ public data class PropertyChangedMessage(
|
|||||||
override val sourceDevice: Name = Name.EMPTY,
|
override val sourceDevice: Name = Name.EMPTY,
|
||||||
override val targetDevice: Name? = null,
|
override val targetDevice: Name? = null,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
) : DeviceMessage() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
||||||
}
|
}
|
||||||
@ -71,11 +71,11 @@ public data class PropertyChangedMessage(
|
|||||||
@SerialName("property.set")
|
@SerialName("property.set")
|
||||||
public data class PropertySetMessage(
|
public data class PropertySetMessage(
|
||||||
public val property: String,
|
public val property: String,
|
||||||
public val value: Meta?,
|
public val value: Meta,
|
||||||
override val sourceDevice: Name? = null,
|
override val sourceDevice: Name? = null,
|
||||||
override val targetDevice: Name,
|
override val targetDevice: Name?,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
) : DeviceMessage() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
||||||
}
|
}
|
||||||
@ -91,7 +91,7 @@ public data class PropertyGetMessage(
|
|||||||
override val sourceDevice: Name? = null,
|
override val sourceDevice: Name? = null,
|
||||||
override val targetDevice: Name,
|
override val targetDevice: Name,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
) : DeviceMessage() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
||||||
}
|
}
|
||||||
@ -103,9 +103,9 @@ public data class PropertyGetMessage(
|
|||||||
@SerialName("description.get")
|
@SerialName("description.get")
|
||||||
public data class GetDescriptionMessage(
|
public data class GetDescriptionMessage(
|
||||||
override val sourceDevice: Name? = null,
|
override val sourceDevice: Name? = null,
|
||||||
override val targetDevice: Name,
|
override val targetDevice: Name? = null,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
) : DeviceMessage() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
||||||
}
|
}
|
||||||
@ -122,7 +122,7 @@ public data class DescriptionMessage(
|
|||||||
override val sourceDevice: Name,
|
override val sourceDevice: Name,
|
||||||
override val targetDevice: Name? = null,
|
override val targetDevice: Name? = null,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
) : DeviceMessage() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
||||||
}
|
}
|
||||||
@ -141,7 +141,7 @@ public data class ActionExecuteMessage(
|
|||||||
override val sourceDevice: Name? = null,
|
override val sourceDevice: Name? = null,
|
||||||
override val targetDevice: Name,
|
override val targetDevice: Name,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
) : DeviceMessage() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
||||||
}
|
}
|
||||||
@ -160,22 +160,28 @@ public data class ActionResultMessage(
|
|||||||
override val sourceDevice: Name,
|
override val sourceDevice: Name,
|
||||||
override val targetDevice: Name? = null,
|
override val targetDevice: Name? = null,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
) : DeviceMessage() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
@Serializable
|
||||||
@SerialName("binary.notification")
|
@SerialName("binary.notification")
|
||||||
public data class BinaryNotificationMessage(
|
public data class BinaryNotificationMessage(
|
||||||
val binaryID: String,
|
val contentId: String,
|
||||||
|
val contentMeta: Meta,
|
||||||
override val sourceDevice: Name,
|
override val sourceDevice: Name,
|
||||||
override val targetDevice: Name? = null,
|
override val targetDevice: Name? = null,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
) : DeviceMessage() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
||||||
}
|
}
|
||||||
@ -190,7 +196,7 @@ public data class EmptyDeviceMessage(
|
|||||||
override val sourceDevice: Name? = null,
|
override val sourceDevice: Name? = null,
|
||||||
override val targetDevice: Name? = null,
|
override val targetDevice: Name? = null,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
) : DeviceMessage() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
||||||
}
|
}
|
||||||
@ -203,12 +209,12 @@ public data class EmptyDeviceMessage(
|
|||||||
public data class DeviceLogMessage(
|
public data class DeviceLogMessage(
|
||||||
val message: String,
|
val message: String,
|
||||||
val data: Meta? = null,
|
val data: Meta? = null,
|
||||||
override val sourceDevice: Name? = null,
|
override val sourceDevice: Name = Name.EMPTY,
|
||||||
override val targetDevice: Name? = null,
|
override val targetDevice: Name? = null,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
) : DeviceMessage() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -220,10 +226,25 @@ public data class DeviceErrorMessage(
|
|||||||
public val errorMessage: String?,
|
public val errorMessage: String?,
|
||||||
public val errorType: String? = null,
|
public val errorType: String? = null,
|
||||||
public val errorStackTrace: String? = null,
|
public val errorStackTrace: String? = null,
|
||||||
override val sourceDevice: Name,
|
override val sourceDevice: Name = Name.EMPTY,
|
||||||
override val targetDevice: Name? = null,
|
override val targetDevice: Name? = null,
|
||||||
override val comment: String? = null,
|
override val comment: String? = null,
|
||||||
@EncodeDefault override val time: Instant? = Clock.System.now(),
|
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||||
|
) : DeviceMessage() {
|
||||||
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Device [Device.lifecycleState] is changed
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
@SerialName("lifecycle")
|
||||||
|
public data class DeviceLifeCycleMessage(
|
||||||
|
val state: 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() {
|
) : DeviceMessage() {
|
||||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
||||||
}
|
}
|
||||||
|
@ -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) }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -12,21 +12,26 @@ import space.kscience.dataforge.meta.descriptors.MetaDescriptorBuilder
|
|||||||
@Serializable
|
@Serializable
|
||||||
public class PropertyDescriptor(
|
public class PropertyDescriptor(
|
||||||
public val name: String,
|
public val name: String,
|
||||||
public var info: String? = null,
|
public var description: String? = null,
|
||||||
public var metaDescriptor: MetaDescriptor = MetaDescriptor(),
|
public var metaDescriptor: MetaDescriptor = MetaDescriptor(),
|
||||||
public var readable: Boolean = true,
|
public var readable: Boolean = true,
|
||||||
public var writable: Boolean = false
|
public var mutable: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Unit){
|
public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.() -> Unit) {
|
||||||
metaDescriptor = MetaDescriptor(block)
|
metaDescriptor = MetaDescriptor {
|
||||||
|
from(metaDescriptor)
|
||||||
|
block()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A descriptor for property
|
* A descriptor for property
|
||||||
*/
|
*/
|
||||||
@Serializable
|
@Serializable
|
||||||
public class ActionDescriptor(public val name: String) {
|
public class ActionDescriptor(
|
||||||
public var info: String? = null
|
public val name: String,
|
||||||
}
|
public var description: String? = null,
|
||||||
|
public var inputMetaDescriptor: MetaDescriptor = MetaDescriptor(),
|
||||||
|
public var outputMetaDescriptor: MetaDescriptor = MetaDescriptor()
|
||||||
|
)
|
||||||
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -3,12 +3,13 @@ package space.kscience.controls.manager
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import space.kscience.controls.api.Device
|
import space.kscience.controls.api.Device
|
||||||
import space.kscience.controls.api.DeviceHub
|
import space.kscience.controls.api.DeviceHub
|
||||||
import space.kscience.controls.api.getOrNull
|
import space.kscience.controls.api.id
|
||||||
import space.kscience.dataforge.context.*
|
import space.kscience.dataforge.context.*
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.Meta
|
||||||
import space.kscience.dataforge.meta.MutableMeta
|
import space.kscience.dataforge.meta.MutableMeta
|
||||||
import space.kscience.dataforge.names.Name
|
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.collections.set
|
||||||
import kotlin.properties.ReadOnlyProperty
|
import kotlin.properties.ReadOnlyProperty
|
||||||
|
|
||||||
@ -21,11 +22,11 @@ public class DeviceManager : AbstractPlugin(), DeviceHub {
|
|||||||
/**
|
/**
|
||||||
* Actual list of connected devices
|
* Actual list of connected devices
|
||||||
*/
|
*/
|
||||||
private val top = HashMap<NameToken, Device>()
|
private val _devices = HashMap<Name, Device>()
|
||||||
override val devices: Map<NameToken, Device> get() = top
|
override val devices: Map<Name, Device> get() = _devices
|
||||||
|
|
||||||
public fun registerDevice(name: NameToken, device: Device) {
|
public fun registerDevice(name: Name, device: Device) {
|
||||||
top[name] = device
|
_devices[name] = device
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun content(target: String): Map<Name, Any> = super<DeviceHub>.content(target)
|
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 {
|
public fun <D : Device> DeviceManager.install(name: String, device: D): D {
|
||||||
registerDevice(NameToken(name), device)
|
registerDevice(name.parseAsName(), device)
|
||||||
device.launch {
|
device.launch {
|
||||||
device.open()
|
device.start()
|
||||||
}
|
}
|
||||||
return device
|
return device
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public fun <D : Device> DeviceManager.install(device: D): D = install(device.id, device)
|
||||||
|
|
||||||
|
|
||||||
|
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].
|
* 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)
|
val meta = Meta(builder)
|
||||||
return ReadOnlyProperty { _, property ->
|
return ReadOnlyProperty { _, property ->
|
||||||
val name = property.name
|
val name = property.name
|
||||||
val current = getOrNull(name)
|
val current = devices[name]
|
||||||
if (current == null) {
|
if (current == null) {
|
||||||
install(name, factory, meta)
|
install(name, factory, meta)
|
||||||
} else if (current.meta != meta) {
|
} else if (current.meta != meta) {
|
||||||
@ -72,5 +79,4 @@ public inline fun <D : Device> DeviceManager.installing(
|
|||||||
current as D
|
current as D
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1,10 +1,9 @@
|
|||||||
package space.kscience.controls.manager
|
package space.kscience.controls.manager
|
||||||
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.merge
|
||||||
import space.kscience.controls.api.*
|
import space.kscience.controls.api.*
|
||||||
import space.kscience.dataforge.names.Name
|
import space.kscience.dataforge.names.Name
|
||||||
import space.kscience.dataforge.names.plus
|
import space.kscience.dataforge.names.plus
|
||||||
@ -24,11 +23,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
|
|||||||
}
|
}
|
||||||
|
|
||||||
is PropertySetMessage -> {
|
is PropertySetMessage -> {
|
||||||
if (request.value == null) {
|
writeProperty(request.property, request.value)
|
||||||
invalidate(request.property)
|
|
||||||
} else {
|
|
||||||
writeProperty(request.property, request.value)
|
|
||||||
}
|
|
||||||
PropertyChangedMessage(
|
PropertyChangedMessage(
|
||||||
property = request.property,
|
property = request.property,
|
||||||
value = getOrReadProperty(request.property),
|
value = getOrReadProperty(request.property),
|
||||||
@ -64,6 +59,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
|
|||||||
is DeviceErrorMessage,
|
is DeviceErrorMessage,
|
||||||
is EmptyDeviceMessage,
|
is EmptyDeviceMessage,
|
||||||
is DeviceLogMessage,
|
is DeviceLogMessage,
|
||||||
|
is DeviceLifeCycleMessage,
|
||||||
-> null
|
-> null
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
@ -71,42 +67,41 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Process incoming [DeviceMessage], using hub naming to evaluate target.
|
* Process incoming [DeviceMessage], using hub naming to find target.
|
||||||
|
* If the `targetDevice` is `null`, then 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 {
|
return try {
|
||||||
val targetName = request.targetDevice ?: return null
|
val targetName = request.targetDevice
|
||||||
val device = getOrNull(targetName) ?: error("The device with name $targetName not found in $this")
|
if (targetName == null) {
|
||||||
device.respondMessage(targetName, request)
|
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) {
|
} catch (ex: Exception) {
|
||||||
DeviceMessage.error(ex, sourceDevice = Name.EMPTY, targetDevice = request.sourceDevice)
|
listOf(DeviceMessage.error(ex, sourceDevice = Name.EMPTY, targetDevice = request.sourceDevice))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collect all messages from given [DeviceHub], applying proper relative names.
|
* Collect all messages from given [DeviceHub], applying proper relative names.
|
||||||
*/
|
*/
|
||||||
public fun DeviceHub.hubMessageFlow(scope: CoroutineScope): Flow<DeviceMessage> {
|
public fun DeviceHub.hubMessageFlow(): Flow<DeviceMessage> {
|
||||||
|
|
||||||
//TODO could we avoid using downstream scope?
|
val deviceMessageFlow = if (this is Device) messageFlow else emptyFlow()
|
||||||
val outbox = MutableSharedFlow<DeviceMessage>()
|
|
||||||
if (this is Device) {
|
val childrenFlows = devices.map { (token, childDevice) ->
|
||||||
messageFlow.onEach {
|
if (childDevice is DeviceHub) {
|
||||||
outbox.emit(it)
|
childDevice.hubMessageFlow()
|
||||||
}.launchIn(scope)
|
|
||||||
}
|
|
||||||
//TODO maybe better create map of all devices to limit copying
|
|
||||||
devices.forEach { (token, childDevice) ->
|
|
||||||
val flow = if (childDevice is DeviceHub) {
|
|
||||||
childDevice.hubMessageFlow(scope)
|
|
||||||
} else {
|
} else {
|
||||||
childDevice.messageFlow
|
childDevice.messageFlow
|
||||||
|
}.map { deviceMessage ->
|
||||||
|
deviceMessage.changeSource { token + it }
|
||||||
}
|
}
|
||||||
flow.onEach { deviceMessage ->
|
|
||||||
outbox.emit(
|
|
||||||
deviceMessage.changeSource { token + it }
|
|
||||||
)
|
|
||||||
}.launchIn(scope)
|
|
||||||
}
|
}
|
||||||
return outbox
|
|
||||||
|
return merge(deviceMessageFlow, *childrenFlows.toTypedArray())
|
||||||
}
|
}
|
@ -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)
|
@ -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)
|
@ -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
|
@ -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) }
|
@ -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,
|
|
||||||
)
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
@ -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())
|
@ -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())
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -11,26 +11,43 @@ public class Ports : AbstractPlugin() {
|
|||||||
|
|
||||||
override val tag: PluginTag get() = Companion.tag
|
override val tag: PluginTag get() = Companion.tag
|
||||||
|
|
||||||
private val portFactories by lazy {
|
private val synchronousPortFactories by lazy {
|
||||||
context.gather<PortFactory>(PortFactory.TYPE)
|
context.gather<Factory<SynchronousPort>>(SYNCHRONOUS_PORT_TYPE)
|
||||||
}
|
}
|
||||||
|
|
||||||
private val portCache = mutableMapOf<Meta, Port>()
|
private val asynchronousPortFactories by lazy {
|
||||||
|
context.gather<Factory<AsynchronousPort>>(ASYNCHRONOUS_PORT_TYPE)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a new [Port] according to specification
|
* Create a new [AsynchronousPort] according to specification
|
||||||
*/
|
*/
|
||||||
public fun buildPort(meta: Meta): Port = portCache.getOrPut(meta) {
|
public fun buildAsynchronousPort(meta: Meta): AsynchronousPort {
|
||||||
val type by meta.string { error("Port type is not defined") }
|
val type by meta.string { error("Port type is not defined") }
|
||||||
val factory = portFactories.values.firstOrNull { it.type == type }
|
val factory = asynchronousPortFactories.entries
|
||||||
|
.firstOrNull { it.key.toString() == type }?.value
|
||||||
?: error("Port factory for type $type not found")
|
?: error("Port factory for type $type not found")
|
||||||
factory.build(context, meta)
|
return factory.build(context, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a [SynchronousPort] according to specification or wrap an asynchronous implementation
|
||||||
|
*/
|
||||||
|
public fun buildSynchronousPort(meta: Meta): SynchronousPort {
|
||||||
|
val type by meta.string { error("Port type is not defined") }
|
||||||
|
val factory = synchronousPortFactories.entries
|
||||||
|
.firstOrNull { it.key.toString() == type }?.value
|
||||||
|
?: return buildAsynchronousPort(meta).asSynchronousPort()
|
||||||
|
return factory.build(context, meta)
|
||||||
}
|
}
|
||||||
|
|
||||||
public companion object : PluginFactory<Ports> {
|
public companion object : PluginFactory<Ports> {
|
||||||
|
|
||||||
override val tag: PluginTag = PluginTag("controls.ports", group = PluginTag.DATAFORGE_GROUP)
|
override val tag: PluginTag = PluginTag("controls.ports", group = PluginTag.DATAFORGE_GROUP)
|
||||||
|
|
||||||
|
public const val ASYNCHRONOUS_PORT_TYPE: String = "controls.asynchronousPort"
|
||||||
|
public const val SYNCHRONOUS_PORT_TYPE: String = "controls.synchronousPort"
|
||||||
|
|
||||||
override fun build(context: Context, meta: Meta): Ports = Ports()
|
override fun build(context: Context, meta: Meta): Ports = Ports()
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -1,28 +1,103 @@
|
|||||||
package space.kscience.controls.ports
|
package space.kscience.controls.ports
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
|
import kotlinx.io.Buffer
|
||||||
|
import kotlinx.io.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.
|
* A port handler for synchronous (request-response) communication with a port.
|
||||||
* The handler does not guarantee exclusive access to the port so the user mush ensure that no other controller handles port at the moment.
|
* Only one request could be active at a time (others are suspended).
|
||||||
*/
|
*/
|
||||||
public class SynchronousPort(public val port: Port, private val mutex: Mutex) : Port by port {
|
public interface SynchronousPort : ContextAware, 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 {
|
public suspend fun <R> respond(
|
||||||
port.send(data)
|
request: ByteArray,
|
||||||
transform(port.receiving())
|
transform: suspend Flow<ByteArray>.() -> R,
|
||||||
|
): R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously read fixed size response to a given [request]. Discard additional response bytes.
|
||||||
|
*/
|
||||||
|
public suspend fun respondFixedMessageSize(
|
||||||
|
request: ByteArray,
|
||||||
|
responseSize: Int,
|
||||||
|
): ByteArray = respond(request) {
|
||||||
|
val buffer = Buffer()
|
||||||
|
takeWhile {
|
||||||
|
buffer.size < responseSize
|
||||||
|
}.collect {
|
||||||
|
buffer.write(it)
|
||||||
|
}
|
||||||
|
buffer.readByteArray(responseSize)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* Send request and read incoming data blocks until the delimiter is encountered
|
||||||
|
@ -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
|
||||||
|
}
|
@ -1,21 +1,27 @@
|
|||||||
package space.kscience.controls.ports
|
package space.kscience.controls.ports
|
||||||
|
|
||||||
import io.ktor.utils.io.core.BytePacketBuilder
|
|
||||||
import io.ktor.utils.io.core.readBytes
|
|
||||||
import io.ktor.utils.io.core.reset
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
|
import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.transform
|
import kotlinx.coroutines.flow.transform
|
||||||
|
import kotlinx.io.Buffer
|
||||||
|
import kotlinx.io.readByteArray
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform byte fragments into complete phrases using given delimiter. Not thread safe.
|
* Transform byte fragments into complete phrases using given delimiter. Not thread safe.
|
||||||
|
*
|
||||||
|
* TODO add type wrapper for phrases
|
||||||
*/
|
*/
|
||||||
public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray> {
|
public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray> {
|
||||||
require(delimiter.isNotEmpty()) { "Delimiter must not be empty" }
|
require(delimiter.isNotEmpty()) { "Delimiter must not be empty" }
|
||||||
|
|
||||||
val output = BytePacketBuilder()
|
val output = Buffer()
|
||||||
var matcherPosition = 0
|
var matcherPosition = 0
|
||||||
|
|
||||||
|
onCompletion {
|
||||||
|
output.close()
|
||||||
|
}
|
||||||
|
|
||||||
return transform { chunk ->
|
return transform { chunk ->
|
||||||
chunk.forEach { byte ->
|
chunk.forEach { byte ->
|
||||||
output.writeByte(byte)
|
output.writeByte(byte)
|
||||||
@ -24,9 +30,8 @@ public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray>
|
|||||||
matcherPosition++
|
matcherPosition++
|
||||||
if (matcherPosition == delimiter.size) {
|
if (matcherPosition == delimiter.size) {
|
||||||
//full match achieved, sending result
|
//full match achieved, sending result
|
||||||
val bytes = output.build()
|
emit(output.readByteArray())
|
||||||
emit(bytes.readBytes())
|
output.clear()
|
||||||
output.reset()
|
|
||||||
matcherPosition = 0
|
matcherPosition = 0
|
||||||
}
|
}
|
||||||
} else if (matcherPosition > 0) {
|
} else if (matcherPosition > 0) {
|
||||||
@ -37,6 +42,31 @@ public fun Flow<ByteArray>.withDelimiter(delimiter: ByteArray): Flow<ByteArray>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun Flow<ByteArray>.withFixedMessageSize(messageSize: Int): Flow<ByteArray> {
|
||||||
|
require(messageSize > 0) { "Message size should be positive" }
|
||||||
|
|
||||||
|
val output = Buffer()
|
||||||
|
|
||||||
|
onCompletion {
|
||||||
|
output.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
return transform { chunk ->
|
||||||
|
val remaining: Int = (messageSize - output.size).toInt()
|
||||||
|
if (chunk.size >= remaining) {
|
||||||
|
output.write(chunk, endIndex = remaining)
|
||||||
|
emit(output.readByteArray())
|
||||||
|
output.clear()
|
||||||
|
//write the remaining chunk fragment
|
||||||
|
if(chunk.size> remaining) {
|
||||||
|
output.write(chunk, startIndex = remaining)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
output.write(chunk)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transform byte fragments into utf-8 phrases using utf-8 delimiter
|
* Transform byte fragments into utf-8 phrases using utf-8 delimiter
|
||||||
*/
|
*/
|
||||||
@ -47,9 +77,9 @@ public fun Flow<ByteArray>.withStringDelimiter(delimiter: String): Flow<String>
|
|||||||
/**
|
/**
|
||||||
* A flow of delimited phrases
|
* A flow of delimited phrases
|
||||||
*/
|
*/
|
||||||
public fun Port.delimitedIncoming(delimiter: ByteArray): Flow<ByteArray> = receiving().withDelimiter(delimiter)
|
public fun AsynchronousPort.delimitedIncoming(delimiter: ByteArray): Flow<ByteArray> = subscribe().withDelimiter(delimiter)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A flow of delimited phrases with string content
|
* A flow of delimited phrases with string content
|
||||||
*/
|
*/
|
||||||
public fun Port.stringsDelimitedIncoming(delimiter: String): Flow<String> = receiving().withStringDelimiter(delimiter)
|
public fun AsynchronousPort.stringsDelimitedIncoming(delimiter: String): Flow<String> = subscribe().withStringDelimiter(delimiter)
|
||||||
|
@ -1,36 +1,44 @@
|
|||||||
package space.kscience.controls.spec
|
package space.kscience.controls.spec
|
||||||
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import space.kscience.controls.api.*
|
import space.kscience.controls.api.*
|
||||||
import space.kscience.dataforge.context.Context
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.context.debug
|
||||||
import space.kscience.dataforge.context.error
|
import space.kscience.dataforge.context.error
|
||||||
import space.kscience.dataforge.context.logger
|
import space.kscience.dataforge.context.logger
|
||||||
import space.kscience.dataforge.meta.Meta
|
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
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a meta [item] to [device]
|
||||||
|
*/
|
||||||
@OptIn(InternalDeviceAPI::class)
|
@OptIn(InternalDeviceAPI::class)
|
||||||
private suspend fun <D : Device, T> WritableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) {
|
private suspend fun <D : Device, T> MutableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) {
|
||||||
write(device, converter.metaToObject(item) ?: error("Meta $item could not be read with $converter"))
|
write(device, converter.readOrNull(item) ?: error("Meta $item could not be read with $converter"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read Meta item from the [device]
|
||||||
|
*/
|
||||||
@OptIn(InternalDeviceAPI::class)
|
@OptIn(InternalDeviceAPI::class)
|
||||||
private suspend fun <D : Device, T> DevicePropertySpec<D, T>.readMeta(device: D): Meta? =
|
private suspend fun <D : Device, T> DevicePropertySpec<D, T>.readMeta(device: D): Meta? =
|
||||||
read(device)?.let(converter::objectToMeta)
|
read(device)?.let(converter::convert)
|
||||||
|
|
||||||
|
|
||||||
private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta(
|
private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta(
|
||||||
device: D,
|
device: D,
|
||||||
item: Meta,
|
item: Meta,
|
||||||
): Meta? {
|
): Meta? {
|
||||||
val arg: I = inputConverter.metaToObject(item) ?: error("Failed to convert $item with $inputConverter")
|
val arg: I = inputConverter.readOrNull(item) ?: error("Failed to convert $item with $inputConverter")
|
||||||
val res = execute(device, arg)
|
val res = execute(device, arg)
|
||||||
return res?.let { outputConverter.objectToMeta(res) }
|
return res?.let { outputConverter.convert(res) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -39,8 +47,15 @@ private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta
|
|||||||
*/
|
*/
|
||||||
public abstract class DeviceBase<D : Device>(
|
public abstract class DeviceBase<D : Device>(
|
||||||
final override val context: Context,
|
final override val context: Context,
|
||||||
override val meta: Meta = Meta.EMPTY,
|
final override val meta: Meta = Meta.EMPTY,
|
||||||
) : Device {
|
) : CachingDevice {
|
||||||
|
|
||||||
|
private val stateLock = Mutex()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logical state store
|
||||||
|
*/
|
||||||
|
private val logicalState: MutableMap<String, Meta?> = HashMap()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Collection of property specifications
|
* Collection of property specifications
|
||||||
@ -58,23 +73,28 @@ public abstract class DeviceBase<D : Device>(
|
|||||||
override val actionDescriptors: Collection<ActionDescriptor>
|
override val actionDescriptors: Collection<ActionDescriptor>
|
||||||
get() = actions.values.map { it.descriptor }
|
get() = actions.values.map { it.descriptor }
|
||||||
|
|
||||||
override val coroutineContext: CoroutineContext by lazy {
|
|
||||||
context.newCoroutineContext(
|
private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow(
|
||||||
SupervisorJob(context.coroutineContext[Job]) +
|
replay = meta["message.buffer"].int ?: 1000,
|
||||||
CoroutineName("Device $this") +
|
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||||
CoroutineExceptionHandler { _, throwable ->
|
)
|
||||||
logger.error(throwable) { "Exception in device $this job" }
|
|
||||||
|
override val coroutineContext: CoroutineContext = context.newCoroutineContext(
|
||||||
|
SupervisorJob(context.coroutineContext[Job]) +
|
||||||
|
CoroutineName("Device $id") +
|
||||||
|
CoroutineExceptionHandler { _, throwable ->
|
||||||
|
launch {
|
||||||
|
sharedMessageFlow.emit(
|
||||||
|
DeviceErrorMessage(
|
||||||
|
errorMessage = throwable.message,
|
||||||
|
errorType = throwable::class.simpleName,
|
||||||
|
errorStackTrace = throwable.stackTraceToString()
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
logger.error(throwable) { "Exception in device $id" }
|
||||||
}
|
}
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
|
||||||
* Logical state store
|
|
||||||
*/
|
|
||||||
private val logicalState: HashMap<String, Meta?> = HashMap()
|
|
||||||
|
|
||||||
private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow()
|
|
||||||
|
|
||||||
public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow
|
public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow
|
||||||
|
|
||||||
@ -82,12 +102,10 @@ public abstract class DeviceBase<D : Device>(
|
|||||||
internal val self: D
|
internal val self: D
|
||||||
get() = this as D
|
get() = this as D
|
||||||
|
|
||||||
private val stateLock = Mutex()
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update logical property state and notify listeners
|
* Update logical property state and notify listeners
|
||||||
*/
|
*/
|
||||||
protected suspend fun updateLogical(propertyName: String, value: Meta?) {
|
protected suspend fun propertyChanged(propertyName: String, value: Meta?) {
|
||||||
if (value != logicalState[propertyName]) {
|
if (value != logicalState[propertyName]) {
|
||||||
stateLock.withLock {
|
stateLock.withLock {
|
||||||
logicalState[propertyName] = value
|
logicalState[propertyName] = value
|
||||||
@ -99,10 +117,10 @@ public abstract class DeviceBase<D : Device>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update logical state using given [spec] and its convertor
|
* Notify the device that a property with [spec] value is changed
|
||||||
*/
|
*/
|
||||||
public suspend fun <T> updateLogical(spec: DevicePropertySpec<D, T>, value: T) {
|
protected suspend fun <T> propertyChanged(spec: DevicePropertySpec<D, T>, value: T) {
|
||||||
updateLogical(spec.name, spec.converter.objectToMeta(value))
|
propertyChanged(spec.name, spec.converter.convert(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -112,7 +130,7 @@ public abstract class DeviceBase<D : Device>(
|
|||||||
override suspend fun readProperty(propertyName: String): Meta {
|
override suspend fun readProperty(propertyName: String): Meta {
|
||||||
val spec = properties[propertyName] ?: error("Property with name $propertyName not found")
|
val spec = properties[propertyName] ?: error("Property with name $propertyName not found")
|
||||||
val meta = spec.readMeta(self) ?: error("Failed to read property $propertyName")
|
val meta = spec.readMeta(self) ?: error("Failed to read property $propertyName")
|
||||||
updateLogical(propertyName, meta)
|
propertyChanged(propertyName, meta)
|
||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -122,7 +140,7 @@ public abstract class DeviceBase<D : Device>(
|
|||||||
public suspend fun readPropertyOrNull(propertyName: String): Meta? {
|
public suspend fun readPropertyOrNull(propertyName: String): Meta? {
|
||||||
val spec = properties[propertyName] ?: return null
|
val spec = properties[propertyName] ?: return null
|
||||||
val meta = spec.readMeta(self) ?: return null
|
val meta = spec.readMeta(self) ?: return null
|
||||||
updateLogical(propertyName, meta)
|
propertyChanged(propertyName, meta)
|
||||||
return meta
|
return meta
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -135,15 +153,26 @@ public abstract class DeviceBase<D : Device>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun writeProperty(propertyName: String, value: Meta): Unit {
|
override suspend fun writeProperty(propertyName: String, value: Meta): Unit {
|
||||||
|
//bypass property setting if it already has that value
|
||||||
|
if (logicalState[propertyName] == value) {
|
||||||
|
logger.debug { "Skipping setting $propertyName to $value because value is already set" }
|
||||||
|
return
|
||||||
|
}
|
||||||
when (val property = properties[propertyName]) {
|
when (val property = properties[propertyName]) {
|
||||||
null -> {
|
null -> {
|
||||||
//If there is a physical property with a given name, invalidate logical property and write physical one
|
//If there are no registered physical properties with given name, write a logical one.
|
||||||
updateLogical(propertyName, value)
|
propertyChanged(propertyName, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
is WritableDevicePropertySpec -> {
|
is MutableDevicePropertySpec -> {
|
||||||
|
//if there is a writeable property with a given name, invalidate logical and write physical
|
||||||
invalidate(propertyName)
|
invalidate(propertyName)
|
||||||
property.writeMeta(self, value)
|
property.writeMeta(self, value)
|
||||||
|
// perform read after writing if the writer did not set the value and the value is still in invalid state
|
||||||
|
if (logicalState[propertyName] == null) {
|
||||||
|
val meta = property.readMeta(self)
|
||||||
|
propertyChanged(propertyName, meta)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
@ -157,22 +186,43 @@ public abstract class DeviceBase<D : Device>(
|
|||||||
return spec.executeWithMeta(self, argument ?: Meta.EMPTY)
|
return spec.executeWithMeta(self, argument ?: Meta.EMPTY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@DFExperimental
|
final override var lifecycleState: LifecycleState = LifecycleState.STOPPED
|
||||||
override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.INIT
|
private set
|
||||||
protected set
|
|
||||||
|
|
||||||
@OptIn(DFExperimental::class)
|
|
||||||
override suspend fun open() {
|
private suspend fun setLifecycleState(lifecycleState: LifecycleState) {
|
||||||
super.open()
|
this.lifecycleState = lifecycleState
|
||||||
lifecycleState = DeviceLifecycleState.OPEN
|
sharedMessageFlow.emit(
|
||||||
|
DeviceLifeCycleMessage(lifecycleState)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(DFExperimental::class)
|
protected open suspend fun onStart() {
|
||||||
override fun close() {
|
|
||||||
lifecycleState = DeviceLifecycleState.CLOSED
|
|
||||||
super.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
abstract override fun toString(): String
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -2,7 +2,7 @@ package space.kscience.controls.spec
|
|||||||
|
|
||||||
import space.kscience.controls.api.Device
|
import space.kscience.controls.api.Device
|
||||||
import space.kscience.dataforge.context.Context
|
import space.kscience.dataforge.context.Context
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A device generated from specification
|
* A device generated from specification
|
||||||
@ -16,15 +16,36 @@ public open class DeviceBySpec<D : Device>(
|
|||||||
override val properties: Map<String, DevicePropertySpec<D, *>> get() = spec.properties
|
override val properties: Map<String, DevicePropertySpec<D, *>> get() = spec.properties
|
||||||
override val actions: Map<String, DeviceActionSpec<D, *, *>> get() = spec.actions
|
override val actions: Map<String, DeviceActionSpec<D, *, *>> get() = spec.actions
|
||||||
|
|
||||||
override suspend fun open(): Unit = with(spec) {
|
// Map to store instances of nested devices
|
||||||
super.open()
|
private val _nestedDevices = hashMapOf<String, Device>()
|
||||||
|
|
||||||
|
// Provide access to nested devices
|
||||||
|
public val nestedDevices: Map<String, Device> get() = _nestedDevices
|
||||||
|
|
||||||
|
override suspend fun onStart(): Unit = with(spec) {
|
||||||
|
for ((name, deviceSpec) in spec.nestedDeviceSpecs) {
|
||||||
|
val nestedDevice = createNestedDevice(deviceSpec, name)
|
||||||
|
_nestedDevices[name] = nestedDevice
|
||||||
|
nestedDevice.start()
|
||||||
|
}
|
||||||
self.onOpen()
|
self.onOpen()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun close(): Unit = with(spec) {
|
override suspend fun onStop(): Unit = with(spec){
|
||||||
|
for (device in _nestedDevices.values) {
|
||||||
|
device.stop()
|
||||||
|
}
|
||||||
self.onClose()
|
self.onClose()
|
||||||
super.close()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun toString(): String = "Device(spec=$spec)"
|
override fun toString(): String = "Device(spec=$spec)"
|
||||||
|
|
||||||
|
private fun <ND : Device> createNestedDevice(deviceSpec: DeviceSpec<ND>, name: String): ND {
|
||||||
|
// Create an instance of the nested device.
|
||||||
|
val nestedMeta = meta[name] ?: Meta.EMPTY
|
||||||
|
val nestedDevice = deviceSpec.createDevice(context, nestedMeta)
|
||||||
|
return nestedDevice
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
@ -3,9 +3,9 @@ package space.kscience.controls.spec
|
|||||||
import space.kscience.controls.api.Device
|
import space.kscience.controls.api.Device
|
||||||
import space.kscience.controls.api.PropertyDescriptor
|
import space.kscience.controls.api.PropertyDescriptor
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.Meta
|
||||||
import space.kscience.dataforge.meta.transformations.MetaConverter
|
import space.kscience.dataforge.meta.MetaConverter
|
||||||
|
|
||||||
internal object DeviceMetaPropertySpec: DevicePropertySpec<Device,Meta> {
|
internal object DeviceMetaPropertySpec : DevicePropertySpec<Device, Meta> {
|
||||||
override val descriptor: PropertyDescriptor = PropertyDescriptor("@meta")
|
override val descriptor: PropertyDescriptor = PropertyDescriptor("@meta")
|
||||||
|
|
||||||
override val converter: MetaConverter<Meta> = MetaConverter.meta
|
override val converter: MetaConverter<Meta> = MetaConverter.meta
|
||||||
|
@ -4,11 +4,8 @@ import kotlinx.coroutines.CoroutineScope
|
|||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import space.kscience.controls.api.ActionDescriptor
|
import space.kscience.controls.api.*
|
||||||
import space.kscience.controls.api.Device
|
import space.kscience.dataforge.meta.MetaConverter
|
||||||
import space.kscience.controls.api.PropertyChangedMessage
|
|
||||||
import space.kscience.controls.api.PropertyDescriptor
|
|
||||||
import space.kscience.dataforge.meta.transformations.MetaConverter
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -20,7 +17,7 @@ public annotation class InternalDeviceAPI
|
|||||||
/**
|
/**
|
||||||
* Specification for a device read-only property
|
* Specification for a device read-only property
|
||||||
*/
|
*/
|
||||||
public interface DevicePropertySpec<in D : Device, T> {
|
public interface DevicePropertySpec<in D, T> {
|
||||||
/**
|
/**
|
||||||
* Property descriptor
|
* Property descriptor
|
||||||
*/
|
*/
|
||||||
@ -44,7 +41,7 @@ public interface DevicePropertySpec<in D : Device, T> {
|
|||||||
public val DevicePropertySpec<*, *>.name: String get() = descriptor.name
|
public val DevicePropertySpec<*, *>.name: String get() = descriptor.name
|
||||||
|
|
||||||
|
|
||||||
public interface WritableDevicePropertySpec<in D : Device, T> : DevicePropertySpec<D, T> {
|
public interface MutableDevicePropertySpec<in D : Device, T> : DevicePropertySpec<D, T> {
|
||||||
/**
|
/**
|
||||||
* Write physical value to a device
|
* Write physical value to a device
|
||||||
*/
|
*/
|
||||||
@ -53,7 +50,7 @@ public interface WritableDevicePropertySpec<in D : Device, T> : DevicePropertySp
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public interface DeviceActionSpec<in D : Device, I, O> {
|
public interface DeviceActionSpec<in D, I, O> {
|
||||||
/**
|
/**
|
||||||
* Action descriptor
|
* Action descriptor
|
||||||
*/
|
*/
|
||||||
@ -75,30 +72,29 @@ public interface DeviceActionSpec<in D : Device, I, O> {
|
|||||||
public val DeviceActionSpec<*, *, *>.name: String get() = descriptor.name
|
public val DeviceActionSpec<*, *, *>.name: String get() = descriptor.name
|
||||||
|
|
||||||
public suspend fun <T, D : Device> D.read(propertySpec: DevicePropertySpec<D, T>): T =
|
public suspend fun <T, D : Device> D.read(propertySpec: DevicePropertySpec<D, T>): T =
|
||||||
propertySpec.converter.metaToObject(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
|
propertySpec.converter.readOrNull(readProperty(propertySpec.name)) ?: error("Property read result is not valid")
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read typed value and update/push event if needed.
|
* Read typed value and update/push event if needed.
|
||||||
* Return null if property read is not successful or property is undefined.
|
* Return null if property read is not successful or property is undefined.
|
||||||
*/
|
*/
|
||||||
public suspend fun <T, D : DeviceBase<D>> D.readOrNull(propertySpec: DevicePropertySpec<D, T>): T? =
|
public suspend fun <T, D : DeviceBase<D>> D.readOrNull(propertySpec: DevicePropertySpec<D, T>): T? =
|
||||||
readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::metaToObject)
|
readPropertyOrNull(propertySpec.name)?.let(propertySpec.converter::readOrNull)
|
||||||
|
|
||||||
|
public suspend fun <T, D : Device> D.getOrRead(propertySpec: DevicePropertySpec<D, T>): T =
|
||||||
public operator fun <T, D : Device> D.get(propertySpec: DevicePropertySpec<D, T>): T? =
|
propertySpec.converter.read(getOrReadProperty(propertySpec.name))
|
||||||
getProperty(propertySpec.name)?.let(propertySpec.converter::metaToObject)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write typed property state and invalidate logical state
|
* Write typed property state and invalidate logical state
|
||||||
*/
|
*/
|
||||||
public suspend fun <T, D : Device> D.write(propertySpec: WritableDevicePropertySpec<D, T>, value: T) {
|
public suspend fun <T, D : Device> D.write(propertySpec: MutableDevicePropertySpec<D, T>, value: T) {
|
||||||
writeProperty(propertySpec.name, propertySpec.converter.objectToMeta(value))
|
writeProperty(propertySpec.name, propertySpec.converter.convert(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fire and forget variant of property writing. Actual write is performed asynchronously on a [Device] scope
|
* Fire and forget variant of property writing. Actual write is performed asynchronously on a [Device] scope
|
||||||
*/
|
*/
|
||||||
public operator fun <T, D : Device> D.set(propertySpec: WritableDevicePropertySpec<D, T>, value: T): Job = launch {
|
public fun <T, D : Device> D.writeAsync(propertySpec: MutableDevicePropertySpec<D, T>, value: T): Job = launch {
|
||||||
write(propertySpec, value)
|
write(propertySpec, value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,37 +104,39 @@ public operator fun <T, D : Device> D.set(propertySpec: WritableDevicePropertySp
|
|||||||
public fun <D : Device, T> D.propertyFlow(spec: DevicePropertySpec<D, T>): Flow<T> = messageFlow
|
public fun <D : Device, T> D.propertyFlow(spec: DevicePropertySpec<D, T>): Flow<T> = messageFlow
|
||||||
.filterIsInstance<PropertyChangedMessage>()
|
.filterIsInstance<PropertyChangedMessage>()
|
||||||
.filter { it.property == spec.name }
|
.filter { it.property == spec.name }
|
||||||
.mapNotNull { spec.converter.metaToObject(it.value) }
|
.mapNotNull { spec.converter.read(it.value) }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A type safe property change listener. Uses the device [CoroutineScope].
|
* A type safe property change listener. Uses the device [CoroutineScope].
|
||||||
*/
|
*/
|
||||||
public fun <D : Device, T> D.onPropertyChange(
|
public fun <D : Device, T> D.onPropertyChange(
|
||||||
spec: DevicePropertySpec<D, T>,
|
spec: DevicePropertySpec<D, T>,
|
||||||
|
scope: CoroutineScope = this,
|
||||||
callback: suspend PropertyChangedMessage.(T) -> Unit,
|
callback: suspend PropertyChangedMessage.(T) -> Unit,
|
||||||
): Job = messageFlow
|
): Job = messageFlow
|
||||||
.filterIsInstance<PropertyChangedMessage>()
|
.filterIsInstance<PropertyChangedMessage>()
|
||||||
.filter { it.property == spec.name }
|
.filter { it.property == spec.name }
|
||||||
.onEach { change ->
|
.onEach { change ->
|
||||||
val newValue = spec.converter.metaToObject(change.value)
|
val newValue = spec.converter.read(change.value)
|
||||||
if (newValue != null) {
|
if (newValue != null) {
|
||||||
change.callback(newValue)
|
change.callback(newValue)
|
||||||
}
|
}
|
||||||
}.launchIn(this)
|
}.launchIn(scope)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Call [callback] on initial property value and each value change
|
* Call [callback] on initial property value and each value change
|
||||||
*/
|
*/
|
||||||
public fun <D : Device, T> D.useProperty(
|
public fun <D : Device, T> D.useProperty(
|
||||||
spec: DevicePropertySpec<D, T>,
|
spec: DevicePropertySpec<D, T>,
|
||||||
|
scope: CoroutineScope = this,
|
||||||
callback: suspend (T) -> Unit,
|
callback: suspend (T) -> Unit,
|
||||||
): Job = launch {
|
): Job = scope.launch {
|
||||||
callback(read(spec))
|
callback(read(spec))
|
||||||
messageFlow
|
messageFlow
|
||||||
.filterIsInstance<PropertyChangedMessage>()
|
.filterIsInstance<PropertyChangedMessage>()
|
||||||
.filter { it.property == spec.name }
|
.filter { it.property == spec.name }
|
||||||
.collect { change ->
|
.collect { change ->
|
||||||
val newValue = spec.converter.metaToObject(change.value)
|
val newValue = spec.converter.readOrNull(change.value)
|
||||||
if (newValue != null) {
|
if (newValue != null) {
|
||||||
callback(newValue)
|
callback(newValue)
|
||||||
}
|
}
|
||||||
@ -149,7 +147,7 @@ public fun <D : Device, T> D.useProperty(
|
|||||||
/**
|
/**
|
||||||
* Reset the logical state of a property
|
* Reset the logical state of a property
|
||||||
*/
|
*/
|
||||||
public suspend fun <D : Device> D.invalidate(propertySpec: DevicePropertySpec<D, *>) {
|
public suspend fun <D : CachingDevice> D.invalidate(propertySpec: DevicePropertySpec<D, *>) {
|
||||||
invalidate(propertySpec.name)
|
invalidate(propertySpec.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,115 +1,85 @@
|
|||||||
package space.kscience.controls.spec
|
package space.kscience.controls.spec
|
||||||
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import space.kscience.controls.api.ActionDescriptor
|
import space.kscience.controls.api.*
|
||||||
import space.kscience.controls.api.Device
|
import space.kscience.dataforge.context.Context
|
||||||
import space.kscience.controls.api.PropertyDescriptor
|
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.Meta
|
||||||
import space.kscience.dataforge.meta.transformations.MetaConverter
|
import space.kscience.dataforge.meta.MetaConverter
|
||||||
|
import space.kscience.dataforge.meta.descriptors.MetaDescriptor
|
||||||
import kotlin.properties.PropertyDelegateProvider
|
import kotlin.properties.PropertyDelegateProvider
|
||||||
import kotlin.properties.ReadOnlyProperty
|
import kotlin.properties.ReadOnlyProperty
|
||||||
import kotlin.reflect.KMutableProperty1
|
|
||||||
import kotlin.reflect.KProperty
|
import kotlin.reflect.KProperty
|
||||||
import kotlin.reflect.KProperty1
|
|
||||||
|
|
||||||
public object UnitMetaConverter: MetaConverter<Unit>{
|
public object UnitMetaConverter : MetaConverter<Unit> {
|
||||||
override fun metaToObject(meta: Meta): Unit = Unit
|
|
||||||
|
|
||||||
override fun objectToMeta(obj: Unit): Meta = Meta.EMPTY
|
override fun readOrNull(source: Meta): Unit = Unit
|
||||||
|
|
||||||
|
override fun convert(obj: Unit): Meta = Meta.EMPTY
|
||||||
}
|
}
|
||||||
|
|
||||||
public val MetaConverter.Companion.unit: MetaConverter<Unit> get() = UnitMetaConverter
|
public val MetaConverter.Companion.unit: MetaConverter<Unit> get() = UnitMetaConverter
|
||||||
|
|
||||||
@OptIn(InternalDeviceAPI::class)
|
@OptIn(InternalDeviceAPI::class)
|
||||||
public abstract class DeviceSpec<D : Device> {
|
public abstract class DeviceSpec<D : Device> {
|
||||||
//initializing meta property for everyone
|
// Map to store properties
|
||||||
private val _properties = hashMapOf<String, DevicePropertySpec<D, *>>(
|
private val _properties = hashMapOf<String, DevicePropertySpec<D, *>>()
|
||||||
DeviceMetaPropertySpec.name to DeviceMetaPropertySpec
|
|
||||||
)
|
|
||||||
public val properties: Map<String, DevicePropertySpec<D, *>> get() = _properties
|
public val properties: Map<String, DevicePropertySpec<D, *>> get() = _properties
|
||||||
|
|
||||||
private val _actions = HashMap<String, DeviceActionSpec<D, *, *>>()
|
// Map to store actions
|
||||||
|
private val _actions = hashMapOf<String, DeviceActionSpec<D, *, *>>()
|
||||||
public val actions: Map<String, DeviceActionSpec<D, *, *>> get() = _actions
|
public val actions: Map<String, DeviceActionSpec<D, *, *>> get() = _actions
|
||||||
|
|
||||||
|
/**
|
||||||
public open suspend fun D.onOpen() {
|
* Registers a property in the spec.
|
||||||
}
|
*/
|
||||||
|
|
||||||
public open fun D.onClose() {
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
public fun <T, P : DevicePropertySpec<D, T>> registerProperty(deviceProperty: P): P {
|
public fun <T, P : DevicePropertySpec<D, T>> registerProperty(deviceProperty: P): P {
|
||||||
_properties[deviceProperty.name] = deviceProperty
|
_properties[deviceProperty.name] = deviceProperty
|
||||||
return deviceProperty
|
return deviceProperty
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun <T> property(
|
// Map to store nested device specifications
|
||||||
converter: MetaConverter<T>,
|
private val _nestedDeviceSpecs = hashMapOf<String, DeviceSpec<*>>()
|
||||||
readOnlyProperty: KProperty1<D, T>,
|
public val nestedDeviceSpecs: Map<String, DeviceSpec<*>> get() = _nestedDeviceSpecs
|
||||||
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)
|
* Registers an action in the spec.
|
||||||
}
|
*/
|
||||||
}
|
public fun <I, O> registerAction(deviceAction: DeviceActionSpec<D, I, O>): DeviceActionSpec<D, I, O> {
|
||||||
registerProperty(deviceProperty)
|
_actions[deviceAction.name] = deviceAction
|
||||||
ReadOnlyProperty { _, _ ->
|
return deviceAction
|
||||||
deviceProperty
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun <T> mutableProperty(
|
public open suspend fun D.onOpen() {
|
||||||
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 {
|
public open suspend fun D.onClose() {
|
||||||
//TODO add the type from converter
|
}
|
||||||
writable = true
|
|
||||||
}.apply(descriptorBuilder)
|
|
||||||
|
|
||||||
override val converter: MetaConverter<T> = converter
|
|
||||||
|
|
||||||
override suspend fun read(device: D): T = withContext(device.coroutineContext) {
|
|
||||||
readWriteProperty.get(device)
|
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) {
|
|
||||||
readWriteProperty.set(device, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
registerProperty(deviceProperty)
|
|
||||||
ReadOnlyProperty { _, _ ->
|
|
||||||
deviceProperty
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun <T> property(
|
public fun <T> property(
|
||||||
converter: MetaConverter<T>,
|
converter: MetaConverter<T>,
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> T?,
|
read: suspend D.(propertyName: String) -> T?,
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> =
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> =
|
||||||
PropertyDelegateProvider { _: DeviceSpec<D>, property ->
|
PropertyDelegateProvider { _: DeviceSpec<D>, property ->
|
||||||
val propertyName = name ?: property.name
|
val propertyName = name ?: property.name
|
||||||
val deviceProperty = object : DevicePropertySpec<D, T> {
|
val deviceProperty = object : DevicePropertySpec<D, T> {
|
||||||
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
|
|
||||||
|
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
|
||||||
|
converter.descriptor?.let { converterDescriptor ->
|
||||||
|
metaDescriptor {
|
||||||
|
from(converterDescriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fromSpec(property)
|
||||||
|
descriptorBuilder()
|
||||||
|
}
|
||||||
|
|
||||||
override val converter: MetaConverter<T> = converter
|
override val converter: MetaConverter<T> = converter
|
||||||
|
|
||||||
override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() }
|
override suspend fun read(device: D): T? =
|
||||||
|
withContext(device.coroutineContext) { device.read(propertyName) }
|
||||||
}
|
}
|
||||||
registerProperty(deviceProperty)
|
registerProperty(deviceProperty)
|
||||||
ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>> { _, _ ->
|
ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>> { _, _ ->
|
||||||
@ -121,33 +91,39 @@ public abstract class DeviceSpec<D : Device> {
|
|||||||
converter: MetaConverter<T>,
|
converter: MetaConverter<T>,
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> T?,
|
read: suspend D.(propertyName: String) -> T?,
|
||||||
write: suspend D.(T) -> Unit,
|
write: suspend D.(propertyName: String, value: T) -> Unit,
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>>> =
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
|
||||||
PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
|
PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
|
||||||
val propertyName = name ?: property.name
|
val propertyName = name ?: property.name
|
||||||
val deviceProperty = object : WritableDevicePropertySpec<D, T> {
|
val deviceProperty = object : MutableDevicePropertySpec<D, T> {
|
||||||
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
|
override val descriptor: PropertyDescriptor = PropertyDescriptor(
|
||||||
|
propertyName,
|
||||||
|
mutable = true
|
||||||
|
).apply {
|
||||||
|
converter.descriptor?.let { converterDescriptor ->
|
||||||
|
metaDescriptor {
|
||||||
|
from(converterDescriptor)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fromSpec(property)
|
||||||
|
descriptorBuilder()
|
||||||
|
}
|
||||||
override val converter: MetaConverter<T> = converter
|
override val converter: MetaConverter<T> = converter
|
||||||
|
|
||||||
override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() }
|
override suspend fun read(device: D): T? =
|
||||||
|
withContext(device.coroutineContext) { device.read(propertyName) }
|
||||||
|
|
||||||
override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) {
|
override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) {
|
||||||
device.write(value)
|
device.write(propertyName, value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_properties[propertyName] = deviceProperty
|
registerProperty(deviceProperty)
|
||||||
ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>> { _, _ ->
|
ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>> { _, _ ->
|
||||||
deviceProperty
|
deviceProperty
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public fun <I, O> registerAction(deviceAction: DeviceActionSpec<D, I, O>): DeviceActionSpec<D, I, O> {
|
|
||||||
_actions[deviceAction.name] = deviceAction
|
|
||||||
return deviceAction
|
|
||||||
}
|
|
||||||
|
|
||||||
public fun <I, O> action(
|
public fun <I, O> action(
|
||||||
inputConverter: MetaConverter<I>,
|
inputConverter: MetaConverter<I>,
|
||||||
outputConverter: MetaConverter<O>,
|
outputConverter: MetaConverter<O>,
|
||||||
@ -155,10 +131,26 @@ public abstract class DeviceSpec<D : Device> {
|
|||||||
name: String? = null,
|
name: String? = null,
|
||||||
execute: suspend D.(I) -> O,
|
execute: suspend D.(I) -> O,
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, I, O>>> =
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, I, O>>> =
|
||||||
PropertyDelegateProvider { _: DeviceSpec<D>, property ->
|
PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
|
||||||
val actionName = name ?: property.name
|
val actionName = name ?: property.name
|
||||||
val deviceAction = object : DeviceActionSpec<D, I, O> {
|
val deviceAction = object : DeviceActionSpec<D, I, O> {
|
||||||
override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply(descriptorBuilder)
|
override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply {
|
||||||
|
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 inputConverter: MetaConverter<I> = inputConverter
|
||||||
override val outputConverter: MetaConverter<O> = outputConverter
|
override val outputConverter: MetaConverter<O> = outputConverter
|
||||||
@ -173,68 +165,69 @@ public abstract class DeviceSpec<D : Device> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
public open fun createDevice(context: Context, meta: Meta): D {
|
||||||
* An action that takes [Meta] and returns [Meta]. No conversions are done
|
// Since DeviceSpec<D> doesn't know how to create an instance of D,
|
||||||
*/
|
// you need to override this method in subclasses.
|
||||||
public fun metaAction(
|
throw NotImplementedError("createDevice must be implemented in subclasses")
|
||||||
descriptorBuilder: ActionDescriptor.() -> Unit = {},
|
}
|
||||||
name: String? = null,
|
|
||||||
execute: suspend D.(Meta) -> Meta,
|
public fun <ND : Device> device(
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> =
|
deviceSpec: DeviceSpec<ND>,
|
||||||
action(
|
name: String? = null
|
||||||
MetaConverter.Companion.meta,
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceSpec<ND>>> =
|
||||||
MetaConverter.Companion.meta,
|
PropertyDelegateProvider { _, property ->
|
||||||
descriptorBuilder,
|
val deviceName = name ?: property.name
|
||||||
name
|
// Register the nested device spec
|
||||||
) {
|
_nestedDeviceSpecs[deviceName] = deviceSpec
|
||||||
execute(it)
|
ReadOnlyProperty { _, _ -> deviceSpec }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 DeviceSpec<*>.validate(device: Device) {
|
||||||
public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
|
properties.map { it.value.descriptor }.forEach { specProperty ->
|
||||||
converter: MetaConverter<T>,
|
check(specProperty in device.propertyDescriptors) { "Property ${specProperty.name} not registered in ${device.id}" }
|
||||||
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)
|
|
||||||
|
|
||||||
override val converter: MetaConverter<T> = converter
|
actions.map { it.value.descriptor }.forEach { specAction ->
|
||||||
|
check(specAction in device.actionDescriptors) { "Action ${specAction.name} not registered in ${device.id}" }
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@ -1,38 +1,46 @@
|
|||||||
package space.kscience.controls.spec
|
package space.kscience.controls.spec
|
||||||
|
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.delay
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
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.api.Device
|
||||||
|
import space.kscience.controls.manager.getCoroutineDispatcher
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a recurring asynchronous read action and return a flow of results.
|
* Do a recurring (with a fixed delay) task on a device.
|
||||||
* The flow is lazy, so action is not performed unless flow is consumed.
|
|
||||||
* The flow uses called context. In order to call it on device context, use `flowOn(coroutineContext)`.
|
|
||||||
*
|
|
||||||
* The flow is canceled when the device scope is canceled
|
|
||||||
*/
|
*/
|
||||||
public fun <D : Device, R> D.readRecurring(interval: Duration, reader: suspend D.() -> R): Flow<R> = flow {
|
public fun <D : Device> D.doRecurring(
|
||||||
while (isActive) {
|
interval: Duration,
|
||||||
delay(interval)
|
debugTaskName: String? = null,
|
||||||
launch {
|
task: suspend D.() -> Unit,
|
||||||
emit(reader())
|
): 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 {
|
public fun <D : Device, R> D.readRecurring(
|
||||||
while (isActive) {
|
interval: Duration,
|
||||||
delay(interval)
|
debugTaskName: String? = null,
|
||||||
launch {
|
reader: suspend D.() -> R,
|
||||||
task()
|
): Flow<R> = flow {
|
||||||
}
|
doRecurring(interval, debugTaskName) {
|
||||||
|
emit(reader())
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -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<*>)
|
@ -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
|
|
@ -4,22 +4,73 @@ import space.kscience.controls.api.Device
|
|||||||
import space.kscience.controls.api.PropertyDescriptor
|
import space.kscience.controls.api.PropertyDescriptor
|
||||||
import space.kscience.controls.api.metaDescriptor
|
import space.kscience.controls.api.metaDescriptor
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.meta.MetaConverter
|
||||||
import space.kscience.dataforge.meta.ValueType
|
import space.kscience.dataforge.meta.ValueType
|
||||||
import space.kscience.dataforge.meta.transformations.MetaConverter
|
import space.kscience.dataforge.meta.descriptors.MetaDescriptor
|
||||||
|
import space.kscience.dataforge.meta.string
|
||||||
import kotlin.properties.PropertyDelegateProvider
|
import kotlin.properties.PropertyDelegateProvider
|
||||||
import kotlin.properties.ReadOnlyProperty
|
import kotlin.properties.ReadOnlyProperty
|
||||||
|
import kotlin.reflect.KMutableProperty1
|
||||||
|
import kotlin.reflect.KProperty1
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A read-only device property that delegates reading to a device [KProperty1]
|
||||||
|
*/
|
||||||
|
public fun <T, D : Device> DeviceSpec<D>.property(
|
||||||
|
converter: MetaConverter<T>,
|
||||||
|
readOnlyProperty: KProperty1<D, T>,
|
||||||
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = property(
|
||||||
|
converter,
|
||||||
|
descriptorBuilder,
|
||||||
|
name = readOnlyProperty.name,
|
||||||
|
read = { readOnlyProperty.get(this) }
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mutable property that delegates reading and writing to a device [KMutableProperty1]
|
||||||
|
*/
|
||||||
|
public fun <T, D : Device> DeviceSpec<D>.mutableProperty(
|
||||||
|
converter: MetaConverter<T>,
|
||||||
|
readWriteProperty: KMutableProperty1<D, T>,
|
||||||
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
|
||||||
|
mutableProperty(
|
||||||
|
converter,
|
||||||
|
descriptorBuilder,
|
||||||
|
readWriteProperty.name,
|
||||||
|
read = { _ -> readWriteProperty.get(this) },
|
||||||
|
write = { _, value: T -> readWriteProperty.set(this, value) }
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a mutable logical property (without a corresponding physical state) for a device
|
||||||
|
*/
|
||||||
|
public fun <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
|
||||||
|
converter: MetaConverter<T>,
|
||||||
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
|
name: String? = null,
|
||||||
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
|
||||||
|
mutableProperty(
|
||||||
|
converter,
|
||||||
|
descriptorBuilder,
|
||||||
|
name,
|
||||||
|
read = { propertyName -> getProperty(propertyName)?.let(converter::readOrNull) },
|
||||||
|
write = { propertyName, value -> writeProperty(propertyName, converter.convert(value)) }
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
//read only delegates
|
//read only delegates
|
||||||
|
|
||||||
public fun <D : Device> DeviceSpec<D>.booleanProperty(
|
public fun <D : Device> DeviceSpec<D>.booleanProperty(
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> Boolean?
|
read: suspend D.(propertyName: String) -> Boolean?
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Boolean>>> = property(
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Boolean>>> = property(
|
||||||
MetaConverter.boolean,
|
MetaConverter.boolean,
|
||||||
{
|
{
|
||||||
metaDescriptor {
|
metaDescriptor {
|
||||||
type(ValueType.BOOLEAN)
|
valueType(ValueType.BOOLEAN)
|
||||||
}
|
}
|
||||||
descriptorBuilder()
|
descriptorBuilder()
|
||||||
},
|
},
|
||||||
@ -31,15 +82,15 @@ private inline fun numberDescriptor(
|
|||||||
crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {}
|
crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {}
|
||||||
): PropertyDescriptor.() -> Unit = {
|
): PropertyDescriptor.() -> Unit = {
|
||||||
metaDescriptor {
|
metaDescriptor {
|
||||||
type(ValueType.NUMBER)
|
valueType(ValueType.NUMBER)
|
||||||
}
|
}
|
||||||
descriptorBuilder()
|
descriptorBuilder()
|
||||||
}
|
}
|
||||||
|
|
||||||
public fun <D : Device> DeviceSpec<D>.numberProperty(
|
public fun <D : Device> DeviceSpec<D>.numberProperty(
|
||||||
name: String? = null,
|
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
read: suspend D.() -> Number?
|
name: String? = null,
|
||||||
|
read: suspend D.(propertyName: String) -> Number?
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Number>>> = property(
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Number>>> = property(
|
||||||
MetaConverter.number,
|
MetaConverter.number,
|
||||||
numberDescriptor(descriptorBuilder),
|
numberDescriptor(descriptorBuilder),
|
||||||
@ -50,7 +101,7 @@ public fun <D : Device> DeviceSpec<D>.numberProperty(
|
|||||||
public fun <D : Device> DeviceSpec<D>.doubleProperty(
|
public fun <D : Device> DeviceSpec<D>.doubleProperty(
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> Double?
|
read: suspend D.(propertyName: String) -> Double?
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Double>>> = property(
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Double>>> = property(
|
||||||
MetaConverter.double,
|
MetaConverter.double,
|
||||||
numberDescriptor(descriptorBuilder),
|
numberDescriptor(descriptorBuilder),
|
||||||
@ -61,12 +112,12 @@ public fun <D : Device> DeviceSpec<D>.doubleProperty(
|
|||||||
public fun <D : Device> DeviceSpec<D>.stringProperty(
|
public fun <D : Device> DeviceSpec<D>.stringProperty(
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> String?
|
read: suspend D.(propertyName: String) -> String?
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, String>>> = property(
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, String>>> = property(
|
||||||
MetaConverter.string,
|
MetaConverter.string,
|
||||||
{
|
{
|
||||||
metaDescriptor {
|
metaDescriptor {
|
||||||
type(ValueType.STRING)
|
valueType(ValueType.STRING)
|
||||||
}
|
}
|
||||||
descriptorBuilder()
|
descriptorBuilder()
|
||||||
},
|
},
|
||||||
@ -77,12 +128,12 @@ public fun <D : Device> DeviceSpec<D>.stringProperty(
|
|||||||
public fun <D : Device> DeviceSpec<D>.metaProperty(
|
public fun <D : Device> DeviceSpec<D>.metaProperty(
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> Meta?
|
read: suspend D.(propertyName: String) -> Meta?
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Meta>>> = property(
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Meta>>> = property(
|
||||||
MetaConverter.meta,
|
MetaConverter.meta,
|
||||||
{
|
{
|
||||||
metaDescriptor {
|
metaDescriptor {
|
||||||
type(ValueType.STRING)
|
valueType(ValueType.STRING)
|
||||||
}
|
}
|
||||||
descriptorBuilder()
|
descriptorBuilder()
|
||||||
},
|
},
|
||||||
@ -95,14 +146,14 @@ public fun <D : Device> DeviceSpec<D>.metaProperty(
|
|||||||
public fun <D : Device> DeviceSpec<D>.booleanProperty(
|
public fun <D : Device> DeviceSpec<D>.booleanProperty(
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> Boolean?,
|
read: suspend D.(propertyName: String) -> Boolean?,
|
||||||
write: suspend D.(Boolean) -> Unit
|
write: suspend D.(propertyName: String, value: Boolean) -> Unit
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Boolean>>> =
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Boolean>>> =
|
||||||
mutableProperty(
|
mutableProperty(
|
||||||
MetaConverter.boolean,
|
MetaConverter.boolean,
|
||||||
{
|
{
|
||||||
metaDescriptor {
|
metaDescriptor {
|
||||||
type(ValueType.BOOLEAN)
|
valueType(ValueType.BOOLEAN)
|
||||||
}
|
}
|
||||||
descriptorBuilder()
|
descriptorBuilder()
|
||||||
},
|
},
|
||||||
@ -115,31 +166,150 @@ public fun <D : Device> DeviceSpec<D>.booleanProperty(
|
|||||||
public fun <D : Device> DeviceSpec<D>.numberProperty(
|
public fun <D : Device> DeviceSpec<D>.numberProperty(
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> Number,
|
read: suspend D.(propertyName: String) -> Number,
|
||||||
write: suspend D.(Number) -> Unit
|
write: suspend D.(propertyName: String, value: Number) -> Unit
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Number>>> =
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Number>>> =
|
||||||
mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write)
|
mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write)
|
||||||
|
|
||||||
public fun <D : Device> DeviceSpec<D>.doubleProperty(
|
public fun <D : Device> DeviceSpec<D>.doubleProperty(
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> Double,
|
read: suspend D.(propertyName: String) -> Double,
|
||||||
write: suspend D.(Double) -> Unit
|
write: suspend D.(propertyName: String, value: Double) -> Unit
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Double>>> =
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Double>>> =
|
||||||
mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write)
|
mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write)
|
||||||
|
|
||||||
public fun <D : Device> DeviceSpec<D>.stringProperty(
|
public fun <D : Device> DeviceSpec<D>.stringProperty(
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> String,
|
read: suspend D.(propertyName: String) -> String,
|
||||||
write: suspend D.(String) -> Unit
|
write: suspend D.(propertyName: String, value: String) -> Unit
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, String>>> =
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, String>>> =
|
||||||
mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write)
|
mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write)
|
||||||
|
|
||||||
public fun <D : Device> DeviceSpec<D>.metaProperty(
|
public fun <D : Device> DeviceSpec<D>.metaProperty(
|
||||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
name: String? = null,
|
name: String? = null,
|
||||||
read: suspend D.() -> Meta,
|
read: suspend D.(propertyName: String) -> Meta,
|
||||||
write: suspend D.(Meta) -> Unit
|
write: suspend D.(propertyName: String, value: Meta) -> Unit
|
||||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Meta>>> =
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Meta>>> =
|
||||||
mutableProperty(MetaConverter.meta, descriptorBuilder, name, read, write)
|
mutableProperty(MetaConverter.meta, descriptorBuilder, name, read, write)
|
||||||
|
|
||||||
|
public fun <D : Device> DeviceSpec<D>.doubleProperty(
|
||||||
|
description: String? = null,
|
||||||
|
name: String? = null,
|
||||||
|
read: suspend D.(String) -> Double?,
|
||||||
|
write: (suspend D.(String, Double) -> Unit)? = null
|
||||||
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Double>>> {
|
||||||
|
val converter = MetaConverter.double
|
||||||
|
val descriptorBuilder: PropertyDescriptor.() -> Unit = {
|
||||||
|
this.description = description
|
||||||
|
metaDescriptor {
|
||||||
|
valueType(ValueType.NUMBER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (write != null) {
|
||||||
|
mutableProperty(converter, descriptorBuilder, name, read, write)
|
||||||
|
} else {
|
||||||
|
property(converter, descriptorBuilder, name, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun <D : Device> DeviceSpec<D>.booleanProperty(
|
||||||
|
description: String? = null,
|
||||||
|
name: String? = null,
|
||||||
|
read: suspend D.(String) -> Boolean?,
|
||||||
|
write: (suspend D.(String, Boolean) -> Unit)? = null
|
||||||
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Boolean>>> {
|
||||||
|
val converter = MetaConverter.boolean
|
||||||
|
val descriptorBuilder: PropertyDescriptor.() -> Unit = {
|
||||||
|
this.description = description
|
||||||
|
metaDescriptor {
|
||||||
|
valueType(ValueType.BOOLEAN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (write != null) {
|
||||||
|
mutableProperty(converter, descriptorBuilder, name, read, write)
|
||||||
|
} else {
|
||||||
|
property(converter, descriptorBuilder, name, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun <D : Device> DeviceSpec<D>.stringProperty(
|
||||||
|
description: String? = null,
|
||||||
|
name: String? = null,
|
||||||
|
read: suspend D.(String) -> String?,
|
||||||
|
write: (suspend D.(String, String) -> Unit)? = null
|
||||||
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, String>>> {
|
||||||
|
val converter = MetaConverter.string
|
||||||
|
val descriptorBuilder: PropertyDescriptor.() -> Unit = {
|
||||||
|
this.description = description
|
||||||
|
metaDescriptor {
|
||||||
|
valueType(ValueType.STRING)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (write != null) {
|
||||||
|
mutableProperty(converter, descriptorBuilder, name, read, write)
|
||||||
|
} else {
|
||||||
|
property(converter, descriptorBuilder, name, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun <D : Device> DeviceSpec<D>.intProperty(
|
||||||
|
description: String? = null,
|
||||||
|
name: String? = null,
|
||||||
|
read: suspend D.(propertyName: String) -> Int?,
|
||||||
|
write: (suspend D.(propertyName: String, value: Int) -> Unit)? = null
|
||||||
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Int>>> {
|
||||||
|
val converter = MetaConverter.int
|
||||||
|
val descriptorBuilder: PropertyDescriptor.() -> Unit = {
|
||||||
|
this.description = description
|
||||||
|
metaDescriptor {
|
||||||
|
valueType(ValueType.NUMBER)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return if (write != null) {
|
||||||
|
mutableProperty(converter, descriptorBuilder, name, read, write)
|
||||||
|
} else {
|
||||||
|
property(converter, descriptorBuilder, name, read)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public fun <E : Enum<E>, D : Device> DeviceSpec<D>.enumProperty(
|
||||||
|
enumValues: Array<E>,
|
||||||
|
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||||
|
name: String? = null,
|
||||||
|
read: suspend D.(propertyName: String) -> E?,
|
||||||
|
write: (suspend D.(propertyName: String, value: E) -> Unit)? = null
|
||||||
|
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, E>>> {
|
||||||
|
val converter = object : MetaConverter<E> {
|
||||||
|
override val descriptor: MetaDescriptor = MetaDescriptor {
|
||||||
|
valueType(ValueType.STRING)
|
||||||
|
allowedValues(enumValues.map { it.name })
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun readOrNull(source: Meta): E? {
|
||||||
|
val value = source.string ?: return null
|
||||||
|
return enumValues.firstOrNull { it.name == value }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun convert(obj: E): Meta = Meta(obj.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (write != null) {
|
||||||
|
mutableProperty(
|
||||||
|
converter = converter,
|
||||||
|
descriptorBuilder = descriptorBuilder,
|
||||||
|
name = name,
|
||||||
|
read = read,
|
||||||
|
write = write
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
property(
|
||||||
|
converter = converter,
|
||||||
|
descriptorBuilder = descriptorBuilder,
|
||||||
|
name = name,
|
||||||
|
read = read
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -1,9 +1,8 @@
|
|||||||
package space.kscience.controls.api
|
package space.kscience.controls.api
|
||||||
|
|
||||||
import kotlinx.serialization.decodeFromString
|
|
||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import space.kscience.controls.spec.asMeta
|
import space.kscience.controls.misc.asMeta
|
||||||
import kotlin.test.Test
|
import kotlin.test.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
|
|
||||||
|
@ -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<*>){}
|
@ -1,19 +1,21 @@
|
|||||||
package space.kscience.controls.ports
|
package space.kscience.controls.ports
|
||||||
|
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import space.kscience.dataforge.context.Context
|
import space.kscience.controls.api.LifecycleState
|
||||||
import space.kscience.dataforge.context.error
|
import space.kscience.dataforge.context.*
|
||||||
import space.kscience.dataforge.context.info
|
|
||||||
import space.kscience.dataforge.context.logger
|
|
||||||
import space.kscience.dataforge.meta.*
|
import space.kscience.dataforge.meta.*
|
||||||
import java.net.InetSocketAddress
|
import java.net.InetSocketAddress
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.channels.AsynchronousCloseException
|
||||||
import java.nio.channels.ByteChannel
|
import java.nio.channels.ByteChannel
|
||||||
import java.nio.channels.DatagramChannel
|
import java.nio.channels.DatagramChannel
|
||||||
import java.nio.channels.SocketChannel
|
import java.nio.channels.SocketChannel
|
||||||
import kotlin.coroutines.CoroutineContext
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
|
||||||
public fun ByteBuffer.toArray(limit: Int = limit()): ByteArray {
|
/**
|
||||||
|
* Copy the contents of this buffer to an array
|
||||||
|
*/
|
||||||
|
public fun ByteBuffer.copyToArray(limit: Int = limit()): ByteArray {
|
||||||
rewind()
|
rewind()
|
||||||
val response = ByteArray(limit)
|
val response = ByteArray(limit)
|
||||||
get(response)
|
get(response)
|
||||||
@ -26,32 +28,42 @@ public fun ByteBuffer.toArray(limit: Int = limit()): ByteArray {
|
|||||||
*/
|
*/
|
||||||
public class ChannelPort(
|
public class ChannelPort(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
meta: Meta,
|
||||||
coroutineContext: CoroutineContext = context.coroutineContext,
|
coroutineContext: CoroutineContext = context.coroutineContext,
|
||||||
channelBuilder: suspend () -> ByteChannel,
|
channelBuilder: suspend () -> ByteChannel,
|
||||||
) : AbstractPort(context, coroutineContext), AutoCloseable {
|
) : AbstractAsynchronousPort(context, meta, coroutineContext) {
|
||||||
|
|
||||||
private val futureChannel: Deferred<ByteChannel> = this.scope.async(Dispatchers.IO) {
|
|
||||||
channelBuilder()
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A handler to await port connection
|
* A handler to await port connection
|
||||||
*/
|
*/
|
||||||
public val startJob: Job get() = futureChannel
|
private val futureChannel: Deferred<ByteChannel> = scope.async(Dispatchers.IO, start = CoroutineStart.LAZY) {
|
||||||
|
channelBuilder()
|
||||||
|
}
|
||||||
|
|
||||||
private val listenerJob = this.scope.launch(Dispatchers.IO) {
|
private var listenerJob: Job? = null
|
||||||
val channel = futureChannel.await()
|
|
||||||
val buffer = ByteBuffer.allocate(1024)
|
override val lifecycleState: LifecycleState
|
||||||
while (isActive) {
|
get() = if(listenerJob?.isActive == true) LifecycleState.STARTED else LifecycleState.STOPPED
|
||||||
try {
|
|
||||||
val num = channel.read(buffer)
|
override fun onOpen() {
|
||||||
if (num > 0) {
|
listenerJob = scope.launch(Dispatchers.IO) {
|
||||||
receive(buffer.toArray(num))
|
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)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
override fun close() {
|
override suspend fun stop() {
|
||||||
listenerJob.cancel()
|
listenerJob?.cancel()
|
||||||
if (futureChannel.isCompleted) {
|
if (futureChannel.isCompleted) {
|
||||||
futureChannel.getCompleted().close()
|
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 build(
|
||||||
|
|
||||||
public fun open(
|
|
||||||
context: Context,
|
context: Context,
|
||||||
host: String,
|
host: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
coroutineContext: CoroutineContext = context.coroutineContext,
|
coroutineContext: CoroutineContext = context.coroutineContext,
|
||||||
): ChannelPort = ChannelPort(context, coroutineContext) {
|
): ChannelPort {
|
||||||
SocketChannel.open(InetSocketAddress(host, port))
|
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 {
|
override fun build(context: Context, meta: Meta): ChannelPort {
|
||||||
val host = meta["host"].string ?: "localhost"
|
val host = meta["host"].string ?: "localhost"
|
||||||
val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta")
|
val port = meta["port"].int ?: error("Port value for TCP port is not defined in $meta")
|
||||||
return open(context, host, port)
|
return build(context, host, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A [PortFactory] for UDP connections
|
* A [Factory] for UDP connections
|
||||||
*/
|
*/
|
||||||
public object UdpPort : PortFactory {
|
public object UdpPort : Factory<AsynchronousPort> {
|
||||||
|
|
||||||
override val type: String = "udp"
|
public fun build(
|
||||||
|
context: Context,
|
||||||
|
remoteHost: String,
|
||||||
|
remotePort: Int,
|
||||||
|
localPort: Int? = null,
|
||||||
|
localHost: String? = null,
|
||||||
|
coroutineContext: CoroutineContext = context.coroutineContext,
|
||||||
|
): ChannelPort {
|
||||||
|
val meta = Meta {
|
||||||
|
"name" put "udp://$remoteHost:$remotePort"
|
||||||
|
"type" put "udp"
|
||||||
|
"remoteHost" put remoteHost
|
||||||
|
"remotePort" put remotePort
|
||||||
|
localHost?.let { "localHost" put it }
|
||||||
|
localPort?.let { "localPort" put it }
|
||||||
|
}
|
||||||
|
return ChannelPort(context, meta, coroutineContext) {
|
||||||
|
DatagramChannel.open().apply {
|
||||||
|
//bind the channel to a local port to receive messages
|
||||||
|
localPort?.let { bind(InetSocketAddress(localHost ?: "localhost", it)) }
|
||||||
|
//connect to remote port to send messages
|
||||||
|
connect(InetSocketAddress(remoteHost, remotePort.toInt()))
|
||||||
|
context.logger.info { "Connected to UDP $remotePort on $remoteHost" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect a datagram channel to a remote host/port. If [localPort] is provided, it is used to bind local port for receiving messages.
|
* Connect a datagram channel to a remote host/port. If [localPort] is provided, it is used to bind local port for receiving messages.
|
||||||
*/
|
*/
|
||||||
public fun open(
|
public suspend fun start(
|
||||||
context: Context,
|
context: Context,
|
||||||
remoteHost: String,
|
remoteHost: String,
|
||||||
remotePort: Int,
|
remotePort: Int,
|
||||||
localPort: Int? = null,
|
localPort: Int? = null,
|
||||||
localHost: String = "localhost",
|
localHost: String = "localhost",
|
||||||
coroutineContext: CoroutineContext = context.coroutineContext,
|
): ChannelPort = build(context, remoteHost, remotePort, localPort, localHost).apply { start() }
|
||||||
): ChannelPort = ChannelPort(context, coroutineContext) {
|
|
||||||
DatagramChannel.open().apply {
|
|
||||||
//bind the channel to a local port to receive messages
|
|
||||||
localPort?.let { bind(InetSocketAddress(localHost, localPort)) }
|
|
||||||
//connect to remote port to send messages
|
|
||||||
connect(InetSocketAddress(remoteHost, remotePort))
|
|
||||||
context.logger.info { "Connected to UDP $remotePort on $remoteHost" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun build(context: Context, meta: Meta): ChannelPort {
|
override fun build(context: Context, meta: Meta): ChannelPort {
|
||||||
val remoteHost by meta.string { error("Remote host is not specified") }
|
val remoteHost by meta.string { error("Remote host is not specified") }
|
||||||
val remotePort by meta.number { error("Remote port is not specified") }
|
val remotePort by meta.number { error("Remote port is not specified") }
|
||||||
val localHost: String? by meta.string()
|
val localHost: String? by meta.string()
|
||||||
val localPort: Int? by meta.int()
|
val localPort: Int? by meta.int()
|
||||||
return open(context, remoteHost, remotePort.toInt(), localPort, localHost ?: "localhost")
|
return build(context, remoteHost, remotePort.toInt(), localPort, localHost)
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -6,7 +6,7 @@ import space.kscience.dataforge.context.PluginFactory
|
|||||||
import space.kscience.dataforge.context.PluginTag
|
import space.kscience.dataforge.context.PluginTag
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.Meta
|
||||||
import space.kscience.dataforge.names.Name
|
import space.kscience.dataforge.names.Name
|
||||||
import space.kscience.dataforge.names.parseAsName
|
import space.kscience.dataforge.names.asName
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A plugin for loading JVM nio-based ports
|
* A plugin for loading JVM nio-based ports
|
||||||
@ -17,9 +17,9 @@ public class JvmPortsPlugin : AbstractPlugin() {
|
|||||||
override val tag: PluginTag get() = Companion.tag
|
override val tag: PluginTag get() = Companion.tag
|
||||||
|
|
||||||
override fun content(target: String): Map<Name, Any> = when(target){
|
override fun content(target: String): Map<Name, Any> = when(target){
|
||||||
PortFactory.TYPE -> mapOf(
|
Ports.ASYNCHRONOUS_PORT_TYPE -> mapOf(
|
||||||
TcpPort.type.parseAsName() to TcpPort,
|
"tcp".asName() to TcpPort,
|
||||||
UdpPort.type.parseAsName() to UdpPort
|
"udp".asName() to UdpPort
|
||||||
)
|
)
|
||||||
else -> emptyMap()
|
else -> emptyMap()
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
@ -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())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@ -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<*>){}
|
9
controls-core/src/wasmJsMain/kotlin/fromSpec.wasm.kt
Normal file
9
controls-core/src/wasmJsMain/kotlin/fromSpec.wasm.kt
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package space.kscience.controls.spec
|
||||||
|
|
||||||
|
import space.kscience.controls.api.ActionDescriptor
|
||||||
|
import space.kscience.controls.api.PropertyDescriptor
|
||||||
|
import kotlin.reflect.KProperty
|
||||||
|
|
||||||
|
internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>){}
|
||||||
|
|
||||||
|
internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){}
|
21
controls-jupyter/README.md
Normal file
21
controls-jupyter/README.md
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
# Module controls-jupyter
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
## Artifact:
|
||||||
|
|
||||||
|
The Maven coordinates of this project are `space.kscience:controls-jupyter:0.4.0-dev-4`.
|
||||||
|
|
||||||
|
**Gradle Kotlin DSL:**
|
||||||
|
```kotlin
|
||||||
|
repositories {
|
||||||
|
maven("https://repo.kotlin.link")
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation("space.kscience:controls-jupyter:0.4.0-dev-4")
|
||||||
|
}
|
||||||
|
```
|
8
controls-jupyter/api/controls-jupyter.api
Normal file
8
controls-jupyter/api/controls-jupyter.api
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
public final class space/kscience/controls/jupyter/ControlsJupyter : space/kscience/visionforge/jupyter/VisionForgeIntegration {
|
||||||
|
public static final field Companion Lspace/kscience/controls/jupyter/ControlsJupyter$Companion;
|
||||||
|
public fun <init> ()V
|
||||||
|
}
|
||||||
|
|
||||||
|
public final class space/kscience/controls/jupyter/ControlsJupyter$Companion {
|
||||||
|
}
|
||||||
|
|
18
controls-jupyter/build.gradle.kts
Normal file
18
controls-jupyter/build.gradle.kts
Normal file
@ -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)
|
||||||
|
}
|
||||||
|
}
|
14
controls-jupyter/src/jsMain/kotlin/commonJupyter.kt
Normal file
14
controls-jupyter/src/jsMain/kotlin/commonJupyter.kt
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import space.kscience.visionforge.html.runVisionClient
|
||||||
|
import space.kscience.visionforge.jupyter.VFNotebookClient
|
||||||
|
import space.kscience.visionforge.markup.MarkupPlugin
|
||||||
|
import space.kscience.visionforge.plotly.PlotlyPlugin
|
||||||
|
|
||||||
|
public fun main(): Unit = runVisionClient {
|
||||||
|
// plugin(DeviceManager)
|
||||||
|
// plugin(ClockManager)
|
||||||
|
plugin(PlotlyPlugin)
|
||||||
|
plugin(MarkupPlugin)
|
||||||
|
// plugin(TableVisionJsPlugin)
|
||||||
|
plugin(VFNotebookClient)
|
||||||
|
}
|
||||||
|
|
71
controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt
Normal file
71
controls-jupyter/src/jvmMain/kotlin/ControlsJupyter.kt
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package space.kscience.controls.jupyter
|
||||||
|
|
||||||
|
import org.jetbrains.kotlinx.jupyter.api.declare
|
||||||
|
import org.jetbrains.kotlinx.jupyter.api.libraries.resources
|
||||||
|
import space.kscience.controls.manager.ClockManager
|
||||||
|
import space.kscience.controls.manager.DeviceManager
|
||||||
|
import space.kscience.dataforge.context.Context
|
||||||
|
import space.kscience.dataforge.misc.DFExperimental
|
||||||
|
import space.kscience.plotly.Plot
|
||||||
|
import space.kscience.visionforge.jupyter.VisionForge
|
||||||
|
import space.kscience.visionforge.jupyter.VisionForgeIntegration
|
||||||
|
import space.kscience.visionforge.markup.MarkupPlugin
|
||||||
|
import space.kscience.visionforge.plotly.PlotlyPlugin
|
||||||
|
import space.kscience.visionforge.plotly.asVision
|
||||||
|
import space.kscience.visionforge.visionManager
|
||||||
|
|
||||||
|
|
||||||
|
@OptIn(DFExperimental::class)
|
||||||
|
public class ControlsJupyter : VisionForgeIntegration(CONTEXT.visionManager) {
|
||||||
|
|
||||||
|
override fun Builder.afterLoaded(vf: VisionForge) {
|
||||||
|
|
||||||
|
resources {
|
||||||
|
js("controls-jupyter") {
|
||||||
|
classPath("js/controls-jupyter.js")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onLoaded {
|
||||||
|
declare("context" to CONTEXT)
|
||||||
|
}
|
||||||
|
|
||||||
|
import(
|
||||||
|
"kotlin.time.*",
|
||||||
|
"kotlin.time.Duration.Companion.milliseconds",
|
||||||
|
"kotlin.time.Duration.Companion.seconds",
|
||||||
|
// "space.kscience.tables.*",
|
||||||
|
"space.kscience.dataforge.meta.*",
|
||||||
|
"space.kscience.dataforge.context.*",
|
||||||
|
"space.kscience.plotly.*",
|
||||||
|
"space.kscience.plotly.models.*",
|
||||||
|
"space.kscience.visionforge.plotly.*",
|
||||||
|
"space.kscience.controls.manager.*",
|
||||||
|
"space.kscience.controls.constructor.*",
|
||||||
|
"space.kscience.controls.vision.*",
|
||||||
|
"space.kscience.controls.spec.*"
|
||||||
|
)
|
||||||
|
|
||||||
|
// render<Table<*>> { table ->
|
||||||
|
// vf.produceHtml {
|
||||||
|
// vision { table.toVision() }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
render<Plot> { plot ->
|
||||||
|
vf.produceHtml {
|
||||||
|
vision { plot.asVision() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public companion object {
|
||||||
|
private val CONTEXT: Context = Context("controls-jupyter") {
|
||||||
|
plugin(DeviceManager)
|
||||||
|
plugin(ClockManager)
|
||||||
|
plugin(PlotlyPlugin)
|
||||||
|
// plugin(TableVisionPlugin)
|
||||||
|
plugin(MarkupPlugin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -12,18 +12,16 @@ Magix service for binding controls devices (both as RPC client and server)
|
|||||||
|
|
||||||
## Artifact:
|
## Artifact:
|
||||||
|
|
||||||
The Maven coordinates of this project are `space.kscience:controls-magix:0.2.0`.
|
The Maven coordinates of this project are `space.kscience:controls-magix:0.4.0-dev-4`.
|
||||||
|
|
||||||
**Gradle Kotlin DSL:**
|
**Gradle Kotlin DSL:**
|
||||||
```kotlin
|
```kotlin
|
||||||
repositories {
|
repositories {
|
||||||
maven("https://repo.kotlin.link")
|
maven("https://repo.kotlin.link")
|
||||||
//uncomment to access development builds
|
|
||||||
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
|
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("space.kscience:controls-magix:0.2.0")
|
implementation("space.kscience:controls-magix:0.4.0-dev-4")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@ -12,13 +12,26 @@ description = """
|
|||||||
kscience {
|
kscience {
|
||||||
jvm()
|
jvm()
|
||||||
js()
|
js()
|
||||||
|
native()
|
||||||
|
wasm()
|
||||||
|
useCoroutines()
|
||||||
useSerialization {
|
useSerialization {
|
||||||
json()
|
json()
|
||||||
}
|
}
|
||||||
dependencies {
|
|
||||||
|
commonMain {
|
||||||
api(projects.magix.magixApi)
|
api(projects.magix.magixApi)
|
||||||
api(projects.controlsCore)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
package space.kscience.controls.client
|
package space.kscience.controls.client
|
||||||
|
|
||||||
import com.benasher44.uuid.uuid4
|
import com.benasher44.uuid.uuid4
|
||||||
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.*
|
import kotlinx.coroutines.flow.*
|
||||||
import kotlinx.coroutines.newCoroutineContext
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import space.kscience.controls.api.*
|
import space.kscience.controls.api.*
|
||||||
import space.kscience.controls.manager.DeviceManager
|
import space.kscience.controls.manager.DeviceManager
|
||||||
|
import space.kscience.controls.spec.DevicePropertySpec
|
||||||
|
import space.kscience.controls.spec.name
|
||||||
import space.kscience.dataforge.context.Context
|
import space.kscience.dataforge.context.Context
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.Meta
|
||||||
import space.kscience.dataforge.misc.DFExperimental
|
import space.kscience.dataforge.misc.DFExperimental
|
||||||
@ -21,46 +26,41 @@ private fun stringUID() = uuid4().leastSignificantBits.toString(16)
|
|||||||
/**
|
/**
|
||||||
* A remote accessible device that relies on connection via Magix
|
* A remote accessible device that relies on connection via Magix
|
||||||
*/
|
*/
|
||||||
public class DeviceClient(
|
public class DeviceClient internal constructor(
|
||||||
override val context: Context,
|
override val context: Context,
|
||||||
private val deviceName: Name,
|
private val deviceName: Name,
|
||||||
|
propertyDescriptors: Collection<PropertyDescriptor>,
|
||||||
|
actionDescriptors: Collection<ActionDescriptor>,
|
||||||
incomingFlow: Flow<DeviceMessage>,
|
incomingFlow: Flow<DeviceMessage>,
|
||||||
private val send: suspend (DeviceMessage) -> Unit,
|
private val send: suspend (DeviceMessage) -> Unit,
|
||||||
) : Device {
|
) : CachingDevice {
|
||||||
|
|
||||||
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
|
|
||||||
override val coroutineContext: CoroutineContext = newCoroutineContext(context.coroutineContext)
|
override 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 mutex = Mutex()
|
||||||
|
|
||||||
private val propertyCache = HashMap<String, Meta>()
|
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 {
|
private val flowInternal = incomingFlow.filter {
|
||||||
it.sourceDevice == deviceName
|
it.sourceDevice == deviceName
|
||||||
}.shareIn(this, started = SharingStarted.Eagerly).also {
|
}.onEach { message ->
|
||||||
it.onEach { message ->
|
when (message) {
|
||||||
when (message) {
|
is PropertyChangedMessage -> mutex.withLock {
|
||||||
is PropertyChangedMessage -> mutex.withLock {
|
propertyCache[message.property] = message.value
|
||||||
propertyCache[message.property] = message.value
|
|
||||||
}
|
|
||||||
|
|
||||||
is DescriptionMessage -> mutex.withLock {
|
|
||||||
propertyDescriptors = message.properties
|
|
||||||
actionDescriptors = message.actions
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> {
|
|
||||||
//ignore
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}.launchIn(this)
|
|
||||||
}
|
else -> {
|
||||||
|
//ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.shareIn(this, started = SharingStarted.Eagerly)
|
||||||
|
|
||||||
override val messageFlow: Flow<DeviceMessage> get() = flowInternal
|
override val messageFlow: Flow<DeviceMessage> get() = flowInternal
|
||||||
|
|
||||||
@ -69,7 +69,7 @@ public class DeviceClient(
|
|||||||
send(
|
send(
|
||||||
PropertyGetMessage(propertyName, targetDevice = deviceName)
|
PropertyGetMessage(propertyName, targetDevice = deviceName)
|
||||||
)
|
)
|
||||||
return flowInternal.filterIsInstance<PropertyChangedMessage>().first {
|
return messageFlow.filterIsInstance<PropertyChangedMessage>().first {
|
||||||
it.property == propertyName
|
it.property == propertyName
|
||||||
}.value
|
}.value
|
||||||
}
|
}
|
||||||
@ -93,25 +93,181 @@ public class DeviceClient(
|
|||||||
send(
|
send(
|
||||||
ActionExecuteMessage(actionName, argument, id, targetDevice = deviceName)
|
ActionExecuteMessage(actionName, argument, id, targetDevice = deviceName)
|
||||||
)
|
)
|
||||||
return flowInternal.filterIsInstance<ActionResultMessage>().first {
|
return messageFlow.filterIsInstance<ActionResultMessage>().first {
|
||||||
it.action == actionName && it.requestId == id
|
it.action == actionName && it.requestId == id
|
||||||
}.result
|
}.result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val lifecycleStateFlow = messageFlow.filterIsInstance<DeviceLifeCycleMessage>()
|
||||||
|
.map { it.state }.stateIn(this, started = SharingStarted.Eagerly, LifecycleState.STARTED)
|
||||||
|
|
||||||
@DFExperimental
|
@DFExperimental
|
||||||
override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.OPEN
|
override val lifecycleState: LifecycleState get() = lifecycleStateFlow.value
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect to a remote device via this endpoint.
|
* Connect to a remote device via this endpoint.
|
||||||
*
|
*
|
||||||
* @param context a [Context] to run device in
|
* @param context a [Context] to run device in
|
||||||
* @param endpointName the name of endpoint in Magix to connect to
|
* @param 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
|
* @param deviceName the name of device within endpoint
|
||||||
*/
|
*/
|
||||||
public fun MagixEndpoint.remoteDevice(context: Context, endpointName: String, deviceName: Name): DeviceClient {
|
public suspend fun MagixEndpoint.remoteDevice(
|
||||||
val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second }
|
context: Context,
|
||||||
return DeviceClient(context, deviceName, subscription) {
|
thisEndpoint: String,
|
||||||
send(DeviceManager.magixFormat, it, endpointName, id = stringUID())
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
@ -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)
|
||||||
|
}
|
@ -1,5 +1,7 @@
|
|||||||
package space.kscience.controls.client
|
package space.kscience.controls.client
|
||||||
|
|
||||||
|
import com.benasher44.uuid.uuid4
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.flow.catch
|
import kotlinx.coroutines.flow.catch
|
||||||
import kotlinx.coroutines.flow.launchIn
|
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.error
|
||||||
import space.kscience.dataforge.context.logger
|
import space.kscience.dataforge.context.logger
|
||||||
import space.kscience.magix.api.*
|
import space.kscience.magix.api.*
|
||||||
|
import kotlin.coroutines.CoroutineContext
|
||||||
|
import kotlin.coroutines.EmptyCoroutineContext
|
||||||
|
|
||||||
|
|
||||||
internal val controlsMagixFormat: MagixFormat<DeviceMessage> = MagixFormat(
|
internal val controlsMagixFormat: MagixFormat<DeviceMessage> = MagixFormat(
|
||||||
@ -27,22 +31,25 @@ public val DeviceManager.Companion.magixFormat: MagixFormat<DeviceMessage> get()
|
|||||||
internal fun generateId(request: MagixMessage): String = if (request.id != null) {
|
internal fun generateId(request: MagixMessage): String = if (request.id != null) {
|
||||||
"${request.id}.response"
|
"${request.id}.response"
|
||||||
} else {
|
} 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)
|
* Communicate with server in [Magix format](https://github.com/waltz-controls/rfc/tree/master/1)
|
||||||
|
*
|
||||||
|
* Accepts messages with target that equals [endpointID] or null (broadcast messages)
|
||||||
*/
|
*/
|
||||||
public fun DeviceManager.launchMagixService(
|
public fun DeviceManager.launchMagixService(
|
||||||
endpoint: MagixEndpoint,
|
endpoint: MagixEndpoint,
|
||||||
endpointID: String = controlsMagixFormat.defaultFormat,
|
endpointID: String,
|
||||||
): Job = context.launch {
|
coroutineContext: CoroutineContext = EmptyCoroutineContext,
|
||||||
endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID)).onEach { (request, payload) ->
|
): Job = context.launch(coroutineContext) {
|
||||||
val responsePayload = respondHubMessage(payload)
|
endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID, null)).onEach { (request, payload) ->
|
||||||
if (responsePayload != null) {
|
val responsePayload: List<DeviceMessage> = respondHubMessage(payload)
|
||||||
|
responsePayload.forEach {
|
||||||
endpoint.send(
|
endpoint.send(
|
||||||
format = controlsMagixFormat,
|
format = controlsMagixFormat,
|
||||||
payload = responsePayload,
|
payload = it,
|
||||||
source = endpointID,
|
source = endpointID,
|
||||||
target = request.sourceEndpoint,
|
target = request.sourceEndpoint,
|
||||||
id = generateId(request),
|
id = generateId(request),
|
||||||
@ -50,10 +57,10 @@ public fun DeviceManager.launchMagixService(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.catch { error ->
|
}.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)
|
}.launchIn(this)
|
||||||
|
|
||||||
hubMessageFlow(this).onEach { payload ->
|
hubMessageFlow().onEach { payload ->
|
||||||
endpoint.send(
|
endpoint.send(
|
||||||
format = controlsMagixFormat,
|
format = controlsMagixFormat,
|
||||||
payload = payload,
|
payload = payload,
|
||||||
|
@ -5,12 +5,12 @@ import kotlinx.coroutines.flow.launchIn
|
|||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import space.kscience.controls.api.get
|
|
||||||
import space.kscience.controls.api.getOrReadProperty
|
import space.kscience.controls.api.getOrReadProperty
|
||||||
import space.kscience.controls.manager.DeviceManager
|
import space.kscience.controls.manager.DeviceManager
|
||||||
import space.kscience.dataforge.context.error
|
import space.kscience.dataforge.context.error
|
||||||
import space.kscience.dataforge.context.logger
|
import space.kscience.dataforge.context.logger
|
||||||
import space.kscience.dataforge.meta.Meta
|
import space.kscience.dataforge.meta.Meta
|
||||||
|
import space.kscience.dataforge.names.get
|
||||||
import space.kscience.magix.api.*
|
import space.kscience.magix.api.*
|
||||||
|
|
||||||
public const val TANGO_MAGIX_FORMAT: String = "tango"
|
public const val TANGO_MAGIX_FORMAT: String = "tango"
|
||||||
@ -88,7 +88,7 @@ public fun DeviceManager.launchTangoMagix(
|
|||||||
return context.launch {
|
return context.launch {
|
||||||
endpoint.subscribe(tangoMagixFormat).onEach { (request, payload) ->
|
endpoint.subscribe(tangoMagixFormat).onEach { (request, payload) ->
|
||||||
try {
|
try {
|
||||||
val device = get(payload.device)
|
val device = devices[payload.device] ?: error("Device ${payload.device} not found")
|
||||||
when (payload.action) {
|
when (payload.action) {
|
||||||
TangoAction.read -> {
|
TangoAction.read -> {
|
||||||
val value = device.getOrReadProperty(payload.name)
|
val value = device.getOrReadProperty(payload.name)
|
||||||
@ -99,6 +99,7 @@ public fun DeviceManager.launchTangoMagix(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TangoAction.write -> {
|
TangoAction.write -> {
|
||||||
payload.value?.let { value ->
|
payload.value?.let { value ->
|
||||||
device.writeProperty(payload.name, value)
|
device.writeProperty(payload.name, value)
|
||||||
@ -112,6 +113,7 @@ public fun DeviceManager.launchTangoMagix(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TangoAction.exec -> {
|
TangoAction.exec -> {
|
||||||
val result = device.execute(payload.name, payload.argin)
|
val result = device.execute(payload.name, payload.argin)
|
||||||
respond(request, payload) { requestPayload ->
|
respond(request, payload) { requestPayload ->
|
||||||
@ -121,6 +123,7 @@ public fun DeviceManager.launchTangoMagix(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TangoAction.pipe -> TODO("Pipe not implemented")
|
TangoAction.pipe -> TODO("Pipe not implemented")
|
||||||
}
|
}
|
||||||
} catch (ex: Exception) {
|
} catch (ex: Exception) {
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
@ -14,18 +14,16 @@ Automatically checks consistency.
|
|||||||
|
|
||||||
## Artifact:
|
## Artifact:
|
||||||
|
|
||||||
The Maven coordinates of this project are `space.kscience:controls-modbus:0.2.0`.
|
The Maven coordinates of this project are `space.kscience:controls-modbus:0.4.0-dev-4`.
|
||||||
|
|
||||||
**Gradle Kotlin DSL:**
|
**Gradle Kotlin DSL:**
|
||||||
```kotlin
|
```kotlin
|
||||||
repositories {
|
repositories {
|
||||||
maven("https://repo.kotlin.link")
|
maven("https://repo.kotlin.link")
|
||||||
//uncomment to access development builds
|
|
||||||
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
|
|
||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("space.kscience:controls-modbus:0.2.0")
|
implementation("space.kscience:controls-modbus:0.4.0-dev-4")
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import space.kscience.gradle.Maturity
|
import space.kscience.gradle.Maturity
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id("space.kscience.gradle.jvm")
|
id("space.kscience.gradle.mpp")
|
||||||
`maven-publish`
|
`maven-publish`
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -9,10 +9,12 @@ description = """
|
|||||||
A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols
|
A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
kscience {
|
||||||
dependencies {
|
jvm()
|
||||||
api(projects.controlsCore)
|
jvmMain {
|
||||||
api("com.ghgande:j2mod:3.1.1")
|
api(projects.controlsCore)
|
||||||
|
api(libs.j2mod)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
readme{
|
readme{
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user