Add Eclipse Milo client
This commit is contained in:
parent
3606bc3a46
commit
d503f0499e
@ -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)
|
||||
|
@ -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,
|
||||
)
|
14
controls-opcua/build.gradle.kts
Normal file
14
controls-opcua/build.gradle.kts
Normal 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")
|
||||
}
|
@ -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()
|
||||
//}
|
@ -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)
|
@ -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()
|
||||
}
|
||||
}
|
@ -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()))
|
||||
}
|
||||
}
|
@ -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"))
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -47,7 +47,7 @@ class DemoDevice : DeviceBySpec<DemoDevice>(DemoDevice) {
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override fun DemoDevice.onStartup() {
|
||||
doRecurring(Duration.milliseconds(10)){
|
||||
doRecurring(Duration.milliseconds(50)){
|
||||
sin.read()
|
||||
cos.read()
|
||||
}
|
||||
|
@ -24,6 +24,7 @@ include(
|
||||
":controls-tcp",
|
||||
":controls-serial",
|
||||
":controls-server",
|
||||
":controls-opcua",
|
||||
":demo",
|
||||
":magix",
|
||||
":magix:magix-api",
|
||||
|
Loading…
Reference in New Issue
Block a user