Compare commits
28 Commits
Author | SHA1 | Date | |
---|---|---|---|
1863a611d1 | |||
bf9db7de08 | |||
b344ae7cfb | |||
3727310e55 | |||
ba3af28804 | |||
74a3905118 | |||
ee14bf7dfc | |||
7658dd168a | |||
144746445a | |||
962164fa89 | |||
4b3ad34e82 | |||
c6375a60ac | |||
e8a52a0e46 | |||
a77494c98c | |||
1a74d9446d | |||
db1e5c8c14 | |||
6f8bb6cc80 | |||
37e357a404 | |||
7d11650cab | |||
fc6b43fe0b | |||
0272c75f96 | |||
7efd0b423c | |||
d7fbc17462 | |||
eed5d27dc5 | |||
7e286ca111 | |||
76fa751e25 | |||
e9e2c7b8d8 | |||
7cde308114 |
controls-composite-spec
README.mdbuild.gradle.kts
src
commonMain/kotlin/space/kscience/controls/spec
CompositeControlComponents.kt
api
config
infra
model
runtime
CircuitBreakerManager.ktDeviceHubManager.ktDeviceLifecycleManager.ktDeviceRegistry.ktDeviceRestartManager.ktSystemResourceInfo.ktTransactionManager.kt
utils
commonTest/kotlin/space/kscience/controls/spec
controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor
controls-core/src
commonMain/kotlin/space/kscience/controls
api
spec
jsMain/kotlin/space/kscience/controls/spec
jvmMain/kotlin/space/kscience/controls/spec
nativeMain/kotlin/space/kscience/controls/spec
wasmJsMain/kotlin
demo
all-things/src/main/kotlin/space/kscience/controls/demo
motors/src/main/kotlin/ru/mipt/npm/devices/pimotionmaster
81
controls-composite-spec/README.md
Normal file
81
controls-composite-spec/README.md
Normal file
@ -0,0 +1,81 @@
|
||||
# Module controls-constructor
|
||||
|
||||
The CompositeControlComponents module is an extension for creating composite devices with a declarative hierarchy description, lifecycle management, and error handling.
|
||||
|
||||
## Usage
|
||||
|
||||
Spec creation:
|
||||
```kotlin
|
||||
object TemperatureSensorSpec : CompositeControlComponentSpec<TemperatureSensor>() {
|
||||
// read only property
|
||||
val temperature by doubleProperty(
|
||||
descriptorBuilder = {
|
||||
description = "Current temperature in degrees Celsius"
|
||||
},
|
||||
read = { readTemperature() }
|
||||
)
|
||||
|
||||
// Read and write property
|
||||
val units by stringProperty(
|
||||
descriptorBuilder = {
|
||||
description = "Temperature measurement units (C or F)"
|
||||
},
|
||||
read = { getUnits() },
|
||||
write = { _, value -> setUnits(value) }
|
||||
)
|
||||
|
||||
// Action without parameters
|
||||
val reset by unitAction(
|
||||
descriptorBuilder = {
|
||||
description = "Reset the sensor to the initial values"
|
||||
},
|
||||
execute = { resetSensor() }
|
||||
)
|
||||
|
||||
// Child component
|
||||
val calibrationDevice by childSpec(
|
||||
fallbackSpec = CalibrationDeviceSpec,
|
||||
configBuilder = {
|
||||
linked() // Lifecycle mode
|
||||
onError = ChildDeviceErrorHandler.RESTART // Error handling strategy
|
||||
}
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
Device creation:
|
||||
```kotlin
|
||||
class TemperatureSensor(
|
||||
context: Context,
|
||||
meta: Meta = Meta.EMPTY
|
||||
) : ConfigurableCompositeControlComponent<TemperatureSensor>(
|
||||
spec = TemperatureSensorSpec,
|
||||
context = context,
|
||||
meta = meta
|
||||
) {
|
||||
private var currentTemperature = 25.0
|
||||
private var currentUnits = "C"
|
||||
|
||||
// Simulation
|
||||
suspend fun readTemperature(): Double = currentTemperature
|
||||
|
||||
fun getUnits(): String = currentUnits
|
||||
|
||||
suspend fun setUnits(units: String) {
|
||||
currentUnits = units
|
||||
}
|
||||
|
||||
suspend fun resetSensor() {
|
||||
currentTemperature = 25.0
|
||||
currentUnits = "C"
|
||||
}
|
||||
|
||||
// Child device access
|
||||
val calibrationDevice by childDevice<CalibrationDevice>()
|
||||
}
|
||||
```
|
||||
|
||||
## Artifact:
|
||||
|
||||
|
||||
```
|
35
controls-composite-spec/build.gradle.kts
Normal file
35
controls-composite-spec/build.gradle.kts
Normal file
@ -0,0 +1,35 @@
|
||||
plugins {
|
||||
id("space.kscience.gradle.mpp")
|
||||
`maven-publish`
|
||||
}
|
||||
|
||||
description = """
|
||||
An extension for creating composite devices
|
||||
""".trimIndent()
|
||||
|
||||
kscience{
|
||||
jvm()
|
||||
js()
|
||||
native()
|
||||
wasm()
|
||||
useCoroutines()
|
||||
useSerialization()
|
||||
commonMain {
|
||||
api(projects.controlsCore)
|
||||
api(projects.controlsConstructor)
|
||||
implementation(projects.magix.magixApi)
|
||||
implementation(projects.magix.magixServer)
|
||||
implementation(projects.magix.magixRsocket)
|
||||
implementation(projects.magix.magixZmq)
|
||||
implementation(projects.controlsMagix)
|
||||
implementation("org.jetbrains.kotlinx:atomicfu:0.27.0")
|
||||
}
|
||||
|
||||
commonTest{
|
||||
implementation(spclibs.logback.classic)
|
||||
}
|
||||
}
|
||||
|
||||
readme{
|
||||
maturity = space.kscience.gradle.Maturity.PROTOTYPE
|
||||
}
|
258
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/CompositeControlComponents.kt
Normal file
258
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/CompositeControlComponents.kt
Normal file
@ -0,0 +1,258 @@
|
||||
package space.kscience.controls.spec
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import space.kscience.controls.api.*
|
||||
import space.kscience.controls.constructor.ConstructorElement
|
||||
import space.kscience.controls.constructor.DeviceState
|
||||
import space.kscience.controls.constructor.MutableDeviceState
|
||||
import space.kscience.controls.constructor.StateContainer
|
||||
import space.kscience.controls.constructor.registerState
|
||||
import space.kscience.controls.spec.api.CompositeControlComponentSpec
|
||||
import space.kscience.controls.spec.api.ChildComponentConfig
|
||||
import space.kscience.controls.spec.api.DeviceSpecification
|
||||
import space.kscience.controls.spec.config.DeviceLifecycleConfig
|
||||
import space.kscience.controls.spec.model.*
|
||||
import space.kscience.controls.spec.runtime.DeviceHubManager
|
||||
import space.kscience.controls.spec.utils.*
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.ContextAware
|
||||
import space.kscience.dataforge.context.Logger
|
||||
import space.kscience.dataforge.context.debug
|
||||
import space.kscience.dataforge.context.error
|
||||
import space.kscience.dataforge.context.info
|
||||
import space.kscience.dataforge.context.logger
|
||||
import space.kscience.dataforge.context.warn
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.meta.get
|
||||
import space.kscience.dataforge.meta.string
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.dataforge.names.asName
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.properties.PropertyDelegateProvider
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
||||
/**
|
||||
* Interface for a composite control component that hosts child devices.
|
||||
*/
|
||||
public interface CompositeControlComponent : Device {
|
||||
public val devices: Map<Name, Device>
|
||||
}
|
||||
|
||||
/**
|
||||
* A configurable composite device managing child components via a [spec].
|
||||
* Interacts with [DeviceHubManager] for lifecycle and messaging.
|
||||
*
|
||||
* @param D Self-referential type for the device.
|
||||
* @property spec The [CompositeControlComponentSpec] defining this device.
|
||||
* @param context The parent [Context].
|
||||
* @param meta Device metadata.
|
||||
* @property deviceHubManager The [DeviceHubManager] instance.
|
||||
* @param timeSource The [TimeSource] for time-dependent operations.
|
||||
* @param actionExecutionTimeout Default timeout for executing actions.
|
||||
*/
|
||||
public open class ConfigurableCompositeControlComponent<D : ConfigurableCompositeControlComponent<D>>(
|
||||
public val spec: CompositeControlComponentSpec<D>,
|
||||
context: Context,
|
||||
meta: Meta = Meta.EMPTY,
|
||||
public val deviceHubManager: DeviceHubManager = context.deviceHubManagerOrNull
|
||||
?: throw IllegalStateException("DeviceHubManager not found for composite device ${meta["name"].string ?: "unnamed"}. Ensure registered or provided."),
|
||||
private val timeSource: TimeSource = context.timeSourceOrDefault,
|
||||
private val actionExecutionTimeout: Duration = meta["actionTimeout"]?.string?.let {
|
||||
ParsingUtils.parseDurationOrNull(it)
|
||||
} ?: context.deviceManagerConfig.defaultActionExecutionTimeout
|
||||
) : DeviceBase<D>(context, meta), CompositeControlComponent {
|
||||
|
||||
final override val properties: Map<String, DevicePropertySpec<D, *>> get() = spec.properties
|
||||
final override val actions: Map<String, DeviceActionSpec<D, *, *>> get() = spec.actions
|
||||
final override val devices: Map<Name, Device> get() = deviceHubManager.devices
|
||||
|
||||
override fun toString(): String = "CompositeDevice(id=$id, spec=${spec::class.simpleName})"
|
||||
|
||||
protected val stateContainer: StateContainerImpl = StateContainerImpl(this)
|
||||
protected val childConfigsFromSpec: List<ChildComponentConfig<*>> = spec.childSpecs.values.toList()
|
||||
private val childInitializationStatus = mutableMapOf<Name, Boolean>()
|
||||
private val initLock = kotlinx.coroutines.sync.Mutex()
|
||||
|
||||
protected class StateContainerImpl(device: Device) : StateContainer {
|
||||
override val context: Context = device.context
|
||||
override val coroutineContext: CoroutineContext = device.coroutineContext
|
||||
private val elements = mutableSetOf<ConstructorElement>()
|
||||
override val constructorElements: Set<ConstructorElement> get() = elements.toSet()
|
||||
override fun registerElement(constructorElement: ConstructorElement) { elements.add(constructorElement) }
|
||||
override fun unregisterElement(constructorElement: ConstructorElement) { elements.remove(constructorElement) }
|
||||
}
|
||||
|
||||
init {
|
||||
spec.states.values.forEach { stateContainer.registerState(it) }
|
||||
|
||||
launch(CoroutineName("ActionHandler-init-$id")) {
|
||||
spec.actions.forEach { (actionName, _) ->
|
||||
launch(CoroutineName("ActionHandler-$actionName-$id")) {
|
||||
messageFlow.filterIsInstance<ActionExecuteMessage>()
|
||||
.filter { it.action == actionName && it.targetDevice == id.asName() }
|
||||
.collect { msg ->
|
||||
launch(CoroutineName("ActionExec-$actionName-${msg.requestId}-$id")) {
|
||||
val response: ActionResultMessage = try {
|
||||
val result = withTimeoutOrNull(actionExecutionTimeout) {
|
||||
execute(actionName, msg.argument)
|
||||
}
|
||||
if (result == null) throw DeviceTimeoutException("Action '$actionName' on '$id' timed out after $actionExecutionTimeout.")
|
||||
ActionResultMessage(actionName, result, requestId = msg.requestId, sourceDevice = id.asName(), time = timeSource.now())
|
||||
} catch (ex: TimeoutCancellationException) {
|
||||
logger.error(ex) { "Action '$actionName' on '$id' timed out." }
|
||||
val timeoutEx = DeviceTimeoutException("Action '$actionName' on '$id' timed out.", ex)
|
||||
ActionResultMessage(actionName, null, timeoutEx.toSerializableFailure(), msg.requestId, id.asName(), time = timeSource.now())
|
||||
} catch (ex: DeviceException) {
|
||||
logger.error(ex) { "Error executing action '$actionName' on '$id'." }
|
||||
ActionResultMessage(actionName, null, ex.toSerializableFailure(), msg.requestId, id.asName(), time = timeSource.now())
|
||||
} catch (ex: Exception) {
|
||||
logger.error(ex) { "Unexpected error executing action '$actionName' on '$id'." }
|
||||
val opEx = DeviceOperationException("Action '$actionName' on '$id' failed unexpectedly.", ex)
|
||||
ActionResultMessage(actionName, null, opEx.toSerializableFailure(), msg.requestId, id.asName(), time = timeSource.now())
|
||||
}
|
||||
deviceHubManager.messagingSystem.publish(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun initChildren() {
|
||||
for (childSpecConfig in childConfigsFromSpec) {
|
||||
if (initLock.withLock { childInitializationStatus[childSpecConfig.name] == true }) {
|
||||
logger.debug { "Child '${childSpecConfig.name}' for '$id' already initialized."}
|
||||
continue
|
||||
}
|
||||
val effectiveChildConfig = childSpecConfig.config
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val childDevice: Device = if (childSpecConfig.spec is DeviceSpecification<*>) {
|
||||
(childSpecConfig.spec as DeviceSpecification<out ConfigurableCompositeControlComponent<*>>)
|
||||
.deviceFactory(context, childSpecConfig.meta ?: Meta.EMPTY)
|
||||
} else {
|
||||
ConfigurableCompositeControlComponent(
|
||||
childSpecConfig.spec,
|
||||
context, childSpecConfig.meta ?: Meta.EMPTY, deviceHubManager, timeSource
|
||||
)
|
||||
}
|
||||
deviceHubManager.attachDevice(
|
||||
childSpecConfig.name, childDevice, effectiveChildConfig,
|
||||
childSpecConfig.meta, StartMode.NONE
|
||||
)
|
||||
initLock.withLock { childInitializationStatus[childSpecConfig.name] = true }
|
||||
logger.debug { "Child '${childSpecConfig.name}' initialized and attached for '$id'." }
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Error initializing child '${childSpecConfig.name}' for '$id'." }
|
||||
initLock.withLock { childInitializationStatus[childSpecConfig.name] = false }
|
||||
if (effectiveChildConfig.onError == ChildDeviceErrorHandler.PROPAGATE) {
|
||||
throw DeviceStartupException("Failed to init child '${childSpecConfig.name}' for '$id'.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun onStart() {
|
||||
with(spec) { self.onOpen(); validate(self) }
|
||||
initChildren()
|
||||
coroutineScope {
|
||||
childConfigsFromSpec
|
||||
.filter {
|
||||
initLock.withLock { childInitializationStatus[it.name] == true } &&
|
||||
it.config.lifecycleMode == LifecycleMode.LINKED
|
||||
}
|
||||
.map { cfg ->
|
||||
async(CoroutineName("StartChild-${cfg.name}-for-$id")) {
|
||||
try {
|
||||
logger.debug { "Starting LINKED child '${cfg.name}' for '$id'." }
|
||||
deviceHubManager.lifecycleManager.startDevice(cfg.name)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Error starting LINKED child '${cfg.name}' during parent '$id' start." }
|
||||
if (cfg.config.onError == ChildDeviceErrorHandler.PROPAGATE) {
|
||||
throw DeviceStartupException("Failed to start LINKED child '${cfg.name}' for '$id'.", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
currentCoroutineContext().ensureActive()
|
||||
}
|
||||
|
||||
override suspend fun onStop() {
|
||||
supervisorScope {
|
||||
childConfigsFromSpec.reversed().forEach { cfg ->
|
||||
val childName = cfg.name
|
||||
if (initLock.withLock { childInitializationStatus[childName] == true } &&
|
||||
cfg.config.lifecycleMode == LifecycleMode.LINKED) {
|
||||
val childDev = deviceHubManager.devices[childName] as? WithLifeCycle
|
||||
if (childDev?.lifecycleState == LifecycleState.STARTED) {
|
||||
launch(CoroutineName("StopChild-$childName-for-$id")) {
|
||||
try {
|
||||
logger.debug { "Stopping LINKED child '$childName' for '$id'." }
|
||||
deviceHubManager.lifecycleManager.stopDevice(childName)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Error stopping LINKED child '$childName' during parent '$id' stop." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
with(spec) { self.onClose() }
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
public fun <CD : Device> getChildDevice(name: Name): CD? = deviceHubManager.devices[name] as? CD
|
||||
|
||||
public fun <CD : Device> childDevice(name: Name? = null): PropertyDelegateProvider<ConfigurableCompositeControlComponent<D>, ReadOnlyProperty<ConfigurableCompositeControlComponent<D>, CD>> =
|
||||
PropertyDelegateProvider { _, property ->
|
||||
val devName = name ?: property.name.asName()
|
||||
val deviceInstance: CD by lazy {
|
||||
getChildDevice(devName) ?: throw DeviceNotFoundException("Child device '$devName' (property '${property.name}') not found in '$id'.")
|
||||
}
|
||||
ReadOnlyProperty { _, _ -> deviceInstance }
|
||||
}
|
||||
|
||||
public inline operator fun <reified Dev : Device> get(name: Name): Dev? = getChildDevice(name)
|
||||
public inline operator fun <reified Dev : Device> get(name: String): Dev? = this[name.asName()]
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
public fun <T> getState(name: String): DeviceState<T>? = spec.states[name] as? DeviceState<T>
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
public fun <T> getMutableState(name: String): MutableDeviceState<T>? = spec.states[name] as? MutableDeviceState<T>
|
||||
|
||||
public fun <T> state(name: String? = null): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, DeviceState<T>>> =
|
||||
PropertyDelegateProvider { _, property ->
|
||||
val stateName = name ?: property.name
|
||||
val stateInst: DeviceState<T> by lazy { getState(stateName) ?: throw IllegalStateException("State '$stateName' not found in '$id'.") }
|
||||
ReadOnlyProperty { _, _ -> stateInst }
|
||||
}
|
||||
|
||||
public fun <T> mutableState(name: String? = null): PropertyDelegateProvider<Any?, ReadOnlyProperty<Any?, MutableDeviceState<T>>> =
|
||||
PropertyDelegateProvider { _, property ->
|
||||
val stateName = name ?: property.name
|
||||
val stateInst: MutableDeviceState<T> by lazy { getMutableState(stateName) ?: throw IllegalStateException("MutableState '$stateName' not found or not mutable in '$id'.") }
|
||||
ReadOnlyProperty { _, _ -> stateInst }
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun WithLifeCycle.stopWithTimeout(
|
||||
timeout: Duration = DeviceLifecycleConfig.Factory.Defaults.DEVICE_STOP_TIMEOUT,
|
||||
loggerOverride: Logger? = null
|
||||
) {
|
||||
val logger = loggerOverride ?: (this as? ContextAware)?.context?.logger
|
||||
val deviceIdString = (this as? Device)?.id ?: this.toString()
|
||||
try {
|
||||
withTimeout(timeout) { stop() }
|
||||
logger?.info { "Device '$deviceIdString' stopped within timeout $timeout." }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
logger?.warn { "Timeout ($timeout) stopping device '$deviceIdString'." }
|
||||
} catch (e: Exception) {
|
||||
logger?.error(e) { "Error stopping device '$deviceIdString'." }
|
||||
}
|
||||
}
|
174
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/api/ChildComponentConfig.kt
Normal file
174
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/api/ChildComponentConfig.kt
Normal file
@ -0,0 +1,174 @@
|
||||
package space.kscience.controls.spec.api
|
||||
|
||||
import space.kscience.controls.spec.ConfigurableCompositeControlComponent
|
||||
import space.kscience.controls.spec.config.DeviceLifecycleConfig
|
||||
import space.kscience.controls.spec.config.DeviceLifecycleConfigBuilder
|
||||
import space.kscience.controls.spec.model.ChildDeviceErrorHandler
|
||||
import space.kscience.controls.spec.model.LifecycleMode
|
||||
import space.kscience.controls.spec.utils.ParsingUtils // Assuming ParsingUtils is moved here
|
||||
import space.kscience.dataforge.context.Logger
|
||||
import space.kscience.dataforge.context.warn
|
||||
import space.kscience.dataforge.meta.*
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.dataforge.names.asName
|
||||
|
||||
/**
|
||||
* Interface representing the configuration for a child component within a composite device.
|
||||
* It links a child's [Name] to its [CompositeControlComponentSpec], [DeviceLifecycleConfig],
|
||||
* and optional [Meta].
|
||||
*
|
||||
* @param CD The type of the child's [ConfigurableCompositeControlComponent].
|
||||
*/
|
||||
public interface ChildComponentConfig<CD : ConfigurableCompositeControlComponent<CD>> {
|
||||
/** The [CompositeControlComponentSpec] that defines the child component. */
|
||||
public val spec: CompositeControlComponentSpec<CD>
|
||||
/** The [DeviceLifecycleConfig] for managing the child component's lifecycle. */
|
||||
public val config: DeviceLifecycleConfig
|
||||
/** Optional [Meta] providing additional configuration or metadata for the child component. */
|
||||
public val meta: Meta?
|
||||
/** The unique [Name] of the child component within its parent. */
|
||||
public val name: Name
|
||||
|
||||
public companion object {
|
||||
/**
|
||||
* Creates a [ChildComponentConfig] instance from a [Meta] object.
|
||||
* This allows defining child component configurations declaratively.
|
||||
*
|
||||
* @param CD The type of the child's [ConfigurableCompositeControlComponent].
|
||||
* @param meta The [Meta] object containing the child's configuration.
|
||||
* @param registry The [ComponentRegistry] used to look up the child's [spec] by name.
|
||||
* @param name The intended [Name] for this child component.
|
||||
* @param logger Optional [Logger] for reporting issues during parsing.
|
||||
* @return A [ChildComponentConfig] instance if parsing is successful, null otherwise.
|
||||
*/
|
||||
public fun <CD : ConfigurableCompositeControlComponent<CD>> fromMeta(
|
||||
meta: Meta,
|
||||
registry: ComponentRegistry,
|
||||
name: Name, // Name of the child instance
|
||||
logger: Logger? = null
|
||||
): ChildComponentConfig<CD>? {
|
||||
val specNameString = meta["spec"].string ?: run {
|
||||
logger?.warn { "Child component '$name' in Meta is missing the required 'spec' field (string)." }
|
||||
return null
|
||||
}
|
||||
val specName = specNameString.asName()
|
||||
|
||||
val spec: CompositeControlComponentSpec<CD> = registry.getSpec<CD>(specName) ?: run {
|
||||
logger?.warn { "Specification '$specName' for child '$name' not found in the component registry." }
|
||||
return null
|
||||
}
|
||||
|
||||
val lifecycleConfigBuilder = DeviceLifecycleConfigBuilder()
|
||||
|
||||
meta["config"]?.let { configMeta ->
|
||||
configMeta["lifecycleMode"]?.string?.let { modeStr ->
|
||||
try {
|
||||
lifecycleConfigBuilder.lifecycleMode = LifecycleMode.valueOf(modeStr.uppercase())
|
||||
} catch (_: IllegalArgumentException) {
|
||||
logger?.warn { "Invalid 'lifecycleMode' value '$modeStr' for child '$name'. Using default." }
|
||||
}
|
||||
}
|
||||
configMeta["messageBuffer"]?.int?.let { lifecycleConfigBuilder.messageBuffer = it }
|
||||
ParsingUtils.parseDurationOrNull(configMeta["startDelay"].string)?.let { lifecycleConfigBuilder.startDelay = it }
|
||||
ParsingUtils.parseDurationOrNull(configMeta["startTimeout"].string)?.let { lifecycleConfigBuilder.startTimeout = it }
|
||||
ParsingUtils.parseDurationOrNull(configMeta["stopTimeout"].string)?.let { lifecycleConfigBuilder.stopTimeout = it }
|
||||
|
||||
configMeta["onError"]?.string?.let { errorHandlerStr ->
|
||||
try {
|
||||
lifecycleConfigBuilder.onError = ChildDeviceErrorHandler.valueOf(errorHandlerStr.uppercase())
|
||||
} catch (_: IllegalArgumentException) {
|
||||
logger?.warn { "Invalid 'onError' value '$errorHandlerStr' for child '$name'. Using default." }
|
||||
}
|
||||
}
|
||||
// TODO: Add parsing for RestartPolicy from meta.
|
||||
}
|
||||
|
||||
val deviceInstanceMeta = meta["meta"]
|
||||
|
||||
return object : ChildComponentConfig<CD> {
|
||||
override val spec: CompositeControlComponentSpec<CD> = spec
|
||||
override val config: DeviceLifecycleConfig = lifecycleConfigBuilder.build()
|
||||
override val meta: Meta? = deviceInstanceMeta
|
||||
override val name: Name = name
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [ChildComponentConfigBuilder] for fluently constructing a [ChildComponentConfig].
|
||||
*
|
||||
* @param CD The type of the child's [ConfigurableCompositeControlComponent].
|
||||
* @param spec The [CompositeControlComponentSpec] for the child.
|
||||
* @param name The [Name] for this child component.
|
||||
* @return A new [ChildComponentConfigBuilder] instance.
|
||||
*/
|
||||
public fun <CD : ConfigurableCompositeControlComponent<CD>> builder(
|
||||
spec: CompositeControlComponentSpec<CD>,
|
||||
name: Name
|
||||
): ChildComponentConfigBuilder<CD> = ChildComponentConfigBuilder(spec, name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder class for creating [ChildComponentConfig] instances with a fluent API.
|
||||
*
|
||||
* @param CD The type of the child's [ConfigurableCompositeControlComponent].
|
||||
* @property spec The [CompositeControlComponentSpec] for the child component.
|
||||
* @property name The [Name] for this child component.
|
||||
*/
|
||||
public class ChildComponentConfigBuilder<CD : ConfigurableCompositeControlComponent<CD>>(
|
||||
private val spec: CompositeControlComponentSpec<CD>,
|
||||
private val name: Name
|
||||
) {
|
||||
private var lifecycleConfigBuilder = DeviceLifecycleConfigBuilder()
|
||||
private var meta: Meta? = null
|
||||
|
||||
/**
|
||||
* Sets the entire [DeviceLifecycleConfig] for the child component.
|
||||
* This replaces any previous lifecycle configurations set via the builder.
|
||||
*/
|
||||
public fun withLifecycleConfig(config: DeviceLifecycleConfig): ChildComponentConfigBuilder<CD> = apply {
|
||||
lifecycleConfigBuilder = DeviceLifecycleConfigBuilder().apply {
|
||||
lifecycleMode = config.lifecycleMode
|
||||
messageBuffer = config.messageBuffer
|
||||
startDelay = config.startDelay
|
||||
startTimeout = config.startTimeout
|
||||
stopTimeout = config.stopTimeout
|
||||
coroutineScope = config.coroutineScope
|
||||
dispatcher = config.dispatcher
|
||||
onError = config.onError
|
||||
restartPolicy = config.restartPolicy
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the [DeviceLifecycleConfig] for the child component using a lambda
|
||||
* applied to a [DeviceLifecycleConfigBuilder].
|
||||
*/
|
||||
public fun withLifecycleConfigBuilder(block: DeviceLifecycleConfigBuilder.() -> Unit): ChildComponentConfigBuilder<CD> = apply {
|
||||
lifecycleConfigBuilder.apply(block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the optional [Meta] for the child component instance.
|
||||
*/
|
||||
public fun withMeta(meta: Meta?): ChildComponentConfigBuilder<CD> = apply {
|
||||
this.meta = meta
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the optional [Meta] for the child component instance using a builder lambda.
|
||||
*/
|
||||
public fun withMeta(block: MutableMeta.() -> Unit): ChildComponentConfigBuilder<CD> = apply {
|
||||
this.meta = Meta(block)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns the [ChildComponentConfig] instance.
|
||||
*/
|
||||
public fun build(): ChildComponentConfig<CD> = object : ChildComponentConfig<CD> {
|
||||
override val spec: CompositeControlComponentSpec<CD> = this@ChildComponentConfigBuilder.spec
|
||||
override val config: DeviceLifecycleConfig = lifecycleConfigBuilder.build()
|
||||
override val meta: Meta? = this@ChildComponentConfigBuilder.meta
|
||||
override val name: Name = this@ChildComponentConfigBuilder.name
|
||||
}
|
||||
}
|
86
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/api/ComponentRegistry.kt
Normal file
86
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/api/ComponentRegistry.kt
Normal file
@ -0,0 +1,86 @@
|
||||
package space.kscience.controls.spec.api
|
||||
|
||||
import space.kscience.controls.spec.ConfigurableCompositeControlComponent
|
||||
import space.kscience.controls.api.DeviceConfigurationException
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.ContextAware
|
||||
import space.kscience.dataforge.context.debug
|
||||
import space.kscience.dataforge.context.logger
|
||||
import space.kscience.dataforge.context.warn
|
||||
import space.kscience.dataforge.names.Name
|
||||
|
||||
/**
|
||||
* Interface for a registry that stores and provides access to [CompositeControlComponentSpec]s.
|
||||
* This allows for dynamic lookup and instantiation of composite device specifications.
|
||||
*/
|
||||
public interface ComponentRegistry : ContextAware {
|
||||
/**
|
||||
* Retrieves a [CompositeControlComponentSpec] by its unique [name].
|
||||
*
|
||||
* @param D The expected type of the [ConfigurableCompositeControlComponent] the spec is for.
|
||||
* @param name The [Name] used to register the specification.
|
||||
* @return The [CompositeControlComponentSpec] if found and type matches, otherwise null.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> getSpec(name: Name): CompositeControlComponentSpec<D>?
|
||||
|
||||
/**
|
||||
* Registers a [CompositeControlComponentSpec] with a given [name].
|
||||
*
|
||||
* @param D The type of the [ConfigurableCompositeControlComponent] the spec is for.
|
||||
* @param name The [Name] under which to register the specification.
|
||||
* @param spec The [CompositeControlComponentSpec] instance to register.
|
||||
* @throws DeviceConfigurationException if a specification with the same name is already registered
|
||||
* and overwriting is not permitted by the implementation.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> registerSpec(
|
||||
name: Name,
|
||||
spec: CompositeControlComponentSpec<D>
|
||||
)
|
||||
|
||||
/**
|
||||
* Checks if a specification with the given [name] exists in the registry.
|
||||
*
|
||||
* @param name The [Name] to check.
|
||||
* @return True if a specification with this name is registered, false otherwise.
|
||||
*/
|
||||
public fun hasSpec(name: Name): Boolean
|
||||
|
||||
/**
|
||||
* Lists the [Name]s of all specifications currently registered.
|
||||
*
|
||||
* @return A [Set] of [Name]s of all registered specifications.
|
||||
*/
|
||||
public fun listSpecs(): Set<Name>
|
||||
}
|
||||
|
||||
/**
|
||||
* Default in-memory implementation of [ComponentRegistry].
|
||||
* Stores specifications in a mutable map.
|
||||
*
|
||||
* @param context The parent [Context].
|
||||
*/
|
||||
public class DefaultComponentRegistry(
|
||||
override val context: Context
|
||||
) : ComponentRegistry {
|
||||
private val registry = mutableMapOf<Name, CompositeControlComponentSpec<*>>()
|
||||
|
||||
override fun <D : ConfigurableCompositeControlComponent<D>> getSpec(name: Name): CompositeControlComponentSpec<D>? {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
return registry[name] as? CompositeControlComponentSpec<D>
|
||||
}
|
||||
|
||||
override fun <D : ConfigurableCompositeControlComponent<D>> registerSpec(
|
||||
name: Name,
|
||||
spec: CompositeControlComponentSpec<D>
|
||||
) {
|
||||
if (registry.containsKey(name)) {
|
||||
context.logger.warn { "Overwriting specification for name '$name' in ComponentRegistry." }
|
||||
}
|
||||
registry[name] = spec
|
||||
context.logger.debug { "Registered specification for name '$name'."}
|
||||
}
|
||||
|
||||
override fun hasSpec(name: Name): Boolean = name in registry
|
||||
|
||||
override fun listSpecs(): Set<Name> = registry.keys.toSet()
|
||||
}
|
430
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/api/CompositeDeviceDelegates.kt
Normal file
430
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/api/CompositeDeviceDelegates.kt
Normal file
@ -0,0 +1,430 @@
|
||||
package space.kscience.controls.spec.api
|
||||
|
||||
import kotlinx.coroutines.Deferred
|
||||
import space.kscience.controls.api.*
|
||||
import space.kscience.controls.spec.ConfigurableCompositeControlComponent
|
||||
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.controls.spec.unit
|
||||
import space.kscience.dataforge.meta.*
|
||||
import space.kscience.dataforge.meta.descriptors.MetaDescriptor
|
||||
import kotlin.properties.PropertyDelegateProvider
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
|
||||
/**
|
||||
* Create a [MetaConverter] for enum values using a reified type [E] with an option to ignore case.
|
||||
*/
|
||||
public inline fun <reified E : Enum<E>> createEnumConverter(ignoreCase: Boolean = false): MetaConverter<E> {
|
||||
val allValues = enumValues<E>()
|
||||
return object : MetaConverter<E> {
|
||||
override val descriptor: MetaDescriptor = MetaDescriptor {
|
||||
valueType(ValueType.STRING)
|
||||
allowedValues(allValues.map { it.name })
|
||||
}
|
||||
|
||||
override fun readOrNull(source: Meta): E? {
|
||||
val stringVal = source.value?.string ?: return null
|
||||
return allValues.firstOrNull { it.name.equals(stringVal, ignoreCase) }
|
||||
}
|
||||
|
||||
override fun convert(obj: E): Meta = Meta(obj.name)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unified function to declare a device property. If [write] is null, a read-only property is declared;
|
||||
* otherwise, a mutable property is declared.
|
||||
*
|
||||
* @param T The type of the property value.
|
||||
* @param D The type of the [ConfigurableCompositeControlComponent] this property belongs to.
|
||||
* @param converter The [MetaConverter] for serializing/deserializing the property value.
|
||||
* @param name Optional explicit name for the property. If null, the Kotlin property name is used.
|
||||
* @param descriptorBuilder Lambda for customizing the [PropertyDescriptor].
|
||||
* @param read Suspend function to read the property value from the device.
|
||||
* @param write Optional suspend function to write a new value to the property on the device.
|
||||
* @return A [PropertyDelegateProvider] for the declared device property.
|
||||
*/
|
||||
public fun <T, D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.typedProperty(
|
||||
converter: MetaConverter<T>,
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(propertyName: String) -> T?,
|
||||
write: (suspend D.(propertyName: String, value: T) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, T>>> {
|
||||
return if (write == null) {
|
||||
property(converter, descriptorBuilder, name, read)
|
||||
} else {
|
||||
mutableProperty(converter, descriptorBuilder, name, read, write)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a boolean device property.
|
||||
* See [typedProperty] for parameter details.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.booleanProperty(
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(String) -> Boolean?,
|
||||
write: (suspend D.(String, Boolean) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, Boolean>>> =
|
||||
typedProperty(MetaConverter.boolean, name, descriptorBuilder, read, write)
|
||||
|
||||
|
||||
/**
|
||||
* Declares an integer device property.
|
||||
* See [typedProperty] for parameter details.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.intProperty(
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(String) -> Int?,
|
||||
write: (suspend D.(String, Int) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, Int>>> =
|
||||
typedProperty(MetaConverter.int, name, descriptorBuilder, read, write)
|
||||
|
||||
/**
|
||||
* Declares a double-precision floating-point device property.
|
||||
* See [typedProperty] for parameter details.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.doubleProperty(
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(String) -> Double?,
|
||||
write: (suspend D.(String, Double) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, Double>>> =
|
||||
typedProperty(MetaConverter.double, name, descriptorBuilder, read, write)
|
||||
|
||||
/**
|
||||
* Declares a long integer device property.
|
||||
* See [typedProperty] for parameter details.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.longProperty(
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(String) -> Long?,
|
||||
write: (suspend D.(String, Long) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, Long>>> =
|
||||
typedProperty(MetaConverter.long, name, descriptorBuilder, read, write)
|
||||
|
||||
/**
|
||||
* Declares a single-precision floating-point device property.
|
||||
* See [typedProperty] for parameter details.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.floatProperty(
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(String) -> Float?,
|
||||
write: (suspend D.(String, Float) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, Float>>> =
|
||||
typedProperty(MetaConverter.float, name, descriptorBuilder, read, write)
|
||||
|
||||
/**
|
||||
* Declares a generic number device property.
|
||||
* See [typedProperty] for parameter details.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.numberProperty(
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(String) -> Number?,
|
||||
write: (suspend D.(String, Number) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, Number>>> =
|
||||
typedProperty(MetaConverter.number, name, descriptorBuilder, read, write)
|
||||
|
||||
/**
|
||||
* Declares a string device property.
|
||||
* See [typedProperty] for parameter details.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.stringProperty(
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(String) -> String?,
|
||||
write: (suspend D.(String, String) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, String>>> =
|
||||
typedProperty(MetaConverter.string, name, descriptorBuilder, read, write)
|
||||
|
||||
/**
|
||||
* Declares a [Meta] device property.
|
||||
* See [typedProperty] for parameter details.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.metaProperty(
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(String) -> Meta?,
|
||||
write: (suspend D.(String, Meta) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, Meta>>> =
|
||||
typedProperty(MetaConverter.meta, name, descriptorBuilder, read, write)
|
||||
|
||||
|
||||
/**
|
||||
* Declares an enum device property.
|
||||
* See [typedProperty] and [createEnumConverter] for parameter details.
|
||||
*/
|
||||
public inline fun <reified E : Enum<E>, D : ConfigurableCompositeControlComponent<D>>
|
||||
CompositeControlComponentSpec<D>.enumProperty(
|
||||
name: String? = null,
|
||||
ignoreCase: Boolean = false,
|
||||
noinline descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
noinline read: suspend D.(String) -> E?,
|
||||
noinline write: (suspend D.(String, E) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, E>>> =
|
||||
typedProperty(
|
||||
converter = createEnumConverter<E>(ignoreCase),
|
||||
name = name,
|
||||
descriptorBuilder = descriptorBuilder,
|
||||
read = read,
|
||||
write = write
|
||||
)
|
||||
|
||||
/**
|
||||
* Declares a list device property.
|
||||
*
|
||||
* @param T The type of elements in the list.
|
||||
* @param listConverter A [MetaConverter] for `List<T>`.
|
||||
* See [typedProperty] for other parameter details.
|
||||
*/
|
||||
public fun <T, D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.listProperty(
|
||||
listConverter: MetaConverter<List<T>>,
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(String) -> List<T>?,
|
||||
write: (suspend D.(String, List<T>) -> Unit)? = null,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, List<T>>>> =
|
||||
typedProperty(listConverter, name, descriptorBuilder, read, write)
|
||||
|
||||
|
||||
/**
|
||||
* Declares a logical property that is not directly tied to hardware I/O but managed
|
||||
* by the device's internal state (via `getProperty`/`writeProperty` of `Device`).
|
||||
*
|
||||
* @param T The type of the property value.
|
||||
* @param converter The [MetaConverter] for the property type.
|
||||
* @param name Optional explicit name for the property.
|
||||
* @param descriptorBuilder Lambda for customizing the [PropertyDescriptor].
|
||||
* @return A [PropertyDelegateProvider] for the logical property.
|
||||
*/
|
||||
public fun <T, D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.logicalProperty(
|
||||
converter: MetaConverter<T>,
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, T>>> = typedProperty(
|
||||
converter = converter,
|
||||
name = name,
|
||||
descriptorBuilder = descriptorBuilder,
|
||||
read = { propertyName ->
|
||||
this.getProperty(propertyName)?.let(converter::readOrNull)
|
||||
},
|
||||
write = { propertyName, value ->
|
||||
this.writeProperty(propertyName, converter.convert(value))
|
||||
}
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates an action with specified input and output [MetaConverter]s.
|
||||
*
|
||||
* @param I The type of the action input.
|
||||
* @param O The type of the action output.
|
||||
* @param D The type of the [ConfigurableCompositeControlComponent] this action belongs to.
|
||||
* @param inputConverter The [MetaConverter] for the action input.
|
||||
* @param outputConverter The [MetaConverter] for the action output.
|
||||
* @param name Optional explicit name for the action. If null, the Kotlin property name is used.
|
||||
* @param descriptorBuilder Lambda for customizing the [ActionDescriptor].
|
||||
* @param execute Suspend function defining the action's logic.
|
||||
* @return A [PropertyDelegateProvider] for the declared device action.
|
||||
*/
|
||||
public fun <I, O, D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.typedAction(
|
||||
inputConverter: MetaConverter<I>,
|
||||
outputConverter: MetaConverter<O>,
|
||||
name: String? = null,
|
||||
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
|
||||
execute: suspend D.(input: I) -> O,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DeviceActionSpec<D, I, O>>> =
|
||||
action(
|
||||
inputConverter = inputConverter,
|
||||
outputConverter = outputConverter,
|
||||
descriptorBuilder = descriptorBuilder,
|
||||
name = name,
|
||||
execute = execute
|
||||
)
|
||||
|
||||
/**
|
||||
* Declares a device action with no parameters and no return value ([Unit]).
|
||||
* See [typedAction] for other parameter details.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.unitAction(
|
||||
name: String? = null,
|
||||
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
|
||||
execute: suspend D.() -> Unit,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DeviceActionSpec<D, Unit, Unit>>> =
|
||||
typedAction(
|
||||
inputConverter = MetaConverter.unit,
|
||||
outputConverter = MetaConverter.unit,
|
||||
name = name,
|
||||
descriptorBuilder = descriptorBuilder,
|
||||
execute = { execute() }
|
||||
)
|
||||
|
||||
/**
|
||||
* Declares a device action where the execution returns a [Deferred] value. The action itself
|
||||
* will await the deferred result.
|
||||
* See [typedAction] for other parameter details.
|
||||
*/
|
||||
public fun <I, O, D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.asyncAction(
|
||||
inputConverter: MetaConverter<I>,
|
||||
outputConverter: MetaConverter<O>,
|
||||
name: String? = null,
|
||||
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
|
||||
execute: suspend D.(input: I) -> Deferred<O>,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DeviceActionSpec<D, I, O>>> =
|
||||
typedAction(inputConverter, outputConverter, name, descriptorBuilder) { input ->
|
||||
execute(input).await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a device action that takes a [Meta] object as input and returns a [Meta] object as output.
|
||||
* See [typedAction] for other parameter details.
|
||||
*/
|
||||
public fun <D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.metaAction(
|
||||
name: String? = null,
|
||||
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
|
||||
execute: suspend D.(input: Meta) -> Meta,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> =
|
||||
typedAction(MetaConverter.meta, MetaConverter.meta, name, descriptorBuilder, execute)
|
||||
|
||||
/**
|
||||
* Validates that the given [device] instance correctly implements all properties and actions
|
||||
* defined by this [CompositeDeviceSpec] (or more generally, any [CompositeDeviceSpec]).
|
||||
* It checks for the presence of corresponding descriptors in the device.
|
||||
*
|
||||
* @param device The [Device] instance to validate against this specification.
|
||||
* @throws IllegalStateException if a property or action defined in the spec is not found in the device.
|
||||
*/
|
||||
public fun CompositeDeviceSpec<*>.validateSpec(device: Device) {
|
||||
properties.values.forEach { propSpec ->
|
||||
check(device.propertyDescriptors.contains(propSpec.descriptor)) {
|
||||
"Property descriptor for '${propSpec.name}' defined in spec is not registered (or mismatch) in device '${device.id}'."
|
||||
}
|
||||
}
|
||||
actions.values.forEach { actSpec ->
|
||||
check(device.actionDescriptors.contains(actSpec.descriptor)) {
|
||||
"Action descriptor for '${actSpec.name}' defined in spec is not registered (or mismatch) in device '${device.id}'."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A read-only property with the ability to embed processing logic before and after reading a value.
|
||||
*
|
||||
* @param T The type of the property value.
|
||||
* @param D The type of the [ConfigurableCompositeControlComponent].
|
||||
* @param converter The [MetaConverter] for the property type.
|
||||
* @param name Optional explicit name for the property.
|
||||
* @param descriptorBuilder Lambda for customizing the [PropertyDescriptor].
|
||||
* @param beforeRead Suspend lambda executed before the actual read operation.
|
||||
* @param afterRead Suspend lambda executed after the read operation, receiving the read value.
|
||||
* @param read Suspend function to perform the actual read from the device.
|
||||
* @return A [PropertyDelegateProvider] for the checked read-only property.
|
||||
*/
|
||||
public fun <T, D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.checkedReadOnlyProperty(
|
||||
converter: MetaConverter<T>,
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
beforeRead: suspend D.(propertyName: String) -> Unit = { _ -> }, // Default no-op
|
||||
afterRead: suspend D.(propertyName: String, value: T?) -> Unit = { _, _ -> }, // Default no-op
|
||||
read: suspend D.(propertyName: String) -> T?,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, T>>> {
|
||||
return property(
|
||||
converter = converter,
|
||||
descriptorBuilder = descriptorBuilder,
|
||||
name = name
|
||||
) { propertyName ->
|
||||
beforeRead(this, propertyName) // `this` is D
|
||||
val result = read(propertyName)
|
||||
afterRead(this, propertyName, result)
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A mutable property with the ability to embed processing logic before and after reading or writing a value.
|
||||
*
|
||||
* @param T The type of the property value.
|
||||
* @param D The type of the [ConfigurableCompositeControlComponent].
|
||||
* @param converter The [MetaConverter] for the property type.
|
||||
* @param name Optional explicit name for the property.
|
||||
* @param descriptorBuilder Lambda for customizing the [PropertyDescriptor].
|
||||
* @param beforeRead Suspend lambda executed before a read operation.
|
||||
* @param afterRead Suspend lambda executed after a read operation.
|
||||
* @param beforeWrite Suspend lambda executed before a write operation.
|
||||
* @param afterWrite Suspend lambda executed after a write operation.
|
||||
* @param read Suspend function for the actual read operation.
|
||||
* @param write Suspend function for the actual write operation.
|
||||
* @return A [PropertyDelegateProvider] for the checked mutable property.
|
||||
*/
|
||||
public fun <T, D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.checkedMutableProperty(
|
||||
converter: MetaConverter<T>,
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
beforeRead: suspend D.(propertyName: String) -> Unit = { _ -> },
|
||||
afterRead: suspend D.(propertyName: String, value: T?) -> Unit = { _, _ -> },
|
||||
beforeWrite: suspend D.(propertyName: String, newValue: T) -> Unit = { _, _ -> },
|
||||
afterWrite: suspend D.(propertyName: String, newValue: T) -> Unit = { _, _ -> },
|
||||
read: suspend D.(propertyName: String) -> T?,
|
||||
write: suspend D.(propertyName: String, value: T) -> Unit,
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, MutableDevicePropertySpec<D, T>>> {
|
||||
return mutableProperty(
|
||||
converter = converter,
|
||||
descriptorBuilder = descriptorBuilder,
|
||||
name = name,
|
||||
read = { propertyName ->
|
||||
beforeRead(this, propertyName)
|
||||
val result = read(propertyName)
|
||||
afterRead(this, propertyName, result)
|
||||
result
|
||||
},
|
||||
write = { propertyName, value ->
|
||||
beforeWrite(this, propertyName, value)
|
||||
write(propertyName, value)
|
||||
afterWrite(this, propertyName, value)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a device property that returns a [defaultValue] if the actual read operation
|
||||
* results in null. Can be read-only or mutable.
|
||||
*
|
||||
* @param T The type of the property value.
|
||||
* @param D The type of the [ConfigurableCompositeControlComponent].
|
||||
* @param converter The [MetaConverter] for the property type.
|
||||
* @param defaultValue The value to return if the read operation yields null.
|
||||
* @param name Optional explicit name for the property.
|
||||
* @param descriptorBuilder Lambda for customizing the [PropertyDescriptor].
|
||||
* @param read Suspend function for the actual read operation.
|
||||
* @param write Optional suspend function for write operations (if mutable).
|
||||
* @return A [PropertyDelegateProvider] for the property with a default value.
|
||||
*/
|
||||
public fun <T, D : ConfigurableCompositeControlComponent<D>> CompositeControlComponentSpec<D>.defaultValueProperty(
|
||||
converter: MetaConverter<T>,
|
||||
defaultValue: T,
|
||||
name: String? = null,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
read: suspend D.(propertyName: String) -> T?,
|
||||
write: (suspend D.(propertyName: String, value: T) -> Unit)? = null
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, T>>> {
|
||||
return if (write == null) {
|
||||
property(converter, descriptorBuilder, name) { propertyName ->
|
||||
read(propertyName) ?: defaultValue
|
||||
}
|
||||
} else {
|
||||
mutableProperty(
|
||||
converter,
|
||||
descriptorBuilder,
|
||||
name,
|
||||
read = { propertyName -> read(propertyName) ?: defaultValue },
|
||||
write = { propertyName, value -> write(propertyName, value) }
|
||||
)
|
||||
}
|
||||
}
|
430
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/api/CompositeDeviceSpec.kt
Normal file
430
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/api/CompositeDeviceSpec.kt
Normal file
@ -0,0 +1,430 @@
|
||||
package space.kscience.controls.spec.api
|
||||
|
||||
import kotlinx.coroutines.withContext
|
||||
import space.kscience.controls.api.*
|
||||
import space.kscience.controls.constructor.*
|
||||
import space.kscience.controls.spec.ConfigurableCompositeControlComponent
|
||||
import space.kscience.controls.spec.DeviceActionSpec
|
||||
import space.kscience.controls.spec.DevicePropertySpec
|
||||
import space.kscience.controls.spec.InternalDeviceAPI
|
||||
import space.kscience.controls.spec.MutableDevicePropertySpec
|
||||
import space.kscience.controls.api.DeviceConfigurationException
|
||||
import space.kscience.controls.spec.config.DeviceLifecycleConfigBuilder
|
||||
import space.kscience.controls.spec.name
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.error
|
||||
import space.kscience.dataforge.context.logger
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.meta.MetaConverter
|
||||
import space.kscience.dataforge.meta.MutableMeta
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.dataforge.names.asName
|
||||
import kotlin.properties.PropertyDelegateProvider
|
||||
import kotlin.properties.ReadOnlyProperty
|
||||
|
||||
/**
|
||||
* Interface defining a composite device specification with properties, actions, and child specs.
|
||||
*
|
||||
* @param D The device type this spec applies to.
|
||||
*/
|
||||
public interface CompositeDeviceSpec<D : ConfigurableCompositeControlComponent<D>> {
|
||||
/** Map of property specifications. */
|
||||
public val properties: Map<String, DevicePropertySpec<D, *>>
|
||||
/** Map of action specifications. */
|
||||
public val actions: Map<String, DeviceActionSpec<D, *, *>>
|
||||
/** Map of child component configurations. */
|
||||
public val childSpecs: Map<String, ChildComponentConfig<*>>
|
||||
|
||||
/**
|
||||
* Called when the device is opening (starting).
|
||||
*
|
||||
* @receiver The device instance.
|
||||
*/
|
||||
public suspend fun D.onOpen()
|
||||
|
||||
/**
|
||||
* Called when the device is closing (stopping).
|
||||
*
|
||||
* @receiver The device instance.
|
||||
*/
|
||||
public suspend fun D.onClose()
|
||||
|
||||
/**
|
||||
* Validates the [device]'s state or properties. Throws an exception if validation fails.
|
||||
*
|
||||
* @param device The device instance to validate.
|
||||
*/
|
||||
public fun validate(device: D)
|
||||
|
||||
/**
|
||||
* Registers a [deviceProperty].
|
||||
*
|
||||
* @param deviceProperty The property specification to register.
|
||||
* @return The registered property specification.
|
||||
*/
|
||||
public fun <T, P : DevicePropertySpec<D, T>> registerProperty(deviceProperty: P): P
|
||||
|
||||
/**
|
||||
* Registers a [deviceAction].
|
||||
*
|
||||
* @param deviceAction The action specification to register.
|
||||
* @return The registered action specification.
|
||||
*/
|
||||
public fun <I, O> registerAction(deviceAction: DeviceActionSpec<D, I, O>): DeviceActionSpec<D, I, O>
|
||||
|
||||
/**
|
||||
* Declares a read-only property with the given [converter].
|
||||
*
|
||||
* @param converter The meta converter for the property.
|
||||
* @param descriptorBuilder Optional builder for the property descriptor.
|
||||
* @param name An optional name override.
|
||||
* @param read A suspend function to read the property value.
|
||||
* @return A property delegate provider for the declared property.
|
||||
*/
|
||||
public fun <T> property(
|
||||
converter: MetaConverter<T>,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> T?
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, T>>>
|
||||
|
||||
/**
|
||||
* Declares a mutable property with the given [converter].
|
||||
*
|
||||
* @param converter The meta converter for the property.
|
||||
* @param descriptorBuilder Optional builder for the property descriptor.
|
||||
* @param name An optional name override.
|
||||
* @param read A suspend function to read the property value.
|
||||
* @param write A suspend function to write the property value.
|
||||
* @return A property delegate provider for the declared mutable property.
|
||||
*/
|
||||
public fun <T> mutableProperty(
|
||||
converter: MetaConverter<T>,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> T?,
|
||||
write: suspend D.(propertyName: String, value: T) -> Unit
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, MutableDevicePropertySpec<D, T>>>
|
||||
|
||||
/**
|
||||
* Declares an action with the specified input and output converters and execution block.
|
||||
*
|
||||
* @param inputConverter The meta converter for the action input.
|
||||
* @param outputConverter The meta converter for the action output.
|
||||
* @param descriptorBuilder Optional builder for the action descriptor.
|
||||
* @param name An optional name override.
|
||||
* @param execute The suspend function to execute the action.
|
||||
* @return A property delegate provider for the declared action.
|
||||
*/
|
||||
public fun <I, O> action(
|
||||
inputConverter: MetaConverter<I>,
|
||||
outputConverter: MetaConverter<O>,
|
||||
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
execute: suspend D.(input: I) -> O
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DeviceActionSpec<D, I, O>>>
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of [CompositeDeviceSpec].
|
||||
*
|
||||
* @param D The device type.
|
||||
* @property registry Optional [ComponentRegistry] for looking up child specifications.
|
||||
*/
|
||||
@OptIn(InternalDeviceAPI::class)
|
||||
public open class CompositeControlComponentSpec<D : ConfigurableCompositeControlComponent<D>>(
|
||||
public val registry: ComponentRegistry? = null
|
||||
) : CompositeDeviceSpec<D> {
|
||||
private val propertyMap = hashMapOf<String, DevicePropertySpec<D, *>>()
|
||||
private val actionMap = hashMapOf<String, DeviceActionSpec<D, *, *>>()
|
||||
private val childConfigMap = mutableMapOf<String, ChildComponentConfig<*>>()
|
||||
private val stateMap = mutableMapOf<String, DeviceState<*>>()
|
||||
|
||||
override val properties: Map<String, DevicePropertySpec<D, *>> get() = propertyMap
|
||||
override val actions: Map<String, DeviceActionSpec<D, *, *>> get() = actionMap
|
||||
override val childSpecs: Map<String, ChildComponentConfig<*>> get() = childConfigMap
|
||||
|
||||
/**
|
||||
* Map of state objects defined in this spec.
|
||||
*/
|
||||
public val states: Map<String, DeviceState<*>> get() = stateMap
|
||||
|
||||
override suspend fun D.onOpen() {
|
||||
// Default implementation is no-op.
|
||||
}
|
||||
|
||||
override suspend fun D.onClose() {
|
||||
// Default implementation is no-op.
|
||||
}
|
||||
|
||||
override fun validate(device: D) {
|
||||
validateSpec(device)
|
||||
}
|
||||
|
||||
override fun <T, P : DevicePropertySpec<D, T>> registerProperty(deviceProperty: P): P {
|
||||
if (propertyMap.containsKey(deviceProperty.name)) {
|
||||
throw DeviceConfigurationException("Property ${deviceProperty.name} is already registered.")
|
||||
}
|
||||
propertyMap[deviceProperty.name] = deviceProperty
|
||||
return deviceProperty
|
||||
}
|
||||
|
||||
override fun <I, O> registerAction(deviceAction: DeviceActionSpec<D, I, O>): DeviceActionSpec<D, I, O> {
|
||||
if (actionMap.containsKey(deviceAction.name)) {
|
||||
throw DeviceConfigurationException("Action ${deviceAction.name} is already registered.")
|
||||
}
|
||||
actionMap[deviceAction.name] = deviceAction
|
||||
return deviceAction
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a DeviceState associated with this component.
|
||||
*/
|
||||
public fun <T, S : DeviceState<T>> registerState(name: String, state: S): S {
|
||||
if (stateMap.containsKey(name)) {
|
||||
throw DeviceConfigurationException("State $name is already registered.")
|
||||
}
|
||||
stateMap[name] = state
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a property descriptor.
|
||||
*/
|
||||
private fun createPropertyDescriptor(
|
||||
propertyName: String,
|
||||
converter: MetaConverter<*>,
|
||||
mutable: Boolean,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit
|
||||
): PropertyDescriptor = propertyDescriptor(propertyName) {
|
||||
this.mutable = mutable
|
||||
converter.descriptor?.let { conv -> metaDescriptor { from(conv) } }
|
||||
descriptorBuilder()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an action descriptor.
|
||||
*/
|
||||
private fun createActionDescriptor(
|
||||
actionName: String,
|
||||
inputConverter: MetaConverter<*>,
|
||||
outputConverter: MetaConverter<*>,
|
||||
descriptorBuilder: ActionDescriptorBuilder.() -> Unit
|
||||
): ActionDescriptor = actionDescriptor(actionName) {
|
||||
inputConverter.descriptor?.let { convIn -> inputMeta { from(convIn) } }
|
||||
outputConverter.descriptor?.let { convOut -> outputMeta { from(convOut) } }
|
||||
descriptorBuilder()
|
||||
}
|
||||
|
||||
override fun <T> property(
|
||||
converter: MetaConverter<T>,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit,
|
||||
name: String?,
|
||||
read: suspend D.(propertyName: String) -> T?
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, T>>> =
|
||||
PropertyDelegateProvider { _, property ->
|
||||
val propertyName = name ?: property.name
|
||||
val descriptor = createPropertyDescriptor(
|
||||
propertyName, converter, mutable = false, descriptorBuilder
|
||||
)
|
||||
val devProp = registerProperty(object : DevicePropertySpec<D, T> {
|
||||
override val descriptor: PropertyDescriptor = descriptor
|
||||
override val converter: MetaConverter<T> = converter
|
||||
override suspend fun read(device: D): T? =
|
||||
withContext(device.coroutineContext) { device.read(propertyName) }
|
||||
})
|
||||
ReadOnlyProperty { _, _ -> devProp }
|
||||
}
|
||||
|
||||
override fun <T> mutableProperty(
|
||||
converter: MetaConverter<T>,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit,
|
||||
name: String?,
|
||||
read: suspend D.(propertyName: String) -> T?,
|
||||
write: suspend D.(propertyName: String, value: T) -> Unit
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
|
||||
PropertyDelegateProvider { _, property ->
|
||||
val propertyName = name ?: property.name
|
||||
val descriptor = createPropertyDescriptor(
|
||||
propertyName, converter, mutable = true, descriptorBuilder
|
||||
)
|
||||
val devProp = registerProperty(object : MutableDevicePropertySpec<D, T> {
|
||||
override val descriptor: PropertyDescriptor = descriptor
|
||||
override val converter: MetaConverter<T> = converter
|
||||
override suspend fun read(device: D): T? =
|
||||
withContext(device.coroutineContext) { device.read(propertyName) }
|
||||
|
||||
override suspend fun write(device: D, value: T) =
|
||||
withContext(device.coroutineContext) { device.write(propertyName, value) }
|
||||
})
|
||||
ReadOnlyProperty { _, _ -> devProp }
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares a child specification, using [fallbackSpec] or retrieving from [registry].
|
||||
*
|
||||
* @param fallbackSpec The default spec if not found in registry.
|
||||
* @param specKeyInRegistry Optional registry key.
|
||||
* @param childDeviceName Optional explicit name for the child device.
|
||||
* @param metaBuilder Optional lambda to build child [Meta].
|
||||
* @param configBuilder Lambda to configure the child's [DeviceLifecycleConfig].
|
||||
*/
|
||||
public fun <CDS : CompositeControlComponentSpec<CD>, CD : ConfigurableCompositeControlComponent<CD>> childConfig(
|
||||
fallbackSpec: CDS,
|
||||
specKeyInRegistry: Name? = null,
|
||||
childDeviceName: Name? = null,
|
||||
metaBuilder: (MutableMeta.() -> Unit)? = null,
|
||||
configBuilder: DeviceLifecycleConfigBuilder.() -> Unit = {}
|
||||
): PropertyDelegateProvider<CompositeControlComponentSpec<D>, ReadOnlyProperty<CompositeControlComponentSpec<D>, ChildComponentConfig<CD>>> =
|
||||
PropertyDelegateProvider { thisRef, property ->
|
||||
val registryKey = specKeyInRegistry ?: property.name.asName()
|
||||
val cName = childDeviceName ?: property.name.asName()
|
||||
val config = DeviceLifecycleConfigBuilder().apply(configBuilder).build()
|
||||
val meta = metaBuilder?.let { Meta(it) }
|
||||
val fromRegistry: CompositeControlComponentSpec<CD>? = thisRef.registry?.getSpec(registryKey)
|
||||
val foundSpec: CompositeControlComponentSpec<CD> = fromRegistry ?: fallbackSpec
|
||||
val mapKey = cName.toString()
|
||||
|
||||
if (thisRef.childConfigMap.containsKey(mapKey)) {
|
||||
throw DeviceConfigurationException("Child config $mapKey already registered.")
|
||||
}
|
||||
|
||||
val childConfig = object : ChildComponentConfig<CD> {
|
||||
override val spec: CompositeControlComponentSpec<CD> = foundSpec
|
||||
override val config: space.kscience.controls.spec.config.DeviceLifecycleConfig = config
|
||||
override val meta: Meta? = meta
|
||||
override val name: Name = cName
|
||||
}
|
||||
|
||||
thisRef.childConfigMap[mapKey] = childConfig
|
||||
ReadOnlyProperty { _, _ -> childConfig }
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares child component configuration with direct specification.
|
||||
*/
|
||||
public fun <CD : ConfigurableCompositeControlComponent<CD>> childConfig(
|
||||
spec: CompositeControlComponentSpec<CD>,
|
||||
name: Name,
|
||||
configBuilder: ChildComponentConfigBuilder<CD>.() -> Unit
|
||||
): PropertyDelegateProvider<CompositeControlComponentSpec<D>, ReadOnlyProperty<CompositeControlComponentSpec<D>, ChildComponentConfig<CD>>> =
|
||||
PropertyDelegateProvider { thisRef, _ ->
|
||||
val builder = ChildComponentConfig.builder(spec, name).apply(configBuilder)
|
||||
val childComponentConfig = builder.build()
|
||||
val mapKey = name.toString()
|
||||
|
||||
if (thisRef.childConfigMap.containsKey(mapKey)) {
|
||||
throw DeviceConfigurationException("Child config $mapKey already registered.")
|
||||
}
|
||||
|
||||
thisRef.childConfigMap[mapKey] = childComponentConfig
|
||||
ReadOnlyProperty { _, _ -> childComponentConfig }
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares child component, returning only [CompositeControlComponentSpec].
|
||||
*/
|
||||
public fun <CDS : CompositeControlComponentSpec<CD>, CD : ConfigurableCompositeControlComponent<CD>> childSpec(
|
||||
fallbackSpec: CDS,
|
||||
specKeyInRegistry: Name? = null,
|
||||
childDeviceName: Name? = null,
|
||||
metaBuilder: (MutableMeta.() -> Unit)? = null,
|
||||
configBuilder: DeviceLifecycleConfigBuilder.() -> Unit = {}
|
||||
): PropertyDelegateProvider<CompositeControlComponentSpec<D>, ReadOnlyProperty<CompositeControlComponentSpec<D>, CompositeControlComponentSpec<CD>>> =
|
||||
PropertyDelegateProvider { thisRef, property ->
|
||||
val delegate = thisRef.childConfig(fallbackSpec, specKeyInRegistry, childDeviceName, metaBuilder, configBuilder)
|
||||
.provideDelegate(thisRef, property)
|
||||
|
||||
ReadOnlyProperty { _, _ -> delegate.getValue(thisRef, property).spec }
|
||||
}
|
||||
|
||||
/**
|
||||
* Declares child component with direct specification, returning only [CompositeControlComponentSpec].
|
||||
*/
|
||||
public fun <CD : ConfigurableCompositeControlComponent<CD>> childSpec(
|
||||
spec: CompositeControlComponentSpec<CD>,
|
||||
name: Name,
|
||||
configBuilder: ChildComponentConfigBuilder<CD>.() -> Unit
|
||||
): PropertyDelegateProvider<CompositeControlComponentSpec<D>, ReadOnlyProperty<CompositeControlComponentSpec<D>, CompositeControlComponentSpec<CD>>> =
|
||||
PropertyDelegateProvider { thisRef, property ->
|
||||
val delegate = thisRef.childConfig(spec, name, configBuilder).provideDelegate(thisRef, property)
|
||||
ReadOnlyProperty { _, _ -> delegate.getValue(thisRef, property).spec }
|
||||
}
|
||||
|
||||
override fun <I, O> action(
|
||||
inputConverter: MetaConverter<I>,
|
||||
outputConverter: MetaConverter<O>,
|
||||
descriptorBuilder: ActionDescriptorBuilder.() -> Unit,
|
||||
name: String?,
|
||||
execute: suspend D.(input: I) -> O
|
||||
): PropertyDelegateProvider<CompositeDeviceSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DeviceActionSpec<D, I, O>>> =
|
||||
PropertyDelegateProvider { _, property ->
|
||||
val actionName = name ?: property.name
|
||||
val descriptor = createActionDescriptor(
|
||||
actionName, inputConverter, outputConverter, descriptorBuilder
|
||||
)
|
||||
val devAction = registerAction(object : DeviceActionSpec<D, I, O> {
|
||||
override val descriptor: ActionDescriptor = descriptor
|
||||
override val inputConverter: MetaConverter<I> = inputConverter
|
||||
override val outputConverter: MetaConverter<O> = outputConverter
|
||||
|
||||
override suspend fun execute(device: D, input: I): O = try {
|
||||
withContext(device.coroutineContext) { device.execute(input) }
|
||||
} catch (ex: Exception) {
|
||||
device.logger.error(ex) { "Error executing action $actionName on ${device.id}" }
|
||||
throw ex
|
||||
}
|
||||
})
|
||||
|
||||
ReadOnlyProperty { _, _ -> devAction }
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a [DeviceState] that will be linked to a property.
|
||||
*/
|
||||
public fun <T> stateProperty(
|
||||
state: DeviceState<T>,
|
||||
converter: MetaConverter<T>,
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null
|
||||
): PropertyDelegateProvider<CompositeControlComponentSpec<D>, ReadOnlyProperty<CompositeDeviceSpec<D>, DevicePropertySpec<D, T>>> =
|
||||
PropertyDelegateProvider { _, property ->
|
||||
val propertyName = name ?: property.name
|
||||
|
||||
// Ensure the state is registered under the property's name if not already or if different.
|
||||
if (!stateMap.containsKey(propertyName) || stateMap[propertyName] !== state) {
|
||||
registerState(propertyName, state)
|
||||
}
|
||||
|
||||
val descriptor = createPropertyDescriptor(propertyName, converter, state is MutableDeviceState<*>, descriptorBuilder)
|
||||
|
||||
val devProp = if (state is MutableDeviceState<T>) {
|
||||
registerProperty(object : MutableDevicePropertySpec<D, T> {
|
||||
override val descriptor: PropertyDescriptor = descriptor
|
||||
override val converter: MetaConverter<T> = converter
|
||||
override suspend fun read(device: D): T? = state.value
|
||||
override suspend fun write(device: D, value: T) { state.value = value }
|
||||
})
|
||||
} else {
|
||||
registerProperty(object : DevicePropertySpec<D, T> {
|
||||
override val descriptor: PropertyDescriptor = descriptor
|
||||
override val converter: MetaConverter<T> = converter
|
||||
override suspend fun read(device: D): T? = state.value
|
||||
})
|
||||
}
|
||||
|
||||
ReadOnlyProperty { _, _ -> devProp }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Abstract base class for specifying a [ConfigurableCompositeControlComponent].
|
||||
* This simplifies creating specs for concrete composite device types by handling the factory.
|
||||
*
|
||||
* @param D The device type.
|
||||
* @property deviceFactory Factory function to create the device instance.
|
||||
*/
|
||||
public abstract class DeviceSpecification<D : ConfigurableCompositeControlComponent<D>>(
|
||||
public val deviceFactory: (Context, Meta) -> D
|
||||
) : CompositeControlComponentSpec<D>()
|
101
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/config/DeviceHubConfig.kt
Normal file
101
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/config/DeviceHubConfig.kt
Normal file
@ -0,0 +1,101 @@
|
||||
package space.kscience.controls.spec.config
|
||||
|
||||
import space.kscience.dataforge.context.AbstractPlugin
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.PluginFactory
|
||||
import space.kscience.dataforge.context.PluginTag
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.meta.get
|
||||
import space.kscience.dataforge.meta.int
|
||||
import space.kscience.dataforge.meta.string
|
||||
import space.kscience.controls.spec.utils.ParsingUtils
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
|
||||
/**
|
||||
* Configuration for the [DeviceHubManager].
|
||||
*
|
||||
* @property messageBufferSize Default buffer size for device message flows if not specified per device.
|
||||
* @property defaultConcurrencyLevel Default concurrency level for the hub's dispatcher.
|
||||
* @property defaultStartTimeout Default timeout for starting devices if not specified in their [DeviceLifecycleConfig].
|
||||
* @property defaultStopTimeout Default timeout for stopping devices if not specified in their [DeviceLifecycleConfig].
|
||||
* @property resourceCleanupInterval Interval for running cleanup tasks (e.g., for circuit breakers, restart states).
|
||||
* @property resourceMaxIdleTime Maximum idle time for resources before they are considered for cleanup.
|
||||
* @property healthRestartConcurrency Concurrency limit for restarts triggered by health checks.
|
||||
* @property meta The raw [Meta] object from which this configuration was built, or an empty Meta.
|
||||
*/
|
||||
public class DeviceHubConfig(
|
||||
public val messageBufferSize: Int = MessageBusConfig.Factory.Defaults.DEVICE_MESSAGE_FLOW_BUFFER_CAPACITY,
|
||||
public val defaultConcurrencyLevel: Int = Defaults.CONCURRENCY_LEVEL,
|
||||
public val defaultStartTimeout: Duration = DeviceLifecycleConfig.Factory.Defaults.DEVICE_START_TIMEOUT,
|
||||
public val defaultStopTimeout: Duration = DeviceLifecycleConfig.Factory.Defaults.DEVICE_STOP_TIMEOUT,
|
||||
public val resourceCleanupInterval: Duration = Defaults.RESOURCE_CLEANUP_INTERVAL,
|
||||
public val resourceMaxIdleTime: Duration = Defaults.RESOURCE_MAX_IDLE_TIME,
|
||||
public val healthRestartConcurrency: Int = Defaults.HEALTH_CHECK_RESTART_CONCURRENCY,
|
||||
public val defaultActionExecutionTimeout: Duration = DeviceLifecycleConfig.Factory.Defaults.DEVICE_ACTION_EXECUTION_TIMEOUT,
|
||||
override val meta: Meta = Meta.EMPTY
|
||||
) : AbstractPlugin() {
|
||||
override val tag: PluginTag get() = Factory.tag
|
||||
|
||||
init {
|
||||
require(messageBufferSize > 0) { "Message buffer size must be positive." }
|
||||
require(defaultConcurrencyLevel > 0) { "Default concurrency level must be positive." }
|
||||
require(healthRestartConcurrency > 0) { "Health restart concurrency must be positive." }
|
||||
require(!resourceCleanupInterval.isNegative()) { "Resource cleanup interval must be non-negative." }
|
||||
require(!resourceMaxIdleTime.isNegative()) { "Resource max idle time must be non-negative." }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this configuration object to a [Meta] representation.
|
||||
*/
|
||||
public override fun toMeta(): Meta = Meta {
|
||||
"messageBufferSize" put messageBufferSize
|
||||
"defaultConcurrencyLevel" put defaultConcurrencyLevel
|
||||
"defaultStartTimeout" put defaultStartTimeout.toString()
|
||||
"defaultStopTimeout" put defaultStopTimeout.toString()
|
||||
"resourceCleanupInterval" put resourceCleanupInterval.toString()
|
||||
"resourceMaxIdleTime" put resourceMaxIdleTime.toString()
|
||||
"healthRestartConcurrency" put healthRestartConcurrency
|
||||
"defaultActionExecutionTimeout" put defaultActionExecutionTimeout.toString()
|
||||
if (this@DeviceHubConfig.meta.items.isNotEmpty()) {
|
||||
"sourceMeta" put this@DeviceHubConfig.meta
|
||||
}
|
||||
}
|
||||
|
||||
public companion object Factory : PluginFactory<DeviceHubConfig> {
|
||||
override val tag: PluginTag = PluginTag("controls.device.hub.config", PluginTag.DATAFORGE_GROUP)
|
||||
|
||||
public object Defaults {
|
||||
public const val CONCURRENCY_LEVEL: Int = 4
|
||||
public const val HEALTH_CHECK_RESTART_CONCURRENCY: Int = 2
|
||||
public val RESOURCE_CLEANUP_INTERVAL: Duration = 15.minutes
|
||||
public val RESOURCE_MAX_IDLE_TIME: Duration = 60.minutes
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a [DeviceHubConfig] from a [Context] and [Meta].
|
||||
* Values from [meta] override defaults.
|
||||
*/
|
||||
override fun build(context: Context, meta: Meta): DeviceHubConfig = fromMeta(meta)
|
||||
|
||||
/**
|
||||
* Creates a [DeviceHubConfig] instance from a [Meta] object.
|
||||
*/
|
||||
public fun fromMeta(meta: Meta): DeviceHubConfig = DeviceHubConfig(
|
||||
meta["messageBufferSize"].int ?: MessageBusConfig.Factory.Defaults.DEVICE_MESSAGE_FLOW_BUFFER_CAPACITY,
|
||||
meta["defaultConcurrencyLevel"].int ?: Defaults.CONCURRENCY_LEVEL,
|
||||
meta["defaultStartTimeout"]?.string?.let { ParsingUtils.parseDurationOrNull(it) }
|
||||
?: DeviceLifecycleConfig.Factory.Defaults.DEVICE_START_TIMEOUT,
|
||||
meta["defaultStopTimeout"]?.string?.let { ParsingUtils.parseDurationOrNull(it) }
|
||||
?: DeviceLifecycleConfig.Factory.Defaults.DEVICE_STOP_TIMEOUT,
|
||||
meta["resourceCleanupInterval"]?.string?.let { ParsingUtils.parseDurationOrNull(it) }
|
||||
?: Defaults.RESOURCE_CLEANUP_INTERVAL,
|
||||
meta["resourceMaxIdleTime"]?.string?.let { ParsingUtils.parseDurationOrNull(it) }
|
||||
?: Defaults.RESOURCE_MAX_IDLE_TIME,
|
||||
meta["healthRestartConcurrency"].int ?: Defaults.HEALTH_CHECK_RESTART_CONCURRENCY,
|
||||
meta["defaultActionExecutionTimeout"]?.string?.let { ParsingUtils.parseDurationOrNull(it) }
|
||||
?: DeviceLifecycleConfig.Factory.Defaults.DEVICE_ACTION_EXECUTION_TIMEOUT,
|
||||
meta
|
||||
)
|
||||
}
|
||||
}
|
255
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/config/DeviceLifecycleConfig.kt
Normal file
255
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/config/DeviceLifecycleConfig.kt
Normal file
@ -0,0 +1,255 @@
|
||||
package space.kscience.controls.spec.config
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import space.kscience.controls.spec.model.ChildDeviceErrorHandler
|
||||
import space.kscience.controls.spec.model.LifecycleMode
|
||||
import space.kscience.controls.spec.utils.ParsingUtils
|
||||
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.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Configuration for a device's lifecycle, including timeouts and error handling.
|
||||
*
|
||||
* @property lifecycleMode The [LifecycleMode] of the device.
|
||||
* @property messageBuffer Buffer size for the device's message flow.
|
||||
* @property startDelay Delay before starting the device.
|
||||
* @property startTimeout Timeout for starting the device. Nullable, system default ([DeviceHubConfig]) will be used if null.
|
||||
* @property stopTimeout Timeout for stopping the device. Nullable, system default ([DeviceHubConfig]) will be used if null.
|
||||
* @property coroutineScope Optional [CoroutineScope] for the device. If null, a scope will be created by the manager.
|
||||
* @property dispatcher Optional [CoroutineDispatcher] for the device's operations. If null, manager's default will be used.
|
||||
* @property onError The [ChildDeviceErrorHandler] strategy to apply if this device is a child and encounters an error.
|
||||
* @property restartPolicy The [RestartPolicy] to apply if [onError] is set to [ChildDeviceErrorHandler.RESTART].
|
||||
*/
|
||||
public data class DeviceLifecycleConfig(
|
||||
val lifecycleMode: LifecycleMode = LifecycleMode.LINKED,
|
||||
val messageBuffer: Int = MessageBusConfig.Factory.Defaults.DEVICE_MESSAGE_FLOW_BUFFER_CAPACITY,
|
||||
val startDelay: Duration = Duration.ZERO,
|
||||
val startTimeout: Duration? = Defaults.DEVICE_START_TIMEOUT,
|
||||
val stopTimeout: Duration? = Defaults.DEVICE_STOP_TIMEOUT,
|
||||
val coroutineScope: CoroutineScope? = null,
|
||||
val dispatcher: CoroutineDispatcher? = null,
|
||||
val onError: ChildDeviceErrorHandler = ChildDeviceErrorHandler.RESTART,
|
||||
val restartPolicy: RestartPolicy = RestartPolicy.Factory.Defaults.DEFAULT
|
||||
) {
|
||||
init {
|
||||
require(messageBuffer > 0) { "Message buffer size must be positive." }
|
||||
startTimeout?.let { require(!it.isNegative()) { "Start timeout must not be negative." } }
|
||||
stopTimeout?.let { require(!it.isNegative()) { "Stop timeout must not be negative." } }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this configuration object to a [Meta] representation.
|
||||
* Note: `coroutineScope`, `dispatcher` are not serialized
|
||||
* as they are runtime objects.
|
||||
*/
|
||||
public fun toMeta(): Meta = Meta {
|
||||
"lifecycleMode" put lifecycleMode.name
|
||||
"messageBuffer" put messageBuffer
|
||||
"startDelay" put startDelay.toString()
|
||||
startTimeout?.let { "startTimeout" put it.toString() }
|
||||
stopTimeout?.let { "stopTimeout" put it.toString() }
|
||||
"onError" put onError.name
|
||||
"restartPolicy" put restartPolicy.toMeta()
|
||||
}
|
||||
|
||||
public companion object Factory {
|
||||
public object Defaults {
|
||||
public val DEVICE_START_TIMEOUT: Duration = 30.seconds
|
||||
public val DEVICE_STOP_TIMEOUT: Duration = 10.seconds
|
||||
public val DEVICE_ACTION_EXECUTION_TIMEOUT: Duration = 60.seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [DeviceLifecycleConfig] instance from a [Meta] object.
|
||||
* Note: `coroutineScope`, `dispatcher` cannot be deserialized
|
||||
* from Meta and must be set programmatically if needed.
|
||||
*/
|
||||
public fun fromMeta(meta: Meta): DeviceLifecycleConfig {
|
||||
val lifecycleMode = meta["lifecycleMode"]?.string?.let {
|
||||
try { LifecycleMode.valueOf(it.uppercase()) } catch (_: Exception) { LifecycleMode.LINKED }
|
||||
} ?: LifecycleMode.LINKED
|
||||
|
||||
val messageBuffer = meta["messageBuffer"].int ?: MessageBusConfig.Factory.Defaults.DEVICE_MESSAGE_FLOW_BUFFER_CAPACITY
|
||||
val startDelay = meta["startDelay"]?.string?.let { ParsingUtils.parseDurationOrNull(it) } ?: Duration.ZERO
|
||||
val startTimeout = meta["startTimeout"]?.string?.let { ParsingUtils.parseDurationOrNull(it) }
|
||||
val stopTimeout = meta["stopTimeout"]?.string?.let { ParsingUtils.parseDurationOrNull(it) }
|
||||
|
||||
val onError = meta["onError"]?.string?.let {
|
||||
try { ChildDeviceErrorHandler.valueOf(it.uppercase()) } catch (_: Exception) { ChildDeviceErrorHandler.RESTART }
|
||||
} ?: ChildDeviceErrorHandler.RESTART
|
||||
|
||||
val restartPolicy = meta["restartPolicy"]?.let { RestartPolicy.fromMeta(it) }
|
||||
?: RestartPolicy.Factory.Defaults.DEFAULT
|
||||
|
||||
return DeviceLifecycleConfig(
|
||||
lifecycleMode = lifecycleMode,
|
||||
messageBuffer = messageBuffer,
|
||||
startDelay = startDelay,
|
||||
startTimeout = startTimeout,
|
||||
stopTimeout = stopTimeout,
|
||||
onError = onError,
|
||||
restartPolicy = restartPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder class for constructing [DeviceLifecycleConfig] instances fluently.
|
||||
*/
|
||||
public class DeviceLifecycleConfigBuilder {
|
||||
public var lifecycleMode: LifecycleMode = LifecycleMode.LINKED
|
||||
public var messageBuffer: Int = MessageBusConfig.Factory.Defaults.DEVICE_MESSAGE_FLOW_BUFFER_CAPACITY
|
||||
public var startDelay: Duration = Duration.ZERO
|
||||
public var startTimeout: Duration? = DeviceLifecycleConfig.Factory.Defaults.DEVICE_START_TIMEOUT
|
||||
public var stopTimeout: Duration? = DeviceLifecycleConfig.Factory.Defaults.DEVICE_STOP_TIMEOUT
|
||||
public var coroutineScope: CoroutineScope? = null
|
||||
public var dispatcher: CoroutineDispatcher? = null
|
||||
public var onError: ChildDeviceErrorHandler = ChildDeviceErrorHandler.RESTART
|
||||
public var restartPolicy: RestartPolicy = RestartPolicy.Factory.Defaults.DEFAULT
|
||||
|
||||
/** Sets linked lifecycle mode (child device is linked to parent). */
|
||||
public fun linkedMode(): DeviceLifecycleConfigBuilder = apply { lifecycleMode = LifecycleMode.LINKED }
|
||||
/** Sets independent lifecycle mode (child device is independent from parent). */
|
||||
public fun independentMode(): DeviceLifecycleConfigBuilder = apply { lifecycleMode = LifecycleMode.INDEPENDENT }
|
||||
|
||||
/** Sets message buffer size. */
|
||||
public fun messageBuffer(size: Int): DeviceLifecycleConfigBuilder = apply {
|
||||
if (size <= 0) throw IllegalArgumentException("Message buffer size must be positive.")
|
||||
messageBuffer = size
|
||||
}
|
||||
|
||||
/** Sets start delay. */
|
||||
public fun startDelay(delay: Duration): DeviceLifecycleConfigBuilder = apply { startDelay = delay }
|
||||
|
||||
/** Helper class for configuring timeouts in a DSL style. */
|
||||
public class TimeoutConfig {
|
||||
public var start: Duration? = null
|
||||
public var stop: Duration? = null
|
||||
}
|
||||
|
||||
/** Configures start and stop timeouts using a nested DSL. */
|
||||
public fun timeouts(block: TimeoutConfig.() -> Unit): DeviceLifecycleConfigBuilder = apply {
|
||||
val tc = TimeoutConfig().apply {
|
||||
start = this@DeviceLifecycleConfigBuilder.startTimeout
|
||||
stop = this@DeviceLifecycleConfigBuilder.stopTimeout
|
||||
}.apply(block)
|
||||
|
||||
startTimeout = tc.start?.also {
|
||||
if (it.isNegative()) throw IllegalArgumentException("Start timeout must not be negative.")
|
||||
}
|
||||
stopTimeout = tc.stop?.also {
|
||||
if (it.isNegative()) throw IllegalArgumentException("Stop timeout must not be negative.")
|
||||
}
|
||||
}
|
||||
|
||||
/** Sets start timeout. */
|
||||
public fun startTimeout(timeout: Duration?): DeviceLifecycleConfigBuilder = apply {
|
||||
timeout?.let { if (it.isNegative()) throw IllegalArgumentException("Start timeout must not be negative.") }
|
||||
startTimeout = timeout
|
||||
}
|
||||
|
||||
/** Sets stop timeout. */
|
||||
public fun stopTimeout(timeout: Duration?): DeviceLifecycleConfigBuilder = apply {
|
||||
timeout?.let { if (it.isNegative()) throw IllegalArgumentException("Stop timeout must not be negative.") }
|
||||
stopTimeout = timeout
|
||||
}
|
||||
|
||||
/** Sets the [CoroutineScope] for the device. */
|
||||
public fun coroutineScope(scope: CoroutineScope?): DeviceLifecycleConfigBuilder = apply { coroutineScope = scope }
|
||||
/** Sets the [CoroutineDispatcher] for the device. */
|
||||
public fun dispatcher(disp: CoroutineDispatcher?): DeviceLifecycleConfigBuilder = apply { dispatcher = disp }
|
||||
|
||||
/** Helper class for configuring error handling in a DSL style. */
|
||||
public class ErrorHandlingConfig {
|
||||
public var strategy: ChildDeviceErrorHandler? = null
|
||||
public var policy: RestartPolicy? = null
|
||||
}
|
||||
|
||||
/** Configures error handling strategy and restart policy using a nested DSL. */
|
||||
public fun errorHandling(block: ErrorHandlingConfig.() -> Unit): DeviceLifecycleConfigBuilder = apply {
|
||||
val ehc = ErrorHandlingConfig().apply {
|
||||
strategy = this@DeviceLifecycleConfigBuilder.onError
|
||||
policy = this@DeviceLifecycleConfigBuilder.restartPolicy
|
||||
}.apply(block)
|
||||
|
||||
ehc.strategy?.let { onError = it }
|
||||
ehc.policy?.let { restartPolicy = it }
|
||||
}
|
||||
|
||||
/** Sets the [RestartPolicy] directly. */
|
||||
public fun restartPolicy(policy: RestartPolicy): DeviceLifecycleConfigBuilder = apply {
|
||||
this.restartPolicy = policy
|
||||
}
|
||||
|
||||
/** Configures the [RestartPolicy] using a nested DSL via [RestartPolicyBuilder]. */
|
||||
public fun restartPolicy(block: RestartPolicyBuilder.() -> Unit): DeviceLifecycleConfigBuilder = apply {
|
||||
val builder = RestartPolicyBuilder(this.restartPolicy) // Initialize with current policy
|
||||
builder.apply(block)
|
||||
this.restartPolicy = builder.build()
|
||||
}
|
||||
|
||||
/** Builds the [DeviceLifecycleConfig] instance. */
|
||||
public fun build(): DeviceLifecycleConfig = DeviceLifecycleConfig(
|
||||
lifecycleMode, messageBuffer, startDelay, startTimeout, stopTimeout,
|
||||
coroutineScope, dispatcher, onError, restartPolicy
|
||||
)
|
||||
|
||||
public companion object {
|
||||
/** Creates a new [DeviceLifecycleConfigBuilder]. */
|
||||
public fun builder(): DeviceLifecycleConfigBuilder = DeviceLifecycleConfigBuilder()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for creating [RestartPolicy] instances fluently, typically used within
|
||||
* the [DeviceLifecycleConfigBuilder] DSL.
|
||||
*
|
||||
* @param initialPolicy An optional initial [RestartPolicy] to pre-fill the builder.
|
||||
*/
|
||||
public class RestartPolicyBuilder internal constructor(initialPolicy: RestartPolicy?) {
|
||||
public var maxAttempts: Int = initialPolicy?.maxAttempts ?: RestartPolicy.Factory.Defaults.DEFAULT_RESTART_POLICY_MAX_ATTEMPTS
|
||||
public var delayBetweenAttempts: Duration = initialPolicy?.delayBetweenAttempts ?: RestartPolicy.Factory.Defaults.DEFAULT_RESTART_POLICY_DELAY
|
||||
public var resetOnSuccess: Boolean = initialPolicy?.resetOnSuccess ?: true
|
||||
public var strategy: RestartStrategy = initialPolicy?.strategy ?: RestartStrategy.Linear
|
||||
public var circuitBreaker: CircuitBreakerConfig? = initialPolicy?.circuitBreaker
|
||||
|
||||
/** Sets the restart strategy to [RestartStrategy.Linear]. */
|
||||
public fun linearStrategy() { strategy = RestartStrategy.Linear }
|
||||
/** Sets the restart strategy to [RestartStrategy.ExponentialBackoff]. */
|
||||
public fun exponentialBackoffStrategy() { strategy = RestartStrategy.ExponentialBackoff }
|
||||
/** Sets the restart strategy to [RestartStrategy.Fibonacci]. */
|
||||
public fun fibonacciStrategy() { strategy = RestartStrategy.Fibonacci }
|
||||
|
||||
/** Helper class for configuring [CircuitBreakerConfig] in a DSL style. */
|
||||
public class CircuitBreakerPolicyConfigBuilder internal constructor(initialCbConfig: CircuitBreakerConfig?) {
|
||||
public var failureThreshold: Int = initialCbConfig?.failureThreshold ?: CircuitBreakerConfig.Factory.Defaults.DEFAULT_FAILURE_THRESHOLD
|
||||
public var resetTimeout: Duration = initialCbConfig?.resetTimeout ?: CircuitBreakerConfig.Factory.Defaults.DEFAULT_RESET_TIMEOUT
|
||||
public var additionalTimeAfterFailure: Duration = initialCbConfig?.additionalTimeAfterFailure ?: CircuitBreakerConfig.Factory.Defaults.DEFAULT_ADDITIONAL_TIME_AFTER_FAILURE
|
||||
|
||||
internal fun build(): CircuitBreakerConfig = CircuitBreakerConfig(
|
||||
failureThreshold, resetTimeout, additionalTimeAfterFailure
|
||||
)
|
||||
}
|
||||
|
||||
/** Enables and configures the circuit breaker using a nested DSL. */
|
||||
public fun circuitBreaker(block: CircuitBreakerPolicyConfigBuilder.() -> Unit = {}): RestartPolicyBuilder = apply {
|
||||
val builder = CircuitBreakerPolicyConfigBuilder(this.circuitBreaker)
|
||||
builder.apply(block)
|
||||
this.circuitBreaker = builder.build()
|
||||
}
|
||||
|
||||
/** Disables the circuit breaker for this restart policy. */
|
||||
public fun noCircuitBreaker(): RestartPolicyBuilder = apply {
|
||||
this.circuitBreaker = null
|
||||
}
|
||||
|
||||
internal fun build(): RestartPolicy = RestartPolicy(
|
||||
maxAttempts, delayBetweenAttempts, resetOnSuccess, strategy, circuitBreaker
|
||||
)
|
||||
}
|
78
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/config/MessageBusConfig.kt
Normal file
78
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/config/MessageBusConfig.kt
Normal file
@ -0,0 +1,78 @@
|
||||
package space.kscience.controls.spec.config
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
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 space.kscience.dataforge.names.Name
|
||||
import space.kscience.dataforge.names.asName
|
||||
import space.kscience.dataforge.names.parseAsName
|
||||
|
||||
/**
|
||||
* Configuration for message bus and related messaging parameters.
|
||||
*
|
||||
* @property inMemoryMessageBusBufferCapacity Buffer size for the in-memory message bus.
|
||||
* @property deviceMessageFlowBufferCapacity Buffer size for individual device message flows.
|
||||
* @property defaultMagixSourceEndpoint Default Magix endpoint identifier for this hub instance.
|
||||
* @property metricsDefaultSourceDeviceName Default source device name for metrics published by the hub itself.
|
||||
*/
|
||||
@Serializable
|
||||
public data class MessageBusConfig(
|
||||
public val inMemoryMessageBusBufferCapacity: Int,
|
||||
public val deviceMessageFlowBufferCapacity: Int,
|
||||
public val defaultMagixSourceEndpoint: String,
|
||||
public val metricsDefaultSourceDeviceName: Name
|
||||
) {
|
||||
/**
|
||||
* Converts this configuration object to a [Meta] representation.
|
||||
*/
|
||||
public fun toMeta(): Meta = Meta {
|
||||
"inMemoryMessageBusBufferCapacity" put inMemoryMessageBusBufferCapacity
|
||||
"deviceMessageFlowBufferCapacity" put deviceMessageFlowBufferCapacity
|
||||
"defaultMagixSourceEndpoint" put defaultMagixSourceEndpoint
|
||||
"metricsDefaultSourceDeviceName" put metricsDefaultSourceDeviceName.toString()
|
||||
}
|
||||
|
||||
public companion object Factory {
|
||||
public object Defaults {
|
||||
public const val IN_MEMORY_MESSAGE_BUS_BUFFER_CAPACITY: Int = 64
|
||||
public const val DEVICE_MESSAGE_FLOW_BUFFER_CAPACITY: Int = 1000
|
||||
public const val DEFAULT_MAGIX_SOURCE_ENDPOINT: String = "device.hub"
|
||||
public val METRICS_DEFAULT_SOURCE_DEVICE_NAME: Name = "metrics".asName()
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [MessageBusConfig] instance from a [Meta] object.
|
||||
*/
|
||||
public fun fromMeta(meta: Meta): MessageBusConfig = MessageBusConfig(
|
||||
inMemoryMessageBusBufferCapacity = meta["inMemoryMessageBusBufferCapacity"].int ?: Defaults.IN_MEMORY_MESSAGE_BUS_BUFFER_CAPACITY,
|
||||
deviceMessageFlowBufferCapacity = meta["deviceMessageFlowBufferCapacity"].int ?: Defaults.DEVICE_MESSAGE_FLOW_BUFFER_CAPACITY,
|
||||
defaultMagixSourceEndpoint = meta["defaultMagixSourceEndpoint"].string ?: Defaults.DEFAULT_MAGIX_SOURCE_ENDPOINT,
|
||||
metricsDefaultSourceDeviceName = meta["metricsDefaultSourceDeviceName"]?.string?.parseAsName() ?: Defaults.METRICS_DEFAULT_SOURCE_DEVICE_NAME
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for [MessageBusConfig].
|
||||
*/
|
||||
public class MessageBusConfigBuilder {
|
||||
public var inMemoryMessageBusBufferCapacity: Int = MessageBusConfig.Factory.Defaults.IN_MEMORY_MESSAGE_BUS_BUFFER_CAPACITY
|
||||
public var deviceMessageFlowBufferCapacity: Int = MessageBusConfig.Factory.Defaults.DEVICE_MESSAGE_FLOW_BUFFER_CAPACITY
|
||||
public var defaultMagixSourceEndpoint: String = MessageBusConfig.Factory.Defaults.DEFAULT_MAGIX_SOURCE_ENDPOINT
|
||||
public var metricsDefaultSourceDeviceName: Name = MessageBusConfig.Factory.Defaults.METRICS_DEFAULT_SOURCE_DEVICE_NAME
|
||||
|
||||
public fun build(): MessageBusConfig = MessageBusConfig(
|
||||
inMemoryMessageBusBufferCapacity,
|
||||
deviceMessageFlowBufferCapacity,
|
||||
defaultMagixSourceEndpoint,
|
||||
metricsDefaultSourceDeviceName
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* DSL function to create [MessageBusConfig].
|
||||
*/
|
||||
public inline fun messageBusConfig(block: MessageBusConfigBuilder.() -> Unit = {}): MessageBusConfig =
|
||||
MessageBusConfigBuilder().apply(block).build()
|
186
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/config/RestartComponents.kt
Normal file
186
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/config/RestartComponents.kt
Normal file
@ -0,0 +1,186 @@
|
||||
package space.kscience.controls.spec.config
|
||||
|
||||
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 space.kscience.controls.spec.utils.ParsingUtils
|
||||
import space.kscience.dataforge.meta.boolean
|
||||
import kotlin.math.pow
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Strategy for calculating delay between restart attempts.
|
||||
*/
|
||||
public sealed interface RestartStrategy {
|
||||
/** Calculates delay based on base delay and attempt number. */
|
||||
public fun calculateDelay(baseDelay: Duration, attempt: Int): Duration
|
||||
|
||||
/** Linear strategy - always the same delay. */
|
||||
public object Linear : RestartStrategy {
|
||||
override fun calculateDelay(baseDelay: Duration, attempt: Int): Duration = baseDelay
|
||||
}
|
||||
|
||||
/** Exponential strategy - delay grows exponentially with each attempt. */
|
||||
public object ExponentialBackoff : RestartStrategy {
|
||||
override fun calculateDelay(baseDelay: Duration, attempt: Int): Duration =
|
||||
baseDelay * 2.0.pow((attempt - 1).coerceAtLeast(0).toDouble())
|
||||
}
|
||||
|
||||
/** Fibonacci strategy - delay follows the Fibonacci sequence. */
|
||||
public object Fibonacci : RestartStrategy {
|
||||
private val fibCache = mutableMapOf<Int, Long>()
|
||||
|
||||
private fun fib(n: Int): Long {
|
||||
if (n <= 0) return 0L
|
||||
if (n == 1) return 1L
|
||||
if (n <= 20) {
|
||||
var a = 0L
|
||||
var b = 1L
|
||||
repeat(n -1) {
|
||||
val sum = a + b
|
||||
a = b
|
||||
b = sum
|
||||
}
|
||||
return b
|
||||
}
|
||||
return fibCache.getOrPut(n) { fib(n - 1) + fib(n - 2) }
|
||||
}
|
||||
|
||||
override fun calculateDelay(baseDelay: Duration, attempt: Int): Duration =
|
||||
if (attempt <= 0) Duration.ZERO else baseDelay * fib(attempt).toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Circuit Breaker configuration for resilient failure recovery.
|
||||
*
|
||||
* @property failureThreshold Consecutive failures threshold before opening the circuit.
|
||||
* @property resetTimeout Time in open state before automatic transition to half-open.
|
||||
* @property additionalTimeAfterFailure Additional backoff time added to [resetTimeout] for each failure
|
||||
* that occurred while the circuit was already open or during half-open attempts,
|
||||
* making recovery progressively slower for persistently failing services.
|
||||
*/
|
||||
public data class CircuitBreakerConfig(
|
||||
val failureThreshold: Int = Defaults.DEFAULT_FAILURE_THRESHOLD,
|
||||
val resetTimeout: Duration = Defaults.DEFAULT_RESET_TIMEOUT,
|
||||
val additionalTimeAfterFailure: Duration = Defaults.DEFAULT_ADDITIONAL_TIME_AFTER_FAILURE
|
||||
) {
|
||||
/**
|
||||
* Converts this configuration object to a [Meta] representation.
|
||||
*/
|
||||
public fun toMeta(): Meta = Meta {
|
||||
"failureThreshold" put failureThreshold
|
||||
"resetTimeout" put resetTimeout.toString()
|
||||
"additionalTimeAfterFailure" put additionalTimeAfterFailure.toString()
|
||||
}
|
||||
|
||||
public companion object Factory {
|
||||
public object Defaults {
|
||||
public const val DEFAULT_FAILURE_THRESHOLD: Int = 5
|
||||
public val DEFAULT_RESET_TIMEOUT: Duration = 60.seconds
|
||||
public val DEFAULT_ADDITIONAL_TIME_AFTER_FAILURE: Duration = 30.seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [CircuitBreakerConfig] instance from a [Meta] object.
|
||||
*/
|
||||
public fun fromMeta(meta: Meta): CircuitBreakerConfig = CircuitBreakerConfig(
|
||||
failureThreshold = meta["failureThreshold"].int ?: Defaults.DEFAULT_FAILURE_THRESHOLD,
|
||||
resetTimeout = meta["resetTimeout"]?.string?.let { ParsingUtils.parseDurationOrNull(it) } ?: Defaults.DEFAULT_RESET_TIMEOUT,
|
||||
additionalTimeAfterFailure = meta["additionalTimeAfterFailure"]?.string?.let { ParsingUtils.parseDurationOrNull(it) } ?: Defaults.DEFAULT_ADDITIONAL_TIME_AFTER_FAILURE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Data class describing restart behavior for a device.
|
||||
*
|
||||
* @property maxAttempts Maximum number of restart attempts. Use [Int.MAX_VALUE] for unlimited.
|
||||
* @property delayBetweenAttempts Base delay between restart attempts. Specific [RestartStrategy] may modify this.
|
||||
* @property resetOnSuccess Whether to reset the restart attempt counter on a successful start.
|
||||
* @property strategy The [RestartStrategy] for calculating delay between attempts.
|
||||
* @property circuitBreaker Optional [CircuitBreakerConfig] to apply circuit breaker pattern.
|
||||
*/
|
||||
public data class RestartPolicy(
|
||||
val maxAttempts: Int = Defaults.DEFAULT_RESTART_POLICY_MAX_ATTEMPTS,
|
||||
val delayBetweenAttempts: Duration = Defaults.DEFAULT_RESTART_POLICY_DELAY,
|
||||
val resetOnSuccess: Boolean = true,
|
||||
val strategy: RestartStrategy = RestartStrategy.Linear,
|
||||
val circuitBreaker: CircuitBreakerConfig? = null
|
||||
) {
|
||||
init {
|
||||
require(maxAttempts > 0) { "maxAttempts must be positive." }
|
||||
require(!delayBetweenAttempts.isNegative()) { "delayBetweenAttempts must not be negative." }
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts this configuration object to a [Meta] representation.
|
||||
*/
|
||||
public fun toMeta(): Meta = Meta {
|
||||
"maxAttempts" put maxAttempts
|
||||
"delayBetweenAttempts" put delayBetweenAttempts.toString()
|
||||
"resetOnSuccess" put resetOnSuccess
|
||||
strategy::class.simpleName?.let { "strategy" put it }
|
||||
circuitBreaker?.let { "circuitBreaker" put it.toMeta() }
|
||||
}
|
||||
|
||||
public companion object Factory {
|
||||
public object Defaults {
|
||||
public const val DEFAULT_RESTART_POLICY_MAX_ATTEMPTS: Int = 5
|
||||
public val DEFAULT_RESTART_POLICY_DELAY: Duration = 2.seconds
|
||||
|
||||
/** Standard restart policy with sensible defaults. */
|
||||
public val DEFAULT: RestartPolicy = RestartPolicy(
|
||||
DEFAULT_RESTART_POLICY_MAX_ATTEMPTS,
|
||||
DEFAULT_RESTART_POLICY_DELAY
|
||||
)
|
||||
|
||||
/** Example policy with circuit breaker enabled. */
|
||||
public val WITH_CIRCUIT_BREAKER: RestartPolicy = RestartPolicy(
|
||||
maxAttempts = 3,
|
||||
delayBetweenAttempts = 5.seconds,
|
||||
strategy = RestartStrategy.ExponentialBackoff,
|
||||
circuitBreaker = CircuitBreakerConfig(failureThreshold = 3, resetTimeout = 30.seconds)
|
||||
)
|
||||
|
||||
/** Example policy using Fibonacci backoff and circuit breaker. */
|
||||
public val FIBONACCI_WITH_CIRCUIT_BREAKER: RestartPolicy = RestartPolicy(
|
||||
maxAttempts = 8,
|
||||
delayBetweenAttempts = 1.seconds,
|
||||
strategy = RestartStrategy.Fibonacci,
|
||||
circuitBreaker = CircuitBreakerConfig(failureThreshold = 5, resetTimeout = 2.minutes)
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a [RestartPolicy] instance from a [Meta] object.
|
||||
*/
|
||||
public fun fromMeta(meta: Meta): RestartPolicy {
|
||||
val maxAttempts = meta["maxAttempts"].int ?: Defaults.DEFAULT_RESTART_POLICY_MAX_ATTEMPTS
|
||||
val delayBetweenAttempts = meta["delayBetweenAttempts"]?.string?.let { ParsingUtils.parseDurationOrNull(it) } ?: Defaults.DEFAULT_RESTART_POLICY_DELAY
|
||||
val resetOnSuccess = meta["resetOnSuccess"]?.boolean ?: true
|
||||
|
||||
val strategyName = meta["strategy"].string
|
||||
val strategy = when (strategyName) {
|
||||
"Linear" -> RestartStrategy.Linear
|
||||
"ExponentialBackoff" -> RestartStrategy.ExponentialBackoff
|
||||
"Fibonacci" -> RestartStrategy.Fibonacci
|
||||
else -> RestartStrategy.Linear
|
||||
}
|
||||
|
||||
val circuitBreaker = meta["circuitBreaker"]?.let { CircuitBreakerConfig.fromMeta(it) }
|
||||
|
||||
return RestartPolicy(
|
||||
maxAttempts = maxAttempts,
|
||||
delayBetweenAttempts = delayBetweenAttempts,
|
||||
resetOnSuccess = resetOnSuccess,
|
||||
strategy = strategy,
|
||||
circuitBreaker = circuitBreaker
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
126
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/infra/MessageBus.kt
Normal file
126
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/infra/MessageBus.kt
Normal file
@ -0,0 +1,126 @@
|
||||
package space.kscience.controls.spec.infra
|
||||
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import space.kscience.controls.api.Message
|
||||
import space.kscience.controls.spec.config.MessageBusConfig
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.Logger
|
||||
import space.kscience.dataforge.context.error
|
||||
import space.kscience.dataforge.context.info
|
||||
import space.kscience.dataforge.context.logger
|
||||
import space.kscience.dataforge.context.warn
|
||||
import space.kscience.magix.api.MagixEndpoint
|
||||
import space.kscience.magix.api.MagixFormat
|
||||
import space.kscience.magix.api.MagixMessageFilter as MagixApiMessageFilter
|
||||
import space.kscience.magix.api.send as magixSend
|
||||
|
||||
/**
|
||||
* Interface for message tracing.
|
||||
*/
|
||||
public fun interface MessageTracer {
|
||||
public fun onPublish(message: Message, bus: MessageBus)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for a message bus.
|
||||
*/
|
||||
public interface MessageBus : AutoCloseable {
|
||||
public fun subscribe(filter: MessageFilter = MessageFilter.ALL): Flow<Message>
|
||||
public suspend fun publish(message: Message)
|
||||
override fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory for creating [MessageBus] instances.
|
||||
*/
|
||||
public object MessageBusFactory {
|
||||
public fun create(
|
||||
context: Context,
|
||||
magixSourceEndpoint: String = MessageBusConfig.Factory.Defaults.DEFAULT_MAGIX_SOURCE_ENDPOINT,
|
||||
inMemoryBufferSize: Int = MessageBusConfig.Factory.Defaults.IN_MEMORY_MESSAGE_BUS_BUFFER_CAPACITY,
|
||||
tracer: MessageTracer? = null
|
||||
): MessageBus {
|
||||
val magixEndpoint = context.plugins.filterIsInstance<MagixEndpoint>().firstOrNull()
|
||||
return if (magixEndpoint != null) {
|
||||
MagixMessageBus(magixEndpoint, magixSourceEndpoint, tracer, context.logger)
|
||||
} else {
|
||||
InMemoryMessageBus(inMemoryBufferSize, tracer, context.logger)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MessageBus] implementation bridging to a [MagixEndpoint].
|
||||
*/
|
||||
public class MagixMessageBus(
|
||||
public val magixEndpoint: MagixEndpoint,
|
||||
public val sourceEndpoint: String,
|
||||
private val tracer: MessageTracer? = null,
|
||||
private val logger: Logger? = null
|
||||
) : MessageBus {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private val internalMessageFormat = MagixFormat(
|
||||
Message.serializer(),
|
||||
setOf(Message.serializer().descriptor.serialName)
|
||||
)
|
||||
|
||||
override fun subscribe(filter: MessageFilter): Flow<Message> =
|
||||
magixEndpoint.subscribe(
|
||||
MagixApiMessageFilter(format = setOf(internalMessageFormat.defaultFormat))
|
||||
).mapNotNull { magixMsg ->
|
||||
try {
|
||||
val decodedInternalMessage = MagixEndpoint.magixJson.decodeFromJsonElement(
|
||||
Message.serializer(),
|
||||
magixMsg.payload
|
||||
)
|
||||
if (filter.accepts(decodedInternalMessage)) decodedInternalMessage else null
|
||||
} catch (e: Exception) {
|
||||
logger?.error(e) { "Failed to decode Magix payload to internal Message: ${magixMsg.payload}" }
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun publish(message: Message) {
|
||||
tracer?.onPublish(message, this)
|
||||
try {
|
||||
magixEndpoint.magixSend(internalMessageFormat, message, source = this.sourceEndpoint, target = message.targetDevice?.toString())
|
||||
} catch (e: Exception) {
|
||||
logger?.error(e) { "Failed to send internal Message via Magix: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
logger?.info { "MagixMessageBus for endpoint '$sourceEndpoint' closed (underlying MagixEndpoint not closed by this adapter)." }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An in-memory implementation of [MessageBus].
|
||||
*/
|
||||
public class InMemoryMessageBus(
|
||||
bufferCapacity: Int = MessageBusConfig.Factory.Defaults.IN_MEMORY_MESSAGE_BUS_BUFFER_CAPACITY,
|
||||
private val tracer: MessageTracer? = null,
|
||||
private val logger: Logger? = null
|
||||
) : MessageBus {
|
||||
private val sharedFlow = MutableSharedFlow<Message>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = bufferCapacity,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
|
||||
override fun subscribe(filter: MessageFilter): Flow<Message> =
|
||||
sharedFlow.asSharedFlow().filter { filter.accepts(it) }
|
||||
|
||||
override suspend fun publish(message: Message) {
|
||||
tracer?.onPublish(message, this)
|
||||
if (!sharedFlow.tryEmit(message)) {
|
||||
logger?.warn { "InMemoryMessageBus buffer overflow or slow subscriber, message dropped: ${message.messageType} from ${message.sourceDevice}" }
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
logger?.info { "InMemoryMessageBus closed." }
|
||||
}
|
||||
}
|
164
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/infra/MessagingSystem.kt
Normal file
164
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/infra/MessagingSystem.kt
Normal file
@ -0,0 +1,164 @@
|
||||
package space.kscience.controls.spec.infra
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import space.kscience.controls.api.*
|
||||
import space.kscience.controls.spec.config.MessageBusConfig
|
||||
import space.kscience.controls.api.DeviceException
|
||||
import space.kscience.controls.spec.utils.TimeSource
|
||||
import space.kscience.controls.spec.utils.SystemTimeSource as DefaultTimeSource
|
||||
import space.kscience.controls.spec.utils.timeSourceOrDefault
|
||||
import space.kscience.dataforge.context.ContextAware
|
||||
import space.kscience.dataforge.context.Logger
|
||||
import space.kscience.dataforge.context.error
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.dataforge.names.asName
|
||||
import space.kscience.dataforge.names.parseAsName
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Extension property to get message type from `@SerialName` annotation.
|
||||
*/
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
public val Message.messageType: String
|
||||
get() = Message.serializer().descriptor.serialName
|
||||
|
||||
/**
|
||||
* Factory for creating common [Message] types.
|
||||
*/
|
||||
public class MessageFactory(private val timeSource: TimeSource) {
|
||||
public fun deviceLog(message: String, sourceDevice: Name, data: Meta? = null): DeviceLogMessage =
|
||||
DeviceLogMessage(message, data, sourceDevice, time = timeSource.now())
|
||||
|
||||
public fun systemLog(message: String, component: String, details: Map<String, String> = emptyMap()): SystemLogMessage =
|
||||
SystemLogMessage(message, component, details = details, time = timeSource.now())
|
||||
|
||||
public fun metricValue(name: String, value: Double, sourceDevice: Name = MessageBusConfig.Factory.Defaults.METRICS_DEFAULT_SOURCE_DEVICE_NAME, tags: Map<String, String> = emptyMap()): MetricMessage.MetricValueMessage =
|
||||
MetricMessage.MetricValueMessage(name, value, sourceDevice, tags, time = timeSource.now())
|
||||
|
||||
public fun metricCounter(name: String, increment: Double = 1.0, sourceDevice: Name = MessageBusConfig.Factory.Defaults.METRICS_DEFAULT_SOURCE_DEVICE_NAME, tags: Map<String, String> = emptyMap()): MetricMessage.MetricCounterMessage =
|
||||
MetricMessage.MetricCounterMessage(name, increment, tags, sourceDevice, time = timeSource.now())
|
||||
|
||||
public fun metricDuration(name: String, duration: Duration, sourceDevice: Name = MessageBusConfig.Factory.Defaults.METRICS_DEFAULT_SOURCE_DEVICE_NAME, tags: Map<String, String> = emptyMap()): MetricMessage.MetricDurationMessage =
|
||||
MetricMessage.MetricDurationMessage(name, duration.inWholeMilliseconds, tags, sourceDevice, time = timeSource.now())
|
||||
|
||||
public fun deviceAdded(deviceName: String): DeviceStateMessage.DeviceStateAddedMessage =
|
||||
DeviceStateMessage.DeviceStateAddedMessage(deviceName, time = timeSource.now())
|
||||
|
||||
public fun deviceStarted(deviceName: String): DeviceStateMessage.DeviceStateStartedMessage =
|
||||
DeviceStateMessage.DeviceStateStartedMessage(deviceName, time = timeSource.now())
|
||||
|
||||
public fun deviceStopped(deviceName: String): DeviceStateMessage.DeviceStateStoppedMessage =
|
||||
DeviceStateMessage.DeviceStateStoppedMessage(deviceName, time = timeSource.now())
|
||||
|
||||
public fun deviceRemoved(deviceName: String): DeviceStateMessage.DeviceStateRemovedMessage =
|
||||
DeviceStateMessage.DeviceStateRemovedMessage(deviceName, time = timeSource.now())
|
||||
|
||||
public fun deviceFailed(deviceName: String, failure: SerializableDeviceFailure): DeviceStateMessage.DeviceStateFailedMessage =
|
||||
DeviceStateMessage.DeviceStateFailedMessage(deviceName, failure, time = timeSource.now())
|
||||
|
||||
public fun deviceDetached(deviceName: String): DeviceStateMessage.DeviceStateDetachedMessage =
|
||||
DeviceStateMessage.DeviceStateDetachedMessage(deviceName, time = timeSource.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension for creating [DeviceLogMessage] from a [Device].
|
||||
*/
|
||||
public fun Device.log(
|
||||
message: String,
|
||||
data: Meta? = null,
|
||||
timeSource: TimeSource = (this as? ContextAware)?.context?.timeSourceOrDefault ?: DefaultTimeSource
|
||||
): DeviceLogMessage = DeviceLogMessage(message, data, this.id.asName(), time = timeSource.now())
|
||||
|
||||
/**
|
||||
* Filter for messages on the [MessageBus].
|
||||
*/
|
||||
@Serializable
|
||||
public data class MessageFilter(
|
||||
val messageType: Collection<String>? = null,
|
||||
val sourceDevice: Collection<Name>? = null,
|
||||
val targetDevice: Collection<Name?>? = null,
|
||||
) {
|
||||
public fun accepts(message: Message): Boolean =
|
||||
(messageType == null || messageType.contains(message.messageType)) &&
|
||||
(sourceDevice == null || sourceDevice.contains(message.sourceDevice)) &&
|
||||
(targetDevice == null || targetDevice.contains(message.targetDevice))
|
||||
|
||||
public class Builder {
|
||||
private val messageTypes = mutableSetOf<String>()
|
||||
private val sourceDevices = mutableSetOf<Name>()
|
||||
private val targetDevices = mutableSetOf<Name?>()
|
||||
|
||||
public fun messageType(type: String): Builder = apply { messageTypes.add(type) }
|
||||
public fun messageTypes(types: Collection<String>): Builder = apply { messageTypes.addAll(types) }
|
||||
public fun sourceDevice(device: Name): Builder = apply { sourceDevices.add(device) }
|
||||
public fun sourceDevice(deviceName: String): Builder = apply { sourceDevices.add(deviceName.parseAsName()) }
|
||||
public fun sourceDevices(devices: Collection<Name>): Builder = apply { sourceDevices.addAll(devices) }
|
||||
public fun targetDevice(device: Name?): Builder = apply { targetDevices.add(device) }
|
||||
public fun targetDevice(deviceName: String?): Builder = apply { targetDevices.add(deviceName?.parseAsName()) }
|
||||
public fun targetDevices(devices: Collection<Name?>): Builder = apply { targetDevices.addAll(devices) }
|
||||
|
||||
public fun build(): MessageFilter = MessageFilter(
|
||||
messageTypes.ifEmpty { null },
|
||||
sourceDevices.ifEmpty { null },
|
||||
targetDevices.ifEmpty { null }
|
||||
)
|
||||
}
|
||||
public companion object {
|
||||
public val ALL: MessageFilter = MessageFilter()
|
||||
public fun builder(): Builder = Builder()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* System for sending and receiving messages via a [MessageBus].
|
||||
*/
|
||||
public class MessagingSystem(
|
||||
public val messageBus: MessageBus,
|
||||
private val logger: Logger,
|
||||
private val timeSource: TimeSource = DefaultTimeSource,
|
||||
public val messageFactory: MessageFactory = MessageFactory(timeSource)
|
||||
) {
|
||||
public suspend fun publish(message: Message) {
|
||||
try {
|
||||
messageBus.publish(message)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to publish message (type ${message.messageType} from ${message.sourceDevice}): ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
public inline fun <reified T : Message> getMessageFlow(filter: MessageFilter = MessageFilter.ALL): Flow<T> =
|
||||
messageBus.subscribe(filter).filterIsInstance<T>()
|
||||
|
||||
public fun getDeviceLogMessages(): Flow<DeviceLogMessage> = getMessageFlow()
|
||||
public fun getSystemLogMessages(): Flow<SystemLogMessage> = getMessageFlow()
|
||||
public fun getDeviceStateMessages(): Flow<DeviceStateMessage> = getMessageFlow()
|
||||
public fun getTransactionMessages(): Flow<TransactionMessage> = getMessageFlow()
|
||||
public fun getMetricMessages(): Flow<MetricMessage> = getMessageFlow()
|
||||
public fun getDeviceFailureMessages(): Flow<DeviceFailureMessage> = getMessageFlow()
|
||||
public fun getAllMessages(): Flow<Message> = messageBus.subscribe(MessageFilter.ALL)
|
||||
|
||||
public suspend fun logDevice(message: String, sourceDevice: Name, data: Meta? = null): Unit =
|
||||
publish(messageFactory.deviceLog(message, sourceDevice, data))
|
||||
|
||||
public suspend fun logSystem(message: String, component: String, details: Map<String, String> = emptyMap()): Unit =
|
||||
publish(messageFactory.systemLog(message, component, details))
|
||||
|
||||
public suspend fun recordMetricValue(name: String, value: Double, sourceDevice: Name = MessageBusConfig.Factory.Defaults.METRICS_DEFAULT_SOURCE_DEVICE_NAME, tags: Map<String, String> = emptyMap()): Unit =
|
||||
publish(messageFactory.metricValue(name, value, sourceDevice, tags))
|
||||
|
||||
public suspend fun incrementCounter(name: String, increment: Double = 1.0, sourceDevice: Name = MessageBusConfig.Factory.Defaults.METRICS_DEFAULT_SOURCE_DEVICE_NAME, tags: Map<String, String> = emptyMap()): Unit =
|
||||
publish(messageFactory.metricCounter(name, increment, sourceDevice, tags))
|
||||
|
||||
public suspend fun recordDuration(name: String, duration: Duration, sourceDevice: Name = MessageBusConfig.Factory.Defaults.METRICS_DEFAULT_SOURCE_DEVICE_NAME, tags: Map<String, String> = emptyMap()): Unit =
|
||||
publish(messageFactory.metricDuration(name, duration, sourceDevice, tags))
|
||||
|
||||
public suspend fun reportDeviceFailure(failure: DeviceException, sourceDevice: Name?): Unit =
|
||||
publish(DeviceFailureMessage(failure.toSerializableFailure(), sourceDevice, time = timeSource.now()))
|
||||
|
||||
public suspend fun reportDeviceFailure(failure: SerializableDeviceFailure, sourceDevice: Name?): Unit =
|
||||
publish(DeviceFailureMessage(failure, sourceDevice, time = timeSource.now()))
|
||||
}
|
96
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/infra/Metrics.kt
Normal file
96
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/infra/Metrics.kt
Normal file
@ -0,0 +1,96 @@
|
||||
package space.kscience.controls.spec.infra
|
||||
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.datetime.Instant
|
||||
import space.kscience.controls.api.MetricMessage
|
||||
import space.kscience.controls.api.MetricMessage.*
|
||||
import space.kscience.controls.spec.config.MessageBusConfig
|
||||
import space.kscience.controls.spec.utils.TimeSource
|
||||
import space.kscience.dataforge.context.Logger
|
||||
import space.kscience.dataforge.context.error
|
||||
import space.kscience.dataforge.context.info
|
||||
import space.kscience.dataforge.context.warn
|
||||
import space.kscience.dataforge.names.Name
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Backend interface for publishing metrics.
|
||||
*/
|
||||
public interface MetricsBackend : AutoCloseable {
|
||||
public suspend fun publish(message: MetricMessage)
|
||||
override fun close() {}
|
||||
}
|
||||
|
||||
/**
|
||||
* A [MetricsBackend] sending metrics via [MessageBus].
|
||||
*/
|
||||
public class MessageBusMetricsBackend(private val messageBus: MessageBus) : MetricsBackend {
|
||||
override suspend fun publish(message: MetricMessage) { messageBus.publish(message) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for publishing various types of metrics.
|
||||
*/
|
||||
public interface MetricsPublisher : AutoCloseable {
|
||||
public suspend fun publishMetric(name: String, value: Double, tags: Map<String, String> = emptyMap())
|
||||
public suspend fun recordDuration(name: String, duration: Duration, tags: Map<String, String> = emptyMap())
|
||||
public suspend fun incrementCounter(name: String, amount: Double = 1.0, tags: Map<String, String> = emptyMap())
|
||||
public suspend fun recordGauge(name: String, value: Double, tags: Map<String, String> = emptyMap())
|
||||
public suspend fun recordDistribution(name: String, value: Double, tags: Map<String, String> = emptyMap())
|
||||
public suspend fun recordHistogramBucket(name: String, value: Double, bucket: String, tags: Map<String, String> = emptyMap())
|
||||
override fun close()
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of [MetricsPublisher].
|
||||
*/
|
||||
public class MetricsPublisherImpl(
|
||||
private val backend: MetricsBackend,
|
||||
private val sourceDeviceName: Name = MessageBusConfig.Factory.Defaults.METRICS_DEFAULT_SOURCE_DEVICE_NAME,
|
||||
private val logger: Logger,
|
||||
private val timeSource: TimeSource
|
||||
) : MetricsPublisher {
|
||||
private val isActive = atomic(true)
|
||||
|
||||
private suspend fun tryPublish(messageCreator: (Instant) -> MetricMessage) {
|
||||
if (!isActive.value) {
|
||||
logger.warn { "MetricsPublisher for '$sourceDeviceName' is closed. Metric not published." }
|
||||
return
|
||||
}
|
||||
val message = messageCreator(timeSource.now())
|
||||
try {
|
||||
backend.publish(message)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Failed to publish metric '${message.metricName}' from '$sourceDeviceName'." }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun publishMetric(name: String, value: Double, tags: Map<String, String>): Unit =
|
||||
tryPublish { MetricValueMessage(name, value, sourceDeviceName, tags, time = it) }
|
||||
|
||||
override suspend fun recordDuration(name: String, duration: Duration, tags: Map<String, String>): Unit =
|
||||
tryPublish { MetricDurationMessage(name, duration.inWholeMilliseconds, tags, sourceDeviceName, time = it) }
|
||||
|
||||
override suspend fun incrementCounter(name: String, amount: Double, tags: Map<String, String>): Unit =
|
||||
tryPublish { MetricCounterMessage(name, amount, tags, sourceDeviceName, time = it) }
|
||||
|
||||
override suspend fun recordGauge(name: String, value: Double, tags: Map<String, String>): Unit =
|
||||
tryPublish { MetricGaugeMessage(name, value, tags, sourceDeviceName, time = it) }
|
||||
|
||||
override suspend fun recordDistribution(name: String, value: Double, tags: Map<String, String>): Unit =
|
||||
tryPublish { MetricDistributionMessage(name, value, tags, sourceDeviceName, time = it) }
|
||||
|
||||
override suspend fun recordHistogramBucket(name: String, value: Double, bucket: String, tags: Map<String, String>): Unit =
|
||||
tryPublish {
|
||||
MetricValueMessage(name, value, sourceDeviceName, tags + ("bucket" to bucket), time = it)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (isActive.compareAndSet(expect = true, update = false)) {
|
||||
try { backend.close() }
|
||||
catch (e: Exception) { logger.error(e) { "Error closing metrics backend for '$sourceDeviceName'." } }
|
||||
logger.info { "MetricsPublisher for '$sourceDeviceName' closed." }
|
||||
}
|
||||
}
|
||||
// TODO: adding support for high-frequency metrics.
|
||||
}
|
43
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/model/Lifecycle.kt
Normal file
43
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/model/Lifecycle.kt
Normal file
@ -0,0 +1,43 @@
|
||||
package space.kscience.controls.spec.model
|
||||
|
||||
/**
|
||||
* Enum defining how a child device's lifecycle is coupled to its parent.
|
||||
*/
|
||||
public enum class LifecycleMode {
|
||||
/** Linked mode - child device starts/stops with parent. */
|
||||
LINKED,
|
||||
|
||||
/** Independent mode - child device must be started/stopped manually. */
|
||||
INDEPENDENT
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum defining how a device should be started when attached to a manager.
|
||||
*/
|
||||
public enum class StartMode {
|
||||
/** Don't start device automatically. */
|
||||
NONE,
|
||||
|
||||
/** Start device asynchronously, not waiting for completion. */
|
||||
ASYNC,
|
||||
|
||||
/** Start device synchronously, waiting for startup completion. */
|
||||
SYNC
|
||||
}
|
||||
|
||||
/**
|
||||
* Enum defining strategies for handling errors encountered in child devices.
|
||||
*/
|
||||
public enum class ChildDeviceErrorHandler {
|
||||
/** Ignore errors, only log them. The parent device continues operation. */
|
||||
IGNORE,
|
||||
|
||||
/** Attempt to restart the failed child device according to its [RestartPolicy]. */
|
||||
RESTART,
|
||||
|
||||
/** Stop the parent device if a child device encounters a critical error. */
|
||||
STOP_PARENT,
|
||||
|
||||
/** Propagate the error upwards, potentially cancelling the parent's coroutine or operation. */
|
||||
PROPAGATE
|
||||
}
|
265
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/CircuitBreakerManager.kt
Normal file
265
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/CircuitBreakerManager.kt
Normal file
@ -0,0 +1,265 @@
|
||||
package space.kscience.controls.spec.runtime
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import space.kscience.controls.spec.config.CircuitBreakerConfig
|
||||
import space.kscience.controls.spec.config.RestartPolicy
|
||||
import space.kscience.controls.spec.infra.MessagingSystem
|
||||
import space.kscience.controls.spec.utils.TimeSource
|
||||
import space.kscience.controls.spec.utils.timeSourceOrDefault
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.Logger
|
||||
import space.kscience.dataforge.context.debug
|
||||
import space.kscience.dataforge.context.info
|
||||
import space.kscience.dataforge.context.logger
|
||||
import space.kscience.dataforge.context.warn
|
||||
import space.kscience.dataforge.names.Name
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
/**
|
||||
* Represents the state of a circuit breaker for a device.
|
||||
*
|
||||
* @property config The configuration for this circuit breaker.
|
||||
* @property timeSource The time source used for timestamps.
|
||||
* @property failureCount Current number of consecutive failures.
|
||||
* @property openSince Timestamp (epoch milliseconds) when the circuit was opened (0 if closed or never opened).
|
||||
* @property isOpen Whether the circuit is currently open.
|
||||
* @property lastAccessed Timestamp (epoch milliseconds) of the last access or state change, used for cleanup.
|
||||
* @property halfOpenAttemptInProgress True if a restart attempt is in progress while the circuit was half-open.
|
||||
*/
|
||||
public data class CircuitBreakerState(
|
||||
val config: CircuitBreakerConfig,
|
||||
internal val timeSource: TimeSource,
|
||||
var failureCount: Int = 0,
|
||||
var openSince: Long = 0,
|
||||
var isOpen: Boolean = false,
|
||||
var lastAccessed: Long = timeSource.now().toEpochMilliseconds(),
|
||||
var halfOpenAttemptInProgress: Boolean = false
|
||||
) {
|
||||
public constructor(config: CircuitBreakerConfig, timeSource: TimeSource) : this(
|
||||
config = config,
|
||||
timeSource = timeSource,
|
||||
lastAccessed = timeSource.now().toEpochMilliseconds()
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Manages circuit breaker patterns for device fault tolerance.
|
||||
*/
|
||||
public class CircuitBreakerManager(
|
||||
context: Context,
|
||||
private val registry: DeviceRegistry,
|
||||
private val messagingSystem: MessagingSystem,
|
||||
private val timeSource: TimeSource = context.timeSourceOrDefault,
|
||||
private val logger: Logger = context.logger
|
||||
) {
|
||||
private val circuitBreakerStates = mutableMapOf<Name, CircuitBreakerState>()
|
||||
private val lock = Mutex()
|
||||
|
||||
public suspend fun shouldAttemptRestart(deviceName: Name, policy: RestartPolicy): Boolean {
|
||||
val circuitBreakerConfig = policy.circuitBreaker ?: return true
|
||||
|
||||
return lock.withLock {
|
||||
val state = circuitBreakerStates.getOrPut(deviceName) {
|
||||
CircuitBreakerState(circuitBreakerConfig, timeSource)
|
||||
}
|
||||
state.lastAccessed = timeSource.now().toEpochMilliseconds()
|
||||
|
||||
if (state.halfOpenAttemptInProgress) {
|
||||
logger.debug { "Circuit breaker for '$deviceName': Half-open attempt already in progress. Denying." }
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
if (state.isOpen) {
|
||||
val nowMs = timeSource.now().toEpochMilliseconds()
|
||||
val timeInOpenStateMs = nowMs - state.openSince
|
||||
val baseResetTimeoutMs = circuitBreakerConfig.resetTimeout.inWholeMilliseconds
|
||||
val additionalFailures = (state.failureCount - circuitBreakerConfig.failureThreshold).coerceAtLeast(0)
|
||||
val additionalBackoffMs = additionalFailures * circuitBreakerConfig.additionalTimeAfterFailure.inWholeMilliseconds
|
||||
val effectiveResetTimeoutMs = baseResetTimeoutMs + additionalBackoffMs
|
||||
|
||||
if (timeInOpenStateMs >= effectiveResetTimeoutMs) {
|
||||
state.halfOpenAttemptInProgress = true
|
||||
messagingSystem.incrementCounter(
|
||||
"device.circuit_breaker.half_open",
|
||||
tags = mapOf("device_name" to deviceName.toString())
|
||||
)
|
||||
logger.info { "Circuit breaker for '$deviceName': Reset timeout ($effectiveResetTimeoutMs ms) expired. Transitioning to HALF-OPEN." }
|
||||
true
|
||||
} else {
|
||||
messagingSystem.incrementCounter(
|
||||
"device.circuit_breaker.reject",
|
||||
tags = mapOf("device_name" to deviceName.toString())
|
||||
)
|
||||
logger.debug { "Circuit breaker for '$deviceName' is OPEN, restart rejected. Time left for reset: ${(effectiveResetTimeoutMs - timeInOpenStateMs).milliseconds}." }
|
||||
false
|
||||
}
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun recordRestartFailure(deviceName: Name, policy: RestartPolicy) {
|
||||
val circuitBreakerConfig = policy.circuitBreaker ?: return
|
||||
|
||||
lock.withLock {
|
||||
val state = circuitBreakerStates.getOrPut(deviceName) {
|
||||
CircuitBreakerState(circuitBreakerConfig, timeSource)
|
||||
}
|
||||
state.lastAccessed = timeSource.now().toEpochMilliseconds()
|
||||
state.failureCount++
|
||||
|
||||
if (state.halfOpenAttemptInProgress) {
|
||||
logger.warn { "Circuit breaker for '$deviceName': Failure recorded during half-open attempt. Count: ${state.failureCount}."}
|
||||
} else if (!state.isOpen && state.failureCount >= circuitBreakerConfig.failureThreshold) {
|
||||
state.isOpen = true
|
||||
state.openSince = timeSource.now().toEpochMilliseconds()
|
||||
messagingSystem.incrementCounter(
|
||||
"device.circuit_breaker.open",
|
||||
tags = mapOf("device_name" to deviceName.toString())
|
||||
)
|
||||
logger.warn { "Circuit breaker for '$deviceName' OPENED after ${state.failureCount} failures (threshold: ${circuitBreakerConfig.failureThreshold})." }
|
||||
} else if (state.isOpen) {
|
||||
state.openSince = timeSource.now().toEpochMilliseconds()
|
||||
logger.warn { "Circuit breaker for '$deviceName' remains OPEN after another failure. Total failures: ${state.failureCount}." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun recordRestartSuccess(deviceName: Name) {
|
||||
val policy = registry.getDeviceJob(deviceName)?.config?.restartPolicy
|
||||
if (policy?.circuitBreaker == null && !lock.withLock { circuitBreakerStates.containsKey(deviceName) }) {
|
||||
return
|
||||
}
|
||||
|
||||
lock.withLock {
|
||||
circuitBreakerStates[deviceName]?.let { state ->
|
||||
if (state.isOpen || state.failureCount > 0) {
|
||||
logger.info { "Circuit breaker for '$deviceName': Resetting. Previous state: isOpen=${state.isOpen}, failures=${state.failureCount}." }
|
||||
messagingSystem.incrementCounter(
|
||||
"device.circuit_breaker.reset",
|
||||
tags = mapOf("device_name" to deviceName.toString())
|
||||
)
|
||||
}
|
||||
state.failureCount = 0
|
||||
state.isOpen = false
|
||||
state.openSince = 0
|
||||
state.lastAccessed = timeSource.now().toEpochMilliseconds()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal suspend fun concludeHalfOpenAttempt(deviceName: Name, success: Boolean) {
|
||||
lock.withLock {
|
||||
val state = circuitBreakerStates[deviceName] ?: run {
|
||||
logger.warn { "concludeHalfOpenAttempt called for '$deviceName', but no CB state found." }
|
||||
return@withLock
|
||||
}
|
||||
|
||||
if (!state.halfOpenAttemptInProgress) {
|
||||
logger.debug { "concludeHalfOpenAttempt called for '$deviceName', but no attempt was marked in progress. Current: isOpen=${state.isOpen}, failures=${state.failureCount}." }
|
||||
if (success && (state.isOpen || state.failureCount > 0)) {
|
||||
logger.info { "Circuit breaker for '$deviceName': Half-open attempt concluded (no prior mark), resetting from state isOpen=${state.isOpen}, failures=${state.failureCount}."}
|
||||
state.failureCount = 0
|
||||
state.isOpen = false
|
||||
state.openSince = 0
|
||||
messagingSystem.incrementCounter(
|
||||
"device.circuit_breaker.reset_from_half_open",
|
||||
tags = mapOf("device_name" to deviceName.toString())
|
||||
)
|
||||
}
|
||||
return@withLock
|
||||
}
|
||||
|
||||
state.halfOpenAttemptInProgress = false
|
||||
state.lastAccessed = timeSource.now().toEpochMilliseconds()
|
||||
|
||||
if (success) {
|
||||
if (state.isOpen || state.failureCount > 0) {
|
||||
logger.info { "Circuit breaker for '$deviceName': Half-open restart SUCCEEDED. Resetting. Failures before reset: ${state.failureCount}." }
|
||||
messagingSystem.incrementCounter(
|
||||
"device.circuit_breaker.reset_from_half_open",
|
||||
tags = mapOf("device_name" to deviceName.toString())
|
||||
)
|
||||
}
|
||||
state.failureCount = 0
|
||||
state.isOpen = false
|
||||
state.openSince = 0
|
||||
} else {
|
||||
state.isOpen = true
|
||||
state.openSince = timeSource.now().toEpochMilliseconds()
|
||||
logger.warn { "Circuit breaker for '$deviceName': Half-open restart FAILED. Circuit remains/becomes OPEN. Failures: ${state.failureCount}." }
|
||||
messagingSystem.incrementCounter(
|
||||
"device.circuit_breaker.re_trip_half_open",
|
||||
tags = mapOf("device_name" to deviceName.toString())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun getCircuitBreakerStatus(deviceName: Name): Map<String, Any>? = lock.withLock {
|
||||
circuitBreakerStates[deviceName]?.let { state ->
|
||||
mapOf(
|
||||
"isOpen" to state.isOpen,
|
||||
"failureCount" to state.failureCount,
|
||||
"openSinceEpochMs" to state.openSince,
|
||||
"lastAccessedEpochMs" to state.lastAccessed,
|
||||
"configFailureThreshold" to state.config.failureThreshold,
|
||||
"configResetTimeoutMs" to state.config.resetTimeout.inWholeMilliseconds,
|
||||
"configAdditionalTimeAfterFailureMs" to state.config.additionalTimeAfterFailure.inWholeMilliseconds,
|
||||
"halfOpenAttemptInProgress" to state.halfOpenAttemptInProgress
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun resetCircuitBreaker(deviceName: Name) {
|
||||
lock.withLock {
|
||||
if (circuitBreakerStates.remove(deviceName) != null) {
|
||||
logger.info { "Circuit breaker for '$deviceName' manually reset." }
|
||||
messagingSystem.incrementCounter(
|
||||
"device.circuit_breaker.manual_reset",
|
||||
tags = mapOf("device_name" to deviceName.toString())
|
||||
)
|
||||
} else {
|
||||
logger.info { "Manual reset for '$deviceName' requested, but no CB state found." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up stale circuit breaker states.
|
||||
*
|
||||
* @param maxIdleTime Time after which a non-open, non-half-open state is removed.
|
||||
* @param maxIdleTimeOpen Time after which an open or half-open state is removed (typically longer).
|
||||
*/
|
||||
public suspend fun cleanup(maxIdleTime: Duration, maxIdleTimeOpen: Duration = maxIdleTime * 5) {
|
||||
val nowMs = timeSource.now().toEpochMilliseconds()
|
||||
val maxIdleMs = maxIdleTime.inWholeMilliseconds
|
||||
val maxIdleOpenMs = maxIdleTimeOpen.inWholeMilliseconds.coerceAtLeast(maxIdleMs) // Ensure open idle time is at least normal idle time
|
||||
var cleanedCount = 0
|
||||
|
||||
lock.withLock {
|
||||
val initialSize = circuitBreakerStates.size
|
||||
circuitBreakerStates.entries.removeAll { (name, state) ->
|
||||
val idleTimeMs = nowMs - state.lastAccessed
|
||||
val shouldRemove = if (state.isOpen || state.halfOpenAttemptInProgress) {
|
||||
idleTimeMs > maxIdleOpenMs
|
||||
} else {
|
||||
idleTimeMs > maxIdleMs
|
||||
}
|
||||
if (shouldRemove) {
|
||||
logger.debug { "Cleaning up CB state for '$name' (isOpen=${state.isOpen}, halfOpenInProgress=${state.halfOpenAttemptInProgress}, idle=${idleTimeMs}ms)." }
|
||||
}
|
||||
shouldRemove
|
||||
}
|
||||
cleanedCount = initialSize - circuitBreakerStates.size
|
||||
}
|
||||
|
||||
if (cleanedCount > 0) {
|
||||
logger.info { "Cleaned up $cleanedCount stale circuit breaker states." }
|
||||
}
|
||||
}
|
||||
}
|
380
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/DeviceHubManager.kt
Normal file
380
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/DeviceHubManager.kt
Normal file
@ -0,0 +1,380 @@
|
||||
package space.kscience.controls.spec.runtime
|
||||
|
||||
import kotlinx.atomicfu.atomic
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import space.kscience.controls.api.*
|
||||
import space.kscience.controls.spec.config.DeviceHubConfig
|
||||
import space.kscience.controls.spec.config.DeviceLifecycleConfig
|
||||
import space.kscience.controls.spec.config.MessageBusConfig
|
||||
import space.kscience.controls.spec.infra.*
|
||||
import space.kscience.controls.spec.model.*
|
||||
import space.kscience.controls.spec.utils.ParsingUtils
|
||||
import space.kscience.controls.spec.utils.TimeSource
|
||||
import space.kscience.controls.spec.utils.deviceManagerConfig
|
||||
import space.kscience.controls.spec.utils.timeSourceOrDefault
|
||||
import space.kscience.dataforge.context.*
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.meta.boolean
|
||||
import space.kscience.dataforge.meta.get
|
||||
import space.kscience.dataforge.meta.string
|
||||
import space.kscience.dataforge.names.Name
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.EmptyCoroutineContext
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
|
||||
/**
|
||||
* The main hub manager for device coordination.
|
||||
*/
|
||||
public class DeviceHubManager(
|
||||
override val context: Context,
|
||||
messageBusOverride: MessageBus? = null,
|
||||
private val timeSource: TimeSource = context.timeSourceOrDefault
|
||||
) : AbstractPlugin(), ContextAware {
|
||||
|
||||
override val tag: PluginTag get() = Companion.tag
|
||||
|
||||
private val hubConfig: DeviceHubConfig = context.deviceManagerConfig
|
||||
|
||||
private val resourceInfo: SystemResourceInfo by lazy { SystemResourceInfo(context) }
|
||||
|
||||
public companion object : PluginFactory<DeviceHubManager> {
|
||||
override val tag: PluginTag = PluginTag("controls.device.hub", PluginTag.DATAFORGE_GROUP)
|
||||
|
||||
override fun build(context: Context, meta: Meta): DeviceHubManager {
|
||||
val hubConfigForBuild = DeviceHubConfig.fromMeta(meta)
|
||||
|
||||
val sourceEndpoint = hubConfigForBuild.meta["sourceEndpoint"].string
|
||||
?: MessageBusConfig.Factory.Defaults.DEFAULT_MAGIX_SOURCE_ENDPOINT
|
||||
|
||||
val tracerImpl: MessageTracer? = hubConfigForBuild.meta["messageTracer.enabled"].boolean.let { enabled ->
|
||||
if (enabled == true) MessageTracer { message, _ ->
|
||||
context.logger.debug { "[MessageTrace] Bus: ${message.messageType} from ${message.sourceDevice}" }
|
||||
} else null
|
||||
}
|
||||
|
||||
val messageBus = MessageBusFactory.create(
|
||||
context,
|
||||
sourceEndpoint,
|
||||
hubConfigForBuild.messageBufferSize,
|
||||
tracer = tracerImpl
|
||||
)
|
||||
val timeSrc = context.timeSourceOrDefault
|
||||
|
||||
return DeviceHubManager(context, messageBus, timeSrc)
|
||||
}
|
||||
}
|
||||
|
||||
private val messageBus: MessageBus = messageBusOverride
|
||||
?: MessageBusFactory.create(
|
||||
context,
|
||||
hubConfig.meta["sourceEndpoint"].string ?: MessageBusConfig.Factory.Defaults.DEFAULT_MAGIX_SOURCE_ENDPOINT,
|
||||
hubConfig.messageBufferSize,
|
||||
// TODO: Add tracer from hubConfig if defined
|
||||
tracer = hubConfig.meta["messageTracer.enabled"].boolean.let { enabled ->
|
||||
if (enabled == true) MessageTracer { message, _ ->
|
||||
context.logger.debug { "[MessageTrace] Bus: ${message.messageType} from ${message.sourceDevice}" }
|
||||
} else null
|
||||
}
|
||||
)
|
||||
|
||||
public val messagingSystem: MessagingSystem = MessagingSystem(messageBus, context.logger, timeSource)
|
||||
public val messages: Flow<Message> get() = messagingSystem.getAllMessages()
|
||||
public val deviceLogs: Flow<DeviceLogMessage> get() = messagingSystem.getDeviceLogMessages()
|
||||
public val systemLogs: Flow<SystemLogMessage> get() = messagingSystem.getSystemLogMessages()
|
||||
public val deviceStateEvents: Flow<DeviceStateMessage> get() = messagingSystem.getDeviceStateMessages()
|
||||
public val transactionEvents: Flow<TransactionMessage> get() = messagingSystem.getTransactionMessages()
|
||||
public val metricEvents: Flow<MetricMessage> get() = messagingSystem.getMetricMessages()
|
||||
|
||||
private val metricsBackend: MetricsBackend = MessageBusMetricsBackend(messageBus)
|
||||
public val metricsPublisher: MetricsPublisher = MetricsPublisherImpl(
|
||||
metricsBackend,
|
||||
hubConfig.meta["metricsSourceDeviceName"]?.string?.let { Name.parse(it) } ?: MessageBusConfig.Factory.Defaults.METRICS_DEFAULT_SOURCE_DEVICE_NAME,
|
||||
context.logger,
|
||||
timeSource
|
||||
)
|
||||
public val transactionManager: TransactionManager = TransactionManagerImpl(messagingSystem, context.logger, timeSource)
|
||||
|
||||
private val deviceRegistry = DeviceRegistry(context)
|
||||
internal val lifecycleManager = DeviceLifecycleManager(context, deviceRegistry, messagingSystem, timeSource, context.logger)
|
||||
private val circuitBreakerManager = CircuitBreakerManager(context, deviceRegistry, messagingSystem, timeSource, context.logger)
|
||||
private val restartManager = DeviceRestartManager(context, deviceRegistry, lifecycleManager, circuitBreakerManager, messagingSystem, timeSource, context.logger)
|
||||
|
||||
private val exceptionHandler = CoroutineExceptionHandler { _, ex ->
|
||||
context.logger.error(ex) { "Unhandled exception in DeviceHubManager scope." }
|
||||
if (managerScope.isActive) {
|
||||
managerScope.launch {
|
||||
messagingSystem.logSystem(
|
||||
"Unhandled exception in DeviceHubManager: ${ex::class.simpleName} - ${ex.message}",
|
||||
"DeviceHubManagerError"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
context.logger.error { "Manager scope inactive, cannot log unhandled DeviceHubManager exception." }
|
||||
}
|
||||
}
|
||||
|
||||
private val parentJob = SupervisorJob(context.coroutineContext[Job])
|
||||
private val isActive = atomic(true)
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
private val defaultDispatcher: CoroutineDispatcher = (context.coroutineContext[CoroutineDispatcher] ?: Dispatchers.Default)
|
||||
.limitedParallelism(resourceInfo.getConcurrencyLevel())
|
||||
|
||||
private val managerScope: CoroutineScope = CoroutineScope(
|
||||
parentJob + defaultDispatcher + exceptionHandler + CoroutineName("DeviceHubManager")
|
||||
)
|
||||
|
||||
private val cleanupJob: Job = managerScope.launch(CoroutineName("DeviceHubManager-Cleanup")) {
|
||||
while (this@DeviceHubManager.isActive.value && currentCoroutineContext().isActive) {
|
||||
try {
|
||||
timeSource.delay(hubConfig.resourceCleanupInterval)
|
||||
if (!this@DeviceHubManager.isActive.value) break
|
||||
val cbMaxIdleOpen = hubConfig.meta["circuitBreakerMaxIdleTimeOpen"]?.string?.let {
|
||||
ParsingUtils.parseDurationOrNull(it)
|
||||
} ?: (hubConfig.resourceMaxIdleTime * 5)
|
||||
|
||||
circuitBreakerManager.cleanup(hubConfig.resourceMaxIdleTime, cbMaxIdleOpen)
|
||||
restartManager.cleanup()
|
||||
} catch (_: CancellationException) {
|
||||
context.logger.info { "DeviceHubManager cleanup job cancelled." }
|
||||
break
|
||||
} catch (e: Exception) {
|
||||
context.logger.error(e) { "Error during DeviceHubManager resource cleanup." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public val devices: Map<Name, Device> get() = deviceRegistry.devices
|
||||
|
||||
public fun launchGlobal(
|
||||
coroutineContext: CoroutineContext = EmptyCoroutineContext,
|
||||
block: suspend CoroutineScope.() -> Unit
|
||||
): Job = managerScope.launch(context = coroutineContext, block = block)
|
||||
|
||||
public suspend fun recordDuration(name: String, duration: Duration, tags: Map<String, String> = emptyMap()): Unit =
|
||||
metricsPublisher.recordDuration(name, duration, tags)
|
||||
|
||||
public suspend fun incrementCounter(name: String, amount: Double = 1.0, tags: Map<String, String> = emptyMap()): Unit =
|
||||
metricsPublisher.incrementCounter(name, amount, tags)
|
||||
|
||||
public suspend fun recordGauge(name: String, value: Double, tags: Map<String, String> = emptyMap()): Unit =
|
||||
metricsPublisher.recordGauge(name, value, tags)
|
||||
|
||||
public suspend fun recordDistribution(name: String, value: Double, tags: Map<String, String> = emptyMap()): Unit =
|
||||
metricsPublisher.recordDistribution(name, value, tags)
|
||||
|
||||
public suspend fun publishMessage(message: Message): Unit = messagingSystem.publish(message)
|
||||
|
||||
public suspend fun attachDevice(
|
||||
name: Name,
|
||||
device: Device,
|
||||
config: DeviceLifecycleConfig,
|
||||
meta: Meta? = null,
|
||||
startMode: StartMode = StartMode.NONE
|
||||
) {
|
||||
if (!isActive.value) {
|
||||
throw DeviceConfigurationException("DeviceHubManager is not active, cannot attach device '$name'.")
|
||||
}
|
||||
lifecycleManager.attachDevice(name, device, config, meta, startMode)
|
||||
}
|
||||
|
||||
public suspend fun detachDevice(name: Name, waitStop: Boolean = false): Unit =
|
||||
lifecycleManager.detachDevice(name, waitStop)
|
||||
|
||||
public suspend fun restartDevice(name: Name): Boolean {
|
||||
if (!isActive.value) {
|
||||
throw DeviceConfigurationException("DeviceHubManager is not active, cannot restart device '$name'.")
|
||||
}
|
||||
return restartManager.restartDevice(name)
|
||||
}
|
||||
|
||||
public suspend fun startDevicesBatch(deviceNames: List<Name>): Boolean =
|
||||
transactionManager.withTransaction { txContext ->
|
||||
val startedInThisBatch = mutableListOf<Name>()
|
||||
try {
|
||||
for (name in deviceNames) {
|
||||
val deviceJob = deviceRegistry.getDeviceJob(name)
|
||||
?: throw DeviceNotFoundException("Device '$name' not found for batch start.")
|
||||
if ((deviceJob.device as? WithLifeCycle)?.lifecycleState == LifecycleState.STARTED) {
|
||||
logger.info { "Device '$name' in batch start is already started, skipping." }
|
||||
continue
|
||||
}
|
||||
lifecycleManager.startDevice(name, deviceJob.config, deviceJob.device)
|
||||
startedInThisBatch.add(name)
|
||||
txContext.recordAction(object : ReversibleAction {
|
||||
override val id: String = "start_device_batch_$name"
|
||||
override suspend fun reverse() {
|
||||
try {
|
||||
logger.info { "Rolling back batch start: Stopping device '$name'." }
|
||||
lifecycleManager.stopDevice(name)
|
||||
} catch (e: Exception) {
|
||||
logger.warn { "Failed to reverse batch start (stop '$name') for TX ${txContext.id}." }
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
messagingSystem.logSystem("Batch start successful for devices: $startedInThisBatch.", "DeviceHubManager")
|
||||
true
|
||||
} catch (ex: Exception) {
|
||||
logger.error(ex) { "Failed to start devices batch ($deviceNames), initiating rollback." }
|
||||
messagingSystem.logSystem("Batch start failed for devices ($deviceNames): ${ex.message}. Rolling back.", "DeviceHubManager")
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun stopDevicesBatch(deviceNames: List<Name>): Boolean =
|
||||
transactionManager.withTransaction { txContext ->
|
||||
val stoppedInThisBatch = mutableListOf<Name>()
|
||||
try {
|
||||
for (name in deviceNames) {
|
||||
val deviceJob = deviceRegistry.getDeviceJob(name)
|
||||
?: throw DeviceNotFoundException("Device '$name' not found for batch stop.")
|
||||
if ((deviceJob.device as? WithLifeCycle)?.lifecycleState == LifecycleState.STARTED) {
|
||||
lifecycleManager.stopDevice(name)
|
||||
stoppedInThisBatch.add(name)
|
||||
txContext.recordAction(object : ReversibleAction {
|
||||
override val id: String = "stop_device_batch_$name"
|
||||
override suspend fun reverse() {
|
||||
try {
|
||||
logger.info { "Rolling back batch stop: Starting device '$name'." }
|
||||
lifecycleManager.startDevice(name, deviceJob.config, deviceJob.device)
|
||||
} catch (e: Exception) {
|
||||
logger.warn { "Failed to reverse batch stop (start '$name') for TX ${txContext.id}." }
|
||||
}
|
||||
}
|
||||
})
|
||||
} else {
|
||||
logger.info { "Device '$name' in batch stop is already stopped or not applicable, skipping." }
|
||||
}
|
||||
}
|
||||
messagingSystem.logSystem("Batch stop successful for devices: $stoppedInThisBatch.", "DeviceHubManager")
|
||||
true
|
||||
} catch (ex: Exception) {
|
||||
logger.error(ex) { "Failed to stop devices batch ($deviceNames), initiating rollback." }
|
||||
messagingSystem.logSystem("Batch stop failed for devices ($deviceNames): ${ex.message}. Rolling back.", "DeviceHubManager")
|
||||
throw ex
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun hotSwapDevice(
|
||||
name: Name,
|
||||
newDevice: Device,
|
||||
newConfig: DeviceLifecycleConfig,
|
||||
newMeta: Meta? = null
|
||||
): Unit = transactionManager.withTransaction { txContext ->
|
||||
if (!isActive.value) {
|
||||
throw DeviceConfigurationException("DeviceHubManager is not active, cannot hot swap device '$name'.")
|
||||
}
|
||||
val oldJob = deviceRegistry.getDeviceJob(name)
|
||||
var oldDeviceWasStarted = false
|
||||
|
||||
if (oldJob != null) {
|
||||
oldDeviceWasStarted = (oldJob.device as? WithLifeCycle)?.lifecycleState == LifecycleState.STARTED
|
||||
txContext.recordAction(object : ReversibleAction {
|
||||
override val id: String = "hotSwap_revert_$name"
|
||||
override suspend fun reverse() {
|
||||
logger.info { "Reverting hot swap for '$name' to old device configuration." }
|
||||
val currentJobForNew = deviceRegistry.getDeviceJob(name)
|
||||
if (currentJobForNew != null && currentJobForNew.device === newDevice) {
|
||||
if ((currentJobForNew.device as? WithLifeCycle)?.lifecycleState == LifecycleState.STARTED) {
|
||||
try { lifecycleManager.stopDevice(name) } catch (e: Exception) {
|
||||
logger.warn { "Error stopping new device '$name' during hotswap revert." }
|
||||
}
|
||||
}
|
||||
deviceRegistry.removeDevice(name)
|
||||
}
|
||||
deviceRegistry.registerDevice(name, oldJob.device, oldJob.config, oldJob.meta) { msg ->
|
||||
messagingSystem.publish(msg)
|
||||
}
|
||||
if (oldDeviceWasStarted && oldJob.config.lifecycleMode != LifecycleMode.INDEPENDENT) {
|
||||
try {
|
||||
lifecycleManager.startDevice(name, oldJob.config, oldJob.device)
|
||||
} catch (e: Exception) {
|
||||
logger.warn { "Error restarting old device '$name' during hotswap revert." }
|
||||
}
|
||||
}
|
||||
logger.info { "Hot swap revert for '$name' completed." }
|
||||
}
|
||||
})
|
||||
if (oldDeviceWasStarted) {
|
||||
lifecycleManager.stopDevice(name)
|
||||
}
|
||||
deviceRegistry.removeDevice(name)
|
||||
logger.info { "Old device '$name' stopped and removed for hot swap." }
|
||||
}
|
||||
|
||||
val startModeForNew = if (newConfig.lifecycleMode != LifecycleMode.INDEPENDENT && (oldJob == null || oldDeviceWasStarted)) {
|
||||
StartMode.SYNC
|
||||
} else {
|
||||
StartMode.NONE
|
||||
}
|
||||
lifecycleManager.attachDevice(name, newDevice, newConfig, newMeta, startModeForNew)
|
||||
messagingSystem.logSystem("Device '$name' successfully hot-swapped.", "DeviceHubManager")
|
||||
}
|
||||
|
||||
public suspend fun publishLog(deviceName: Name? = null, message: String) {
|
||||
if (deviceName != null) {
|
||||
messagingSystem.logDevice(message, deviceName)
|
||||
} else {
|
||||
messagingSystem.logSystem(message, "DeviceHubManager")
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun shutdown() {
|
||||
if (!isActive.compareAndSet(expect = true, update = false)) {
|
||||
context.logger.info { "DeviceHubManager shutdown already in progress or completed." }
|
||||
return
|
||||
}
|
||||
context.logger.info { "Starting DeviceHubManager shutdown..." }
|
||||
cleanupJob.cancelAndJoin()
|
||||
context.logger.info { "Cleanup job cancelled." }
|
||||
|
||||
val deviceNames = deviceRegistry.getDeviceNames().toList()
|
||||
context.logger.info { "Detaching ${deviceNames.size} devices: $deviceNames." }
|
||||
supervisorScope {
|
||||
val detachJobs = deviceNames.map { name ->
|
||||
launch(CoroutineName("Shutdown-Detach-$name")) {
|
||||
try {
|
||||
val detachTimeout = hubConfig.defaultStopTimeout + 5.seconds
|
||||
withTimeout(detachTimeout) { detachDevice(name, true) }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
context.logger.error { "Timed out detaching device '$name' during shutdown." }
|
||||
} catch (e: Exception) {
|
||||
context.logger.error(e) { "Error detaching device '$name' during shutdown." }
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
val overallDetachTimeout = (hubConfig.defaultStopTimeout * deviceNames.size.coerceAtLeast(1)) + (10.seconds * deviceNames.size.coerceAtLeast(1))
|
||||
withTimeout(overallDetachTimeout) { detachJobs.joinAll() }
|
||||
} catch (_: TimeoutCancellationException) {
|
||||
context.logger.warn { "Timed out waiting for all devices to complete detachment." }
|
||||
}
|
||||
}
|
||||
context.logger.info { "All device detachment attempts completed." }
|
||||
deviceRegistry.clear()
|
||||
|
||||
try { messageBus.close(); context.logger.info { "MessageBus closed." } }
|
||||
catch (e: Exception) { context.logger.error(e) { "Error closing message bus." } }
|
||||
try { metricsPublisher.close(); context.logger.info { "MetricsPublisher closed." } }
|
||||
catch (e: Exception) { context.logger.error(e) { "Error closing metrics publisher." } }
|
||||
|
||||
parentJob.cancelAndJoin()
|
||||
context.logger.info { "DeviceHubManager shutdown completed." }
|
||||
}
|
||||
|
||||
public fun deviceExists(name: Name): Boolean = deviceRegistry.containsDevice(name)
|
||||
public fun getAllDeviceNames(): Set<Name> = deviceRegistry.getDeviceNames()
|
||||
|
||||
public suspend fun getCircuitBreakerStatus(deviceName: Name): Map<String, Any>? =
|
||||
circuitBreakerManager.getCircuitBreakerStatus(deviceName)
|
||||
|
||||
public suspend fun resetRestartAttempts(deviceName: Name): Unit =
|
||||
restartManager.resetRestartAttempts(deviceName)
|
||||
|
||||
public suspend fun getRestartAttemptCount(deviceName: Name): Int =
|
||||
restartManager.getRestartAttemptCount(deviceName)
|
||||
}
|
244
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/DeviceLifecycleManager.kt
Normal file
244
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/DeviceLifecycleManager.kt
Normal file
@ -0,0 +1,244 @@
|
||||
package space.kscience.controls.spec.runtime
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import space.kscience.controls.api.*
|
||||
import space.kscience.controls.spec.config.DeviceHubConfig
|
||||
import space.kscience.controls.spec.config.DeviceLifecycleConfig
|
||||
import space.kscience.controls.spec.infra.MessagingSystem
|
||||
import space.kscience.controls.spec.model.*
|
||||
import space.kscience.controls.spec.utils.TimeSource
|
||||
import space.kscience.controls.spec.utils.deviceManagerConfig
|
||||
import space.kscience.controls.spec.config.RestartPolicy
|
||||
import space.kscience.controls.spec.utils.timeSourceOrDefault
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.Logger
|
||||
import space.kscience.dataforge.context.error
|
||||
import space.kscience.dataforge.context.info
|
||||
import space.kscience.dataforge.context.logger
|
||||
import space.kscience.dataforge.context.warn
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.names.Name
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of devices, including attachment, detachment, start, and stop operations.
|
||||
* It interacts with a [DeviceRegistry] and a [MessagingSystem].
|
||||
*/
|
||||
public class DeviceLifecycleManager(
|
||||
private val context: Context,
|
||||
private val registry: DeviceRegistry,
|
||||
private val messagingSystem: MessagingSystem,
|
||||
internal val timeSource: TimeSource = context.timeSourceOrDefault,
|
||||
private val logger: Logger = context.logger
|
||||
) {
|
||||
private val hubConfig: DeviceHubConfig by lazy { context.deviceManagerConfig }
|
||||
|
||||
/**
|
||||
* Attaches a device to the system.
|
||||
*/
|
||||
public suspend fun attachDevice(
|
||||
name: Name,
|
||||
device: Device,
|
||||
config: DeviceLifecycleConfig,
|
||||
meta: Meta? = null,
|
||||
startMode: StartMode = StartMode.NONE
|
||||
) {
|
||||
if (registry.containsDevice(name)) {
|
||||
throw DeviceConfigurationException("Device '$name' already exists in the registry.")
|
||||
}
|
||||
|
||||
registry.registerDevice(name, device, config, meta) { message ->
|
||||
messagingSystem.publish(message)
|
||||
}
|
||||
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceAdded(name.toString()))
|
||||
logger.info { "Device '$name' attached with startMode=$startMode, lifecycleMode=${config.lifecycleMode}." }
|
||||
|
||||
if (config.lifecycleMode == LifecycleMode.INDEPENDENT && (startMode == StartMode.ASYNC || startMode == StartMode.SYNC)) {
|
||||
logger.info { "Device '$name' is INDEPENDENT; explicit start via startMode=$startMode is ignored. Device must be started manually if needed." }
|
||||
return
|
||||
}
|
||||
|
||||
if (config.lifecycleMode == LifecycleMode.LINKED) {
|
||||
when (startMode) {
|
||||
StartMode.NONE -> Unit
|
||||
StartMode.ASYNC -> context.launch(CoroutineName("AsyncStart-$name")) {
|
||||
try {
|
||||
startDevice(name)
|
||||
} catch (e: Exception) {
|
||||
logger.error(e) { "Async start for device '$name' failed." }
|
||||
}
|
||||
}
|
||||
StartMode.SYNC -> startDevice(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a registered device.
|
||||
*/
|
||||
public suspend fun startDevice(
|
||||
name: Name,
|
||||
configOverride: DeviceLifecycleConfig? = null,
|
||||
deviceOverride: Device? = null
|
||||
) {
|
||||
val job = registry.getDeviceJob(name)
|
||||
?: throw DeviceNotFoundException("Device '$name' not found for start operation.")
|
||||
|
||||
val configToUse = configOverride ?: job.config
|
||||
val deviceToUse = deviceOverride ?: job.device
|
||||
val deviceScope = job.deviceScope
|
||||
|
||||
val lifeCycleDevice = deviceToUse as? WithLifeCycle
|
||||
val state = lifeCycleDevice?.lifecycleState ?: LifecycleState.UNKNOWN
|
||||
|
||||
if (state == LifecycleState.STARTED) {
|
||||
logger.warn { "Device '$name' is already started." }
|
||||
return
|
||||
}
|
||||
if (state != LifecycleState.INITIAL && state != LifecycleState.STOPPED && state != LifecycleState.UNKNOWN) {
|
||||
throw DeviceStateTransitionException("Cannot start device '$name' from state $state.")
|
||||
}
|
||||
|
||||
if (lifeCycleDevice == null) {
|
||||
logger.warn { "Device '$name' does not implement WithLifeCycle; cannot be explicitly started. Assuming operational." }
|
||||
if (state == LifecycleState.UNKNOWN) {
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceStarted(name.toString()))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (configToUse.startDelay > Duration.ZERO) {
|
||||
logger.info { "Delaying start of '$name' by ${configToUse.startDelay}." }
|
||||
timeSource.delay(configToUse.startDelay)
|
||||
}
|
||||
|
||||
val startTime = timeSource.now()
|
||||
val startTimeoutDuration = configToUse.startTimeout ?: hubConfig.defaultStartTimeout
|
||||
|
||||
try {
|
||||
withTimeout(startTimeoutDuration) {
|
||||
lifeCycleDevice.start()
|
||||
}
|
||||
val duration = timeSource.now() - startTime
|
||||
messagingSystem.recordDuration("$name.start.duration", duration, name, mapOf("device_name" to name.toString()))
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceStarted(name.toString()))
|
||||
logger.info { "Device '$name' started successfully in $duration." }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
messagingSystem.incrementCounter("$name.start.timeout", sourceDevice = name, tags = mapOf("device_name" to name.toString()))
|
||||
val failure = DeviceTimeoutException("Timeout ($startTimeoutDuration) starting device '$name'.", e)
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceFailed(name.toString(), failure.toSerializableFailure()))
|
||||
messagingSystem.reportDeviceFailure(failure, name)
|
||||
deviceScope.cancel(CancellationException("Device '$name' start timed out, cancelling device scope.", failure)) // Cancel device scope
|
||||
throw failure
|
||||
} catch (e: DeviceException) {
|
||||
messagingSystem.incrementCounter("$name.start.error", sourceDevice = name, tags = mapOf("device_name" to name.toString(), "error_type" to (e::class.simpleName ?: "unknown")))
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceFailed(name.toString(), e.toSerializableFailure()))
|
||||
messagingSystem.reportDeviceFailure(e, name)
|
||||
deviceScope.cancel(CancellationException("Device '$name' failed to start, cancelling device scope.", e)) // Cancel device scope
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
messagingSystem.incrementCounter("$name.start.error", sourceDevice = name, tags = mapOf("device_name" to name.toString(), "error_type" to (e::class.simpleName ?: "unknown")))
|
||||
val failure = DeviceStartupException("Failed to start device '$name' due to an unexpected error.", e)
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceFailed(name.toString(), failure.toSerializableFailure()))
|
||||
messagingSystem.reportDeviceFailure(failure, name)
|
||||
deviceScope.cancel(CancellationException("Device '$name' failed to start unexpectedly, cancelling device scope.", failure)) // Cancel device scope
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detaches a device from the system.
|
||||
*/
|
||||
public suspend fun detachDevice(name: Name, waitStop: Boolean = false) {
|
||||
val deviceJob = registry.removeDevice(name)
|
||||
|
||||
if (deviceJob != null) {
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceRemoved(name.toString()))
|
||||
logger.info { "Device '$name' removed from registry (waitStop=$waitStop)." }
|
||||
|
||||
val lifeCycleDevice = deviceJob.device as? WithLifeCycle
|
||||
if (lifeCycleDevice?.lifecycleState == LifecycleState.STARTED) {
|
||||
if (waitStop) {
|
||||
logger.info { "Stopping device '$name' synchronously as part of detach."}
|
||||
stopDeviceInternal(name, deviceJob, lifeCycleDevice)
|
||||
} else {
|
||||
logger.info { "Stopping device '$name' asynchronously as part of detach."}
|
||||
context.launch(CoroutineName("AsyncStop-$name")) {
|
||||
stopDeviceInternal(name, deviceJob, lifeCycleDevice)
|
||||
}
|
||||
}
|
||||
}
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceDetached(name.toString()))
|
||||
logger.info { "Device '$name' fully detached." }
|
||||
} else {
|
||||
logger.warn { "Device '$name' not found for detachment." }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal utility to stop a device, handling timeouts and errors.
|
||||
*/
|
||||
internal suspend fun stopDeviceInternal(name: Name, deviceJob: DeviceRegistry.DeviceJob, lifeCycleDevice: WithLifeCycle) {
|
||||
val stopTimeoutDuration = deviceJob.config.stopTimeout ?: hubConfig.defaultStopTimeout
|
||||
|
||||
if (lifeCycleDevice.lifecycleState != LifecycleState.STARTED) {
|
||||
if (lifeCycleDevice.lifecycleState == LifecycleState.STOPPED) {
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceStopped(name.toString()))
|
||||
}
|
||||
logger.info { "Device '$name' is not in STARTED state (current: ${lifeCycleDevice.lifecycleState}), skipping stop logic." }
|
||||
return
|
||||
}
|
||||
|
||||
val startTime = timeSource.now()
|
||||
logger.info { "Attempting to stop device '$name'." }
|
||||
|
||||
try {
|
||||
withTimeout(stopTimeoutDuration) {
|
||||
lifeCycleDevice.stop()
|
||||
}
|
||||
val duration = timeSource.now() - startTime
|
||||
messagingSystem.recordDuration("$name.stop.duration", duration, name, mapOf("device_name" to name.toString()))
|
||||
logger.info { "Device '$name' stopped successfully in $duration." }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
messagingSystem.incrementCounter("$name.stop.timeout", sourceDevice = name, tags = mapOf("device_name" to name.toString()))
|
||||
val failure = DeviceTimeoutException("Timeout ($stopTimeoutDuration) stopping device '$name'.", e)
|
||||
messagingSystem.reportDeviceFailure(failure, name)
|
||||
logger.warn { failure.message.toString() }
|
||||
} catch (e: DeviceException) {
|
||||
messagingSystem.incrementCounter("$name.stop.error", sourceDevice = name, tags = mapOf("device_name" to name.toString(), "error_type" to (e::class.simpleName ?: "unknown")))
|
||||
messagingSystem.reportDeviceFailure(e, name)
|
||||
logger.error(e) { "DeviceException while stopping '$name'." }
|
||||
} catch (e: Exception) {
|
||||
messagingSystem.incrementCounter("$name.stop.error", sourceDevice = name, tags = mapOf("device_name" to name.toString(), "error_type" to (e::class.simpleName ?: "unknown")))
|
||||
val failure = DeviceShutdownException("Failed to stop device '$name' due to an unexpected error.", e)
|
||||
messagingSystem.reportDeviceFailure(failure, name)
|
||||
logger.error(failure) { "Unexpected error while stopping '$name'." }
|
||||
} finally {
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceStopped(name.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stops a registered device.
|
||||
*/
|
||||
public suspend fun stopDevice(name: Name) {
|
||||
val job = registry.getDeviceJob(name)
|
||||
?: throw DeviceNotFoundException("Device '$name' not found for stop operation.")
|
||||
|
||||
val lifeCycleDevice = job.device as? WithLifeCycle ?: run {
|
||||
logger.warn { "Device '$name' does not implement WithLifeCycle; cannot be explicitly stopped." }
|
||||
if (job.device.lifecycleState == LifecycleState.UNKNOWN || job.device.lifecycleState == LifecycleState.STARTED) {
|
||||
messagingSystem.publish(messagingSystem.messageFactory.deviceStopped(name.toString()))
|
||||
}
|
||||
return
|
||||
}
|
||||
stopDeviceInternal(name, job, lifeCycleDevice)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates restart delay based on policy and attempts.
|
||||
*/
|
||||
internal fun calculateRestartDelay(policy: RestartPolicy, attempts: Int): Duration =
|
||||
policy.strategy.calculateDelay(policy.delayBetweenAttempts, attempts)
|
||||
}
|
214
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/DeviceRegistry.kt
Normal file
214
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/DeviceRegistry.kt
Normal file
@ -0,0 +1,214 @@
|
||||
package space.kscience.controls.spec.runtime
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.update
|
||||
import space.kscience.controls.api.Device
|
||||
import space.kscience.controls.api.Message
|
||||
import space.kscience.controls.api.id
|
||||
import space.kscience.controls.spec.config.DeviceLifecycleConfig
|
||||
import space.kscience.controls.spec.model.LifecycleMode
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.debug
|
||||
import space.kscience.dataforge.context.error
|
||||
import space.kscience.dataforge.context.info
|
||||
import space.kscience.dataforge.context.logger
|
||||
import space.kscience.dataforge.context.warn
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.dataforge.names.plus
|
||||
import kotlin.coroutines.coroutineContext
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Manages a collection of devices, their configurations, and their message collector jobs.
|
||||
*/
|
||||
public class DeviceRegistry(private val context: Context) {
|
||||
|
||||
/**
|
||||
* Represents a registered device along with its associated [CoroutineScope],
|
||||
* message collector [Job], lifecycle [config], and optional [meta]data.
|
||||
*/
|
||||
public data class DeviceJob(
|
||||
val device: Device,
|
||||
val deviceScope: CoroutineScope,
|
||||
val collectorJob: Job,
|
||||
val config: DeviceLifecycleConfig,
|
||||
val meta: Meta? = null
|
||||
) {
|
||||
val lifecycleMode: LifecycleMode get() = config.lifecycleMode
|
||||
}
|
||||
|
||||
private val _deviceJobs = MutableStateFlow<Map<Name, DeviceJob>>(emptyMap())
|
||||
|
||||
/**
|
||||
* A [StateFlow] emitting the current map of registered device jobs.
|
||||
* Suitable for observing changes to the set of managed devices.
|
||||
*/
|
||||
public val deviceJobsFlow: StateFlow<Map<Name, DeviceJob>> = _deviceJobs.asStateFlow()
|
||||
|
||||
/**
|
||||
* A snapshot map of registered devices. For observing changes, use [deviceJobsFlow].
|
||||
*/
|
||||
public val devices: Map<Name, Device> get() = _deviceJobs.value.mapValues { it.value.device }
|
||||
|
||||
/**
|
||||
* Registers a device with the registry.
|
||||
* A dedicated [CoroutineScope] and a message collector job are created for the device.
|
||||
*
|
||||
* @param name The [Name] to register the device under.
|
||||
* @param device The [Device] instance.
|
||||
* @param config The [DeviceLifecycleConfig] for this device.
|
||||
* @param meta Optional [Meta]data for the device.
|
||||
* @param messageHandler A suspend function to handle messages received from the device's message flow.
|
||||
* @return The created [DeviceJob].
|
||||
*/
|
||||
public suspend fun registerDevice(
|
||||
name: Name,
|
||||
device: Device,
|
||||
config: DeviceLifecycleConfig,
|
||||
meta: Meta? = null,
|
||||
messageHandler: suspend (Message) -> Unit
|
||||
): DeviceJob {
|
||||
val parentJobForDevice = coroutineContext[Job] ?: context.coroutineContext[Job]
|
||||
|
||||
val actualDeviceScope = config.coroutineScope ?: CoroutineScope(
|
||||
SupervisorJob(parentJobForDevice) +
|
||||
(config.dispatcher ?: context.coroutineContext[CoroutineDispatcher] ?: Dispatchers.Default) +
|
||||
CoroutineName("DeviceScope-$name")
|
||||
)
|
||||
|
||||
val collectorJob = actualDeviceScope.launch(CoroutineName("DeviceMsgCollector-$name")) {
|
||||
try {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val flowToCollect: Flow<Message>? = device.messageFlow as? Flow<Message>
|
||||
|
||||
flowToCollect?.catch { e ->
|
||||
context.logger.error(e) { "Error in message flow from device '$name'." }
|
||||
}?.collect { msg ->
|
||||
try {
|
||||
val transformedMsg = msg.changeSource { baseId -> name.plus(baseId) }
|
||||
messageHandler(transformedMsg)
|
||||
} catch (e: Exception) {
|
||||
context.logger.error(e) { "Error handling message from '$name': $msg." }
|
||||
}
|
||||
}
|
||||
} catch (ex: CancellationException) {
|
||||
context.logger.debug { "Message collector for device '$name' cancelled." }
|
||||
throw ex
|
||||
} catch (ex: Exception) {
|
||||
context.logger.error(ex) { "Unexpected error collecting messages from '$name'." }
|
||||
}
|
||||
}
|
||||
|
||||
val deviceJob = DeviceJob(device, actualDeviceScope, collectorJob, config, meta)
|
||||
_deviceJobs.update { it + (name to deviceJob) }
|
||||
context.logger.debug { "Device '$name' registered." }
|
||||
return deviceJob
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the [DeviceJob] for a device by its [Name].
|
||||
*/
|
||||
public fun getDeviceJob(name: Name): DeviceJob? = _deviceJobs.value[name]
|
||||
|
||||
/**
|
||||
* Removes a device from the registry.
|
||||
* This cancels the device's dedicated [CoroutineScope] (which includes the collector job)
|
||||
* and waits for its completion.
|
||||
*
|
||||
* @param name The [Name] of the device to remove.
|
||||
* @return The removed [DeviceJob], or null if the device was not found.
|
||||
*/
|
||||
public suspend fun removeDevice(name: Name): DeviceJob? {
|
||||
var removedJob: DeviceJob? = null
|
||||
_deviceJobs.update { currentJobs ->
|
||||
removedJob = currentJobs[name]
|
||||
if (removedJob != null) currentJobs - name else currentJobs
|
||||
}
|
||||
|
||||
removedJob?.let {
|
||||
context.logger.debug { "Cancelling device scope for '$name' on removal." }
|
||||
it.deviceScope.cancel(CancellationException("Device '$name' removed from registry."))
|
||||
try {
|
||||
withTimeout(
|
||||
it.config.stopTimeout ?: (DeviceLifecycleConfig.Factory.Defaults.DEVICE_STOP_TIMEOUT + 5.seconds)
|
||||
) {
|
||||
it.collectorJob.join()
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
context.logger.warn { "Timeout waiting for device '$name' scope to complete on removal."}
|
||||
}
|
||||
context.logger.debug { "Device '$name' removed from registry." }
|
||||
}
|
||||
return removedJob
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a device in the registry. This involves removing the old [DeviceJob] (if one exists)
|
||||
* and adding the new one. The old device's scope is cancelled.
|
||||
*
|
||||
* @param name The [Name] of the device to update.
|
||||
* @param newJob The new [DeviceJob] to register.
|
||||
*/
|
||||
public suspend fun updateDevice(name: Name, newJob: DeviceJob) {
|
||||
val oldJob = _deviceJobs.value[name]
|
||||
oldJob?.let {
|
||||
it.deviceScope.cancel(CancellationException("Device '$name' updated; cancelling old scope."))
|
||||
try {
|
||||
withTimeout(
|
||||
it.config.stopTimeout ?: (DeviceLifecycleConfig.Factory.Defaults.DEVICE_STOP_TIMEOUT + 5.seconds)
|
||||
) {
|
||||
it.collectorJob.join()
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
context.logger.warn { "Timeout waiting for old job of device '$name' to complete on update."}
|
||||
}
|
||||
}
|
||||
_deviceJobs.update { it + (name to newJob) }
|
||||
context.logger.debug { "Device '$name' updated in registry." }
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a device with the given [Name] is registered.
|
||||
*/
|
||||
public fun containsDevice(name: Name): Boolean = _deviceJobs.value.containsKey(name)
|
||||
|
||||
/**
|
||||
* Gets the [Name]s of all currently registered devices.
|
||||
*/
|
||||
public fun getDeviceNames(): Set<Name> = _deviceJobs.value.keys.toSet()
|
||||
|
||||
/**
|
||||
* Clears the registry, removing all devices and cancelling their associated scopes.
|
||||
*/
|
||||
public suspend fun clear() {
|
||||
val jobsToClear = _deviceJobs.value.values.toList()
|
||||
_deviceJobs.value = emptyMap()
|
||||
if (jobsToClear.isNotEmpty()) {
|
||||
context.logger.info { "Clearing device registry. Cancelling scopes for ${jobsToClear.size} devices." }
|
||||
supervisorScope {
|
||||
jobsToClear.forEach { deviceJob ->
|
||||
launch {
|
||||
deviceJob.deviceScope.cancel(CancellationException("Device registry cleared."))
|
||||
try {
|
||||
withTimeout(
|
||||
deviceJob.config.stopTimeout
|
||||
?: (DeviceLifecycleConfig.Factory.Defaults.DEVICE_STOP_TIMEOUT + 5.seconds)
|
||||
) {
|
||||
deviceJob.collectorJob.join()
|
||||
}
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
context.logger.warn { "Timeout waiting for device '${deviceJob.device.id}' scope to complete during registry clear."}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
context.logger.info { "Device registry cleared." }
|
||||
}
|
||||
}
|
191
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/DeviceRestartManager.kt
Normal file
191
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/DeviceRestartManager.kt
Normal file
@ -0,0 +1,191 @@
|
||||
package space.kscience.controls.spec.runtime
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import space.kscience.controls.api.DeviceNotFoundException
|
||||
import space.kscience.controls.api.DeviceStartupException
|
||||
import space.kscience.controls.spec.model.* // Imports exceptions, LifecycleMode etc.
|
||||
import space.kscience.controls.spec.infra.MessagingSystem
|
||||
import space.kscience.controls.spec.utils.TimeSource
|
||||
import space.kscience.controls.spec.utils.timeSourceOrDefault
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.Logger
|
||||
import space.kscience.dataforge.context.debug
|
||||
import space.kscience.dataforge.context.error
|
||||
import space.kscience.dataforge.context.info
|
||||
import space.kscience.dataforge.context.logger
|
||||
import space.kscience.dataforge.context.warn
|
||||
import space.kscience.dataforge.names.Name
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Manages device restart operations, applying restart policies and interacting with the [CircuitBreakerManager].
|
||||
*/
|
||||
public class DeviceRestartManager(
|
||||
private val context: Context,
|
||||
private val registry: DeviceRegistry,
|
||||
private val lifecycleManager: DeviceLifecycleManager,
|
||||
private val circuitBreakerManager: CircuitBreakerManager,
|
||||
private val messagingSystem: MessagingSystem,
|
||||
private val timeSource: TimeSource = context.timeSourceOrDefault,
|
||||
private val logger: Logger = context.logger
|
||||
) {
|
||||
private data class RestartState(var attempts: Int = 0, var isRestarting: Boolean = false)
|
||||
|
||||
private val restartStates = mutableMapOf<Name, RestartState>()
|
||||
private val restartStateLock = Mutex()
|
||||
|
||||
public suspend fun restartDevice(name: Name): Boolean {
|
||||
val initialDeviceJob = registry.getDeviceJob(name)
|
||||
?: throw DeviceNotFoundException("Device '$name' not found, cannot perform restart.")
|
||||
|
||||
val policy = initialDeviceJob.config.restartPolicy
|
||||
var attemptForThisRun: Int
|
||||
var isCurrentAttemptHalfOpen = false
|
||||
|
||||
restartStateLock.withLock {
|
||||
val state = restartStates.getOrPut(name) { RestartState() }
|
||||
if (state.isRestarting) {
|
||||
logger.warn { "Device '$name' restart skipped: Already in process of restarting." }
|
||||
messagingSystem.incrementCounter(
|
||||
"$name.restart.rejected",
|
||||
tags = mapOf("device_name" to name.toString(), "reason" to "already_restarting")
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!circuitBreakerManager.shouldAttemptRestart(name, policy)) {
|
||||
val cbStatus = circuitBreakerManager.getCircuitBreakerStatus(name)
|
||||
if (cbStatus?.get("halfOpenAttemptInProgress") == true) {
|
||||
isCurrentAttemptHalfOpen = true
|
||||
logger.info { "Circuit breaker for '$name' is HALF-OPEN. Permitting one restart attempt." }
|
||||
} else {
|
||||
logger.warn { "Circuit breaker denied restart attempt for '$name' (state: OPEN)." }
|
||||
messagingSystem.incrementCounter(
|
||||
"$name.restart.rejected",
|
||||
tags = mapOf("device_name" to name.toString(), "reason" to "circuit_breaker_open")
|
||||
)
|
||||
return false
|
||||
}
|
||||
}
|
||||
state.isRestarting = true
|
||||
state.attempts++
|
||||
attemptForThisRun = state.attempts
|
||||
}
|
||||
|
||||
var success = false
|
||||
try {
|
||||
val restartStartTime = timeSource.now()
|
||||
messagingSystem.incrementCounter(
|
||||
"$name.restart.attempt",
|
||||
tags = mapOf("device_name" to name.toString(), "attempt" to attemptForThisRun.toString(), "half_open" to isCurrentAttemptHalfOpen.toString())
|
||||
)
|
||||
|
||||
if (attemptForThisRun > policy.maxAttempts && !isCurrentAttemptHalfOpen) {
|
||||
logger.warn { "Max restart attempts (${policy.maxAttempts}) exceeded for '$name'. Current: $attemptForThisRun." }
|
||||
messagingSystem.incrementCounter(
|
||||
"$name.restart.max_attempts_exceeded",
|
||||
tags = mapOf("device_name" to name.toString())
|
||||
)
|
||||
if (!isCurrentAttemptHalfOpen) circuitBreakerManager.recordRestartFailure(name, policy)
|
||||
} else {
|
||||
if (!isCurrentAttemptHalfOpen) {
|
||||
val delayDuration = lifecycleManager.calculateRestartDelay(policy, attemptForThisRun)
|
||||
if (delayDuration > Duration.ZERO) {
|
||||
logger.info { "Delaying restart of '$name' by $delayDuration (Attempt $attemptForThisRun)." }
|
||||
messagingSystem.recordDuration(
|
||||
"$name.restart.delay", delayDuration, name,
|
||||
mapOf("device_name" to name.toString(), "attempt" to attemptForThisRun.toString())
|
||||
)
|
||||
timeSource.delay(delayDuration)
|
||||
}
|
||||
}
|
||||
|
||||
val attemptType = if (isCurrentAttemptHalfOpen) "HALF-OPEN" else "NORMAL"
|
||||
logger.info { "Executing $attemptType restart for device '$name' (Attempt $attemptForThisRun)." }
|
||||
try {
|
||||
lifecycleManager.detachDevice(name, waitStop = true)
|
||||
lifecycleManager.attachDevice(
|
||||
name, initialDeviceJob.device, initialDeviceJob.config, initialDeviceJob.meta, StartMode.NONE
|
||||
)
|
||||
val newDeviceJob = registry.getDeviceJob(name)
|
||||
?: throw DeviceStartupException("Failed to re-register device '$name' during restart.")
|
||||
|
||||
if (newDeviceJob.config.lifecycleMode != LifecycleMode.INDEPENDENT) {
|
||||
val startOpTime = timeSource.now()
|
||||
lifecycleManager.startDevice(name, newDeviceJob.config, newDeviceJob.device)
|
||||
messagingSystem.recordDuration(
|
||||
"$name.restart.start_duration", timeSource.now() - startOpTime, name,
|
||||
mapOf("device_name" to name.toString())
|
||||
)
|
||||
}
|
||||
success = true
|
||||
} catch (restartEx: Exception) {
|
||||
logger.error(restartEx) { "Core restart logic failed for '$name' (Attempt $attemptForThisRun, Type: $attemptType)." }
|
||||
messagingSystem.incrementCounter(
|
||||
"$name.restart.core_failure",
|
||||
tags = mapOf("device_name" to name.toString(), "error_type" to (restartEx::class.simpleName ?: "unknown"))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (success) {
|
||||
if (policy.resetOnSuccess && !isCurrentAttemptHalfOpen) {
|
||||
restartStateLock.withLock { restartStates[name]?.attempts = 0 }
|
||||
}
|
||||
if (!isCurrentAttemptHalfOpen) circuitBreakerManager.recordRestartSuccess(name)
|
||||
|
||||
messagingSystem.recordDuration(
|
||||
"$name.restart.total_duration", timeSource.now() - restartStartTime, name,
|
||||
mapOf("device_name" to name.toString())
|
||||
)
|
||||
messagingSystem.incrementCounter("$name.restart.success", tags = mapOf("device_name" to name.toString()))
|
||||
messagingSystem.logDevice("Device '$name' successfully restarted.", name)
|
||||
} else {
|
||||
if (!isCurrentAttemptHalfOpen) circuitBreakerManager.recordRestartFailure(name, policy)
|
||||
|
||||
messagingSystem.incrementCounter("$name.restart.failure", tags = mapOf("device_name" to name.toString()))
|
||||
messagingSystem.logDevice("Failed to restart device '$name' (Attempt $attemptForThisRun).", name)
|
||||
}
|
||||
return success
|
||||
} finally {
|
||||
if (isCurrentAttemptHalfOpen) {
|
||||
try {
|
||||
circuitBreakerManager.concludeHalfOpenAttempt(name, success)
|
||||
} catch (concludeEx: Exception) {
|
||||
logger.error(concludeEx) { "Error concluding half-open attempt for '$name'." }
|
||||
}
|
||||
}
|
||||
restartStateLock.withLock { restartStates[name]?.isRestarting = false }
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun resetRestartAttempts(deviceName: Name) {
|
||||
restartStateLock.withLock { restartStates.remove(deviceName) }
|
||||
circuitBreakerManager.resetCircuitBreaker(deviceName)
|
||||
logger.info { "Restart attempts and circuit breaker state for device '$deviceName' manually reset." }
|
||||
messagingSystem.logSystem("Restart attempts and CB for '$deviceName' reset.", "DeviceRestartManager")
|
||||
}
|
||||
|
||||
public suspend fun getRestartAttemptCount(deviceName: Name): Int =
|
||||
restartStateLock.withLock { restartStates[deviceName]?.attempts ?: 0 }
|
||||
|
||||
public suspend fun cleanup() {
|
||||
val registeredDeviceNames = registry.getDeviceNames()
|
||||
var cleanedCount = 0
|
||||
restartStateLock.withLock {
|
||||
val initialSize = restartStates.size
|
||||
restartStates.entries.removeAll { (key, state) ->
|
||||
val shouldRemove = !state.isRestarting && key !in registeredDeviceNames
|
||||
if (shouldRemove) {
|
||||
logger.debug { "Cleaning stale restart state for unregistered device '$key'." }
|
||||
}
|
||||
shouldRemove
|
||||
}
|
||||
cleanedCount = initialSize - restartStates.size
|
||||
}
|
||||
if (cleanedCount > 0) {
|
||||
logger.info { "Cleaned $cleanedCount stale restart attempt entries." }
|
||||
}
|
||||
}
|
||||
}
|
15
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/SystemResourceInfo.kt
Normal file
15
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/SystemResourceInfo.kt
Normal file
@ -0,0 +1,15 @@
|
||||
package space.kscience.controls.spec.runtime
|
||||
|
||||
import space.kscience.controls.spec.utils.deviceManagerConfig
|
||||
import space.kscience.dataforge.context.Context
|
||||
|
||||
/**
|
||||
* Provides information about system resources relevant to device management,
|
||||
* primarily the configured default concurrency level from [DeviceHubConfig].
|
||||
*/
|
||||
public class SystemResourceInfo(private val context: Context) {
|
||||
/**
|
||||
* Returns the configured default concurrency level for device operations.
|
||||
*/
|
||||
public fun getConcurrencyLevel(): Int = context.deviceManagerConfig.defaultConcurrencyLevel
|
||||
}
|
181
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/TransactionManager.kt
Normal file
181
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/runtime/TransactionManager.kt
Normal file
@ -0,0 +1,181 @@
|
||||
package space.kscience.controls.spec.runtime
|
||||
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.datetime.Instant
|
||||
import space.kscience.controls.api.TransactionMessage.*
|
||||
import space.kscience.controls.spec.infra.MessagingSystem
|
||||
import space.kscience.controls.spec.utils.TimeSource
|
||||
import space.kscience.dataforge.context.Logger
|
||||
import space.kscience.dataforge.context.error
|
||||
import space.kscience.dataforge.context.info
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
import kotlin.coroutines.coroutineContext
|
||||
|
||||
/**
|
||||
* Interface for a reversible action that can be undone during transaction rollback.
|
||||
*/
|
||||
public interface ReversibleAction {
|
||||
/** Unique action identifier. */
|
||||
public val id: String
|
||||
/** Reverses the action. Should be idempotent if possible. */
|
||||
public suspend fun reverse()
|
||||
}
|
||||
|
||||
/**
|
||||
* Context for a single transaction, tracking its ID, start time, and recorded actions.
|
||||
*/
|
||||
public class TransactionContext internal constructor(
|
||||
public val id: String,
|
||||
private val timeSource: TimeSource,
|
||||
private val actions: MutableList<ReversibleAction> = mutableListOf()
|
||||
) {
|
||||
public val startTime: Instant = timeSource.now()
|
||||
private val mutex = Mutex()
|
||||
private val savepoints = mutableMapOf<String, Int>()
|
||||
|
||||
public suspend fun recordAction(action: ReversibleAction) {
|
||||
mutex.withLock { actions.add(action) }
|
||||
}
|
||||
|
||||
public suspend fun createSavepoint(name: String): String {
|
||||
mutex.withLock {
|
||||
if (savepoints.containsKey(name)) {
|
||||
throw IllegalArgumentException("Savepoint '$name' already exists in TX '$id'.")
|
||||
}
|
||||
savepoints[name] = actions.size
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
public suspend fun rollbackToSavepoint(name: String, logger: Logger? = null) {
|
||||
val actionsToRollback: List<ReversibleAction> = mutex.withLock {
|
||||
val index = savepoints[name] ?: throw IllegalArgumentException("Savepoint '$name' not found in TX '$id'.")
|
||||
val rollbackList = if (index < actions.size) actions.subList(index, actions.size).toList().asReversed() else emptyList()
|
||||
if (index < actions.size) actions.subList(index, actions.size).clear()
|
||||
savepoints.entries.removeAll { it.value > index }
|
||||
rollbackList
|
||||
}
|
||||
logger?.info { "Rolling back TX '$id' to savepoint '$name'. Reversing ${actionsToRollback.size} actions." }
|
||||
for (action in actionsToRollback) {
|
||||
try { action.reverse() }
|
||||
catch (e: Exception) { logger?.error(e) { "Error reversing action '${action.id}' during rollback to savepoint '$name' in TX '$id'." } }
|
||||
}
|
||||
}
|
||||
|
||||
public suspend fun getActions(): List<ReversibleAction> = mutex.withLock { actions.toList() }
|
||||
}
|
||||
|
||||
/**
|
||||
* CoroutineContext element holding the current [TransactionContext].
|
||||
*/
|
||||
private class TransactionContextElement(val context: TransactionContext) : CoroutineContext.Element {
|
||||
companion object Key : CoroutineContext.Key<TransactionContextElement>
|
||||
override val key: CoroutineContext.Key<*> = Key
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages transactional operations.
|
||||
*/
|
||||
public interface TransactionManager {
|
||||
public suspend fun <T> withTransaction(block: suspend (TransactionContext) -> T): T
|
||||
public suspend fun recordAction(action: ReversibleAction)
|
||||
public suspend fun isInTransaction(): Boolean
|
||||
public suspend fun createSavepoint(name: String): String
|
||||
public suspend fun rollbackToSavepoint(name: String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Default implementation of [TransactionManager].
|
||||
*/
|
||||
public class TransactionManagerImpl(
|
||||
private val messagingSystem: MessagingSystem,
|
||||
private val logger: Logger,
|
||||
private val timeSource: TimeSource
|
||||
) : TransactionManager {
|
||||
private val GidManagerLock = Mutex()
|
||||
private val activeTransactions = mutableMapOf<String, TransactionContext>()
|
||||
|
||||
override suspend fun <T> withTransaction(block: suspend (TransactionContext) -> T): T {
|
||||
val currentCoroutineCtx = coroutineContext
|
||||
val existingTxElement = currentCoroutineCtx[TransactionContextElement.Key]
|
||||
|
||||
if (existingTxElement != null) {
|
||||
logger.info { "Joining existing transaction '${existingTxElement.context.id}'." }
|
||||
return block(existingTxElement.context)
|
||||
}
|
||||
|
||||
val txId = "tx_${timeSource.now().toEpochMilliseconds()}_${(0..Int.MAX_VALUE).random().toString(16)}"
|
||||
val txContext = TransactionContext(txId, timeSource)
|
||||
|
||||
try {
|
||||
GidManagerLock.withLock { activeTransactions[txId] = txContext }
|
||||
messagingSystem.publish(TransactionStartedMessage(txId, time = timeSource.now()))
|
||||
logger.info { "Transaction '$txId' started." }
|
||||
|
||||
val result = withContext(currentCoroutineCtx + TransactionContextElement(txContext)) {
|
||||
block(txContext)
|
||||
}
|
||||
|
||||
messagingSystem.publish(TransactionCommittedMessage(txId, time = timeSource.now()))
|
||||
logger.info { "Transaction '$txId' committed." }
|
||||
return result
|
||||
} catch (ex: Exception) {
|
||||
if (ex is CancellationException) {
|
||||
logger.info { "Transaction '$txId' cancelled." }
|
||||
messagingSystem.publish(TransactionRolledBackMessage(txId, "Transaction cancelled.", ex::class.simpleName, time = timeSource.now()))
|
||||
throw ex
|
||||
}
|
||||
logger.error(ex) { "Transaction '$txId' failed. Initiating rollback." }
|
||||
var rollbackError: Exception? = null
|
||||
try {
|
||||
val actionsToRollback = txContext.getActions().reversed()
|
||||
for (action in actionsToRollback) {
|
||||
try { action.reverse() }
|
||||
catch (undoEx: Exception) {
|
||||
val msg = "Failed to reverse action '${action.id}' during rollback of TX '$txId'."
|
||||
logger.error(undoEx) { msg }
|
||||
val wrappedError = RuntimeException(msg, undoEx)
|
||||
if (rollbackError == null) rollbackError = wrappedError else rollbackError.addSuppressed(wrappedError)
|
||||
}
|
||||
}
|
||||
} catch (getActionEx: Exception) {
|
||||
logger.error(getActionEx) { "Critical error getting actions for rollback in TX '$txId'." }
|
||||
val criticalError = RuntimeException("Critical error during rollback prep for TX '$txId'", getActionEx)
|
||||
if (rollbackError == null) rollbackError = criticalError else rollbackError.addSuppressed(criticalError)
|
||||
}
|
||||
messagingSystem.publish(TransactionRolledBackMessage(txId, ex.message, ex::class.simpleName, time = timeSource.now()))
|
||||
rollbackError?.let { ex.addSuppressed(it) }
|
||||
throw ex
|
||||
} finally {
|
||||
GidManagerLock.withLock { activeTransactions.remove(txId) }
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun recordAction(action: ReversibleAction) {
|
||||
val txCtx = coroutineContext[TransactionContextElement.Key]?.context
|
||||
?: throw IllegalStateException("Cannot record action: Not in an active transaction.")
|
||||
txCtx.recordAction(action)
|
||||
}
|
||||
|
||||
override suspend fun isInTransaction(): Boolean = coroutineContext[TransactionContextElement.Key] != null
|
||||
|
||||
override suspend fun createSavepoint(name: String): String {
|
||||
val txCtx = coroutineContext[TransactionContextElement.Key]?.context
|
||||
?: throw IllegalStateException("Cannot create savepoint: Not in an active transaction.")
|
||||
val savepointName = txCtx.createSavepoint(name)
|
||||
messagingSystem.publish(TransactionSavepointMessage(txCtx.id, savepointName, time = timeSource.now()))
|
||||
logger.info { "Savepoint '$savepointName' created in TX '${txCtx.id}'."}
|
||||
return savepointName
|
||||
}
|
||||
|
||||
override suspend fun rollbackToSavepoint(name: String) {
|
||||
val txCtx = coroutineContext[TransactionContextElement.Key]?.context
|
||||
?: throw IllegalStateException("Cannot rollback: Not in an active transaction.")
|
||||
txCtx.rollbackToSavepoint(name, logger)
|
||||
logger.info { "Rolled back to savepoint '$name' in TX '${txCtx.id}'."}
|
||||
// TODO publishing a "TransactionRolledBackToSavepointMessage" for observability.
|
||||
}
|
||||
}
|
38
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/utils/ContextExtensions.kt
Normal file
38
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/utils/ContextExtensions.kt
Normal file
@ -0,0 +1,38 @@
|
||||
package space.kscience.controls.spec.utils
|
||||
|
||||
import space.kscience.controls.spec.config.DeviceHubConfig
|
||||
import space.kscience.controls.spec.runtime.DeviceHubManager
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.Plugin
|
||||
import space.kscience.dataforge.context.PluginTag
|
||||
|
||||
/**
|
||||
* Extension to get [DeviceHubManager] from context.
|
||||
* @throws IllegalStateException if [DeviceHubManager] plugin is not registered.
|
||||
*/
|
||||
public val Context.deviceHubManager: DeviceHubManager
|
||||
get() = plugins[DeviceHubManager.tag] as? DeviceHubManager
|
||||
?: throw IllegalStateException("DeviceHubManager plugin not found. Ensure registered: context.plugin(DeviceHubManager).")
|
||||
|
||||
/**
|
||||
* Extension to get [DeviceHubManager] from context, returning null if not found.
|
||||
*/
|
||||
public val Context.deviceHubManagerOrNull: DeviceHubManager?
|
||||
get() = plugins[DeviceHubManager.tag] as? DeviceHubManager
|
||||
|
||||
/**
|
||||
* Extension to get [DeviceHubConfig] from context.
|
||||
* @throws IllegalStateException if [DeviceHubConfig] plugin is not registered.
|
||||
*/
|
||||
public val Context.deviceManagerConfig: DeviceHubConfig
|
||||
get() = plugins[DeviceHubConfig.tag] as? DeviceHubConfig
|
||||
?: throw IllegalStateException("DeviceHubConfig plugin not found. Ensure registered: context.plugin(DeviceHubConfig).")
|
||||
|
||||
|
||||
/**
|
||||
* Requests a plugin of a specified type [T] from the context using its [pluginTag].
|
||||
* @throws IllegalStateException if the required plugin is not found or type mismatch.
|
||||
*/
|
||||
public inline fun <reified T : Plugin> Context.requirePlugin(pluginTag: PluginTag): T =
|
||||
plugins[pluginTag] as? T
|
||||
?: throw IllegalStateException("Required plugin '${pluginTag.name}' (type ${T::class.simpleName}) not found or type mismatch in context $this.")
|
19
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/utils/ParsingUtils.kt
Normal file
19
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/utils/ParsingUtils.kt
Normal file
@ -0,0 +1,19 @@
|
||||
package space.kscience.controls.spec.utils
|
||||
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Internal utility object for parsing operations.
|
||||
*/
|
||||
internal object ParsingUtils {
|
||||
/**
|
||||
* Parses a string to a [Duration], returning null on failure.
|
||||
*/
|
||||
internal fun parseDurationOrNull(raw: String?): Duration? = raw?.trim()?.takeIf { it.isNotEmpty() }?.let {
|
||||
try {
|
||||
Duration.parse(it)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
52
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/utils/TimeSource.kt
Normal file
52
controls-composite-spec/src/commonMain/kotlin/space/kscience/controls/spec/utils/TimeSource.kt
Normal file
@ -0,0 +1,52 @@
|
||||
package space.kscience.controls.spec.utils
|
||||
|
||||
import kotlinx.coroutines.delay as kotlinDelay
|
||||
import kotlinx.datetime.Clock
|
||||
import kotlinx.datetime.Instant
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.Plugin
|
||||
import space.kscience.dataforge.context.PluginFactory
|
||||
import space.kscience.dataforge.context.PluginTag
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import kotlin.time.Duration
|
||||
|
||||
/**
|
||||
* Abstraction for working with time and delays.
|
||||
* Allows injection of custom time sources for testing.
|
||||
*/
|
||||
public interface TimeSource : Plugin {
|
||||
public fun now(): Instant
|
||||
public suspend fun delay(duration: Duration)
|
||||
override val tag: PluginTag get() = Factory.tag
|
||||
|
||||
public companion object Factory : PluginFactory<TimeSource> {
|
||||
override val tag: PluginTag = PluginTag("TimeSource", PluginTag.DATAFORGE_GROUP)
|
||||
override fun build(context: Context, meta: Meta): TimeSource = SystemTimeSource
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard implementation of [TimeSource] using system clock and coroutine delay.
|
||||
*/
|
||||
public object SystemTimeSource : TimeSource {
|
||||
override fun now(): Instant = Clock.System.now()
|
||||
override suspend fun delay(duration: Duration) { kotlinDelay(duration) }
|
||||
override val tag: PluginTag = PluginTag("SystemTimeSource", PluginTag.DATAFORGE_GROUP)
|
||||
override val meta: Meta get() = Meta.EMPTY
|
||||
override val context: Context get() = throw UnsupportedOperationException("SystemTimeSource is a global object and not bound to a specific context.")
|
||||
override val isAttached: Boolean = false
|
||||
override fun dependsOn(): Map<PluginFactory<*>, Meta> = emptyMap()
|
||||
override fun attach(context: Context) { /* No-op */ }
|
||||
override fun detach() { /* No-op */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Extension to get [TimeSource] from context.
|
||||
*/
|
||||
public fun Context.getTimeSource(default: TimeSource = SystemTimeSource): TimeSource =
|
||||
plugins[TimeSource.Factory.tag] as? TimeSource ?: default
|
||||
|
||||
/**
|
||||
* Convenience property to get [TimeSource] from context, or [SystemTimeSource] if none.
|
||||
*/
|
||||
public val Context.timeSourceOrDefault: TimeSource get() = getTimeSource()
|
1025
controls-composite-spec/src/commonTest/kotlin/space/kscience/controls/spec/CompositeControlTest.kt
Normal file
1025
controls-composite-spec/src/commonTest/kotlin/space/kscience/controls/spec/CompositeControlTest.kt
Normal file
File diff suppressed because it is too large
Load Diff
14
controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
14
controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor/ConstructorElement.kt
@ -32,7 +32,7 @@ public class StateConstructorElement<T>(
|
||||
public val state: DeviceState<T>,
|
||||
) : ConstructorElement
|
||||
|
||||
public class ConnectionConstrucorElement(
|
||||
public class ConnectionConstructorElement(
|
||||
public val reads: Collection<DeviceState<*>>,
|
||||
public val writes: Collection<DeviceState<*>>,
|
||||
) : ConstructorElement
|
||||
@ -58,7 +58,7 @@ public interface StateContainer : ContextAware, CoroutineScope {
|
||||
reads: Collection<DeviceState<*>> = emptySet(),
|
||||
onChange: suspend (T) -> Unit,
|
||||
): Job = valueFlow.onEach(onChange).launchIn(this@StateContainer).also {
|
||||
registerElement(ConnectionConstrucorElement(reads + this, writes))
|
||||
registerElement(ConnectionConstructorElement(reads + this, writes))
|
||||
}
|
||||
|
||||
public fun <T> DeviceState<T>.onChange(
|
||||
@ -72,7 +72,7 @@ public interface StateContainer : ContextAware, CoroutineScope {
|
||||
onChange(pair.first, pair.second)
|
||||
}
|
||||
}.launchIn(this@StateContainer).also {
|
||||
registerElement(ConnectionConstrucorElement(reads + this, writes))
|
||||
registerElement(ConnectionConstructorElement(reads + this, writes))
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,7 +164,7 @@ public fun <T1, T2, R> StateContainer.combineState(
|
||||
* 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))
|
||||
val descriptor = ConnectionConstructorElement(setOf(sourceState), setOf(targetState))
|
||||
registerElement(descriptor)
|
||||
return sourceState.valueFlow.onEach {
|
||||
targetState.value = it
|
||||
@ -186,7 +186,7 @@ public fun <T, R> StateContainer.transformTo(
|
||||
targetState: MutableDeviceState<R>,
|
||||
transformation: suspend (T) -> R,
|
||||
): Job {
|
||||
val descriptor = ConnectionConstrucorElement(setOf(sourceState), setOf(targetState))
|
||||
val descriptor = ConnectionConstructorElement(setOf(sourceState), setOf(targetState))
|
||||
registerElement(descriptor)
|
||||
return sourceState.valueFlow.onEach {
|
||||
targetState.value = transformation(it)
|
||||
@ -208,7 +208,7 @@ public fun <T1, T2, R> StateContainer.combineTo(
|
||||
targetState: MutableDeviceState<R>,
|
||||
transformation: suspend (T1, T2) -> R,
|
||||
): Job {
|
||||
val descriptor = ConnectionConstrucorElement(setOf(sourceState1, sourceState2), setOf(targetState))
|
||||
val descriptor = ConnectionConstructorElement(setOf(sourceState1, sourceState2), setOf(targetState))
|
||||
registerElement(descriptor)
|
||||
return kotlinx.coroutines.flow.combine(sourceState1.valueFlow, sourceState2.valueFlow, transformation).onEach {
|
||||
targetState.value = it
|
||||
@ -229,7 +229,7 @@ public inline fun <reified T, R> StateContainer.combineTo(
|
||||
targetState: MutableDeviceState<R>,
|
||||
noinline transformation: suspend (Array<T>) -> R,
|
||||
): Job {
|
||||
val descriptor = ConnectionConstrucorElement(sourceStates, setOf(targetState))
|
||||
val descriptor = ConnectionConstructorElement(sourceStates, setOf(targetState))
|
||||
registerElement(descriptor)
|
||||
return kotlinx.coroutines.flow.combine(sourceStates.map { it.valueFlow }, transformation).onEach {
|
||||
targetState.value = it
|
||||
|
@ -16,18 +16,28 @@ import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.meta.toJson
|
||||
import space.kscience.dataforge.meta.toMeta
|
||||
import space.kscience.dataforge.names.Name
|
||||
import space.kscience.dataforge.names.asName
|
||||
|
||||
@Serializable
|
||||
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 sealed interface Message {
|
||||
public val sourceDevice: Name?
|
||||
public val targetDevice: Name?
|
||||
public val time: Instant
|
||||
|
||||
/**
|
||||
* Update the source device name for composition. If the original name is null, the resulting name is also null.
|
||||
*/
|
||||
public abstract fun changeSource(block: (Name) -> Name): DeviceMessage
|
||||
public fun changeSource(block: (Name) -> Name): Message
|
||||
}
|
||||
|
||||
@Serializable
|
||||
public sealed class DeviceMessage: Message {
|
||||
public abstract val comment: String?
|
||||
|
||||
/**
|
||||
* Update the source device name for composition. If the original name is null, the resulting name is also null.
|
||||
*/
|
||||
public abstract override fun changeSource(block: (Name) -> Name): DeviceMessage
|
||||
|
||||
public companion object {
|
||||
public fun error(
|
||||
@ -46,6 +56,19 @@ public sealed class DeviceMessage {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Message with serialized device failure
|
||||
*/
|
||||
@Serializable
|
||||
public data class DeviceFailureMessage(
|
||||
val failure: SerializableDeviceFailure,
|
||||
override val sourceDevice: Name?,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : Message {
|
||||
override fun changeSource(block: (Name) -> Name): DeviceFailureMessage = copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify that property is changed. [sourceDevice] is mandatory.
|
||||
* [property] corresponds to property name.
|
||||
@ -156,6 +179,7 @@ public data class ActionExecuteMessage(
|
||||
public data class ActionResultMessage(
|
||||
public val action: String,
|
||||
public val result: Meta?,
|
||||
val failure: SerializableDeviceFailure? = null,
|
||||
public val requestId: String,
|
||||
override val sourceDevice: Name,
|
||||
override val targetDevice: Name? = null,
|
||||
@ -201,22 +225,6 @@ public data class EmptyDeviceMessage(
|
||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Information log message
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("log")
|
||||
public data class DeviceLogMessage(
|
||||
val message: String,
|
||||
val data: Meta? = 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(),
|
||||
) : DeviceMessage() {
|
||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
||||
}
|
||||
|
||||
/**
|
||||
* The evaluation of the message produced a service error
|
||||
*/
|
||||
@ -249,6 +257,293 @@ public data class DeviceLifeCycleMessage(
|
||||
override fun changeSource(block: (Name) -> Name): DeviceMessage = copy(sourceDevice = block(sourceDevice))
|
||||
}
|
||||
|
||||
/**
|
||||
* Device log message: Messages from specific devices about their internal state
|
||||
* and operations. Always have a source device and tied to a specific device context.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("device.log")
|
||||
public data class DeviceLogMessage(
|
||||
val message: String,
|
||||
val data: Meta? = null,
|
||||
override val sourceDevice: Name,
|
||||
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))
|
||||
}
|
||||
|
||||
/**
|
||||
* System log message: Infrastructure events and system notifications
|
||||
* that aren't tied to a specific device. Used for resource management
|
||||
* and system state reporting.
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("system.log")
|
||||
public data class SystemLogMessage(
|
||||
val message: String,
|
||||
val component: String,
|
||||
override val sourceDevice: Name = "system".asName(),
|
||||
override val targetDevice: Name? = null,
|
||||
val details: Map<String, String> = emptyMap(),
|
||||
@EncodeDefault override val time: Instant = Clock.System.now(),
|
||||
) : Message {
|
||||
override fun changeSource(block: (Name) -> Name): Message = copy(sourceDevice = block(sourceDevice))
|
||||
}
|
||||
|
||||
/**
|
||||
* Transaction messages for coordinating transactional operations
|
||||
*/
|
||||
@Serializable
|
||||
public sealed interface TransactionMessage : Message {
|
||||
public val transactionId: String
|
||||
|
||||
/**
|
||||
* Message about transaction starting
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("transaction.started")
|
||||
public data class TransactionStartedMessage(
|
||||
override val transactionId: String,
|
||||
override val sourceDevice: Name? = null,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : TransactionMessage {
|
||||
override fun changeSource(block: (Name) -> Name): TransactionMessage
|
||||
= copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Message about transaction commit
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("transaction.committed")
|
||||
public data class TransactionCommittedMessage(
|
||||
override val transactionId: String,
|
||||
override val sourceDevice: Name? = null,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : TransactionMessage {
|
||||
override fun changeSource(block: (Name) -> Name): TransactionMessage
|
||||
= copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Message about transaction rollback
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("transaction.rolled_back")
|
||||
public data class TransactionRolledBackMessage(
|
||||
override val transactionId: String,
|
||||
val reason: String?,
|
||||
val errorType: String?,
|
||||
override val sourceDevice: Name? = null,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : TransactionMessage {
|
||||
override fun changeSource(block: (Name) -> Name): TransactionMessage
|
||||
= copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Message about creating a savepoint in a transaction
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("transaction.savepoint")
|
||||
public data class TransactionSavepointMessage(
|
||||
override val transactionId: String,
|
||||
val savepointName: String,
|
||||
override val sourceDevice: Name? = null,
|
||||
override val targetDevice: Name? = null,
|
||||
@EncodeDefault override val time: Instant = Clock.System.now()
|
||||
) : TransactionMessage {
|
||||
override fun changeSource(block: (Name) -> Name): TransactionMessage
|
||||
= copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Device state messages for device lifecycle events
|
||||
*/
|
||||
@Serializable
|
||||
public sealed interface DeviceStateMessage : Message {
|
||||
public val deviceName: String
|
||||
|
||||
/**
|
||||
* Message about device addition
|
||||
*/
|
||||
@Serializable
|
||||
public data class DeviceStateAddedMessage(
|
||||
override val deviceName: String,
|
||||
override val sourceDevice: Name? = null,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : DeviceStateMessage {
|
||||
override fun changeSource(block: (Name) -> Name): DeviceStateMessage
|
||||
= copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Message about device startup
|
||||
*/
|
||||
@Serializable
|
||||
public data class DeviceStateStartedMessage(
|
||||
override val deviceName: String,
|
||||
override val sourceDevice: Name? = null,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : DeviceStateMessage {
|
||||
override fun changeSource(block: (Name) -> Name): DeviceStateMessage
|
||||
= copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Message about device stopping
|
||||
*/
|
||||
@Serializable
|
||||
public data class DeviceStateStoppedMessage(
|
||||
override val deviceName: String,
|
||||
override val sourceDevice: Name? = null,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : DeviceStateMessage {
|
||||
override fun changeSource(block: (Name) -> Name): DeviceStateMessage
|
||||
= copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Message about device removal
|
||||
*/
|
||||
@Serializable
|
||||
public data class DeviceStateRemovedMessage(
|
||||
override val deviceName: String,
|
||||
override val sourceDevice: Name? = null,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : DeviceStateMessage {
|
||||
override fun changeSource(block: (Name) -> Name): Message = copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Message about device failure
|
||||
*/
|
||||
@Serializable
|
||||
public data class DeviceStateFailedMessage(
|
||||
override val deviceName: String,
|
||||
val failure: SerializableDeviceFailure,
|
||||
override val sourceDevice: Name? = null,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : DeviceStateMessage {
|
||||
override fun changeSource(block: (Name) -> Name): DeviceStateMessage
|
||||
= copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
|
||||
/**
|
||||
* Message about device detachment
|
||||
*/
|
||||
@Serializable
|
||||
public data class DeviceStateDetachedMessage(
|
||||
override val deviceName: String,
|
||||
override val sourceDevice: Name? = null,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : DeviceStateMessage {
|
||||
override fun changeSource(block: (Name) -> Name): DeviceStateMessage
|
||||
= copy(sourceDevice = sourceDevice?.let(block))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for metrics
|
||||
*/
|
||||
@Serializable
|
||||
public sealed interface MetricMessage : Message {
|
||||
public val metricName: String
|
||||
public val tags: Map<String, String>
|
||||
|
||||
/**
|
||||
* Metric value message
|
||||
*/
|
||||
@Serializable
|
||||
public data class MetricValueMessage(
|
||||
override val metricName: String,
|
||||
val value: Double,
|
||||
override val sourceDevice: Name,
|
||||
override val tags: Map<String, String> = emptyMap(),
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : MetricMessage {
|
||||
override fun changeSource(block: (Name) -> Name): MetricMessage
|
||||
= copy(sourceDevice = block(sourceDevice))
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric counter message
|
||||
*/
|
||||
@Serializable
|
||||
public data class MetricCounterMessage(
|
||||
override val metricName: String,
|
||||
val increment: Double = 1.0,
|
||||
override val tags: Map<String, String> = emptyMap(),
|
||||
override val sourceDevice: Name,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : MetricMessage {
|
||||
override fun changeSource(block: (Name) -> Name): MetricMessage
|
||||
= copy(sourceDevice = block(sourceDevice))
|
||||
}
|
||||
|
||||
/**
|
||||
* Operation duration metric message
|
||||
*/
|
||||
@Serializable
|
||||
public data class MetricDurationMessage(
|
||||
override val metricName: String,
|
||||
val durationMs: Long,
|
||||
override val tags: Map<String, String> = emptyMap(),
|
||||
override val sourceDevice: Name,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : MetricMessage {
|
||||
override fun changeSource(block: (Name) -> Name): Message = copy(sourceDevice = block(sourceDevice))
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric distribution message
|
||||
*/
|
||||
@Serializable
|
||||
public data class MetricDistributionMessage(
|
||||
override val metricName: String,
|
||||
val value: Double,
|
||||
override val tags: Map<String, String> = emptyMap(),
|
||||
override val sourceDevice: Name,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : MetricMessage {
|
||||
override fun changeSource(block: (Name) -> Name): MetricMessage
|
||||
= copy(sourceDevice = block(sourceDevice))
|
||||
}
|
||||
|
||||
/**
|
||||
* Metric gauge message
|
||||
*/
|
||||
@Serializable
|
||||
public data class MetricGaugeMessage(
|
||||
override val metricName: String,
|
||||
val value: Double,
|
||||
override val tags: Map<String, String> = emptyMap(),
|
||||
override val sourceDevice: Name,
|
||||
override val targetDevice: Name? = null,
|
||||
override val time: Instant = Clock.System.now()
|
||||
) : MetricMessage {
|
||||
override fun changeSource(block: (Name) -> Name): MetricMessage
|
||||
= copy(sourceDevice = block(sourceDevice))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
public fun DeviceMessage.toMeta(): Meta = Json.encodeToJsonElement(this).toMeta()
|
||||
|
||||
|
@ -0,0 +1,184 @@
|
||||
package space.kscience.controls.api
|
||||
|
||||
import kotlinx.serialization.Contextual
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* Device error categories by severity level.
|
||||
* - [CRITICAL] - critical error requiring immediate response
|
||||
* - [NON_CRITICAL] - non-critical error that allows continued operation
|
||||
*/
|
||||
public enum class DeviceErrorCategory {
|
||||
CRITICAL,
|
||||
NON_CRITICAL
|
||||
}
|
||||
|
||||
/**
|
||||
* Base class for all device-related exceptions.
|
||||
*/
|
||||
public sealed class DeviceException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
public open val category: DeviceErrorCategory = DeviceErrorCategory.CRITICAL,
|
||||
public open val context: Map<String, Any?> = emptyMap()
|
||||
) : RuntimeException(message, cause) {
|
||||
|
||||
/**
|
||||
* Creates a new exception instance with updated context
|
||||
*/
|
||||
protected abstract fun withNewContext(newContext: Map<String, Any?>): DeviceException
|
||||
|
||||
/**
|
||||
* Adds a key-value pair to the exception context
|
||||
*/
|
||||
public fun withContext(key: String, value: Any?): DeviceException {
|
||||
return withNewContext(this.context + (key to value))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds multiple pairs to the exception context
|
||||
*/
|
||||
public fun withContext(additionalContext: Map<String, Any?>): DeviceException {
|
||||
return withNewContext(this.context + additionalContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the exception to a serializable representation for messaging
|
||||
*/
|
||||
public open fun toSerializableFailure(): SerializableDeviceFailure = SerializableDeviceFailure(
|
||||
message = this.message ?: "Unknown error",
|
||||
type = this::class.simpleName ?: "DeviceException",
|
||||
category = this.category,
|
||||
context = this.context.mapValues { it.value?.toString() },
|
||||
causeType = this.cause?.let { it::class.simpleName },
|
||||
causeMessage = this.cause?.message
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for device connection errors
|
||||
*/
|
||||
public class DeviceConnectionException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
category: DeviceErrorCategory = DeviceErrorCategory.CRITICAL,
|
||||
context: Map<String, Any?> = emptyMap()
|
||||
) : DeviceException(message, cause, category, context) {
|
||||
override fun withNewContext(newContext: Map<String, Any?>): DeviceConnectionException =
|
||||
DeviceConnectionException(message ?: "", cause, category, newContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for device operation timeout
|
||||
*/
|
||||
public class DeviceTimeoutException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
category: DeviceErrorCategory = DeviceErrorCategory.CRITICAL,
|
||||
context: Map<String, Any?> = emptyMap()
|
||||
) : DeviceException(message, cause, category, context) {
|
||||
override fun withNewContext(newContext: Map<String, Any?>): DeviceTimeoutException =
|
||||
DeviceTimeoutException(message ?: "", cause, category, newContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for device configuration errors
|
||||
*/
|
||||
public class DeviceConfigurationException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
category: DeviceErrorCategory = DeviceErrorCategory.CRITICAL,
|
||||
context: Map<String, Any?> = emptyMap()
|
||||
) : DeviceException(message, cause, category, context) {
|
||||
override fun withNewContext(newContext: Map<String, Any?>): DeviceConfigurationException =
|
||||
DeviceConfigurationException(message ?: "", cause, category, newContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for concurrent access errors to a device
|
||||
*/
|
||||
public class DeviceConcurrencyException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
category: DeviceErrorCategory = DeviceErrorCategory.CRITICAL,
|
||||
context: Map<String, Any?> = emptyMap()
|
||||
) : DeviceException(message, cause, category, context) {
|
||||
override fun withNewContext(newContext: Map<String, Any?>): DeviceConcurrencyException =
|
||||
DeviceConcurrencyException(message ?: "", cause, category, newContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for device startup errors
|
||||
*/
|
||||
public class DeviceStartupException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
category: DeviceErrorCategory = DeviceErrorCategory.CRITICAL,
|
||||
context: Map<String, Any?> = emptyMap()
|
||||
) : DeviceException(message, cause, category, context) {
|
||||
override fun withNewContext(newContext: Map<String, Any?>): DeviceStartupException =
|
||||
DeviceStartupException(message ?: "", cause, category, newContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for device shutdown errors
|
||||
*/
|
||||
public class DeviceShutdownException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
category: DeviceErrorCategory = DeviceErrorCategory.CRITICAL,
|
||||
context: Map<String, Any?> = emptyMap()
|
||||
) : DeviceException(message, cause, category, context) {
|
||||
override fun withNewContext(newContext: Map<String, Any?>): DeviceShutdownException =
|
||||
DeviceShutdownException(message ?: "", cause, category, newContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for device state transition errors
|
||||
*/
|
||||
public class DeviceStateTransitionException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
category: DeviceErrorCategory = DeviceErrorCategory.CRITICAL,
|
||||
context: Map<String, Any?> = emptyMap()
|
||||
) : DeviceException(message, cause, category, context) {
|
||||
override fun withNewContext(newContext: Map<String, Any?>): DeviceStateTransitionException =
|
||||
DeviceStateTransitionException(message ?: "", cause, category, newContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception for device operation errors
|
||||
*/
|
||||
public class DeviceOperationException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
category: DeviceErrorCategory = DeviceErrorCategory.NON_CRITICAL,
|
||||
context: Map<String, Any?> = emptyMap()
|
||||
) : DeviceException(message, cause, category, context) {
|
||||
override fun withNewContext(newContext: Map<String, Any?>): DeviceOperationException =
|
||||
DeviceOperationException(message ?: "", cause, category, newContext)
|
||||
}
|
||||
|
||||
public class DeviceNotFoundException(
|
||||
message: String,
|
||||
cause: Throwable? = null,
|
||||
category: DeviceErrorCategory = DeviceErrorCategory.CRITICAL,
|
||||
context: Map<String, Any?> = emptyMap()
|
||||
) : DeviceException(message, cause, category, context) {
|
||||
override fun withNewContext(newContext: Map<String, Any?>): DeviceNotFoundException =
|
||||
DeviceNotFoundException(message ?: "", cause, category, newContext)
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializable representation of a device failure.
|
||||
* Used to transmit error information between processes.
|
||||
*/
|
||||
@Serializable
|
||||
public data class SerializableDeviceFailure(
|
||||
val message: String,
|
||||
val type: String,
|
||||
val category: DeviceErrorCategory,
|
||||
val context: Map<String, @Contextual String?> = emptyMap(),
|
||||
val causeType: String? = null,
|
||||
val causeMessage: String? = null
|
||||
)
|
@ -8,6 +8,11 @@ import kotlinx.serialization.Serializable
|
||||
@Serializable
|
||||
public enum class LifecycleState {
|
||||
|
||||
/**
|
||||
* The device is newly created and has not started yet.
|
||||
*/
|
||||
INITIAL,
|
||||
|
||||
/**
|
||||
* Device is initializing
|
||||
*/
|
||||
@ -18,6 +23,11 @@ public enum class LifecycleState {
|
||||
*/
|
||||
STARTED,
|
||||
|
||||
/**
|
||||
* The Device is stopping
|
||||
*/
|
||||
STOPPING,
|
||||
|
||||
/**
|
||||
* The Device is closed
|
||||
*/
|
||||
@ -26,7 +36,13 @@ public enum class LifecycleState {
|
||||
/**
|
||||
* The device encountered irrecoverable error
|
||||
*/
|
||||
ERROR
|
||||
ERROR,
|
||||
|
||||
/**
|
||||
* The lifecycle state is unknown or indeterminate
|
||||
*/
|
||||
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
|
||||
@ -50,10 +66,13 @@ public interface WithLifeCycle {
|
||||
public fun WithLifeCycle.bindToDeviceLifecycle(device: Device){
|
||||
device.onLifecycleEvent {
|
||||
when(it){
|
||||
LifecycleState.INITIAL -> {/*ignore*/}
|
||||
LifecycleState.STARTING -> start()
|
||||
LifecycleState.STARTED -> {/*ignore*/}
|
||||
LifecycleState.STOPPING -> stop()
|
||||
LifecycleState.STOPPED -> stop()
|
||||
LifecycleState.ERROR -> stop()
|
||||
LifecycleState.UNKNOWN -> {/*ignore*/}
|
||||
}
|
||||
}
|
||||
}
|
@ -4,34 +4,109 @@ import kotlinx.serialization.Serializable
|
||||
import space.kscience.dataforge.meta.descriptors.MetaDescriptor
|
||||
import space.kscience.dataforge.meta.descriptors.MetaDescriptorBuilder
|
||||
|
||||
//TODO add proper builders
|
||||
//TODO check proper builders
|
||||
|
||||
/**
|
||||
* A descriptor for property
|
||||
* A descriptor for a property
|
||||
*/
|
||||
@Serializable
|
||||
public class PropertyDescriptor(
|
||||
public val name: String,
|
||||
public var description: String? = null,
|
||||
public var metaDescriptor: MetaDescriptor = MetaDescriptor(),
|
||||
public var readable: Boolean = true,
|
||||
public var mutable: Boolean = false,
|
||||
public val description: String? = null,
|
||||
public val metaDescriptor: MetaDescriptor = MetaDescriptor(),
|
||||
public val readable: Boolean = true,
|
||||
public val mutable: Boolean = false,
|
||||
)
|
||||
|
||||
public fun PropertyDescriptor.metaDescriptor(block: MetaDescriptorBuilder.() -> Unit) {
|
||||
metaDescriptor = MetaDescriptor {
|
||||
from(metaDescriptor)
|
||||
block()
|
||||
/**
|
||||
* A builder for PropertyDescriptor
|
||||
*/
|
||||
public class PropertyDescriptorBuilder(public val name: String) {
|
||||
public var description: String? = null
|
||||
public var metaDescriptor: MetaDescriptor = MetaDescriptor()
|
||||
public var readable: Boolean = true
|
||||
public var mutable: Boolean = false
|
||||
|
||||
/**
|
||||
* Configure the metaDescriptor using a block
|
||||
*/
|
||||
public fun metaDescriptor(block: MetaDescriptorBuilder.() -> Unit) {
|
||||
metaDescriptor = MetaDescriptor {
|
||||
from(metaDescriptor)
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the PropertyDescriptor
|
||||
*/
|
||||
public fun build(): PropertyDescriptor = PropertyDescriptor(
|
||||
name = name,
|
||||
description = description,
|
||||
metaDescriptor = metaDescriptor,
|
||||
readable = readable,
|
||||
mutable = mutable
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* A descriptor for property
|
||||
* Create a PropertyDescriptor using a builder
|
||||
*/
|
||||
public fun propertyDescriptor(name: String, builder: PropertyDescriptorBuilder.() -> Unit = {}): PropertyDescriptor =
|
||||
PropertyDescriptorBuilder(name).apply(builder).build()
|
||||
|
||||
/**
|
||||
* A descriptor for an action
|
||||
*/
|
||||
@Serializable
|
||||
public class ActionDescriptor(
|
||||
public val name: String,
|
||||
public var description: String? = null,
|
||||
public var inputMetaDescriptor: MetaDescriptor = MetaDescriptor(),
|
||||
public var outputMetaDescriptor: MetaDescriptor = MetaDescriptor()
|
||||
public val description: String? = null,
|
||||
public val inputMetaDescriptor: MetaDescriptor = MetaDescriptor(),
|
||||
public val outputMetaDescriptor: MetaDescriptor = MetaDescriptor(),
|
||||
)
|
||||
|
||||
/**
|
||||
* A builder for ActionDescriptor
|
||||
*/
|
||||
public class ActionDescriptorBuilder(public val name: String) {
|
||||
public var description: String? = null
|
||||
public var inputMetaDescriptor: MetaDescriptor = MetaDescriptor()
|
||||
public var outputMetaDescriptor: MetaDescriptor = MetaDescriptor()
|
||||
|
||||
/**
|
||||
* Configure the inputMetaDescriptor using a block
|
||||
*/
|
||||
public fun inputMeta(block: MetaDescriptorBuilder.() -> Unit) {
|
||||
inputMetaDescriptor = MetaDescriptor {
|
||||
from(inputMetaDescriptor)
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the outputMetaDescriptor using a block
|
||||
*/
|
||||
public fun outputMeta(block: MetaDescriptorBuilder.() -> Unit) {
|
||||
outputMetaDescriptor = MetaDescriptor {
|
||||
from(outputMetaDescriptor)
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the ActionDescriptor
|
||||
*/
|
||||
public fun build(): ActionDescriptor = ActionDescriptor(
|
||||
name = name,
|
||||
description = description,
|
||||
inputMetaDescriptor = inputMetaDescriptor,
|
||||
outputMetaDescriptor = outputMetaDescriptor
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an ActionDescriptor using a builder
|
||||
*/
|
||||
public fun actionDescriptor(name: String, builder: ActionDescriptorBuilder.() -> Unit = {}): ActionDescriptor =
|
||||
ActionDescriptorBuilder(name).apply(builder).build()
|
||||
|
@ -98,7 +98,7 @@ public abstract class DeviceBase<D : Device>(
|
||||
public override val messageFlow: SharedFlow<DeviceMessage> get() = sharedMessageFlow
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal val self: D
|
||||
public val self: D
|
||||
get() = this as D
|
||||
|
||||
private val stateLock = Mutex()
|
||||
@ -187,14 +187,35 @@ public abstract class DeviceBase<D : Device>(
|
||||
return spec.executeWithMeta(self, argument ?: Meta.EMPTY)
|
||||
}
|
||||
|
||||
final override var lifecycleState: LifecycleState = LifecycleState.STOPPED
|
||||
final override var lifecycleState: LifecycleState = LifecycleState.INITIAL
|
||||
private set
|
||||
|
||||
|
||||
private suspend fun setLifecycleState(lifecycleState: LifecycleState) {
|
||||
this.lifecycleState = lifecycleState
|
||||
private suspend fun setLifecycleState(newState: LifecycleState) {
|
||||
val validTransition = when (lifecycleState) {
|
||||
LifecycleState.INITIAL -> newState in listOf(LifecycleState.STARTING, LifecycleState.STOPPED)
|
||||
LifecycleState.STARTING -> newState in listOf(LifecycleState.STARTED, LifecycleState.STOPPING, LifecycleState.ERROR)
|
||||
LifecycleState.STARTED -> newState in listOf(LifecycleState.STOPPING, LifecycleState.ERROR)
|
||||
LifecycleState.STOPPING -> newState in listOf(LifecycleState.STOPPED, LifecycleState.ERROR, LifecycleState.STARTING)
|
||||
LifecycleState.STOPPED -> newState in listOf(LifecycleState.STARTING)
|
||||
LifecycleState.ERROR -> newState in listOf(LifecycleState.STARTING, LifecycleState.STOPPED)
|
||||
LifecycleState.UNKNOWN -> newState in listOf(
|
||||
LifecycleState.ERROR,
|
||||
LifecycleState.INITIAL,
|
||||
LifecycleState.STARTING,
|
||||
LifecycleState.STARTED,
|
||||
LifecycleState.STOPPING,
|
||||
LifecycleState.STOPPED
|
||||
)
|
||||
}
|
||||
|
||||
if (!validTransition) {
|
||||
error("Invalid lifecycle state transition from $lifecycleState to $newState")
|
||||
}
|
||||
|
||||
this.lifecycleState = newState
|
||||
sharedMessageFlow.emit(
|
||||
DeviceLifeCycleMessage(lifecycleState)
|
||||
DeviceLifeCycleMessage(newState)
|
||||
)
|
||||
}
|
||||
|
||||
@ -203,13 +224,13 @@ public abstract class DeviceBase<D : Device>(
|
||||
}
|
||||
|
||||
final override suspend fun start() {
|
||||
if (lifecycleState == LifecycleState.STOPPED) {
|
||||
if (lifecycleState == LifecycleState.INITIAL || lifecycleState == LifecycleState.STOPPED) {
|
||||
super.start()
|
||||
setLifecycleState(LifecycleState.STARTING)
|
||||
onStart()
|
||||
setLifecycleState(LifecycleState.STARTED)
|
||||
} else {
|
||||
logger.debug { "Device $this is already started" }
|
||||
logger.debug { "Device $this is already started or in invalid state: $lifecycleState" }
|
||||
}
|
||||
}
|
||||
|
||||
@ -218,9 +239,17 @@ public abstract class DeviceBase<D : Device>(
|
||||
}
|
||||
|
||||
final override suspend fun stop() {
|
||||
onStop()
|
||||
setLifecycleState(LifecycleState.STOPPED)
|
||||
super.stop()
|
||||
if (lifecycleState == LifecycleState.STARTED ||
|
||||
lifecycleState == LifecycleState.STARTING ||
|
||||
lifecycleState == LifecycleState.ERROR) {
|
||||
setLifecycleState(LifecycleState.STOPPING)
|
||||
onStop()
|
||||
setLifecycleState(LifecycleState.STOPPED)
|
||||
super.stop()
|
||||
} else {
|
||||
logger.debug { "Device $this is already stopped or in invalid state: $lifecycleState" }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
@ -44,24 +44,24 @@ public abstract class DeviceSpec<D : Device> {
|
||||
|
||||
public fun <T> property(
|
||||
converter: MetaConverter<T>,
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
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 {
|
||||
converter.descriptor?.let { converterDescriptor ->
|
||||
metaDescriptor {
|
||||
from(converterDescriptor)
|
||||
}
|
||||
val descriptor = propertyDescriptor(propertyName) {
|
||||
mutable = false
|
||||
converter.descriptor?.let { converterDescriptor ->
|
||||
metaDescriptor {
|
||||
from(converterDescriptor)
|
||||
}
|
||||
fromSpec(property)
|
||||
descriptorBuilder()
|
||||
}
|
||||
|
||||
fromSpec(property)
|
||||
descriptorBuilder()
|
||||
}
|
||||
val deviceProperty = object : DevicePropertySpec<D, T> {
|
||||
override val descriptor = descriptor
|
||||
override val converter: MetaConverter<T> = converter
|
||||
|
||||
override suspend fun read(device: D): T? =
|
||||
@ -75,26 +75,25 @@ public abstract class DeviceSpec<D : Device> {
|
||||
|
||||
public fun <T> mutableProperty(
|
||||
converter: MetaConverter<T>,
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
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 : MutableDevicePropertySpec<D, T> {
|
||||
override val descriptor: PropertyDescriptor = PropertyDescriptor(
|
||||
propertyName,
|
||||
mutable = true
|
||||
).apply {
|
||||
converter.descriptor?.let { converterDescriptor ->
|
||||
metaDescriptor {
|
||||
from(converterDescriptor)
|
||||
}
|
||||
val descriptor = propertyDescriptor(propertyName) {
|
||||
this.mutable = true
|
||||
converter.descriptor?.let { converterDescriptor ->
|
||||
metaDescriptor {
|
||||
from(converterDescriptor)
|
||||
}
|
||||
fromSpec(property)
|
||||
descriptorBuilder()
|
||||
}
|
||||
fromSpec(property)
|
||||
descriptorBuilder()
|
||||
}
|
||||
val deviceProperty = object : MutableDevicePropertySpec<D, T> {
|
||||
override val descriptor = descriptor
|
||||
override val converter: MetaConverter<T> = converter
|
||||
|
||||
override suspend fun read(device: D): T? =
|
||||
@ -119,30 +118,28 @@ public abstract class DeviceSpec<D : Device> {
|
||||
public fun <I, O> action(
|
||||
inputConverter: MetaConverter<I>,
|
||||
outputConverter: MetaConverter<O>,
|
||||
descriptorBuilder: ActionDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
execute: suspend D.(I) -> O,
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, I, O>>> =
|
||||
PropertyDelegateProvider { _: DeviceSpec<D>, property: KProperty<*> ->
|
||||
val actionName = name ?: property.name
|
||||
val deviceAction = object : DeviceActionSpec<D, I, O> {
|
||||
override val descriptor: ActionDescriptor = ActionDescriptor(actionName).apply {
|
||||
inputConverter.descriptor?.let { converterDescriptor ->
|
||||
inputMetaDescriptor = MetaDescriptor {
|
||||
from(converterDescriptor)
|
||||
from(inputMetaDescriptor)
|
||||
}
|
||||
val descriptor = actionDescriptor(actionName) {
|
||||
inputConverter.descriptor?.let { converterDescriptor ->
|
||||
inputMeta {
|
||||
from(converterDescriptor)
|
||||
}
|
||||
outputConverter.descriptor?.let { converterDescriptor ->
|
||||
outputMetaDescriptor = MetaDescriptor {
|
||||
from(converterDescriptor)
|
||||
from(outputMetaDescriptor)
|
||||
}
|
||||
}
|
||||
|
||||
fromSpec(property)
|
||||
descriptorBuilder()
|
||||
}
|
||||
outputConverter.descriptor?.let { converterDescriptor ->
|
||||
outputMeta {
|
||||
from(converterDescriptor)
|
||||
}
|
||||
}
|
||||
fromSpec(property)
|
||||
descriptorBuilder()
|
||||
}
|
||||
val deviceAction = object : DeviceActionSpec<D, I, O> {
|
||||
override val descriptor: ActionDescriptor = descriptor
|
||||
|
||||
override val inputConverter: MetaConverter<I> = inputConverter
|
||||
override val outputConverter: MetaConverter<O> = outputConverter
|
||||
@ -162,7 +159,7 @@ public abstract class DeviceSpec<D : Device> {
|
||||
* An action that takes no parameters and returns no values
|
||||
*/
|
||||
public fun <D : Device> DeviceSpec<D>.unitAction(
|
||||
descriptorBuilder: ActionDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
execute: suspend D.() -> Unit,
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Unit, Unit>>> =
|
||||
@ -179,7 +176,7 @@ public fun <D : Device> DeviceSpec<D>.unitAction(
|
||||
* An action that takes [Meta] and returns [Meta]. No conversions are done
|
||||
*/
|
||||
public fun <D : Device> DeviceSpec<D>.metaAction(
|
||||
descriptorBuilder: ActionDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: ActionDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
execute: suspend D.(Meta) -> Meta,
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DeviceActionSpec<D, Meta, Meta>>> =
|
||||
|
@ -1,10 +1,10 @@
|
||||
package space.kscience.controls.spec
|
||||
|
||||
import space.kscience.controls.api.ActionDescriptor
|
||||
import space.kscience.controls.api.PropertyDescriptor
|
||||
import space.kscience.controls.api.ActionDescriptorBuilder
|
||||
import space.kscience.controls.api.PropertyDescriptorBuilder
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
|
||||
internal expect fun PropertyDescriptor.fromSpec(property: KProperty<*>)
|
||||
internal expect fun PropertyDescriptorBuilder.fromSpec(property: KProperty<*>)
|
||||
|
||||
internal expect fun ActionDescriptor.fromSpec(property: KProperty<*>)
|
||||
internal expect fun ActionDescriptorBuilder.fromSpec(property: KProperty<*>)
|
@ -1,8 +1,7 @@
|
||||
package space.kscience.controls.spec
|
||||
|
||||
import space.kscience.controls.api.Device
|
||||
import space.kscience.controls.api.PropertyDescriptor
|
||||
import space.kscience.controls.api.metaDescriptor
|
||||
import space.kscience.controls.api.PropertyDescriptorBuilder
|
||||
import space.kscience.dataforge.meta.Meta
|
||||
import space.kscience.dataforge.meta.MetaConverter
|
||||
import space.kscience.dataforge.meta.ValueType
|
||||
@ -17,7 +16,7 @@ import kotlin.reflect.KProperty1
|
||||
public fun <T, D : Device> DeviceSpec<D>.property(
|
||||
converter: MetaConverter<T>,
|
||||
readOnlyProperty: KProperty1<D, T>,
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> = property(
|
||||
converter,
|
||||
descriptorBuilder,
|
||||
@ -31,7 +30,7 @@ public fun <T, D : Device> DeviceSpec<D>.property(
|
||||
public fun <T, D : Device> DeviceSpec<D>.mutableProperty(
|
||||
converter: MetaConverter<T>,
|
||||
readWriteProperty: KMutableProperty1<D, T>,
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
|
||||
mutableProperty(
|
||||
converter,
|
||||
@ -49,7 +48,7 @@ public fun <T, D : Device> DeviceSpec<D>.mutableProperty(
|
||||
*/
|
||||
public fun <T, D : DeviceBase<D>> DeviceSpec<D>.property(
|
||||
converter: MetaConverter<T>,
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, T>>> =
|
||||
property(
|
||||
@ -60,7 +59,7 @@ public fun <T, D : DeviceBase<D>> DeviceSpec<D>.property(
|
||||
)
|
||||
|
||||
public fun <D : Device> DeviceSpec<D>.booleanProperty(
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> Boolean?
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Boolean>>> = property(
|
||||
@ -76,8 +75,8 @@ public fun <D : Device> DeviceSpec<D>.booleanProperty(
|
||||
)
|
||||
|
||||
private inline fun numberDescriptor(
|
||||
crossinline descriptorBuilder: PropertyDescriptor.() -> Unit = {}
|
||||
): PropertyDescriptor.() -> Unit = {
|
||||
crossinline descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {}
|
||||
): PropertyDescriptorBuilder.() -> Unit = {
|
||||
metaDescriptor {
|
||||
valueType(ValueType.NUMBER)
|
||||
}
|
||||
@ -85,7 +84,7 @@ private inline fun numberDescriptor(
|
||||
}
|
||||
|
||||
public fun <D : Device> DeviceSpec<D>.numberProperty(
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> Number?
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Number>>> = property(
|
||||
@ -96,7 +95,7 @@ public fun <D : Device> DeviceSpec<D>.numberProperty(
|
||||
)
|
||||
|
||||
public fun <D : Device> DeviceSpec<D>.doubleProperty(
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> Double?
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Double>>> = property(
|
||||
@ -107,7 +106,7 @@ public fun <D : Device> DeviceSpec<D>.doubleProperty(
|
||||
)
|
||||
|
||||
public fun <D : Device> DeviceSpec<D>.stringProperty(
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> String?
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, String>>> = property(
|
||||
@ -123,7 +122,7 @@ public fun <D : Device> DeviceSpec<D>.stringProperty(
|
||||
)
|
||||
|
||||
public fun <D : Device> DeviceSpec<D>.metaProperty(
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> Meta?
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, DevicePropertySpec<D, Meta>>> = property(
|
||||
@ -147,7 +146,7 @@ public fun <D : Device> DeviceSpec<D>.metaProperty(
|
||||
*/
|
||||
public fun <T, D : DeviceBase<D>> DeviceSpec<D>.mutableProperty(
|
||||
converter: MetaConverter<T>,
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
): PropertyDelegateProvider<DeviceSpec<D>, ReadOnlyProperty<DeviceSpec<D>, MutableDevicePropertySpec<D, T>>> =
|
||||
mutableProperty(
|
||||
@ -159,7 +158,7 @@ public fun <T, D : DeviceBase<D>> DeviceSpec<D>.mutableProperty(
|
||||
)
|
||||
|
||||
public fun <D : Device> DeviceSpec<D>.mutableBooleanProperty(
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> Boolean?,
|
||||
write: suspend D.(propertyName: String, value: Boolean) -> Unit
|
||||
@ -179,7 +178,7 @@ public fun <D : Device> DeviceSpec<D>.mutableBooleanProperty(
|
||||
|
||||
|
||||
public fun <D : Device> DeviceSpec<D>.mutableNumberProperty(
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> Number,
|
||||
write: suspend D.(propertyName: String, value: Number) -> Unit
|
||||
@ -187,7 +186,7 @@ public fun <D : Device> DeviceSpec<D>.mutableNumberProperty(
|
||||
mutableProperty(MetaConverter.number, numberDescriptor(descriptorBuilder), name, read, write)
|
||||
|
||||
public fun <D : Device> DeviceSpec<D>.mutableDoubleProperty(
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> Double,
|
||||
write: suspend D.(propertyName: String, value: Double) -> Unit
|
||||
@ -195,7 +194,7 @@ public fun <D : Device> DeviceSpec<D>.mutableDoubleProperty(
|
||||
mutableProperty(MetaConverter.double, numberDescriptor(descriptorBuilder), name, read, write)
|
||||
|
||||
public fun <D : Device> DeviceSpec<D>.mutableStringProperty(
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> String,
|
||||
write: suspend D.(propertyName: String, value: String) -> Unit
|
||||
@ -203,7 +202,7 @@ public fun <D : Device> DeviceSpec<D>.mutableStringProperty(
|
||||
mutableProperty(MetaConverter.string, descriptorBuilder, name, read, write)
|
||||
|
||||
public fun <D : Device> DeviceSpec<D>.mutableMetaProperty(
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
name: String? = null,
|
||||
read: suspend D.(propertyName: String) -> Meta,
|
||||
write: suspend D.(propertyName: String, value: Meta) -> Unit
|
||||
|
@ -1,9 +1,9 @@
|
||||
package space.kscience.controls.spec
|
||||
|
||||
import space.kscience.controls.api.ActionDescriptor
|
||||
import space.kscience.controls.api.PropertyDescriptor
|
||||
import space.kscience.controls.api.ActionDescriptorBuilder
|
||||
import space.kscience.controls.api.PropertyDescriptorBuilder
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>){}
|
||||
internal actual fun PropertyDescriptorBuilder.fromSpec(property: KProperty<*>){}
|
||||
|
||||
internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){}
|
||||
internal actual fun ActionDescriptorBuilder.fromSpec(property: KProperty<*>){}
|
@ -1,18 +1,18 @@
|
||||
package space.kscience.controls.spec
|
||||
|
||||
import space.kscience.controls.api.ActionDescriptor
|
||||
import space.kscience.controls.api.PropertyDescriptor
|
||||
import space.kscience.controls.api.ActionDescriptorBuilder
|
||||
import space.kscience.controls.api.PropertyDescriptorBuilder
|
||||
import space.kscience.dataforge.descriptors.Description
|
||||
import kotlin.reflect.KProperty
|
||||
import kotlin.reflect.full.findAnnotation
|
||||
|
||||
internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) {
|
||||
internal actual fun PropertyDescriptorBuilder.fromSpec(property: KProperty<*>) {
|
||||
property.findAnnotation<Description>()?.let {
|
||||
description = it.value
|
||||
}
|
||||
}
|
||||
|
||||
internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){
|
||||
internal actual fun ActionDescriptorBuilder.fromSpec(property: KProperty<*>){
|
||||
property.findAnnotation<Description>()?.let {
|
||||
description = it.value
|
||||
}
|
||||
|
@ -1,9 +1,9 @@
|
||||
package space.kscience.controls.spec
|
||||
|
||||
import space.kscience.controls.api.ActionDescriptor
|
||||
import space.kscience.controls.api.PropertyDescriptor
|
||||
import space.kscience.controls.api.ActionDescriptorBuilder
|
||||
import space.kscience.controls.api.PropertyDescriptorBuilder
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>) {}
|
||||
internal actual fun PropertyDescriptorBuilder.fromSpec(property: KProperty<*>) {}
|
||||
|
||||
internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){}
|
||||
internal actual fun ActionDescriptorBuilder.fromSpec(property: KProperty<*>){}
|
@ -1,9 +1,9 @@
|
||||
package space.kscience.controls.spec
|
||||
|
||||
import space.kscience.controls.api.ActionDescriptor
|
||||
import space.kscience.controls.api.PropertyDescriptor
|
||||
import space.kscience.controls.api.ActionDescriptorBuilder
|
||||
import space.kscience.controls.api.PropertyDescriptorBuilder
|
||||
import kotlin.reflect.KProperty
|
||||
|
||||
internal actual fun PropertyDescriptor.fromSpec(property: KProperty<*>){}
|
||||
internal actual fun PropertyDescriptorBuilder.fromSpec(property: KProperty<*>){}
|
||||
|
||||
internal actual fun ActionDescriptor.fromSpec(property: KProperty<*>){}
|
||||
internal actual fun ActionDescriptorBuilder.fromSpec(property: KProperty<*>){}
|
@ -2,7 +2,6 @@ package space.kscience.controls.demo
|
||||
|
||||
import kotlinx.coroutines.launch
|
||||
import space.kscience.controls.api.Device
|
||||
import space.kscience.controls.api.metaDescriptor
|
||||
import space.kscience.controls.spec.*
|
||||
import space.kscience.dataforge.context.Context
|
||||
import space.kscience.dataforge.context.Factory
|
||||
|
@ -12,6 +12,7 @@ import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import space.kscience.controls.api.DeviceHub
|
||||
import space.kscience.controls.api.PropertyDescriptor
|
||||
import space.kscience.controls.api.PropertyDescriptorBuilder
|
||||
import space.kscience.controls.misc.asMeta
|
||||
import space.kscience.controls.misc.duration
|
||||
import space.kscience.controls.ports.AsynchronousPort
|
||||
@ -235,7 +236,7 @@ class PiMotionMasterDevice(
|
||||
|
||||
private fun axisBooleanProperty(
|
||||
command: String,
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
) = mutableBooleanProperty(
|
||||
read = {
|
||||
readAxisBoolean("$command?")
|
||||
@ -248,7 +249,7 @@ class PiMotionMasterDevice(
|
||||
|
||||
private fun axisNumberProperty(
|
||||
command: String,
|
||||
descriptorBuilder: PropertyDescriptor.() -> Unit = {},
|
||||
descriptorBuilder: PropertyDescriptorBuilder.() -> Unit = {},
|
||||
) = mutableDoubleProperty(
|
||||
read = {
|
||||
mm.requestAndParse("$command?", axisId)[axisId]?.toDoubleOrNull()
|
||||
|
@ -92,5 +92,6 @@ include(
|
||||
":demo:echo",
|
||||
":demo:mks-pdr900",
|
||||
":demo:constructor",
|
||||
":demo:device-collective"
|
||||
":demo:device-collective",
|
||||
":controls-composite-spec"
|
||||
)
|
||||
|
Loading…
x
Reference in New Issue
Block a user