Files
Arma3-Influx/arma.go

694 lines
20 KiB
Go

package main
/*
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include "extensionCallback.h"
*/
import "C" // This is required to import the C code
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"os"
"reflect"
"strings"
"time"
"unsafe"
influxdb2 "github.com/influxdata/influxdb-client-go/v2"
"github.com/jackc/pgx/v4"
"github.com/jackc/pgx/v4/pgxpool"
)
// declare list of functions available for call
var AVAILABLE_FUNCTIONS = map[string]interface{}{
"loadSettings": loadSettings,
"connectToInflux": connectToInflux,
"writeToInflux": writeToInflux,
"connectToTimescale": connectToTimescale,
"initTimescale": initTimescale,
"writeToTimescale": writeToTimescale,
"deinitialize": deinitialize,
"getDir": getDir,
"sanitizeLineProtocol": sanitizeLineProtocol,
}
var EXTENSION_VERSION string = "0.0.1"
var extensionCallbackFnc C.extensionCallback
var influxConnectionSettings influxSettings
var a3Settings arma3Settings
var timescaleConnectionSettings timescaleSettings
var timescaleDbPool *pgxpool.Pool
// InfluxDB variables
var DB_CLIENT influxdb2.Client
// file paths
var ADDON_FOLDER string = "./@RangerMetrics"
var LOG_FILE string = ADDON_FOLDER + "/rangermetrics.log"
var SETTINGS_FILE string = ADDON_FOLDER + "/settings.json"
// var BACKUP_FILE_PATH string = ADDON_FOLDER + "/local_backup.log.gzip"
// var BACKUP_WRITER *gzip.Writer
// configure log output
func init() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
// log to file
f, err := os.OpenFile(LOG_FILE, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
log.Fatalf("error opening file: %v", err)
}
// log to console as well
log.SetOutput(io.MultiWriter(f, os.Stdout))
}
// func RVExtensionContext(output *C.char, argc *C.int) {
// }
type influxSettings struct {
Host string `json:"host"`
Token string `json:"token"`
Org string `json:"org"`
}
type timescaleSettings struct {
ConnectionUrl string `json:"connectionUrl"`
}
type arma3Settings struct {
RefreshRateMs int `json:"refreshRateMs"`
}
type settingsJson struct {
Influx influxSettings `json:"influxdb"`
Arma3 arma3Settings `json:"arma3"`
Timescale timescaleSettings `json:"timescaledb"`
}
func connectToInflux() {
if influxConnectionSettings.Host == "" {
logLine("connectToInflux", `["influxConnectionSettings.Host is empty", "ERROR"]`)
// logLine("connectToInflux", `["Creating backup file", "INFO"]`)
// file, err := os.Open(BACKUP_FILE_PATH)
// if err != nil {
// log.Fatal(err)
// logLine("connectToInflux", `["Error opening backup file", "ERROR"]`)
// }
// BACKUP_WRITER = gzip.NewWriter(file)
// if err != nil {
// log.Fatal(err)
// logLine("connectToInflux", `["Error creating gzip writer", "ERROR"]`)
// }
// return "Error connecting to Influx. Using local backup"
}
DB_CLIENT = influxdb2.NewClientWithOptions(influxConnectionSettings.Host, influxConnectionSettings.Token, influxdb2.DefaultOptions().SetBatchSize(500).SetFlushInterval(2000))
logLine("connectToInflux", `["DB_CLIENT created", "INFO"]`)
}
//////////////////////////////////
// TIMESCALE
//////////////////////////////////
func connectToTimescale() {
functionName := "connectToTimescale"
var err error
// urlExample := "postgres://username:password@localhost:5432/database_name"
// logLine("connectToTimescale", fmt.Sprintf(`["timescaleConnectionSettings.ConnectionUrl: %s", "INFO"]`, timescaleConnectionSettings.ConnectionUrl))
conn, err := pgx.Connect(context.Background(), timescaleConnectionSettings.ConnectionUrl)
if err != nil {
logLine(
functionName,
fmt.Sprintf(`["Unable to connect to Timescale DB: %v", "ERROR"]`, err.Error()),
)
// return fmt.Sprintf(`["Error connecting to timescale DB: %v\n"]`, err.Error())
}
conn.Exec(context.Background(), "CREATE DATABASE rangermetrics;")
logLine("connectToTimescale", `["Connected to Timescale successfully", "INFO"]`)
}
func initTimescale() {
// create a table
// CREATE TABLE IF NOT EXISTS rangermetrics (time timestamp, line text);
timescaleDbPool.Exec(context.Background(), "CREATE DATABASE rangermetrics;")
// create entities table
timescaleDbPool.Exec(context.Background(), `CREATE TABLE "public.Missions" (
"id" serial NOT NULL UNIQUE,
"world_name" VARCHAR(255) NOT NULL,
"briefing_name" VARCHAR(255) NOT NULL,
"mission_name" VARCHAR(255) NOT NULL,
"mission_author" VARCHAR(255) NOT NULL,
"server_name" VARCHAR(255) NOT NULL,
"server_mods" TEXT,
"ace_medical" BOOLEAN NOT NULL,
"radio_tfar" BOOLEAN NOT NULL,
"radio_acre" BOOLEAN NOT NULL,
"start_game" DATETIME NOT NULL,
"start_utc" DATETIME NOT NULL,
"frame_count" FLOAT NOT NULL,
"capture_delay_s" FLOAT NOT NULL,
"addon_ver_major" integer NOT NULL,
"addon_ver_minor" integer NOT NULL,
"addon_ver_patch" integer NOT NULL,
"extension_ver_major" integer NOT NULL,
"extension_ver_minor" integer NOT NULL,
"extension_ver_patch" integer NOT NULL,
"tags" VARCHAR(255) NOT NULL,
CONSTRAINT "Missions_pk" PRIMARY KEY ("id")
) WITH (
OIDS=FALSE
);
CREATE TABLE "public.Worlds" (
"world_name" VARCHAR(255) NOT NULL,
"display_name" VARCHAR(255) NOT NULL,
"world_size_m" integer NOT NULL,
CONSTRAINT "Worlds_pk" PRIMARY KEY ("world_name")
) WITH (
OIDS=FALSE
);
CREATE TABLE "public.Units" (
"mission_id" integer NOT NULL,
"unit_id" integer NOT NULL,
"frame" integer NOT NULL,
"steamid64" varchar(100),
"steam_name" VARCHAR(255) NOT NULL,
"a3_profile_name" VARCHAR(255) NOT NULL,
"is_human" BOOLEAN NOT NULL,
"is_afk" BOOLEAN NOT NULL,
"is_alive" BOOLEAN NOT NULL,
"unit_type" VARCHAR(255) NOT NULL,
"role_description" VARCHAR(255),
"side" integer NOT NULL,
"group_id" varchar(100) NOT NULL,
"name" VARCHAR(255),
"position" VARCHAR(255) NOT NULL,
"direction" FLOAT NOT NULL,
"anim_state" varchar(100),
"stance" VARCHAR(255),
"traits" VARCHAR(255),
"damage" FLOAT NOT NULL,
"is_speaking" integer NOT NULL,
CONSTRAINT "Units_pk" PRIMARY KEY ("mission_id","unit_id","frame")
) WITH (
OIDS=FALSE
);
CREATE TABLE "public.Vehicles" (
"mission_id" integer NOT NULL,
"frame" integer NOT NULL,
"vehicle_id" integer NOT NULL,
"object_type" VARCHAR(255),
"weapons" varchar(3000),
"customization" varchar(1000),
"position" VARCHAR(255) NOT NULL,
"direction" FLOAT NOT NULL,
"vector_dir" varchar(70) NOT NULL,
"vector_up" varchar(70) NOT NULL,
"is_alive" BOOLEAN NOT NULL,
"damage" FLOAT NOT NULL,
CONSTRAINT "Vehicles_pk" PRIMARY KEY ("mission_id","frame","vehicle_id")
) WITH (
OIDS=FALSE
);
CREATE TABLE "public.Markers" (
"mission_id" integer NOT NULL,
"frame" integer NOT NULL,
"marker_name" VARCHAR(255) NOT NULL,
"data" VARCHAR(255) NOT NULL,
CONSTRAINT "Markers_pk" PRIMARY KEY ("mission_id","frame","marker_name")
) WITH (
OIDS=FALSE
);
CREATE TABLE "public.Chat" (
"id" serial NOT NULL,
"mission_id" integer NOT NULL,
"sender_id" integer NOT NULL,
"frame" integer NOT NULL,
"timestamp_utc" TIMESTAMP NOT NULL,
"content" VARCHAR(255) NOT NULL,
"channel" integer NOT NULL,
CONSTRAINT "Chat_pk" PRIMARY KEY ("id")
) WITH (
OIDS=FALSE
);
CREATE TABLE "public.UniqueUsers" (
"steamid64" varchar(100) NOT NULL,
CONSTRAINT "UniqueUsers_pk" PRIMARY KEY ("steamid64")
) WITH (
OIDS=FALSE
);
CREATE TABLE "public.Environment" (
"mission_id" integer NOT NULL,
"frame" integer NOT NULL,
"date_game" DATETIME NOT NULL,
"date_utc" DATETIME NOT NULL,
"overcast" FLOAT NOT NULL,
"rain" FLOAT NOT NULL,
"humidity" FLOAT NOT NULL,
"fog_value" FLOAT NOT NULL,
"fog_decay" FLOAT NOT NULL,
"fog_base" FLOAT NOT NULL,
"wind_vector" VARCHAR(255) NOT NULL,
"gusts" FLOAT NOT NULL,
"waves" FLOAT NOT NULL,
CONSTRAINT "Environment_pk" PRIMARY KEY ("mission_id","frame")
) WITH (
OIDS=FALSE
);
CREATE TABLE "public.StaticObjects" (
"mission_id" integer NOT NULL,
"frame" integer NOT NULL,
"building_id" integer NOT NULL,
"position" integer NOT NULL,
"direction" FLOAT NOT NULL,
"vector_dir" varchar(70) NOT NULL,
"vector_up" varchar(70) NOT NULL,
"object_type" VARCHAR(255) NOT NULL,
"classname" VARCHAR(255) NOT NULL,
"simple_object_data" varchar(1500) NOT NULL,
CONSTRAINT "StaticObjects_pk" PRIMARY KEY ("mission_id","frame","building_id")
) WITH (
OIDS=FALSE
);
CREATE TABLE "public.Campaigns" (
"id" serial NOT NULL,
"name" VARCHAR(255) NOT NULL,
"description" TEXT NOT NULL,
"image" bytea NOT NULL,
CONSTRAINT "Campaigns_pk" PRIMARY KEY ("id")
) WITH (
OIDS=FALSE
);
CREATE TABLE "public.Missions_In_Campaigns" (
"mission" integer NOT NULL,
"campaign" integer NOT NULL,
CONSTRAINT "Missions_In_Campaigns_pk" PRIMARY KEY ("mission","campaign")
) WITH (
OIDS=FALSE
);
ALTER TABLE "Missions" ADD CONSTRAINT "Missions_fk0" FOREIGN KEY ("world_name") REFERENCES "Worlds"("world_name");
ALTER TABLE "Units" ADD CONSTRAINT "Units_fk0" FOREIGN KEY ("mission_id") REFERENCES "Missions"("id");
ALTER TABLE "Units" ADD CONSTRAINT "Units_fk1" FOREIGN KEY ("steamid64") REFERENCES "UniqueUsers"("steamid64");
ALTER TABLE "Vehicles" ADD CONSTRAINT "Vehicles_fk0" FOREIGN KEY ("mission_id") REFERENCES "Missions"("id");
ALTER TABLE "Markers" ADD CONSTRAINT "Markers_fk0" FOREIGN KEY ("mission_id") REFERENCES "Missions"("id");
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_fk0" FOREIGN KEY ("mission_id") REFERENCES "Missions"("id");
ALTER TABLE "Chat" ADD CONSTRAINT "Chat_fk1" FOREIGN KEY ("sender_id") REFERENCES "Units"("unit_id");
ALTER TABLE "Environment" ADD CONSTRAINT "Environment_fk0" FOREIGN KEY ("mission_id") REFERENCES "Missions"("id");
ALTER TABLE "StaticObjects" ADD CONSTRAINT "StaticObjects_fk0" FOREIGN KEY ("mission_id") REFERENCES "Missions"("id");
ALTER TABLE "Missions_In_Campaigns" ADD CONSTRAINT "Missions_In_Campaigns_fk0" FOREIGN KEY ("mission") REFERENCES "Missions"("id");
ALTER TABLE "Missions_In_Campaigns" ADD CONSTRAINT "Missions_In_Campaigns_fk1" FOREIGN KEY ("campaign") REFERENCES "Campaigns"("id");
`)
}
func writeToTimescale(table string, line string) {
// logLine("writeToTimescale", fmt.Sprintf(`["line: %s", "INFO"]`, line))
functionName := "writeToTimescale"
_, err := timescaleDbPool.Exec(context.Background(), "INSERT INTO %1 (time, line) VALUES (NOW(), $2);", table, line)
if err != nil {
logLine(functionName, fmt.Sprintf(`["Error writing to timescale: %v", "ERROR"]`, err.Error()))
}
}
func deinitialize() {
logLine("deinitialize", `["deinitialize called", "INFO"]`)
DB_CLIENT.Close()
timescaleDbPool.Close()
}
// sanitize line protocol for influx
func sanitizeLineProtocol(line string) string {
// replace all spaces with underscores
// line = strings.ReplaceAll(line, ` `, `\ `)
// replace all commas with underscores
// line = strings.ReplaceAll(line, `,`, `\,`)
// replace all equals with underscores
// line = strings.ReplaceAll(line, "=", "_")
// replace all quotes with underscores
// line = strings.ReplaceAll(line, "\"", "_")
return line
}
func getDir() string {
dir, err := os.Getwd()
if err != nil {
log.Fatal(err)
}
return dir
}
func loadSettings() (dir string, result string, influxHost string, timescaleUrl string) {
functionName := "loadSettings"
logLine(functionName, fmt.Sprintf(`["ADDON_FOLDER: %s", "INFO"]`, ADDON_FOLDER))
logLine(functionName, fmt.Sprintf(`["LOG_FILE: %s", "INFO"]`, LOG_FILE))
logLine(functionName, fmt.Sprintf(`["SETTINGS_FILE: %s", "INFO"]`, SETTINGS_FILE))
// print the current working directory
var file *os.File
var err error
// read settings from file
// settings.json should be in the same directory as the .dll
// see if the file exists
if _, err = os.Stat(SETTINGS_FILE); os.IsNotExist(err) {
// file does not exist
log.Println("settings.json does not exist")
// create the file
file, err = os.Create(SETTINGS_FILE)
if err != nil {
log.Fatal(err)
}
defer file.Close()
// write the default settings to the file
ifSet := influxSettings{
Host: "http://localhost:8086",
Token: "my-token",
Org: "my-org",
}
a3Set := arma3Settings{
RefreshRateMs: 1000,
}
tsSettings := timescaleSettings{
ConnectionUrl: "postgres://username:password@localhost:5432/database_name",
}
defaultSettings := map[string]interface{}{
"influxdb": ifSet,
"arma3": a3Set,
"timescale": tsSettings,
}
encoder := json.NewEncoder(file)
err = encoder.Encode(defaultSettings)
if err != nil {
log.Fatal(err)
}
result = `["settings.json created - please modify!", "WARN"]`
influxHost = ifSet.Host
timescaleUrl = tsSettings.ConnectionUrl
return dir, result, influxHost, timescaleUrl
} else {
// file exists
log.Println("settings.json exists")
// read the file
file, err = os.Open(SETTINGS_FILE)
if err != nil {
log.Fatal(err)
}
defer file.Close()
decoder := json.NewDecoder(file)
var settings settingsJson
err = decoder.Decode(&settings)
if err != nil {
log.Fatal(err)
}
// set the settings
influxConnectionSettings = settings.Influx
a3Settings = settings.Arma3
timescaleConnectionSettings = settings.Timescale
// set the result
result = `["settings.json loaded", "INFO"]`
influxHost = influxConnectionSettings.Host
timescaleUrl = timescaleConnectionSettings.ConnectionUrl
}
return dir, result, influxHost, timescaleUrl
}
func runExtensionCallback(name *C.char, function *C.char, data *C.char) C.int {
return C.runExtensionCallback(extensionCallbackFnc, name, function, data)
}
//export goRVExtensionVersion
func goRVExtensionVersion(output *C.char, outputsize C.size_t) {
result := C.CString(EXTENSION_VERSION)
defer C.free(unsafe.Pointer(result))
var size = C.strlen(result) + 1
if size > outputsize {
size = outputsize
}
C.memmove(unsafe.Pointer(output), unsafe.Pointer(result), size)
}
//export goRVExtensionArgs
func goRVExtensionArgs(output *C.char, outputsize C.size_t, input *C.char, argv **C.char, argc C.int) {
var offset = unsafe.Sizeof(uintptr(0))
var out []string
for index := C.int(0); index < argc; index++ {
out = append(out, C.GoString(*argv))
argv = (**C.char)(unsafe.Pointer(uintptr(unsafe.Pointer(argv)) + offset))
}
var temp string
temp = fmt.Sprintf("Function: %s nb params: %d params: %s!", C.GoString(input), argc, out)
if C.GoString(input) == "sendToInflux" {
// start a goroutine to send the data to influx
// param string is argv[0] which is the data to send to influx
go writeToInflux(&out)
temp = fmt.Sprintf("Function: %s nb params: %d", C.GoString(input), argc)
}
// Return a result to Arma
result := C.CString(temp)
defer C.free(unsafe.Pointer(result))
var size = C.strlen(result) + 1
if size > outputsize {
size = outputsize
}
C.memmove(unsafe.Pointer(output), unsafe.Pointer(result), size)
}
func callBackExample() {
name := C.CString("arma")
defer C.free(unsafe.Pointer(name))
function := C.CString("funcToExecute")
defer C.free(unsafe.Pointer(function))
// Make a callback to Arma
for i := 0; i < 3; i++ {
time.Sleep(2 * time.Second)
param := C.CString(fmt.Sprintf("Loop: %d", i))
defer C.free(unsafe.Pointer(param))
runExtensionCallback(name, function, param)
}
}
func getUnixTimeNano() int64 {
// get the current unix timestamp in nanoseconds
return time.Now().UnixNano()
}
func trimQuotes(s string) string {
// trim the start and end quotes from a string
return strings.Trim(s, `"`)
}
func fixEscapeQuotes(s string) string {
// fix the escape quotes in a string
return strings.Replace(s, `""`, `"`, -1)
}
func writeToInflux(a3DataRaw *[]string) string {
// convert to string array
a3Data := *a3DataRaw
logLine("writeToInflux", fmt.Sprintf(`["Received %d params", "DEBUG"]`, len(a3Data)))
MIN_PARAMS_COUNT := 1
var logData string
functionName := "writeToInflux"
if len(a3Data) < MIN_PARAMS_COUNT {
logData = fmt.Sprintf(`["Not all parameters present (got %d, expected at least %d)", "ERROR"]`, len(a3Data), MIN_PARAMS_COUNT)
logLine(functionName, logData)
return logData
}
// use custom bucket or default
var bucket string = fixEscapeQuotes(trimQuotes(string(a3Data[0])))
// Get non-blocking write client
WRITE_API := DB_CLIENT.WriteAPI(influxConnectionSettings.Org, bucket)
if WRITE_API == nil {
logData = `["Error creating write API", "ERROR"]`
logLine(functionName, logData)
return logData
}
// Get errors channel
errorsCh := WRITE_API.Errors()
go func() {
for writeErr := range errorsCh {
logData = fmt.Sprintf(`["Error parsing line protocol: %s", "ERROR"]`, strings.Replace(writeErr.Error(), `"`, `'`, -1))
logLine(functionName, logData)
}
}()
// now we have our write client, we'll go through the rest of the receive array items in line protocol format and write them to influx
for i := 1; i < len(a3Data); i++ {
var p string = fixEscapeQuotes(trimQuotes(string(a3Data[i])))
// write the line to influx
WRITE_API.WriteRecord(p)
// TODO: Add backup writer
// // append backup line to file if BACKUP_WRITER is set
// //
// if BACKUP_WRITER != nil {
// _, err = BACKUP_WRITER.Write([]byte(p + "\n"))
// }
}
// schedule cleanup
WRITE_API.Flush()
logData = fmt.Sprintf(`["Wrote %d lines to influx", "INFO"]`, len(a3Data)-1)
logLine(functionName, logData)
return "Success"
}
func logLine(functionName string, data string) {
statusName := C.CString("RangerMetrics")
defer C.free(unsafe.Pointer(statusName))
statusFunction := C.CString(functionName)
defer C.free(unsafe.Pointer(statusFunction))
statusParam := C.CString(data)
defer C.free(unsafe.Pointer(statusParam))
runExtensionCallback(statusName, statusFunction, statusParam)
log.Println(data)
}
//export goRVExtension
func goRVExtension(output *C.char, outputsize C.size_t, input *C.char) {
var temp string
// check if input is in AVAILABLE_FUNCTIONS
// if not, return error
// if yes, continue
if _, ok := AVAILABLE_FUNCTIONS[C.GoString(input)]; !ok {
temp = fmt.Sprintf("Function: %s not found!", C.GoString(input))
} else {
// call the function by name
reflect.ValueOf(AVAILABLE_FUNCTIONS[C.GoString(input)]).Call([]reflect.Value{})
}
// switch C.GoString(input) {
// case "version":
// logLine("goRVExtension", fmt.Sprintf(`["Input: %s", "INFO"]`, C.GoString(input)))
// temp = EXTENSION_VERSION
// case "getDir":
// logLine("goRVExtension", fmt.Sprintf(`["Input: %s", "INFO"]`, C.GoString(input)))
// temp = getDir()
// case "loadSettings":
// logLine("goRVExtension", fmt.Sprintf(`["Input: %s", "INFO"]`, C.GoString(input)))
// cwd, result, influxHost, timescaleUrl := loadSettings()
// log.Println("CWD:", cwd)
// log.Println("RESULT:", result)
// log.Println("INFLUX HOST:", influxHost)
// log.Println("TIMESCALE URL:", timescaleUrl)
// if result != "" {
// logLine("goRVExtension", result)
// temp = fmt.Sprintf(
// `["%s", "%s", "%s", "%d"]`,
// EXTENSION_VERSION,
// influxConnectionSettings.Host,
// influxConnectionSettings.Org,
// a3Settings.RefreshRateMs,
// )
// }
// case "connectToInflux":
// // logLine("goRVExtension", fmt.Sprintf(`["Input: %s", "INFO"]`, C.GoString(input)))
// go connectToInflux()
// temp = `["Connecting to InfluxDB", "INFO"]`
// case "connectToTimescale":
// // logLine("goRVExtension", fmt.Sprintf(`["Input: %s", "INFO"]`, C.GoString(input)))
// go connectToTimescale()
// temp = `["Connecting to TimescaleDB", "INFO"]`
// case "getUnixTimeNano":
// temp = fmt.Sprintf(`["%d", "INFO"]`, getUnixTimeNano())
// case "deinitialize":
// logLine("goRVExtension", fmt.Sprintf(`["Input: %s", "INFO"]`, C.GoString(input)))
// deinitialize()
// temp = `["Deinitializing", "INFO"]`
// default:
// temp = fmt.Sprintf(`["Unknown command: %s", "ERROR"]`, C.GoString(input))
// }
result := C.CString(temp)
defer C.free(unsafe.Pointer(result))
var size = C.strlen(result) + 1
if size > outputsize {
size = outputsize
}
C.memmove(unsafe.Pointer(output), unsafe.Pointer(result), size)
// return
}
//export goRVExtensionRegisterCallback
func goRVExtensionRegisterCallback(fnc C.extensionCallback) {
extensionCallbackFnc = fnc
}
func main() {}