package charger import ( "net/http" "net/http/httptest" "strings" "testing" "time" "github.com/evcc-io/evcc/api" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) // Mock SEMP server responses const ( mockDeviceStatusResponse = ` F-12345678-ABCDEF123456-00 On true 7400 1640995200 60 ` mockDeviceStatusOffResponse = ` F-12345678-ABCDEF123456-00 Off false 0 1640995200 60 ` mockDeviceStatusReadyResponse = ` F-12345678-ABCDEF123456-00 Ready true 0 1640995200 60 ` mockPlanningRequestResponse = ` F-12345678-ABCDEF123456-00 1640995200 1641006000 10000 ` mockEmptyPlanningRequestResponse = ` ` mockDeviceInfoResponse = ` F-12345678-ABCDEF123456-00 Test Wallbox EVCharger 123456 Test Vendor 0 11000 Measurement true true true ` mockDeviceInfoPhases1p3pResponse = ` F-12345678-ABCDEF123456-00 Test Wallbox EVCharger 123456 Test Vendor 3600 11000 Measurement true true true ` mockParametersResponse = ` Measurement.ChaSess.WhIn 2024-04-30T15:00:36.213Z 12500.0 Measurement.Operation.Health 2024-04-30T15:00:36.347Z 307 ` ) type sempTestHandler struct { statusResponse string planningResponse string infoResponse string parametersResponse string lastRequest string requestCount int } func (h *sempTestHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.requestCount++ // Handle POST to base URL for DeviceControl if r.Method == http.MethodPost { body := make([]byte, r.ContentLength) r.Body.Read(body) h.lastRequest = string(body) w.WriteHeader(http.StatusOK) return } // Handle GET requests switch r.URL.Path { case "/semp/": // Return complete SEMP document at base URL // Extract and combine the XML fragments var parts []string // Add DeviceInfo if start := strings.Index(h.infoResponse, ""); start != -1 { if end := strings.Index(h.infoResponse, ""); end != -1 { parts = append(parts, h.infoResponse[start:end]) } } // Add DeviceStatus if start := strings.Index(h.statusResponse, ""); start != -1 { if end := strings.Index(h.statusResponse, ""); end != -1 { parts = append(parts, h.statusResponse[start:end]) } } // Add PlanningRequest if exists if start := strings.Index(h.planningResponse, ""); start != -1 { if end := strings.Index(h.planningResponse, ""); end != -1 { parts = append(parts, h.planningResponse[start:end]) } } fullDoc := ` ` + strings.Join(parts, "") + `` w.Header().Set("Content-Type", "application/xml") w.Write([]byte(fullDoc)) case "/semp/DeviceStatus": // Legacy endpoint - still supported w.Header().Set("Content-Type", "application/xml") w.Write([]byte(h.statusResponse)) case "/semp/DeviceInfo": // Legacy endpoint - still supported w.Header().Set("Content-Type", "application/xml") w.Write([]byte(h.infoResponse)) case "/semp/PlanningRequest": // Legacy endpoint - still supported w.Header().Set("Content-Type", "application/xml") w.Write([]byte(h.planningResponse)) case "/semp/Parameters": // Parameters endpoint w.Header().Set("Content-Type", "application/xml") if h.parametersResponse != "" { w.Write([]byte(h.parametersResponse)) } else { // Return empty parameters if not set w.Write([]byte(` `)) } default: http.NotFound(w, r) } } func TestSEMPCharger(t *testing.T) { handler := &sempTestHandler{ statusResponse: mockDeviceStatusResponse, planningResponse: mockPlanningRequestResponse, infoResponse: mockDeviceInfoResponse, } server := httptest.NewTestServer(t, handler) server.Start() ctx := t.Context() wb, err := NewSEMP(ctx, server.URL+"/semp", "F-12345678-ABCDEF123456-00", time.Second) require.NoError(t, err) t.Run("Status", func(t *testing.T) { status, err := wb.Status() require.NoError(t, err) assert.Equal(t, api.StatusC, status) // Multiple requests during NewSEMP: device info, parameters, status checks assert.Greater(t, handler.requestCount, 0) }) t.Run("Enabled", func(t *testing.T) { handler.requestCount = 0 // Reset cache to force new request wb.(*SEMP).deviceG.Reset() enabled, err := wb.Enabled() require.NoError(t, err) assert.True(t, enabled) assert.Equal(t, 1, handler.requestCount) // Only DeviceStatus for Enabled }) t.Run("CurrentPower", func(t *testing.T) { handler.requestCount = 0 // Reset cache to force new request wb.(*SEMP).deviceG.Reset() meter, ok := api.Cap[api.Meter](wb) require.True(t, ok) power, err := meter.CurrentPower() require.NoError(t, err) assert.Equal(t, 7400.0, power) assert.Equal(t, 1, handler.requestCount) // Only DeviceStatus for CurrentPower }) t.Run("Enable", func(t *testing.T) { handler.requestCount = 0 // Reset caches to ensure fresh requests wb.(*SEMP).deviceG.Reset() err := wb.Enable(true) require.NoError(t, err) assert.Contains(t, handler.lastRequest, "true") assert.Contains(t, handler.lastRequest, "F-12345678-ABCDEF123456-00") // calcPower() returns 4140 because current is initialized to 6A: 230V * 3phases * 6A = 4140W assert.Contains(t, handler.lastRequest, "4140") assert.Equal(t, 1, handler.requestCount) // Only 1 POST for DeviceControl (getDeviceInfo is cached) }) t.Run("MaxCurrent", func(t *testing.T) { handler.requestCount = 0 err := wb.MaxCurrent(16) require.NoError(t, err) // calcPower() = 230 * 3 (phases) * 16 (current) = 11040, but limited to maxPower=11000 assert.Contains(t, handler.lastRequest, "11000") assert.Equal(t, 1, handler.requestCount) // Only DeviceControl }) } func TestSEMPChargerOff(t *testing.T) { handler := &sempTestHandler{ statusResponse: mockDeviceStatusOffResponse, planningResponse: mockEmptyPlanningRequestResponse, infoResponse: mockDeviceInfoResponse, } server := httptest.NewTestServer(t, handler) server.Start() ctx := t.Context() wb, err := NewSEMP(ctx, server.URL+"/semp", "F-12345678-ABCDEF123456-00", time.Second) require.NoError(t, err) t.Run("StatusOff", func(t *testing.T) { status, err := wb.Status() require.NoError(t, err) assert.Equal(t, api.StatusA, status) }) t.Run("DisabledWhenOff", func(t *testing.T) { enabled, err := wb.Enabled() require.NoError(t, err) assert.False(t, enabled) }) t.Run("CurrentPowerZero", func(t *testing.T) { meter, ok := api.Cap[api.Meter](wb) require.True(t, ok) power, err := meter.CurrentPower() require.NoError(t, err) assert.Equal(t, 0.0, power) }) } func TestSEMPChargerDeviceNotFound(t *testing.T) { handler := &sempTestHandler{ statusResponse: strings.Replace(mockDeviceStatusResponse, "F-12345678-ABCDEF123456-00", "DIFFERENT-DEVICE-ID", -1), planningResponse: mockPlanningRequestResponse, infoResponse: mockDeviceInfoResponse, } server := httptest.NewTestServer(t, handler) server.Start() // NewSEMP now calls Enabled() which will fail if device is not found _, err := NewSEMP(t.Context(), server.URL+"/semp", "F-12345678-ABCDEF123456-00", time.Second) require.Error(t, err) assert.Contains(t, err.Error(), "device F-12345678-ABCDEF123456-00 not found") } func TestSEMPChargerReady(t *testing.T) { handler := &sempTestHandler{ statusResponse: mockDeviceStatusReadyResponse, planningResponse: mockPlanningRequestResponse, infoResponse: mockDeviceInfoResponse, } server := httptest.NewTestServer(t, handler) server.Start() ctx := t.Context() wb, err := NewSEMP(ctx, server.URL+"/semp", "F-12345678-ABCDEF123456-00", time.Second) require.NoError(t, err) t.Run("StatusReady", func(t *testing.T) { status, err := wb.Status() require.NoError(t, err) assert.Equal(t, api.StatusB, status) // EMSignalsAccepted=true but Status!=On -> Status B }) t.Run("EnabledWhenReady", func(t *testing.T) { enabled, err := wb.Enabled() require.NoError(t, err) // Returns true because EMSignalsAccepted=true and wb.enabled=true (workaround for phase switching) assert.True(t, enabled) }) } func TestSEMPChargerPhases1p3p(t *testing.T) { handler := &sempTestHandler{ statusResponse: mockDeviceStatusResponse, planningResponse: mockPlanningRequestResponse, infoResponse: mockDeviceInfoPhases1p3pResponse, } server := httptest.NewTestServer(t, handler) server.Start() ctx := t.Context() wb, err := NewSEMP(ctx, server.URL+"/semp", "F-12345678-ABCDEF123456-00", time.Second) require.NoError(t, err) // Check if the charger supports phase switching phaseSwitcher, ok := api.Cap[api.PhaseSwitcher](wb) require.True(t, ok, "Expected charger to support phase switching") t.Run("SwitchTo1Phase", func(t *testing.T) { handler.requestCount = 0 // Enable the charger first so calcPower() returns a non-zero value err := wb.Enable(true) require.NoError(t, err) // Set current to have a predictable power calculation err = wb.MaxCurrent(16) require.NoError(t, err) err = phaseSwitcher.Phases1p3p(1) require.NoError(t, err) // calcPower() = 230 * 1 * 16 = 3680, but limited to minPower=3600 assert.Contains(t, handler.lastRequest, "3680") assert.Equal(t, 3, handler.requestCount) // Enable (1 POST) + MaxCurrent (1 POST) + Phases1p3p (1 POST) }) t.Run("SwitchTo3Phase", func(t *testing.T) { handler.requestCount = 0 err := phaseSwitcher.Phases1p3p(3) require.NoError(t, err) // calcPower() = 230 * 3 * 16 = 11040, but limited to maxPower=11000W assert.Contains(t, handler.lastRequest, "11000") assert.Equal(t, 1, handler.requestCount) // Only 1 POST for Phases1p3p }) } func TestSEMPChargerChargedEnergy(t *testing.T) { handler := &sempTestHandler{ statusResponse: mockDeviceStatusResponse, planningResponse: mockPlanningRequestResponse, infoResponse: mockDeviceInfoResponse, parametersResponse: mockParametersResponse, } server := httptest.NewTestServer(t, handler) server.Start() ctx := t.Context() wb, err := NewSEMP(ctx, server.URL+"/semp", "F-12345678-ABCDEF123456-00", time.Second) require.NoError(t, err) t.Run("ChargedEnergy", func(t *testing.T) { chargeRater, ok := api.Cap[api.ChargeRater](wb) require.True(t, ok) energy, err := chargeRater.ChargedEnergy() require.NoError(t, err) // Mock data has 12500.0 Wh, should be converted to 12.5 kWh assert.Equal(t, 12.5, energy) }) t.Run("ChargedEnergyNoParameters", func(t *testing.T) { // Test with handler that doesn't provide parameters handler2 := &sempTestHandler{ statusResponse: mockDeviceStatusResponse, planningResponse: mockPlanningRequestResponse, infoResponse: mockDeviceInfoResponse, // parametersResponse left empty } server2 := httptest.NewTestServer(t, handler2) server2.Start() wb2, err := NewSEMP(t.Context(), server2.URL+"/semp", "F-12345678-ABCDEF123456-00", time.Second) require.NoError(t, err) // ChargeRater interface should NOT be available when parameters are not supported _, ok := api.Cap[api.ChargeRater](wb2) assert.False(t, ok, "ChargeRater should not be available when device doesn't support parameters") }) } func TestSEMPChargerAutoDetectDeviceID(t *testing.T) { handler := &sempTestHandler{ statusResponse: mockDeviceStatusResponse, planningResponse: mockPlanningRequestResponse, infoResponse: mockDeviceInfoResponse, } server := httptest.NewTestServer(t, handler) server.Start() ctx := t.Context() // Create charger without deviceID - should auto-detect wb, err := NewSEMP(ctx, server.URL+"/semp", "", time.Second) require.NoError(t, err) // Check that deviceID was auto-detected assert.Equal(t, "F-12345678-ABCDEF123456-00", wb.(*SEMP).deviceID) // Verify charger is functional status, err := wb.Status() require.NoError(t, err) assert.Equal(t, api.StatusC, status) }