diff --git a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DeviceBySpec.kt b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DeviceBySpec.kt index d2ef67e..91f0d61 100644 --- a/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DeviceBySpec.kt +++ b/controls-core/src/commonMain/kotlin/ru/mipt/npm/controls/properties/DeviceBySpec.kt @@ -18,6 +18,7 @@ import kotlin.properties.ReadWriteProperty import kotlin.reflect.KProperty /** + * A device generated from specification * @param D recursive self-type for properties and actions */ @OptIn(InternalDeviceAPI::class) diff --git a/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/misc/javaTimeMeta.kt b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/misc/javaTimeMeta.kt new file mode 100644 index 0000000..eec5774 --- /dev/null +++ b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/misc/javaTimeMeta.kt @@ -0,0 +1,19 @@ +package ru.mipt.npm.controls.misc + +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.long +import space.kscience.dataforge.values.long +import java.time.Instant + +// TODO move to core + +public fun Instant.toMeta(): Meta = Meta { + "seconds" put epochSecond + "nanos" put nano +} + +public fun Meta.instant(): Instant = value?.long?.let { Instant.ofEpochMilli(it) } ?: Instant.ofEpochSecond( + get("seconds")?.long ?: 0L, + get("nanos")?.long ?: 0L, +) \ No newline at end of file diff --git a/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/controllers/delegates.kt b/controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/properties/delegates.kt similarity index 100% rename from controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/controllers/delegates.kt rename to controls-core/src/jvmMain/kotlin/ru/mipt/npm/controls/properties/delegates.kt diff --git a/controls-opcua/build.gradle.kts b/controls-opcua/build.gradle.kts new file mode 100644 index 0000000..caea7f5 --- /dev/null +++ b/controls-opcua/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + id("ru.mipt.npm.gradle.jvm") +} + +val ktorVersion: String by rootProject.extra + +val miloVersion: String = "0.6.3" + +dependencies { + api(project(":controls-core")) + implementation("org.eclipse.milo:sdk-client:$miloVersion") + implementation("org.eclipse.milo:bsd-parser:$miloVersion") + implementation("org.eclipse.milo:dictionary-reader:$miloVersion") +} diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/MetaBsdParser.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/MetaBsdParser.kt new file mode 100644 index 0000000..aaa8346 --- /dev/null +++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/MetaBsdParser.kt @@ -0,0 +1,208 @@ +package ru.mipt.npm.controls.opcua + +import org.eclipse.milo.opcua.binaryschema.AbstractCodec +import org.eclipse.milo.opcua.binaryschema.parser.BsdParser +import org.eclipse.milo.opcua.stack.core.UaSerializationException +import org.eclipse.milo.opcua.stack.core.serialization.OpcUaBinaryStreamDecoder +import org.eclipse.milo.opcua.stack.core.serialization.OpcUaBinaryStreamEncoder +import org.eclipse.milo.opcua.stack.core.serialization.SerializationContext +import org.eclipse.milo.opcua.stack.core.serialization.codecs.OpcUaBinaryDataTypeCodec +import org.eclipse.milo.opcua.stack.core.types.builtin.* +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.* +import org.opcfoundation.opcua.binaryschema.EnumeratedType +import org.opcfoundation.opcua.binaryschema.StructuredType +import ru.mipt.npm.controls.misc.instant +import ru.mipt.npm.controls.misc.toMeta +import space.kscience.dataforge.meta.* +import space.kscience.dataforge.names.Name +import space.kscience.dataforge.names.asName +import space.kscience.dataforge.values.* +import java.util.* + + +public class MetaBsdParser : BsdParser() { + override fun getEnumCodec(enumeratedType: EnumeratedType): OpcUaBinaryDataTypeCodec<*> { + return MetaEnumCodec() + } + + override fun getStructCodec(structuredType: StructuredType): OpcUaBinaryDataTypeCodec<*> { + return MetaStructureCodec(structuredType) + } +} + +internal class MetaEnumCodec : OpcUaBinaryDataTypeCodec { + override fun getType(): Class { + return Number::class.java + } + + @Throws(UaSerializationException::class) + override fun encode( + context: SerializationContext, + encoder: OpcUaBinaryStreamEncoder, + value: Number + ) { + encoder.writeInt32(value.toInt()) + } + + @Throws(UaSerializationException::class) + override fun decode( + context: SerializationContext, + decoder: OpcUaBinaryStreamDecoder + ): Number { + return decoder.readInt32() + } +} + +internal fun opcToMeta(value: Any?): Meta = when (value) { + null -> Meta(Null) + is Meta -> value + is Value -> Meta(value) + is Number -> when (value) { + is UByte -> Meta(value.toShort().asValue()) + is UShort -> Meta(value.toInt().asValue()) + is UInteger -> Meta(value.toLong().asValue()) + is ULong -> Meta(value.toBigInteger().asValue()) + else -> Meta(value.asValue()) + } + is Boolean -> Meta(value.asValue()) + is String -> Meta(value.asValue()) + is Char -> Meta(value.toString().asValue()) + is DateTime -> value.javaInstant.toMeta() + is UUID -> Meta(value.toString().asValue()) + is QualifiedName -> Meta { + "namespaceIndex" put value.namespaceIndex + "name" put value.name?.asValue() + } + is LocalizedText -> Meta { + "locale" put value.locale?.asValue() + "text" put value.text?.asValue() + } + is DataValue -> Meta { + "value" put opcToMeta(value.value) // need SerializationContext to do that properly + value.statusCode?.value?.let { "status" put Meta(it.asValue()) } + value.sourceTime?.javaInstant?.let { "sourceTime" put it.toMeta() } + value.sourcePicoseconds?.let { "sourcePicoseconds" put Meta(it.asValue()) } + value.serverTime?.javaInstant?.let { "serverTime" put it.toMeta() } + value.serverPicoseconds?.let { "serverPicoseconds" put Meta(it.asValue()) } + } + is ByteString -> Meta(value.bytesOrEmpty().asValue()) + is XmlElement -> Meta(value.fragment?.asValue() ?: Null) + is NodeId -> Meta(value.toParseableString().asValue()) + is ExpandedNodeId -> Meta(value.toParseableString().asValue()) + is StatusCode -> Meta(value.value.asValue()) + //is ExtensionObject -> value.decode(client.getDynamicSerializationContext()) + else -> error("Could not create Meta for value: $value") +} + + +/** + * based on https://github.com/eclipse/milo/blob/master/opc-ua-stack/bsd-parser-gson/src/main/java/org/eclipse/milo/opcua/binaryschema/gson/JsonStructureCodec.java + */ +internal class MetaStructureCodec( + structuredType: StructuredType? +) : AbstractCodec(structuredType) { + + override fun getType(): Class = Meta::class.java + + override fun createStructure(name: String, members: LinkedHashMap): Meta = Meta { + members.forEach { (property: String, value: Meta?) -> + setMeta(Name.parse(property), value) + } + } + + override fun opcUaToMemberTypeScalar(name: String, value: Any?, typeName: String): Meta = opcToMeta(value) + + override fun opcUaToMemberTypeArray(name: String, values: Any?, typeName: String): Meta = if (values == null) { + Meta(Null) + } else { + // This is a bit array... + when (values) { + is DoubleArray -> Meta(values.asValue()) + is FloatArray -> Meta(values.asValue()) + is IntArray -> Meta(values.asValue()) + is ByteArray -> Meta(values.asValue()) + is ShortArray -> Meta(values.asValue()) + is Array<*> -> Meta { + setIndexed(Name.parse(name), values.map { opcUaToMemberTypeScalar(name, it, typeName) }) + } + is Number -> Meta(values.asValue()) + else -> error("Could not create Meta for value: $values") + } + } + + override fun memberTypeToOpcUaScalar(member: Meta?, typeName: String): Any? = + if (member == null || member.isEmpty()) { + null + } else when (typeName) { + "Boolean" -> member.boolean + "SByte" -> member.value?.numberOrNull?.toByte() + "Int16" -> member.value?.numberOrNull?.toShort() + "Int32" -> member.value?.numberOrNull?.toInt() + "Int64" -> member.value?.numberOrNull?.toLong() + "Byte" -> member.value?.numberOrNull?.toShort()?.let { Unsigned.ubyte(it) } + "UInt16" -> member.value?.numberOrNull?.toInt()?.let { Unsigned.ushort(it) } + "UInt32" -> member.value?.numberOrNull?.toLong()?.let { Unsigned.uint(it) } + "UInt64" -> member.value?.numberOrNull?.toLong()?.let { Unsigned.ulong(it) } + "Float" -> member.value?.numberOrNull?.toFloat() + "Double" -> member.value?.numberOrNull?.toDouble() + "String" -> member.string + "DateTime" -> DateTime(member.instant()) + "Guid" -> member.string?.let { UUID.fromString(it) } + "ByteString" -> member.value?.list?.let { list -> + ByteString(list.map { it.number.toByte() }.toByteArray()) + } + "XmlElement" -> member.string?.let { XmlElement(it) } + "NodeId" -> member.string?.let { NodeId.parse(it) } + "ExpandedNodeId" -> member.string?.let { ExpandedNodeId.parse(it) } + "StatusCode" -> member.long?.let { StatusCode(it) } + "QualifiedName" -> QualifiedName( + member["namespaceIndex"].int ?: 0, + member["name"].string + ) + "LocalizedText" -> LocalizedText( + member["locale"].string, + member["text"].string + ) + else -> member.toString() + } + + override fun memberTypeToOpcUaArray(member: Meta, typeName: String): Any = if ("Bit" == typeName) { + member.value?.int ?: error("Meta node does not contain int value") + } else { + when (typeName) { + "SByte" -> member.value?.list?.map { it.number.toByte() }?.toByteArray() ?: emptyArray() + "Int16" -> member.value?.list?.map { it.number.toShort() }?.toShortArray() ?: emptyArray() + "Int32" -> member.value?.list?.map { it.number.toInt() }?.toIntArray() ?: emptyArray() + "Int64" -> member.value?.list?.map { it.number.toLong() }?.toLongArray() ?: emptyArray() + "Byte" -> member.value?.list?.map { + Unsigned.ubyte(it.number.toShort()) + }?.toTypedArray() ?: emptyArray() + "UInt16" -> member.value?.list?.map { + Unsigned.ushort(it.number.toInt()) + }?.toTypedArray() ?: emptyArray() + "UInt32" -> member.value?.list?.map { + Unsigned.uint(it.number.toLong()) + }?.toTypedArray() ?: emptyArray() + "UInt64" -> member.value?.list?.map { + Unsigned.ulong(it.number.toLong()) + }?.toTypedArray() ?: emptyArray() + "Float" -> member.value?.list?.map { it.number.toFloat() }?.toFloatArray() ?: emptyArray() + "Double" -> member.value?.list?.map { it.number.toDouble() }?.toDoubleArray() ?: emptyArray() + else -> member.getIndexed(Meta.JSON_ARRAY_KEY.asName()).map { + memberTypeToOpcUaScalar(it.value, typeName) + }.toTypedArray() + } + } + + override fun getMembers(value: Meta): Map = value.items.mapKeys { it.toString() } +} + +public fun Variant.toMeta(serializationContext: SerializationContext): Meta = (value as? ExtensionObject)?.let { + it.decode(serializationContext) as Meta +} ?: opcToMeta(value) + +//public fun Meta.toVariant(): Variant = if (items.isEmpty()) { +// Variant(value?.value) +//} else { +// TODO() +//} diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/MiloDevice.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/MiloDevice.kt new file mode 100644 index 0000000..ae79425 --- /dev/null +++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/MiloDevice.kt @@ -0,0 +1,67 @@ +package ru.mipt.npm.controls.opcua + +import org.eclipse.milo.opcua.sdk.client.OpcUaClient +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue +import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant +import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn +import ru.mipt.npm.controls.api.Device +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.transformations.MetaConverter +import kotlin.properties.ReadWriteProperty +import kotlin.reflect.KProperty + + +/** + * An OPC-UA device backed by Eclipse Milo client + */ +public interface MiloDevice : Device { + /** + * The OPC-UA client initialized on first use + */ + public val client: OpcUaClient + + override fun close() { + client.disconnect() + super.close() + } +} + +public inline fun MiloDevice.opc( + nodeId: NodeId, + converter: MetaConverter, + magAge: Double = 500.0 +): ReadWriteProperty = object : ReadWriteProperty { + override fun getValue(thisRef: Any?, property: KProperty<*>): T { + val data = client.readValue(magAge, TimestampsToReturn.Server, nodeId).get() + val meta: Meta = when (val content = data.value.value) { + is T -> return content + content is Meta -> content as Meta + content is ExtensionObject -> (content as ExtensionObject).decode(client.dynamicSerializationContext) as Meta + else -> error("Incompatible OPC property value $content") + } + + return converter.metaToObject(meta) ?: error("Meta $meta could not be converted to ${T::class}") + } + + override fun setValue(thisRef: Any?, property: KProperty<*>, value: T) { + val meta = converter.objectToMeta(value) + client.writeValue(nodeId, DataValue(Variant(meta))) + } +} + +public inline fun MiloDevice.opcDouble( + nodeId: NodeId, + magAge: Double = 1.0 +): ReadWriteProperty = opc(nodeId, MetaConverter.double, magAge) + +public inline fun MiloDevice.opcInt( + nodeId: NodeId, + magAge: Double = 1.0 +): ReadWriteProperty = opc(nodeId, MetaConverter.int, magAge) + +public inline fun MiloDevice.opcString( + nodeId: NodeId, + magAge: Double = 1.0 +): ReadWriteProperty = opc(nodeId, MetaConverter.string, magAge) \ No newline at end of file diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/MiloDeviceBySpec.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/MiloDeviceBySpec.kt new file mode 100644 index 0000000..6b98de2 --- /dev/null +++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/MiloDeviceBySpec.kt @@ -0,0 +1,29 @@ +package ru.mipt.npm.controls.opcua + +import org.eclipse.milo.opcua.sdk.client.OpcUaClient +import ru.mipt.npm.controls.properties.DeviceBySpec +import ru.mipt.npm.controls.properties.DeviceSpec +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.Global +import space.kscience.dataforge.meta.Meta +import space.kscience.dataforge.meta.get +import space.kscience.dataforge.meta.string + +public open class MiloDeviceBySpec>( + spec: DeviceSpec, + context: Context = Global, + meta: Meta = Meta.EMPTY +): MiloDevice, DeviceBySpec(spec, context, meta) { + + override val client: OpcUaClient by lazy { + val endpointUrl = meta["endpointUrl"].string ?: error("Endpoint url is not defined") + context.createMiloClient(endpointUrl).apply { + connect().get() + } + } + + override fun close() { + super.close() + super.close() + } +} \ No newline at end of file diff --git a/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/miloClient.kt b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/miloClient.kt new file mode 100644 index 0000000..596bf88 --- /dev/null +++ b/controls-opcua/src/main/kotlin/ru/mipt/npm/controls/opcua/miloClient.kt @@ -0,0 +1,62 @@ +package ru.mipt.npm.controls.opcua + +import org.eclipse.milo.opcua.sdk.client.OpcUaClient +import org.eclipse.milo.opcua.sdk.client.api.config.OpcUaClientConfigBuilder +import org.eclipse.milo.opcua.sdk.client.api.identity.AnonymousProvider +import org.eclipse.milo.opcua.sdk.client.api.identity.IdentityProvider +import org.eclipse.milo.opcua.sdk.client.dtd.DataTypeDictionarySessionInitializer +import org.eclipse.milo.opcua.stack.client.security.DefaultClientCertificateValidator +import org.eclipse.milo.opcua.stack.core.security.DefaultTrustListManager +import org.eclipse.milo.opcua.stack.core.security.SecurityPolicy +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint +import org.eclipse.milo.opcua.stack.core.types.structured.EndpointDescription +import space.kscience.dataforge.context.Context +import space.kscience.dataforge.context.info +import space.kscience.dataforge.context.logger +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths + + +internal fun Context.createMiloClient( + endpointUrl: String, //"opc.tcp://localhost:12686/milo" + securityPolicy: SecurityPolicy = SecurityPolicy.Basic256Sha256, + identityProvider: IdentityProvider = AnonymousProvider(), + endpointFilter: (EndpointDescription?) -> Boolean = { securityPolicy.uri == it?.securityPolicyUri } +): OpcUaClient { + + val securityTempDir: Path = Paths.get(System.getProperty("java.io.tmpdir"), "client", "security") + Files.createDirectories(securityTempDir) + check(Files.exists(securityTempDir)) { "Unable to create security dir: $securityTempDir" } + + val pkiDir: Path = securityTempDir.resolve("pki") + logger.info { "Milo client security dir: ${securityTempDir.toAbsolutePath()}" } + logger.info { "Security pki dir: ${pkiDir.toAbsolutePath()}" } + + //val loader: KeyStoreLoader = KeyStoreLoader().load(securityTempDir) + val trustListManager = DefaultTrustListManager(pkiDir.toFile()) + val certificateValidator = DefaultClientCertificateValidator(trustListManager) + + return OpcUaClient.create( + endpointUrl, + { endpoints: List -> + endpoints.stream() + .filter(endpointFilter) + .findFirst() + } + ) { configBuilder: OpcUaClientConfigBuilder -> + configBuilder + .setApplicationName(LocalizedText.english("Controls.kt")) + .setApplicationUri("urn:ru.mipt:npm:controls:opcua") +// .setKeyPair(loader.getClientKeyPair()) +// .setCertificate(loader.getClientCertificate()) +// .setCertificateChain(loader.getClientCertificateChain()) + .setCertificateValidator(certificateValidator) + .setIdentityProvider(identityProvider) + .setRequestTimeout(uint(5000)) + .build() + }.apply { + addSessionInitializer(DataTypeDictionarySessionInitializer(MetaBsdParser())) + } +} \ No newline at end of file diff --git a/controls-server/build.gradle.kts b/controls-server/build.gradle.kts index 3f5597f..7eba824 100644 --- a/controls-server/build.gradle.kts +++ b/controls-server/build.gradle.kts @@ -8,7 +8,7 @@ description = """ """.trimIndent() val dataforgeVersion: String by rootProject.extra -val ktorVersion: String = "1.5.3" +val ktorVersion: String by rootProject.extra dependencies { implementation(project(":controls-core")) diff --git a/controls-server/src/main/kotlin/ru/mipt/npm/controls/server/deviceWebServer.kt b/controls-server/src/main/kotlin/ru/mipt/npm/controls/server/deviceWebServer.kt index 2392c64..cb4639a 100644 --- a/controls-server/src/main/kotlin/ru/mipt/npm/controls/server/deviceWebServer.kt +++ b/controls-server/src/main/kotlin/ru/mipt/npm/controls/server/deviceWebServer.kt @@ -9,6 +9,7 @@ import io.ktor.http.HttpStatusCode import io.ktor.request.receiveText import io.ktor.response.respond import io.ktor.response.respondRedirect +import io.ktor.response.respondText import io.ktor.routing.get import io.ktor.routing.post import io.ktor.routing.route @@ -157,11 +158,13 @@ public fun Application.deviceManagerModule( post("message") { val body = call.receiveText() - val request: DeviceMessage = MagixEndpoint.magixJson.decodeFromString(DeviceMessage.serializer(), body) - val response = manager.respondHubMessage(request) - call.respondMessage(response) + if (response != null) { + call.respondMessage(response) + } else { + call.respondText("No response") + } } route("{target}") { @@ -178,7 +181,11 @@ public fun Application.deviceManagerModule( ) val response = manager.respondHubMessage(request) - call.respondMessage(response) + if (response != null) { + call.respondMessage(response) + } else { + call.respond(HttpStatusCode.InternalServerError) + } } post("set") { val target: String by call.parameters @@ -194,7 +201,11 @@ public fun Application.deviceManagerModule( ) val response = manager.respondHubMessage(request) - call.respondMessage(response) + if (response != null) { + call.respondMessage(response) + } else { + call.respond(HttpStatusCode.InternalServerError) + } } } } diff --git a/demo/src/main/kotlin/ru/mipt/npm/controls/demo/DemoDevice.kt b/demo/src/main/kotlin/ru/mipt/npm/controls/demo/DemoDevice.kt index a8f05e1..308bc48 100644 --- a/demo/src/main/kotlin/ru/mipt/npm/controls/demo/DemoDevice.kt +++ b/demo/src/main/kotlin/ru/mipt/npm/controls/demo/DemoDevice.kt @@ -47,7 +47,7 @@ class DemoDevice : DeviceBySpec(DemoDevice) { @OptIn(ExperimentalTime::class) override fun DemoDevice.onStartup() { - doRecurring(Duration.milliseconds(10)){ + doRecurring(Duration.milliseconds(50)){ sin.read() cos.read() } diff --git a/settings.gradle.kts b/settings.gradle.kts index b6899d0..8b8e683 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -24,6 +24,7 @@ include( ":controls-tcp", ":controls-serial", ":controls-server", + ":controls-opcua", ":demo", ":magix", ":magix:magix-api",