From 33b6b3de45382849cd74732851daa689ad7d8f96 Mon Sep 17 00:00:00 2001 From: Erich Barnstedt Date: Tue, 26 Nov 2024 09:33:58 +0100 Subject: [PATCH] Initial BACNet implementation. --- BACNetClient.cs | 96 ++++++++++++++++++++++++++ README.md | 2 +- UANodeManager.cs | 174 ++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 246 insertions(+), 26 deletions(-) create mode 100644 BACNetClient.cs diff --git a/BACNetClient.cs b/BACNetClient.cs new file mode 100644 index 0000000..17ba54b --- /dev/null +++ b/BACNetClient.cs @@ -0,0 +1,96 @@ + +namespace Opc.Ua.Edge.Translator +{ + using Opc.Ua.Edge.Translator.Interfaces; + using Serilog; + using System; + using System.Collections.Generic; + using System.IO.BACnet; + using System.Threading.Tasks; + + public class BACNetClient : IAsset + { + private string _endpoint = string.Empty; + private BacnetClient _client; + + public void Connect(string ipAddress, int port) + { + try + { + _endpoint = ipAddress; + + BacnetIpUdpProtocolTransport transport = new(0xBAC0, false); + _client = new BacnetClient(transport); + _client.OnIam += OnIAm; + _client.Start(); + _client.WhoIs(); + + Log.Logger.Information("Connected to BACNet device at " + ipAddress); + } + catch (Exception ex) + { + Log.Logger.Error(ex.Message, ex); + } + } + + private void OnIAm(BacnetClient sender, BacnetAddress adr, uint deviceid, uint maxapdu, BacnetSegmentations segmentation, ushort vendorid) + { + Log.Logger.Information($"Detected device {deviceid} at {adr}"); + + IList value_list; + sender.ReadPropertyRequest(adr, new BacnetObjectId(BacnetObjectTypes.OBJECT_DEVICE, deviceid), BacnetPropertyIds.PROP_OBJECT_LIST, out value_list); + + LinkedList object_list = new LinkedList(); + foreach (BacnetValue value in value_list) + { + if (Enum.IsDefined(typeof(BacnetObjectTypes), ((BacnetObjectId)value.Value).Type)) + { + object_list.AddLast((BacnetObjectId)value.Value); + } + } + + foreach (BacnetObjectId object_id in object_list) + { + //read all properties + IList values = null; + try + { + if (!sender.ReadPropertyRequest(adr, object_id, BacnetPropertyIds.PROP_PRESENT_VALUE, out values)) + { + Log.Logger.Error("Couldn't fetch 'present value' for object: " + object_id.ToString()); + continue; + } + } + catch (Exception ex) + { + Log.Logger.Error("Couldn't fetch 'present value' for object: " + object_id.ToString() + ": " + ex.Message); + continue; + } + + Log.Logger.Information("Object Name: " + object_id.ToString() + ", Property Id: " + values[0].Tag.ToString() + ", Value: " + values[0].Value.ToString()); + } + } + + public void Disconnect() + { + // nothing to do + } + + public string GetRemoteEndpoint() + { + return _endpoint; + } + + public Task Read(string addressWithinAsset, byte unitID, string function, ushort count) + { + // TODO + return Task.FromResult((byte[]) null); + } + + public Task Write(string addressWithinAsset, byte unitID, string function, byte[] values, bool singleBitOnly) + { + // TODO + return Task.CompletedTask; + } + } +} diff --git a/README.md b/README.md index 09bbd5e..ffd6133 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ UA Edge Translator can be controlled through the use of just 2 OPC UA methods re ## Supported "Southbound" Asset Interfaces -In this reference implementation, Modbus TCP, OPC UA, Siemens S7Comm (experimental), Mitsubishi MC Protocol (experimental), Rockwell CIP-Ethernet/IP (experimental), and Beckhoff ADS (experimental) are supported. Other interfaces can easily be added by implementing the IAsset interface. There is also a tool provided that can convert from an OPC UA nodeset file (with instance variable nodes defined in it), an AutomationML file, a TwinCAT file, or an Asset Admin Shell file, to a WoT Thing Model file. +In this reference implementation, Modbus TCP, OPC UA, Siemens S7Comm (experimental), Mitsubishi MC Protocol (experimental), Rockwell CIP-Ethernet/IP (experimental), Beckhoff ADS (experimental) and BACNet (experimental) are supported. Other interfaces can easily be added by implementing the IAsset interface. There is also a tool provided that can convert from an OPC UA nodeset file (with instance variable nodes defined in it), an AutomationML file, a TwinCAT file, or an Asset Admin Shell file, to a WoT Thing Model file. ## Running UA Edge Translator from a Docker environment diff --git a/UANodeManager.cs b/UANodeManager.cs index 0a0b0c8..85b7992 100644 --- a/UANodeManager.cs +++ b/UANodeManager.cs @@ -188,13 +188,14 @@ private void AddNodesForAssetManagement(IList objectsFolderReference _assetManagement.DeleteAsset.InputArguments.Create(SystemContext, deleteAssetInputArgumentsPassiveNode); // create a variable listing our supported WoT protocol bindings - _uaVariables.Add("SupportedWoTBindings", CreateVariable(_assetManagement, "SupportedWoTBindings", new ExpandedNodeId(DataTypes.UriString), WoTConNamespaceIndex, false, new string[6] { + _uaVariables.Add("SupportedWoTBindings", CreateVariable(_assetManagement, "SupportedWoTBindings", new ExpandedNodeId(DataTypes.UriString), WoTConNamespaceIndex, false, new string[7] { "https://www.w3.org/2019/wot/modbus", "https://www.w3.org/2019/wot/opcua", "https://www.w3.org/2019/wot/s7", "https://www.w3.org/2019/wot/mcp", "https://www.w3.org/2019/wot/eip", - "https://www.w3.org/2019/wot/ads" + "https://www.w3.org/2019/wot/ads", + "http://www.w3.org/2022/bacnet" })); // add everything to our server namespace @@ -748,6 +749,25 @@ private void AddTag(ThingDescription td, object form, string assetId, byte unitI _tags[assetId].Add(tag); } + + if (td.Base.ToLower().StartsWith("bacnet://")) + { + // create an asset tag and add to our list + GenericForm adsForm = JsonConvert.DeserializeObject(form.ToString()); + AssetTag tag = new() + { + Name = variableId, + Address = adsForm.Href, + UnitID = unitId, + Type = adsForm.Type.ToString(), + PollingInterval = (int)adsForm.PollingTime, + Entity = null, + MappedUAExpandedNodeID = NodeId.ToExpandedNodeId(_uaVariables[variableId].NodeId, Server.NamespaceUris).ToString(), + MappedUAFieldPath = fieldPath + }; + + _tags[assetId].Add(tag); + } } private void AssetConnectionTest(ThingDescription td, out byte unitId) @@ -757,91 +777,106 @@ private void AssetConnectionTest(ThingDescription td, out byte unitId) if (td.Base.ToLower().StartsWith("modbus+tcp://")) { - string[] modbusAddress = td.Base.Split(new char[] { ':', '/' }); - if ((modbusAddress.Length != 6) || (modbusAddress[0] != "modbus+tcp")) + string[] address = td.Base.Split(new char[] { ':', '/' }); + if ((address.Length != 6) || (address[0] != "modbus+tcp")) { throw new Exception("Expected Modbus server address in the format modbus+tcp://ipaddress:port/unitID!"); } // check if we can reach the Modbus asset - unitId = byte.Parse(modbusAddress[5]); + unitId = byte.Parse(address[5]); ModbusTCPClient client = new(); - client.Connect(modbusAddress[3], int.Parse(modbusAddress[4])); + client.Connect(address[3], int.Parse(address[4])); assetInterface = client; } if (td.Base.ToLower().StartsWith("opc.tcp://")) { - string[] opcuaAddress = td.Base.Split(new char[] { ':', '/' }); - if ((opcuaAddress.Length != 5) || (opcuaAddress[0] != "opc.tcp")) + string[] address = td.Base.Split(new char[] { ':', '/' }); + if ((address.Length != 5) || (address[0] != "opc.tcp")) { throw new Exception("Expected OPC UA server address in the format opc.tcp://ipaddress:port!"); } // check if we can reach the OPC UA asset UAClient client = new(); - client.Connect(opcuaAddress[3], int.Parse(opcuaAddress[4])); + client.Connect(address[3], int.Parse(address[4])); assetInterface = client; } if (td.Base.ToLower().StartsWith("s7://")) { - string[] opcuaAddress = td.Base.Split(new char[] { ':', '/' }); - if ((opcuaAddress.Length != 5) || (opcuaAddress[0] != "s7")) + string[] address = td.Base.Split(new char[] { ':', '/' }); + if ((address.Length != 5) || (address[0] != "s7")) { throw new Exception("Expected S7 PLC address in the format s7://ipaddress:port!"); } - // check if we can reach the OPC UA asset + // check if we can reach the Siemens asset SiemensClient client = new(); - client.Connect(opcuaAddress[3], int.Parse(opcuaAddress[4])); + client.Connect(address[3], int.Parse(address[4])); assetInterface = client; } if (td.Base.ToLower().StartsWith("mcp://")) { - string[] opcuaAddress = td.Base.Split(new char[] { ':', '/' }); - if ((opcuaAddress.Length != 5) || (opcuaAddress[0] != "mcp")) + string[] address = td.Base.Split(new char[] { ':', '/' }); + if ((address.Length != 5) || (address[0] != "mcp")) { throw new Exception("Expected Mitsubishi PLC address in the format mcp://ipaddress:port!"); } - // check if we can reach the OPC UA asset + // check if we can reach the Mitsubishi asset MitsubishiClient client = new(); - client.Connect(opcuaAddress[3], int.Parse(opcuaAddress[4])); + client.Connect(address[3], int.Parse(address[4])); assetInterface = client; } if (td.Base.ToLower().StartsWith("eip://")) { - string[] opcuaAddress = td.Base.Split(new char[] { ':', '/' }); - if ((opcuaAddress.Length != 4) || (opcuaAddress[0] != "eip")) + string[] address = td.Base.Split(new char[] { ':', '/' }); + if ((address.Length != 4) || (address[0] != "eip")) { throw new Exception("Expected Rockwell PLC address in the format eip://ipaddress:port!"); } - // check if we can reach the OPC UA asset + // check if we can reach the Ethernet/IP asset RockwellClient client = new(); - client.Connect(opcuaAddress[3], 0); + client.Connect(address[3], 0); assetInterface = client; } if (td.Base.ToLower().StartsWith("ads://")) { - string[] opcuaAddress = td.Base.Split(new char[] { ':', '/' }); - if ((opcuaAddress.Length != 6) || (opcuaAddress[0] != "ads")) + string[] address = td.Base.Split(new char[] { ':', '/' }); + if ((address.Length != 6) || (address[0] != "ads")) { throw new Exception("Expected Beckhoff PLC address in the format ads://ipaddress:port!"); } - // check if we can reach the OPC UA asset + // check if we can reach the Beckhoff asset + BeckhoffClient client = new(); + client.Connect(address[3] + ":" + address[4], int.Parse(address[5])); + + assetInterface = client; + } + + if (td.Base.ToLower().StartsWith("bacnet://")) + { + string[] address = td.Base.Split(new char[] { ':', '/' }); + if ((address.Length != 6) || (address[0] != "bacnet")) + { + throw new Exception("Expected BACNet device address in the format bacnet://ipaddress:port!"); + } + + // check if we can reach the BACNet asset BeckhoffClient client = new(); - client.Connect(opcuaAddress[3] + ":" + opcuaAddress[4], int.Parse(opcuaAddress[5])); + client.Connect(address[3] + ":" + address[4], int.Parse(address[5])); assetInterface = client; } @@ -1012,6 +1047,11 @@ private ServiceResult OnWriteValue(ISystemContext context, NodeState node, Numer HandleBeckhoffDataWrite(tag, assetId, value.ToString()); } + if (_assets[assetId] is BACNetClient) + { + HandleBACNetDataWrite(tag, assetId, value.ToString()); + } + _uaVariables[tag.Name].Value = value; _uaVariables[tag.Name].Timestamp = DateTime.UtcNow; _uaVariables[tag.Name].ClearChangeMasks(SystemContext, false); @@ -1082,6 +1122,11 @@ private void UpdateNodeValues(object assetNameObject) { HandleBeckhoffDataRead(tag, assetId); } + + if (_assets[assetId] is BACNetClient) + { + HandleBACNetDataRead(tag, assetId); + } } } catch (Exception ex) @@ -1713,5 +1758,84 @@ private void HandleBeckhoffDataWrite(AssetTag tag, string assetId, string value) _assets[assetId].Write(addressParts[0], 0, string.Empty, tagBytes, false).GetAwaiter().GetResult(); } + + private void HandleBACNetDataRead(AssetTag tag, string assetId) + { + string[] addressParts = tag.Address.Split(['?', '&', '=']); + + if (addressParts.Length == 2) + { + byte[] tagBytes = null; + try + { + tagBytes = _assets[assetId].Read(addressParts[0], 0, null, ushort.Parse(addressParts[1])).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + Log.Logger.Error(ex.Message, ex); + + // try reconnecting + string[] remoteEndpoint = _assets[assetId].GetRemoteEndpoint().Split(':'); + _assets[assetId].Disconnect(); + _assets[assetId].Connect(remoteEndpoint[0] + ":" + remoteEndpoint[1], int.Parse(remoteEndpoint[2])); + } + + if ((tagBytes != null) && (tagBytes.Length > 0)) + { + object value = null; + if (tag.Type == "Float") + { + value = BitConverter.ToSingle(tagBytes); + } + else if (tag.Type == "Boolean") + { + value = BitConverter.ToBoolean(tagBytes); + } + else if (tag.Type == "Integer") + { + value = BitConverter.ToInt32(tagBytes); + } + else if (tag.Type == "String") + { + value = Encoding.UTF8.GetString(tagBytes); + } + else + { + throw new ArgumentException("Type not supported by BACNet."); + } + + UpdateUAServerVariable(tag, value); + } + } + } + + private void HandleBACNetDataWrite(AssetTag tag, string assetId, string value) + { + string[] addressParts = tag.Address.Split(['?', '&', '=']); + byte[] tagBytes = null; + + if (tag.Type == "Float") + { + tagBytes = BitConverter.GetBytes(float.Parse(value)); + } + else if (tag.Type == "Boolean") + { + tagBytes = BitConverter.GetBytes(bool.Parse(value)); + } + else if (tag.Type == "Integer") + { + tagBytes = BitConverter.GetBytes(int.Parse(value)); + } + else if (tag.Type == "String") + { + tagBytes = Encoding.UTF8.GetBytes(value); + } + else + { + throw new ArgumentException("Type not supported by BACNet."); + } + + _assets[assetId].Write(addressParts[0], 0, string.Empty, tagBytes, false).GetAwaiter().GetResult(); + } } }