970 lines
28 KiB
Go
970 lines
28 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 (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/ioutil"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strconv"
|
|
"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{}{
|
|
"initExtension": initExtension,
|
|
"deinitExtension": deinitExtension,
|
|
"loadSettings": loadSettings,
|
|
"connectToInflux": connectToInflux,
|
|
"writeToInflux": writeToInflux,
|
|
"connectToTimescale": connectToTimescale,
|
|
"initTimescale": initTimescale,
|
|
"writeToTimescale": writeToTimescale,
|
|
"getDir": getDir,
|
|
"sanitizeLineProtocol": sanitizeLineProtocol,
|
|
"version": version,
|
|
"getUnixTimeNano": getUnixTimeNano,
|
|
}
|
|
|
|
var EXTENSION_VERSION string = "0.0.2"
|
|
var extensionCallbackFnc C.extensionCallback
|
|
|
|
type ServerPollSetting struct {
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
ServerOnly bool `json:"serverOnly"`
|
|
IntervalMs int `json:"intervalMs"`
|
|
Bucket string `json:"bucket"`
|
|
Measurement string `json:"measurement"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
var ServerPollSettingProperties []string = []string{
|
|
"Name",
|
|
"Enabled",
|
|
"ServerOnly",
|
|
"IntervalMs",
|
|
"Bucket",
|
|
"Measurement",
|
|
"Description",
|
|
}
|
|
|
|
type CBAEventHandler struct {
|
|
Name string `json:"name"`
|
|
Enabled bool `json:"enabled"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
var CBAEventHandlerProperties []string = []string{
|
|
"Name",
|
|
"Enabled",
|
|
"Description",
|
|
}
|
|
|
|
type settingsJson struct {
|
|
Influx struct {
|
|
Enabled bool `json:"enabled"`
|
|
Host string `json:"host"`
|
|
Token string `json:"token"`
|
|
Org string `json:"org"`
|
|
} `json:"influxdb"`
|
|
Timescale struct {
|
|
Enabled bool `json:"enabled"`
|
|
ConnectionUrl string `json:"connectionUrl"`
|
|
DatabaseName string `json:"databaseName"`
|
|
} `json:"timescaledb"`
|
|
Arma3 struct {
|
|
RefreshRateMs int `json:"refreshRateMs"`
|
|
Debug bool `json:"debug"`
|
|
} `json:"arma3"`
|
|
RecordingSettings map[string]interface{} `json:"recordingSettings"`
|
|
}
|
|
|
|
var activeSettings settingsJson
|
|
|
|
// InfluxDB variables
|
|
var influxClient influxdb2.Client
|
|
|
|
// TimescaleDB variables
|
|
var timescaleDbPool *pgxpool.Pool
|
|
|
|
// file paths
|
|
var ADDON_FOLDER string = getDir() + "\\@RangerMetrics"
|
|
var LOG_FILE string = ADDON_FOLDER + "\\rangermetrics.log"
|
|
var SETTINGS_FILE string = ADDON_FOLDER + "\\settings.json"
|
|
var SETTINGS_FILE_EXAMPLE string = ADDON_FOLDER + "\\settings.example.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))
|
|
// log only to file
|
|
log.SetOutput(f)
|
|
}
|
|
|
|
func initExtension() {
|
|
functionName := "initExtension"
|
|
logLine(functionName, `["Initializing RangerMetrics extension", "INFO"]`, false)
|
|
logLine(functionName, fmt.Sprintf(`["RangerMetrics Extension Version: %v", "INFO"]`, EXTENSION_VERSION), false)
|
|
|
|
var err error
|
|
|
|
// get location of this dll
|
|
dllPath, err := filepath.Abs(os.Args[0])
|
|
if err != nil {
|
|
logLine(functionName, fmt.Sprintf(`["Error getting DLL path: %v", "ERROR"]`, err), false)
|
|
return
|
|
}
|
|
|
|
// set the addon directory to the parent directory of the dll
|
|
ADDON_FOLDER = filepath.Dir(dllPath)
|
|
LOG_FILE = ADDON_FOLDER + "\\rangermetrics.log"
|
|
SETTINGS_FILE = ADDON_FOLDER + "\\settings.json"
|
|
SETTINGS_FILE_EXAMPLE = ADDON_FOLDER + "\\settings.example.json"
|
|
|
|
activeSettings, err = loadSettings()
|
|
if err != nil {
|
|
logLine(functionName, fmt.Sprintf(`["Error loading settings: %v", "ERROR"]`, err), false)
|
|
return
|
|
}
|
|
|
|
if activeSettings.Influx.Enabled {
|
|
influxClient, err = connectToInflux()
|
|
if err != nil {
|
|
logLine(functionName, fmt.Sprintf(`["Error connecting to InfluxDB: %v", "ERROR"]`, err), false)
|
|
return
|
|
}
|
|
}
|
|
if activeSettings.Timescale.Enabled {
|
|
timescaleDbPool, err = connectToTimescale()
|
|
if err != nil {
|
|
logLine(functionName, fmt.Sprintf(`["Error connecting to TimescaleDB: %v", "ERROR"]`, err), false)
|
|
return
|
|
}
|
|
|
|
initTimescale()
|
|
}
|
|
logLine("extensionReady", `["Extension ready", "INFO"]`, false)
|
|
}
|
|
|
|
func deinitExtension() {
|
|
functionName := "deinitExtension"
|
|
logLine(functionName, `["Deinitializing RangerMetrics extension", "INFO"]`, false)
|
|
|
|
if timescaleDbPool != nil {
|
|
logLine(functionName, `["Closing TimescaleDB connection", "INFO"]`, false)
|
|
timescaleDbPool.Close()
|
|
} else {
|
|
logLine(functionName, `["TimescaleDB connection not open", "INFO"]`, false)
|
|
}
|
|
|
|
logLine(functionName, `[true]`, false)
|
|
}
|
|
|
|
// func RVExtensionContext(output *C.char, argc *C.int) {
|
|
|
|
// }
|
|
|
|
func version() {
|
|
functionName := "version"
|
|
logLine(functionName, fmt.Sprintf(`["RangerMetrics Extension Version:%s", "INFO"]`, EXTENSION_VERSION), false)
|
|
}
|
|
|
|
// return db client and error
|
|
func connectToInflux() (influxdb2.Client, error) {
|
|
if activeSettings.Influx.Host == "" ||
|
|
activeSettings.Influx.Host == "http://host:8086" {
|
|
|
|
return nil, errors.New("influxConnectionSettings.Host is empty")
|
|
// 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"
|
|
}
|
|
|
|
influxClient := influxdb2.NewClientWithOptions(activeSettings.Influx.Host, activeSettings.Influx.Token, influxdb2.DefaultOptions().SetBatchSize(1000).SetFlushInterval(1000))
|
|
|
|
return influxClient, nil
|
|
}
|
|
|
|
//////////////////////////////////
|
|
// TIMESCALE
|
|
//////////////////////////////////
|
|
|
|
func connectToTimescale() (*pgxpool.Pool, error) {
|
|
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(), activeSettings.Timescale.ConnectionUrl+"/postgres")
|
|
|
|
if err != nil {
|
|
logLine(
|
|
functionName,
|
|
fmt.Sprintf(`["Error connecting to Timescale DB: %v", "ERROR"]`, err.Error()),
|
|
false,
|
|
)
|
|
return nil, err
|
|
}
|
|
|
|
// ensure database exists
|
|
logLine(
|
|
functionName,
|
|
fmt.Sprintf(`["TimescaleDB: 'CREATE DATABASE %s'", "INFO"]`, activeSettings.Timescale.DatabaseName),
|
|
false,
|
|
)
|
|
conn.Query(context.Background(), fmt.Sprintf(`CREATE DATABASE %s`, activeSettings.Timescale.DatabaseName))
|
|
|
|
// close connection
|
|
conn.Close(context.Background())
|
|
|
|
// create connection pool
|
|
var dbPool *pgxpool.Pool
|
|
dbPool, err = pgxpool.Connect(
|
|
context.Background(),
|
|
fmt.Sprintf(`%s/%s`, activeSettings.Timescale.ConnectionUrl, activeSettings.Timescale.DatabaseName),
|
|
)
|
|
if err != nil {
|
|
logLine(
|
|
functionName,
|
|
fmt.Sprintf(`["Error connecting to Timescale DB: %v", "ERROR"]`, err.Error()),
|
|
false,
|
|
)
|
|
return nil, err
|
|
}
|
|
|
|
logLine("connectToTimescale", `["Connected to Timescale successfully", "INFO"]`, false)
|
|
return dbPool, nil
|
|
}
|
|
|
|
func initTimescale() {
|
|
|
|
functionName := "initTimescale"
|
|
var err error
|
|
|
|
// schema init sql
|
|
var tableCreationSql string = `
|
|
CREATE TABLE IF NOT EXISTS units."State"
|
|
(
|
|
"timestamp" timestamp with time zone NOT NULL,
|
|
mission_id integer NOT NULL,
|
|
net_id text COLLATE pg_catalog."default" NOT NULL,
|
|
player_uid text COLLATE pg_catalog."default" NOT NULL DEFAULT '-1'::integer,
|
|
is_alive boolean NOT NULL,
|
|
is_afk boolean,
|
|
is_speaking smallint,
|
|
unit_name text COLLATE pg_catalog."default" NOT NULL,
|
|
side text COLLATE pg_catalog."default" NOT NULL,
|
|
"position" point NOT NULL,
|
|
direction numeric(2, 0) NOT NULL,
|
|
health numeric(2, 0),
|
|
traits text[] COLLATE pg_catalog."default",
|
|
CONSTRAINT "UnitStates_pkey" PRIMARY KEY (net_id, "timestamp", player_uid, mission_id)
|
|
);
|
|
|
|
COMMENT ON TABLE units."State"
|
|
IS 'Reflects a unit''s (soldier''s) state during a mission. Can be AI (player_uid = -1) or a player.';
|
|
|
|
CREATE TABLE IF NOT EXISTS units."Identity"
|
|
(
|
|
"timestamp" timestamp with time zone NOT NULL,
|
|
mission_id integer NOT NULL,
|
|
net_id text COLLATE pg_catalog."default" NOT NULL,
|
|
player_uid text COLLATE pg_catalog."default" NOT NULL DEFAULT '-1'::integer,
|
|
class_name text COLLATE pg_catalog."default" NOT NULL,
|
|
display_name text,
|
|
side text,
|
|
role_description text COLLATE pg_catalog."default",
|
|
traits text[] COLLATE pg_catalog."default",
|
|
CONSTRAINT "Units_pkey" PRIMARY KEY (mission_id, net_id, "timestamp", player_uid)
|
|
);
|
|
|
|
COMMENT ON TABLE units."Identity"
|
|
IS 'This is polled periodically during the mission to gather static and less-dynamic traits of units (soldiers).';
|
|
|
|
CREATE TABLE IF NOT EXISTS events."Chat"
|
|
(
|
|
id bigserial NOT NULL,
|
|
"timestamp" timestamp with time zone NOT NULL,
|
|
mission_id integer NOT NULL,
|
|
player_uid text COLLATE pg_catalog."default" NOT NULL DEFAULT -1,
|
|
channel integer,
|
|
owner integer,
|
|
name text COLLATE pg_catalog."default",
|
|
"from" text COLLATE pg_catalog."default",
|
|
text text COLLATE pg_catalog."default",
|
|
"forcedDisplay" boolean,
|
|
"isPlayerMessage" boolean,
|
|
"sentenceType" integer,
|
|
"chatMessageType" integer,
|
|
CONSTRAINT "Chat_pkey" PRIMARY KEY ("timestamp", mission_id, player_uid, id),
|
|
UNIQUE (id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS missions."Played"
|
|
(
|
|
id serial NOT NULL,
|
|
start_time_utc timestamp with time zone NOT NULL,
|
|
start_time_game timestamp with time zone NOT NULL,
|
|
world_name text COLLATE pg_catalog."default" NOT NULL,
|
|
briefing_name text COLLATE pg_catalog."default" NOT NULL,
|
|
mission_name text COLLATE pg_catalog."default" NOT NULL,
|
|
mission_name_source text COLLATE pg_catalog."default" NOT NULL,
|
|
on_load_name text NOT NULL,
|
|
author text COLLATE pg_catalog."default" NOT NULL,
|
|
server_name text COLLATE pg_catalog."default" NOT NULL,
|
|
server_mods json,
|
|
ace_medical boolean,
|
|
radio_tfar boolean,
|
|
radio_acre boolean,
|
|
duration interval,
|
|
version_addon integer[] NOT NULL,
|
|
version_extension integer[] NOT NULL,
|
|
tags text[] COLLATE pg_catalog."default",
|
|
CONSTRAINT "MissionsRun_pkey" PRIMARY KEY (id, world_name, start_time_utc),
|
|
CONSTRAINT "MissionsRun_id_start_time_utc_key" UNIQUE (id, start_time_utc),
|
|
UNIQUE (id)
|
|
);
|
|
|
|
COMMENT ON TABLE missions."Played"
|
|
IS 'Contains core mission data, recorded at mission init. The IDs here are referenced by many other areas to "place" them.';
|
|
|
|
CREATE TABLE IF NOT EXISTS players."ConnectionsMission"
|
|
(
|
|
id bigserial NOT NULL,
|
|
"timestamp" timestamp with time zone NOT NULL,
|
|
mission_id integer NOT NULL,
|
|
player_uid text COLLATE pg_catalog."default" NOT NULL,
|
|
event_type text COLLATE pg_catalog."default" NOT NULL,
|
|
profile_name text COLLATE pg_catalog."default" NOT NULL,
|
|
display_name text COLLATE pg_catalog."default" NOT NULL,
|
|
steam_name text COLLATE pg_catalog."default" NOT NULL,
|
|
is_jip boolean,
|
|
CONSTRAINT "ConnectionsMission_pkey" PRIMARY KEY (player_uid, "timestamp", profile_name, mission_id, id),
|
|
UNIQUE ("timestamp", mission_id, player_uid, id)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS units."Inventory"
|
|
(
|
|
"timestamp" timestamp with time zone NOT NULL,
|
|
mission_id integer NOT NULL,
|
|
net_id text NOT NULL,
|
|
player_uid text NOT NULL DEFAULT -1,
|
|
inventory jsonb NOT NULL,
|
|
PRIMARY KEY ("timestamp", net_id, mission_id, player_uid)
|
|
);
|
|
|
|
COMMENT ON TABLE units."Inventory"
|
|
IS 'This is used to track the inventory state of units.';
|
|
|
|
CREATE TABLE IF NOT EXISTS players."ConnectionsServer"
|
|
(
|
|
"timestamp" timestamp with time zone NOT NULL,
|
|
player_uid text COLLATE pg_catalog."default" NOT NULL,
|
|
event_type text COLLATE pg_catalog."default" NOT NULL,
|
|
profile_name text COLLATE pg_catalog."default" NOT NULL,
|
|
display_name text COLLATE pg_catalog."default" NOT NULL,
|
|
steam_name text COLLATE pg_catalog."default" NOT NULL,
|
|
is_jip boolean,
|
|
CONSTRAINT "PlayersObserved_pkey" PRIMARY KEY (player_uid, "timestamp", profile_name)
|
|
);
|
|
|
|
CREATE TABLE IF NOT EXISTS missions."Environment"
|
|
(
|
|
"timestamp" timestamp with time zone NOT NULL,
|
|
mission_id integer NOT NULL,
|
|
fog numeric(2),
|
|
overcast numeric(2),
|
|
rain numeric(2),
|
|
humidity numeric(2),
|
|
waves numeric(2),
|
|
"windDir" numeric(2),
|
|
"windStr" numeric(2),
|
|
gusts numeric(2),
|
|
lightnings numeric(2),
|
|
"moonIntensity" numeric(2),
|
|
"moonPhase" numeric(2),
|
|
"sunOrMoon" numeric(2),
|
|
PRIMARY KEY ("timestamp", mission_id)
|
|
);
|
|
|
|
COMMENT ON TABLE missions."Environment"
|
|
IS 'Contains periodically collected environmental information during missions.';
|
|
|
|
CREATE TABLE IF NOT EXISTS vehicles."Identity"
|
|
(
|
|
"timestamp" timestamp with time zone NOT NULL,
|
|
mission_id integer NOT NULL,
|
|
net_id text NOT NULL,
|
|
class_name text NOT NULL,
|
|
display_name text,
|
|
customization jsonb,
|
|
weapons jsonb,
|
|
PRIMARY KEY ("timestamp", mission_id, net_id)
|
|
);
|
|
|
|
COMMENT ON TABLE vehicles."Identity"
|
|
IS 'Periodically polled identity information about a vehicle.';
|
|
|
|
CREATE TABLE IF NOT EXISTS vehicles."State"
|
|
(
|
|
"timestamp" timestamp with time zone NOT NULL,
|
|
mission_id integer NOT NULL,
|
|
net_id text NOT NULL,
|
|
is_alive boolean,
|
|
side text,
|
|
"position" point NOT NULL,
|
|
direction numeric(2),
|
|
health numeric(2),
|
|
crew text[],
|
|
PRIMARY KEY ("timestamp", mission_id, net_id)
|
|
);
|
|
|
|
COMMENT ON TABLE vehicles."State"
|
|
IS 'State information about vehicles. Crew is an array of net_id to be referenced against the Unit tables.';
|
|
|
|
CREATE TABLE IF NOT EXISTS players."AllObserved"
|
|
(
|
|
id serial NOT NULL,
|
|
player_uid text NOT NULL,
|
|
PRIMARY KEY (id, player_uid),
|
|
UNIQUE (player_uid)
|
|
);
|
|
`
|
|
|
|
relationCreationSql := []string{
|
|
`ALTER TABLE IF EXISTS units."State"
|
|
ADD FOREIGN KEY (mission_id)
|
|
REFERENCES missions."Played" (id) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS units."State"
|
|
ADD FOREIGN KEY (player_uid)
|
|
REFERENCES players."AllObserved" (player_uid) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS units."Identity"
|
|
ADD FOREIGN KEY (mission_id)
|
|
REFERENCES missions."Played" (id) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS units."Identity"
|
|
ADD FOREIGN KEY (player_uid)
|
|
REFERENCES players."AllObserved" (player_uid) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS events."Chat"
|
|
ADD FOREIGN KEY (mission_id)
|
|
REFERENCES missions."Played" (id) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS events."Chat"
|
|
ADD FOREIGN KEY (player_uid)
|
|
REFERENCES players."AllObserved" (player_uid) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS players."ConnectionsMission"
|
|
ADD FOREIGN KEY (mission_id)
|
|
REFERENCES missions."Played" (id) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS players."ConnectionsMission"
|
|
ADD FOREIGN KEY (player_uid)
|
|
REFERENCES players."AllObserved" (player_uid) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS units."Inventory"
|
|
ADD FOREIGN KEY (mission_id)
|
|
REFERENCES missions."Played" (id) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS units."Inventory"
|
|
ADD FOREIGN KEY (player_uid)
|
|
REFERENCES players."AllObserved" (player_uid) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS missions."Environment"
|
|
ADD FOREIGN KEY (mission_id)
|
|
REFERENCES missions."Played" (id) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS vehicles."Identity"
|
|
ADD FOREIGN KEY (mission_id)
|
|
REFERENCES missions."Played" (id) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
|
|
`ALTER TABLE IF EXISTS vehicles."State"
|
|
ADD FOREIGN KEY (mission_id)
|
|
REFERENCES missions."Played" (id) MATCH SIMPLE
|
|
ON UPDATE NO ACTION
|
|
ON DELETE NO ACTION
|
|
NOT VALID;
|
|
`,
|
|
}
|
|
|
|
var tx pgx.Tx
|
|
tx, err = timescaleDbPool.Begin(context.Background())
|
|
if err != nil {
|
|
logLine(functionName, fmt.Sprintf(`["Error creating transaction: %v", "ERROR"]`, err.Error()), false)
|
|
return
|
|
}
|
|
|
|
_, err = tx.Exec(context.Background(), tableCreationSql)
|
|
if err != nil {
|
|
logLine(functionName, fmt.Sprintf(`["Error creating tables: %v", "ERROR"]`, err.Error()), false)
|
|
tx.Rollback(context.Background())
|
|
tx.Commit(context.Background())
|
|
return
|
|
}
|
|
err = tx.Commit(context.Background())
|
|
if err != nil {
|
|
logLine(functionName, fmt.Sprintf(`["Error committing table creation transaction: %v", "ERROR"]`, err.Error()), false)
|
|
return
|
|
}
|
|
|
|
// run each relation creation sql statement
|
|
for _, sql := range relationCreationSql {
|
|
_, err = tx.Exec(context.Background(), sql)
|
|
|
|
if err != nil {
|
|
logLine(functionName, fmt.Sprintf(`["Error creating relation: %v", "ERROR"]`, err.Error()), false)
|
|
tx.Rollback(context.Background())
|
|
tx.Commit(context.Background())
|
|
return
|
|
}
|
|
}
|
|
|
|
err = tx.Commit(context.Background())
|
|
if err != nil {
|
|
logLine(functionName, fmt.Sprintf(`["Error committing relation creation transaction: %v", "ERROR"]`, err.Error()), false)
|
|
return
|
|
}
|
|
|
|
logLine(functionName, `["Timescale schema initialized", "INFO"]`, false)
|
|
|
|
}
|
|
|
|
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()), false)
|
|
}
|
|
}
|
|
|
|
func writeToInflux(a3DataRaw *[]string) string {
|
|
|
|
// convert to string array
|
|
a3Data := *a3DataRaw
|
|
|
|
logLine("writeToInflux", fmt.Sprintf(`["Received %d params", "DEBUG"]`, len(a3Data)), true)
|
|
|
|
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, false)
|
|
return logData
|
|
}
|
|
|
|
// use custom bucket or default
|
|
var bucket string = fixEscapeQuotes(trimQuotes(string(a3Data[0])))
|
|
|
|
// Get non-blocking write client
|
|
WRITE_API := influxClient.WriteAPI(activeSettings.Influx.Org, bucket)
|
|
|
|
if WRITE_API == nil {
|
|
logData = `["Error creating write API", "ERROR"]`
|
|
logLine(functionName, logData, false)
|
|
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, false)
|
|
}
|
|
}()
|
|
|
|
// 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", "DEBUG"]`, len(a3Data)-1)
|
|
logLine(functionName, logData, true)
|
|
|
|
return "Success"
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// return true if the program should continue
|
|
func loadSettings() (settingsJson, error) {
|
|
functionName := "loadSettings"
|
|
logLine(functionName, fmt.Sprintf(`["ADDON_FOLDER: %s", "INFO"]`, ADDON_FOLDER), false)
|
|
logLine(functionName, fmt.Sprintf(`["LOG_FILE: %s", "INFO"]`, LOG_FILE), false)
|
|
logLine(functionName, fmt.Sprintf(`["SETTINGS_FILE: %s", "INFO"]`, SETTINGS_FILE), false)
|
|
|
|
settings := settingsJson{}
|
|
|
|
// 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
|
|
logLine(
|
|
functionName,
|
|
fmt.Sprintf(`["%s does not exist", "ERROR"]`, SETTINGS_FILE),
|
|
false,
|
|
)
|
|
// copy settings.json.example to settings.json
|
|
// load contents
|
|
fileContents, err := ioutil.ReadFile(SETTINGS_FILE_EXAMPLE)
|
|
if err != nil {
|
|
return settings, err
|
|
}
|
|
// write contents to settings.json
|
|
err = ioutil.WriteFile(SETTINGS_FILE, fileContents, 0644)
|
|
if err != nil {
|
|
return settings, err
|
|
}
|
|
|
|
// Exit false to discontinue initialization since settings are defaults
|
|
logLine(functionName, `["CREATED SETTINGS"]`, false)
|
|
// return a new error
|
|
return settings, errors.New("settings.json does not exist")
|
|
} else {
|
|
// file exists
|
|
logLine(functionName, `["settings.json found", "DEBUG"]`, true)
|
|
// read the file
|
|
file, err = os.Open(SETTINGS_FILE)
|
|
if err != nil {
|
|
return settings, err
|
|
}
|
|
defer file.Close()
|
|
decoder := json.NewDecoder(file)
|
|
err = decoder.Decode(&settings)
|
|
if err != nil {
|
|
return settings, err
|
|
}
|
|
|
|
// send contents of settings file
|
|
// get the file contents
|
|
fileContents, err := ioutil.ReadFile(SETTINGS_FILE)
|
|
if err != nil {
|
|
return settings, err
|
|
}
|
|
|
|
// compact the json
|
|
var jsonStr bytes.Buffer
|
|
err = json.Compact(&jsonStr, fileContents)
|
|
if err != nil {
|
|
return settings, err
|
|
}
|
|
|
|
logLine(
|
|
"loadSettingsJSON",
|
|
jsonStr.String(),
|
|
false,
|
|
)
|
|
|
|
}
|
|
|
|
return settings, nil
|
|
}
|
|
|
|
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 logLine(functionName string, data string, isDebug bool) {
|
|
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)
|
|
|
|
if activeSettings.Arma3.Debug && isDebug {
|
|
log.Println(data)
|
|
} else if !isDebug {
|
|
log.Println(data)
|
|
}
|
|
}
|
|
|
|
//export goRVExtension
|
|
func goRVExtension(output *C.char, outputsize C.size_t, input *C.char) {
|
|
|
|
var temp string
|
|
|
|
// logLine("goRVExtension", fmt.Sprintf(`["Input: %s", "DEBUG"]`, C.GoString(input)), true)
|
|
|
|
switch C.GoString(input) {
|
|
case "version":
|
|
temp = EXTENSION_VERSION
|
|
case "getDir":
|
|
temp = getDir()
|
|
case "getUnixTimeNano":
|
|
time := getUnixTimeNano()
|
|
temp = fmt.Sprintf(`["%s"]`, strconv.FormatInt(time, 10))
|
|
|
|
default:
|
|
// 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!", "ERROR"]`, C.GoString(input))
|
|
} else {
|
|
// call the function by name
|
|
go reflect.ValueOf(AVAILABLE_FUNCTIONS[C.GoString(input)]).Call([]reflect.Value{})
|
|
temp = fmt.Sprintf(`["Function: %s called successfully", "DEBUG"]`, C.GoString(input))
|
|
}
|
|
}
|
|
|
|
// 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() {}
|