From 0126b02cafd4ed29f0cff0296e864868444e1951 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miha=20Frange=C5=BE?= Date: Mon, 5 May 2025 23:06:44 +0200 Subject: [PATCH] Generate CSV docs, tag non-room PLCs with "plc" instead of "room" --- README.md | 4 +- main.go | 199 ++++++++++++++++++++++++++++++++++++------------------ nodes.csv | 136 +++++++++++++++++++++++++++++++++++++ 3 files changed, 272 insertions(+), 67 deletions(-) create mode 100644 nodes.csv diff --git a/README.md b/README.md index c66e3e4..ebbcc04 100644 --- a/README.md +++ b/README.md @@ -4,12 +4,14 @@ Generate a **Telegraf** config file to ingest **OPC-UA** data based on a **KEPSe 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`). +It also generates a CSV with a list of all the different data points and their descriptions: [./nodes.csv](./nodes.csv) ## 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 + - **nodes.csv** - path where to save a CSV with a list of nodes and descriptions ## Getting the project file @@ -32,5 +34,5 @@ go build Once you have the executable: ```sh -./kepware-opc-telegraf kepware_export.xml base_telegraf.conf.toml generated_telegraf.conf.toml +./kepware-opc-telegraf -csv_out nodes.csv -telegraf_template ./base_telegraf.conf.toml -telegraf_out ./generated_telegraf.conf.toml ./kepware_export.xml ``` diff --git a/main.go b/main.go index a6df183..313c4bd 100644 --- a/main.go +++ b/main.go @@ -1,11 +1,13 @@ package main import ( + "encoding/csv" "encoding/xml" "flag" "fmt" "io" "os" + "sort" "github.com/BurntSushi/toml" ) @@ -28,6 +30,7 @@ type Device struct { type Tag struct { Name string `xml:"Name"` Description string `xml:"Description"` + DataType string `xml:"DataType"` } type TelegrafConfig struct { @@ -38,7 +41,7 @@ type TelegrafConfig struct { } `toml:"inputs"` } -func printStatistics(project Project, nodes []map[string]interface{}) { +func printStatistics(project Project) { groupSet := make(map[string]bool) roomSet := make(map[string]bool) @@ -52,22 +55,29 @@ func printStatistics(project Project, nodes []map[string]interface{}) { 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 + inputPath := flag.String("telegraf_template", "", "Input telegraf.conf file") + outputPath := flag.String("telegraf_out", "", "Output telegraf.conf file") + csvPath := flag.String("csv_out", "", "Write documentation to CSV file") + flag.Parse() - if flag.NArg() != 3 { - fmt.Println("Usage: program ") + if flag.NArg() != 1 { + fmt.Println("Usage: program ") flag.PrintDefaults() return } + // Need either both or neither of telegraf input/output + if *inputPath != "" && *outputPath == "" || *inputPath == "" && *outputPath != "" { + fmt.Println("Error: Either both or neither of -telegraf_template and -output_file must be provided.") + return + } + kepwareXML := flag.Arg(0) - inputFile := flag.Arg(1) - outputFile := flag.Arg(2) // Read the XML file xmlFile, err := os.Open(kepwareXML) @@ -82,73 +92,130 @@ func main() { 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 - } + /* + Telegraf config generation + */ - // 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}, - }, + if *inputPath != "" { + + // Read the base Telegraf config + var config map[string]interface{} + if _, err := toml.DecodeFile(*inputPath, &config); err != nil { + fmt.Println("Error reading base config:", err) + return + } + + // Print statistics + printStatistics(project) + + // Generate node definitions + var nodes []map[string]interface{} + for _, channel := range project.ChannelList { + for _, device := range channel.DeviceList { + for _, tag := range device.TagList { + + // Group "Razno" are general PLCs, not bound to specific rooms, so we use "plc" instead of "room" + plc_tag := "room" + if channel.Name == "Razno" { + plc_tag = "plc" + } + + 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}, + {plc_tag, device.Name}, + }, + } + nodes = append(nodes, node) } - nodes = append(nodes, node) } } + + fmt.Printf(" Total number of nodes: %d\n", len(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(*outputPath) + 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.Printf("Config written to %s\n", *outputPath) } - // Print statistics - printStatistics(project, nodes) + /* + Generate CSV documentation (optional) + */ + if *csvPath != "" { + uniqueTags := make(map[string]Tag) + for _, channel := range project.ChannelList { + for _, device := range channel.DeviceList { + for _, tag := range device.TagList { + uniqueTags[fmt.Sprintf("%s.%s.%s", tag.Name, tag.Description, tag.DataType)] = tag + } + } + } + fmt.Printf(" Total number of unique tags: %d\n", len(uniqueTags)) - // Update the config - inputs, ok := config["inputs"].(map[string]interface{}) - if !ok { - fmt.Println("Error: 'inputs' section not found in config") - return + // Sort the tags alphabetically by name + keys := make([]string, 0, len(uniqueTags)) + for k := range uniqueTags { + keys = append(keys, k) + } + sort.Strings(keys) + + // Write the CSV file + f, err := os.Create(*csvPath) + if err != nil { + fmt.Println("Error creating CSV file:", err) + return + } + defer f.Close() + + writer := csv.NewWriter(f) + defer writer.Flush() + + writer.Write([]string{"DataType", "Name", "Description"}) + for _, k := range keys { + writer.Write([]string{uniqueTags[k].DataType, uniqueTags[k].Name, uniqueTags[k].Description}) + } + + fmt.Printf("CSV documentation written to %s\n", *csvPath) } - 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") } diff --git a/nodes.csv b/nodes.csv new file mode 100644 index 0000000..467b840 --- /dev/null +++ b/nodes.csv @@ -0,0 +1,136 @@ +DataType,Name,Description +Float,AI_AI1_Temp,Zunanja temperatura +Float,AI_AI1_Temp_VTZ,Temperatura vtočni zrak +Float,AI_AI1_Temp_prostora,Temperatura prostora +Float,AI_AI1_Vlaga,Zunanja vlaga +Float,AI_AI2_Temp_VTZ,Temperatura vtočni zrak 2 +Float,AI_AI2_Temp_prostora,Temperatura prostora - K.KL.03.11 +Float,AI_AI2_Temp_prostora,Temperatura prostora - R.KL.03.06 +Float,AI_AI2_Temp_prostora,Temperatura prostora - X.KL.03.02 +Float,AI_AI2_Tlak_prostor,Tlak v prostoru +Float,AI_AI3_Kvalita_zr_pr,Kvaliteta zraka v prostoru +Float,AI_AI3_Temp_VTZ,Temperatura vtočni zrak 3 +Float,AI_AI3_Tlak_prostor_2,Tlak v prostoru +Float,AI_AI3_Veter,Hitrost vetra +Float,AI_AI4_Twilight,Osvetlejnost max 250 lx +Float,AI_AI4_VRP_Dovod,Volumski regulator pretoka dov. - pretok +Float,AI_AI5_Osvetljenost_Jug,Osvetlejnost - JUG +Float,AI_AI5_Osvetljenost_Vzh,Osvetlejnost - VZHOD +Float,AI_AI5_Temp_VTZ_2,Temperatura vtočni zrak +Float,AI_AI6_Osvetljenost_Zah,Osvetlejnost - ZAHOD +Float,AI_AI6_Padavine,"Padavine: 0V-DA, 10V-NE" +Float,AI_AI6_VRP_Dovod_2,Volumski regulator pretoka dov. - pretok +Float,AO_AO1_Ventil_Predgr,Ventil predgrelnika +Float,AO_AO2_VRP_Dovod,Volumski regulator pretoka - dovod +Float,AO_AO2_Ventil_Predgr2,Ventil predgrelnika 2 +Float,AO_AO3_LOPUTA_Odvod,Loputa - odvod +Float,AO_AO3_Ventil_Predgr3,Ventil predgrelnika 3 +Boolean,AO_DO4_Ventil_FC_HL,Ventil konvektor hlajenje +Float,AO_DO4_Ventil_FC_HL,Ventil konvektor hlajenje +Boolean,AO_DO5_Ventil_RAD_GR,Ventil radiator gretje +Float,AO_DO5_Ventil_RAD_GR,Ventil radiator gretje +Float,AV_AVG_Pretok,Pretok - Povprečna vrednost +Float,AV_AVG_Temperatura,Temperatura - Povprečna vrednost +Float,AV_AVG_Tlak,Tlak - Povprečna vrednost +Float,AV_Dnevni_rezim,Dnevni režim - Izkl komf/stby/varčni +Boolean,AV_FKKT_KRM1_Pozar_1,Požar cona TR13/1 +Boolean,AV_FKKT_KRM1_Pozar_2,Požar cona TR12/4 +Boolean,AV_FKKT_KRM1_Pozar_3,Požar cona TR12/2 +Float,AV_FRI_KRM1_Padavine,Padavine - Vremenska postaja +Boolean,AV_FRI_KRM1_Pozar,Požar +Boolean,AV_FRI_KRM1_Pozar_R,Tipka požar - Ročno +Float,AV_HIST_Occ,Histereza ogrevanja - dnevni režim +Float,AV_HIST_Stby,Histereza ogrevanja - režim pripravljenost +Float,AV_HIST_UnOcc,Histereza ogrevanja - nočni režim +Float,AV_Hitrost_ventilat_G,Dejanska hitrost ventilatorja - GR +Float,AV_Hitrost_ventilator,Dejanska hitrost ventilatorja - HL +Float,AV_Hitrost_ventilator,Dejanska hitrost ventilatorja +Float,AV_Nagib_zaluzij,Dejanski kot zaluzij +Float,AV_Odpiranje_CNS_1,Odpiranje kupol preko CNS- Stikalo +Float,AV_Odpiranje_CNS_1,Odpiranje oken preko CNS- Stikalo +Float,AV_Odpiranje_CNS_2,Odpiranje kupol preko CNS- Stikalo +Float,AV_Odpiranje_CNS_2,Odpiranje oken preko CNS- Stikalo +Float,AV_Odpiranje_CNS_3,Odpiranje kupol preko CNS- Stikalo +Float,AV_Odpiranje_CNS_3,Odpiranje oken preko CNS- Stikalo +Float,AV_Odpiranje_CNS_4,Odpiranje kupol preko CNS- Stikalo +Float,AV_Odpiranje_CNS_4,Odpiranje oken preko CNS- Stikalo +Float,AV_PID_P_I,Regulator tlaka: I-člen +Float,AV_PID_P_OPT_Y,Regulator tlaka - Optimalna vr. izhoda +Float,AV_PID_P_P,Regulator tlaka: P-člen +Float,AV_Polozaj_kupola_1,"Trenutni položaj kupole: 0-zaprta, 1-odprta" +Float,AV_Polozaj_kupola_2,"Trenutni položaj kupole: 0-zaprta, 1-odprta" +Float,AV_Polozaj_kupola_3,"Trenutni položaj kupole: 0-zaprta, 1-odprta" +Float,AV_Polozaj_kupola_4,"Trenutni položaj kupole: 0-zaprta, 1-odprta" +Float,AV_Polozaj_okna_1,"Trenutni položaj okna: 0-zaprto, 1-odprto" +Float,AV_Polozaj_okna_2,"Trenutni položaj okna: 0-zaprto, 1-odprto" +Float,AV_Polozaj_okna_3,"Trenutni položaj okna: 0-zaprto, 1-odprto" +Float,AV_Polozaj_okna_4,"Trenutni položaj okna: 0-zaprto, 1-odprto" +Float,AV_Preklop_Lab_Uc,"Tip prostora: 0-Laboratorij, 1-Učilnica" +Float,AV_Prisotnost,Prisotnost preko kartice +Float,AV_SEQ_VRP_Y1,Nastavitev rampe - VRP Y1 +Float,AV_SEQ_VRP_Y2,Nastavitev rampe - VRP Y2 +Float,AV_SP_Hist_Izklopa,Histereza izklopa GR in HL +Float,AV_SP_Hist_Izklopa_2,Histereza izklopa GR in HL - K.KL.03.11 +Float,AV_SP_Hist_Izklopa_2,Histereza izklopa GR in HL - R.KL.03.06 +Float,AV_SP_Hist_Izklopa_2,Histereza izklopa GR in HL - X.KL.03.02 +Float,AV_SP_Ppr,Želen tlak v prostoru +Float,AV_SP_Prezrac_vkl,Nastavitev pretoka VRP - Prezrac. vkl +Float,AV_SP_Qpr,Želena kvaliteta zraka v prostoru +Float,AV_SP_Rezim_Urnik,Nastavitev dnevnega režima - Urnik +Float,AV_SP_Tpr,Želena temperatura prostora +Float,AV_SP_Tpr_Max,Maksimalna želena temp. prostora +Float,AV_SP_Tpr_Max_1,Maksimalna želena temp. prostora-1 +Float,AV_SP_Tpr_Max_2,Maksimalna želena temp. prostora - K.KL.03.11 +Float,AV_SP_Tpr_Max_2,Maksimalna želena temp. prostora - R.KL.03.06 +Float,AV_SP_Tpr_Max_2,Maksimalna želena temp. prostora - X.KL.03.02 +Float,AV_SP_Tpr_Max_2,Maksimalna želena temp. prostora-2 +Float,AV_SP_Tpr_Min,Minimalna želena temp. prostora +Float,AV_SP_Tpr_Min_2,Minimalna želena temp. prostora - K.KL.03.11 +Float,AV_SP_Tpr_Min_2,Minimalna želena temp. prostora - R.KL.03.06 +Float,AV_SP_Tpr_Min_2,Minimalna želena temp. prostora - X.KL.03.02 +Float,AV_SP_Tpr_Odmik,Želena temperatura prostora - Odmik +Float,AV_SP_Tvp,Želena temperatura vtočni zrak +Float,AV_SP_Tvp_2,Želena temperatura vtočni zrak 2 +Float,AV_SP_Tvp_3,Želena temperatura vtočni zrak 3 +Float,AV_SP_Zaluzije_AVTO,Nastavitev kota žaluzij Avtomatsko +Float,AV_Sp_Tpr_Aktivna,Aktivna želena temp. prostora +Float,AV_Sp_Tpr_Dejanska,Dejanska želena temp. prostora +Float,AV_Stikalo_ventilator,Položaj stikala ventialtorja A/0/1/2/3 +Float,AV_Temp_Rezim,Temperaturni režim - GR/HL/IZKL +Float,AV_Temp_prostora,Temperatura prostora +Float,AV_Temp_prostora_1,Temperatura prostora 1 +Float,AV_Temp_prostora_2,Temperatura prostora 2 +Float,AV_Temp_prostora_povpr,Temperatura prostora - povprečna +Float,AV_VRP_Prekl_Min_Max,Vklop povečanega prezračevanja +Float,AV_VRP_SP_Max,Nastavitev povečanega prezračevanja +Float,AV_VRP_SP_Min,Nastavitev zmanjšanega prezračevanja +Float,AV_Vlaga_prostora_1,Vlaga prostora 1 +Float,AV_Vlaga_prostora_2,Vlaga prostora 2 +Boolean,BI_DI1_Ogr_zlebov_nap,Ogrevanje žlebov - napaka +Boolean,BI_DI1_Pozar,Požar +Boolean,BI_DI1_Pozar_1,Požar cona TR13/1 +Boolean,BI_DI2_Ogr_zlebov_del,Ogrevanje žlebov - delovanje +Boolean,BI_DI2_Pozar_2,Požar cona TR12/4 +Boolean,BI_DI2_Tipka_Pozar_R,Tipka požar - Ročno +Float,BI_DI3_Okno,Okensko stikalo +Boolean,BI_DI3_Pozar,Požar +Boolean,BI_DI3_Pozar_3,Požar cona TR12/2 +Boolean,BI_DI4_Tehnicni_Plini,Signal koncentracije tehničnih plinov +Boolean,BI_DI4_Tipka_Pozar_R,Tipka požar - Ročno +Boolean,BO_DO1_Vkl_Prezrac,Vklop hlajenja v prostoru +Boolean,BO_DO1_Vkl_Prezrac_1,Vklop hlajenja v prostoru-1 +Boolean,BO_DO2_Vkl_Kalorifer,Vklop gretja v prostoru +Boolean,BO_DO2_Vkl_Prezrac_2,Vklop hlajenja v prostoru-2 +Boolean,BO_DO3_Vkl_Prezrac,Vklop hlajenja v prostoru - K.KL.03.11 +Boolean,BO_DO3_Vkl_Prezrac,Vklop hlajenja v prostoru - R.KL.03.06 +Boolean,BO_DO3_Vkl_Prezrac,Vklop hlajenja v prostoru - X.KL.03.02 +Boolean,BO_DO4_Vkl_Kalorifer,Vklop gretja v prostoru - K.KL.03.11 +Boolean,BO_DO4_Vkl_Kalorifer,Vklop gretja v prostoru - R.KL.03.06 +Boolean,BO_DO4_Vkl_Kalorifer,Vklop gretja v prostoru - X.KL.03.02 +Boolean,BO_DO6_Prezrac_vkl,Signal - Prezračevanje vključeno +Boolean,BO_DO7_Prezrac_vkl,Signal - Prezračevanje vključeno +Boolean,BO_DO7_Prezrac_vkl,Vklop prezračevanja +Boolean,BO_DO8_Prezrac_vkl,Signal - Prezračevanje vključeno +Boolean,BV_Izklop_FC_iz_CNS,Izklop hitrosti FC iz CNS +Boolean,BV_Ni_medija_v_HP,Ni hladilnega medija v HP +Boolean,BV_Ni_medija_v_TP,Ni grelnega medija v TP