commit 1aac927559eaa76d9e7a42f5bd4e96ed58c00579 Author: Miha Frangež Date: Mon May 5 21:26:26 2025 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6bda556 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +# Generated file +generated* +# Input project file +kepware_export.xml +# Binary +/kepware-opc-telegraf \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..c66e3e4 --- /dev/null +++ b/README.md @@ -0,0 +1,36 @@ +# kepware-opc-telegraf + +Generate a **Telegraf** config file to ingest **OPC-UA** data based on a **KEPServerEX** project file. + +The generated config includes two inputs, one based on `opcua_listener` (event-based, instant updates, no initial values or periodic polling) and one based on `opcua` (polling-based, slow, but provides initial values and outputs a value at least every `interval`). + + +## Inputs + + - **kepware_export.xml** - list of OPC nodes from KEPServerEX (see below) + - **base_telegraf.conf.toml** - base Telegraf conf (should contain at least one of `inputs.opcua` or `inputs.opcua_listener`) + - **generated_telegraf.conf.toml** - path where to save the generated Telegraf conf + +## Getting the project file + +Open **KEPServerEX**, go to File > Save as > select XML in the file type dropdown. Save it as `kepware_export.xml`. + +## Base Telegraf configuration + +You'll need to configure the base Telegraf config according to your setup. This probably means changing at least `outputs.influxdb_v2.{urls,organization,bucket,token}`. + +You will also need to set your OPC-UA connection parameters in both of the input config blocks. If you want to disable one of the inputs, remove or comment out the entire block. + +## Running the converter + +If you're running from source, build it first: + +```sh +go build +``` + +Once you have the executable: + +```sh +./kepware-opc-telegraf kepware_export.xml base_telegraf.conf.toml generated_telegraf.conf.toml +``` diff --git a/base_telegraf.conf.toml b/base_telegraf.conf.toml new file mode 100644 index 0000000..ddc81af --- /dev/null +++ b/base_telegraf.conf.toml @@ -0,0 +1,80 @@ +# Configuration for telegraf agent +[agent] + ## Default data collection interval for all inputs + interval = "10s" + ## Rounds collection interval to 'interval' + ## ie, if interval="10s" then always collect on :00, :10, :20, etc. + round_interval = true + + ## Telegraf will send metrics to outputs in batches of at most + ## metric_batch_size metrics. + ## This controls the size of writes that Telegraf sends to output plugins. + metric_batch_size = 1000 + + ## Maximum number of unwritten metrics per output. Increasing this value + ## allows for longer periods of output downtime without dropping metrics at the + ## cost of higher maximum memory usage. + metric_buffer_limit = 10000 + + ## Collection jitter is used to jitter the collection by a random amount. + ## Each plugin will sleep for a random time within jitter before collecting. + ## This can be used to avoid many plugins querying things like sysfs at the + ## same time, which can have a measurable effect on the system. + collection_jitter = "0s" + + ## Default flushing interval for all outputs. Maximum flush_interval will be + ## flush_interval + flush_jitter + flush_interval = "10s" + ## Jitter the flush interval by a random amount. This is primarily to avoid + ## large write spikes for users running a large number of telegraf instances. + ## ie, a jitter of 5s and interval 10s means flushes will happen every 10-15s + flush_jitter = "0s" + + ## By default or when set to "0s", precision will be set to the same + ## timestamp order as the collection interval, with the maximum being 1s. + ## ie, when interval = "10s", precision will be "1s" + ## when interval = "250ms", precision will be "1ms" + ## Precision will NOT be used for service inputs. It is up to each individual + ## service input to set the timestamp at the appropriate precision. + ## Valid time units are "ns", "us" (or "µs"), "ms", "s". + precision = "" + + ## Log at debug level. + # debug = false + ## Log only error level messages. + # quiet = false + + ## If set to true, do no set the "host" tag in the telegraf agent. + omit_hostname = true + +[[outputs.influxdb_v2]] + urls = ["https://influx.bajtastats.ingress.si/"] + organization = "docs" + bucket = "opcua" + + token = "$INFLUX_TOKEN" + +# Polling-based collector (for initial values and to catch values that we might have missed due to external issues) +[[inputs.opcua]] + name = "opcua" + interval = "30m" + + request_timeout = "30m" + + endpoint = "opc.tcp://192.168.190.81:49320" + security_policy = "None" + security_mode = "None" + + timestamp = "source" + nodes = [] + +# Event-based collector (for instant updates) +[[inputs.opcua_listener]] + name = "opcua" + + endpoint = "opc.tcp://192.168.190.81:49320" + security_policy = "None" + security_mode = "None" + + timestamp = "source" + nodes = [] \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..c4f3e54 --- /dev/null +++ b/go.mod @@ -0,0 +1,5 @@ +module fri.uni-lj.si/kepware-opc-telegraf + +go 1.24.2 + +require github.com/BurntSushi/toml v1.5.0 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ff7fd09 --- /dev/null +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= +github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a6df183 --- /dev/null +++ b/main.go @@ -0,0 +1,154 @@ +package main + +import ( + "encoding/xml" + "flag" + "fmt" + "io" + "os" + + "github.com/BurntSushi/toml" +) + +type Project struct { + XMLName xml.Name `xml:"Project"` + ChannelList []Channel `xml:"ChannelList>Channel"` +} + +type Channel struct { + Name string `xml:"Name"` + DeviceList []Device `xml:"DeviceList>Device"` +} + +type Device struct { + Name string `xml:"Name"` + TagList []Tag `xml:"TagList>Tag"` +} + +type Tag struct { + Name string `xml:"Name"` + Description string `xml:"Description"` +} + +type TelegrafConfig struct { + Inputs struct { + OPCUA []struct { + Nodes []map[string]interface{} `toml:"nodes"` + } `toml:"inputs.opcua"` + } `toml:"inputs"` +} + +func printStatistics(project Project, nodes []map[string]interface{}) { + groupSet := make(map[string]bool) + roomSet := make(map[string]bool) + + for _, channel := range project.ChannelList { + groupSet[channel.Name] = true + for _, device := range channel.DeviceList { + roomSet[fmt.Sprintf("%s.%s", channel.Name, device.Name)] = true + } + } + + fmt.Printf("Statistics:\n") + fmt.Printf(" Number of groups: %d\n", len(groupSet)) + fmt.Printf(" Number of rooms: %d\n", len(roomSet)) + fmt.Printf(" Total number of nodes: %d\n", len(nodes)) +} + +func main() { + // Parse CLI args + flag.Parse() + + if flag.NArg() != 3 { + fmt.Println("Usage: program ") + flag.PrintDefaults() + return + } + + kepwareXML := flag.Arg(0) + inputFile := flag.Arg(1) + outputFile := flag.Arg(2) + + // Read the XML file + xmlFile, err := os.Open(kepwareXML) + if err != nil { + fmt.Println("Error opening file:", err) + return + } + defer xmlFile.Close() + + byteValue, _ := io.ReadAll(xmlFile) + + var project Project + xml.Unmarshal(byteValue, &project) + + // Read the base Telegraf config + var config map[string]interface{} + if _, err := toml.DecodeFile(inputFile, &config); err != nil { + fmt.Println("Error reading base config:", err) + return + } + + // Generate node definitions + var nodes []map[string]interface{} + for _, channel := range project.ChannelList { + for _, device := range channel.DeviceList { + for _, tag := range device.TagList { + node := map[string]interface{}{ + "name": tag.Name, + "identifier_type": "s", + "namespace": "2", + "identifier": fmt.Sprintf("%s.%s.%s", channel.Name, device.Name, tag.Name), + "tags": [][]string{ + {"group", channel.Name}, + {"room", device.Name}, + }, + } + nodes = append(nodes, node) + } + } + } + + // Print statistics + printStatistics(project, nodes) + + // Update the config + inputs, ok := config["inputs"].(map[string]interface{}) + if !ok { + fmt.Println("Error: 'inputs' section not found in config") + return + } + + opcuaInputs, ok := inputs["opcua_listener"].([]map[string]interface{}) + if !ok || len(opcuaInputs) == 0 { + fmt.Println("Warn: 'inputs.opcua_listener' section not found in config") + // return + } else { + opcuaInputs[0]["nodes"] = nodes + } + + opcuaListenerInputs, ok := inputs["opcua"].([]map[string]interface{}) + if !ok || len(opcuaListenerInputs) == 0 { + fmt.Println("Warn: 'inputs.opcua' section not found in config") + // return + } else { + opcuaListenerInputs[0]["nodes"] = nodes + } + + // Write the updated config to a new file + f, err := os.Create(outputFile) + if err != nil { + fmt.Println("Error creating output file:", err) + return + } + defer f.Close() + + encoder := toml.NewEncoder(f) + encoder.Indent = " " + if err := encoder.Encode(config); err != nil { + fmt.Println("Error encoding TOML:", err) + return + } + + fmt.Println("Config generated successfully: generated_telegraf.conf.toml") +}