add testing, reverse engineered / generated from OpenDTU source to improve compatibility testing and detecting regressions early.

This commit is contained in:
Pieter Hollander 2025-10-04 01:57:56 +02:00
parent 6b496a39ee
commit 99959726c8
Signed by: pieter
SSH key fingerprint: SHA256:HbX+9cBXsop9SuvL+mELd29sK+7DehFfdVweFVDtMSg
12 changed files with 2888 additions and 0 deletions

561
config_test.go Normal file
View file

@ -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
}()))
}

1
go.mod
View file

@ -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

3
go.sum
View file

@ -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=

466
integration_test.go Normal file
View file

@ -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)
}
})
}
}

436
main_persistence_test.go Normal file
View file

@ -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)
}
}

566
main_test.go Normal file
View file

@ -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)
}
}

View file

@ -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)
}
})
}
}

23
testdata/events_response.json vendored Normal file
View file

@ -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
}
]
}

112
testdata/livedata_night.json vendored Normal file
View file

@ -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
}
}

137
testdata/livedata_producing.json vendored Normal file
View file

@ -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
}
}

73
testdata/livedata_warnings.json vendored Normal file
View file

@ -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
}
}

10
testdata/test_config.json vendored Normal file
View file

@ -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"
}