Compare commits

..

125 Commits

Author SHA1 Message Date
Maxim
b6f7963f68 Some changes for testing 2024-10-23 11:30:43 +03:00
Maxim
3f09d20465 Add simple expressions and equations 2024-10-10 19:44:29 +03:00
89d78c43bb Add wasm and native targets to core modules 2024-08-03 21:26:50 +03:00
c12f1ce1cd Add lifecycle to ports. Suspended device start 2024-08-03 21:11:59 +03:00
47327aef19 Add message flow output for PidDemo 2024-07-25 12:28:58 +03:00
f0f9d0e174 Add direct canvas customization for DeviceDrawable2D 2024-07-23 20:32:01 +03:00
fbf79f0a37 Implement new visualization 2024-07-22 18:46:58 +03:00
dc4f2c6126 Merge branch 'refs/heads/feature/device-collective-demo' into dev 2024-07-03 16:01:44 +03:00
a9d58bfac2 Fix StepDrive parameters 2024-06-18 20:18:18 +03:00
8c7c017ab4 Add linear drive calibration demo 2024-06-18 19:25:55 +03:00
f13b7268d6 update device-collective readme 2024-06-17 17:52:02 +03:00
92c4355f48 update device-collective readme 2024-06-17 17:49:12 +03:00
eb126a6090 Finalize collective demo 2024-06-12 16:31:14 +03:00
a5bb42706b Change visualization for collective 2024-06-12 11:56:27 +03:00
60a693b1b3 Fix visibility range for collective 2024-06-09 21:12:18 +03:00
c55ce2cf9a Fix visibility range for collective 2024-06-09 20:51:12 +03:00
e9bde68674 [WIP] remote communication for CollectiveDevice 2024-06-09 15:09:43 +03:00
13b80be884 Implement visibility range for collective device 2024-06-07 20:20:39 +03:00
5c7d3d8a7a Add PeerConnection 2024-06-07 10:52:28 +03:00
a2b5880da9 Add device collective demo 2024-06-06 16:54:17 +03:00
c63c2db651 Fix PID demo 2024-06-05 17:19:20 +03:00
91f860adf6 Move plotter model out of visualization 2024-06-03 18:48:12 +03:00
1799a9a909 Fix ball on springs demo 2024-06-03 18:34:08 +03:00
9f21a14f96 Fix ball on springs demo 2024-06-03 18:02:57 +03:00
4a5f5fab8c Finish plotter demo 2024-06-03 15:38:39 +03:00
a2b7d1ecb0 Fix step drive demo 2024-06-03 13:44:20 +03:00
d0e3faea88 Complete rework of PID demo and underlying devices 2024-06-02 17:58:38 +03:00
54e915ef10 WIP Constructor update 2024-06-01 09:35:03 +03:00
9edde7bdbd Major constructor refactoring 2024-05-29 22:20:22 +03:00
f72d7aa3fa Synchronous port response consumeAsSource 2024-05-28 09:51:30 +03:00
05757aefdc First draft of model binding 2024-05-24 13:56:54 +03:00
55bcb08668 Force to use endpoint ID in launchMagixService 2024-05-23 16:16:22 +03:00
673a7c89a6 [WIP] working on constructor 2024-05-21 13:31:01 +03:00
a66e411848 Fix Rsocket endpoint without filter. Add integration test with loop 2024-05-21 09:44:45 +03:00
4a10c3c443 Add test for remote hub 2024-05-19 18:50:56 +03:00
207064cd45 Remove hierarchical device structure in Hubs 2024-05-19 10:44:34 +03:00
e5088ac8e4 Make remote device connection ask for descriptors before start 2024-05-17 23:01:20 +03:00
ee83f81a04 Fix small typo in event id generation 2024-05-16 21:22:34 +03:00
5921978122 [WIP] Refactor constructor 2024-05-15 22:49:08 +03:00
a9592d0372 Fixed part of motionMaster 2024-05-12 14:14:31 +03:00
44514cd477 [WIP] moving from java fx to compose in examples 2024-05-12 13:52:00 +03:00
24b6856f15 Move all-things to Compose 2024-05-11 17:56:28 +03:00
381da970bf make device stop suspended to properly await for lifecycle event.
Add capabilities to Constructor
2024-05-10 11:33:00 +03:00
4b05f46fa7 Merge pull request 'Обновление зависимостей' (!10) from support/update_dependencies into dev
Reviewed-on: #10
Reviewed-by: Alexander Nozik <altavir@gmail.com>
2024-04-29 18:29:57 +03:00
f974483a41 Update plotly version 2024-04-29 18:28:14 +03:00
e729cb1a79 upfixes 2024-04-29 18:38:14 +06:00
8e7277df69 Merge branch 'dev' into support/update_dependencies 2024-04-29 18:09:22 +06:00
23bceed89d update dependencies 2024-04-29 17:22:56 +06:00
9eb583dfc6 Change plotAveragedDeviceProperty to show last value on miss. 2024-04-09 15:13:41 +03:00
977500223d Plc4X refactor 2024-04-07 10:07:23 +03:00
58675f72f5 Refactor ports 2024-03-31 16:33:22 +03:00
85c2910ee9 Refactor ports 2024-03-31 16:13:02 +03:00
d91296c47d Refactor load test 2024-03-25 15:48:23 +03:00
8965629151 complete dependencies extraction 2024-03-20 01:08:04 +06:00
9a40d4f340 exclude ktor/rsocket/dataforge versions 2024-03-20 00:48:26 +06:00
78dade4b49 PLC4x bindings 2024-03-18 17:18:31 +03:00
70ab60f98c fix plot extensions 2024-03-18 17:15:39 +03:00
53cc4dc0df PLC4x bindings 2024-03-18 09:34:14 +03:00
f28e9dc226 Update constructor api 2024-03-18 09:30:41 +03:00
29af4dfb2c Add heartbeat and watchdog 2024-03-12 22:26:43 +03:00
4835376c0d Add proper deviceName to in-memory property history 2024-03-06 18:55:11 +03:00
4639fdb558 update gradle wrapper 2024-03-06 00:21:55 +06:00
2946f23a4b Update readme and API 2024-03-04 16:02:50 +03:00
e8c6e90a0f Update readme and API 2024-03-04 15:58:53 +03:00
2a700a5a2a Migrate to DataForge 0.8.0 2024-03-04 15:24:27 +03:00
dbacdbc7cf Replace event controls-storage with async api 2024-03-04 12:47:40 +03:00
28ec2bc8b8 Add PropertyHistory API 2024-03-04 11:12:16 +03:00
cfd9eb053c Make DeviceMessage time mandatory 2024-03-04 11:11:56 +03:00
9edf3b13ef Remove unnecessary scope in hub message flow 2024-02-27 10:31:35 +03:00
57e9df140b Add utilities to work with remote devices 2024-02-19 15:10:51 +03:00
231f1bc858 Add special unsafe calls for DeviceClient to mirror safe device 2024-02-19 14:27:36 +03:00
8bd9bcc6a6 Fix bizzare NPE in context generation for DeviceClient.
Add test for remote client
2024-02-15 21:04:59 +03:00
b1121d61cb Allow controls magix endpoint to receive broadcast. 2024-02-05 14:08:15 +03:00
fa2414ef47 Add demo for device message listening 2024-02-02 16:04:41 +03:00
7579ddfad4 Quick fix for OPC us server 2023-12-28 22:40:58 +03:00
aa52b4b927 hub returns list of messages. 2023-12-28 21:09:23 +03:00
34f9108ef7 New builders for devices 2023-12-25 19:09:40 +03:00
bec075328b Make constructor device use context instead of device manager 2023-12-22 09:28:39 +03:00
701ea8cf57 Minor fixes to port implementations 2023-12-15 16:55:56 +03:00
5e64b79b77 Merge remote-tracking branch 'spc/dev' into dev 2023-12-13 20:24:43 +03:00
a12cf440e8 Finish migration to kotlinx-io 2023-12-13 20:20:03 +03:00
606c2cf5b1 Finish migration to kotlinx-io 2023-12-13 14:50:56 +03:00
fb03fcc982 Finish migration to kotlinx-io 2023-12-13 12:29:06 +03:00
cf129b6242 Migrate to DF 0.7 2023-12-12 09:59:52 +03:00
827eb6e4c1 minor update to constructor 2023-11-23 16:52:07 +03:00
81d6b672cf Add compose controls to pid simulator 2023-11-22 21:55:13 +03:00
07cc41c645 Automatic description generation for spec properties (JVM only) 2023-11-18 19:02:56 +03:00
0c647cff30 DeviceSpec properties no explicitly pass property name to getters and setters 2023-11-18 15:39:56 +03:00
b539c2046a DeviceSpec properties no explicitly pass property name to getters and setters 2023-11-18 14:49:23 +03:00
afee2f0a02 minor update to constructor 2023-11-17 12:22:06 +03:00
fb8ee59f14 replace debounce by sample 2023-11-08 22:33:49 +03:00
74301afb42 Return notifications about pid and drive updates. Introduce debounce 2023-11-08 22:28:26 +03:00
fe98a836f8 Update jupyter integration 2023-11-08 21:01:42 +03:00
0c128bce36 Merge remote-tracking branch 'spc/dev' into dev
# Conflicts:
#	demo/constructor/src/jvmMain/kotlin/main.kt
2023-11-08 15:31:55 +03:00
4e17c9051c Update jupyter integration 2023-11-08 15:31:12 +03:00
0f687c3c51 Update jupyter integration 2023-11-08 11:52:57 +03:00
53fc240c75 Test device constructor 2023-11-07 08:46:56 +03:00
825f1a4d04 Add DeviceConstructor 2023-11-06 16:46:16 +03:00
0443fdc3c0 Add fixed age plots for properties and states. 2023-11-06 11:39:56 +03:00
78b18ebda6 Move server to controls-vision 2023-11-05 10:18:26 +03:00
0e963a7b13 Simplify UI management in constructor 2023-11-05 09:47:58 +03:00
2698cee80b Remove automatic reads from virtual drive and pid 2023-11-02 15:36:10 +03:00
811477a636 add limit readers 2023-10-30 22:51:17 +03:00
984e7f12ef Add JVM application for constructor demo 2023-10-30 21:47:41 +03:00
1414cf5a2f implement constructor 2023-10-30 21:35:46 +03:00
1fcdbdc9f4 Update constructor 2023-10-28 14:18:00 +03:00
4f028ccee8 Lifecycle change 2023-10-27 10:57:46 +03:00
1619fdadf2 Refactoring. Developing composer 2023-10-25 22:31:36 +03:00
7f71d0c9e9 modbus registry to json rendering 2023-10-20 10:14:14 +03:00
290010fc8c Add writeable flag to mutable properties 2023-10-19 16:38:50 +03:00
80cc62e25b Merge remote-tracking branch 'spc/dev' into dev
# Conflicts:
#	demo/all-things/build.gradle.kts
2023-10-19 16:21:19 +03:00
f1b63c3951 Add buffer to device messages 2023-10-07 18:34:44 +03:00
01606af307 clientId -> unitId 2023-10-05 07:43:49 +03:00
2cc0a5bcbc Fixex in modbus and write-protection for same meta 2023-10-02 22:12:11 +03:00
efe9a2e842 Fixex in modbus and write-protection for same meta 2023-10-02 21:24:01 +03:00
34e7dd2c6d Add read-after-write for DeviceBase property writers 2023-09-24 13:29:15 +03:00
a337daee93 Add read-after-write for DeviceBase property writers 2023-09-24 13:21:01 +03:00
a51510606f add customizeable scopes to property listeners 2023-09-24 13:02:52 +03:00
aef94767c5 Fix all-things demo 2023-09-18 13:38:45 +03:00
8b6a6abd92 Update to PiPlugin logic 2023-09-18 09:00:04 +03:00
bc5037b256 fix dataforge version 2023-09-16 16:09:47 +03:00
036bef1adb fix dataforge version 2023-09-16 15:54:36 +03:00
cc36ef805b update version 2023-09-04 14:56:18 +03:00
0f610a5e19 Fix mass demo plot 2023-08-24 16:25:17 +03:00
4c93b5c9b3 Update readme 2023-08-23 16:37:35 +03:00
256 changed files with 13613 additions and 2399 deletions

4
.gitignore vendored
View File

@ -1,6 +1,7 @@
# Created by .ignore support plugin (hsz.mobi)
.idea/
.gradle
.kotlin
*.iws
*.iml
@ -8,4 +9,7 @@
out/
build/
!gradle-wrapper.jar
/demo/device-collective/mapCache/

View File

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

View File

@ -1 +0,0 @@
./space/* "Project Admin"

View File

@ -3,6 +3,64 @@
## Unreleased
### Added
- Value averaging plot extension
- PLC4X bindings
- Shortcuts to access all Controls devices in a magix network.
- `DeviceClient` properly evaluates lifecycle and logs
- `PeerConnection` API for direct device-device binary sharing
- DeviceDrawable2D intermediate visualization implementation
- New interface `WithLifeCycle`. Change Port API to adhere to it.
### Changed
- Constructor properties return `DeviceState` in order to be able to subscribe to them
- Refactored ports. Now we have `AsynchronousPort` as well as `SynchronousPort`
- `DeviceClient` now initializes property and action descriptors eagerly.
- `DeviceHub` now works with `Name` instead of `NameToken`. Tree-like structure is made using `Path`. Device messages no longer have access to sub-devices.
- Add some utility methods to ports. Synchronous port response could be now consumed as `Source`.
- `DeviceLifecycleState` is replaced by `LifecycleState`.
### Deprecated
### Removed
### Fixed
- Fix a problem with rsocket endpoint with no filter.
### Security
## 0.3.0 - 2024-03-04
### Added
- Device lifecycle message
- Low-code constructor
- Automatic description generation for spec properties (JVM only)
### Changed
- Property caching moved from core `Device` to the `CachingDevice`
- `DeviceSpec` properties no explicitly pass property name to getters and setters.
- `DeviceHub.respondHubMessage` now returns a list of messages to allow querying multiple devices. Device server also returns an array.
- DataForge 0.8.0
### Fixed
- Property writing does not trigger change if logical state already is the same as value to be set.
- Modbus-slave triggers only once for multi-register write.
- Removed unnecessary scope in hub messageFlow
## 0.2.2-dev-1 - 2023-09-24
### Changed
- updating logical state in `DeviceBase` is now protected and called `propertyChanged()`
- `DeviceBase` tries to read property after write if the writer does not set the value.
## 0.2.1 - 2023-09-24
### Added
- Core interfaces for building a device server
- Magix service for binding controls devices (both as RPC client and server)
- A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols
@ -20,13 +78,3 @@
- A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes.
- Magix history database API
- ZMQ client endpoint for Magix
### Changed
### Deprecated
### Removed
### Fixed
### Security

View File

@ -1,5 +1,7 @@
[![JetBrains Research](https://jb.gg/badges/research.svg)](https://confluence.jetbrains.com/display/ALL/JetBrains+on+GitHub)
[![](https://maven.sciprog.center/api/badge/latest/kscience/space/kscience/controls-core-jvm?color=40c14a&name=repo.kotlin.link&prefix=v)](https://maven.sciprog.center/)
# Controls.kt
Controls.kt (former DataForge-control) is a data acquisition framework (work in progress). It is based on DataForge, a software framework for automated data processing.
@ -42,6 +44,11 @@ Example view of a demo:
## Modules
### [controls-constructor](controls-constructor)
> A low-code constructor for composite devices simulation
>
> **Maturity**: PROTOTYPE
### [controls-core](controls-core)
> Core interfaces for building a device server
>
@ -56,6 +63,10 @@ Example view of a demo:
> - [ports](controls-core/src/commonMain/kotlin/space/kscience/controls/ports) : Working with asynchronous data sending and receiving raw byte arrays
### [controls-jupyter](controls-jupyter)
>
> **Maturity**: EXPERIMENTAL
### [controls-magix](controls-magix)
> Magix service for binding controls devices (both as RPC client and server)
>
@ -93,6 +104,11 @@ Automatically checks consistency.
>
> **Maturity**: EXPERIMENTAL
### [controls-plc4x](controls-plc4x)
> A plugin for Controls-kt device server on top of plc4x library
>
> **Maturity**: EXPERIMENTAL
### [controls-ports-ktor](controls-ports-ktor)
> Implementation of byte ports on top os ktor-io asynchronous API
>
@ -113,6 +129,16 @@ Automatically checks consistency.
>
> **Maturity**: PROTOTYPE
### [controls-vision](controls-vision)
> Dashboard and visualization extensions for devices
>
> **Maturity**: PROTOTYPE
### [controls-visualisation-compose](controls-visualisation-compose)
> Visualisation extension using compose-multiplatform
>
> **Maturity**: PROTOTYPE
### [demo](demo)
>
> **Maturity**: EXPERIMENTAL
@ -134,6 +160,14 @@ Automatically checks consistency.
>
> **Maturity**: EXPERIMENTAL
### [demo/constructor](demo/constructor)
>
> **Maturity**: EXPERIMENTAL
### [demo/device-collective](demo/device-collective)
>
> **Maturity**: EXPERIMENTAL
### [demo/echo](demo/echo)
>
> **Maturity**: EXPERIMENTAL
@ -189,6 +223,11 @@ Automatically checks consistency.
>
> **Maturity**: PROTOTYPE
### [magix/magix-utils](magix/magix-utils)
> Common utilities and services for Magix endpoints.
>
> **Maturity**: EXPERIMENTAL
### [magix/magix-zmq](magix/magix-zmq)
> ZMQ client endpoint for Magix
>

View File

@ -1,37 +1,26 @@
import space.kscience.gradle.isInDevelopment
import space.kscience.gradle.useApache2Licence
import space.kscience.gradle.useSPCTeam
plugins {
id("space.kscience.gradle.project")
alias(libs.plugins.versions)
}
val dataforgeVersion: String by extra("0.6.2-dev-3")
val ktorVersion: String by extra(space.kscience.gradle.KScienceVersions.ktorVersion)
val rsocketVersion by extra("0.15.4")
val xodusVersion by extra("2.0.1")
allprojects {
group = "space.kscience"
version = "0.2.0"
version = "0.4.0-dev-6"
repositories{
maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
google()
}
}
ksciencePublish {
pom("https://github.com/SciProgCentre/controls.kt") {
pom("https://github.com/SciProgCentre/controls-kt") {
useApache2Licence()
useSPCTeam()
}
github("controls.kt", "SciProgCentre")
space(
if (isInDevelopment) {
"https://maven.pkg.jetbrains.space/spc/p/sci/dev"
} else {
"https://maven.pkg.jetbrains.space/spc/p/sci/maven"
}
)
repository("spc","https://maven.sciprog.center/kscience")
sonatype("https://oss.sonatype.org")
}
readme.readmeTemplate = file("docs/templates/README-TEMPLATE.md")

View File

@ -0,0 +1,21 @@
# Module controls-constructor
A low-code constructor for composite devices simulation
## Usage
## Artifact:
The Maven coordinates of this project are `space.kscience:controls-constructor:0.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")
}
```

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,6 @@
package space.kscience.controls.constructor.units
public enum class Direction(public val coef: Int) {
UP(1),
DOWN(-1)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -16,18 +16,16 @@ Core interfaces for building a device server
## Artifact:
The Maven coordinates of this project are `space.kscience:controls-core:0.2.0`.
The Maven coordinates of this project are `space.kscience:controls-core:0.4.0-dev-4`.
**Gradle Kotlin DSL:**
```kotlin
repositories {
maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral()
}
dependencies {
implementation("space.kscience:controls-core:0.2.0")
implementation("space.kscience:controls-core:0.4.0-dev-4")
}
```

View File

@ -9,21 +9,24 @@ description = """
Core interfaces for building a device server
""".trimIndent()
val dataforgeVersion: String by rootProject.extra
kscience {
jvm()
js()
native()
wasm()
useCoroutines()
useSerialization{
json()
}
useContextReceivers()
dependencies {
api("space.kscience:dataforge-io:$dataforgeVersion")
commonMain {
api(libs.dataforge.io)
api(spclibs.kotlinx.datetime)
}
jvmTest{
implementation(spclibs.logback.classic)
}
}

View File

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

View File

@ -3,41 +3,31 @@ package space.kscience.controls.api
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.*
import space.kscience.controls.api.Device.Companion.DEVICE_TARGET
import space.kscience.dataforge.context.ContextAware
import space.kscience.dataforge.context.info
import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.misc.DFExperimental
import space.kscience.dataforge.misc.Type
import space.kscience.dataforge.names.Name
/**
* A lifecycle state of a device
*/
public enum class DeviceLifecycleState{
INIT,
OPEN,
CLOSED
}
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.string
import space.kscience.dataforge.misc.DfType
import space.kscience.dataforge.names.parseAsName
/**
* General interface describing a managed Device.
* [Device] is a supervisor scope encompassing all operations on a device.
* When canceled, cancels all running processes.
*/
@Type(DEVICE_TARGET)
public interface Device : AutoCloseable, ContextAware, CoroutineScope {
@DfType(DEVICE_TARGET)
public interface Device : ContextAware, WithLifeCycle, CoroutineScope {
/**
* Initial configuration meta for the device
*/
public val meta: Meta get() = Meta.EMPTY
/**
* List of supported property descriptors
*/
@ -54,18 +44,6 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope {
*/
public suspend fun readProperty(propertyName: String): Meta
/**
* Get the logical state of property or return null if it is invalid
*/
public fun getProperty(propertyName: String): Meta?
/**
* Invalidate property (set logical state to invalid)
*
* This message is suspended to provide lock-free local property changes (they require coroutine context).
*/
public suspend fun invalidate(propertyName: String)
/**
* Set property [value] for a property with name [propertyName].
* In rare cases could suspend if the [Device] supports command queue, and it is full at the moment.
@ -85,44 +63,86 @@ public interface Device : AutoCloseable, ContextAware, CoroutineScope {
public suspend fun execute(actionName: String, argument: Meta? = null): Meta?
/**
* Initialize the device. This function suspends until the device is finished initialization
* Initialize the device. This function suspends until the device is finished initialization.
* Does nothing if the device is started or is starting
*/
public suspend fun open(): Unit = Unit
override suspend fun start(): Unit = Unit
/**
* Close and terminate the device. This function does not wait for the device to be closed.
*/
override fun close() {
override suspend fun stop() {
coroutineContext[Job]?.cancel("The device is closed")
logger.info { "Device $this is closed" }
cancel("The device is closed")
}
@DFExperimental
public val lifecycleState: DeviceLifecycleState
public companion object {
public const val DEVICE_TARGET: String = "device"
}
}
/**
* Inner id of a device. Not necessary corresponds to the name in the parent container
*/
public val Device.id: String get() = meta["id"].string ?: "device[${hashCode().toString(16)}]"
/**
* Device that caches properties values
*/
public interface CachingDevice : Device {
/**
* Immediately (without waiting) get the cached (logical) state of property or return null if it is invalid
*/
public fun getProperty(propertyName: String): Meta?
/**
* Invalidate property (set logical state to invalid).
*
* This message is suspended to provide lock-free local property changes (they require coroutine context).
*/
public suspend fun invalidate(propertyName: String)
}
/**
* Get the logical state of property or suspend to read the physical value.
*/
public suspend fun Device.getOrReadProperty(propertyName: String): Meta =
public suspend fun Device.getOrReadProperty(propertyName: String): Meta = if (this is CachingDevice) {
getProperty(propertyName) ?: readProperty(propertyName)
} else {
readProperty(propertyName)
}
/**
* Get a snapshot of the device logical state
*
*/
public fun Device.getAllProperties(): Meta = Meta {
public fun CachingDevice.getAllProperties(): Meta = Meta {
for (descriptor in propertyDescriptors) {
setMeta(Name.parse(descriptor.name), getProperty(descriptor.name))
set(descriptor.name.parseAsName(), getProperty(descriptor.name))
}
}
/**
* Subscribe on property changes for the whole device
*/
public fun Device.onPropertyChange(callback: suspend PropertyChangedMessage.() -> Unit): Job =
messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(this)
public fun Device.onPropertyChange(
scope: CoroutineScope = this,
callback: suspend PropertyChangedMessage.() -> Unit,
): Job = messageFlow.filterIsInstance<PropertyChangedMessage>().onEach(callback).launchIn(scope)
/**
* A [Flow] of property change messages for specific property.
*/
public fun Device.propertyMessageFlow(propertyName: String): Flow<PropertyChangedMessage> = messageFlow
.filterIsInstance<PropertyChangedMessage>()
.filter { it.property == propertyName }
/**
* React on device lifecycle events
*/
public fun Device.onLifecycleEvent(
block: suspend (LifecycleState) -> Unit
): Job = messageFlow.filterIsInstance<DeviceLifeCycleMessage>().onEach {
block(it.state)
}.launchIn(this)

View File

@ -1,73 +1,62 @@
package space.kscience.controls.api
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.names.*
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.provider.Path
import space.kscience.dataforge.provider.Provider
import space.kscience.dataforge.provider.asPath
import space.kscience.dataforge.provider.plus
/**
* A hub that could locate multiple devices and redirect actions to them
*/
public interface DeviceHub : Provider {
public val devices: Map<NameToken, Device>
public val devices: Map<Name, Device>
override val defaultTarget: String get() = Device.DEVICE_TARGET
override val defaultChainTarget: String get() = Device.DEVICE_TARGET
override fun content(target: String): Map<Name, Any> = if (target == Device.DEVICE_TARGET) {
buildMap {
fun putAll(prefix: Name, hub: DeviceHub) {
hub.devices.forEach {
put(prefix + it.key, it.value)
}
}
devices.forEach {
val name = it.key.asName()
put(name, it.value)
(it.value as? DeviceHub)?.let { hub ->
putAll(name, hub)
}
}
}
devices
} else {
emptyMap()
}
//TODO send message on device change
public companion object
}
public operator fun DeviceHub.get(nameToken: NameToken): Device =
devices[nameToken] ?: error("Device with name $nameToken not found in $this")
public fun DeviceHub.getOrNull(name: Name): Device? = when {
name.isEmpty() -> this as? Device
name.length == 1 -> get(name.firstOrNull()!!)
else -> (get(name.firstOrNull()!!) as? DeviceHub)?.getOrNull(name.cutFirst())
public fun DeviceHub(deviceMap: Map<Name, Device>): DeviceHub = object : DeviceHub {
override val devices: Map<Name, Device>
get() = deviceMap
}
public operator fun DeviceHub.get(name: Name): Device =
getOrNull(name) ?: error("Device with name $name not found in $this")
/**
* List all devices, including sub-devices
*/
public fun DeviceHub.provideAllDevices(): Map<Path, Device> = buildMap {
fun putAll(prefix: Path, hub: DeviceHub) {
hub.devices.forEach {
put(prefix + it.key.asPath(), it.value)
}
}
public fun DeviceHub.getOrNull(nameString: String): Device? = getOrNull(Name.parse(nameString))
public operator fun DeviceHub.get(nameString: String): Device =
getOrNull(nameString) ?: error("Device with name $nameString not found in $this")
devices.forEach {
val name: Name = it.key
put(name.asPath(), it.value)
(it.value as? DeviceHub)?.let { hub ->
putAll(name.asPath(), hub)
}
}
}
public suspend fun DeviceHub.readProperty(deviceName: Name, propertyName: String): Meta =
this[deviceName].readProperty(propertyName)
(devices[deviceName] ?: error("Device with name $deviceName not found in $this")).readProperty(propertyName)
public suspend fun DeviceHub.writeProperty(deviceName: Name, propertyName: String, value: Meta) {
this[deviceName].writeProperty(propertyName, value)
(devices[deviceName] ?: error("Device with name $deviceName not found in $this")).writeProperty(propertyName, value)
}
public suspend fun DeviceHub.execute(deviceName: Name, command: String, argument: Meta?): Meta? =
this[deviceName].execute(command, argument)
//suspend fun DeviceHub.respond(request: Envelope): EnvelopeBuilder {
// val target = request.meta[DeviceMessage.TARGET_KEY].string ?: defaultTarget
// val device = this[target.toName()]
//
// return device.respond(device, target, request)
//}
(devices[deviceName] ?: error("Device with name $deviceName not found in $this")).execute(command, argument)

View File

@ -22,10 +22,10 @@ public sealed class DeviceMessage {
public abstract val sourceDevice: Name?
public abstract val targetDevice: Name?
public abstract val comment: String?
public abstract val time: Instant?
public abstract val time: Instant
/**
* Update the source device name for composition. If the original name is null, resulting name is also null.
* Update the source device name for composition. If the original name is null, the resulting name is also null.
*/
public abstract fun changeSource(block: (Name) -> Name): DeviceMessage
@ -59,7 +59,7 @@ public data class PropertyChangedMessage(
override val sourceDevice: Name = Name.EMPTY,
override val targetDevice: Name? = null,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
}
@ -71,11 +71,11 @@ public data class PropertyChangedMessage(
@SerialName("property.set")
public data class PropertySetMessage(
public val property: String,
public val value: Meta?,
public val value: Meta,
override val sourceDevice: Name? = null,
override val targetDevice: Name,
override val targetDevice: Name?,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
}
@ -91,7 +91,7 @@ public data class PropertyGetMessage(
override val sourceDevice: Name? = null,
override val targetDevice: Name,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
}
@ -103,9 +103,9 @@ public data class PropertyGetMessage(
@SerialName("description.get")
public data class GetDescriptionMessage(
override val sourceDevice: Name? = null,
override val targetDevice: Name,
override val targetDevice: Name? = null,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
}
@ -122,7 +122,7 @@ public data class DescriptionMessage(
override val sourceDevice: Name,
override val targetDevice: Name? = null,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
}
@ -141,7 +141,7 @@ public data class ActionExecuteMessage(
override val sourceDevice: Name? = null,
override val targetDevice: Name,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
}
@ -160,22 +160,28 @@ public data class ActionResultMessage(
override val sourceDevice: Name,
override val targetDevice: Name? = null,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
}
/**
* Notifies listeners that a new binary with given [binaryID] is available. The binary itself could not be provided via [DeviceMessage] API.
* Notifies listeners that a new binary with given [contentId] and [contentMeta] is available.
*
* [contentMeta] includes public information that could be shared with loop subscribers. It should not contain sensitive data.
*
* The binary itself could not be provided via [DeviceMessage] API.
* [space.kscience.controls.peer.PeerConnection] must be used instead
*/
@Serializable
@SerialName("binary.notification")
public data class BinaryNotificationMessage(
val binaryID: String,
val contentId: String,
val contentMeta: Meta,
override val sourceDevice: Name,
override val targetDevice: Name? = null,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
}
@ -190,7 +196,7 @@ public data class EmptyDeviceMessage(
override val sourceDevice: Name? = null,
override val targetDevice: Name? = null,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
}
@ -203,12 +209,12 @@ public data class EmptyDeviceMessage(
public data class DeviceLogMessage(
val message: String,
val data: Meta? = null,
override val sourceDevice: Name? = null,
override val sourceDevice: Name = Name.EMPTY,
override val targetDevice: Name? = null,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
}
/**
@ -220,10 +226,25 @@ public data class DeviceErrorMessage(
public val errorMessage: String?,
public val errorType: String? = null,
public val errorStackTrace: String? = null,
override val sourceDevice: Name,
override val sourceDevice: Name = Name.EMPTY,
override val targetDevice: Name? = null,
override val comment: String? = null,
@EncodeDefault override val time: Instant? = Clock.System.now(),
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
}
/**
* Device [Device.lifecycleState] is changed
*/
@Serializable
@SerialName("lifecycle")
public data class DeviceLifeCycleMessage(
val state: LifecycleState,
override val sourceDevice: Name = Name.EMPTY,
override val targetDevice: Name? = null,
override val comment: String? = null,
@EncodeDefault override val time: Instant = Clock.System.now(),
) : DeviceMessage() {
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
}

View File

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

View File

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

View File

@ -12,21 +12,26 @@ import space.kscience.dataforge.meta.descriptors.MetaDescriptorBuilder
@Serializable
public class PropertyDescriptor(
public val name: String,
public var info: String? = null,
public var description: String? = null,
public var metaDescriptor: MetaDescriptor = MetaDescriptor(),
public var readable: Boolean = true,
public var writable: Boolean = false
public var mutable: Boolean = false,
)
public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.()->Unit){
metaDescriptor = MetaDescriptor(block)
public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.() -> Unit) {
metaDescriptor = MetaDescriptor {
from(metaDescriptor)
block()
}
}
/**
* A descriptor for property
*/
@Serializable
public class ActionDescriptor(public val name: String) {
public var info: String? = null
}
public class ActionDescriptor(
public val name: String,
public var description: String? = null,
public var inputMetaDescriptor: MetaDescriptor = MetaDescriptor(),
public var outputMetaDescriptor: MetaDescriptor = MetaDescriptor()
)

View File

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

View File

@ -3,12 +3,13 @@ package space.kscience.controls.manager
import kotlinx.coroutines.launch
import space.kscience.controls.api.Device
import space.kscience.controls.api.DeviceHub
import space.kscience.controls.api.getOrNull
import space.kscience.controls.api.id
import space.kscience.dataforge.context.*
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MutableMeta
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.NameToken
import space.kscience.dataforge.names.get
import space.kscience.dataforge.names.parseAsName
import kotlin.collections.set
import kotlin.properties.ReadOnlyProperty
@ -21,11 +22,11 @@ public class DeviceManager : AbstractPlugin(), DeviceHub {
/**
* Actual list of connected devices
*/
private val top = HashMap<NameToken, Device>()
override val devices: Map<NameToken, Device> get() = top
private val _devices = HashMap<Name, Device>()
override val devices: Map<Name, Device> get() = _devices
public fun registerDevice(name: NameToken, device: Device) {
top[name] = device
public fun registerDevice(name: Name, device: Device) {
_devices[name] = device
}
override fun content(target: String): Map<Name, Any> = super<DeviceHub>.content(target)
@ -38,13 +39,19 @@ public class DeviceManager : AbstractPlugin(), DeviceHub {
}
public fun <D : Device> DeviceManager.install(name: String, device: D): D {
registerDevice(NameToken(name), device)
registerDevice(name.parseAsName(), device)
device.launch {
device.open()
device.start()
}
return device
}
public fun <D : Device> DeviceManager.install(device: D): D = install(device.id, device)
public fun <D : Device> Context.install(name: String, device: D): D = request(DeviceManager).install(name, device)
public fun <D : Device> Context.install(device: D): D = request(DeviceManager).install(device.id, device)
/**
* Register and start a device built by [factory] with current [Context] and [meta].
@ -62,7 +69,7 @@ public inline fun <D : Device> DeviceManager.installing(
val meta = Meta(builder)
return ReadOnlyProperty { _, property ->
val name = property.name
val current = getOrNull(name)
val current = devices[name]
if (current == null) {
install(name, factory, meta)
} else if (current.meta != meta) {
@ -73,4 +80,3 @@ public inline fun <D : Device> DeviceManager.installing(
}
}
}

View File

@ -1,10 +1,9 @@
package space.kscience.controls.manager
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import space.kscience.controls.api.*
import space.kscience.dataforge.names.Name
import space.kscience.dataforge.names.plus
@ -24,11 +23,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
}
is PropertySetMessage -> {
if (request.value == null) {
invalidate(request.property)
} else {
writeProperty(request.property, request.value)
}
writeProperty(request.property, request.value)
PropertyChangedMessage(
property = request.property,
value = getOrReadProperty(request.property),
@ -64,6 +59,7 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
is DeviceErrorMessage,
is EmptyDeviceMessage,
is DeviceLogMessage,
is DeviceLifeCycleMessage,
-> null
}
} catch (ex: Exception) {
@ -71,42 +67,41 @@ public suspend fun Device.respondMessage(deviceTarget: Name, request: DeviceMess
}
/**
* Process incoming [DeviceMessage], using hub naming to evaluate target.
* Process incoming [DeviceMessage], using hub naming to find target.
* If the `targetDevice` is `null`, then the message is sent to each device in this hub
*/
public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): DeviceMessage? {
public suspend fun DeviceHub.respondHubMessage(request: DeviceMessage): List<DeviceMessage> {
return try {
val targetName = request.targetDevice ?: return null
val device = getOrNull(targetName) ?: error("The device with name $targetName not found in $this")
device.respondMessage(targetName, request)
val targetName = request.targetDevice
if (targetName == null) {
devices.mapNotNull {
it.value.respondMessage(it.key, request)
}
} else {
val device = devices[targetName] ?: error("The device with name $targetName not found in $this")
listOfNotNull(device.respondMessage(targetName, request))
}
} catch (ex: Exception) {
DeviceMessage.error(ex, sourceDevice = Name.EMPTY, targetDevice = request.sourceDevice)
listOf(DeviceMessage.error(ex, sourceDevice = Name.EMPTY, targetDevice = request.sourceDevice))
}
}
/**
* Collect all messages from given [DeviceHub], applying proper relative names.
*/
public fun DeviceHub.hubMessageFlow(scope: CoroutineScope): Flow<DeviceMessage> {
public fun DeviceHub.hubMessageFlow(): Flow<DeviceMessage> {
//TODO could we avoid using downstream scope?
val outbox = MutableSharedFlow<DeviceMessage>()
if (this is Device) {
messageFlow.onEach {
outbox.emit(it)
}.launchIn(scope)
}
//TODO maybe better create map of all devices to limit copying
devices.forEach { (token, childDevice) ->
val flow = if (childDevice is DeviceHub) {
childDevice.hubMessageFlow(scope)
val deviceMessageFlow = if (this is Device) messageFlow else emptyFlow()
val childrenFlows = devices.map { (token, childDevice) ->
if (childDevice is DeviceHub) {
childDevice.hubMessageFlow()
} else {
childDevice.messageFlow
}.map { deviceMessage ->
deviceMessage.changeSource { token + it }
}
flow.onEach { deviceMessage ->
outbox.emit(
deviceMessage.changeSource { token + it }
)
}.launchIn(scope)
}
return outbox
return merge(deviceMessageFlow, *childrenFlows.toTypedArray())
}

View File

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

View File

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

View File

@ -0,0 +1,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

View File

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

View File

@ -1,18 +0,0 @@
package space.kscience.controls.misc
import kotlinx.datetime.Instant
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.long
// TODO move to core
public fun Instant.toMeta(): Meta = Meta {
"seconds" put epochSeconds
"nanos" put nanosecondsOfSecond
}
public fun Meta.instant(): Instant = value?.long?.let { Instant.fromEpochMilliseconds(it) } ?: Instant.fromEpochSeconds(
get("seconds")?.long ?: 0L,
get("nanos")?.long ?: 0L,
)

View File

@ -0,0 +1,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,
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,28 +1,103 @@
package space.kscience.controls.ports
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.io.Buffer
import kotlinx.io.Source
import kotlinx.io.readByteArray
import space.kscience.controls.api.LifecycleState
import space.kscience.controls.api.WithLifeCycle
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.ContextAware
/**
* A port handler for synchronous (request-response) communication with a port. Only one request could be active at a time (others are suspended.
* The handler does not guarantee exclusive access to the port so the user mush ensure that no other controller handles port at the moment.
* A port handler for synchronous (request-response) communication with a port.
* Only one request could be active at a time (others are suspended).
*/
public class SynchronousPort(public val port: Port, private val mutex: Mutex) : Port by port {
public interface SynchronousPort : ContextAware, WithLifeCycle {
/**
* Send a single message and wait for the flow of respond messages.
* Send a single message and wait for the flow of response chunks.
* The consumer is responsible for calling a terminal operation on the flow.
*/
public suspend fun <R> respond(data: ByteArray, transform: suspend Flow<ByteArray>.() -> R): R = mutex.withLock {
port.send(data)
transform(port.receiving())
public suspend fun <R> respond(
request: ByteArray,
transform: suspend Flow<ByteArray>.() -> R,
): R
/**
* Synchronously read fixed size response to a given [request]. Discard additional response bytes.
*/
public suspend fun respondFixedMessageSize(
request: ByteArray,
responseSize: Int,
): ByteArray = respond(request) {
val buffer = Buffer()
takeWhile {
buffer.size < responseSize
}.collect {
buffer.write(it)
}
buffer.readByteArray(responseSize)
}
}
/**
* Provide a synchronous wrapper for a port
* Read response to a given message using [Source] abstraction
*/
public fun Port.synchronous(mutex: Mutex = Mutex()): SynchronousPort = SynchronousPort(this, mutex)
public suspend fun <R> SynchronousPort.respondAsSource(
request: ByteArray,
transform: suspend Source.() -> R,
): R = respond(request) {
//suspend until the response is fully read
coroutineScope {
val buffer = Buffer()
val collectJob = onEach { buffer.write(it) }.launchIn(this)
val res = transform(buffer)
//cancel collection when the result is achieved
collectJob.cancel()
res
}
}
private class SynchronousOverAsynchronousPort(
val port: AsynchronousPort,
val mutex: Mutex,
) : SynchronousPort {
override val context: Context get() = port.context
override suspend fun start() {
if (port.lifecycleState == LifecycleState.STOPPED) port.start()
}
override val lifecycleState: LifecycleState get() = port.lifecycleState
override suspend fun stop() {
if (port.lifecycleState == LifecycleState.STARTED) port.stop()
}
override suspend fun <R> respond(
request: ByteArray,
transform: suspend Flow<ByteArray>.() -> R,
): R = mutex.withLock {
port.send(request)
transform(port.subscribe())
}
}
/**
* Provide a synchronous wrapper for an asynchronous port.
* Optionally provide external [mutex] for operation synchronization.
*
* If the [AsynchronousPort] is called directly, it could violate [SynchronousPort] contract
* of only one request running simultaneously.
*/
public fun AsynchronousPort.asSynchronousPort(mutex: Mutex = Mutex()): SynchronousPort =
SynchronousOverAsynchronousPort(this, mutex)
/**
* Send request and read incoming data blocks until the delimiter is encountered

View File

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

View File

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

View File

@ -1,36 +1,44 @@
package space.kscience.controls.spec
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import space.kscience.controls.api.*
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.context.debug
import space.kscience.dataforge.context.error
import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.misc.DFExperimental
import space.kscience.dataforge.meta.get
import space.kscience.dataforge.meta.int
import kotlin.coroutines.CoroutineContext
/**
* Write a meta [item] to [device]
*/
@OptIn(InternalDeviceAPI::class)
private suspend fun <D : Device, T> WritableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) {
write(device, converter.metaToObject(item) ?: error("Meta $item could not be read with $converter"))
private suspend fun <D : Device, T> MutableDevicePropertySpec<D, T>.writeMeta(device: D, item: Meta) {
write(device, converter.readOrNull(item) ?: error("Meta $item could not be read with $converter"))
}
/**
* Read Meta item from the [device]
*/
@OptIn(InternalDeviceAPI::class)
private suspend fun <D : Device, T> DevicePropertySpec<D, T>.readMeta(device: D): Meta? =
read(device)?.let(converter::objectToMeta)
read(device)?.let(converter::convert)
private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta(
device: D,
item: Meta,
): Meta? {
val arg: I = inputConverter.metaToObject(item) ?: error("Failed to convert $item with $inputConverter")
val arg: I = inputConverter.readOrNull(item) ?: error("Failed to convert $item with $inputConverter")
val res = execute(device, arg)
return res?.let { outputConverter.objectToMeta(res) }
return res?.let { outputConverter.convert(res) }
}
@ -39,8 +47,15 @@ private suspend fun <D : Device, I, O> DeviceActionSpec<D, I, O>.executeWithMeta
*/
public abstract class DeviceBase<D : Device>(
final override val context: Context,
override val meta: Meta = Meta.EMPTY,
) : Device {
final override val meta: Meta = Meta.EMPTY,
) : CachingDevice {
private val stateLock = Mutex()
/**
* Logical state store
*/
private val logicalState: MutableMap<String, Meta?> = HashMap()
/**
* Collection of property specifications
@ -58,23 +73,28 @@ public abstract class DeviceBase<D : Device>(
override val actionDescriptors: Collection<ActionDescriptor>
get() = actions.values.map { it.descriptor }
override val coroutineContext: CoroutineContext by lazy {
context.newCoroutineContext(
SupervisorJob(context.coroutineContext[Job]) +
CoroutineName("Device $this") +
CoroutineExceptionHandler { _, throwable ->
logger.error(throwable) { "Exception in device $this job" }
private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow(
replay = meta["message.buffer"].int ?: 1000,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
override val coroutineContext: CoroutineContext = context.newCoroutineContext(
SupervisorJob(context.coroutineContext[Job]) +
CoroutineName("Device $id") +
CoroutineExceptionHandler { _, throwable ->
launch {
sharedMessageFlow.emit(
DeviceErrorMessage(
errorMessage = throwable.message,
errorType = throwable::class.simpleName,
errorStackTrace = throwable.stackTraceToString()
)
)
}
)
}
/**
* Logical state store
*/
private val logicalState: HashMap<String, Meta?> = HashMap()
private val sharedMessageFlow: MutableSharedFlow<DeviceMessage> = MutableSharedFlow()
logger.error(throwable) { "Exception in device $id" }
}
)
public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow
@ -82,12 +102,10 @@ public abstract class DeviceBase<D : Device>(
internal val self: D
get() = this as D
private val stateLock = Mutex()
/**
* Update logical property state and notify listeners
*/
protected suspend fun updateLogical(propertyName: String, value: Meta?) {
protected suspend fun propertyChanged(propertyName: String, value: Meta?) {
if (value != logicalState[propertyName]) {
stateLock.withLock {
logicalState[propertyName] = value
@ -99,10 +117,10 @@ public abstract class DeviceBase<D : Device>(
}
/**
* Update logical state using given [spec] and its convertor
* Notify the device that a property with [spec] value is changed
*/
public suspend fun <T> updateLogical(spec: DevicePropertySpec<D, T>, value: T) {
updateLogical(spec.name, spec.converter.objectToMeta(value))
protected suspend fun <T> propertyChanged(spec: DevicePropertySpec<D, T>, value: T) {
propertyChanged(spec.name, spec.converter.convert(value))
}
/**
@ -112,7 +130,7 @@ public abstract class DeviceBase<D : Device>(
override suspend fun readProperty(propertyName: String): Meta {
val spec = properties[propertyName] ?: error("Property with name $propertyName not found")
val meta = spec.readMeta(self) ?: error("Failed to read property $propertyName")
updateLogical(propertyName, meta)
propertyChanged(propertyName, meta)
return meta
}
@ -122,7 +140,7 @@ public abstract class DeviceBase<D : Device>(
public suspend fun readPropertyOrNull(propertyName: String): Meta? {
val spec = properties[propertyName] ?: return null
val meta = spec.readMeta(self) ?: return null
updateLogical(propertyName, meta)
propertyChanged(propertyName, meta)
return meta
}
@ -135,15 +153,26 @@ public abstract class DeviceBase<D : Device>(
}
override suspend fun writeProperty(propertyName: String, value: Meta): Unit {
//bypass property setting if it already has that value
if (logicalState[propertyName] == value) {
logger.debug { "Skipping setting $propertyName to $value because value is already set" }
return
}
when (val property = properties[propertyName]) {
null -> {
//If there is a physical property with a given name, invalidate logical property and write physical one
updateLogical(propertyName, value)
//If there are no registered physical properties with given name, write a logical one.
propertyChanged(propertyName, value)
}
is WritableDevicePropertySpec -> {
is MutableDevicePropertySpec -> {
//if there is a writeable property with a given name, invalidate logical and write physical
invalidate(propertyName)
property.writeMeta(self, value)
// perform read after writing if the writer did not set the value and the value is still in invalid state
if (logicalState[propertyName] == null) {
val meta = property.readMeta(self)
propertyChanged(propertyName, meta)
}
}
else -> {
@ -157,22 +186,43 @@ public abstract class DeviceBase<D : Device>(
return spec.executeWithMeta(self, argument ?: Meta.EMPTY)
}
@DFExperimental
override var lifecycleState: DeviceLifecycleState = DeviceLifecycleState.INIT
protected set
final override var lifecycleState: LifecycleState = LifecycleState.STOPPED
private set
@OptIn(DFExperimental::class)
override suspend fun open() {
super.open()
lifecycleState = DeviceLifecycleState.OPEN
private suspend fun setLifecycleState(lifecycleState: LifecycleState) {
this.lifecycleState = lifecycleState
sharedMessageFlow.emit(
DeviceLifeCycleMessage(lifecycleState)
)
}
@OptIn(DFExperimental::class)
override fun close() {
lifecycleState = DeviceLifecycleState.CLOSED
super.close()
protected open suspend fun onStart() {
}
final override suspend fun start() {
if (lifecycleState == LifecycleState.STOPPED) {
super.start()
setLifecycleState(LifecycleState.STARTING)
onStart()
setLifecycleState(LifecycleState.STARTED)
} else {
logger.debug { "Device $this is already started" }
}
}
protected open suspend fun onStop() {
}
final override suspend fun stop() {
onStop()
setLifecycleState(LifecycleState.STOPPED)
super.stop()
}
abstract override fun toString(): String
}

View File

@ -2,7 +2,7 @@ package space.kscience.controls.spec
import space.kscience.controls.api.Device
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.*
/**
* 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 actions: Map<String, DeviceActionSpec<D, *, *>> get() = spec.actions
override suspend fun open(): Unit = with(spec) {
super.open()
// Map to store instances of nested devices
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()
}
override fun close(): Unit = with(spec) {
override suspend fun onStop(): Unit = with(spec){
for (device in _nestedDevices.values) {
device.stop()
}
self.onClose()
super.close()
}
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
}
}

View File

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

View File

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

View File

@ -1,115 +1,85 @@
package space.kscience.controls.spec
import kotlinx.coroutines.withContext
import space.kscience.controls.api.ActionDescriptor
import space.kscience.controls.api.Device
import space.kscience.controls.api.PropertyDescriptor
import space.kscience.controls.api.*
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.transformations.MetaConverter
import space.kscience.dataforge.meta.MetaConverter
import space.kscience.dataforge.meta.descriptors.MetaDescriptor
import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KProperty
import kotlin.reflect.KProperty1
public object UnitMetaConverter: MetaConverter<Unit>{
override fun metaToObject(meta: Meta): Unit = Unit
public object UnitMetaConverter : MetaConverter<Unit> {
override fun objectToMeta(obj: Unit): Meta = Meta.EMPTY
override fun readOrNull(source: Meta): Unit = Unit
override fun convert(obj: Unit): Meta = Meta.EMPTY
}
public val MetaConverter.Companion.unit: MetaConverter<Unit> get() = UnitMetaConverter
@OptIn(InternalDeviceAPI::class)
public abstract class DeviceSpec<D : Device> {
//initializing meta property for everyone
private val _properties = hashMapOf<String, DevicePropertySpec<D, *>>(
DeviceMetaPropertySpec.name to DeviceMetaPropertySpec
)
// Map to store properties
private val _properties = hashMapOf<String, DevicePropertySpec<D, *>>()
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 open suspend fun D.onOpen() {
}
public open fun D.onClose() {
}
/**
* Registers a property in the spec.
*/
public fun <T, P : DevicePropertySpec<D, T>> registerProperty(deviceProperty: P): P {
_properties[deviceProperty.name] = deviceProperty
return deviceProperty
}
public fun <T> property(
converter: MetaConverter<T>,
readOnlyProperty: KProperty1<D, T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, DevicePropertySpec<D, T>>> =
PropertyDelegateProvider { _, property ->
val deviceProperty = object : DevicePropertySpec<D, T> {
override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply {
//TODO add type from converter
writable = true
}.apply(descriptorBuilder)
// Map to store nested device specifications
private val _nestedDeviceSpecs = hashMapOf<String, DeviceSpec<*>>()
public val nestedDeviceSpecs: Map<String, DeviceSpec<*>> get() = _nestedDeviceSpecs
override val converter: MetaConverter<T> = converter
override suspend fun read(device: D): T = withContext(device.coroutineContext) {
readOnlyProperty.get(device)
}
}
registerProperty(deviceProperty)
ReadOnlyProperty { _, _ ->
deviceProperty
}
}
/**
* Registers an action in the spec.
*/
public fun <I, O> registerAction(deviceAction: DeviceActionSpec<D, I, O>): DeviceActionSpec<D, I, O> {
_actions[deviceAction.name] = deviceAction
return deviceAction
}
public fun <T> mutableProperty(
converter: MetaConverter<T>,
readWriteProperty: KMutableProperty1<D, T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, WritableDevicePropertySpec<D, T>>> =
PropertyDelegateProvider { _, property ->
val deviceProperty = object : WritableDevicePropertySpec<D, T> {
public open suspend fun D.onOpen() {
}
override val descriptor: PropertyDescriptor = PropertyDescriptor(property.name).apply {
//TODO add the type from converter
writable = true
}.apply(descriptorBuilder)
override val converter: MetaConverter<T> = converter
override suspend fun read(device: D): T = withContext(device.coroutineContext) {
readWriteProperty.get(device)
}
override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) {
readWriteProperty.set(device, value)
}
}
registerProperty(deviceProperty)
ReadOnlyProperty { _, _ ->
deviceProperty
}
}
public open suspend fun D.onClose() {
}
public fun <T> property(
converter: MetaConverter<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> T?,
read: suspend D.(propertyName: String) -> T?,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> =
PropertyDelegateProvider { _: DeviceSpec<D>, property ->
val propertyName = name ?: property.name
val deviceProperty = object : DevicePropertySpec<D, T> {
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
converter.descriptor?.let { converterDescriptor ->
metaDescriptor {
from(converterDescriptor)
}
}
fromSpec(property)
descriptorBuilder()
}
override val converter: MetaConverter<T> = converter
override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() }
override suspend fun read(device: D): T? =
withContext(device.coroutineContext) { device.read(propertyName) }
}
registerProperty(deviceProperty)
ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>> { _, _ ->
@ -121,33 +91,39 @@ public abstract class DeviceSpec<D : Device> {
converter: MetaConverter<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> T?,
write: suspend D.(T) -> Unit,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>>> =
read: suspend D.(propertyName: String) -> T?,
write: suspend D.(propertyName: String, value: T) -> Unit,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
val propertyName = name ?: property.name
val deviceProperty = object : WritableDevicePropertySpec<D, T> {
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply(descriptorBuilder)
val deviceProperty = object : MutableDevicePropertySpec<D, T> {
override val descriptor: PropertyDescriptor = PropertyDescriptor(
propertyName,
mutable = true
).apply {
converter.descriptor?.let { converterDescriptor ->
metaDescriptor {
from(converterDescriptor)
}
}
fromSpec(property)
descriptorBuilder()
}
override val converter: MetaConverter<T> = converter
override suspend fun read(device: D): T? = withContext(device.coroutineContext) { device.read() }
override suspend fun read(device: D): T? =
withContext(device.coroutineContext) { device.read(propertyName) }
override suspend fun write(device: D, value: T): Unit = withContext(device.coroutineContext) {
device.write(value)
device.write(propertyName, value)
}
}
_properties[propertyName] = deviceProperty
ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, T>> { _, _ ->
registerProperty(deviceProperty)
ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>> { _, _ ->
deviceProperty
}
}
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(
inputConverter: MetaConverter<I>,
outputConverter: MetaConverter<O>,
@ -155,10 +131,26 @@ public abstract class DeviceSpec<D : Device> {
name: String? = null,
execute: suspend D.(I) -> O,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, I, O>>> =
PropertyDelegateProvider { _: DeviceSpec<D>, property ->
PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
val actionName = name ?: property.name
val deviceAction = object : DeviceActionSpec<D, I, O> {
override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply(descriptorBuilder)
override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply {
inputConverter.descriptor?.let { converterDescriptor ->
inputMetaDescriptor = MetaDescriptor {
from(converterDescriptor)
from(inputMetaDescriptor)
}
}
outputConverter.descriptor?.let { converterDescriptor ->
outputMetaDescriptor = MetaDescriptor {
from(converterDescriptor)
from(outputMetaDescriptor)
}
}
fromSpec(property)
descriptorBuilder()
}
override val inputConverter: MetaConverter<I> = inputConverter
override val outputConverter: MetaConverter<O> = outputConverter
@ -173,68 +165,69 @@ public abstract class DeviceSpec<D : Device> {
}
}
/**
* An action that takes [Meta] and returns [Meta]. No conversions are done
*/
public fun metaAction(
descriptorBuilder: ActionDescriptor.() -> Unit = {},
name: String? = null,
execute: suspend D.(Meta) -> Meta,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> =
action(
MetaConverter.Companion.meta,
MetaConverter.Companion.meta,
descriptorBuilder,
name
) {
execute(it)
public open fun createDevice(context: Context, meta: Meta): D {
// Since DeviceSpec<D> doesn't know how to create an instance of D,
// you need to override this method in subclasses.
throw NotImplementedError("createDevice must be implemented in subclasses")
}
public fun <ND : Device> device(
deviceSpec: DeviceSpec<ND>,
name: String? = null
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceSpec<ND>>> =
PropertyDelegateProvider { _, property ->
val deviceName = name ?: property.name
// Register the nested device spec
_nestedDeviceSpecs[deviceName] = deviceSpec
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 <T, D : DeviceBase<D>> DeviceSpec<D>.logicalProperty(
converter: MetaConverter<T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<Any?, WritableDevicePropertySpec<D, T>>> =
PropertyDelegateProvider { _, property ->
val deviceProperty = object : WritableDevicePropertySpec<D, T> {
val propertyName = name ?: property.name
override val descriptor: PropertyDescriptor = PropertyDescriptor(propertyName).apply {
//TODO add type from converter
writable = true
}.apply(descriptorBuilder)
override val converter: MetaConverter<T> = converter
override suspend fun read(device: D): T? = device.getProperty(propertyName)?.let(converter::metaToObject)
override suspend fun write(device: D, value: T): Unit =
device.writeProperty(propertyName, converter.objectToMeta(value))
}
registerProperty(deviceProperty)
ReadOnlyProperty { _, _ ->
deviceProperty
}
public fun DeviceSpec<*>.validate(device: Device) {
properties.map { it.value.descriptor }.forEach { specProperty ->
check(specProperty in device.propertyDescriptors) { "Property ${specProperty.name} not registered in ${device.id}" }
}
actions.map { it.value.descriptor }.forEach { specAction ->
check(specAction in device.actionDescriptors) { "Action ${specAction.name} not registered in ${device.id}" }
}
}

View File

@ -1,38 +1,46 @@
package space.kscience.controls.spec
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import space.kscience.controls.api.Device
import space.kscience.controls.manager.getCoroutineDispatcher
import kotlin.time.Duration
/**
* Perform a recurring asynchronous read action and return a flow of results.
* The flow is lazy, so action is not performed unless flow is consumed.
* The flow uses called context. In order to call it on device context, use `flowOn(coroutineContext)`.
*
* The flow is canceled when the device scope is canceled
*/
public fun <D : Device, R> D.readRecurring(interval: Duration, reader: suspend D.() -> R): Flow<R> = flow {
while (isActive) {
delay(interval)
launch {
emit(reader())
}
}
}
/**
* Do a recurring (with a fixed delay) task on a device.
*/
public fun <D : Device> D.doRecurring(interval: Duration, task: suspend D.() -> Unit): Job = launch {
while (isActive) {
delay(interval)
launch {
task()
public fun <D : Device> D.doRecurring(
interval: Duration,
debugTaskName: String? = null,
task: suspend D.() -> Unit,
): Job {
val taskName = debugTaskName ?: "task[${task.hashCode().toString(16)}]"
val dispatcher = getCoroutineDispatcher()
return launch(CoroutineName(taskName) + dispatcher) {
while (isActive) {
delay(interval)
//launch in parent scope to properly evaluate exceptions
this@doRecurring.launch(CoroutineName("$taskName-recurring") + dispatcher) {
task()
}
}
}
}
/**
* 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, R> D.readRecurring(
interval: Duration,
debugTaskName: String? = null,
reader: suspend D.() -> R,
): Flow<R> = flow {
doRecurring(interval, debugTaskName) {
emit(reader())
}
}

View File

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

View File

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

View File

@ -4,22 +4,73 @@ import space.kscience.controls.api.Device
import space.kscience.controls.api.PropertyDescriptor
import space.kscience.controls.api.metaDescriptor
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.meta.MetaConverter
import space.kscience.dataforge.meta.ValueType
import space.kscience.dataforge.meta.transformations.MetaConverter
import space.kscience.dataforge.meta.descriptors.MetaDescriptor
import space.kscience.dataforge.meta.string
import kotlin.properties.PropertyDelegateProvider
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KMutableProperty1
import kotlin.reflect.KProperty1
/**
* A read-only device property that delegates reading to a device [KProperty1]
*/
public fun <T, D : Device> DeviceSpec<D>.property(
converter: MetaConverter<T>,
readOnlyProperty: KProperty1<D, T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = property(
converter,
descriptorBuilder,
name = readOnlyProperty.name,
read = { readOnlyProperty.get(this) }
)
/**
* Mutable property that delegates reading and writing to a device [KMutableProperty1]
*/
public fun <T, D : Device> DeviceSpec<D>.mutableProperty(
converter: MetaConverter<T>,
readWriteProperty: KMutableProperty1<D, T>,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
mutableProperty(
converter,
descriptorBuilder,
readWriteProperty.name,
read = { _ -> readWriteProperty.get(this) },
write = { _, value: T -> readWriteProperty.set(this, value) }
)
/**
* 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
public fun <D : Device> DeviceSpec<D>.booleanProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> Boolean?
read: suspend D.(propertyName: String) -> Boolean?
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Boolean>>> = property(
MetaConverter.boolean,
{
metaDescriptor {
type(ValueType.BOOLEAN)
valueType(ValueType.BOOLEAN)
}
descriptorBuilder()
},
@ -31,15 +82,15 @@ private inline fun numberDescriptor(
crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {}
): PropertyDescriptor.() -> Unit = {
metaDescriptor {
type(ValueType.NUMBER)
valueType(ValueType.NUMBER)
}
descriptorBuilder()
}
public fun <D : Device> DeviceSpec<D>.numberProperty(
name: String? = null,
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
read: suspend D.() -> Number?
name: String? = null,
read: suspend D.(propertyName: String) -> Number?
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Number>>> = property(
MetaConverter.number,
numberDescriptor(descriptorBuilder),
@ -50,7 +101,7 @@ public fun <D : Device> DeviceSpec<D>.numberProperty(
public fun <D : Device> DeviceSpec<D>.doubleProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> Double?
read: suspend D.(propertyName: String) -> Double?
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Double>>> = property(
MetaConverter.double,
numberDescriptor(descriptorBuilder),
@ -61,12 +112,12 @@ public fun <D : Device> DeviceSpec<D>.doubleProperty(
public fun <D : Device> DeviceSpec<D>.stringProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> String?
read: suspend D.(propertyName: String) -> String?
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, String>>> = property(
MetaConverter.string,
{
metaDescriptor {
type(ValueType.STRING)
valueType(ValueType.STRING)
}
descriptorBuilder()
},
@ -77,12 +128,12 @@ public fun <D : Device> DeviceSpec<D>.stringProperty(
public fun <D : Device> DeviceSpec<D>.metaProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> Meta?
read: suspend D.(propertyName: String) -> Meta?
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Meta>>> = property(
MetaConverter.meta,
{
metaDescriptor {
type(ValueType.STRING)
valueType(ValueType.STRING)
}
descriptorBuilder()
},
@ -95,14 +146,14 @@ public fun <D : Device> DeviceSpec<D>.metaProperty(
public fun <D : Device> DeviceSpec<D>.booleanProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> Boolean?,
write: suspend D.(Boolean) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Boolean>>> =
read: suspend D.(propertyName: String) -> Boolean?,
write: suspend D.(propertyName: String, value: Boolean) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Boolean>>> =
mutableProperty(
MetaConverter.boolean,
{
metaDescriptor {
type(ValueType.BOOLEAN)
valueType(ValueType.BOOLEAN)
}
descriptorBuilder()
},
@ -115,31 +166,150 @@ public fun <D : Device> DeviceSpec<D>.booleanProperty(
public fun <D : Device> DeviceSpec<D>.numberProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> Number,
write: suspend D.(Number) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Number>>> =
read: suspend D.(propertyName: String) -> Number,
write: suspend D.(propertyName: String, value: Number) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Number>>> =
mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write)
public fun <D : Device> DeviceSpec<D>.doubleProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> Double,
write: suspend D.(Double) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Double>>> =
read: suspend D.(propertyName: String) -> Double,
write: suspend D.(propertyName: String, value: Double) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Double>>> =
mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write)
public fun <D : Device> DeviceSpec<D>.stringProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> String,
write: suspend D.(String) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, String>>> =
read: suspend D.(propertyName: String) -> String,
write: suspend D.(propertyName: String, value: String) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, String>>> =
mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write)
public fun <D : Device> DeviceSpec<D>.metaProperty(
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
name: String? = null,
read: suspend D.() -> Meta,
write: suspend D.(Meta) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, WritableDevicePropertySpec<D, Meta>>> =
read: suspend D.(propertyName: String) -> Meta,
write: suspend D.(propertyName: String, value: Meta) -> Unit
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, Meta>>> =
mutableProperty(MetaConverter.meta, descriptorBuilder, name, read, write)
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
)
}
}

View File

@ -1,9 +1,8 @@
package space.kscience.controls.api
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import space.kscience.controls.spec.asMeta
import space.kscience.controls.misc.asMeta
import kotlin.test.Test
import kotlin.test.assertEquals

View File

@ -0,0 +1,9 @@
package space.kscience.controls.spec
import space.kscience.controls.api.ActionDescriptor
import space.kscience.controls.api.PropertyDescriptor
import kotlin.reflect.KProperty
internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>){}
internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){}

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,50 @@
package space.kscience.controls.ports
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import space.kscience.dataforge.context.Global
import kotlin.test.assertEquals
internal class AsynchronousPortIOTest {
@Test
fun testDelimiteredByteArrayFlow() {
val flow = flowOf("bb?b", "ddd?", ":defgb?:ddf", "34fb?:--").map { it.encodeToByteArray() }
val chunked = flow.withDelimiter("?:".encodeToByteArray())
runBlocking {
val result = chunked.toList()
assertEquals(3, result.size)
assertEquals("bb?bddd?:", result[0].decodeToString())
assertEquals("defgb?:", result[1].decodeToString())
assertEquals("ddf34fb?:", result[2].decodeToString())
}
}
@Test
fun testUdpCommunication() = runTest {
val receiver = UdpPort.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()
}
}

View File

@ -1,25 +0,0 @@
package space.kscience.controls.ports
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
internal class PortIOTest{
@Test
fun testDelimiteredByteArrayFlow(){
val flow = flowOf("bb?b","ddd?",":defgb?:ddf","34fb?:--").map { it.encodeToByteArray() }
val chunked = flow.withDelimiter("?:".encodeToByteArray())
runBlocking {
val result = chunked.toList()
assertEquals(3, result.size)
assertEquals("bb?bddd?:",result[0].decodeToString())
assertEquals("defgb?:", result[1].decodeToString())
assertEquals("ddf34fb?:", result[2].decodeToString())
}
}
}

View File

@ -0,0 +1,9 @@
package space.kscience.controls.spec
import space.kscience.controls.api.ActionDescriptor
import space.kscience.controls.api.PropertyDescriptor
import kotlin.reflect.KProperty
internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) {}
internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){}

View File

@ -0,0 +1,9 @@
package space.kscience.controls.spec
import space.kscience.controls.api.ActionDescriptor
import space.kscience.controls.api.PropertyDescriptor
import kotlin.reflect.KProperty
internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>){}
internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){}

View File

@ -0,0 +1,21 @@
# Module controls-jupyter
## Usage
## Artifact:
The Maven coordinates of this project are `space.kscience:controls-jupyter:0.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")
}
```

View File

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

View File

@ -0,0 +1,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)
}
}

View File

@ -0,0 +1,14 @@
import space.kscience.visionforge.html.runVisionClient
import space.kscience.visionforge.jupyter.VFNotebookClient
import space.kscience.visionforge.markup.MarkupPlugin
import space.kscience.visionforge.plotly.PlotlyPlugin
public fun main(): Unit = runVisionClient {
// plugin(DeviceManager)
// plugin(ClockManager)
plugin(PlotlyPlugin)
plugin(MarkupPlugin)
// plugin(TableVisionJsPlugin)
plugin(VFNotebookClient)
}

View File

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

View File

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

View File

@ -12,13 +12,26 @@ description = """
kscience {
jvm()
js()
native()
wasm()
useCoroutines()
useSerialization {
json()
}
dependencies {
commonMain {
api(projects.magix.magixApi)
api(projects.controlsCore)
api("com.benasher44:uuid:0.8.0")
api(libs.uuid)
}
jvmTest{
implementation(spclibs.logback.classic)
implementation(projects.magix.magixServer)
implementation(projects.magix.magixRsocket)
implementation(spclibs.ktor.server.cio)
implementation(spclibs.ktor.server.websockets)
implementation(spclibs.ktor.client.cio)
}
}

View File

@ -1,12 +1,17 @@
package space.kscience.controls.client
import com.benasher44.uuid.uuid4
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.newCoroutineContext
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import space.kscience.controls.api.*
import space.kscience.controls.manager.DeviceManager
import space.kscience.controls.spec.DevicePropertySpec
import space.kscience.controls.spec.name
import space.kscience.dataforge.context.Context
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.misc.DFExperimental
@ -21,46 +26,41 @@ private fun stringUID() = uuid4().leastSignificantBits.toString(16)
/**
* A remote accessible device that relies on connection via Magix
*/
public class DeviceClient(
public class DeviceClient internal constructor(
override val context: Context,
private val deviceName: Name,
propertyDescriptors: Collection<PropertyDescriptor>,
actionDescriptors: Collection<ActionDescriptor>,
incomingFlow: Flow<DeviceMessage>,
private val send: suspend (DeviceMessage) -> Unit,
) : Device {
) : CachingDevice {
@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
override val coroutineContext: CoroutineContext = newCoroutineContext(context.coroutineContext)
override var actionDescriptors: Collection<ActionDescriptor> = actionDescriptors
internal set
override var propertyDescriptors: Collection<PropertyDescriptor> = propertyDescriptors
internal set
override val coroutineContext: CoroutineContext = context.coroutineContext + Job(context.coroutineContext[Job])
private val mutex = Mutex()
private val propertyCache = HashMap<String, Meta>()
override var propertyDescriptors: Collection<PropertyDescriptor> = emptyList()
private set
override var actionDescriptors: Collection<ActionDescriptor> = emptyList()
private set
private val flowInternal = incomingFlow.filter {
it.sourceDevice == deviceName
}.shareIn(this, started = SharingStarted.Eagerly).also {
it.onEach { message ->
when (message) {
is PropertyChangedMessage -> mutex.withLock {
propertyCache[message.property] = message.value
}
is DescriptionMessage -> mutex.withLock {
propertyDescriptors = message.properties
actionDescriptors = message.actions
}
else -> {
//ignore
}
}.onEach { message ->
when (message) {
is PropertyChangedMessage -> mutex.withLock {
propertyCache[message.property] = message.value
}
}.launchIn(this)
}
else -> {
//ignore
}
}
}.shareIn(this, started = SharingStarted.Eagerly)
override val messageFlow: Flow<DeviceMessage> get() = flowInternal
@ -69,7 +69,7 @@ public class DeviceClient(
send(
PropertyGetMessage(propertyName, targetDevice = deviceName)
)
return flowInternal.filterIsInstance<PropertyChangedMessage>().first {
return messageFlow.filterIsInstance<PropertyChangedMessage>().first {
it.property == propertyName
}.value
}
@ -93,25 +93,181 @@ public class DeviceClient(
send(
ActionExecuteMessage(actionName, argument, id, targetDevice = deviceName)
)
return flowInternal.filterIsInstance<ActionResultMessage>().first {
return messageFlow.filterIsInstance<ActionResultMessage>().first {
it.action == actionName && it.requestId == id
}.result
}
private val lifecycleStateFlow = messageFlow.filterIsInstance<DeviceLifeCycleMessage>()
.map { it.state }.stateIn(this, started = SharingStarted.Eagerly, LifecycleState.STARTED)
@DFExperimental
override val lifecycleState: DeviceLifecycleState = DeviceLifecycleState.OPEN
override val lifecycleState: LifecycleState get() = lifecycleStateFlow.value
}
/**
* Connect to a remote device via this endpoint.
*
* @param context a [Context] to run device in
* @param endpointName the name of endpoint in Magix to connect to
* @param thisEndpoint the name of this endpoint
* @param deviceEndpoint the name of endpoint in Magix to connect to
* @param deviceName the name of device within endpoint
*/
public fun MagixEndpoint.remoteDevice(context: Context, endpointName: String, deviceName: Name): DeviceClient {
val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second }
return DeviceClient(context, deviceName, subscription) {
send(DeviceManager.magixFormat, it, endpointName, id = stringUID())
public suspend fun MagixEndpoint.remoteDevice(
context: Context,
thisEndpoint: String,
deviceEndpoint: String,
deviceName: Name,
): DeviceClient = coroutineScope {
val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(deviceEndpoint))
.map { it.second }
.filter {
it.sourceDevice == null || it.sourceDevice == deviceName
}
val deferredDescriptorMessage = CompletableDeferred<DescriptionMessage>()
launch {
deferredDescriptorMessage.complete(
subscription.filterIsInstance<DescriptionMessage>().first()
)
}
send(
format = DeviceManager.magixFormat,
payload = GetDescriptionMessage(targetDevice = deviceName),
source = thisEndpoint,
target = deviceEndpoint,
id = stringUID()
)
val descriptionMessage = deferredDescriptorMessage.await()
DeviceClient(
context = context,
deviceName = deviceName,
propertyDescriptors = descriptionMessage.properties,
actionDescriptors = descriptionMessage.actions,
incomingFlow = subscription
) {
send(
format = DeviceManager.magixFormat,
payload = it,
source = thisEndpoint,
target = deviceEndpoint,
id = stringUID()
)
}
}
/**
* Create a dynamic [DeviceHub] from incoming messages
*/
public suspend fun MagixEndpoint.remoteDeviceHub(
context: Context,
thisEndpoint: String,
deviceEndpoint: String,
): DeviceHub {
val devices = mutableMapOf<Name, DeviceClient>()
val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(deviceEndpoint)).map { it.second }
subscription.filterIsInstance<DescriptionMessage>().onEach { descriptionMessage ->
devices.getOrPut(descriptionMessage.sourceDevice) {
DeviceClient(
context = context,
deviceName = descriptionMessage.sourceDevice,
propertyDescriptors = descriptionMessage.properties,
actionDescriptors = descriptionMessage.actions,
incomingFlow = subscription
) {
send(
format = DeviceManager.magixFormat,
payload = it,
source = thisEndpoint,
target = deviceEndpoint,
id = stringUID()
)
}
}.run {
propertyDescriptors = descriptionMessage.properties
}
}.launchIn(context)
send(
format = DeviceManager.magixFormat,
payload = GetDescriptionMessage(targetDevice = null),
source = thisEndpoint,
target = deviceEndpoint,
id = stringUID()
)
return DeviceHub(devices)
}
/**
* Request a description update for all devices on an endpoint
*/
public suspend fun MagixEndpoint.requestDeviceUpdate(
thisEndpoint: String,
deviceEndpoint: String,
) {
send(
format = DeviceManager.magixFormat,
payload = GetDescriptionMessage(),
source = thisEndpoint,
target = deviceEndpoint,
id = stringUID()
)
}
/**
* Subscribe on specific property of a device without creating a device
*/
public fun <T> MagixEndpoint.controlsPropertyFlow(
endpointName: String,
deviceName: Name,
propertySpec: DevicePropertySpec<*, T>,
): Flow<T> {
val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second }
return subscription.filterIsInstance<PropertyChangedMessage>()
.filter { message ->
message.sourceDevice == deviceName && message.property == propertySpec.name
}.map {
propertySpec.converter.read(it.value)
}
}
public suspend fun <T> MagixEndpoint.sendControlsPropertyChange(
sourceEndpointName: String,
targetEndpointName: String,
deviceName: Name,
propertySpec: DevicePropertySpec<*, T>,
value: T,
) {
val message = PropertySetMessage(
property = propertySpec.name,
value = propertySpec.converter.convert(value),
targetDevice = deviceName
)
send(DeviceManager.magixFormat, message, source = sourceEndpointName, target = targetEndpointName)
}
/**
* Subscribe on property change messages together with property values
*/
public fun <T> MagixEndpoint.controlsPropertyMessageFlow(
endpointName: String,
deviceName: Name,
propertySpec: DevicePropertySpec<*, T>,
): Flow<Pair<PropertyChangedMessage, T>> {
val subscription = subscribe(DeviceManager.magixFormat, originFilter = listOf(endpointName)).map { it.second }
return subscription.filterIsInstance<PropertyChangedMessage>()
.filter { message ->
message.sourceDevice == deviceName && message.property == propertySpec.name
}.map {
it to propertySpec.converter.read(it.value)
}
}

View File

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

View File

@ -1,5 +1,7 @@
package space.kscience.controls.client
import com.benasher44.uuid.uuid4
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.launchIn
@ -12,6 +14,8 @@ import space.kscience.controls.manager.respondHubMessage
import space.kscience.dataforge.context.error
import space.kscience.dataforge.context.logger
import space.kscience.magix.api.*
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
internal val controlsMagixFormat: MagixFormat<DeviceMessage> = MagixFormat(
@ -27,22 +31,25 @@ public val DeviceManager.Companion.magixFormat: MagixFormat<DeviceMessage> get()
internal fun generateId(request: MagixMessage): String = if (request.id != null) {
"${request.id}.response"
} else {
"controls[${request.payload.hashCode().toString(16)}"
uuid4().leastSignificantBits.toULong().toString(16)
}
/**
* Communicate with server in [Magix format](https://github.com/waltz-controls/rfc/tree/master/1)
*
* Accepts messages with target that equals [endpointID] or null (broadcast messages)
*/
public fun DeviceManager.launchMagixService(
endpoint: MagixEndpoint,
endpointID: String = controlsMagixFormat.defaultFormat,
): Job = context.launch {
endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID)).onEach { (request, payload) ->
val responsePayload = respondHubMessage(payload)
if (responsePayload != null) {
endpointID: String,
coroutineContext: CoroutineContext = EmptyCoroutineContext,
): Job = context.launch(coroutineContext) {
endpoint.subscribe(controlsMagixFormat, targetFilter = listOf(endpointID, null)).onEach { (request, payload) ->
val responsePayload: List<DeviceMessage> = respondHubMessage(payload)
responsePayload.forEach {
endpoint.send(
format = controlsMagixFormat,
payload = responsePayload,
payload = it,
source = endpointID,
target = request.sourceEndpoint,
id = generateId(request),
@ -50,10 +57,10 @@ public fun DeviceManager.launchMagixService(
)
}
}.catch { error ->
logger.error(error) { "Error while responding to message: ${error.message}" }
if (error !is CancellationException) logger.error(error) { "Error while responding to message: ${error.message}" }
}.launchIn(this)
hubMessageFlow(this).onEach { payload ->
hubMessageFlow().onEach { payload ->
endpoint.send(
format = controlsMagixFormat,
payload = payload,

View File

@ -5,12 +5,12 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import space.kscience.controls.api.get
import space.kscience.controls.api.getOrReadProperty
import space.kscience.controls.manager.DeviceManager
import space.kscience.dataforge.context.error
import space.kscience.dataforge.context.logger
import space.kscience.dataforge.meta.Meta
import space.kscience.dataforge.names.get
import space.kscience.magix.api.*
public const val TANGO_MAGIX_FORMAT: String = "tango"
@ -88,7 +88,7 @@ public fun DeviceManager.launchTangoMagix(
return context.launch {
endpoint.subscribe(tangoMagixFormat).onEach { (request, payload) ->
try {
val device = get(payload.device)
val device = devices[payload.device] ?: error("Device ${payload.device} not found")
when (payload.action) {
TangoAction.read -> {
val value = device.getOrReadProperty(payload.name)
@ -99,6 +99,7 @@ public fun DeviceManager.launchTangoMagix(
)
}
}
TangoAction.write -> {
payload.value?.let { value ->
device.writeProperty(payload.name, value)
@ -112,6 +113,7 @@ public fun DeviceManager.launchTangoMagix(
)
}
}
TangoAction.exec -> {
val result = device.execute(payload.name, payload.argin)
respond(request, payload) { requestPayload ->
@ -121,6 +123,7 @@ public fun DeviceManager.launchTangoMagix(
)
}
}
TangoAction.pipe -> TODO("Pipe not implemented")
}
} catch (ex: Exception) {

View File

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

View File

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

View File

@ -14,18 +14,16 @@ Automatically checks consistency.
## Artifact:
The Maven coordinates of this project are `space.kscience:controls-modbus:0.2.0`.
The Maven coordinates of this project are `space.kscience:controls-modbus:0.4.0-dev-4`.
**Gradle Kotlin DSL:**
```kotlin
repositories {
maven("https://repo.kotlin.link")
//uncomment to access development builds
//maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev")
mavenCentral()
}
dependencies {
implementation("space.kscience:controls-modbus:0.2.0")
implementation("space.kscience:controls-modbus:0.4.0-dev-4")
}
```

View File

@ -1,7 +1,7 @@
import space.kscience.gradle.Maturity
plugins {
id("space.kscience.gradle.jvm")
id("space.kscience.gradle.mpp")
`maven-publish`
}
@ -9,10 +9,12 @@ description = """
A plugin for Controls-kt device server on top of modbus-rtu/modbus-tcp protocols
""".trimIndent()
dependencies {
api(projects.controlsCore)
api("com.ghgande:j2mod:3.1.1")
kscience {
jvm()
jvmMain {
api(projects.controlsCore)
api(libs.j2mod)
}
}
readme{

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