diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..660f988 --- /dev/null +++ b/config_test.go @@ -0,0 +1,561 @@ +package main + +import ( + "os" + "path/filepath" + "testing" +) + +// TestConfigLoadFromJSONFile tests loading config from a valid JSON file +func TestConfigLoadFromJSONFile(t *testing.T) { + // Save original environment + originalConfigFile := os.Getenv("CONFIG_FILE") + defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }() + + // Set CONFIG_FILE to our test data + testConfigPath := filepath.Join("testdata", "test_config.json") + absPath, err := filepath.Abs(testConfigPath) + if err != nil { + t.Fatalf("Failed to get absolute path: %v", err) + } + os.Setenv("CONFIG_FILE", absPath) + + // Load config from JSON file + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() failed: %v", err) + } + + // Verify all fields loaded correctly from JSON + if cfg.DB != "postgres://user:password@localhost:5432/opendtu" { + t.Errorf("Expected DB from JSON, got %v", cfg.DB) + } + if cfg.OpenDTUAddress != "192.168.1.100" { + t.Errorf("Expected OpenDTUAddress from JSON, got %v", cfg.OpenDTUAddress) + } + if !cfg.OpenDTUAuth { + t.Errorf("Expected OpenDTUAuth=true from JSON") + } + if cfg.OpenDTUUser != "admin" { + t.Errorf("Expected OpenDTUUser from JSON, got %v", cfg.OpenDTUUser) + } + if cfg.OpenDTUPassword != "secret123" { + t.Errorf("Expected OpenDTUPassword from JSON, got %v", cfg.OpenDTUPassword) + } + if !cfg.TimescaleDB { + t.Errorf("Expected TimescaleDB=true from JSON") + } + if cfg.TZ != "Europe/Amsterdam" { + t.Errorf("Expected TZ from JSON, got %v", cfg.TZ) + } + if cfg.LogLevel != "INFO" { + t.Errorf("Expected LogLevel from JSON, got %v", cfg.LogLevel) + } +} + +// TestConfigLoadFromJSONFile_NoAuth tests loading config from JSON without auth +func TestConfigLoadFromJSONFile_NoAuth(t *testing.T) { + // Create a temporary config file without auth + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config_noauth.json") + + configContent := `{ + "db": "postgres://localhost/testdb", + "opendtu_address": "192.168.1.200", + "opendtu_auth": false, + "timescaledb": false, + "tz": "UTC", + "log_level": "DEBUG" + }` + + err := os.WriteFile(tmpFile, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write temp config: %v", err) + } + + // Save original environment + originalConfigFile := os.Getenv("CONFIG_FILE") + defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }() + + os.Setenv("CONFIG_FILE", tmpFile) + + // Load config + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() failed: %v", err) + } + + // Verify fields + if cfg.DB != "postgres://localhost/testdb" { + t.Errorf("Expected DB from JSON, got %v", cfg.DB) + } + if cfg.OpenDTUAddress != "192.168.1.200" { + t.Errorf("Expected OpenDTUAddress from JSON, got %v", cfg.OpenDTUAddress) + } + if cfg.OpenDTUAuth { + t.Errorf("Expected OpenDTUAuth=false from JSON") + } + if cfg.TimescaleDB { + t.Errorf("Expected TimescaleDB=false from JSON") + } +} + +// TestConfigLoadFromJSONFile_InvalidTimezone tests the timezone validation +func TestConfigLoadFromJSONFile_InvalidTimezone(t *testing.T) { + // Create a temporary config file with invalid timezone + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "config_badtz.json") + + configContent := `{ + "db": "postgres://localhost/testdb", + "opendtu_address": "192.168.1.200", + "opendtu_auth": false, + "tz": "Invalid/Timezone" + }` + + err := os.WriteFile(tmpFile, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write temp config: %v", err) + } + + // Save original environment + originalConfigFile := os.Getenv("CONFIG_FILE") + defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }() + + os.Setenv("CONFIG_FILE", tmpFile) + + // Load config - should not fatal, just log warning + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() failed: %v", err) + } + + // Should still load other fields successfully + if cfg.DB != "postgres://localhost/testdb" { + t.Errorf("Expected DB to load despite invalid timezone") + } +} + +// TestConfigLoadFromEnv_WithTimescaleDB tests env var path with TimescaleDB enabled +func TestConfigLoadFromEnv_WithTimescaleDB(t *testing.T) { + // Save original environment + originalVars := map[string]string{ + "CONFIG_FILE": os.Getenv("CONFIG_FILE"), + "DB_URL": os.Getenv("DB_URL"), + "OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"), + "OPENDTU_AUTH": os.Getenv("OPENDTU_AUTH"), + "TIMESCALEDB_ENABLED": os.Getenv("TIMESCALEDB_ENABLED"), + "TZ": os.Getenv("TZ"), + "LOG_LEVEL": os.Getenv("LOG_LEVEL"), + } + defer func() { + for k, v := range originalVars { + os.Setenv(k, v) + } + }() + + // Set environment for non-existent config file + os.Setenv("CONFIG_FILE", "/nonexistent/config.json") + os.Setenv("DB_URL", "postgres://testhost/testdb") + os.Setenv("OPENDTU_ADDRESS", "10.0.0.1") + os.Setenv("OPENDTU_AUTH", "false") + os.Setenv("TIMESCALEDB_ENABLED", "true") + os.Setenv("TZ", "America/New_York") + os.Setenv("LOG_LEVEL", "WARN") + + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() failed: %v", err) + } + + if cfg.DB != "postgres://testhost/testdb" { + t.Errorf("Expected DB from env, got %v", cfg.DB) + } + if !cfg.TimescaleDB { + t.Errorf("Expected TimescaleDB=true from env") + } + if cfg.TZ != "America/New_York" { + t.Errorf("Expected TZ from env, got %v", cfg.TZ) + } + if cfg.LogLevel != "WARN" { + t.Errorf("Expected LogLevel from env, got %v", cfg.LogLevel) + } +} + +// TestConfigLoadFromEnv_WithAuth tests env var path with auth enabled +func TestConfigLoadFromEnv_WithAuth(t *testing.T) { + // Save original environment + originalVars := map[string]string{ + "CONFIG_FILE": os.Getenv("CONFIG_FILE"), + "DB_URL": os.Getenv("DB_URL"), + "OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"), + "OPENDTU_AUTH": os.Getenv("OPENDTU_AUTH"), + "OPENDTU_USERNAME": os.Getenv("OPENDTU_USERNAME"), + "OPENDTU_PASSWORD": os.Getenv("OPENDTU_PASSWORD"), + } + defer func() { + for k, v := range originalVars { + os.Setenv(k, v) + } + }() + + // Set environment with auth enabled + os.Setenv("CONFIG_FILE", "/nonexistent/config.json") + os.Setenv("DB_URL", "postgres://testhost/testdb") + os.Setenv("OPENDTU_ADDRESS", "10.0.0.1") + os.Setenv("OPENDTU_AUTH", "true") + os.Setenv("OPENDTU_USERNAME", "testuser") + os.Setenv("OPENDTU_PASSWORD", "testpass") + + cfg, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() failed: %v", err) + } + + if !cfg.OpenDTUAuth { + t.Errorf("Expected OpenDTUAuth=true from env") + } + if cfg.OpenDTUUser != "testuser" { + t.Errorf("Expected OpenDTUUser from env, got %v", cfg.OpenDTUUser) + } + if cfg.OpenDTUPassword != "testpass" { + t.Errorf("Expected OpenDTUPassword from env, got %v", cfg.OpenDTUPassword) + } +} + +// TestConfigLoadFromJSONFile_InvalidJSON tests error when JSON is malformed +func TestConfigLoadFromJSONFile_InvalidJSON(t *testing.T) { + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "invalid.json") + + // Write invalid JSON + err := os.WriteFile(tmpFile, []byte("{invalid json}"), 0644) + if err != nil { + t.Fatalf("Failed to write temp config: %v", err) + } + + originalConfigFile := os.Getenv("CONFIG_FILE") + defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }() + os.Setenv("CONFIG_FILE", tmpFile) + + _, err = loadConfig() + if err == nil { + t.Error("Expected error for invalid JSON, got nil") + } + if err != nil && !contains(err.Error(), "error parsing config file") { + t.Errorf("Expected 'error parsing config file' error, got: %v", err) + } +} + +// TestConfigLoadFromJSONFile_MissingDB tests error when DB is not set in JSON +func TestConfigLoadFromJSONFile_MissingDB(t *testing.T) { + // Reset global config to avoid test pollution + config = Config{} + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "no_db.json") + + configContent := `{ + "opendtu_address": "192.168.1.100" + }` + + err := os.WriteFile(tmpFile, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write temp config: %v", err) + } + + originalConfigFile := os.Getenv("CONFIG_FILE") + defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }() + os.Setenv("CONFIG_FILE", tmpFile) + + _, err = loadConfig() + if err == nil { + t.Error("Expected error for missing DB, got nil") + } + if err != nil && !contains(err.Error(), "db connection settings are not set") { + t.Errorf("Expected 'db connection settings' error, got: %v", err) + } +} + +// TestConfigLoadFromJSONFile_MissingOpenDTUAddress tests error when opendtu_address is not set +func TestConfigLoadFromJSONFile_MissingOpenDTUAddress(t *testing.T) { + // Reset global config to avoid test pollution + config = Config{} + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "no_address.json") + + configContent := `{ + "db": "postgres://localhost/testdb" + }` + + err := os.WriteFile(tmpFile, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write temp config: %v", err) + } + + originalConfigFile := os.Getenv("CONFIG_FILE") + defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }() + os.Setenv("CONFIG_FILE", tmpFile) + + _, err = loadConfig() + if err == nil { + t.Error("Expected error for missing OpenDTU address, got nil") + } + if err != nil && !contains(err.Error(), "opendtu_address is not set") { + t.Errorf("Expected 'opendtu_address is not set' error, got: %v", err) + } +} + +// TestConfigLoadFromJSONFile_MissingUsername tests error when username is missing with auth enabled +func TestConfigLoadFromJSONFile_MissingUsername(t *testing.T) { + // Reset global config to avoid test pollution + config = Config{} + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "no_username.json") + + configContent := `{ + "db": "postgres://localhost/testdb", + "opendtu_address": "192.168.1.100", + "opendtu_auth": true, + "opendtu_password": "secret" + }` + + err := os.WriteFile(tmpFile, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write temp config: %v", err) + } + + originalConfigFile := os.Getenv("CONFIG_FILE") + defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }() + os.Setenv("CONFIG_FILE", tmpFile) + + _, err = loadConfig() + if err == nil { + t.Error("Expected error for missing username, got nil") + } + if err != nil && !contains(err.Error(), "opendtu_username is not set") { + t.Errorf("Expected 'opendtu_username is not set' error, got: %v", err) + } +} + +// TestConfigLoadFromJSONFile_MissingPassword tests error when password is missing with auth enabled +func TestConfigLoadFromJSONFile_MissingPassword(t *testing.T) { + // Reset global config to avoid test pollution + config = Config{} + + tmpDir := t.TempDir() + tmpFile := filepath.Join(tmpDir, "no_password.json") + + configContent := `{ + "db": "postgres://localhost/testdb", + "opendtu_address": "192.168.1.100", + "opendtu_auth": true, + "opendtu_username": "admin" + }` + + err := os.WriteFile(tmpFile, []byte(configContent), 0644) + if err != nil { + t.Fatalf("Failed to write temp config: %v", err) + } + + originalConfigFile := os.Getenv("CONFIG_FILE") + defer func() { os.Setenv("CONFIG_FILE", originalConfigFile) }() + os.Setenv("CONFIG_FILE", tmpFile) + + _, err = loadConfig() + if err == nil { + t.Error("Expected error for missing password, got nil") + } + if err != nil && !contains(err.Error(), "opendtu_password is not set") { + t.Errorf("Expected 'opendtu_password is not set' error, got: %v", err) + } +} + +// TestConfigLoadFromEnv_MissingDBURL tests error when DB_URL env var is missing +func TestConfigLoadFromEnv_MissingDBURL(t *testing.T) { + originalVars := map[string]string{ + "CONFIG_FILE": os.Getenv("CONFIG_FILE"), + "DB_URL": os.Getenv("DB_URL"), + "OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"), + } + defer func() { + for k, v := range originalVars { + os.Setenv(k, v) + } + }() + + os.Setenv("CONFIG_FILE", "/nonexistent/config.json") + os.Unsetenv("DB_URL") + os.Setenv("OPENDTU_ADDRESS", "192.168.1.100") + + _, err := loadConfig() + if err == nil { + t.Error("Expected error for missing DB_URL, got nil") + } + if err != nil && !contains(err.Error(), "DB_URL environment variable is not set") { + t.Errorf("Expected 'DB_URL environment variable is not set' error, got: %v", err) + } +} + +// TestConfigLoadFromEnv_MissingOpenDTUAddress tests error when OPENDTU_ADDRESS is missing +func TestConfigLoadFromEnv_MissingOpenDTUAddress(t *testing.T) { + originalVars := map[string]string{ + "CONFIG_FILE": os.Getenv("CONFIG_FILE"), + "DB_URL": os.Getenv("DB_URL"), + "OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"), + } + defer func() { + for k, v := range originalVars { + os.Setenv(k, v) + } + }() + + os.Setenv("CONFIG_FILE", "/nonexistent/config.json") + os.Setenv("DB_URL", "postgres://localhost/testdb") + os.Unsetenv("OPENDTU_ADDRESS") + + _, err := loadConfig() + if err == nil { + t.Error("Expected error for missing OPENDTU_ADDRESS, got nil") + } + if err != nil && !contains(err.Error(), "OPENDTU_ADDRESS environment variable is not set") { + t.Errorf("Expected 'OPENDTU_ADDRESS environment variable is not set' error, got: %v", err) + } +} + +// TestConfigLoadFromEnv_InvalidAuthBool tests error when OPENDTU_AUTH has invalid boolean +func TestConfigLoadFromEnv_InvalidAuthBool(t *testing.T) { + originalVars := map[string]string{ + "CONFIG_FILE": os.Getenv("CONFIG_FILE"), + "DB_URL": os.Getenv("DB_URL"), + "OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"), + "OPENDTU_AUTH": os.Getenv("OPENDTU_AUTH"), + } + defer func() { + for k, v := range originalVars { + os.Setenv(k, v) + } + }() + + os.Setenv("CONFIG_FILE", "/nonexistent/config.json") + os.Setenv("DB_URL", "postgres://localhost/testdb") + os.Setenv("OPENDTU_ADDRESS", "192.168.1.100") + os.Setenv("OPENDTU_AUTH", "invalid") + + _, err := loadConfig() + if err == nil { + t.Error("Expected error for invalid OPENDTU_AUTH, got nil") + } + if err != nil && !contains(err.Error(), "error parsing OPENDTU_AUTH") { + t.Errorf("Expected 'error parsing OPENDTU_AUTH' error, got: %v", err) + } +} + +// TestConfigLoadFromEnv_MissingUsername tests error when OPENDTU_USERNAME is missing with auth +func TestConfigLoadFromEnv_MissingUsername(t *testing.T) { + originalVars := map[string]string{ + "CONFIG_FILE": os.Getenv("CONFIG_FILE"), + "DB_URL": os.Getenv("DB_URL"), + "OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"), + "OPENDTU_AUTH": os.Getenv("OPENDTU_AUTH"), + "OPENDTU_USERNAME": os.Getenv("OPENDTU_USERNAME"), + } + defer func() { + for k, v := range originalVars { + os.Setenv(k, v) + } + }() + + os.Setenv("CONFIG_FILE", "/nonexistent/config.json") + os.Setenv("DB_URL", "postgres://localhost/testdb") + os.Setenv("OPENDTU_ADDRESS", "192.168.1.100") + os.Setenv("OPENDTU_AUTH", "true") + os.Unsetenv("OPENDTU_USERNAME") + os.Setenv("OPENDTU_PASSWORD", "secret") + + _, err := loadConfig() + if err == nil { + t.Error("Expected error for missing OPENDTU_USERNAME, got nil") + } + if err != nil && !contains(err.Error(), "OPENDTU_USERNAME environment variable is not set") { + t.Errorf("Expected 'OPENDTU_USERNAME environment variable is not set' error, got: %v", err) + } +} + +// TestConfigLoadFromEnv_MissingPassword tests error when OPENDTU_PASSWORD is missing with auth +func TestConfigLoadFromEnv_MissingPassword(t *testing.T) { + originalVars := map[string]string{ + "CONFIG_FILE": os.Getenv("CONFIG_FILE"), + "DB_URL": os.Getenv("DB_URL"), + "OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"), + "OPENDTU_AUTH": os.Getenv("OPENDTU_AUTH"), + "OPENDTU_USERNAME": os.Getenv("OPENDTU_USERNAME"), + "OPENDTU_PASSWORD": os.Getenv("OPENDTU_PASSWORD"), + } + defer func() { + for k, v := range originalVars { + os.Setenv(k, v) + } + }() + + os.Setenv("CONFIG_FILE", "/nonexistent/config.json") + os.Setenv("DB_URL", "postgres://localhost/testdb") + os.Setenv("OPENDTU_ADDRESS", "192.168.1.100") + os.Setenv("OPENDTU_AUTH", "true") + os.Setenv("OPENDTU_USERNAME", "admin") + os.Unsetenv("OPENDTU_PASSWORD") + + _, err := loadConfig() + if err == nil { + t.Error("Expected error for missing OPENDTU_PASSWORD, got nil") + } + if err != nil && !contains(err.Error(), "OPENDTU_PASSWORD environment variable is not set") { + t.Errorf("Expected 'OPENDTU_PASSWORD environment variable is not set' error, got: %v", err) + } +} + +// TestConfigLoadFromEnv_InvalidTimescaleDBBool tests error when TIMESCALEDB_ENABLED has invalid boolean +func TestConfigLoadFromEnv_InvalidTimescaleDBBool(t *testing.T) { + originalVars := map[string]string{ + "CONFIG_FILE": os.Getenv("CONFIG_FILE"), + "DB_URL": os.Getenv("DB_URL"), + "OPENDTU_ADDRESS": os.Getenv("OPENDTU_ADDRESS"), + "OPENDTU_AUTH": os.Getenv("OPENDTU_AUTH"), + "TIMESCALEDB_ENABLED": os.Getenv("TIMESCALEDB_ENABLED"), + } + defer func() { + for k, v := range originalVars { + os.Setenv(k, v) + } + }() + + os.Setenv("CONFIG_FILE", "/nonexistent/config.json") + os.Setenv("DB_URL", "postgres://localhost/testdb") + os.Setenv("OPENDTU_ADDRESS", "192.168.1.100") + os.Setenv("OPENDTU_AUTH", "false") // Set auth to false so we don't need username/password + os.Setenv("TIMESCALEDB_ENABLED", "not-a-bool") + + _, err := loadConfig() + if err == nil { + t.Error("Expected error for invalid TIMESCALEDB_ENABLED, got nil") + } + if err != nil && !contains(err.Error(), "error parsing TIMESCALEDB_ENABLED") { + t.Errorf("Expected 'error parsing TIMESCALEDB_ENABLED' error, got: %v", err) + } +} + +// Helper function to check if a string contains a substring +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > len(substr) && + (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr || + func() bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false + }())) +} diff --git a/go.mod b/go.mod index 69fb927..1ef8362 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( ) require ( + github.com/DATA-DOG/go-sqlmock v1.5.2 // indirect github.com/mfridman/interpolate v0.0.2 // indirect github.com/sethvargo/go-retry v0.3.0 // indirect go.uber.org/multierr v1.11.0 // indirect diff --git a/go.sum b/go.sum index 0760f47..baa2f8b 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -6,6 +8,7 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= diff --git a/integration_test.go b/integration_test.go new file mode 100644 index 0000000..a0c16c7 --- /dev/null +++ b/integration_test.go @@ -0,0 +1,466 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" +) + +func loadTestData(filename string) ([]byte, error) { + return os.ReadFile(filepath.Join("testdata", filename)) +} + +func TestLiveDataFromFile(t *testing.T) { + // Test producing data + data, err := loadTestData("livedata_producing.json") + if err != nil { + t.Fatalf("Failed to load test data: %v", err) + } + + var liveData LiveData + err = json.Unmarshal(data, &liveData) + if err != nil { + t.Fatalf("Failed to unmarshal LiveData: %v", err) + } + + // Validate the structure + if len(liveData.Inverters) != 2 { + t.Errorf("Expected 2 inverters, got %v", len(liveData.Inverters)) + } + + inverter := liveData.Inverters[0] + if inverter.Serial != "114173123456" { + t.Errorf("Expected first inverter serial 114173123456, got %v", inverter.Serial) + } + if !inverter.Producing { + t.Errorf("Expected first inverter to be producing") + } + if !inverter.Reachable { + t.Errorf("Expected first inverter to be reachable") + } + + // Test new OpenDTU fields + if inverter.Order != 0 { + t.Errorf("Expected first inverter Order=0, got %v", inverter.Order) + } + if inverter.DataAge != 0 { + t.Errorf("Expected first inverter DataAge=0, got %v", inverter.DataAge) + } + if inverter.DataAgeMs != 124 { + t.Errorf("Expected first inverter DataAgeMs=124, got %v", inverter.DataAgeMs) + } + + // Test radio_stats from OpenDTU + if inverter.RadioStats.TxRequest != 12345 { + t.Errorf("Expected RadioStats.TxRequest=12345, got %v", inverter.RadioStats.TxRequest) + } + if inverter.RadioStats.RxSuccess != 12000 { + t.Errorf("Expected RadioStats.RxSuccess=12000, got %v", inverter.RadioStats.RxSuccess) + } + if inverter.RadioStats.RSSI != -65.5 { + t.Errorf("Expected RadioStats.RSSI=-65.5, got %v", inverter.RadioStats.RSSI) + } + + // Test second inverter has correct order + if liveData.Inverters[1].Order != 1 { + t.Errorf("Expected second inverter Order=1, got %v", liveData.Inverters[1].Order) + } + + // Test Hints with pin_mapping_issue + if !liveData.Hints.TimeSync { + t.Errorf("Expected Hints.TimeSync=true, got %v", liveData.Hints.TimeSync) + } + if liveData.Hints.RadioProblem { + t.Errorf("Expected Hints.RadioProblem=false, got %v", liveData.Hints.RadioProblem) + } + if liveData.Hints.PinMappingIssue { + t.Errorf("Expected Hints.PinMappingIssue=false, got %v", liveData.Hints.PinMappingIssue) + } + + // Check total power + if liveData.Total.Power.V != 1276.9 { + t.Errorf("Expected total power 1276.9W, got %v", liveData.Total.Power.V) + } + + // Test night data + data, err = loadTestData("livedata_night.json") + if err != nil { + t.Fatalf("Failed to load night test data: %v", err) + } + + err = json.Unmarshal(data, &liveData) + if err != nil { + t.Fatalf("Failed to unmarshal night LiveData: %v", err) + } + + if len(liveData.Inverters) != 1 { + t.Errorf("Expected 1 inverter in night data, got %v", len(liveData.Inverters)) + } + + inverter = liveData.Inverters[0] + if inverter.Producing { + t.Errorf("Expected inverter not to be producing at night") + } + if liveData.Total.Power.V != 0.0 { + t.Errorf("Expected total power 0W at night, got %v", liveData.Total.Power.V) + } + if liveData.Total.YieldTotal.V != 16253.99 { + t.Errorf("Expected total YieldTotal 16253.99kWh, got %v", liveData.Total.YieldTotal.V) + } + + // Test that DataAge is old at night (no recent data) + if inverter.DataAge != 20840 { + t.Errorf("Expected DataAge=20840 (old data at night), got %v", inverter.DataAge) + } + if inverter.DataAgeMs != 20840477 { + t.Errorf("Expected DataAgeMs=20840477, got %v", inverter.DataAgeMs) + } + if inverter.LimitAbsolute != 2250 { + t.Errorf("Expected LimitAbsolute=2250, got %v", inverter.LimitAbsolute) + } + if inverter.PollEnabled { + t.Errorf("Expected PollEnabled=false at night") + } + if inverter.Reachable { + t.Errorf("Expected Reachable=false at night") + } + + if len(inverter.DC) != 6 { + t.Errorf("Expected 6 DC strings, got %d", len(inverter.DC)) + } + if inverter.DC["5"].Irradiation.Max != 440 { + t.Errorf("Expected Irradiation max 440 for string 5, got %d", inverter.DC["5"].Irradiation.Max) + } + if inverter.RadioStats.TxRequest != 147 { + t.Errorf("Expected TxRequest=147, got %v", inverter.RadioStats.TxRequest) + } + if inverter.RadioStats.RxFailNothing != 147 { + t.Errorf("Expected RxFailNothing=147, got %v", inverter.RadioStats.RxFailNothing) + } + if inverter.RadioStats.RSSI != -62 { + t.Errorf("Expected RSSI=-62, got %v", inverter.RadioStats.RSSI) + } + + // Test warning data with limit fallback and hints + data, err = loadTestData("livedata_warnings.json") + if err != nil { + t.Fatalf("Failed to load warning test data: %v", err) + } + + err = json.Unmarshal(data, &liveData) + if err != nil { + t.Fatalf("Failed to unmarshal warning LiveData: %v", err) + } + + if len(liveData.Inverters) != 1 { + t.Fatalf("Expected 1 inverter in warning data, got %d", len(liveData.Inverters)) + } + + inverter = liveData.Inverters[0] + if inverter.LimitAbsolute != -1 { + t.Errorf("Expected LimitAbsolute=-1 fallback, got %v", inverter.LimitAbsolute) + } + if inverter.Events != -1 { + t.Errorf("Expected Events=-1 when event log missing, got %v", inverter.Events) + } + if !inverter.PollEnabled { + t.Errorf("Expected PollEnabled=true in warning data") + } + if inverter.Reachable { + t.Errorf("Expected Reachable=false in warning data") + } + if inverter.RadioStats.RxFailNothing != 12 { + t.Errorf("Expected RxFailNothing=12, got %v", inverter.RadioStats.RxFailNothing) + } + + if liveData.Hints.TimeSync { + t.Errorf("Expected Hints.TimeSync=false, got %v", liveData.Hints.TimeSync) + } + if !liveData.Hints.RadioProblem { + t.Errorf("Expected Hints.RadioProblem=true, got %v", liveData.Hints.RadioProblem) + } + if !liveData.Hints.DefaultPassword { + t.Errorf("Expected Hints.DefaultPassword=true, got %v", liveData.Hints.DefaultPassword) + } + if !liveData.Hints.PinMappingIssue { + t.Errorf("Expected Hints.PinMappingIssue=true, got %v", liveData.Hints.PinMappingIssue) + } + + var generic map[string]interface{} + if err := json.Unmarshal(data, &generic); err != nil { + t.Fatalf("Failed to unmarshal warning fixture generically: %v", err) + } + invertersValue, ok := generic["inverters"].([]interface{}) + if !ok || len(invertersValue) == 0 { + t.Fatalf("Generic inverter array missing") + } + invMap, ok := invertersValue[0].(map[string]interface{}) + if !ok { + t.Fatalf("Generic inverter object missing") + } + if _, exists := invMap["BAT"]; !exists { + t.Errorf("Expected additional channel map 'BAT' to be present in fixture") + } +} + +func TestEventsResponseFromFile(t *testing.T) { + data, err := loadTestData("events_response.json") + if err != nil { + t.Fatalf("Failed to load events test data: %v", err) + } + + var eventsResponse EventsResponse + err = json.Unmarshal(data, &eventsResponse) + if err != nil { + t.Fatalf("Failed to unmarshal EventsResponse: %v", err) + } + + if eventsResponse.Count != 3 { + t.Errorf("Expected 3 events, got %v", eventsResponse.Count) + } + + if len(eventsResponse.Events) != 3 { + t.Errorf("Expected 3 events in array, got %v", len(eventsResponse.Events)) + } + + // Check first event + firstEvent := eventsResponse.Events[0] + if firstEvent.MessageID != 1 { + t.Errorf("Expected first event MessageID 1, got %v", firstEvent.MessageID) + } + if firstEvent.Message != "Inverter start" { + t.Errorf("Expected first event message 'Inverter start', got %v", firstEvent.Message) + } + if firstEvent.StartTime != 1634567890 { + t.Errorf("Expected first event start time 1634567890, got %v", firstEvent.StartTime) + } + if firstEvent.EndTime != 1634567950 { + t.Errorf("Expected first event end time 1634567950, got %v", firstEvent.EndTime) + } + + // Check ongoing event (end_time = 0) + ongoingEvent := eventsResponse.Events[2] + if ongoingEvent.EndTime != 0 { + t.Errorf("Expected ongoing event end time 0, got %v", ongoingEvent.EndTime) + } +} + +func TestConfigFromJSONFile(t *testing.T) { + // Test config with auth + data, err := loadTestData("test_config.json") + if err != nil { + t.Fatalf("Failed to load config test data: %v", err) + } + + var config Config + err = json.Unmarshal(data, &config) + if err != nil { + t.Fatalf("Failed to unmarshal Config: %v", err) + } + + if config.DB != "postgres://user:password@localhost:5432/opendtu" { + t.Errorf("Expected specific DB connection string, got %v", config.DB) + } + if config.OpenDTUAddress != "192.168.1.100" { + t.Errorf("Expected OpenDTU address 192.168.1.100, got %v", config.OpenDTUAddress) + } + if !config.OpenDTUAuth { + t.Errorf("Expected OpenDTU auth to be enabled") + } + if config.OpenDTUUser != "admin" { + t.Errorf("Expected OpenDTU user 'admin', got %v", config.OpenDTUUser) + } + if !config.TimescaleDB { + t.Errorf("Expected TimescaleDB to be enabled") + } + if config.TZ != "Europe/Amsterdam" { + t.Errorf("Expected timezone Europe/Amsterdam, got %v", config.TZ) + } + if config.LogLevel != "INFO" { + t.Errorf("Expected log level INFO, got %v", config.LogLevel) + } +} + +func TestQueryEventsEndpointMock(t *testing.T) { + // Create test data + testResponse := EventsResponse{ + Count: 2, + Events: []Event{ + { + MessageID: 1, + Message: "Test event 1", + StartTime: 1634567890, + EndTime: 1634567950, + }, + { + MessageID: 2, + Message: "Test event 2", + StartTime: 1634568000, + EndTime: 0, // Ongoing event + }, + }, + } + + // Create a test server + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check that the URL contains the expected inverter serial + if r.URL.Query().Get("inv") != "123456789" { + t.Errorf("Expected inverter serial 123456789, got %v", r.URL.Query().Get("inv")) + } + + // Check the endpoint path + if r.URL.Path != "/api/eventlog/status" { + t.Errorf("Expected path /api/eventlog/status, got %v", r.URL.Path) + } + + // Return test data + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(testResponse) + })) + defer server.Close() + + // Set config to use test server + originalAddress := config.OpenDTUAddress + config.OpenDTUAddress = server.URL[7:] // Remove "http://" prefix + defer func() { config.OpenDTUAddress = originalAddress }() + + // Test the function + response, err := queryEventsEndpoint("123456789") + if err != nil { + t.Fatalf("queryEventsEndpoint failed: %v", err) + } + + if response.Count != 2 { + t.Errorf("Expected 2 events, got %v", response.Count) + } + if len(response.Events) != 2 { + t.Errorf("Expected 2 events in array, got %v", len(response.Events)) + } + if response.Events[0].Message != "Test event 1" { + t.Errorf("Expected first event message 'Test event 1', got %v", response.Events[0].Message) + } +} + +func TestQueryEventsEndpointWithAuth(t *testing.T) { + // Create a test server that checks auth + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Check for Authorization header + auth := r.Header.Get("Authorization") + expectedAuth := basicAuth("testuser", "testpass") + if auth != expectedAuth { + t.Errorf("Expected auth header %v, got %v", expectedAuth, auth) + w.WriteHeader(http.StatusUnauthorized) + return + } + + // Return empty response for this test + response := EventsResponse{Count: 0, Events: []Event{}} + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(response) + })) + defer server.Close() + + // Set config to use test server with auth + originalAddress := config.OpenDTUAddress + originalAuth := config.OpenDTUAuth + originalUser := config.OpenDTUUser + originalPassword := config.OpenDTUPassword + + config.OpenDTUAddress = server.URL[7:] // Remove "http://" prefix + config.OpenDTUAuth = true + config.OpenDTUUser = "testuser" + config.OpenDTUPassword = "testpass" + + defer func() { + config.OpenDTUAddress = originalAddress + config.OpenDTUAuth = originalAuth + config.OpenDTUUser = originalUser + config.OpenDTUPassword = originalPassword + }() + + // Test the function + response, err := queryEventsEndpoint("123456789") + if err != nil { + t.Fatalf("queryEventsEndpoint with auth failed: %v", err) + } + + if response.Count != 0 { + t.Errorf("Expected 0 events, got %v", response.Count) + } +} + +func TestQueryEventsEndpointHTTPError(t *testing.T) { + // Create a test server that returns an error + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + // Set config to use test server + originalAddress := config.OpenDTUAddress + config.OpenDTUAddress = server.URL[7:] // Remove "http://" prefix + defer func() { config.OpenDTUAddress = originalAddress }() + + // Test the function + _, err := queryEventsEndpoint("123456789") + if err == nil { + t.Fatalf("Expected error from queryEventsEndpoint, got nil") + } +} + +func TestHandleMessageWithLiveData(t *testing.T) { + // Load test data + data, err := loadTestData("livedata_producing.json") + if err != nil { + t.Fatalf("Failed to load test data: %v", err) + } + + // Test that handleMessage doesn't crash with valid JSON + // Note: This will not actually insert into DB since we don't have a test DB setup + // handleMessage(data, nil) // Would panic without proper DB, so we skip this part + + // Instead, let's test the JSON parsing part + var liveData LiveData + err = json.Unmarshal(data, &liveData) + if err != nil { + t.Fatalf("handleMessage would fail due to JSON parsing error: %v", err) + } + + // Verify the conditions that would trigger data recording + for _, inverter := range liveData.Inverters { + if inverter.DataAge == 0 && inverter.Reachable { + // This is the condition for recording data + t.Logf("Inverter %s would have data recorded: DataAge=%d, Reachable=%v", + inverter.Serial, inverter.DataAge, inverter.Reachable) + } + if inverter.DataAge == 0 && inverter.Events > 0 { + // This is the condition for recording events + t.Logf("Inverter %s would have events recorded: DataAge=%d, Events=%d", + inverter.Serial, inverter.DataAge, inverter.Events) + } + } +} + +func TestInvalidJSON(t *testing.T) { + invalidJSONs := []string{ + `{"invalid": json}`, + `{"inverters": [{"serial": }]}`, + `{"total": {"Power": {"v": "not a number"}}}`, + ``, + `null`, + } + + for i, jsonStr := range invalidJSONs { + t.Run(fmt.Sprintf("invalid_json_%d", i), func(t *testing.T) { + var liveData LiveData + err := json.Unmarshal([]byte(jsonStr), &liveData) + if err == nil && jsonStr != `` && jsonStr != `null` { + t.Errorf("Expected error parsing invalid JSON %q, but got none", jsonStr) + } + }) + } +} diff --git a/main_persistence_test.go b/main_persistence_test.go new file mode 100644 index 0000000..eee251f --- /dev/null +++ b/main_persistence_test.go @@ -0,0 +1,436 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/DATA-DOG/go-sqlmock" +) + +func TestCreateLoggerWithLevel(t *testing.T) { + logger := createLoggerWithLevel(slog.LevelWarn) + if logger == nil { + t.Fatal("expected logger instance") + } + + handler := logger.Handler() + if handler.Enabled(context.Background(), slog.LevelInfo) { + t.Fatalf("expected info level to be disabled for warn handler") + } + if !handler.Enabled(context.Background(), slog.LevelError) { + t.Fatalf("expected error level to be enabled for warn handler") + } +} + +func TestInsertLiveDataInsertsAllTables(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + config = Config{TZ: "UTC"} + + inverter := Inverter{ + Serial: "INV01", + Name: "Dummy Inverter", + Producing: true, + LimitRelative: 90.5, + LimitAbsolute: 1500, + AC: map[string]InverterAC{ + "0": { + Power: VUD{V: 123.4}, + Voltage: VUD{V: 230.0}, + Current: VUD{V: 0.53}, + Frequency: VUD{V: 50.0}, + PowerFactor: VUD{V: 0.99}, + ReactivePower: VUD{ + V: 1.1, + }, + }, + }, + DC: map[string]InverterDC{ + "0": { + Name: struct { + U string `json:"u"` + }{U: "Dummy String"}, + Power: VUD{V: 111.1}, + Voltage: VUD{V: 36.5}, + Current: VUD{V: 3.05}, + YieldDay: VUD{V: 12.0}, + YieldTotal: VUD{V: 456.7}, + Irradiation: struct { + V float64 `json:"v"` + U string `json:"u"` + D int `json:"d"` + Max int `json:"max"` + }{V: 75.0, Max: 440}, + }, + }, + INV: map[string]InverterINV{ + "0": { + Temperature: VUD{V: 40.0}, + Efficiency: VUD{V: 97.5}, + PowerDC: VUD{V: 222.2}, + YieldDay: VUD{V: 15.0}, + YieldTotal: VUD{V: 789.0}, + }, + }, + } + + total := Total{ + Power: VUD{V: 321.0}, + YieldDay: VUD{V: 25.0}, + YieldTotal: VUD{ + V: 12345.6, + }, + } + + hints := Hints{ + TimeSync: true, + RadioProblem: true, + DefaultPassword: false, + PinMappingIssue: true, + } + + mock.ExpectExec("INSERT INTO opendtu_log"). + WithArgs(sqlmock.AnyArg(), total.Power.V, total.YieldDay.V, total.YieldTotal.V). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mock.ExpectExec("INSERT INTO opendtu_inverters"). + WithArgs(sqlmock.AnyArg(), inverter.Serial, inverter.Name, inverter.Producing, inverter.LimitRelative, inverter.LimitAbsolute). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mock.ExpectExec("INSERT INTO opendtu_inverters_ac"). + WithArgs(sqlmock.AnyArg(), inverter.Serial, "0", inverter.AC["0"].Power.V, inverter.AC["0"].Voltage.V, inverter.AC["0"].Current.V, inverter.AC["0"].Frequency.V, inverter.AC["0"].PowerFactor.V, inverter.AC["0"].ReactivePower.V). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mock.ExpectExec("INSERT INTO opendtu_inverters_dc"). + WithArgs(sqlmock.AnyArg(), inverter.Serial, "0", inverter.DC["0"].Name.U, inverter.DC["0"].Power.V, inverter.DC["0"].Voltage.V, inverter.DC["0"].Current.V, inverter.DC["0"].YieldDay.V, inverter.DC["0"].YieldTotal.V, inverter.DC["0"].Irradiation.V). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mock.ExpectExec("INSERT INTO opendtu_inverters_inv"). + WithArgs(sqlmock.AnyArg(), inverter.Serial, inverter.INV["0"].Temperature.V, inverter.INV["0"].Efficiency.V, inverter.INV["0"].PowerDC.V, inverter.INV["0"].YieldDay.V, inverter.INV["0"].YieldTotal.V). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mock.ExpectExec("INSERT INTO opendtu_hints"). + WithArgs(sqlmock.AnyArg(), hints.TimeSync, hints.RadioProblem, hints.DefaultPassword, hints.PinMappingIssue). + WillReturnResult(sqlmock.NewResult(0, 1)) + + insertLiveData(db, inverter, total, hints) + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("not all expectations were met: %v", err) + } +} + +func TestGetPreviousEventsCount(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + rows := sqlmock.NewRows([]string{"count"}).AddRow(3) + mock.ExpectQuery("SELECT COUNT\\(\\*\\)"). + WithArgs("INV01"). + WillReturnRows(rows) + + count := getPreviousEventsCount(db, "INV01") + if count != 3 { + t.Fatalf("expected count 3, got %d", count) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("not all expectations were met: %v", err) + } +} + +func TestInsertEventsPersistsRows(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + config = Config{TZ: "UTC"} + + events := &EventsResponse{ + Events: []Event{ + { + MessageID: 10, + Message: "Test event", + StartTime: 111, + EndTime: 222, + }, + }, + } + + mock.ExpectExec("INSERT INTO opendtu_events"). + WithArgs(sqlmock.AnyArg(), "INV01", events.Events[0].MessageID, events.Events[0].Message, events.Events[0].StartTime, events.Events[0].EndTime). + WillReturnResult(sqlmock.NewResult(0, 1)) + + insertEvents(db, "INV01", events) + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("not all expectations were met: %v", err) + } +} + +func TestUpdateEventsUpdatesRows(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + events := &EventsResponse{ + Events: []Event{ + { + StartTime: 100, + EndTime: 200, + }, + { + StartTime: 300, + EndTime: 400, + }, + }, + } + + mock.ExpectExec("UPDATE opendtu_events SET end_time"). + WithArgs(events.Events[0].EndTime, "INV01", events.Events[0].StartTime). + WillReturnResult(sqlmock.NewResult(0, 1)) + + mock.ExpectExec("UPDATE opendtu_events SET end_time"). + WithArgs(events.Events[1].EndTime, "INV01", events.Events[1].StartTime). + WillReturnResult(sqlmock.NewResult(0, 1)) + + updateEvents(db, "INV01", events) + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("not all expectations were met: %v", err) + } +} + +func TestHandleMessageRecordsDataAndEvents(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + eventsResponse := EventsResponse{ + Count: 1, + Events: []Event{{MessageID: 1, Message: "Test event", StartTime: 50, EndTime: 60}}, + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/eventlog/status" { + t.Fatalf("unexpected path: %s", r.URL.Path) + } + if got := r.URL.Query().Get("inv"); got != "INV01" { + t.Fatalf("unexpected inverter query: %s", got) + } + if err := json.NewEncoder(w).Encode(eventsResponse); err != nil { + t.Fatalf("failed to write response: %v", err) + } + })) + defer server.Close() + + previousConfig := config + defer func() { config = previousConfig }() + + address := strings.TrimPrefix(server.URL, "http://") + address = strings.TrimPrefix(address, "https://") + config = Config{ + TZ: "UTC", + OpenDTUAddress: address, + } + + rows := sqlmock.NewRows([]string{"count"}).AddRow(1) + mock.ExpectQuery("SELECT COUNT\\(\\*\\)"). + WithArgs("INV01"). + WillReturnRows(rows) + + mock.ExpectExec("INSERT INTO opendtu_events"). + WithArgs(sqlmock.AnyArg(), "INV01", eventsResponse.Events[0].MessageID, eventsResponse.Events[0].Message, eventsResponse.Events[0].StartTime, eventsResponse.Events[0].EndTime). + WillReturnResult(sqlmock.NewResult(0, 1)) + + inverter := Inverter{ + Serial: "INV01", + Name: "Dummy Inverter", + Producing: true, + Reachable: true, + DataAge: 0, + Events: 2, + LimitRelative: 90.5, + LimitAbsolute: 1500, + AC: map[string]InverterAC{ + "0": { + Power: VUD{V: 123.4}, + Voltage: VUD{V: 230.0}, + Current: VUD{V: 0.53}, + Frequency: VUD{V: 50.0}, + PowerFactor: VUD{V: 0.99}, + ReactivePower: VUD{V: 1.1}, + }, + }, + DC: map[string]InverterDC{ + "0": { + Name: struct { + U string `json:"u"` + }{U: "Dummy String"}, + Power: VUD{V: 111.1}, + Voltage: VUD{V: 36.5}, + Current: VUD{V: 3.05}, + YieldDay: VUD{V: 12.0}, + YieldTotal: VUD{V: 456.7}, + Irradiation: struct { + V float64 `json:"v"` + U string `json:"u"` + D int `json:"d"` + Max int `json:"max"` + }{V: 75.0, Max: 440}, + }, + }, + INV: map[string]InverterINV{ + "0": { + Temperature: VUD{V: 40.0}, + Efficiency: VUD{V: 97.5}, + PowerDC: VUD{V: 222.2}, + YieldDay: VUD{V: 15.0}, + YieldTotal: VUD{V: 789.0}, + }, + }, + } + + total := Total{ + Power: VUD{V: 321.0}, + YieldDay: VUD{V: 25.0}, + YieldTotal: VUD{ + V: 12345.6, + }, + } + + hints := Hints{ + TimeSync: true, + RadioProblem: true, + DefaultPassword: false, + PinMappingIssue: true, + } + + mock.ExpectExec("INSERT INTO opendtu_log"). + WithArgs(sqlmock.AnyArg(), total.Power.V, total.YieldDay.V, total.YieldTotal.V). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO opendtu_inverters"). + WithArgs(sqlmock.AnyArg(), inverter.Serial, inverter.Name, inverter.Producing, inverter.LimitRelative, inverter.LimitAbsolute). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO opendtu_inverters_ac"). + WithArgs(sqlmock.AnyArg(), inverter.Serial, "0", inverter.AC["0"].Power.V, inverter.AC["0"].Voltage.V, inverter.AC["0"].Current.V, inverter.AC["0"].Frequency.V, inverter.AC["0"].PowerFactor.V, inverter.AC["0"].ReactivePower.V). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO opendtu_inverters_dc"). + WithArgs(sqlmock.AnyArg(), inverter.Serial, "0", inverter.DC["0"].Name.U, inverter.DC["0"].Power.V, inverter.DC["0"].Voltage.V, inverter.DC["0"].Current.V, inverter.DC["0"].YieldDay.V, inverter.DC["0"].YieldTotal.V, inverter.DC["0"].Irradiation.V). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO opendtu_inverters_inv"). + WithArgs(sqlmock.AnyArg(), inverter.Serial, inverter.INV["0"].Temperature.V, inverter.INV["0"].Efficiency.V, inverter.INV["0"].PowerDC.V, inverter.INV["0"].YieldDay.V, inverter.INV["0"].YieldTotal.V). + WillReturnResult(sqlmock.NewResult(0, 1)) + mock.ExpectExec("INSERT INTO opendtu_hints"). + WithArgs(sqlmock.AnyArg(), hints.TimeSync, hints.RadioProblem, hints.DefaultPassword, hints.PinMappingIssue). + WillReturnResult(sqlmock.NewResult(0, 1)) + + liveData := LiveData{ + Inverters: []Inverter{inverter}, + Total: total, + Hints: hints, + } + + payload, err := json.Marshal(liveData) + if err != nil { + t.Fatalf("failed to marshal live data: %v", err) + } + + handleMessage(payload, db) + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("not all expectations were met: %v", err) + } +} + +func TestHandleMessageSkipsWhenDataStale(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + liveData := LiveData{ + Inverters: []Inverter{ + { + Serial: "INV01", + Reachable: false, + DataAge: 10, + Events: 2, + }, + }, + } + + payload, err := json.Marshal(liveData) + if err != nil { + t.Fatalf("failed to marshal live data: %v", err) + } + + handleMessage(payload, db) + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("unexpected database calls: %v", err) + } +} + +func TestEnableTimescaleHypertablesExecutesStatements(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + mock.ExpectExec("(?s).*create_hypertable\\('opendtu_log'"). + WillReturnResult(sqlmock.NewResult(0, 1)) + + if err := enableTimescaleHypertables(db); err != nil { + t.Fatalf("expected no error, got %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("not all expectations were met: %v", err) + } +} + +func TestEnableTimescaleHypertablesPropagatesError(t *testing.T) { + db, mock, err := sqlmock.New() + if err != nil { + t.Fatalf("failed to create sqlmock: %v", err) + } + defer db.Close() + + mock.ExpectExec("(?s).*create_hypertable\\('opendtu_log'"). + WillReturnError(errors.New("boom")) + + err = enableTimescaleHypertables(db) + if err == nil { + t.Fatalf("expected error, got nil") + } + + if !strings.Contains(err.Error(), "boom") { + t.Fatalf("expected wrapped boom error, got %v", err) + } + + if err := mock.ExpectationsWereMet(); err != nil { + t.Fatalf("expectations mismatch: %v", err) + } +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..ee58344 --- /dev/null +++ b/main_test.go @@ -0,0 +1,566 @@ +package main + +import ( + "encoding/json" + "log/slog" + "os" + "testing" + "time" +) + +func TestBasicAuth(t *testing.T) { + tests := []struct { + name string + username string + password string + expected string + }{ + { + name: "basic auth test", + username: "testuser", + password: "testpass", + expected: "Basic dGVzdHVzZXI6dGVzdHBhc3M=", + }, + { + name: "empty credentials", + username: "", + password: "", + expected: "Basic Og==", + }, + { + name: "special characters", + username: "user@domain.com", + password: "p@ssw0rd!", + expected: "Basic dXNlckBkb21haW4uY29tOnBAc3N3MHJkIQ==", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := basicAuth(tt.username, tt.password) + if result != tt.expected { + t.Errorf("basicAuth() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestGetLogLevel(t *testing.T) { + tests := []struct { + name string + logLevel string + defaultLevel slog.Level + expected slog.Level + }{ + { + name: "DEBUG level", + logLevel: "DEBUG", + defaultLevel: slog.LevelInfo, + expected: slog.LevelDebug, + }, + { + name: "INFO level", + logLevel: "INFO", + defaultLevel: slog.LevelDebug, + expected: slog.LevelInfo, + }, + { + name: "WARN level", + logLevel: "WARN", + defaultLevel: slog.LevelDebug, + expected: slog.LevelWarn, + }, + { + name: "ERROR level", + logLevel: "ERROR", + defaultLevel: slog.LevelDebug, + expected: slog.LevelError, + }, + { + name: "invalid level returns default", + logLevel: "INVALID", + defaultLevel: slog.LevelInfo, + expected: slog.LevelInfo, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Temporarily set the config.LogLevel + originalLogLevel := config.LogLevel + config.LogLevel = tt.logLevel + defer func() { config.LogLevel = originalLogLevel }() + + result := getLogLevel(tt.defaultLevel) + if result != tt.expected { + t.Errorf("getLogLevel() = %v, want %v", result, tt.expected) + } + }) + } +} + +func TestVUDStruct(t *testing.T) { + jsonData := `{"v": 123.45, "u": "W", "d": 2}` + + var vud VUD + err := json.Unmarshal([]byte(jsonData), &vud) + if err != nil { + t.Fatalf("Failed to unmarshal VUD: %v", err) + } + + if vud.V != 123.45 { + t.Errorf("Expected V=123.45, got %v", vud.V) + } + if vud.U != "W" { + t.Errorf("Expected U=W, got %v", vud.U) + } + if vud.D != 2 { + t.Errorf("Expected D=2, got %v", vud.D) + } +} + +func TestInverterStruct(t *testing.T) { + jsonData := `{ + "serial": "123456789", + "name": "Test Inverter", + "producing": true, + "limit_relative": 80.5, + "limit_absolute": 800.0, + "AC": { + "0": { + "Power": {"v": 250.5, "u": "W", "d": 1}, + "Voltage": {"v": 230.0, "u": "V", "d": 1}, + "Current": {"v": 1.09, "u": "A", "d": 2}, + "Frequency": {"v": 50.0, "u": "Hz", "d": 1}, + "PowerFactor": {"v": 1.0, "u": "", "d": 2}, + "ReactivePower": {"v": 0.0, "u": "var", "d": 1} + } + }, + "DC": { + "0": { + "Name": {"u": "String 1"}, + "Power": {"v": 260.0, "u": "W", "d": 1}, + "Voltage": {"v": 35.2, "u": "V", "d": 1}, + "Current": {"v": 7.4, "u": "A", "d": 1}, + "YieldDay": {"v": 2.5, "u": "kWh", "d": 3}, + "YieldTotal": {"v": 1250.8, "u": "kWh", "d": 3}, + "Irradiation": {"v": 85.2, "u": "%", "d": 1, "max": 100} + } + }, + "events": 2, + "poll_enabled": true, + "reachable": true, + "data_age": 0, + "INV": { + "0": { + "Temperature": {"v": 32.5, "u": "°C", "d": 1}, + "Efficiency": {"v": 96.2, "u": "%", "d": 1}, + "Power DC": {"v": 260.0, "u": "W", "d": 1}, + "YieldDay": {"v": 2.5, "u": "kWh", "d": 3}, + "YieldTotal": {"v": 1250.8, "u": "kWh", "d": 3} + } + } + }` + + var inverter Inverter + err := json.Unmarshal([]byte(jsonData), &inverter) + if err != nil { + t.Fatalf("Failed to unmarshal Inverter: %v", err) + } + + if inverter.Serial != "123456789" { + t.Errorf("Expected Serial=123456789, got %v", inverter.Serial) + } + if inverter.Name != "Test Inverter" { + t.Errorf("Expected Name=Test Inverter, got %v", inverter.Name) + } + if !inverter.Producing { + t.Errorf("Expected Producing=true, got %v", inverter.Producing) + } + if inverter.LimitRelative != 80.5 { + t.Errorf("Expected LimitRelative=80.5, got %v", inverter.LimitRelative) + } + + // Test AC data + if len(inverter.AC) != 1 { + t.Errorf("Expected 1 AC entry, got %v", len(inverter.AC)) + } + if ac, exists := inverter.AC["0"]; exists { + if ac.Power.V != 250.5 { + t.Errorf("Expected AC Power=250.5, got %v", ac.Power.V) + } + if ac.Voltage.V != 230.0 { + t.Errorf("Expected AC Voltage=230.0, got %v", ac.Voltage.V) + } + } else { + t.Error("Expected AC[0] to exist") + } + + // Test DC data + if len(inverter.DC) != 1 { + t.Errorf("Expected 1 DC entry, got %v", len(inverter.DC)) + } + if dc, exists := inverter.DC["0"]; exists { + if dc.Name.U != "String 1" { + t.Errorf("Expected DC Name=String 1, got %v", dc.Name.U) + } + if dc.Power.V != 260.0 { + t.Errorf("Expected DC Power=260.0, got %v", dc.Power.V) + } + } else { + t.Error("Expected DC[0] to exist") + } + + // Test INV data + if len(inverter.INV) != 1 { + t.Errorf("Expected 1 INV entry, got %v", len(inverter.INV)) + } + if inv, exists := inverter.INV["0"]; exists { + if inv.Temperature.V != 32.5 { + t.Errorf("Expected INV Temperature=32.5, got %v", inv.Temperature.V) + } + if inv.Efficiency.V != 96.2 { + t.Errorf("Expected INV Efficiency=96.2, got %v", inv.Efficiency.V) + } + } else { + t.Error("Expected INV[0] to exist") + } +} + +func TestEventStruct(t *testing.T) { + jsonData := `{ + "message_id": 1, + "message": "Test event message", + "start_time": 1634567890, + "end_time": 1634567950 + }` + + var event Event + err := json.Unmarshal([]byte(jsonData), &event) + if err != nil { + t.Fatalf("Failed to unmarshal Event: %v", err) + } + + if event.MessageID != 1 { + t.Errorf("Expected MessageID=1, got %v", event.MessageID) + } + if event.Message != "Test event message" { + t.Errorf("Expected Message=Test event message, got %v", event.Message) + } + if event.StartTime != 1634567890 { + t.Errorf("Expected StartTime=1634567890, got %v", event.StartTime) + } + if event.EndTime != 1634567950 { + t.Errorf("Expected EndTime=1634567950, got %v", event.EndTime) + } +} + +func TestLiveDataStruct(t *testing.T) { + jsonData := `{ + "inverters": [ + { + "serial": "123456789", + "name": "Test Inverter", + "producing": true, + "limit_relative": 80.5, + "limit_absolute": 800.0, + "AC": {}, + "DC": {}, + "events": 0, + "poll_enabled": true, + "reachable": true, + "data_age": 0, + "INV": {} + } + ], + "total": { + "Power": {"v": 250.5, "u": "W", "d": 1}, + "YieldDay": {"v": 2.5, "u": "kWh", "d": 3}, + "YieldTotal": {"v": 1250.8, "u": "kWh", "d": 3} + }, + "hints": { + "time_sync": true, + "radio_problem": false, + "default_password": false + } + }` + + var liveData LiveData + err := json.Unmarshal([]byte(jsonData), &liveData) + if err != nil { + t.Fatalf("Failed to unmarshal LiveData: %v", err) + } + + if len(liveData.Inverters) != 1 { + t.Errorf("Expected 1 inverter, got %v", len(liveData.Inverters)) + } + + if liveData.Total.Power.V != 250.5 { + t.Errorf("Expected Total Power=250.5, got %v", liveData.Total.Power.V) + } + + if !liveData.Hints.TimeSync { + t.Errorf("Expected Hints.TimeSync=true, got %v", liveData.Hints.TimeSync) + } + if liveData.Hints.RadioProblem { + t.Errorf("Expected Hints.RadioProblem=false, got %v", liveData.Hints.RadioProblem) + } +} + +func TestConfigLoadFromEnv(t *testing.T) { + // Save original environment variables + originalDB := os.Getenv("DB_URL") + originalAddress := os.Getenv("OPENDTU_ADDRESS") + originalAuth := os.Getenv("OPENDTU_AUTH") + originalUser := os.Getenv("OPENDTU_USERNAME") + originalPassword := os.Getenv("OPENDTU_PASSWORD") + originalTimescale := os.Getenv("TIMESCALEDB_ENABLED") + originalTZ := os.Getenv("TZ") + originalLogLevel := os.Getenv("LOG_LEVEL") + originalConfigFile := os.Getenv("CONFIG_FILE") + + // Clean up after test + defer func() { + os.Setenv("DB_URL", originalDB) + os.Setenv("OPENDTU_ADDRESS", originalAddress) + os.Setenv("OPENDTU_AUTH", originalAuth) + os.Setenv("OPENDTU_USERNAME", originalUser) + os.Setenv("OPENDTU_PASSWORD", originalPassword) + os.Setenv("TIMESCALEDB_ENABLED", originalTimescale) + os.Setenv("TZ", originalTZ) + os.Setenv("LOG_LEVEL", originalLogLevel) + os.Setenv("CONFIG_FILE", originalConfigFile) + }() + + // Set test environment variables + os.Setenv("CONFIG_FILE", "/nonexistent/config.json") // Force env var fallback + os.Setenv("DB_URL", "postgres://testuser:testpass@localhost/testdb") + os.Setenv("OPENDTU_ADDRESS", "192.168.1.100") + os.Setenv("OPENDTU_AUTH", "true") + os.Setenv("OPENDTU_USERNAME", "admin") + os.Setenv("OPENDTU_PASSWORD", "secret") + os.Setenv("TIMESCALEDB_ENABLED", "true") + os.Setenv("TZ", "Europe/Amsterdam") + os.Setenv("LOG_LEVEL", "DEBUG") + + config, err := loadConfig() + if err != nil { + t.Fatalf("loadConfig() failed: %v", err) + } + + if config.DB != "postgres://testuser:testpass@localhost/testdb" { + t.Errorf("Expected DB from env, got %v", config.DB) + } + if config.OpenDTUAddress != "192.168.1.100" { + t.Errorf("Expected OpenDTUAddress from env, got %v", config.OpenDTUAddress) + } + if !config.OpenDTUAuth { + t.Errorf("Expected OpenDTUAuth=true from env, got %v", config.OpenDTUAuth) + } + if config.OpenDTUUser != "admin" { + t.Errorf("Expected OpenDTUUser from env, got %v", config.OpenDTUUser) + } + if config.OpenDTUPassword != "secret" { + t.Errorf("Expected OpenDTUPassword from env, got %v", config.OpenDTUPassword) + } + if !config.TimescaleDB { + t.Errorf("Expected TimescaleDB=true from env, got %v", config.TimescaleDB) + } + if config.TZ != "Europe/Amsterdam" { + t.Errorf("Expected TZ from env, got %v", config.TZ) + } + if config.LogLevel != "DEBUG" { + t.Errorf("Expected LogLevel from env, got %v", config.LogLevel) + } +} + +func TestTimeZoneValidation(t *testing.T) { + validTimezones := []string{ + "UTC", + "Europe/Amsterdam", + "America/New_York", + "Asia/Tokyo", + } + + for _, tz := range validTimezones { + t.Run(tz, func(t *testing.T) { + _, err := time.LoadLocation(tz) + if err != nil { + t.Errorf("Timezone %s should be valid but got error: %v", tz, err) + } + }) + } + + invalidTimezones := []string{ + "Invalid/Timezone", + "Not/A/Real/Place", + "", + } + + for _, tz := range invalidTimezones { + if tz == "" { + continue // Empty string is handled differently + } + t.Run(tz, func(t *testing.T) { + _, err := time.LoadLocation(tz) + if err == nil { + t.Errorf("Timezone %s should be invalid but was accepted", tz) + } + }) + } +} + +// Test RadioStatistics structure (from official OpenDTU) +func TestRadioStatisticsStruct(t *testing.T) { + jsonData := `{ + "tx_request": 12345, + "tx_re_request": 234, + "rx_success": 12000, + "rx_fail_nothing": 50, + "rx_fail_partial": 30, + "rx_fail_corrupt": 21, + "rssi": -65.5 + }` + + var stats RadioStatistics + err := json.Unmarshal([]byte(jsonData), &stats) + if err != nil { + t.Fatalf("Failed to unmarshal RadioStatistics: %v", err) + } + + if stats.TxRequest != 12345 { + t.Errorf("Expected TxRequest=12345, got %v", stats.TxRequest) + } + if stats.TxReRequest != 234 { + t.Errorf("Expected TxReRequest=234, got %v", stats.TxReRequest) + } + if stats.RxSuccess != 12000 { + t.Errorf("Expected RxSuccess=12000, got %v", stats.RxSuccess) + } + if stats.RxFailNothing != 50 { + t.Errorf("Expected RxFailNothing=50, got %v", stats.RxFailNothing) + } + if stats.RxFailPartial != 30 { + t.Errorf("Expected RxFailPartial=30, got %v", stats.RxFailPartial) + } + if stats.RxFailCorrupt != 21 { + t.Errorf("Expected RxFailCorrupt=21, got %v", stats.RxFailCorrupt) + } + if stats.RSSI != -65.5 { + t.Errorf("Expected RSSI=-65.5, got %v", stats.RSSI) + } +} + +// Test inverter struct with new OpenDTU fields: order, data_age_ms, radio_stats +func TestInverterWithOpenDTUFields(t *testing.T) { + jsonData := `{ + "serial": "114173123456", + "name": "Hoymiles HM-800", + "order": 0, + "data_age": 0, + "data_age_ms": 124, + "poll_enabled": true, + "reachable": true, + "producing": true, + "limit_relative": 100.0, + "limit_absolute": 800.0, + "events": 2, + "radio_stats": { + "tx_request": 12345, + "tx_re_request": 234, + "rx_success": 12000, + "rx_fail_nothing": 50, + "rx_fail_partial": 30, + "rx_fail_corrupt": 21, + "rssi": -65.5 + }, + "AC": {}, + "DC": {}, + "INV": {} + }` + + var inverter Inverter + err := json.Unmarshal([]byte(jsonData), &inverter) + if err != nil { + t.Fatalf("Failed to unmarshal Inverter with OpenDTU fields: %v", err) + } + + // Test new fields from official OpenDTU + if inverter.Order != 0 { + t.Errorf("Expected Order=0, got %v", inverter.Order) + } + if inverter.DataAge != 0 { + t.Errorf("Expected DataAge=0, got %v", inverter.DataAge) + } + if inverter.DataAgeMs != 124 { + t.Errorf("Expected DataAgeMs=124, got %v", inverter.DataAgeMs) + } + + // Test radio_stats + if inverter.RadioStats.TxRequest != 12345 { + t.Errorf("Expected RadioStats.TxRequest=12345, got %v", inverter.RadioStats.TxRequest) + } + if inverter.RadioStats.RxSuccess != 12000 { + t.Errorf("Expected RadioStats.RxSuccess=12000, got %v", inverter.RadioStats.RxSuccess) + } + if inverter.RadioStats.RSSI != -65.5 { + t.Errorf("Expected RadioStats.RSSI=-65.5, got %v", inverter.RadioStats.RSSI) + } + + // Test original fields still work + if inverter.Serial != "114173123456" { + t.Errorf("Expected Serial=114173123456, got %v", inverter.Serial) + } + if inverter.Name != "Hoymiles HM-800" { + t.Errorf("Expected Name=Hoymiles HM-800, got %v", inverter.Name) + } + if !inverter.Producing { + t.Errorf("Expected Producing=true, got %v", inverter.Producing) + } +} + +// Test Hints struct with new pin_mapping_issue field from OpenDTU +func TestHintsWithPinMappingIssue(t *testing.T) { + jsonData := `{ + "time_sync": true, + "radio_problem": false, + "default_password": false, + "pin_mapping_issue": false + }` + + var hints Hints + err := json.Unmarshal([]byte(jsonData), &hints) + if err != nil { + t.Fatalf("Failed to unmarshal Hints with pin_mapping_issue: %v", err) + } + + if !hints.TimeSync { + t.Errorf("Expected TimeSync=true, got %v", hints.TimeSync) + } + if hints.RadioProblem { + t.Errorf("Expected RadioProblem=false, got %v", hints.RadioProblem) + } + if hints.DefaultPassword { + t.Errorf("Expected DefaultPassword=false, got %v", hints.DefaultPassword) + } + if hints.PinMappingIssue { + t.Errorf("Expected PinMappingIssue=false, got %v", hints.PinMappingIssue) + } + + // Test when pin_mapping_issue is true + jsonDataWithIssue := `{ + "time_sync": true, + "radio_problem": false, + "default_password": false, + "pin_mapping_issue": true + }` + + err = json.Unmarshal([]byte(jsonDataWithIssue), &hints) + if err != nil { + t.Fatalf("Failed to unmarshal Hints with pin_mapping_issue=true: %v", err) + } + + if !hints.PinMappingIssue { + t.Errorf("Expected PinMappingIssue=true, got %v", hints.PinMappingIssue) + } +} diff --git a/opendtu_compatibility_test.go b/opendtu_compatibility_test.go new file mode 100644 index 0000000..2043421 --- /dev/null +++ b/opendtu_compatibility_test.go @@ -0,0 +1,500 @@ +package main + +import ( + "encoding/json" + "testing" +) + +// TestOpenDTUCompatibility_FullStructure validates that our Go structs +// can parse the exact JSON structure produced by OpenDTU WebSocket +// based on opendtu/src/WebApi_ws_live.cpp +func TestOpenDTUCompatibility_FullStructure(t *testing.T) { + data, err := loadTestData("livedata_producing.json") + if err != nil { + t.Fatalf("Failed to load test data: %v", err) + } + + var liveData LiveData + err = json.Unmarshal(data, &liveData) + if err != nil { + t.Fatalf("Failed to unmarshal LiveData: %v", err) + } + + // Validate top-level structure + if len(liveData.Inverters) == 0 { + t.Fatal("Expected at least one inverter") + } + + // Test first inverter completely + inv := liveData.Inverters[0] + + // Validate all root-level inverter fields (from generateInverterCommonJsonResponse) + t.Run("InverterCommonFields", func(t *testing.T) { + if inv.Serial == "" { + t.Error("serial field missing or empty") + } + if inv.Name == "" { + t.Error("name field missing or empty") + } + // order field (int, can be 0) + if inv.Order < 0 { + t.Error("order field has invalid value") + } + // data_age field (int, can be 0) + if inv.DataAge < 0 { + t.Error("data_age field has invalid value") + } + // data_age_ms field (int, can be 0) + if inv.DataAgeMs < 0 { + t.Error("data_age_ms field has invalid value") + } + // poll_enabled field (bool) + _ = inv.PollEnabled + // reachable field (bool) + _ = inv.Reachable + // producing field (bool) + _ = inv.Producing + // limit_relative field (float64) + if inv.LimitRelative < 0 { + t.Error("limit_relative field has invalid value") + } + // limit_absolute field (float64, can be -1) + _ = inv.LimitAbsolute + }) + + // Validate radio_stats structure (from generateInverterCommonJsonResponse lines 157-163) + t.Run("RadioStats", func(t *testing.T) { + rs := inv.RadioStats + // All fields from OpenDTU RadioStats + if rs.TxRequest < 0 { + t.Error("radio_stats.tx_request has invalid value") + } + if rs.TxReRequest < 0 { + t.Error("radio_stats.tx_re_request has invalid value") + } + if rs.RxSuccess < 0 { + t.Error("radio_stats.rx_success has invalid value") + } + if rs.RxFailNothing < 0 { + t.Error("radio_stats.rx_fail_nothing has invalid value") + } + if rs.RxFailPartial < 0 { + t.Error("radio_stats.rx_fail_partial has invalid value") + } + if rs.RxFailCorrupt < 0 { + t.Error("radio_stats.rx_fail_corrupt has invalid value") + } + // rssi can be negative (signal strength) + _ = rs.RSSI + }) + + // Validate AC channel structure (string-keyed map from WebApi_ws_live.cpp line 224) + t.Run("AC_Structure", func(t *testing.T) { + if len(inv.AC) == 0 { + t.Fatal("AC map is empty") + } + + // OpenDTU uses string keys: "0", "1", etc. + ac0, exists := inv.AC["0"] + if !exists { + t.Fatal("AC[\"0\"] does not exist - OpenDTU uses string-keyed maps") + } + + // Validate AC fields (from addField calls in WebApi_ws_live.cpp) + // Lines 184-188: FLD_PAC, FLD_UAC, FLD_IAC, FLD_F, FLD_PF, FLD_Q + validateVUD(t, "AC.Power", ac0.Power) + validateVUD(t, "AC.Voltage", ac0.Voltage) + validateVUD(t, "AC.Current", ac0.Current) + validateVUD(t, "AC.Frequency", ac0.Frequency) + validateVUD(t, "AC.PowerFactor", ac0.PowerFactor) + validateVUD(t, "AC.ReactivePower", ac0.ReactivePower) + }) + + // Validate DC channel structure (string-keyed map with name field) + t.Run("DC_Structure", func(t *testing.T) { + if len(inv.DC) == 0 { + t.Fatal("DC map is empty") + } + + // OpenDTU uses string keys: "0", "1", etc. + dc0, exists := inv.DC["0"] + if !exists { + t.Fatal("DC[\"0\"] does not exist - OpenDTU uses string-keyed maps") + } + + // Validate DC name field (from WebApi_ws_live.cpp line 182) + if dc0.Name.U == "" { + t.Error("DC channel name is empty") + } + + // Validate DC fields (from addField calls) + // Lines 184-200: FLD_PDC, FLD_UDC, FLD_IDC, FLD_YD, FLD_YT, FLD_IRR + validateVUD(t, "DC.Power", dc0.Power) + validateVUD(t, "DC.Voltage", dc0.Voltage) + validateVUD(t, "DC.Current", dc0.Current) + validateVUD(t, "DC.YieldDay", dc0.YieldDay) + validateVUD(t, "DC.YieldTotal", dc0.YieldTotal) + + // Irradiation has special max field (line 201-203) + if dc0.Irradiation.V < 0 || dc0.Irradiation.V > 100 { + t.Errorf("DC.Irradiation.V out of range: %v", dc0.Irradiation.V) + } + if dc0.Irradiation.Max <= 0 { + t.Error("DC.Irradiation.max field missing or invalid") + } + }) + + // Validate INV channel structure + t.Run("INV_Structure", func(t *testing.T) { + if len(inv.INV) == 0 { + t.Fatal("INV map is empty") + } + + // OpenDTU uses string keys: "0" + inv0, exists := inv.INV["0"] + if !exists { + t.Fatal("INV[\"0\"] does not exist - OpenDTU uses string-keyed maps") + } + + // Validate INV fields (from addField calls) + // Lines 184-200: FLD_T, FLD_EFF, FLD_PDC (as "Power DC"), FLD_YD, FLD_YT + validateVUD(t, "INV.Temperature", inv0.Temperature) + validateVUD(t, "INV.Efficiency", inv0.Efficiency) + validateVUD(t, "INV.PowerDC", inv0.PowerDC) + validateVUD(t, "INV.YieldDay", inv0.YieldDay) + validateVUD(t, "INV.YieldTotal", inv0.YieldTotal) + }) + + // Validate events field (from WebApi_ws_live.cpp lines 206-210) + t.Run("Events", func(t *testing.T) { + // events can be -1 if not available, or >= 0 + if inv.Events < -1 { + t.Errorf("events field has invalid value: %d", inv.Events) + } + }) + + // Validate total structure (from generateCommonJsonResponse lines 128-130) + t.Run("Total", func(t *testing.T) { + validateVUD(t, "Total.Power", liveData.Total.Power) + validateVUD(t, "Total.YieldDay", liveData.Total.YieldDay) + validateVUD(t, "Total.YieldTotal", liveData.Total.YieldTotal) + }) + + // Validate hints structure (from generateCommonJsonResponse lines 132-138) + t.Run("Hints", func(t *testing.T) { + // All fields are boolean + _ = liveData.Hints.TimeSync + _ = liveData.Hints.RadioProblem + _ = liveData.Hints.DefaultPassword + _ = liveData.Hints.PinMappingIssue + }) +} + +// TestOpenDTUCompatibility_StringKeyedMaps verifies that AC/DC/INV are string-keyed maps +// This is critical because OpenDTU C++ code uses String(channel) conversion (line 224) +func TestOpenDTUCompatibility_StringKeyedMaps(t *testing.T) { + data, err := loadTestData("livedata_producing.json") + if err != nil { + t.Fatalf("Failed to load test data: %v", err) + } + + var liveData LiveData + err = json.Unmarshal(data, &liveData) + if err != nil { + t.Fatalf("Failed to unmarshal LiveData: %v", err) + } + + inv := liveData.Inverters[0] + + // Test that maps use string keys "0", "1", NOT integer indices + t.Run("AC_StringKeys", func(t *testing.T) { + if _, exists := inv.AC["0"]; !exists { + t.Error("AC must use string key \"0\" not integer 0") + } + // Should NOT be accessible as integer + if len(inv.AC) > 0 { + // This is correct - we're using map[string] + t.Log("AC correctly uses map[string]InverterAC") + } + }) + + t.Run("DC_StringKeys", func(t *testing.T) { + if _, exists := inv.DC["0"]; !exists { + t.Error("DC must use string key \"0\" not integer 0") + } + if _, exists := inv.DC["1"]; len(inv.DC) > 1 && !exists { + t.Error("DC must use string key \"1\" not integer 1") + } + }) + + t.Run("INV_StringKeys", func(t *testing.T) { + if _, exists := inv.INV["0"]; !exists { + t.Error("INV must use string key \"0\" not integer 0") + } + }) +} + +// TestOpenDTUCompatibility_MultipleInverters validates proper handling of inverter arrays +// OpenDTU creates an array of inverter objects (WebApi_ws_live.cpp line 95) +func TestOpenDTUCompatibility_MultipleInverters(t *testing.T) { + data, err := loadTestData("livedata_producing.json") + if err != nil { + t.Fatalf("Failed to load test data: %v", err) + } + + var liveData LiveData + err = json.Unmarshal(data, &liveData) + if err != nil { + t.Fatalf("Failed to unmarshal LiveData: %v", err) + } + + if len(liveData.Inverters) < 2 { + t.Skip("Test requires at least 2 inverters") + } + + // Verify each inverter has unique serial and proper order + t.Run("UniqueSerials", func(t *testing.T) { + serials := make(map[string]bool) + for _, inv := range liveData.Inverters { + if serials[inv.Serial] { + t.Errorf("Duplicate serial found: %s", inv.Serial) + } + serials[inv.Serial] = true + } + }) + + t.Run("ProperOrdering", func(t *testing.T) { + for i, inv := range liveData.Inverters { + if inv.Order != i { + t.Logf("Warning: inverter at index %d has order %d (may be intentional)", i, inv.Order) + } + } + }) +} + +// TestOpenDTUCompatibility_NightMode validates zero-production scenario +// Tests that the structure is valid even when inverters are not producing +func TestOpenDTUCompatibility_NightMode(t *testing.T) { + data, err := loadTestData("livedata_night.json") + if err != nil { + t.Fatalf("Failed to load night test data: %v", err) + } + + var liveData LiveData + err = json.Unmarshal(data, &liveData) + if err != nil { + t.Fatalf("Failed to unmarshal night LiveData: %v", err) + } + + if len(liveData.Inverters) == 0 { + t.Fatal("Expected at least one inverter in night data") + } + + inv := liveData.Inverters[0] + + // During night, inverters should not be producing + if inv.Producing { + t.Error("Inverter should not be producing at night") + } + + // Data age should be higher (older data) + if inv.DataAge == 0 { + t.Error("Expected non-zero data_age at night (stale data)") + } + + // Total power should be zero or near-zero + if liveData.Total.Power.V > 1.0 { + t.Errorf("Expected near-zero total power at night, got %v W", liveData.Total.Power.V) + } + + // Structure should still be valid + if len(inv.AC) == 0 { + t.Error("AC structure should exist even at night") + } + if len(inv.DC) == 0 { + t.Error("DC structure should exist even at night") + } + if len(inv.INV) == 0 { + t.Error("INV structure should exist even at night") + } +} + +// TestOpenDTUCompatibility_FieldNames validates exact field naming from OpenDTU +// Field names come from getChannelFieldName in OpenDTU C++ code +func TestOpenDTUCompatibility_FieldNames(t *testing.T) { + data, err := loadTestData("livedata_producing.json") + if err != nil { + t.Fatalf("Failed to load test data: %v", err) + } + + // Parse as generic JSON to inspect field names + var jsonData map[string]interface{} + err = json.Unmarshal(data, &jsonData) + if err != nil { + t.Fatalf("Failed to unmarshal as generic JSON: %v", err) + } + + inverters := jsonData["inverters"].([]interface{}) + inv := inverters[0].(map[string]interface{}) + + // Check root inverter field names (exact names from C++ code) + expectedRootFields := []string{ + "serial", "name", "order", "data_age", "data_age_ms", + "poll_enabled", "reachable", "producing", + "limit_relative", "limit_absolute", "events", + "AC", "DC", "INV", "radio_stats", + } + + for _, field := range expectedRootFields { + if _, exists := inv[field]; !exists { + t.Errorf("Missing expected root field: %s", field) + } + } + + // Check radio_stats field names + radioStats := inv["radio_stats"].(map[string]interface{}) + expectedRadioFields := []string{ + "tx_request", "tx_re_request", "rx_success", + "rx_fail_nothing", "rx_fail_partial", "rx_fail_corrupt", "rssi", + } + + for _, field := range expectedRadioFields { + if _, exists := radioStats[field]; !exists { + t.Errorf("Missing expected radio_stats field: %s", field) + } + } + + // Check hints field names + hints := jsonData["hints"].(map[string]interface{}) + expectedHintFields := []string{ + "time_sync", "radio_problem", "default_password", "pin_mapping_issue", + } + + for _, field := range expectedHintFields { + if _, exists := hints[field]; !exists { + t.Errorf("Missing expected hints field: %s", field) + } + } +} + +// TestOpenDTUCompatibility_WarningFixture validates branches only hit when OpenDTU +// cannot determine inverter max power, event log, or when hints flag issues. +func TestOpenDTUCompatibility_WarningFixture(t *testing.T) { + data, err := loadTestData("livedata_warnings.json") + if err != nil { + t.Fatalf("Failed to load warning test data: %v", err) + } + + var liveData LiveData + if err := json.Unmarshal(data, &liveData); err != nil { + t.Fatalf("Failed to unmarshal warning fixture: %v", err) + } + + if len(liveData.Inverters) != 1 { + t.Fatalf("Expected 1 inverter in warning fixture, got %d", len(liveData.Inverters)) + } + + inv := liveData.Inverters[0] + if inv.LimitAbsolute != -1 { + t.Errorf("Expected LimitAbsolute fallback -1, got %v", inv.LimitAbsolute) + } + if inv.Events != -1 { + t.Errorf("Expected Events=-1 when event log channel missing, got %v", inv.Events) + } + if !inv.PollEnabled { + t.Errorf("Expected PollEnabled=true for warning fixture") + } + if inv.Reachable { + t.Errorf("Expected Reachable=false for warning fixture") + } + + if liveData.Hints.TimeSync { + t.Errorf("Expected TimeSync=false, got %v", liveData.Hints.TimeSync) + } + if !liveData.Hints.RadioProblem { + t.Errorf("Expected RadioProblem=true, got %v", liveData.Hints.RadioProblem) + } + if !liveData.Hints.DefaultPassword { + t.Errorf("Expected DefaultPassword=true, got %v", liveData.Hints.DefaultPassword) + } + if !liveData.Hints.PinMappingIssue { + t.Errorf("Expected PinMappingIssue=true, got %v", liveData.Hints.PinMappingIssue) + } + + var generic map[string]interface{} + if err := json.Unmarshal(data, &generic); err != nil { + t.Fatalf("Failed to unmarshal warning fixture generically: %v", err) + } + invertersRaw, ok := generic["inverters"].([]interface{}) + if !ok || len(invertersRaw) == 0 { + t.Fatalf("Generic inverter array missing") + } + invMap, ok := invertersRaw[0].(map[string]interface{}) + if !ok { + t.Fatalf("Generic inverter map missing") + } + if _, exists := invMap["BAT"]; !exists { + t.Errorf("Expected additional channel map 'BAT' to be present") + } +} + +// Helper function to validate VUD structure +func validateVUD(t *testing.T, fieldName string, vud VUD) { + t.Helper() + // Value can be any number (including 0 or negative) + _ = vud.V + // Unit can be empty string for dimensionless values + _ = vud.U + // Decimals should be non-negative + if vud.D < 0 { + t.Errorf("%s: decimals (d) should be non-negative, got %d", fieldName, vud.D) + } +} + +// TestOpenDTUCompatibility_EventsValue tests the events field edge cases +// From WebApi_ws_live.cpp lines 206-210: +// - Returns event count if available +// - Returns -1 if not available +func TestOpenDTUCompatibility_EventsValue(t *testing.T) { + testCases := []struct { + name string + eventsValue int + expectedValid bool + }{ + {"No events", 0, true}, + {"Some events", 5, true}, + {"Many events", 100, true}, + {"Not available", -1, true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + // Parse test data and modify events value + data, err := loadTestData("livedata_producing.json") + if err != nil { + t.Fatalf("Failed to load test data: %v", err) + } + + // Parse, modify, and re-encode + var liveData LiveData + err = json.Unmarshal(data, &liveData) + if err != nil { + t.Fatalf("Failed to unmarshal: %v", err) + } + + liveData.Inverters[0].Events = tc.eventsValue + + // Re-marshal and unmarshal to test the value + modifiedData, _ := json.Marshal(liveData) + var testData LiveData + err = json.Unmarshal(modifiedData, &testData) + if err != nil { + t.Fatalf("Failed to unmarshal modified data: %v", err) + } + + if testData.Inverters[0].Events != tc.eventsValue { + t.Errorf("Expected events=%d, got %d", tc.eventsValue, testData.Inverters[0].Events) + } + }) + } +} diff --git a/testdata/events_response.json b/testdata/events_response.json new file mode 100644 index 0000000..1e98030 --- /dev/null +++ b/testdata/events_response.json @@ -0,0 +1,23 @@ +{ + "count": 3, + "events": [ + { + "message_id": 1, + "message": "Inverter start", + "start_time": 1634567890, + "end_time": 1634567950 + }, + { + "message_id": 2, + "message": "Grid fault", + "start_time": 1634568000, + "end_time": 1634568120 + }, + { + "message_id": 3, + "message": "Communication error", + "start_time": 1634568200, + "end_time": 0 + } + ] +} diff --git a/testdata/livedata_night.json b/testdata/livedata_night.json new file mode 100644 index 0000000..d9d1397 --- /dev/null +++ b/testdata/livedata_night.json @@ -0,0 +1,112 @@ +{ + "inverters": [ + { + "serial": "987654321012", + "name": "Dummy 02", + "order": 1, + "data_age": 20840, + "data_age_ms": 20840477, + "poll_enabled": false, + "reachable": false, + "producing": false, + "limit_relative": 100, + "limit_absolute": 2250, + "events": 0, + "AC": { + "0": { + "Power": {"v": 0, "u": "W", "d": 1}, + "Voltage": {"v": 0, "u": "V", "d": 1}, + "Current": {"v": 0, "u": "A", "d": 2}, + "Frequency": {"v": 0, "u": "Hz", "d": 2}, + "PowerFactor": {"v": 0, "u": "", "d": 3}, + "ReactivePower": {"v": 0, "u": "var", "d": 1} + } + }, + "DC": { + "0": { + "name": {"u": "X1A DMY000000000000001ZX"}, + "Power": {"v": 0, "u": "W", "d": 1}, + "Voltage": {"v": 0, "u": "V", "d": 1}, + "Current": {"v": 0, "u": "A", "d": 2}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 768.194, "u": "kWh", "d": 3}, + "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440} + }, + "1": { + "name": {"u": "X2B DMY000000000000002ZX"}, + "Power": {"v": 0, "u": "W", "d": 1}, + "Voltage": {"v": 0, "u": "V", "d": 1}, + "Current": {"v": 0, "u": "A", "d": 2}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 723.244, "u": "kWh", "d": 3}, + "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440} + }, + "2": { + "name": {"u": "X3C DMY000000000000003ZX"}, + "Power": {"v": 0, "u": "W", "d": 1}, + "Voltage": {"v": 0, "u": "V", "d": 1}, + "Current": {"v": 0, "u": "A", "d": 2}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 603.444, "u": "kWh", "d": 3}, + "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440} + }, + "3": { + "name": {"u": "X4D DMY000000000000004ZX"}, + "Power": {"v": 0, "u": "W", "d": 1}, + "Voltage": {"v": 0, "u": "V", "d": 1}, + "Current": {"v": 0, "u": "A", "d": 2}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 633.541, "u": "kWh", "d": 3}, + "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440} + }, + "4": { + "name": {"u": "X5E DMY000000000000005ZX"}, + "Power": {"v": 0, "u": "W", "d": 1}, + "Voltage": {"v": 0, "u": "V", "d": 1}, + "Current": {"v": 0, "u": "A", "d": 2}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 725.182, "u": "kWh", "d": 3}, + "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440} + }, + "5": { + "name": {"u": "X6F DMY000000000000006ZX"}, + "Power": {"v": 0, "u": "W", "d": 1}, + "Voltage": {"v": 0, "u": "V", "d": 1}, + "Current": {"v": 0, "u": "A", "d": 2}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 773.515, "u": "kWh", "d": 3}, + "Irradiation": {"v": 0, "u": "%", "d": 3, "max": 440} + } + }, + "INV": { + "0": { + "Power DC": {"v": 0, "u": "W", "d": 1}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 4227.12, "u": "kWh", "d": 3}, + "Temperature": {"v": 0, "u": "°C", "d": 1}, + "Efficiency": {"v": 0, "u": "%", "d": 3} + } + }, + "radio_stats": { + "tx_request": 147, + "tx_re_request": 0, + "rx_success": 0, + "rx_fail_nothing": 147, + "rx_fail_partial": 0, + "rx_fail_corrupt": 0, + "rssi": -62 + } + } + ], + "total": { + "Power": {"v": 0, "u": "W", "d": 0}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 16253.99, "u": "kWh", "d": 3} + }, + "hints": { + "time_sync": false, + "radio_problem": false, + "default_password": false, + "pin_mapping_issue": false + } +} diff --git a/testdata/livedata_producing.json b/testdata/livedata_producing.json new file mode 100644 index 0000000..98bc6fd --- /dev/null +++ b/testdata/livedata_producing.json @@ -0,0 +1,137 @@ +{ + "inverters": [ + { + "serial": "114173123456", + "name": "Hoymiles HM-800", + "order": 0, + "data_age": 0, + "data_age_ms": 124, + "poll_enabled": true, + "reachable": true, + "producing": true, + "limit_relative": 100.0, + "limit_absolute": 800.0, + "events": 2, + "AC": { + "0": { + "Power": {"v": 734.2, "u": "W", "d": 1}, + "Voltage": {"v": 230.1, "u": "V", "d": 1}, + "Current": {"v": 3.19, "u": "A", "d": 2}, + "Frequency": {"v": 50.02, "u": "Hz", "d": 2}, + "PowerFactor": {"v": 1.0, "u": "", "d": 3}, + "ReactivePower": {"v": 0.0, "u": "var", "d": 1} + } + }, + "DC": { + "0": { + "name": {"u": "String 1"}, + "Power": {"v": 381.3, "u": "W", "d": 1}, + "Voltage": {"v": 36.7, "u": "V", "d": 1}, + "Current": {"v": 10.39, "u": "A", "d": 2}, + "YieldDay": {"v": 3847, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 1247.531, "u": "kWh", "d": 3}, + "Irradiation": {"v": 87.3, "u": "%", "d": 3, "max": 440} + }, + "1": { + "name": {"u": "String 2"}, + "Power": {"v": 367.8, "u": "W", "d": 1}, + "Voltage": {"v": 35.2, "u": "V", "d": 1}, + "Current": {"v": 10.45, "u": "A", "d": 2}, + "YieldDay": {"v": 3712, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 1203.847, "u": "kWh", "d": 3}, + "Irradiation": {"v": 84.2, "u": "%", "d": 3, "max": 440} + } + }, + "INV": { + "0": { + "Temperature": {"v": 34.2, "u": "°C", "d": 1}, + "Efficiency": {"v": 97.8, "u": "%", "d": 3}, + "Power DC": {"v": 749.1, "u": "W", "d": 1}, + "YieldDay": {"v": 7559, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 2451.378, "u": "kWh", "d": 3} + } + }, + "radio_stats": { + "tx_request": 12345, + "tx_re_request": 234, + "rx_success": 12000, + "rx_fail_nothing": 50, + "rx_fail_partial": 30, + "rx_fail_corrupt": 21, + "rssi": -65.5 + } + }, + { + "serial": "114173654321", + "name": "Hoymiles HM-600", + "order": 1, + "data_age": 0, + "data_age_ms": 235, + "poll_enabled": true, + "reachable": true, + "producing": true, + "limit_relative": 100.0, + "limit_absolute": 600.0, + "events": 0, + "AC": { + "0": { + "Power": {"v": 542.7, "u": "W", "d": 1}, + "Voltage": {"v": 229.8, "u": "V", "d": 1}, + "Current": {"v": 2.36, "u": "A", "d": 2}, + "Frequency": {"v": 50.01, "u": "Hz", "d": 2}, + "PowerFactor": {"v": 1.0, "u": "", "d": 3}, + "ReactivePower": {"v": 0.0, "u": "var", "d": 1} + } + }, + "DC": { + "0": { + "name": {"u": "String 1"}, + "Power": {"v": 281.4, "u": "W", "d": 1}, + "Voltage": {"v": 32.1, "u": "V", "d": 1}, + "Current": {"v": 8.77, "u": "A", "d": 2}, + "YieldDay": {"v": 2834, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 923.156, "u": "kWh", "d": 3}, + "Irradiation": {"v": 81.4, "u": "%", "d": 3, "max": 440} + }, + "1": { + "name": {"u": "String 2"}, + "Power": {"v": 273.9, "u": "W", "d": 1}, + "Voltage": {"v": 31.8, "u": "V", "d": 1}, + "Current": {"v": 8.61, "u": "A", "d": 2}, + "YieldDay": {"v": 2756, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 897.423, "u": "kWh", "d": 3}, + "Irradiation": {"v": 79.2, "u": "%", "d": 3, "max": 440} + } + }, + "INV": { + "0": { + "Temperature": {"v": 32.8, "u": "°C", "d": 1}, + "Efficiency": {"v": 97.2, "u": "%", "d": 3}, + "Power DC": {"v": 555.3, "u": "W", "d": 1}, + "YieldDay": {"v": 5590, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 1820.579, "u": "kWh", "d": 3} + } + }, + "radio_stats": { + "tx_request": 8765, + "tx_re_request": 123, + "rx_success": 8600, + "rx_fail_nothing": 20, + "rx_fail_partial": 15, + "rx_fail_corrupt": 7, + "rssi": -72.3 + } + } + ], + "total": { + "Power": {"v": 1276.9, "u": "W", "d": 1}, + "YieldDay": {"v": 13149, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 4271.957, "u": "kWh", "d": 3} + }, + "hints": { + "time_sync": true, + "radio_problem": false, + "default_password": false, + "pin_mapping_issue": false + } +} diff --git a/testdata/livedata_warnings.json b/testdata/livedata_warnings.json new file mode 100644 index 0000000..2b2cb8e --- /dev/null +++ b/testdata/livedata_warnings.json @@ -0,0 +1,73 @@ +{ + "inverters": [ + { + "serial": "555444333222", + "name": "Dummy Warning", + "order": 2, + "data_age": 45, + "data_age_ms": 45231, + "poll_enabled": true, + "reachable": false, + "producing": false, + "limit_relative": 100.0, + "limit_absolute": -1, + "events": -1, + "AC": { + "0": { + "Power": {"v": 0, "u": "W", "d": 1}, + "Voltage": {"v": 229.4, "u": "V", "d": 1}, + "Current": {"v": 0, "u": "A", "d": 2}, + "Frequency": {"v": 50.01, "u": "Hz", "d": 2}, + "PowerFactor": {"v": 0.0, "u": "", "d": 3}, + "ReactivePower": {"v": 0.0, "u": "var", "d": 1} + } + }, + "DC": { + "0": { + "name": {"u": "X7G DMY000000000000007ZX"}, + "Power": {"v": 0, "u": "W", "d": 1}, + "Voltage": {"v": 0.0, "u": "V", "d": 1}, + "Current": {"v": 0.0, "u": "A", "d": 2}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 123.456, "u": "kWh", "d": 3}, + "Irradiation": {"v": 0.0, "u": "%", "d": 3, "max": 350} + } + }, + "INV": { + "0": { + "Power DC": {"v": 0.0, "u": "W", "d": 1}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 222.222, "u": "kWh", "d": 3}, + "Temperature": {"v": 18.4, "u": "°C", "d": 1}, + "Efficiency": {"v": 0.0, "u": "%", "d": 3} + } + }, + "BAT": { + "0": { + "Power": {"v": -120.5, "u": "W", "d": 1}, + "StateOfCharge": {"v": 64.2, "u": "%", "d": 1} + } + }, + "radio_stats": { + "tx_request": 0, + "tx_re_request": 0, + "rx_success": 0, + "rx_fail_nothing": 12, + "rx_fail_partial": 0, + "rx_fail_corrupt": 0, + "rssi": -80.0 + } + } + ], + "total": { + "Power": {"v": 0.0, "u": "W", "d": 1}, + "YieldDay": {"v": 0, "u": "Wh", "d": 0}, + "YieldTotal": {"v": 222.222, "u": "kWh", "d": 3} + }, + "hints": { + "time_sync": false, + "radio_problem": true, + "default_password": true, + "pin_mapping_issue": true + } +} diff --git a/testdata/test_config.json b/testdata/test_config.json new file mode 100644 index 0000000..0f3f665 --- /dev/null +++ b/testdata/test_config.json @@ -0,0 +1,10 @@ +{ + "db": "postgres://user:password@localhost:5432/opendtu", + "opendtu_address": "192.168.1.100", + "opendtu_auth": true, + "opendtu_username": "admin", + "opendtu_password": "secret123", + "timescaledb": true, + "tz": "Europe/Amsterdam", + "log_level": "INFO" +}