Add Eclipse Milo client

This commit is contained in:
Alexander Nozik 2021-08-03 21:05:36 +03:00
parent 3606bc3a46
commit d503f0499e
12 changed files with 419 additions and 7 deletions

View File

@ -18,6 +18,7 @@ import kotlin.properties.ReadWriteProperty
import kotlin.reflect.KProperty import kotlin.reflect.KProperty
/** /**
* A device generated from specification
* @param D recursive self-type for properties and actions * @param D recursive self-type for properties and actions
*/ */
@OptIn(InternalDeviceAPI::class) @OptIn(InternalDeviceAPI::class)

View File

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

View File

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

View File

@ -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<Number> {
override fun getType(): Class<Number> {
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<Meta, Meta>(structuredType) {
override fun getType(): Class<Meta> = Meta::class.java
override fun createStructure(name: String, members: LinkedHashMap<String, Meta>): 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<Byte>()
"Int16" -> member.value?.list?.map { it.number.toShort() }?.toShortArray() ?: emptyArray<Short>()
"Int32" -> member.value?.list?.map { it.number.toInt() }?.toIntArray() ?: emptyArray<Int>()
"Int64" -> member.value?.list?.map { it.number.toLong() }?.toLongArray() ?: emptyArray<Long>()
"Byte" -> member.value?.list?.map {
Unsigned.ubyte(it.number.toShort())
}?.toTypedArray() ?: emptyArray<UByte>()
"UInt16" -> member.value?.list?.map {
Unsigned.ushort(it.number.toInt())
}?.toTypedArray() ?: emptyArray<UShort>()
"UInt32" -> member.value?.list?.map {
Unsigned.uint(it.number.toLong())
}?.toTypedArray() ?: emptyArray<UInteger>()
"UInt64" -> member.value?.list?.map {
Unsigned.ulong(it.number.toLong())
}?.toTypedArray() ?: emptyArray<kotlin.ULong>()
"Float" -> member.value?.list?.map { it.number.toFloat() }?.toFloatArray() ?: emptyArray<Float>()
"Double" -> member.value?.list?.map { it.number.toDouble() }?.toDoubleArray() ?: emptyArray<Double>()
else -> member.getIndexed(Meta.JSON_ARRAY_KEY.asName()).map {
memberTypeToOpcUaScalar(it.value, typeName)
}.toTypedArray()
}
}
override fun getMembers(value: Meta): Map<String, Meta> = 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()
//}

View File

@ -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 <reified T> MiloDevice.opc(
nodeId: NodeId,
converter: MetaConverter<T>,
magAge: Double = 500.0
): ReadWriteProperty<Any?, T> = object : ReadWriteProperty<Any?, T> {
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 <reified T> MiloDevice.opcDouble(
nodeId: NodeId,
magAge: Double = 1.0
): ReadWriteProperty<Any?, Double> = opc(nodeId, MetaConverter.double, magAge)
public inline fun <reified T> MiloDevice.opcInt(
nodeId: NodeId,
magAge: Double = 1.0
): ReadWriteProperty<Any?, Int> = opc(nodeId, MetaConverter.int, magAge)
public inline fun <reified T> MiloDevice.opcString(
nodeId: NodeId,
magAge: Double = 1.0
): ReadWriteProperty<Any?, String> = opc(nodeId, MetaConverter.string, magAge)

View File

@ -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<D: MiloDeviceBySpec<D>>(
spec: DeviceSpec<D>,
context: Context = Global,
meta: Meta = Meta.EMPTY
): MiloDevice, DeviceBySpec<D>(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<MiloDevice>.close()
super<DeviceBySpec>.close()
}
}

View File

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

View File

@ -8,7 +8,7 @@ description = """
""".trimIndent() """.trimIndent()
val dataforgeVersion: String by rootProject.extra val dataforgeVersion: String by rootProject.extra
val ktorVersion: String = "1.5.3" val ktorVersion: String by rootProject.extra
dependencies { dependencies {
implementation(project(":controls-core")) implementation(project(":controls-core"))

View File

@ -9,6 +9,7 @@ import io.ktor.http.HttpStatusCode
import io.ktor.request.receiveText import io.ktor.request.receiveText
import io.ktor.response.respond import io.ktor.response.respond
import io.ktor.response.respondRedirect import io.ktor.response.respondRedirect
import io.ktor.response.respondText
import io.ktor.routing.get import io.ktor.routing.get
import io.ktor.routing.post import io.ktor.routing.post
import io.ktor.routing.route import io.ktor.routing.route
@ -157,11 +158,13 @@ public fun Application.deviceManagerModule(
post("message") { post("message") {
val body = call.receiveText() val body = call.receiveText()
val request: DeviceMessage = MagixEndpoint.magixJson.decodeFromString(DeviceMessage.serializer(), body) val request: DeviceMessage = MagixEndpoint.magixJson.decodeFromString(DeviceMessage.serializer(), body)
val response = manager.respondHubMessage(request) val response = manager.respondHubMessage(request)
if (response != null) {
call.respondMessage(response) call.respondMessage(response)
} else {
call.respondText("No response")
}
} }
route("{target}") { route("{target}") {
@ -178,7 +181,11 @@ public fun Application.deviceManagerModule(
) )
val response = manager.respondHubMessage(request) val response = manager.respondHubMessage(request)
if (response != null) {
call.respondMessage(response) call.respondMessage(response)
} else {
call.respond(HttpStatusCode.InternalServerError)
}
} }
post("set") { post("set") {
val target: String by call.parameters val target: String by call.parameters
@ -194,7 +201,11 @@ public fun Application.deviceManagerModule(
) )
val response = manager.respondHubMessage(request) val response = manager.respondHubMessage(request)
if (response != null) {
call.respondMessage(response) call.respondMessage(response)
} else {
call.respond(HttpStatusCode.InternalServerError)
}
} }
} }
} }

View File

@ -47,7 +47,7 @@ class DemoDevice : DeviceBySpec<DemoDevice>(DemoDevice) {
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
override fun DemoDevice.onStartup() { override fun DemoDevice.onStartup() {
doRecurring(Duration.milliseconds(10)){ doRecurring(Duration.milliseconds(50)){
sin.read() sin.read()
cos.read() cos.read()
} }

View File

@ -24,6 +24,7 @@ include(
":controls-tcp", ":controls-tcp",
":controls-serial", ":controls-serial",
":controls-server", ":controls-server",
":controls-opcua",
":demo", ":demo",
":magix", ":magix",
":magix:magix-api", ":magix:magix-api",