diff --git a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/rpc/SnmpAgentRpc.kt b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/rpc/SnmpAgentRpc.kt index 444cf9fe..648553ea 100644 --- a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/rpc/SnmpAgentRpc.kt +++ b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/rpc/SnmpAgentRpc.kt @@ -21,7 +21,7 @@ interface SnmpAgentRpc { fun read(agent: String, oids: List): List - fun write(agent: String, oids: List, values: List): List + fun write(agent: String, oids: List, values: List): List fun walk(agent: String, oids: List): List diff --git a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/BasicOidValue.kt b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/BasicOidValue.kt new file mode 100644 index 00000000..93e259a2 --- /dev/null +++ b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/BasicOidValue.kt @@ -0,0 +1,3 @@ +package com.mussonindustrial.embr.snmp.model + +data class BasicOidValue(override val oid: Oid, override val value: T) : OidValue diff --git a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/BasicQualifiedOidValue.kt b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/BasicQualifiedOidValue.kt index 0e3c2cfa..623f8344 100644 --- a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/BasicQualifiedOidValue.kt +++ b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/BasicQualifiedOidValue.kt @@ -41,7 +41,7 @@ data class BasicQualifiedOidValue( 1 -> BasicQualifiedOidValue( value = payload["value"], - oid = Oid.fromNumeric(payload["oid"] as String), + oid = Snmp4jOid(payload["oid"] as String), quality = QualityCode((payload["quality"] as Number).toInt()), timeStamp = Date((payload["timestamp"] as Number).toLong()), ) diff --git a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/ExtendedOid.kt b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/ExtendedOid.kt new file mode 100644 index 00000000..ab833306 --- /dev/null +++ b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/ExtendedOid.kt @@ -0,0 +1,42 @@ +package com.mussonindustrial.embr.snmp.model + +import java.text.ParseException +import org.snmp4j.SNMP4JSettings +import org.snmp4j.smi.OID + +interface ExtendedOid : Oid { + val suffix: String? + + val hasSuffix: Boolean + get() = suffix != null + + fun toIdentifier(): String { + if (!hasSuffix) return numeric + return "${numeric}/${suffix}" + } +} + +fun String.isOid(): Boolean { + return try { + this.asExtendedOid() + true + } catch (_: ParseException) { + false + } +} + +fun String.asExtendedOid(): ExtendedOid { + val parts = this.split("/", limit = 2) + val maybeOid = parts[0] + val suffix = parts.getOrNull(1)?.takeIf { it.isNotBlank() } + + val oid = SNMP4JSettings.getOIDTextFormat().parse(maybeOid) + return Snmp4jExtendedOid(OID(oid), suffix) +} + +fun String?.nullOrExtendedOid(): ExtendedOid? { + this?.let { + return this.asExtendedOid() + } + return null +} diff --git a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/Oid.kt b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/Oid.kt index 12ece120..b3f96849 100644 --- a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/Oid.kt +++ b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/Oid.kt @@ -1,40 +1,9 @@ package com.mussonindustrial.embr.snmp.model -import org.snmp4j.smi.OID - -class Oid private constructor(private val oid: OID) { - - companion object { - fun fromNumeric(dotted: String): Oid = Oid(oid = OID(dotted)) - - fun fromSnmp4j(oid: OID): Oid = Oid(oid = OID(oid)) - } - +interface Oid { val numeric: String - get() = oid.toDottedString() - - @Suppress("UNUSED") - val dottedString: String - get() = numeric - - @Suppress("UNUSED") - fun toDottedString(): String { - return numeric - } + val symbolicName: String val index: Int - get() = OID(oid).removeLast() - val parent: Oid - get() { - val parentOid = OID(oid) - parentOid.removeLast() - return Oid(oid = parentOid) - } - - override fun toString(): String = numeric - - override fun equals(other: Any?): Boolean = other is Oid && oid == other.oid - - override fun hashCode(): Int = oid.hashCode() } diff --git a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/OidValue.kt b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/OidValue.kt new file mode 100644 index 00000000..11a80971 --- /dev/null +++ b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/OidValue.kt @@ -0,0 +1,6 @@ +package com.mussonindustrial.embr.snmp.model + +interface OidValue { + val oid: Oid + val value: T +} diff --git a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/Snmp4jExtendedOid.kt b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/Snmp4jExtendedOid.kt new file mode 100644 index 00000000..fae90fbe --- /dev/null +++ b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/Snmp4jExtendedOid.kt @@ -0,0 +1,7 @@ +package com.mussonindustrial.embr.snmp.model + +import org.snmp4j.smi.OID + +class Snmp4jExtendedOid(oid: OID, override val suffix: String?) : Snmp4jOid(oid), ExtendedOid { + constructor(numeric: String, suffix: String) : this(OID(numeric), suffix) +} diff --git a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/Snmp4jOid.kt b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/Snmp4jOid.kt new file mode 100644 index 00000000..996ba149 --- /dev/null +++ b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/Snmp4jOid.kt @@ -0,0 +1,30 @@ +package com.mussonindustrial.embr.snmp.model + +import org.snmp4j.smi.OID + +open class Snmp4jOid(val oid: OID) : OID(oid), Oid { + + constructor(numeric: String) : this(OID(numeric)) + + override val numeric: String + get() = oid.toDottedString() + + override val symbolicName: String + get() = "unknownSymbol[$numeric]" + + override val index: Int + get() = OID(oid).removeLast() + + override val parent: Snmp4jOid + get() { + val parentOid = OID(oid) + parentOid.removeLast() + return Snmp4jOid(parentOid) + } + + override fun toString(): String = numeric +} + +fun OID.toOid(): Snmp4jOid = Snmp4jOid(this) + +fun Oid.toSnmp4j(): OID = Snmp4jOid(numeric) diff --git a/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/SnmpError.kt b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/SnmpError.kt new file mode 100644 index 00000000..0e555e59 --- /dev/null +++ b/modules/snmp/common/src/main/kotlin/com/mussonindustrial/embr/snmp/model/SnmpError.kt @@ -0,0 +1,5 @@ +package com.mussonindustrial.embr.snmp.model + +import org.snmp4j.smi.Null + +val SnmpCommunicationError = Null() diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/SnmpGatewayContext.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/SnmpGatewayContext.kt index 4663a1a6..bca1dbc3 100644 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/SnmpGatewayContext.kt +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/SnmpGatewayContext.kt @@ -10,8 +10,10 @@ import com.mussonindustrial.embr.gateway.EmbrGatewayContext import com.mussonindustrial.embr.gateway.EmbrGatewayContextImpl import com.mussonindustrial.embr.snmp.agents.SnmpAgentRegistry import com.mussonindustrial.embr.snmp.agents.rpc.SnmpAgentRpcImpl +import com.mussonindustrial.embr.snmp.opc.SnmpNamespace import java.util.concurrent.ThreadFactory import java.util.concurrent.atomic.AtomicInteger +import org.eclipse.milo.opcua.sdk.server.OpcUaServer import org.snmp4j.SNMP4JSettings import org.snmp4j.mp.MPv3 import org.snmp4j.security.SecurityModels @@ -32,6 +34,7 @@ class SnmpGatewayContext(private val context: GatewayContext) : val logger = this.getLoggerEx() val agentRegistry = SnmpAgentRegistry() val agentRpc = SnmpAgentRpcImpl(this) + lateinit var opcUaServer: OpcUaServer init { instance = this @@ -62,6 +65,13 @@ class SnmpGatewayContext(private val context: GatewayContext) : }, ) + fun initOpcUaServer(server: OpcUaServer) { + if (!::opcUaServer.isInitialized) { + opcUaServer = server + SnmpNamespace(server).startup() + } + } + override fun getHealthCheckRegistry(): HealthCheckRegistry? { return super.getHealthCheckRegistry() } diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentDevice.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentDevice.kt index a0713daa..c519eca3 100644 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentDevice.kt +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentDevice.kt @@ -2,29 +2,32 @@ package com.mussonindustrial.embr.snmp.agents.devices import com.inductiveautomation.ignition.gateway.opcua.server.api.Device import com.mussonindustrial.embr.snmp.agents.context.SnmpAgentContext -import com.mussonindustrial.embr.snmp.requests.OidReadResult -import com.mussonindustrial.embr.snmp.requests.OidWriteResult +import com.mussonindustrial.embr.snmp.model.ObjectModel +import com.mussonindustrial.embr.snmp.model.Oid +import com.mussonindustrial.embr.snmp.model.OidValue import org.eclipse.milo.opcua.sdk.server.AddressSpaceFragment +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId -import org.snmp4j.smi.OID -import org.snmp4j.smi.VariableBinding +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode interface SnmpAgentDevice : AddressSpaceFragment, Device { val context: SnmpAgentContext<*> val status: Status + val model: ObjectModel + val profile: SnmpAgentProfile - fun read(reads: List): List + fun read(reads: List): List> - fun write(writes: List): List + fun write(writes: List>): List> - fun walk(roots: List): List + fun walk(roots: List): List> fun readTable( - columns: List, - lowerBoundIndex: OID?, - upperBoundIndex: OID?, - ): List> + columns: List, + lowerBoundIndex: Oid?, + upperBoundIndex: Oid?, + ): List>> fun stripDeviceName(nodeId: NodeId): String { val id = nodeId.identifier.toString() diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentDeviceImpl.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentDeviceImpl.kt index 7826dfb8..72325df8 100644 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentDeviceImpl.kt +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentDeviceImpl.kt @@ -4,15 +4,22 @@ import com.inductiveautomation.ignition.common.util.LoggerEx import com.mussonindustrial.embr.snmp.SnmpGatewayContext import com.mussonindustrial.embr.snmp.agents.configuration.SnmpAgentConfig import com.mussonindustrial.embr.snmp.agents.context.SnmpAgentContext +import com.mussonindustrial.embr.snmp.agents.opc.DeviceAddressSpace import com.mussonindustrial.embr.snmp.agents.opc.DiagnosticAddressSpace +import com.mussonindustrial.embr.snmp.agents.opc.MethodAddressSpace +import com.mussonindustrial.embr.snmp.agents.opc.ObjectModelAddressSpace import com.mussonindustrial.embr.snmp.agents.opc.OidAddressSpace -import com.mussonindustrial.embr.snmp.opc.DeviceAddressSpace -import com.mussonindustrial.embr.snmp.requests.OidReadResult -import com.mussonindustrial.embr.snmp.requests.OidWriteResult -import com.mussonindustrial.embr.snmp.requests.toOidReadResult -import com.mussonindustrial.embr.snmp.requests.toOidWriteResult -import com.mussonindustrial.embr.snmp.utils.createSizeBoundedPDUs +import com.mussonindustrial.embr.snmp.model.BasicOidValue +import com.mussonindustrial.embr.snmp.model.ConcurrentObjectModel +import com.mussonindustrial.embr.snmp.model.ObjectModel +import com.mussonindustrial.embr.snmp.model.Oid +import com.mussonindustrial.embr.snmp.model.OidValue +import com.mussonindustrial.embr.snmp.model.Snmp4jOid +import com.mussonindustrial.embr.snmp.model.SnmpCommunicationError +import com.mussonindustrial.embr.snmp.model.toOid +import com.mussonindustrial.embr.snmp.model.toSnmp4j import java.util.concurrent.TimeUnit +import kotlin.collections.map import org.eclipse.milo.opcua.sdk.server.AddressSpaceComposite import org.eclipse.milo.opcua.sdk.server.Lifecycle import org.eclipse.milo.opcua.sdk.server.LifecycleManager @@ -20,9 +27,13 @@ import org.eclipse.milo.opcua.stack.core.StatusCodes import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode import org.snmp4j.PDU -import org.snmp4j.smi.OID +import org.snmp4j.mp.SnmpConstants +import org.snmp4j.smi.Variable import org.snmp4j.smi.VariableBinding +import org.snmp4j.util.PDUFactory import org.snmp4j.util.TableUtils +import org.snmp4j.util.TreeEvent +import org.snmp4j.util.TreeListener import org.snmp4j.util.TreeUtils class SnmpAgentDeviceImpl(override val context: SnmpAgentContext) : @@ -35,22 +46,37 @@ class SnmpAgentDeviceImpl(override val context: SnmpAgentCo override var status: SnmpAgentDevice.Status = SnmpAgentDevice.Status.DISCONNECTED private set + override val model = ConcurrentObjectModel(this) + override val profile = SnmpAgentProfile() + val healthcheck = Healthcheck() val deviceAddressSpace = DeviceAddressSpace(context.deviceContext, this) val diagnosticAddressSpace = DiagnosticAddressSpace(this, this) val oidAddressSpace = OidAddressSpace(this, this) + val objectModelAddressSpace = ObjectModelAddressSpace(this, this) + val methodAddressSpace = MethodAddressSpace(this, this) init { + lifecycleManager.addStartupTask { SnmpGatewayContext.instance.initOpcUaServer(server) } lifecycleManager.addLifecycle(context) - lifecycleManager.addLifecycle(healthcheck) lifecycleManager.addLifecycle(deviceAddressSpace) lifecycleManager.addLifecycle(diagnosticAddressSpace) + lifecycleManager.addLifecycle(methodAddressSpace) + lifecycleManager.addLifecycle(objectModelAddressSpace) lifecycleManager.addLifecycle(oidAddressSpace) + lifecycleManager.addLifecycle(healthcheck) lifecycleManager.addStartupTask { - onDataItemsCreated( - context.deviceContext.subscriptionModel.getDataItems(context.deviceContext.name) - ) + context.deviceContext.gatewayContext.executionManager.executeOnce { + discoverMaxPduSize().apply { + profile.maxResponsePduSize = this + profile.maxRequestPduSize = this + } + learnObjectModel() + onDataItemsCreated( + context.deviceContext.subscriptionModel.getDataItems(context.deviceContext.name) + ) + } } } @@ -82,17 +108,38 @@ class SnmpAgentDeviceImpl(override val context: SnmpAgentCo } } - override fun read(reads: List): List { - val results = mutableMapOf() - val remaining = reads.groupBy { it.oid }.toMutableMap() + override fun read(reads: List): List> { + return readRaw(reads).map { model.observe(it) } + } + + override fun write(writes: List>): List> { + return writeRaw( + writes.map { (oid, value) -> oid to model.toSnmpValue(BasicOidValue(oid, value)).value } + ) + } + + override fun walk(roots: List): List> { + return walkRaw(roots).map { model.observe(it) } + } + + override fun readTable( + columns: List, + lowerBoundIndex: Oid?, + upperBoundIndex: Oid?, + ): List>> { + return readTableRaw(columns, lowerBoundIndex, upperBoundIndex).map { + it.map { result -> model.observe(result) } + } + } + + fun readRaw(reads: List): List> { + val results = mutableMapOf>() + val remaining = reads.groupBy { it }.toMutableMap() while (remaining.isNotEmpty()) { val pdus = - context.readTarget.createSizeBoundedPDUs( - context.pduFactory, - remaining.flatMap { it.value }, - ) { + createSizeBoundedPDUs(context.pduFactory, remaining.keys.toList()) { type = PDU.GET } @@ -101,113 +148,138 @@ class SnmpAgentDeviceImpl(override val context: SnmpAgentCo val response = context.snmp.send(pdu, context.readTarget).response if (response == null) { logger.warn("GET failed: no response.") - return reads.map { - it.oid.toOidReadResult(DataValue(StatusCodes.Bad_CommunicationError)) - } + return reads.map { BasicOidValue(it, SnmpCommunicationError) } } if (response.errorStatus == 0) { logger.trace("GET successful: ${response.variableBindings}") response.variableBindings.forEach { binding -> - remaining[binding.oid]?.forEach { - results[it] = binding.toOidReadResult() + val oid = binding.oid.toOid() + remaining[oid]?.forEach { + results[it] = BasicOidValue(it, binding.variable) } - remaining.remove(binding.oid) + remaining.remove(oid) } } else { val errorIdx = response.errorIndex if (errorIdx in 1..pdu.size()) { - val badOid = pdu.get(errorIdx - 1).oid + val oid = (pdu.get(errorIdx - 1).oid).toOid() logger.debug( - "GET failed at OID: $badOid (index ${errorIdx}), removing and retrying..." + "GET failed at OID: $oid (index ${errorIdx}), removing and retrying..." ) - val failedResults = remaining.remove(badOid) + val failedResults = remaining.remove(oid) failedResults?.forEach { - results[it] = - it.oid.toOidReadResult( - DataValue(StatusCodes.Bad_CommunicationError) - ) + results[it] = BasicOidValue(it, SnmpCommunicationError) } } else { logger.warn( "GET failed with errorStatusText: ${response.errorStatusText}" ) - return reads.map { - it.oid.toOidReadResult( - DataValue(StatusCodes.Bad_CommunicationError) - ) - } + return reads.map { BasicOidValue(it, SnmpCommunicationError) } } } } catch (e: Exception) { - logger.warn("GET failed with exception", e) - return reads.map { - it.oid.toOidReadResult(DataValue(StatusCodes.Bad_CommunicationError)) - } + logger.warn("GET failed with exception: ${e.message}", e) + return reads.map { BasicOidValue(it, SnmpCommunicationError) } } } } - return reads.map { results[it] as OidReadResult } + return reads.map { results[it]!! } } - override fun write(writes: List): List { - return writes.map { + fun writeRaw(writes: List>): List> { + return writes.map { (oid, value) -> if (context.writeTarget == null) { - return@map it.oid.toOidWriteResult(StatusCode(StatusCodes.Bad_WriteNotSupported)) + return@map BasicOidValue(oid, StatusCode(StatusCodes.Bad_CommunicationError)) } val pdu = context.pduFactory.createPDU(context.writeTarget).apply { type = PDU.SET - add(it) + add(VariableBinding(oid.toSnmp4j(), value)) } try { val response = context.snmp.send(pdu, context.writeTarget).response if (response == null) { logger.warn("SET failed: no response.") - return@map it.oid.toOidWriteResult( - StatusCode(StatusCodes.Bad_CommunicationError) - ) + return@map BasicOidValue(oid, StatusCode(StatusCodes.Bad_CommunicationError)) } if (response.errorStatus == 0) { - return@map it.oid.toOidWriteResult(StatusCode.GOOD) + return@map BasicOidValue(oid, StatusCode.GOOD) } else { - return@map it.oid.toOidWriteResult(StatusCode.BAD) + logger.warn("SET failed with errorStatusText: ${response.errorStatusText}") + if (response.errorStatusText == "Not writable") { + return@map BasicOidValue(oid, StatusCode(StatusCodes.Bad_NotWritable)) + } + return@map BasicOidValue(oid, StatusCode.BAD) } } catch (e: Exception) { logger.warn("SET failed with exception", e) - return@map it.oid.toOidWriteResult(StatusCode(StatusCodes.Bad_CommunicationError)) + return@map BasicOidValue(oid, StatusCode(StatusCodes.Bad_CommunicationError)) } } } - override fun walk(roots: List): List { - val results = treeUtils.walk(context.readTarget, roots.toTypedArray()) + fun walkRaw(roots: List): List> { + val results = treeUtils.walk(context.readTarget, roots.map { it.toSnmp4j() }.toTypedArray()) return results.flatMap { - it.variableBindings?.map { binding -> binding.toOidReadResult() } ?: listOf() + it.variableBindings?.map { binding -> + BasicOidValue(binding.oid.toOid(), binding.variable) + } ?: listOf() } } - override fun readTable( - columns: List, - lowerBoundIndex: OID?, - upperBoundIndex: OID?, - ): List> { + fun walkAsync(roots: List, listener: WalkListener) { + treeUtils.walk( + context.readTarget, + roots.map { it.toSnmp4j() }.toTypedArray(), + context, + object : TreeListener { + + override fun next(event: TreeEvent): Boolean { + event.variableBindings?.forEach { binding -> + listener.receiveEvent(BasicOidValue(binding.oid.toOid(), binding.variable)) + } + return true + } + + override fun isFinished(): Boolean = false + + override fun finished(event: TreeEvent) {} + }, + ) + } + + fun readTableRaw( + columns: List, + lowerBoundIndex: Oid?, + upperBoundIndex: Oid?, + ): List>> { val results = tableUtils.getTable( context.readTarget, - columns.toTypedArray(), - lowerBoundIndex, - upperBoundIndex, + columns.map { it.toSnmp4j() }.toTypedArray(), + lowerBoundIndex?.toSnmp4j(), + upperBoundIndex?.toSnmp4j(), ) return results.mapNotNull { - it.columns?.mapNotNull { binding -> binding?.toOidReadResult() } + it.columns?.mapNotNull { binding -> + BasicOidValue(binding.oid.toOid(), binding.variable) + } } } + fun interface WalkListener { + fun receiveEvent(variable: OidValue) + } + + fun learnObjectModel() { + walkAsync(listOf(Snmp4jOid("1"))) { model.observe(it) } + } + inner class Healthcheck : Lifecycle { private val taskOwner = "healthcheck" @@ -258,8 +330,8 @@ class SnmpAgentDeviceImpl(override val context: SnmpAgentCo return } - val response = read(listOf(VariableBinding(OID(context.snmpConfig.healthcheck.oid)))) - val isGood = response.first().value.statusCode.isGood + val response = read(listOf(Snmp4jOid(context.snmpConfig.healthcheck.oid!!))).first() + val isGood = response.value.statusCode.isGood logger.trace("Health check result: $isGood") status = @@ -270,4 +342,86 @@ class SnmpAgentDeviceImpl(override val context: SnmpAgentCo } } } + + private fun sendTestPdu(targetSize: Int): PDU { + val pdu = PDU().apply { type = PDU.GET } + + while (pdu.berLength < targetSize) { + pdu.add(VariableBinding(SnmpConstants.sysObjectID)) + } + + pdu.trim() + + return context.snmp.send(pdu, context.readTarget).response + } + + private fun discoverMaxPduSize(min: Int = 256, max: Int = 65535): Int { + var low = min + var high = max + var best = min + + while (low <= high) { + val candidate = (low + high) / 2 + + val response = + try { + sendTestPdu(candidate) + } catch (_: Exception) { + high = candidate - 1 + continue + } + + if (response.errorStatus == PDU.tooBig) { + high = candidate - 1 + continue + } + + best = candidate + low = candidate + 1 + } + + return best + } + + private fun createSizeBoundedPDUs( + pduFactory: PDUFactory, + reads: List, + configure: PDU.() -> Unit = {}, + ): List { + + val descriptors = model.getDescriptors(reads) + val pdus = mutableListOf() + + var pdu = pduFactory.createPDU(context.readTarget).apply(configure) + var expectedResponseSize = 0 + + fun startNewPdu() { + pdu.trim() + pdus += pdu + pdu = pduFactory.createPDU(context.readTarget).apply(configure) + expectedResponseSize = 0 + } + + reads.zip(descriptors).forEach { (oid, descriptor) -> + val binding = VariableBinding(oid.toSnmp4j()) + val valueSize = (descriptor as? ObjectModel.ValueDescriptor)?.expectedSize ?: 0 + + val wouldOverflow = + pdu.berLength > profile.maxRequestPduSize || + expectedResponseSize + valueSize > profile.maxResponsePduSize + + if (wouldOverflow && pdu.size() > 0) { + startNewPdu() + } + + pdu.add(binding) + expectedResponseSize += valueSize + } + + if (pdu.size() > 0) { + pdus += pdu + } + + return pdus + } } diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentProfile.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentProfile.kt new file mode 100644 index 00000000..e98b2bf0 --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/devices/SnmpAgentProfile.kt @@ -0,0 +1,6 @@ +package com.mussonindustrial.embr.snmp.agents.devices + +class SnmpAgentProfile { + var maxRequestPduSize: Int = 1400 + var maxResponsePduSize: Int = 1400 +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/DeviceAddressSpace.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/DeviceAddressSpace.kt new file mode 100644 index 00000000..7ce7b79c --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/DeviceAddressSpace.kt @@ -0,0 +1,39 @@ +package com.mussonindustrial.embr.snmp.agents.opc + +import com.inductiveautomation.ignition.gateway.opcua.server.api.DeviceContext +import com.mussonindustrial.embr.snmp.opc.DeviceContextManagedAddressSpaceFragment +import com.mussonindustrial.embr.snmp.opc.SnmpNamespace +import com.mussonindustrial.embr.snmp.opc.addHasTypeDefinition +import com.mussonindustrial.embr.snmp.opc.addNode +import com.mussonindustrial.embr.snmp.opc.addOrganizedBy +import com.mussonindustrial.embr.snmp.opc.removeAllNodes +import org.eclipse.milo.opcua.sdk.server.AddressSpaceComposite +import org.eclipse.milo.opcua.sdk.server.nodes.UaObjectNode +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger + +class DeviceAddressSpace(deviceContext: DeviceContext, composite: AddressSpaceComposite) : + DeviceContextManagedAddressSpaceFragment(deviceContext, composite) { + + init { + lifecycleManager.addStartupTask { addNodes() } + lifecycleManager.addShutdownTask { nodeManager.removeAllNodes() } + } + + fun addNodes() { + UaObjectNode( + nodeContext, + deviceNodeId, + qualifiedName("[${name}]"), + LocalizedText("[${name}]"), + LocalizedText("SNMP Agent Device"), + UInteger.MIN, + UInteger.MIN, + ) + .apply { + addNode(nodeManager) + addHasTypeDefinition(SnmpNamespace.NodesIds.SnmpAgentDeviceType.expanded()) + addOrganizedBy(rootNodeId.expanded()) + } + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/DiagnosticAddressSpace.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/DiagnosticAddressSpace.kt index 23a46192..42379b11 100644 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/DiagnosticAddressSpace.kt +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/DiagnosticAddressSpace.kt @@ -2,11 +2,12 @@ package com.mussonindustrial.embr.snmp.agents.opc import com.mussonindustrial.embr.snmp.agents.devices.SnmpAgentDevice import com.mussonindustrial.embr.snmp.opc.DeviceContextManagedAddressSpaceFragment -import com.mussonindustrial.embr.snmp.utils.removeAllNodes -import org.eclipse.milo.opcua.sdk.core.Reference +import com.mussonindustrial.embr.snmp.opc.addComponentOf +import com.mussonindustrial.embr.snmp.opc.addNode +import com.mussonindustrial.embr.snmp.opc.addPropertyOf +import com.mussonindustrial.embr.snmp.opc.removeAllNodes import org.eclipse.milo.opcua.sdk.server.AddressSpaceComposite -import org.eclipse.milo.opcua.sdk.server.Lifecycle -import org.eclipse.milo.opcua.sdk.server.nodes.UaFolderNode +import org.eclipse.milo.opcua.sdk.server.nodes.UaObjectNode import org.eclipse.milo.opcua.sdk.server.nodes.UaVariableNode import org.eclipse.milo.opcua.sdk.server.nodes.filters.AttributeFilter import org.eclipse.milo.opcua.sdk.server.nodes.filters.AttributeFilters @@ -15,47 +16,36 @@ import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText 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.builtin.unsigned.UInteger class DiagnosticAddressSpace(val device: SnmpAgentDevice, composite: AddressSpaceComposite) : DeviceContextManagedAddressSpaceFragment(device.context.deviceContext, composite) { - private val root = "[Diagnostics]" + private val root = "Diagnostics" init { - lifecycleManager.addLifecycle( - object : Lifecycle { - override fun startup() { - addNodes() - } - - override fun shutdown() { - nodeManager.removeAllNodes() - } - } - ) + lifecycleManager.addStartupTask { addNodes() } + lifecycleManager.addShutdownTask { nodeManager.removeAllNodes() } } fun addNodes() { - val diagnosticsFolder = - UaFolderNode( - nodeContext, - nodeId(root), - qualifiedName(root), - LocalizedText.english(root), - ) - nodeManager.addNode(diagnosticsFolder) - - diagnosticsFolder.addReference( - Reference( - diagnosticsFolder.nodeId, - NodeIds.Organizes, - deviceNodeId.expanded(), - Reference.Direction.INVERSE, - ) - ) + val folder = + UaObjectNode( + nodeContext, + nodeId(root), + qualifiedName(root), + LocalizedText.english(root), + LocalizedText.NULL_VALUE, + UInteger.MIN, + UInteger.MIN, + ) + .apply { + addNode(nodeManager) + addComponentOf(deviceNodeId.expanded()) + } addDiagnosticNode( - diagnosticsFolder, + folder.nodeId, "Address", NodeIds.String, AttributeFilters.getValue { @@ -63,42 +53,81 @@ class DiagnosticAddressSpace(val device: SnmpAgentDevice, composite: AddressSpac }, ) addDiagnosticNode( - diagnosticsFolder, + folder.nodeId, "Status", NodeIds.String, AttributeFilters.getValue { DataValue(Variant(device.status.toString())) }, ) addDiagnosticNode( - diagnosticsFolder, + folder.nodeId, "Connected", NodeIds.Boolean, AttributeFilters.getValue { DataValue(Variant(device.status == SnmpAgentDevice.Status.CONNECTED)) }, ) + addDiagnosticNode( + folder.nodeId, + "PendingAsyncRequests", + NodeIds.UInt32, + AttributeFilters.getValue { + DataValue(Variant(device.context.snmp.pendingAsyncRequestCount)) + }, + ) + addDiagnosticNode( + folder.nodeId, + "PendingSyncRequests", + NodeIds.UInt32, + AttributeFilters.getValue { + DataValue(Variant(device.context.snmp.pendingSyncRequestCount)) + }, + ) + addDiagnosticNode( + folder.nodeId, + "MaxResponsePduSize", + NodeIds.UInt32, + AttributeFilters.getValue { DataValue(Variant(device.profile.maxResponsePduSize)) }, + ) + addDiagnosticNode( + folder.nodeId, + "MaxRequestPduSize", + NodeIds.UInt32, + AttributeFilters.getValue { DataValue(Variant(device.profile.maxRequestPduSize)) }, + ) + addDiagnosticNode( + folder.nodeId, + "RetryCount", + NodeIds.UInt32, + AttributeFilters.getValue { DataValue(Variant(device.context.readTarget.retries)) }, + ) + addDiagnosticNode( + folder.nodeId, + "ObjectModelSize", + NodeIds.UInt32, + AttributeFilters.getValue { DataValue(Variant(device.model.oids.size)) }, + ) } fun addDiagnosticNode( - folder: UaFolderNode, + parent: NodeId, name: String, dataType: NodeId, attributeFilter: AttributeFilter, ) { - UaVariableNode.UaVariableNodeBuilder(nodeContext).run { - setNodeId(nodeId("${root}${name}")) - setBrowseName(qualifiedName(name)) - setDisplayName(LocalizedText.english(name)) - setDataType(dataType) - addReference( - Reference( - nodeId, - NodeIds.HasComponent, - folder.nodeId.expanded(), - Reference.Direction.INVERSE, - ) + UaVariableNode( + nodeContext, + nodeId("${root}/${name}"), + qualifiedName(name), + LocalizedText.english(name), + LocalizedText.english(name), + UInteger.MIN, + UInteger.MIN, ) - addAttributeFilter(attributeFilter) - buildAndAdd() - } + .apply { + addNode(nodeManager) + setDataType(dataType) + addPropertyOf(parent.expanded()) + filterChain.addLast(attributeFilter) + } } } diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/MethodAddressSpace.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/MethodAddressSpace.kt new file mode 100644 index 00000000..c864d809 --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/MethodAddressSpace.kt @@ -0,0 +1,31 @@ +package com.mussonindustrial.embr.snmp.agents.opc + +import com.mussonindustrial.embr.snmp.agents.devices.SnmpAgentDevice +import com.mussonindustrial.embr.snmp.agents.opc.nodes.ReadTableMethodNode +import com.mussonindustrial.embr.snmp.agents.opc.nodes.WalkMethodNode +import com.mussonindustrial.embr.snmp.opc.DeviceContextManagedAddressSpaceFragment +import com.mussonindustrial.embr.snmp.opc.addComponentOf +import com.mussonindustrial.embr.snmp.opc.addNode +import com.mussonindustrial.embr.snmp.opc.removeAllNodes +import org.eclipse.milo.opcua.sdk.server.AddressSpaceComposite +import org.eclipse.milo.opcua.sdk.server.Lifecycle + +class MethodAddressSpace(val device: SnmpAgentDevice, composite: AddressSpaceComposite) : + DeviceContextManagedAddressSpaceFragment(device.context.deviceContext, composite), Lifecycle { + + init { + lifecycleManager.addStartupTask { addNodes() } + lifecycleManager.addShutdownTask { nodeManager.removeAllNodes() } + } + + fun addNodes() { + ReadTableMethodNode(nodeContext, nodeId("ReadTable"), device).apply { + addNode(nodeManager) + addComponentOf(deviceNodeId.expanded()) + } + WalkMethodNode(nodeContext, nodeId("Walk"), device).apply { + addNode(nodeManager) + addComponentOf(deviceNodeId.expanded()) + } + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/ObjectModelAddressSpace.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/ObjectModelAddressSpace.kt new file mode 100644 index 00000000..1614959e --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/ObjectModelAddressSpace.kt @@ -0,0 +1,67 @@ +package com.mussonindustrial.embr.snmp.agents.opc + +import com.mussonindustrial.embr.snmp.agents.devices.SnmpAgentDevice +import com.mussonindustrial.embr.snmp.opc.DeviceContextManagedAddressSpaceFragment +import com.mussonindustrial.embr.snmp.opc.addComponentOf +import com.mussonindustrial.embr.snmp.opc.addNode +import com.mussonindustrial.embr.snmp.opc.addOrganizedBy +import com.mussonindustrial.embr.snmp.opc.organizes +import com.mussonindustrial.embr.snmp.opc.removeAllNodes +import org.eclipse.milo.opcua.sdk.server.* +import org.eclipse.milo.opcua.sdk.server.nodes.UaFolderNode +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId +import org.eclipse.milo.opcua.stack.core.types.structured.ViewDescription + +class ObjectModelAddressSpace(val device: SnmpAgentDevice, composite: AddressSpaceComposite) : + DeviceContextManagedAddressSpaceFragment(device.context.deviceContext, composite) { + + private val root = "Objects" + private val model = device.model + + init { + lifecycleManager.addStartupTask { addNodes() } + lifecycleManager.addShutdownTask { nodeManager.removeAllNodes() } + } + + fun addNodes() { + UaFolderNode(nodeContext, nodeId(root), qualifiedName(root), LocalizedText.english(root)) + .apply { + addNode(nodeManager) + addComponentOf(deviceNodeId.expanded()) + addObjectsFolder(this, "Numeric") + } + } + + fun addObjectsFolder(folder: UaFolderNode, name: String) { + UaFolderNode( + nodeContext, + nodeId("${root}/${name}"), + qualifiedName(name), + LocalizedText.english(name), + ) + .apply { + addNode(nodeManager) + addOrganizedBy(folder.nodeId.expanded()) + } + } + + override fun browse( + context: AddressSpace.BrowseContext, + view: ViewDescription, + nodeIds: List, + ): List { + return nodeIds.map { nodeId -> + val references = nodeManager.getReferences(nodeId) + + when (nodeId) { + nodeId("Objects/Numeric") -> + model.getDescriptors(model.oids).forEach { descriptor -> + references += nodeId.organizes(nodeId(descriptor.oid.numeric).expanded()) + } + } + + AddressSpace.ReferenceResult.of(references) + } + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/OidAddressSpace.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/OidAddressSpace.kt index 95a608a0..60245670 100644 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/OidAddressSpace.kt +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/OidAddressSpace.kt @@ -1,39 +1,34 @@ package com.mussonindustrial.embr.snmp.agents.opc import com.mussonindustrial.embr.snmp.agents.devices.SnmpAgentDevice +import com.mussonindustrial.embr.snmp.agents.opc.nodes.DescriptorSuffixNode +import com.mussonindustrial.embr.snmp.model.ExtendedOid +import com.mussonindustrial.embr.snmp.model.ObjectModel +import com.mussonindustrial.embr.snmp.model.OidValue +import com.mussonindustrial.embr.snmp.model.Snmp4jExtendedOid +import com.mussonindustrial.embr.snmp.model.asExtendedOid +import com.mussonindustrial.embr.snmp.model.isOid import com.mussonindustrial.embr.snmp.opc.DeviceContextManagedAddressSpaceFragment -import com.mussonindustrial.embr.snmp.requests.OidReadRequest -import com.mussonindustrial.embr.snmp.requests.OidReadResult -import com.mussonindustrial.embr.snmp.requests.OidWriteRequest -import com.mussonindustrial.embr.snmp.requests.OidWriteResult -import com.mussonindustrial.embr.snmp.requests.toOidReadResult -import com.mussonindustrial.embr.snmp.requests.toOidWriteResult -import com.mussonindustrial.embr.snmp.utils.isOid -import com.mussonindustrial.embr.snmp.utils.toVariable +import com.mussonindustrial.embr.snmp.opc.hasProperty +import com.mussonindustrial.embr.snmp.opc.hasTypeDefinition +import com.mussonindustrial.embr.snmp.opc.organizedBy +import com.mussonindustrial.embr.snmp.opc.propertyOf import kotlin.jvm.optionals.getOrNull import org.eclipse.milo.opcua.sdk.core.AccessLevel +import org.eclipse.milo.opcua.sdk.core.Reference import org.eclipse.milo.opcua.sdk.core.ValueRank import org.eclipse.milo.opcua.sdk.server.AddressSpace import org.eclipse.milo.opcua.sdk.server.AddressSpaceComposite import org.eclipse.milo.opcua.sdk.server.AddressSpaceFilter import org.eclipse.milo.opcua.sdk.server.SimpleAddressSpaceFilter -import org.eclipse.milo.opcua.stack.core.AttributeId -import org.eclipse.milo.opcua.stack.core.OpcUaDataType -import org.eclipse.milo.opcua.stack.core.StatusCodes -import org.eclipse.milo.opcua.stack.core.UaException -import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue -import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText -import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId -import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode -import org.eclipse.milo.opcua.stack.core.types.builtin.Variant +import org.eclipse.milo.opcua.stack.core.* +import org.eclipse.milo.opcua.stack.core.types.builtin.* import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger import org.eclipse.milo.opcua.stack.core.types.enumerated.NodeClass import org.eclipse.milo.opcua.stack.core.types.enumerated.TimestampsToReturn import org.eclipse.milo.opcua.stack.core.types.structured.ReadValueId import org.eclipse.milo.opcua.stack.core.types.structured.ViewDescription import org.eclipse.milo.opcua.stack.core.types.structured.WriteValue -import org.snmp4j.smi.OID -import org.snmp4j.smi.VariableBinding class OidAddressSpace(val device: SnmpAgentDevice, composite: AddressSpaceComposite) : DeviceContextManagedAddressSpaceFragment(device.context.deviceContext, composite) { @@ -46,76 +41,129 @@ class OidAddressSpace(val device: SnmpAgentDevice, composite: AddressSpaceCompos timestamps: TimestampsToReturn, readValueIds: List, ): List { - val results = readValueIds.map { ReadRequest(it) } - val toProcess = results.filter { it.result == null } + val requests = readValueIds.map { ReadRequest(it) } + val directReads = requests.filter { !it.oid.hasSuffix } val valueReads = - toProcess.filter { + directReads.filter { AttributeId.from(it.readValueId.attributeId).get() == AttributeId.Value } - val valueReadResults = device.read(valueReads.map { VariableBinding(it.oid) }) - valueReadResults.zip(valueReads).forEach { (value, result) -> result.result = value } + readValueAttributes(valueReads).zip(valueReads).forEach { (value, result) -> + result.value = value + } val nonValueReads = - toProcess.filter { + directReads.filter { AttributeId.from(it.readValueId.attributeId).get() != AttributeId.Value } - val nonValueReadResults = readNonValueAttributes(nonValueReads) - nonValueReadResults.zip(nonValueReads).forEach { (value, result) -> result.result = value } - - return results.map { it.result!!.value } - } - - fun readNonValueAttributes(results: List): List { - return results.map { - val nodeId = it.readValueId.nodeId - val attributeId = AttributeId.from(it.readValueId.attributeId).getOrNull() - - try { - val result = - when (attributeId) { - AttributeId.NodeId -> nodeId - - AttributeId.NodeClass -> NodeClass.Variable - - AttributeId.BrowseName -> - device.context.deviceContext.qualifiedName(nodeId.getPath()) - - AttributeId.DisplayName, - AttributeId.Description -> LocalizedText.english(nodeId.getPath()) - - AttributeId.WriteMask, - AttributeId.UserWriteMask -> UInteger.valueOf(0) - - AttributeId.DataType -> OpcUaDataType.String.nodeId + readNonValueAttributes(nonValueReads).zip(nonValueReads).forEach { (value, result) -> + result.value = value + } - AttributeId.ValueRank -> ValueRank.Scalar.value + val suffixReads = requests.filter { it.oid.hasSuffix } + readSuffixAttributes(suffixReads).zip(suffixReads).forEach { (value, result) -> + result.value = value + } - AttributeId.ArrayDimensions -> intArrayOf() + return requests.map { it.value ?: DataValue(Variant.of(null)) } + } - AttributeId.AccessLevel, - AttributeId.UserAccessLevel -> AccessLevel.toValue(AccessLevel.READ_WRITE) + fun readValueAttributes(requests: List): List { + return device.read(requests.map { it.oid }).map { it.value } + } - AttributeId.Value -> - throw UaException( - StatusCodes.Bad_InternalError, - "attributeId: $attributeId", - ) + fun readNonValueAttributes(requests: List): List { + val descriptors = device.model.getDescriptors(requests.map { it.oid }) + return requests.zip(descriptors).map { (request, descriptor) -> + val nodeId = request.readValueId.nodeId + val attributeId = AttributeId.from(request.readValueId.attributeId).getOrNull() + + attributeId + .runCatching { resolveAttributeValue(attributeId, nodeId, descriptor) } + .fold( + onSuccess = { DataValue(Variant(it)) }, + onFailure = { DataValue((it as UaException).statusCode) }, + ) + } + } - else -> - throw UaException( - StatusCodes.Bad_AttributeIdInvalid, - "attributeId: $attributeId", - ) - }!! + @Throws(UaException::class) + fun resolveAttributeValue( + attributeId: AttributeId?, + nodeId: NodeId, + descriptor: ObjectModel.Descriptor, + ): Any? { + return when (attributeId) { + AttributeId.NodeId -> nodeId + AttributeId.NodeClass -> NodeClass.Variable + + AttributeId.BrowseName -> qualifiedName(descriptor.oid.numeric) + AttributeId.DisplayName -> LocalizedText.english(descriptor.oid.numeric) + AttributeId.Description -> LocalizedText.english("") + + AttributeId.WriteMask, + AttributeId.UserWriteMask -> UInteger.valueOf(0) + + AttributeId.EventNotifier -> null + + AttributeId.DataType -> + when (descriptor) { + is ObjectModel.ValueDescriptor -> descriptor.snmpDataType.nodeId + else -> OpcUaDataType.String.nodeId + } + AttributeId.ValueRank -> + when (descriptor) { + is ObjectModel.ValueDescriptor -> ValueRank.Scalar.value + else -> ValueRank.Scalar.value + } + AttributeId.ArrayDimensions -> + when (descriptor) { + is ObjectModel.ValueDescriptor -> null + else -> null + } + + AttributeId.AccessLevel, + AttributeId.UserAccessLevel -> AccessLevel.toValue(AccessLevel.READ_WRITE) + + AttributeId.Historizing -> false + + AttributeId.Value -> + throw UaException(StatusCodes.Bad_InternalError, "attributeId: $attributeId") + + else -> + throw UaException(StatusCodes.Bad_AttributeIdInvalid, "attributeId: $attributeId") + } + } - it.oid.toOidReadResult(DataValue(Variant(result))) - } catch (e: UaException) { - it.oid.toOidReadResult(DataValue(e.statusCode)) - } + fun readSuffixAttributes(requests: List): List { + val descriptors = device.model.getDescriptors(requests.map { it.oid }) + return requests.zip(descriptors).map { (request, descriptor) -> + val nodeId = request.readValueId.nodeId + val attributeId = AttributeId.from(request.readValueId.attributeId).getOrNull() + + attributeId + .runCatching { + resolveSuffixAttribute(attributeId, nodeId, descriptor, request.oid.suffix!!) + } + .fold( + onSuccess = { DataValue(Variant(it)) }, + onFailure = { DataValue((it as UaException).statusCode) }, + ) } } + fun resolveSuffixAttribute( + attributeId: AttributeId?, + nodeId: NodeId, + descriptor: ObjectModel.Descriptor, + suffix: String, + ): Any? { + val suffixNode = + DescriptorSuffixNode.from(suffix) ?: throw UaException(StatusCodes.Bad_NodeIdUnknown) + val context = DescriptorSuffixNode.Context(nodeId, device, descriptor) + return suffixNode.readAttribute(context, attributeId) + } + override fun write( context: AddressSpace.WriteContext, writeValues: List, @@ -124,22 +172,23 @@ class OidAddressSpace(val device: SnmpAgentDevice, composite: AddressSpaceCompos results.forEach { if (it.writeValue.attributeId == null) { - it.result = it.oid.toOidWriteResult(StatusCode(StatusCodes.Bad_AttributeIdInvalid)) + it.value = StatusCode(StatusCodes.Bad_AttributeIdInvalid) } if (it.writeValue.indexRange != null && it.writeValue.indexRange.isNotEmpty()) { - it.result = it.oid.toOidWriteResult(StatusCode(StatusCodes.Bad_NotImplemented)) + it.value = StatusCode(StatusCodes.Bad_NotImplemented) } if (AttributeId.from(it.writeValue.attributeId).getOrNull() != AttributeId.Value) { - it.result = it.oid.toOidWriteResult(StatusCode(StatusCodes.Bad_NotImplemented)) + it.value = StatusCode(StatusCodes.Bad_NotImplemented) } } - val valueWrites = results.filter { it.result == null } - val valueWriteResults = - device.write(valueWrites.map { VariableBinding(it.oid, it.value.toVariable()) }) - valueWriteResults.zip(valueWrites).forEach { (value, result) -> result.result = value } + val valueWrites = results.filter { it.value == null } + device + .write(valueWrites.map { it.oid to it.writeValue.value.value.value }) + .zip(valueWrites) + .forEach { (value, result) -> result.value = value.value } - return results.map { it.result?.statusCode } + return results.map { it.value } } override fun browse( @@ -147,15 +196,35 @@ class OidAddressSpace(val device: SnmpAgentDevice, composite: AddressSpaceCompos view: ViewDescription, nodeIds: List, ): List { - return emptyList() + return nodeIds.map { nodeId -> + val references = mutableListOf() + val oidPath = nodeId.getPath().asExtendedOid() + references += + if (oidPath.suffix == null) { + browseDirect(oidPath, nodeId) + } else { + browseSuffix(oidPath, nodeId) + } + AddressSpace.ReferenceResult.of(references) + } } - override fun gather( - context: AddressSpace.BrowseContext, - view: ViewDescription, - nodeId: NodeId, - ): AddressSpace.ReferenceResult.ReferenceList { - return AddressSpace.ReferenceResult.ReferenceList(emptyList()) + fun browseDirect(oid: ExtendedOid, nodeId: NodeId): List { + val references = mutableListOf() + references += nodeId.organizedBy(nodeId("Objects/Numeric").expanded()) + + DescriptorSuffixNode.ALL.forEach { suffix -> + val extendedOid = Snmp4jExtendedOid(oid.numeric, suffix.name) + references += nodeId.hasProperty(nodeId(extendedOid.toIdentifier()).expanded()) + } + return references + } + + fun browseSuffix(oid: ExtendedOid, nodeId: NodeId): List { + val references = mutableListOf() + references += nodeId.hasTypeDefinition(NodeIds.PropertyType.expanded()) + references += nodeId.propertyOf(nodeId(oid.numeric).expanded()) + return references } override fun getFilter(): AddressSpaceFilter { @@ -166,14 +235,13 @@ class OidAddressSpace(val device: SnmpAgentDevice, composite: AddressSpaceCompos return device.stripDeviceName(this) } - inner class ReadRequest(val readValueId: ReadValueId) : OidReadRequest { - override var result: OidReadResult? = null - override val oid = OID(readValueId.nodeId.getPath()) + inner class ReadRequest(val readValueId: ReadValueId) : OidValue { + override val oid = readValueId.nodeId.getPath().asExtendedOid() + override var value: DataValue? = null } - inner class WriteRequest(val writeValue: WriteValue) : OidWriteRequest { - override var result: OidWriteResult? = null - override val value: DataValue = writeValue.value - override val oid = OID(writeValue.nodeId.getPath()) + inner class WriteRequest(val writeValue: WriteValue) : OidValue { + override val oid = writeValue.nodeId.getPath().asExtendedOid() + override var value: StatusCode? = null } } diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/nodes/DescriptorSuffixNode.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/nodes/DescriptorSuffixNode.kt new file mode 100644 index 00000000..e32abf46 --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/nodes/DescriptorSuffixNode.kt @@ -0,0 +1,90 @@ +package com.mussonindustrial.embr.snmp.agents.opc.nodes + +import com.mussonindustrial.embr.snmp.agents.devices.SnmpAgentDevice +import com.mussonindustrial.embr.snmp.model.ObjectModel +import org.eclipse.milo.opcua.sdk.core.AccessLevel +import org.eclipse.milo.opcua.sdk.core.ValueRank +import org.eclipse.milo.opcua.stack.core.AttributeId +import org.eclipse.milo.opcua.stack.core.OpcUaDataType +import org.eclipse.milo.opcua.stack.core.StatusCodes +import org.eclipse.milo.opcua.stack.core.UaException +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UByte +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger +import org.eclipse.milo.opcua.stack.core.types.enumerated.NodeClass + +sealed class DescriptorSuffixNode(val name: String) { + + data class Context( + val nodeId: NodeId, + val device: SnmpAgentDevice, + val descriptor: ObjectModel.Descriptor, + ) + + open fun getBrowseName(context: Context): QualifiedName = QualifiedName(0, name) + + open fun getDisplayName(context: Context): LocalizedText = LocalizedText.english(name) + + open fun getDescription(context: Context): LocalizedText = LocalizedText.english("") + + open fun getAccessLevel(context: Context): UByte? = AccessLevel.toValue(AccessLevel.READ_ONLY) + + abstract fun getDataType(context: Context): NodeId + + abstract fun getValueRank(context: Context): Int + + abstract fun getValue(context: Context): Any? + + open fun readAttribute(context: Context, attributeId: AttributeId?): Any? { + return when (attributeId) { + AttributeId.NodeId -> context.nodeId + AttributeId.NodeClass -> NodeClass.Variable + AttributeId.BrowseName -> getBrowseName(context) + AttributeId.DisplayName -> getDisplayName(context) + AttributeId.Description -> getDescription(context) + + AttributeId.DataType -> getDataType(context) + AttributeId.ValueRank -> getValueRank(context) + AttributeId.ArrayDimensions -> null + + AttributeId.AccessLevel, + AttributeId.UserAccessLevel -> getAccessLevel(context) + + AttributeId.Historizing -> false + AttributeId.Value -> getValue(context) + + AttributeId.WriteMask, + AttributeId.UserWriteMask -> UInteger.valueOf(0) + + else -> + throw UaException(StatusCodes.Bad_AttributeIdInvalid, "attributeId: $attributeId") + } + } + + object DataType : DescriptorSuffixNode("DataType") { + override fun getDataType(context: Context): NodeId = OpcUaDataType.String.nodeId + + override fun getValueRank(context: Context) = ValueRank.Scalar.value + + override fun getValue(context: Context) = + (context.descriptor as? ObjectModel.ValueDescriptor)?.snmpDataType?.name + } + + object ExpectedSize : DescriptorSuffixNode("ExpectedSize") { + override fun getDataType(context: Context): NodeId = OpcUaDataType.UInt32.nodeId + + override fun getValueRank(context: Context) = ValueRank.Scalar.value + + override fun getValue(context: Context) = + (context.descriptor as? ObjectModel.ValueDescriptor)?.expectedSize + } + + companion object { + val ALL = listOf(DataType, ExpectedSize) + private val byName = ALL.associateBy { it.name } + + fun from(name: String): DescriptorSuffixNode? = byName[name] + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/nodes/ReadTableMethodNode.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/nodes/ReadTableMethodNode.kt new file mode 100644 index 00000000..48eea529 --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/nodes/ReadTableMethodNode.kt @@ -0,0 +1,121 @@ +package com.mussonindustrial.embr.snmp.agents.opc.nodes + +import com.mussonindustrial.embr.snmp.agents.devices.SnmpAgentDevice +import com.mussonindustrial.embr.snmp.model.asExtendedOid +import com.mussonindustrial.embr.snmp.model.nullOrExtendedOid +import com.mussonindustrial.embr.snmp.opc.SnmpNamespace +import com.mussonindustrial.embr.snmp.opc.types.OidValueType +import kotlin.collections.toTypedArray +import org.eclipse.milo.opcua.sdk.core.ValueRank +import org.eclipse.milo.opcua.sdk.server.methods.MethodInvocationHandler +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode +import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext +import org.eclipse.milo.opcua.stack.core.NodeIds +import org.eclipse.milo.opcua.stack.core.types.builtin.DiagnosticInfo +import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +import org.eclipse.milo.opcua.stack.core.types.builtin.Matrix +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint +import org.eclipse.milo.opcua.stack.core.types.structured.Argument +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult + +class ReadTableMethodNode(context: UaNodeContext, nodeId: NodeId, val device: SnmpAgentDevice) : + UaMethodNode( + context, + nodeId, + QualifiedName.parse("ReadTable"), + LocalizedText.english("ReadTable"), + LocalizedText.english("Read from an SNMP table."), + uint(0), + uint(0), + true, + true, + ) { + + init { + inputArguments = + arrayOf( + Argument( + "Columns", + NodeIds.String, + ValueRank.OneDimension.value, + arrayOf(UInteger.valueOf(0)), + LocalizedText.english("A list of column OIDs that define the table structure."), + ), + Argument( + "LowerBoundIndex", + NodeIds.String, + ValueRank.Scalar.value, + null, + LocalizedText.english("The inclusive lower index bound for the table read."), + ), + Argument( + "UpperBoundIndex", + NodeIds.String, + ValueRank.Scalar.value, + null, + LocalizedText.english("The inclusive upper index bound for the table read."), + ), + ) + + outputArguments = + arrayOf( + Argument( + "Result", + SnmpNamespace.NodesIds.OidValue, + 2, + arrayOf(UInteger.valueOf(0), UInteger.valueOf(0)), + LocalizedText.english( + "An array containing the table rows and columns derived from the SNMP response." + ), + ) + ) + + invocationHandler = MethodInvocationHandler { _, request -> + val columns = + request.inputArguments?.first()?.value as Array<*>? + ?: return@MethodInvocationHandler CallMethodResult( + StatusCode.BAD, + arrayOfNulls(0), + arrayOfNulls(0), + arrayOf(), + ) + + val lowerBoundIndex = request.inputArguments?.get(1)?.value as String + val upperBoundIndex = request.inputArguments?.get(2)?.value as String + + val results = + device + .readTable( + columns.map { (it as String).asExtendedOid() }, + lowerBoundIndex.ifEmpty { null }.nullOrExtendedOid(), + upperBoundIndex.ifEmpty { null }.nullOrExtendedOid(), + ) + .map { column -> + column + .map { + ExtensionObject.encode( + context.server.staticEncodingContext, + OidValueType(it.oid.numeric, it.value.value.value), + ) + } + .toTypedArray() + } + .toTypedArray() + + val output = Variant(Matrix(results)) + + CallMethodResult( + StatusCode.GOOD, + arrayOfNulls(0), + arrayOfNulls(0), + arrayOf(output), + ) + } + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/nodes/WalkMethodNode.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/nodes/WalkMethodNode.kt new file mode 100644 index 00000000..66c880c6 --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/nodes/WalkMethodNode.kt @@ -0,0 +1,84 @@ +package com.mussonindustrial.embr.snmp.agents.opc.nodes + +import com.mussonindustrial.embr.snmp.agents.devices.SnmpAgentDevice +import com.mussonindustrial.embr.snmp.model.asExtendedOid +import com.mussonindustrial.embr.snmp.opc.SnmpNamespace +import com.mussonindustrial.embr.snmp.opc.types.OidValueType +import kotlin.collections.toTypedArray +import org.eclipse.milo.opcua.sdk.server.methods.MethodInvocationHandler +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode +import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext +import org.eclipse.milo.opcua.stack.core.NodeIds +import org.eclipse.milo.opcua.stack.core.types.builtin.DiagnosticInfo +import org.eclipse.milo.opcua.stack.core.types.builtin.ExtensionObject +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName +import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UInteger +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint +import org.eclipse.milo.opcua.stack.core.types.structured.Argument +import org.eclipse.milo.opcua.stack.core.types.structured.CallMethodResult + +class WalkMethodNode(context: UaNodeContext, nodeId: NodeId, val device: SnmpAgentDevice) : + UaMethodNode( + context, + nodeId, + QualifiedName.parse("Walk"), + LocalizedText.english("Walk"), + LocalizedText.english("Walk the SNMP tree starting at the given OID."), + uint(0), + uint(0), + true, + true, + ) { + + init { + inputArguments = + arrayOf( + Argument( + "Roots", + NodeIds.String, + 1, + arrayOf(UInteger.valueOf(0)), + LocalizedText.english("A list of OIDs to walk."), + ) + ) + + outputArguments = + arrayOf( + Argument( + "Result", + SnmpNamespace.NodesIds.OidValue, + 1, + arrayOf(UInteger.valueOf(0)), + LocalizedText.english("A list of values discovered during the walk operation."), + ) + ) + + invocationHandler = MethodInvocationHandler { _, request -> + val roots = request.inputArguments?.first()?.value as Array<*> + + val results = + device + .walk(roots.map { (it as String).asExtendedOid() }) + .map { + ExtensionObject.encode( + context.server.staticEncodingContext, + OidValueType(it.oid.numeric, it.value.value.value), + ) + } + .toTypedArray() + + val output = Variant(results) + + CallMethodResult( + StatusCode.GOOD, + arrayOfNulls(0), + arrayOfNulls(0), + arrayOf(output), + ) + } + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/types/SnmpAgentDeviceType.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/types/SnmpAgentDeviceType.kt new file mode 100644 index 00000000..c259d20e --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/opc/types/SnmpAgentDeviceType.kt @@ -0,0 +1,82 @@ +package com.mussonindustrial.embr.snmp.agents.opc.types + +import com.mussonindustrial.embr.snmp.opc.SnmpNamespace +import com.mussonindustrial.embr.snmp.opc.addComponentOf +import com.mussonindustrial.embr.snmp.opc.addModellingRule +import com.mussonindustrial.embr.snmp.opc.addNode +import com.mussonindustrial.embr.snmp.opc.addOrganizedBy +import com.mussonindustrial.embr.snmp.opc.addSubtypeOf +import org.eclipse.milo.opcua.sdk.server.nodes.UaFolderNode +import org.eclipse.milo.opcua.sdk.server.nodes.UaMethodNode +import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext +import org.eclipse.milo.opcua.sdk.server.nodes.UaObjectTypeNode +import org.eclipse.milo.opcua.stack.core.NodeIds +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned +import org.eclipse.milo.opcua.stack.core.types.structured.AccessRestrictionType + +object SnmpAgentDeviceType { + + const val BROWSE_NAME = "SnmpAgentDeviceType" + + private fun method(nodeId: NodeId, browseName: String, parent: NodeId) = + UaMethodNode( + SnmpNamespace.nodeContext, + nodeId, + SnmpNamespace.qualifiedName(browseName), + LocalizedText.english(browseName), + LocalizedText.english(""), + Unsigned.uint(0), + Unsigned.uint(0), + true, + true, + ) + .apply { + addNode(SnmpNamespace.nodeManager) + addComponentOf(parent.expanded()) + addModellingRule(NodeIds.ModellingRule_Mandatory.expanded()) + + accessRestrictions = AccessRestrictionType.of() + } + + fun register(nodeContext: UaNodeContext) { + val type = + UaObjectTypeNode( + nodeContext, + SnmpNamespace.NodesIds.SnmpAgentDeviceType, + SnmpNamespace.qualifiedName(BROWSE_NAME), + LocalizedText.english(BROWSE_NAME), + LocalizedText.english(""), + Unsigned.uint(0), + Unsigned.uint(0), + false, + ) + .apply { + addNode(nodeContext.nodeManager) + addSubtypeOf(NodeIds.BaseObjectType.expanded()) + + accessRestrictions = AccessRestrictionType.of() + } + + UaFolderNode( + nodeContext, + SnmpNamespace.nodeId("${BROWSE_NAME}.Objects"), + SnmpNamespace.qualifiedName("Objects"), + LocalizedText.english(""), + ) + .apply { + addNode(nodeContext.nodeManager) + addOrganizedBy(type.nodeId.expanded()) + + accessRestrictions = AccessRestrictionType.of() + } + + method(SnmpNamespace.Companion.NodesIds.SnmpAgentDeviceType_Walk, "Walk", type.nodeId) + method( + SnmpNamespace.Companion.NodesIds.SnmpAgentDeviceType_ReadTable, + "ReadTable", + type.nodeId, + ) + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/rpc/SnmpAgentRpcImpl.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/rpc/SnmpAgentRpcImpl.kt index 341a111e..b5b1ea0a 100644 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/rpc/SnmpAgentRpcImpl.kt +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/agents/rpc/SnmpAgentRpcImpl.kt @@ -7,11 +7,11 @@ import com.inductiveautomation.ignition.gateway.rpc.RpcDelegate import com.mussonindustrial.embr.snmp.SnmpGatewayContext import com.mussonindustrial.embr.snmp.agents.devices.SnmpAgentDevice import com.mussonindustrial.embr.snmp.model.QualifiedOidValue -import com.mussonindustrial.embr.snmp.utils.toQualityCode +import com.mussonindustrial.embr.snmp.model.Snmp4jOid +import com.mussonindustrial.embr.snmp.model.nullOrExtendedOid +import com.mussonindustrial.embr.snmp.model.toQualifiedValue +import com.mussonindustrial.embr.snmp.opc.toQualityCode import org.python.core.Py.ValueError -import org.snmp4j.smi.OID -import org.snmp4j.smi.OctetString -import org.snmp4j.smi.VariableBinding @RpcDelegate.RunsOnClient(clientPermissionId = ClientPermissionsConstants.UNRESTRICTED) class SnmpAgentRpcImpl(val context: SnmpGatewayContext) : SnmpAgentRpc { @@ -24,13 +24,11 @@ class SnmpAgentRpcImpl(val context: SnmpGatewayContext) : SnmpAgentRpc { @RpcDelegate.RequiredMutabilityMode(value = MutabilityMode.READ_ONLY) override fun read(agent: String, oids: List): List { val snmpAgent = requireAgent(agent) - return snmpAgent.read(oids.map { oid -> VariableBinding(OID(oid)) }).map { - it.toQualifiedValue() - } + return snmpAgent.read(oids.map { Snmp4jOid(it) }).map { it.toQualifiedValue() } } @RpcDelegate.RequiredMutabilityMode(value = MutabilityMode.READ_WRITE) - override fun write(agent: String, oids: List, values: List): List { + override fun write(agent: String, oids: List, values: List): List { val snmpAgent = requireAgent(agent) if (oids.size != values.size) { @@ -38,18 +36,14 @@ class SnmpAgentRpcImpl(val context: SnmpGatewayContext) : SnmpAgentRpc { } return snmpAgent - .write( - oids.zip(values).map { (oid, value) -> - VariableBinding(OID(oid), OctetString(value)) - } - ) - .map { it.statusCode.toQualityCode() } + .write(oids.zip(values).map { (oid, value) -> Snmp4jOid(oid) to value }) + .map { it.value.toQualityCode() } } @RpcDelegate.RequiredMutabilityMode(value = MutabilityMode.READ_ONLY) override fun walk(agent: String, oids: List): List { val snmpAgent = requireAgent(agent) - return snmpAgent.walk(oids.map { OID(it) }).map { it.toQualifiedValue() } + return snmpAgent.walk(oids.map { Snmp4jOid(it) }).map { it.toQualifiedValue() } } @RpcDelegate.RequiredMutabilityMode(value = MutabilityMode.READ_ONLY) @@ -62,17 +56,10 @@ class SnmpAgentRpcImpl(val context: SnmpGatewayContext) : SnmpAgentRpc { val snmpAgent = requireAgent(agent) return snmpAgent .readTable( - columns.map { OID(it) }, - lowerBoundIndex.nullOrOid(), - upperBoundIndex.nullOrOid(), + columns.map { Snmp4jOid(it) }, + lowerBoundIndex.nullOrExtendedOid(), + upperBoundIndex.nullOrExtendedOid(), ) - .map { it.map { binding -> binding.toQualifiedValue() } } - } - - private fun String?.nullOrOid(): OID? { - this?.let { - return OID(it) - } - return null + .map { results -> results.map { it.toQualifiedValue() } } } } diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/model/ConcurrentObjectModel.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/model/ConcurrentObjectModel.kt new file mode 100644 index 00000000..7b138b8a --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/model/ConcurrentObjectModel.kt @@ -0,0 +1,49 @@ +package com.mussonindustrial.embr.snmp.model + +import com.mussonindustrial.embr.snmp.agents.devices.SnmpAgentDevice +import com.mussonindustrial.embr.snmp.opc.types.SnmpDataType +import java.util.concurrent.ConcurrentHashMap +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue +import org.snmp4j.smi.Variable + +class ConcurrentObjectModel(val device: SnmpAgentDevice) : ObjectModel { + + private val descriptors = ConcurrentHashMap() + private val knownValues = ConcurrentHashMap>() + + override val oids: List + get() = descriptors.map { it.key } + + override fun observe(value: OidValue): OidValue { + val result = toOpcUaValue(value) + knownValues[result.oid] = result + + val variable = value.value + if (variable == SnmpCommunicationError) { + descriptors.getOrPut(result.oid) { ObjectModel.InvalidDescriptor(result.oid) } + } else { + descriptors + .getOrPut(result.oid) { + ObjectModel.ValueDescriptor( + value.oid, + SnmpDataType.of(variable), + variable.berLength, + ) + } + .apply { + when (this) { + is ObjectModel.ValueDescriptor -> { + if (variable.berLength > expectedSize) expectedSize *= 2 + } + else -> {} + } + } + } + + return result + } + + override fun getDescriptors(oids: List): List { + return oids.map { descriptors.getOrDefault(it, ObjectModel.UnknownDescriptor(it)) } + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/model/ObjectModel.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/model/ObjectModel.kt new file mode 100644 index 00000000..0110e604 --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/model/ObjectModel.kt @@ -0,0 +1,77 @@ +package com.mussonindustrial.embr.snmp.model + +import com.mussonindustrial.embr.snmp.opc.types.SnmpDataType +import org.eclipse.milo.opcua.stack.core.StatusCodes +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue +import org.eclipse.milo.opcua.stack.core.types.builtin.Variant +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.ULong +import org.snmp4j.smi.Counter64 +import org.snmp4j.smi.Integer32 +import org.snmp4j.smi.Null +import org.snmp4j.smi.OID +import org.snmp4j.smi.OctetString +import org.snmp4j.smi.SMIAddress +import org.snmp4j.smi.UnsignedInteger32 +import org.snmp4j.smi.Variable + +interface ObjectModel { + + val oids: List + + fun observe(value: OidValue): OidValue + + fun getDescriptors(oids: List): List + + fun toSnmpValue(value: OidValue): OidValue { + val descriptor = getDescriptors(listOf(value.oid)).first() + + val snmpValue = + when (descriptor) { + is ValueDescriptor -> + SnmpDataType.variableOfType(descriptor.snmpDataType, value.value) + is InvalidDescriptor -> Null.instance + is TableColumnDescriptor -> Null.instance + is TableDescriptor -> Null.instance + is UnknownDescriptor -> Null.instance + } + return BasicOidValue(value.oid, snmpValue) + } + + fun toOpcUaValue(value: OidValue): OidValue { + if (value.value == SnmpCommunicationError) { + return BasicOidValue(value.oid, DataValue(StatusCodes.Bad_CommunicationError)) + } + + val opcUaValue = + when (value.value) { + Null.endOfMibView -> DataValue(StatusCodes.Bad_NotFound) + Null.noSuchObject -> DataValue(StatusCodes.Bad_NotFound) + Null.noSuchInstance -> DataValue(StatusCodes.Bad_NotFound) + Null.instance -> DataValue(Variant.NULL_VALUE) + is Integer32 -> DataValue(Variant.ofInt32(value.value.toInt())) + is UnsignedInteger32 -> DataValue(Variant.ofInt32(value.value.toInt())) + is Counter64 -> DataValue(Variant.ofUInt64(ULong.valueOf(value.value.toLong()))) + is SMIAddress -> DataValue(Variant.ofString(value.value.toString())) + is OctetString -> DataValue(Variant.ofString(value.value.toString())) + is OID -> DataValue(Variant.ofString(value.value.toString())) + else -> DataValue(Variant.ofString(value.value.toString())) + } + return BasicOidValue(value.oid, opcUaValue) + } + + sealed class Descriptor(val oid: Oid) + + class InvalidDescriptor(oid: Oid) : Descriptor(oid) + + class UnknownDescriptor(oid: Oid) : Descriptor(oid) + + open class ValueDescriptor( + oid: Oid, + val snmpDataType: SnmpDataType, + var expectedSize: Int = 0, + ) : Descriptor(oid) + + class TableColumnDescriptor(oid: Oid, val snmpDataType: SnmpDataType) : Descriptor(oid) + + class TableDescriptor(oid: Oid, val columns: List) : Descriptor(oid) +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/model/OidValueUtils.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/model/OidValueUtils.kt new file mode 100644 index 00000000..bd02c4cb --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/model/OidValueUtils.kt @@ -0,0 +1,13 @@ +package com.mussonindustrial.embr.snmp.model + +import com.mussonindustrial.embr.snmp.opc.toQualityCode +import java.util.Date +import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue + +fun OidValue.toQualifiedValue(): QualifiedOidValue = + BasicQualifiedOidValue( + oid, + this.value.value.value, + this.value.statusCode.toQualityCode(), + this.value.serverTime?.javaDate ?: Date(), + ) diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/DeviceAddressSpace.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/DeviceAddressSpace.kt deleted file mode 100644 index 9e88ed69..00000000 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/DeviceAddressSpace.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.mussonindustrial.embr.snmp.opc - -import com.inductiveautomation.ignition.gateway.opcua.server.api.DeviceContext -import com.mussonindustrial.embr.snmp.utils.removeAllNodes -import org.eclipse.milo.opcua.sdk.core.Reference -import org.eclipse.milo.opcua.sdk.server.AddressSpaceComposite -import org.eclipse.milo.opcua.sdk.server.Lifecycle -import org.eclipse.milo.opcua.sdk.server.nodes.UaFolderNode -import org.eclipse.milo.opcua.stack.core.NodeIds -import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText - -class DeviceAddressSpace(deviceContext: DeviceContext, composite: AddressSpaceComposite) : - DeviceContextManagedAddressSpaceFragment(deviceContext, composite) { - - private val deviceFolderNode = - UaFolderNode( - nodeContext, - deviceNodeId, - qualifiedName("[${name}]"), - LocalizedText("[${name}]"), - ) - - init { - lifecycleManager.addLifecycle( - object : Lifecycle { - override fun startup() { - nodeManager.addNode(deviceFolderNode) - deviceFolderNode.addReference( - Reference( - deviceFolderNode.nodeId, - NodeIds.Organizes, - getRootNodeId().expanded(), - Reference.Direction.INVERSE, - ) - ) - } - - override fun shutdown() { - nodeManager.removeAllNodes() - } - } - ) - } -} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/utils/OpcUaUtils.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/OpcUaUtils.kt similarity index 90% rename from modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/utils/OpcUaUtils.kt rename to modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/OpcUaUtils.kt index 402227fe..53706617 100644 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/utils/OpcUaUtils.kt +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/OpcUaUtils.kt @@ -1,4 +1,4 @@ -package com.mussonindustrial.embr.snmp.utils +package com.mussonindustrial.embr.snmp.opc import com.inductiveautomation.ignition.common.model.values.QualityCode import org.eclipse.milo.opcua.sdk.server.UaNodeManager diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/ReferenceHelpers.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/ReferenceHelpers.kt new file mode 100644 index 00000000..080c9e89 --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/ReferenceHelpers.kt @@ -0,0 +1,91 @@ +package com.mussonindustrial.embr.snmp.opc + +import org.eclipse.milo.opcua.sdk.core.Reference +import org.eclipse.milo.opcua.sdk.server.NodeManager +import org.eclipse.milo.opcua.sdk.server.nodes.UaNode +import org.eclipse.milo.opcua.stack.core.NodeIds +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId + +fun UaNode.addReference( + sourceNodeId: NodeId, + referenceTypeId: NodeId, + targetNodeId: ExpandedNodeId, + direction: Reference.Direction, +) { + addReference(Reference(sourceNodeId, referenceTypeId, targetNodeId, direction)) +} + +fun UaNode.addNode(nodeManager: NodeManager) { + nodeManager.addNode(this) +} + +fun NodeId.componentOf(targetNodeId: ExpandedNodeId) = + Reference(this, NodeIds.HasComponent, targetNodeId, Reference.Direction.INVERSE) + +fun UaNode.addComponentOf(targetNodeId: ExpandedNodeId) { + addReference(nodeId.componentOf(targetNodeId)) +} + +fun NodeId.hasEncoding(targetNodeId: ExpandedNodeId) = + Reference(this, NodeIds.HasEncoding, targetNodeId, Reference.Direction.FORWARD) + +fun UaNode.addHasEncoding(targetNodeId: ExpandedNodeId) { + addReference(nodeId.hasEncoding(targetNodeId)) +} + +fun NodeId.modellingRule(targetNodeId: ExpandedNodeId) = + Reference(this, NodeIds.HasModellingRule, targetNodeId, Reference.Direction.FORWARD) + +fun UaNode.addModellingRule(targetNodeId: ExpandedNodeId) { + addReference(nodeId.modellingRule(targetNodeId)) +} + +fun NodeId.organizedBy(targetNodeId: ExpandedNodeId) = + Reference(this, NodeIds.Organizes, targetNodeId, Reference.Direction.INVERSE) + +fun UaNode.addOrganizedBy(targetNodeId: ExpandedNodeId) { + addReference(nodeId.organizedBy(targetNodeId)) +} + +fun NodeId.organizes(targetNodeId: ExpandedNodeId) = + Reference(this, NodeIds.Organizes, targetNodeId, Reference.Direction.FORWARD) + +fun UaNode.addOrganizes(targetNodeId: ExpandedNodeId) { + addReference(nodeId.organizes(targetNodeId)) +} + +fun NodeId.hasProperty(targetNodeId: ExpandedNodeId) = + Reference(this, NodeIds.HasProperty, targetNodeId, Reference.Direction.FORWARD) + +fun UaNode.addHasProperty(targetNodeId: ExpandedNodeId) { + addReference(nodeId.hasProperty(targetNodeId)) +} + +fun NodeId.propertyOf(targetNodeId: ExpandedNodeId) = + Reference(this, NodeIds.HasProperty, targetNodeId, Reference.Direction.INVERSE) + +fun UaNode.addPropertyOf(targetNodeId: ExpandedNodeId) { + addReference(nodeId.propertyOf(targetNodeId)) +} + +fun NodeId.hasSubtype(targetNodeId: ExpandedNodeId) = + Reference(this, NodeIds.HasSubtype, targetNodeId, Reference.Direction.FORWARD) + +fun UaNode.addHasSubtype(targetNodeId: ExpandedNodeId) { + addReference(nodeId.hasSubtype(targetNodeId)) +} + +fun NodeId.subTypeOf(targetNodeId: ExpandedNodeId) = + Reference(this, NodeIds.HasSubtype, targetNodeId, Reference.Direction.INVERSE) + +fun UaNode.addSubtypeOf(targetNodeId: ExpandedNodeId) { + addReference(nodeId.subTypeOf(targetNodeId)) +} + +fun NodeId.hasTypeDefinition(targetNodeId: ExpandedNodeId) = + Reference(this, NodeIds.HasTypeDefinition, targetNodeId, Reference.Direction.FORWARD) + +fun UaNode.addHasTypeDefinition(targetNodeId: ExpandedNodeId) { + addReference(nodeId.hasTypeDefinition(targetNodeId)) +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/SnmpNamespace.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/SnmpNamespace.kt new file mode 100644 index 00000000..f9475524 --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/SnmpNamespace.kt @@ -0,0 +1,79 @@ +package com.mussonindustrial.embr.snmp.opc + +import com.mussonindustrial.embr.snmp.agents.opc.types.SnmpAgentDeviceType +import com.mussonindustrial.embr.snmp.opc.types.OidValueType +import com.mussonindustrial.embr.snmp.opc.types.SnmpDataType +import org.eclipse.milo.opcua.sdk.server.ManagedNamespaceWithLifecycle +import org.eclipse.milo.opcua.sdk.server.OpcUaServer +import org.eclipse.milo.opcua.sdk.server.UaNodeManager +import org.eclipse.milo.opcua.sdk.server.items.DataItem +import org.eclipse.milo.opcua.sdk.server.items.MonitoredItem +import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext +import org.eclipse.milo.opcua.sdk.server.util.SubscriptionModel +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId +import org.eclipse.milo.opcua.stack.core.types.builtin.QualifiedName +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.UShort + +class SnmpNamespace(server: OpcUaServer) : ManagedNamespaceWithLifecycle(server, NAMESPACE_URI) { + + companion object { + const val NAMESPACE_URI = "urn:mussonindustrial:embr:snmp" + lateinit var instance: SnmpNamespace + + fun nodeId(id: Int) = NodeId(namespaceIndex, id) + + fun nodeId(id: String) = NodeId(namespaceIndex, id) + + fun qualifiedName(name: String): QualifiedName = + QualifiedName(instance.namespaceIndex, name) + + val nodeContext: UaNodeContext + get() = instance.nodeContext + + val nodeManager: UaNodeManager + get() = instance.nodeManager + + val namespaceIndex: UShort + get() = instance.namespaceIndex + + val NodesIds by lazy { + SnmpNodeIds { + when (it) { + is Number -> nodeId(it.toInt()) + is String -> nodeId(it) + else -> throw IllegalArgumentException("Invalid node ID: $it") + } + } + } + } + + private val subscriptionModel = SubscriptionModel(server, this) + + init { + instance = this + lifecycleManager.addLifecycle(subscriptionModel) + lifecycleManager.addStartupTask { registerTypes() } + } + + fun registerTypes() { + SnmpDataType.registerAll(nodeContext) + SnmpAgentDeviceType.register(nodeContext) + OidValueType.register(nodeContext) + } + + override fun onDataItemsCreated(items: List) { + subscriptionModel.onDataItemsCreated(items) + } + + override fun onDataItemsModified(items: List) { + subscriptionModel.onDataItemsModified(items) + } + + override fun onDataItemsDeleted(items: List) { + subscriptionModel.onDataItemsDeleted(items) + } + + override fun onMonitoringModeChanged(items: List) { + subscriptionModel.onMonitoringModeChanged(items) + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/SnmpNodeIds.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/SnmpNodeIds.kt new file mode 100644 index 00000000..48bf2ab2 --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/SnmpNodeIds.kt @@ -0,0 +1,28 @@ +package com.mussonindustrial.embr.snmp.opc + +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId + +class SnmpNodeIds(nodeIdFactory: (Any) -> NodeId) { + + val Null = nodeIdFactory(0) + val Int32 = nodeIdFactory(1) + val UInt32 = nodeIdFactory(2) + val OctetString = nodeIdFactory(3) + val Oid = nodeIdFactory(4) + val IpAddress = nodeIdFactory(5) + val Counter32 = nodeIdFactory(6) + val Counter64 = nodeIdFactory(7) + val Gauge32 = nodeIdFactory(8) + val TimeTicks = nodeIdFactory(9) + + val SnmpAgentDeviceType = nodeIdFactory(1000) + val SnmpAgentDeviceType_ReadTable = nodeIdFactory(1010) + val SnmpAgentDeviceType_ReadTable_InputArguments = nodeIdFactory(1011) + val SnmpAgentDeviceType_ReadTable_OutputArguments = nodeIdFactory(1012) + val SnmpAgentDeviceType_Walk = nodeIdFactory(1020) + val SnmpAgentDeviceType_Walk_InputArguments = nodeIdFactory(1021) + val SnmpAgentDeviceType_Walk_OutputArguments = nodeIdFactory(1022) + + val OidValue = nodeIdFactory(2000) + val OidValue_Encoding_DefaultBinary = nodeIdFactory(2001) +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/types/OidValueType.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/types/OidValueType.kt new file mode 100644 index 00000000..e027cf3f --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/types/OidValueType.kt @@ -0,0 +1,165 @@ +package com.mussonindustrial.embr.snmp.opc.types + +import com.google.common.base.MoreObjects +import com.google.common.base.Objects +import com.mussonindustrial.embr.snmp.model.OidValue +import com.mussonindustrial.embr.snmp.opc.SnmpNamespace +import com.mussonindustrial.embr.snmp.opc.addHasEncoding +import com.mussonindustrial.embr.snmp.opc.addHasTypeDefinition +import com.mussonindustrial.embr.snmp.opc.addNode +import com.mussonindustrial.embr.snmp.opc.addSubtypeOf +import org.eclipse.milo.opcua.sdk.core.ValueRanks +import org.eclipse.milo.opcua.sdk.server.nodes.UaDataTypeNode +import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext +import org.eclipse.milo.opcua.sdk.server.nodes.UaObjectNode +import org.eclipse.milo.opcua.stack.core.NodeIds +import org.eclipse.milo.opcua.stack.core.UaSerializationException +import org.eclipse.milo.opcua.stack.core.encoding.EncodingContext +import org.eclipse.milo.opcua.stack.core.encoding.GenericDataTypeCodec +import org.eclipse.milo.opcua.stack.core.encoding.UaDecoder +import org.eclipse.milo.opcua.stack.core.encoding.UaEncoder +import org.eclipse.milo.opcua.stack.core.types.UaStructuredType +import org.eclipse.milo.opcua.stack.core.types.builtin.ExpandedNodeId +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +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.builtin.unsigned.Unsigned +import org.eclipse.milo.opcua.stack.core.types.enumerated.StructureType +import org.eclipse.milo.opcua.stack.core.types.structured.AccessRestrictionType +import org.eclipse.milo.opcua.stack.core.types.structured.StructureDefinition +import org.eclipse.milo.opcua.stack.core.types.structured.StructureField + +class OidValueType(val oid: String, val value: Variant) : UaStructuredType { + + constructor(oid: String, value: Any?) : this(oid, Variant(value)) + + constructor(oidValue: OidValue<*>) : this(oidValue.oid.numeric, oidValue.value) + + override fun getTypeId(): ExpandedNodeId { + return typeNodeId.expanded() + } + + override fun getBinaryEncodingId(): ExpandedNodeId { + return binaryEncodingNodeId.expanded() + } + + override fun getXmlEncodingId(): ExpandedNodeId { + return ExpandedNodeId.NULL_VALUE + } + + override fun getJsonEncodingId(): ExpandedNodeId { + return ExpandedNodeId.NULL_VALUE + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || javaClass != other.javaClass) return false + val that = other as OidValueType + return Objects.equal(oid, that.oid) && Objects.equal(value, that.value) + } + + override fun hashCode(): Int { + return Objects.hashCode(oid, value) + } + + override fun toString(): String { + return MoreObjects.toStringHelper(this).add("oid", oid).add("value", value).toString() + } + + class Codec : GenericDataTypeCodec() { + override fun getType(): Class { + return OidValueType::class.java + } + + @Throws(UaSerializationException::class) + override fun decodeType(context: EncodingContext, decoder: UaDecoder): OidValueType { + val oid = decoder.decodeString("Oid") + val value = decoder.decodeVariant("Value") + return OidValueType(oid, value) + } + + @Throws(UaSerializationException::class) + override fun encodeType(context: EncodingContext, encoder: UaEncoder, value: OidValueType) { + encoder.encodeString("Oid", value.oid) + encoder.encodeVariant("Value", value.value) + } + } + + companion object { + const val BROWSE_NAME = "OidValue" + + private val typeNodeId: NodeId by lazy { SnmpNamespace.NodesIds.OidValue } + private val binaryEncodingNodeId: NodeId by lazy { + SnmpNamespace.NodesIds.OidValue_Encoding_DefaultBinary + } + + fun register(nodeContext: UaNodeContext) { + UaObjectNode( + nodeContext, + binaryEncodingNodeId, + SnmpNamespace.qualifiedName("Default Binary"), + LocalizedText.english("Default Binary"), + LocalizedText.NULL_VALUE, + Unsigned.uint(0), + Unsigned.uint(0), + ) + .apply { + addHasTypeDefinition(NodeIds.DataTypeEncodingType.expanded()) + accessRestrictions = AccessRestrictionType.of() + nodeContext.nodeManager.addNode(this) + } + + UaDataTypeNode( + nodeContext, + typeNodeId, + SnmpNamespace.qualifiedName(BROWSE_NAME), + LocalizedText.english(BROWSE_NAME), + LocalizedText.NULL_VALUE, + Unsigned.uint(0), + Unsigned.uint(0), + false, + ) + .apply { + addNode(nodeManager) + addSubtypeOf(NodeIds.Structure.expanded()) + addHasEncoding(binaryEncodingNodeId.expanded()) + + accessRestrictions = AccessRestrictionType.of() + dataTypeDefinition = + StructureDefinition( + binaryEncodingNodeId, + NodeIds.Structure, + StructureType.Structure, + arrayOf( + StructureField( + "oid", + LocalizedText.NULL_VALUE, + SnmpNamespace.NodesIds.Oid, + ValueRanks.Scalar, + null, + nodeContext.server.config.limits.maxStringLength, + false, + ), + StructureField( + "value", + LocalizedText.NULL_VALUE, + NodeIds.BaseDataType, + ValueRanks.Scalar, + null, + Unsigned.uint(0), + false, + ), + ), + ) + + nodeContext.server.staticDataTypeManager.registerType( + nodeId, + Codec(), + binaryEncodingNodeId, + null, + null, + ) + } + } + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/types/SnmpDataType.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/types/SnmpDataType.kt new file mode 100644 index 00000000..46575e58 --- /dev/null +++ b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/opc/types/SnmpDataType.kt @@ -0,0 +1,124 @@ +package com.mussonindustrial.embr.snmp.opc.types + +import com.inductiveautomation.ignition.common.TypeUtilities +import com.mussonindustrial.embr.snmp.opc.SnmpNamespace +import com.mussonindustrial.embr.snmp.opc.addNode +import com.mussonindustrial.embr.snmp.opc.addSubtypeOf +import org.eclipse.milo.opcua.sdk.server.nodes.UaDataTypeNode +import org.eclipse.milo.opcua.sdk.server.nodes.UaNodeContext +import org.eclipse.milo.opcua.stack.core.NodeIds +import org.eclipse.milo.opcua.stack.core.types.builtin.LocalizedText +import org.eclipse.milo.opcua.stack.core.types.builtin.NodeId +import org.eclipse.milo.opcua.stack.core.types.builtin.unsigned.Unsigned.uint +import org.eclipse.milo.opcua.stack.core.types.structured.AccessRestrictionType +import org.snmp4j.smi.Variable + +enum class SnmpDataType( + val nodeId: NodeId, + val parentType: NodeId, + val backingVariableClass: Class, + private val factory: (Any?) -> Variable, +) { + Null( + SnmpNamespace.NodesIds.Null, + NodeIds.BaseDataType, + org.snmp4j.smi.Null::class.java, + { org.snmp4j.smi.Null() }, + ), + Int32( + SnmpNamespace.NodesIds.Int32, + NodeIds.Int32, + org.snmp4j.smi.Integer32::class.java, + { org.snmp4j.smi.Integer32(TypeUtilities.toInteger(it)) }, + ), + UInt32( + SnmpNamespace.NodesIds.UInt32, + NodeIds.UInt32, + org.snmp4j.smi.UnsignedInteger32::class.java, + { org.snmp4j.smi.UnsignedInteger32(TypeUtilities.toInteger(it)) }, + ), + OctetString( + SnmpNamespace.NodesIds.OctetString, + NodeIds.String, + org.snmp4j.smi.OctetString::class.java, + { org.snmp4j.smi.OctetString(TypeUtilities.toString(it)) }, + ), + Oid( + SnmpNamespace.NodesIds.Oid, + NodeIds.String, + org.snmp4j.smi.OID::class.java, + { org.snmp4j.smi.OID(TypeUtilities.toString(it)) }, + ), + IpAddress( + SnmpNamespace.NodesIds.IpAddress, + NodeIds.String, + org.snmp4j.smi.IpAddress::class.java, + { org.snmp4j.smi.IpAddress(TypeUtilities.toString(it)) }, + ), + Counter32( + SnmpNamespace.NodesIds.Counter32, + NodeIds.UInt32, + org.snmp4j.smi.Counter32::class.java, + { org.snmp4j.smi.Counter32(TypeUtilities.toLong(it)) }, + ), + Counter64( + SnmpNamespace.NodesIds.Counter64, + NodeIds.UInt64, + org.snmp4j.smi.Counter64::class.java, + { org.snmp4j.smi.Counter64(TypeUtilities.toLong(it)) }, + ), + Gauge32( + SnmpNamespace.NodesIds.Gauge32, + NodeIds.UInt64, + org.snmp4j.smi.Gauge32::class.java, + { org.snmp4j.smi.Gauge32(TypeUtilities.toLong(it)) }, + ), + TimeTicks( + SnmpNamespace.NodesIds.TimeTicks, + NodeIds.UInt64, + org.snmp4j.smi.TimeTicks::class.java, + { org.snmp4j.smi.TimeTicks(TypeUtilities.toLong(it)) }, + ); + + companion object { + private val byClass: Map, SnmpDataType> = + entries.associateBy { it.backingVariableClass } + + fun registerAll(nodeContext: UaNodeContext) { + entries.forEach { it.register(nodeContext) } + } + + fun of(variable: Variable): SnmpDataType { + byClass[variable.javaClass]?.let { + return it + } + return entries.firstOrNull { + it.backingVariableClass.isAssignableFrom(variable.javaClass) + } + ?: throw IllegalArgumentException( + "No SNMP type for variable [${variable.javaClass}] $variable" + ) + } + + fun variableOfType(dataType: SnmpDataType, value: Any?): Variable = dataType.factory(value) + } + + private fun register(nodeContext: UaNodeContext) { + UaDataTypeNode( + nodeContext, + nodeId, + SnmpNamespace.qualifiedName(name), + LocalizedText.english(name), + LocalizedText.english(""), + uint(0), + uint(0), + false, + ) + .apply { + addNode(nodeContext.nodeManager) + addSubtypeOf(parentType.expanded()) + + accessRestrictions = AccessRestrictionType.of() + } + } +} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidReadRequest.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidReadRequest.kt deleted file mode 100644 index 184729b6..00000000 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidReadRequest.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.mussonindustrial.embr.snmp.requests - -import org.snmp4j.smi.OID - -interface OidReadRequest { - val oid: OID - var result: OidReadResult? -} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidReadResult.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidReadResult.kt deleted file mode 100644 index 4ff32bd2..00000000 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidReadResult.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.mussonindustrial.embr.snmp.requests - -import com.mussonindustrial.embr.snmp.model.BasicQualifiedOidValue -import com.mussonindustrial.embr.snmp.model.Oid -import com.mussonindustrial.embr.snmp.model.QualifiedOidValue -import com.mussonindustrial.embr.snmp.utils.toDataValue -import com.mussonindustrial.embr.snmp.utils.toQualityCode -import java.util.Date -import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue -import org.snmp4j.smi.OID -import org.snmp4j.smi.VariableBinding - -data class OidReadResult(val oid: Oid, val value: DataValue) { - fun toQualifiedValue(): QualifiedOidValue { - return BasicQualifiedOidValue( - this.oid, - this.value.value.value(), - this.value.statusCode.toQualityCode(), - this.value.serverTime?.javaDate ?: Date(), - ) - } -} - -fun OID.toOidReadResult(value: DataValue): OidReadResult { - return OidReadResult(Oid.fromSnmp4j(this), value) -} - -fun VariableBinding.toOidReadResult(): OidReadResult { - return OidReadResult(Oid.fromSnmp4j(this.oid), this.variable.toDataValue()) -} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidWriteRequest.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidWriteRequest.kt deleted file mode 100644 index f6010008..00000000 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidWriteRequest.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.mussonindustrial.embr.snmp.requests - -import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue -import org.snmp4j.smi.OID - -interface OidWriteRequest { - val oid: OID - val value: DataValue - var result: OidWriteResult? -} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidWriteResult.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidWriteResult.kt deleted file mode 100644 index a8eadc03..00000000 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/requests/OidWriteResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.mussonindustrial.embr.snmp.requests - -import org.eclipse.milo.opcua.stack.core.types.builtin.StatusCode -import org.snmp4j.smi.OID - -data class OidWriteResult(val oid: OID, val statusCode: StatusCode) - -fun OID.toOidWriteResult(statusCode: StatusCode): OidWriteResult { - return OidWriteResult(this, statusCode) -} diff --git a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/utils/SnmpUtils.kt b/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/utils/SnmpUtils.kt deleted file mode 100644 index 1b1b4acc..00000000 --- a/modules/snmp/gateway/src/main/kotlin/com/mussonindustrial/embr/snmp/utils/SnmpUtils.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.mussonindustrial.embr.snmp.utils - -import java.text.ParseException -import org.eclipse.milo.opcua.sdk.server.Lifecycle -import org.eclipse.milo.opcua.sdk.server.LifecycleManager -import org.eclipse.milo.opcua.stack.core.StatusCodes -import org.eclipse.milo.opcua.stack.core.types.builtin.DataValue -import org.eclipse.milo.opcua.stack.core.types.builtin.Variant -import org.snmp4j.PDU -import org.snmp4j.SNMP4JSettings -import org.snmp4j.Snmp -import org.snmp4j.Target -import org.snmp4j.smi.Address -import org.snmp4j.smi.Null -import org.snmp4j.smi.OID -import org.snmp4j.smi.OctetString -import org.snmp4j.smi.Variable -import org.snmp4j.smi.VariableBinding -import org.snmp4j.util.PDUFactory - -fun String.toVariableBinding(): VariableBinding { - return VariableBinding(OID(this)) -} - -fun PDU.addOID(oid: String) { - val binding = VariableBinding(OID(oid)) - this.add(binding) -} - -fun String.isOid(): Boolean { - try { - SNMP4JSettings.getOIDTextFormat().parse(this) - return true - } catch (_: ParseException) { - return false - } -} - -fun Variable.toDataValue(): DataValue { - return when (this) { - Null.endOfMibView -> DataValue(StatusCodes.Bad_NotFound) - Null.noSuchObject -> DataValue(StatusCodes.Bad_NotFound) - Null.noSuchInstance -> DataValue(StatusCodes.Bad_NotFound) - Null.instance -> DataValue(Variant.NULL_VALUE) - else -> DataValue(Variant(this.toString())) - } -} - -fun DataValue.toVariable(): Variable { - return OctetString(this.value.value?.toString()) -} - -fun LifecycleManager.addLifecycle(snmp: Snmp) { - this.addLifecycle( - object : Lifecycle { - override fun startup() { - snmp.listen() - } - - override fun shutdown() { - snmp.close() - } - } - ) -} - -fun Target.createSizeBoundedPDUs( - pduFactory: PDUFactory, - bindings: List, - configure: PDU.() -> Unit = {}, -): List { - - val pdus = mutableListOf() - var pdu = pduFactory.createPDU(this).apply { configure(this) } - - bindings.forEach { binding -> - pdu.add(binding) - - if (pdu.berLength > maxSizeRequestPDU) { - pdu.trim() - pdus.add(pdu) - pdu = pduFactory.createPDU(this).apply { configure(this) } - } - } - if (pdu.size() > 0) { - pdus.add(pdu) - } - - return pdus -}