diff --git a/CHANGELOG.md b/CHANGELOG.md index d6f9d13..b7673a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ ## Unreleased +### Added + +### Changed + +### Deprecated + +### Removed + +### Fixed +- Property writing does not trigger change if logical state already is the same as value to be set. +- Modbus-slave triggers only once for multi-register write. + +### Security + +## 0.2.2-dev-1 - 2023-09-24 + +### Changed +- updating logical state in `DeviceBase` is now protected and called `propertyChanged()` +- `DeviceBase` tries to read property after write if the writer does not set the value. + +## 0.2.1 - 2023-09-24 + ### Added - Core interfaces for building a device server - Magix service for binding controls devices (both as RPC client and server) @@ -20,13 +42,3 @@ - A magix event loop implementation in Kotlin. Includes HTTP/SSE and RSocket routes. - Magix history database API - ZMQ client endpoint for Magix - -### Changed - -### Deprecated - -### Removed - -### Fixed - -### Security diff --git a/build.gradle.kts b/build.gradle.kts index aa9f9fb..f88ec4b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -14,7 +14,7 @@ val xodusVersion by extra("2.0.1") allprojects { group = "space.kscience" - version = "0.2.2-dev-1" + version = "0.2.2-dev-2" repositories{ maven("https://maven.pkg.jetbrains.space/spc/p/sci/dev") } diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt index 4fc6365..8a19e68 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/api/Device.kt @@ -124,5 +124,5 @@ public fun Device.getAllProperties(): Meta = Meta { /** * Subscribe on property changes for the whole device */ -public fun Device.onPropertyChange(callback: suspend PropertyChangedMessage.() -> Unit): Job = - messageFlow.filterIsInstance().onEach(callback).launchIn(this) +public fun Device.onPropertyChange(scope: CoroutineScope = this, callback: suspend PropertyChangedMessage.() -> Unit): Job = + messageFlow.filterIsInstance().onEach(callback).launchIn(scope) diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt index 56f5aa9..38aa20a 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceBase.kt @@ -1,24 +1,35 @@ package space.kscience.controls.spec -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.newCoroutineContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import space.kscience.controls.api.* import space.kscience.dataforge.context.Context -import space.kscience.dataforge.context.error +import space.kscience.dataforge.context.debug import space.kscience.dataforge.context.logger import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.int import space.kscience.dataforge.misc.DFExperimental import kotlin.coroutines.CoroutineContext - +/** + * Write a meta [item] to [device] + */ @OptIn(InternalDeviceAPI::class) private suspend fun WritableDevicePropertySpec.writeMeta(device: D, item: Meta) { write(device, converter.metaToObject(item) ?: error("Meta $item could not be read with $converter")) } +/** + * Read Meta item from the [device] + */ @OptIn(InternalDeviceAPI::class) private suspend fun DevicePropertySpec.readMeta(device: D): Meta? = read(device)?.let(converter::objectToMeta) @@ -39,7 +50,7 @@ private suspend fun DeviceActionSpec.executeWithMeta */ public abstract class DeviceBase( final override val context: Context, - override val meta: Meta = Meta.EMPTY, + final override val meta: Meta = Meta.EMPTY, ) : Device { /** @@ -59,13 +70,7 @@ public abstract class DeviceBase( get() = actions.values.map { it.descriptor } override val coroutineContext: CoroutineContext by lazy { - context.newCoroutineContext( - SupervisorJob(context.coroutineContext[Job]) + - CoroutineName("Device $this") + - CoroutineExceptionHandler { _, throwable -> - logger.error(throwable) { "Exception in device $this job" } - } - ) + context.newCoroutineContext(SupervisorJob(context.coroutineContext[Job]) + CoroutineName("Device $this")) } @@ -74,7 +79,10 @@ public abstract class DeviceBase( */ private val logicalState: HashMap = HashMap() - private val sharedMessageFlow: MutableSharedFlow = MutableSharedFlow() + private val sharedMessageFlow: MutableSharedFlow = MutableSharedFlow( + replay = meta["message.buffer"].int ?: 1000, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) public override val messageFlow: SharedFlow get() = sharedMessageFlow @@ -87,7 +95,7 @@ public abstract class DeviceBase( /** * Update logical property state and notify listeners */ - protected suspend fun updateLogical(propertyName: String, value: Meta?) { + protected suspend fun propertyChanged(propertyName: String, value: Meta?) { if (value != logicalState[propertyName]) { stateLock.withLock { logicalState[propertyName] = value @@ -99,10 +107,10 @@ public abstract class DeviceBase( } /** - * Update logical state using given [spec] and its convertor + * Notify the device that a property with [spec] value is changed */ - public suspend fun updateLogical(spec: DevicePropertySpec, value: T) { - updateLogical(spec.name, spec.converter.objectToMeta(value)) + protected suspend fun propertyChanged(spec: DevicePropertySpec, value: T) { + propertyChanged(spec.name, spec.converter.objectToMeta(value)) } /** @@ -112,7 +120,7 @@ public abstract class DeviceBase( override suspend fun readProperty(propertyName: String): Meta { val spec = properties[propertyName] ?: error("Property with name $propertyName not found") val meta = spec.readMeta(self) ?: error("Failed to read property $propertyName") - updateLogical(propertyName, meta) + propertyChanged(propertyName, meta) return meta } @@ -122,7 +130,7 @@ public abstract class DeviceBase( public suspend fun readPropertyOrNull(propertyName: String): Meta? { val spec = properties[propertyName] ?: return null val meta = spec.readMeta(self) ?: return null - updateLogical(propertyName, meta) + propertyChanged(propertyName, meta) return meta } @@ -135,15 +143,26 @@ public abstract class DeviceBase( } override suspend fun writeProperty(propertyName: String, value: Meta): Unit { + //bypass property setting if it already has that value + if (logicalState[propertyName] == value) { + logger.debug { "Skipping setting $propertyName to $value because value is already set" } + return + } when (val property = properties[propertyName]) { null -> { - //If there is a physical property with a given name, invalidate logical property and write physical one - updateLogical(propertyName, value) + //If there are no registered physical properties with given name, write a logical one. + propertyChanged(propertyName, value) } is WritableDevicePropertySpec -> { + //if there is a writeable property with a given name, invalidate logical and write physical invalidate(propertyName) property.writeMeta(self, value) + // perform read after writing if the writer did not set the value and the value is still in invalid state + if (logicalState[propertyName] == null) { + val meta = property.readMeta(self) + propertyChanged(propertyName, meta) + } } else -> { diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt index cf8c741..7f3aa06 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DevicePropertySpec.kt @@ -115,6 +115,7 @@ public fun D.propertyFlow(spec: DevicePropertySpec): Flow< */ public fun D.onPropertyChange( spec: DevicePropertySpec, + scope: CoroutineScope = this, callback: suspend PropertyChangedMessage.(T) -> Unit, ): Job = messageFlow .filterIsInstance() @@ -124,15 +125,16 @@ public fun D.onPropertyChange( if (newValue != null) { change.callback(newValue) } - }.launchIn(this) + }.launchIn(scope) /** * Call [callback] on initial property value and each value change */ public fun D.useProperty( spec: DevicePropertySpec, + scope: CoroutineScope = this, callback: suspend (T) -> Unit, -): Job = launch { +): Job = scope.launch { callback(read(spec)) messageFlow .filterIsInstance() diff --git a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt index d05bf30..17c3ecb 100644 --- a/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt +++ b/controls-core/src/commonMain/kotlin/space/kscience/controls/spec/DeviceSpec.kt @@ -22,7 +22,7 @@ public val MetaConverter.Companion.unit: MetaConverter get() = UnitMetaCon @OptIn(InternalDeviceAPI::class) public abstract class DeviceSpec { - //initializing meta property for everyone + //initializing the metadata property for everyone private val _properties = hashMapOf>( DeviceMetaPropertySpec.name to DeviceMetaPropertySpec ) diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt index 2f56a8a..32adfb3 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt +++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/DeviceProcessImage.kt @@ -1,9 +1,9 @@ package space.kscience.controls.modbus import com.ghgande.j2mod.modbus.procimg.* -import io.ktor.utils.io.core.buildPacket -import io.ktor.utils.io.core.readByteBuffer -import io.ktor.utils.io.core.writeShort +import io.ktor.utils.io.core.* +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import space.kscience.controls.api.Device import space.kscience.controls.spec.DevicePropertySpec @@ -118,22 +118,48 @@ public class DeviceProcessImageBuilder internal constructor( } } + /** + * Trigger [block] if one of register changes. + */ + private fun List.onChange(block: (ByteReadPacket) -> Unit) { + var ready = false + + forEach { register -> + register.addObserver { _, _ -> + ready = true + } + } + + device.launch { + val builder = BytePacketBuilder() + while (isActive) { + delay(1) + if (ready) { + val packet = builder.apply { + forEach { value -> + writeShort(value.toShort()) + } + }.build() + block(packet) + ready = false + } + } + } + } + public fun bind(key: ModbusRegistryKey.HoldingRange, propertySpec: WritableDevicePropertySpec) { val registers = List(key.count) { ObservableRegister() } + registers.forEachIndexed { index, register -> - register.addObserver { _, _ -> - val packet = buildPacket { - registers.forEach { value -> - writeShort(value.toShort()) - } - } - device[propertySpec] = key.format.readObject(packet) - } image.addRegister(key.address + index, register) } + registers.onChange { packet -> + device[propertySpec] = key.format.readObject(packet) + } + device.useProperty(propertySpec) { value -> val packet = buildPacket { key.format.writeObject(this, value) @@ -182,20 +208,17 @@ public class DeviceProcessImageBuilder internal constructor( val registers = List(key.count) { ObservableRegister() } + registers.forEachIndexed { index, register -> - register.addObserver { _, _ -> - val packet = buildPacket { - registers.forEach { value -> - writeShort(value.toShort()) - } - } - device.launch { - device.action(key.format.readObject(packet)) - } - } image.addRegister(key.address + index, register) } + registers.onChange { packet -> + device.launch { + device.action(key.format.readObject(packet)) + } + } + return registers } @@ -205,11 +228,13 @@ public class DeviceProcessImageBuilder internal constructor( * Bind the device to Modbus slave (server) image. */ public fun D.bindProcessImage( + unitId: Int = 0, openOnBind: Boolean = true, binding: DeviceProcessImageBuilder.() -> Unit, ): ProcessImage { - val image = SimpleProcessImage() + val image = SimpleProcessImage(unitId) DeviceProcessImageBuilder(this, image).apply(binding) + image.setLocked(true) if (openOnBind) { launch { open() diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt index ea4330c..7297616 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt +++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDevice.kt @@ -21,9 +21,9 @@ import kotlin.reflect.KProperty public interface ModbusDevice : Device { /** - * Client id for this specific device + * Unit id for this specific device */ - public val clientId: Int + public val unitId: Int /** * The modubus master connector @@ -61,7 +61,7 @@ public interface ModbusDevice : Device { } public operator fun ModbusRegistryKey.HoldingRange.getValue(thisRef: Any?, property: KProperty<*>): T { - val packet = readInputRegistersToPacket(address, count) + val packet = readHoldingRegistersToPacket(address, count) return format.readObject(packet) } @@ -82,35 +82,35 @@ public interface ModbusDevice : Device { * Read multiple sequential modbus coils (bit-values) */ public fun ModbusDevice.readCoils(address: Int, count: Int): BitVector = - master.readCoils(clientId, address, count) + master.readCoils(unitId, address, count) public fun ModbusDevice.readCoil(address: Int): Boolean = - master.readCoils(clientId, address, 1).getBit(0) + master.readCoils(unitId, address, 1).getBit(0) public fun ModbusDevice.writeCoils(address: Int, values: BooleanArray) { val bitVector = BitVector(values.size) values.forEachIndexed { index, value -> bitVector.setBit(index, value) } - master.writeMultipleCoils(clientId, address, bitVector) + master.writeMultipleCoils(unitId, address, bitVector) } public fun ModbusDevice.writeCoil(address: Int, value: Boolean) { - master.writeCoil(clientId, address, value) + master.writeCoil(unitId, address, value) } public fun ModbusDevice.writeCoil(key: ModbusRegistryKey.Coil, value: Boolean) { - master.writeCoil(clientId, key.address, value) + master.writeCoil(unitId, key.address, value) } public fun ModbusDevice.readInputDiscretes(address: Int, count: Int): BitVector = - master.readInputDiscretes(clientId, address, count) + master.readInputDiscretes(unitId, address, count) public fun ModbusDevice.readInputDiscrete(address: Int): Boolean = - master.readInputDiscretes(clientId, address, 1).getBit(0) + master.readInputDiscretes(unitId, address, 1).getBit(0) public fun ModbusDevice.readInputRegisters(address: Int, count: Int): List = - master.readInputRegisters(clientId, address, count).toList() + master.readInputRegisters(unitId, address, count).toList() private fun Array.toBuffer(): ByteBuffer { val buffer: ByteBuffer = ByteBuffer.allocate(size * 2) @@ -129,10 +129,10 @@ private fun Array.toPacket(): ByteReadPacket = buildPacket { } public fun ModbusDevice.readInputRegistersToBuffer(address: Int, count: Int): ByteBuffer = - master.readInputRegisters(clientId, address, count).toBuffer() + master.readInputRegisters(unitId, address, count).toBuffer() public fun ModbusDevice.readInputRegistersToPacket(address: Int, count: Int): ByteReadPacket = - master.readInputRegisters(clientId, address, count).toPacket() + master.readInputRegisters(unitId, address, count).toPacket() public fun ModbusDevice.readDoubleInput(address: Int): Double = readInputRegistersToBuffer(address, Double.SIZE_BYTES).getDouble() @@ -141,7 +141,7 @@ public fun ModbusDevice.readInputRegister(address: Int): Short = readInputRegisters(address, 1).first().toShort() public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List = - master.readMultipleRegisters(clientId, address, count).toList() + master.readMultipleRegisters(unitId, address, count).toList() /** * Read a number of registers to a [ByteBuffer] @@ -149,10 +149,10 @@ public fun ModbusDevice.readHoldingRegisters(address: Int, count: Int): List(values.size) { SimpleInputRegister(values[it].toInt()) } ) public fun ModbusDevice.writeHoldingRegister(address: Int, value: Short): Int = master.writeSingleRegister( - clientId, + unitId, address, SimpleInputRegister(value.toInt()) ) @@ -183,10 +183,6 @@ public fun ModbusDevice.writeHoldingRegisters(address: Int, buffer: ByteBuffer): return writeHoldingRegisters(address, array) } -public fun ModbusDevice.writeShortRegister(address: Int, value: Short) { - master.writeSingleRegister(address, SimpleInputRegister(value.toInt())) -} - public fun ModbusDevice.modbusRegister( address: Int, ): ReadWriteProperty = object : ReadWriteProperty { diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt index 916187f..48f48a4 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt +++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusDeviceBySpec.kt @@ -15,7 +15,7 @@ import space.kscience.dataforge.names.NameToken public open class ModbusDeviceBySpec( context: Context, spec: DeviceSpec, - override val clientId: Int, + override val unitId: Int, override val master: AbstractModbusMaster, private val disposeMasterOnClose: Boolean = true, meta: Meta = Meta.EMPTY, diff --git a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt index a6fc894..6e3c1da 100644 --- a/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt +++ b/controls-modbus/src/main/kotlin/space/kscience/controls/modbus/ModbusRegistryMap.kt @@ -152,7 +152,7 @@ public abstract class ModbusRegistryMap { val rangeString = if (key.count == 1) { key.address.toString() } else { - "${key.address} - ${key.address + key.count}" + "${key.address} - ${key.address + key.count - 1}" } to.appendLine("${typeString}\t$rangeString\t$description") } diff --git a/demo/all-things/build.gradle.kts b/demo/all-things/build.gradle.kts index bb6d5ec..33b654e 100644 --- a/demo/all-things/build.gradle.kts +++ b/demo/all-things/build.gradle.kts @@ -12,7 +12,6 @@ repositories { val ktorVersion: String by rootProject.extra val rsocketVersion: String by rootProject.extra -val visionforgeVersion: String by rootProject.extra dependencies { implementation(projects.controlsCore) @@ -26,7 +25,6 @@ dependencies { implementation("io.ktor:ktor-client-cio:$ktorVersion") implementation("no.tornado:tornadofx:1.7.20") implementation("space.kscience:plotlykt-server:0.6.0") - implementation("space.kscience:visionforge-plotly:$visionforgeVersion") // implementation("com.github.Ricky12Awesome:json-schema-serialization:0.6.6") implementation(spclibs.logback.classic) } diff --git a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt index 949ced9..7771709 100644 --- a/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt +++ b/demo/mks-pdr900/src/main/kotlin/center/sciprog/devices/mks/MksPdr900Device.kt @@ -49,16 +49,16 @@ class MksPdr900Device(context: Context, meta: Meta) : DeviceBySpec