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