diff --git a/Dockerfile b/Dockerfile index 3a04c52..9d19668 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,10 @@ #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. -FROM mcr.microsoft.com/dotnet/aspnet:6.0 AS base +FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base WORKDIR /app EXPOSE 4840 -FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build WORKDIR /src COPY ["UAEdgeTranslator.csproj", "."] RUN dotnet restore "./UAEdgeTranslator.csproj" diff --git a/Program.cs b/Program.cs index 70e1605..7278623 100644 --- a/Program.cs +++ b/Program.cs @@ -67,7 +67,7 @@ private static void OPCUAClientCertificateValidationCallback(CertificateValidato { // check if we have a trusted issuer cert yet bool provisioningMode = (Directory.EnumerateFiles(Path.Combine(Directory.GetCurrentDirectory(), "pki", "issuer", "certs")).Count() == 0); - + // we allow conections in provisoning mode, but limit access to the server if ((e.Error.StatusCode == StatusCodes.BadCertificateUntrusted) && provisioningMode) { diff --git a/RockwellClient.cs b/RockwellClient.cs index ea2ee6c..11ff649 100644 --- a/RockwellClient.cs +++ b/RockwellClient.cs @@ -1,15 +1,56 @@ namespace Opc.Ua.Edge.Translator { + using libplctag; using Opc.Ua.Edge.Translator.Interfaces; using Serilog; - using Sres.Net.EEIP; using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; using System.Threading.Tasks; public class RockwellClient : IAsset { - private EEIPClient _eeipClient = null; + class TagInfo + { + public uint Id { get; set; } + + public ushort Type { get; set; } + + public string Name { get; set; } + + public ushort Length { get; set; } + + public uint[] Dimensions { get; set; } + } + + class UdtFieldInfo + { + public string Name { get; set; } + + public ushort Type { get; set; } + + public ushort Metadata { get; set; } + + public uint Offset { get; set; } + } + + class UdtInfo + { + public uint Size { get; set; } + + public string Name { get; set; } + + public ushort Id { get; set; } + + public ushort NumFields { get; set; } + + public ushort Handle { get; set; } + + public UdtFieldInfo[] Fields { get; set; } + } + private string _endpoint = string.Empty; public void Connect(string ipAddress, int port) @@ -18,12 +59,55 @@ public void Connect(string ipAddress, int port) { _endpoint = ipAddress; - _eeipClient = new EEIPClient(); - _eeipClient.IPAddress = _endpoint; + Tag tags = new() + { + Gateway = ipAddress, + Path = "1,0", + PlcType = PlcType.ControlLogix, + Protocol = Protocol.ab_eip, + Name = "@tags", + Timeout = TimeSpan.FromSeconds(10), + }; - uint result = _eeipClient.RegisterSession(); + tags.Read(); - Log.Logger.Information("Connected to Rockwell PLC: " + result.ToString()); + Log.Logger.Information("Connected to Rockwell ControlLogix PLC at " + ipAddress); + + TagInfo[] tagInfos = DecodeAllTags(tags); + foreach (TagInfo tag in tagInfos) + { + if (!IsTag(tag)) + { + // not interested in anything that is not a tag + continue; + } + + if (TagIsUdt(tag)) + { + int udtTypeId = GetUdtId(tag); + + Tag udtTag = new() + { + Gateway = ipAddress, + Path = "1,0", + PlcType = PlcType.ControlLogix, + Protocol = Protocol.ab_eip, + Name = $"@udt/{udtTypeId}", + }; + + udtTag.Read(); + + UdtInfo udt = DecodeUdtInfo(udtTag); + foreach (UdtFieldInfo f in udt.Fields) + { + Log.Logger.Information($"EIP Tag: Id={tag.Name} Name={udt.Name} FieldName={f.Name} Offset={f.Offset} Metadata={f.Metadata} Type=" + ParseDataType(f.Type)); + } + } + else + { + Log.Logger.Information($"EIP Tag: Id={tag.Name} Type=" + ParseDataType(tag.Type)); + } + } } catch (Exception ex) { @@ -31,13 +115,177 @@ public void Connect(string ipAddress, int port) } } - public void Disconnect() + private string ParseDataType(ushort eipDataTypeId) + { + // Data Type: Tag Type Value: Size of Transmitted Data: + // BOOL 0x0nC1 1 byte + // The BOOL value includes an additional field (n) for specifying the bit position within the SINT (n = 0 - 7). + if ((eipDataTypeId & 0xC1) == 0xC1) + { + return "BOOL"; + } + + switch (eipDataTypeId) + { + case 0xC2: return "SINT"; + case 0xC3: return "INT"; + case 0xC4: return "DINT"; + case 0xC5: return "LINT"; + case 0xC6: return "USINT"; + case 0xC7: return "UINT"; + case 0xC8: return "UDINT"; + case 0xC9: return "ULINT"; + case 0xCA: return "REAL"; + case 0xCB: return "LREAL"; + default: return eipDataTypeId.ToString(); + } + } + + private TagInfo DecodeOneTagInfo(Tag tag, int offset, out int elementSize) + { + + var tagInstanceId = tag.GetUInt32(offset); + var tagType = tag.GetUInt16(offset + 4); + var tagLength = tag.GetUInt16(offset + 6); + var tagArrayDims = new uint[] + { + tag.GetUInt32(offset + 8), + tag.GetUInt32(offset + 12), + tag.GetUInt32(offset + 16) + }; + + var apparentTagNameLength = (int)tag.GetUInt16(offset + 20); + var actualTagNameLength = Math.Min(apparentTagNameLength, 200 * 2 - 1); + + var tagNameBytes = Enumerable.Range(offset + 22, actualTagNameLength) + .Select(o => tag.GetUInt8(o)) + .Select(Convert.ToByte) + .ToArray(); + + var tagName = Encoding.ASCII.GetString(tagNameBytes); + + elementSize = 22 + actualTagNameLength; + + return new TagInfo() + { + Id = tagInstanceId, + Type = tagType, + Name = tagName, + Length = tagLength, + Dimensions = tagArrayDims + }; + + } + + private TagInfo[] DecodeAllTags(Tag tag) + { + var buffer = new List(); + + var tagSize = tag.GetSize(); + + int offset = 0; + while (offset < tagSize) + { + buffer.Add(DecodeOneTagInfo(tag, offset, out int elementSize)); + offset += elementSize; + } + + return buffer.ToArray(); + } + + private UdtInfo DecodeUdtInfo(Tag tag) { - if (_eeipClient != null) + + var template_id = tag.GetUInt16(0); + var member_desc_size = tag.GetUInt32(2); + var udt_instance_size = tag.GetUInt32(6); + var num_members = tag.GetUInt16(10); + var struct_handle = tag.GetUInt16(12); + + var udtInfo = new UdtInfo() + { + Fields = new UdtFieldInfo[num_members], + NumFields = num_members, + Handle = struct_handle, + Id = template_id, + Size = udt_instance_size + }; + + var offset = 14; + + for (int field_index = 0; field_index < num_members; field_index++) { - _eeipClient.UnRegisterSession(); - _eeipClient = null; + ushort field_metadata = tag.GetUInt16(offset); + offset += 2; + + ushort field_element_type = tag.GetUInt16(offset); + offset += 2; + + ushort field_offset = tag.GetUInt16(offset); + offset += 4; + + var field = new UdtFieldInfo() + { + Offset = field_offset, + Metadata = field_metadata, + Type = field_element_type, + }; + + udtInfo.Fields[field_index] = field; } + + var name_str = tag.GetString(offset).Split(';')[0]; + udtInfo.Name = name_str; + + offset += tag.GetStringTotalLength(offset); + + for (int field_index = 0; field_index < num_members; field_index++) + { + udtInfo.Fields[field_index].Name = tag.GetString(offset); + offset += tag.GetStringTotalLength(offset); + } + + return udtInfo; + + } + + private bool TagIsUdt(TagInfo tag) + { + const ushort TYPE_IS_STRUCT = 0x8000; + const ushort TYPE_IS_SYSTEM = 0x1000; + + return ((tag.Type & TYPE_IS_STRUCT) != 0) && !((tag.Type & TYPE_IS_SYSTEM) != 0); + } + + private int GetUdtId(TagInfo tag) + { + const ushort TYPE_UDT_ID_MASK = 0x0FFF; + return tag.Type & TYPE_UDT_ID_MASK; + } + + private bool IsTag(TagInfo tag) + { + if (tag.Name.StartsWith("Program:")) + { + return false; + } + + if (tag.Name.StartsWith("Task:")) + { + return false; + } + + if (tag.Name.StartsWith("Map:")) + { + return false; + } + + return true; + } + + public void Disconnect() + { + // nothing to do } public string GetRemoteEndpoint() @@ -47,17 +295,65 @@ public string GetRemoteEndpoint() public Task Read(string addressWithinAsset, byte unitID, string function, ushort count) { - // the generic input is mapped to Ethernet/IP parameters like so: - // address -> Ethernet/IP class ID - // unitID -> Ethernet/IP instance ID - // function -> Ethernet/IP attribute ID - byte[] result = _eeipClient.GetAttributeSingle(int.Parse(addressWithinAsset), unitID, int.Parse(function)); - return Task.FromResult(result); + var tag = new Tag() + { + Name = addressWithinAsset, + Gateway = _endpoint, + Path = "1,0", + PlcType = PlcType.ControlLogix, + Protocol = Protocol.ab_eip + }; + + tag.Read(); + + switch (unitID) + { + case 0xC1: return Task.FromResult(BitConverter.GetBytes(tag.GetBit(count))); + case 0xC2: return Task.FromResult(new byte[] { (byte) tag.GetInt8(count) } ); + case 0xC3: return Task.FromResult(BitConverter.GetBytes(tag.GetInt16(count))); + case 0xC4: return Task.FromResult(BitConverter.GetBytes(tag.GetInt32(count))); + case 0xC5: return Task.FromResult(BitConverter.GetBytes(tag.GetInt64(count))); + case 0xC6: return Task.FromResult(new byte[] { tag.GetUInt8(count) } ); + case 0xC7: return Task.FromResult(BitConverter.GetBytes(tag.GetUInt16(count))); + case 0xC8: return Task.FromResult(BitConverter.GetBytes(tag.GetUInt32(count))); + case 0xC9: return Task.FromResult(BitConverter.GetBytes(tag.GetUInt64(count))); + case 0xCA: return Task.FromResult(BitConverter.GetBytes(tag.GetFloat32(count))); + case 0xCB: return Task.FromResult(BitConverter.GetBytes(tag.GetFloat64(count))); + default: return Task.FromResult((byte[]) null); + } } public Task Write(string addressWithinAsset, byte unitID, byte[] values, bool singleBitOnly) { - _eeipClient.SetAttributeSingle(int.Parse(addressWithinAsset), unitID, 1, values); + var tag = new Tag() + { + Name = addressWithinAsset, + Gateway = _endpoint, + Path = "1,0", + PlcType = PlcType.ControlLogix, + Protocol = Protocol.ab_eip + }; + + tag.Read(); + + int offset = 0; // TODO: Allow offsets greater than 0! + + switch (unitID) + { + case 0xC1: tag.SetBit(offset, BitConverter.ToBoolean(values)); break; + case 0xC2: tag.SetInt8(offset, (sbyte) BitConverter.ToChar(values)); break; + case 0xC3: tag.SetInt16(offset, BitConverter.ToInt16(values)); break; + case 0xC4: tag.SetInt32(offset, BitConverter.ToInt32(values)); break; + case 0xC5: tag.SetInt64(offset, BitConverter.ToInt64(values)); break; + case 0xC6: tag.SetUInt8(offset, values[0]); break; + case 0xC7: tag.SetUInt16(offset, BitConverter.ToUInt16(values)); break; + case 0xC8: tag.SetUInt32(offset, BitConverter.ToUInt32(values)); break; + case 0xC9: tag.SetUInt64(offset, BitConverter.ToUInt64(values)); break; + case 0xCA: tag.SetFloat32(offset, BitConverter.ToSingle(values)); break; + case 0xCB: tag.SetFloat64(offset, BitConverter.ToDouble(values)); break; + default: break; + } + return Task.CompletedTask; } } diff --git a/Samples/Logix5581E.td.jsonld b/Samples/Logix5581E.td.jsonld index f0ed61e..0d2d288 100644 --- a/Samples/Logix5581E.td.jsonld +++ b/Samples/Logix5581E.td.jsonld @@ -15,7 +15,7 @@ "base": "eip://192.168.178.20", "title": "AB Logix5581E PLC", "properties": { - "Tag1": { + "Production_Data_Product_Count": { "type": "number", "opcua:nodeId": null, "opcua:type": null, @@ -24,12 +24,12 @@ "observable": true, "forms": [ { - "href": "66?1&325", + "href": "Production_Data?C4&0", "op": [ "readproperty", "observeproperty" ], - "type": "xsd:float", + "type": "xsd:Integer", "pollingTime": 1000 } ] diff --git a/UAClient.cs b/UAClient.cs index 421e961..e44c1ad 100644 --- a/UAClient.cs +++ b/UAClient.cs @@ -54,11 +54,10 @@ public Task Read(string addressWithinAsset, byte unitID, string function NodeId nodeId = ExpandedNodeId.ToNodeId(new ExpandedNodeId(addressWithinAsset), _session.NamespaceUris); DataValue value = _session.ReadValue(nodeId); +#pragma warning disable SYSLIB0011 BinaryFormatter bf = new(); using (MemoryStream ms = new()) { - -#pragma warning disable SYSLIB0011 bf.Serialize(ms, value.Value); #pragma warning restore SYSLIB0011 @@ -75,9 +74,9 @@ public Task Write(string addressWithinAsset, byte unitID, byte[] values, bool si { using (MemoryStream memStream = new(values)) { +#pragma warning disable SYSLIB0011 BinaryFormatter binForm = new(); -#pragma warning disable SYSLIB0011 object value = binForm.Deserialize(memStream); #pragma warning restore SYSLIB0011 diff --git a/UAEdgeTranslator.csproj b/UAEdgeTranslator.csproj index 8d92ec4..7205eb5 100644 --- a/UAEdgeTranslator.csproj +++ b/UAEdgeTranslator.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 UAEdgeTranslator Opc.Ua.Edge.Translator @@ -32,13 +32,13 @@ - + - - + + - + diff --git a/UANodeManager.cs b/UANodeManager.cs index 5fa7925..8ec7f16 100644 --- a/UANodeManager.cs +++ b/UANodeManager.cs @@ -9,6 +9,7 @@ namespace Opc.Ua.Edge.Translator using Serilog; using System; using System.Collections.Generic; + using System.Globalization; using System.IO; using System.Linq; using System.Text; @@ -1499,14 +1500,14 @@ private void HandleMitsubishiDataWrite(AssetTag tag, string assetId, string valu private void HandleRockwellDataRead(AssetTag tag, string assetId) { - string[] addressParts = tag.Address.Split(new char[] { '?', '&', '=' }); + string[] addressParts = tag.Address.Split(['?', '&', '=']); if (addressParts.Length == 3) { byte[] tagBytes = null; try { - tagBytes = _assets[assetId].Read(addressParts[0], byte.Parse(addressParts[1]), addressParts[2], 0).GetAwaiter().GetResult(); + tagBytes = _assets[assetId].Read(addressParts[0], byte.Parse(addressParts[1], NumberStyles.HexNumber), string.Empty, ushort.Parse(addressParts[2])).GetAwaiter().GetResult(); } catch (Exception ex) { @@ -1523,7 +1524,7 @@ private void HandleRockwellDataRead(AssetTag tag, string assetId) object value = null; if (tag.Type == "Float") { - value = BitConverter.ToSingle(tagBytes); + value = BitConverter.ToSingle(ByteSwapper.Swap(tagBytes, true)); } else if (tag.Type == "Boolean") { @@ -1531,7 +1532,7 @@ private void HandleRockwellDataRead(AssetTag tag, string assetId) } else if (tag.Type == "Integer") { - value = BitConverter.ToInt32(tagBytes); + value = BitConverter.ToInt32(ByteSwapper.Swap(tagBytes, true)); } else if (tag.Type == "String") {