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