Compare commits

...

28 Commits

Author SHA1 Message Date
1863a611d1 16.05.2025 - API and architecture improvements 2025-05-16 21:38:09 +03:00
bf9db7de08 14.04.2025 - fixes 2025-04-14 23:58:30 +03:00
b344ae7cfb 07.04.2025 - Magix improvements in CompositeControlComponents.kt 2025-04-07 10:56:27 +03:00
3727310e55 refactor(CompositeControlComponents): tests working 2025-03-26 10:35:49 +03:00
ba3af28804 refactor(CompositeControlComponents): tests working 2025-03-25 17:42:31 +03:00
74a3905118 refactor(CompositeControlComponents): refactor Magix 2025-03-25 13:49:08 +03:00
ee14bf7dfc refactor(CompositeControlComponents): refactor Magix 2025-03-25 10:34:57 +03:00
7658dd168a refactor(CompositeControlComponents): add Magix 2025-03-25 08:51:16 +03:00
144746445a refactor(CompositeControlComponents): extract to controls-composite-spec module
- Moved CompositeControlComponents to dedicated module controls-composite-spec
- Improved separation of concerns between core device functionality and composite device architecture
2025-03-21 18:35:15 +03:00
962164fa89 refactor(CompositeControlComponents):
- DeviceHubManager is now abstract
- StandardDeviceHubManager is the concrete implementation
- Logic for registry/health checks/error handling is separated into dedicated managers
- Introduced ControlsConfiguration for global settings and SystemResource for concurrency management
2025-03-15 16:50:45 +03:00
4b3ad34e82 docs(CompositeControlComponents): improved some methods, added logging options and new delegates 2025-02-11 16:24:24 +03:00
c6375a60ac docs(CompositeControlComponents): removed unused methods 2025-02-10 17:12:46 +03:00
e8a52a0e46 docs(CompositeControlTest): improved comments and logging 2025-02-07 09:26:42 +03:00
a77494c98c docs(CompositeControlComponentSpec): improved comments 2025-02-06 13:05:31 +03:00
1a74d9446d docs(CompositeControlComponentSpec): improved comments 2025-02-06 12:55:36 +03:00
db1e5c8c14 docs(CompositeControlComponentSpec): improved comments and code of Actor class 2025-02-04 20:50:56 +03:00
6f8bb6cc80 docs(CompositeControlComponentSpec): improved comments and code 2025-02-01 04:56:40 +03:00
37e357a404 docs(CompositeControlComponentSpec): improved comments and code 2025-02-01 03:42:23 +03:00
7d11650cab docs(CompositeControlComponentSpec): improved comments and code 2025-01-31 23:36:43 +03:00
fc6b43fe0b docs(CompositeControlComponentSpec): improved comments and tests 2025-01-30 13:36:36 +03:00
0272c75f96 fix(CompositeControlComponentSpec): improved setting child devices implementations in hub, all tests are passed 2025-01-28 22:01:55 +03:00
7efd0b423c fix(CompositeControlComponentSpec): register child specs using provideDelegate
- Moved registration logic to `provideDelegate()`, ensuring child specs are
  registered upon declaration without needing explicit access.
2025-01-28 01:43:24 +03:00
d7fbc17462 docs: code with enhanced KDoc comments 2025-01-27 16:44:04 +03:00
eed5d27dc5 feat: implement RestartPolicy, add systemBus for logs, refine DeviceStateEvent
- Implemented new RestartPolicy logic with exponential backoff support.
- Added systemBus as an extra stream for system-level events and logs.
- Improved DeviceStateEvent to handle more detailed device transitions.
2025-01-27 16:29:55 +03:00
7e286ca111 Implemented external configuration support through ExternalConfigurationProvider and ExternalConfigApplier, and expanded error handling in AbstractDeviceHubManager with CUSTOM strategy support. Improved DeviceLifecycleConfig, added HealthChecker support for device health checks. Implemented hot-swappable device functionality. 2024-12-25 18:11:01 +03:00
76fa751e25 Added some examples of new API usage. Not all tests are passed. 2024-12-19 12:11:51 +03:00
e9e2c7b8d8 Fix naming 2024-12-19 03:40:24 +03:00
7cde308114 New hierarchical structure based on CompositeDeviceSpec and ConfigurableCompositeDevice. Also added new builders for PropertyDescriptor and ActionDescriptor 2024-12-19 03:34:01 +03:00
42 changed files with 5904 additions and 138 deletions
controls-composite-spec
controls-constructor/src/commonMain/kotlin/space/kscience/controls/constructor
controls-core/src
commonMain/kotlin/space/kscience/controls
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
settings.gradle.kts

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

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

@ -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'." }
}
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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