From 9a9f73d6c253b90ceef054b64fac8c20e36ad30d Mon Sep 17 00:00:00 2001 From: premultiply <4681172+premultiply@users.noreply.github.com> Date: Sun, 12 Oct 2025 14:42:51 +0200 Subject: [PATCH] Add SEMP charger (#23919) --- charger/semp.go | 415 +++++++++++++++++ charger/semp/connection.go | 93 ++++ charger/semp/types.go | 113 +++++ charger/semp_decorators.go | 111 +++++ charger/semp_test.go | 513 +++++++++++++++++++++ templates/definition/charger/semp-sma.yaml | 28 ++ templates/definition/charger/semp.yaml | 42 ++ util/request/helper.go | 38 ++ util/request/xml.go | 21 + 9 files changed, 1374 insertions(+) create mode 100644 charger/semp.go create mode 100644 charger/semp/connection.go create mode 100644 charger/semp/types.go create mode 100644 charger/semp_decorators.go create mode 100644 charger/semp_test.go create mode 100644 templates/definition/charger/semp-sma.yaml create mode 100644 templates/definition/charger/semp.yaml create mode 100644 util/request/xml.go diff --git a/charger/semp.go b/charger/semp.go new file mode 100644 index 000000000..440c98b7f --- /dev/null +++ b/charger/semp.go @@ -0,0 +1,415 @@ +package charger + +// LICENSE + +// Copyright (c) evcc.io (andig, naltatis, premultiply) + +// This module is NOT covered by the MIT license. All rights reserved. + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +import ( + "context" + "fmt" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/charger/semp" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/sponsor" +) + +// SEMP charger implementation +type SEMP struct { + log *util.Logger + conn *semp.Connection + cache time.Duration + deviceG util.Cacheable[semp.Device2EM] + parametersG util.Cacheable[[]semp.Parameter] + phases int + current float64 + enabled bool + deviceID string + minPower int + maxPower int + hasStatusParam bool +} + +//go:generate go tool decorate -f decorateSEMP -b *SEMP -r api.Charger -t "api.PhaseSwitcher,Phases1p3p,func(int) error" -t "api.PhaseGetter,GetPhases,func() (int, error)" -t "api.ChargeRater,ChargedEnergy,func() (float64, error)" + +func init() { + registry.AddCtx("semp", NewSEMPFromConfig) +} + +// NewSEMPFromConfig creates a SEMP charger from generic config +func NewSEMPFromConfig(ctx context.Context, other map[string]interface{}) (api.Charger, error) { + cc := struct { + URI string + DeviceID string + Cache time.Duration + }{ + Cache: 5 * time.Second, + } + + if !sponsor.IsAuthorized() { + return nil, api.ErrSponsorRequired + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return NewSEMP(ctx, cc.URI, cc.DeviceID, cc.Cache) +} + +// NewSEMP creates a SEMP charger +func NewSEMP(ctx context.Context, uri, deviceID string, cache time.Duration) (api.Charger, error) { + log := util.NewLogger("semp") + + wb := &SEMP{ + log: log, + cache: cache, + phases: 3, + current: 6, + enabled: true, + deviceID: deviceID, + } + + // Initialize SEMP connection + wb.conn = semp.NewConnection(log, uri) + + // Setup cached device getter + wb.deviceG = util.ResettableCached(func() (semp.Device2EM, error) { + return wb.conn.GetDeviceXML() + }, cache) + + // Setup cached parameters getter + wb.parametersG = util.ResettableCached(func() ([]semp.Parameter, error) { + return wb.conn.GetParametersXML() + }, cache) + + // Auto-detect deviceID if not configured + if deviceID == "" { + doc, err := wb.deviceG.Get() + if err != nil { + return nil, fmt.Errorf("failed to retrieve device info: %w", err) + } + + if len(doc.DeviceInfo) == 0 { + return nil, fmt.Errorf("no device info found") + } + + // Use first device ID found + wb.deviceID = doc.DeviceInfo[0].Identification.DeviceID + log.DEBUG.Printf("auto-detected device ID: %s", wb.deviceID) + } + + var ( + phases1p3p func(int) error + getPhases func() (int, error) + chargedEnergy func() (float64, error) + ) + + // Check if device supports phase switching by checking power characteristics + info, err := wb.getDeviceInfo() + if err != nil { + return nil, err + } + + wb.minPower = info.Characteristics.MinPowerConsumption + wb.maxPower = info.Characteristics.MaxPowerConsumption + + // Assume Phase switching support if MinPowerConsumption < 4140W and MaxPowerConsumption > 4600W + if wb.minPower > 0 && wb.minPower < 4140 && wb.maxPower > 4600 { + phases1p3p = wb.phases1p3p + getPhases = wb.getPhases + } + + // Check if device supports charged energy reporting via Parameters endpoint + if _, err := wb.getParameter("Measurement.ChaSess.WhIn"); err == nil { + chargedEnergy = wb.chargedEnergy + } + + // Check if device supports status reporting via Parameters endpoint + if _, err := wb.getParameter("Measurement.Operation.EVeh.ChaStt"); err == nil { + wb.hasStatusParam = true + } + + wb.enabled, err = wb.Enabled() + if err != nil { + return nil, err + } + + go wb.heartbeat(ctx) + + return decorateSEMP(wb, phases1p3p, getPhases, chargedEnergy), nil +} + +// heartbeat ensures that device control updates are sent at least once per minute +func (wb *SEMP) heartbeat(ctx context.Context) { + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // Check if we need to send an update + if wb.conn.TimeSinceLastUpdate() >= time.Minute { + if err := wb.conn.SendDeviceControl(wb.deviceID, wb.calcPower(wb.enabled, wb.current, wb.phases)); err != nil { + wb.log.ERROR.Printf("heartbeat: failed to send update: %v", err) + } + } + case <-ctx.Done(): + return + } + } +} + +// getDeviceStatus retrieves device status from cached document +func (wb *SEMP) getDeviceStatus() (semp.DeviceStatus, error) { + doc, err := wb.deviceG.Get() + if err != nil { + return semp.DeviceStatus{}, err + } + + for _, status := range doc.DeviceStatus { + if status.DeviceID == wb.deviceID { + return status, nil + } + } + + return semp.DeviceStatus{}, fmt.Errorf("device %s not found in status response", wb.deviceID) +} + +// getDeviceInfo retrieves device info from cached document +func (wb *SEMP) getDeviceInfo() (semp.DeviceInfo, error) { + doc, err := wb.deviceG.Get() + if err != nil { + return semp.DeviceInfo{}, err + } + + for _, info := range doc.DeviceInfo { + if info.Identification.DeviceID == wb.deviceID { + return info, nil + } + } + + return semp.DeviceInfo{}, fmt.Errorf("device %s not found in info response", wb.deviceID) +} + +// hasPlanningRequest checks if planning request exists in cached document +func (wb *SEMP) hasPlanningRequest() (bool, error) { + doc, err := wb.deviceG.Get() + if err != nil { + return false, err + } + + for _, planningRequest := range doc.PlanningRequest { + for _, timeframe := range planningRequest.Timeframe { + if timeframe.DeviceID == wb.deviceID { + return true, nil + } + } + } + + return false, nil +} + +// getParameter retrieves a specific parameter value by channel ID +func (wb *SEMP) getParameter(channelID string) (string, error) { + parameters, err := wb.parametersG.Get() + if err != nil { + return "", err + } + + for _, param := range parameters { + if param.ChannelID == channelID { + return param.Value, nil + } + } + + return "", fmt.Errorf("parameter %s not found", channelID) +} + +func (wb *SEMP) calcPower(enabled bool, current float64, phases int) int { + if !enabled || current == 0 { + return 0 + } + + return min(max(int(230*float64(phases)*current), wb.minPower), wb.maxPower) +} + +// Status implements the api.Charger interface +func (wb *SEMP) Status() (api.ChargeStatus, error) { + if wb.hasStatusParam { + value, err := wb.getParameter("Measurement.Operation.EVeh.ChaStt") + if err != nil { + return api.StatusNone, err + } + + switch value { + case "200111": // "nicht verbunden" + return api.StatusA, nil + case "200112": // "verbunden" + return api.StatusB, nil + case "200113": // "Ladevorgang aktiv" + return api.StatusC, nil + default: + return api.StatusNone, fmt.Errorf("unknown status value: %s", value) + } + } + + status, err := wb.getDeviceStatus() + if err != nil { + return api.StatusNone, err + } + + // Check if there is a planning request/timeframe for this device + // If no planning request exists -> Status A (unplugged/disconnected) + hasPlanningRequest, err := wb.hasPlanningRequest() + if err != nil { + return api.StatusNone, err + } + + if !hasPlanningRequest { + return api.StatusA, nil + } + + // If status is "On" and power consumption > 0, the charger is actively charging -> Status C + if status.Status == semp.StatusOn && status.PowerInfo.AveragePower > 0 { + return api.StatusC, nil + } + + // Everything else (ready, waiting, etc.) -> Status B + return api.StatusB, nil +} + +// Enabled implements the api.Charger interface +func (wb *SEMP) Enabled() (bool, error) { + status, err := wb.getDeviceStatus() + if err != nil { + return false, err + } + + if status.EMSignalsAccepted { + if status.Status == semp.StatusOn { + return true, nil + } + + // work around wrong status reporting during phase switching (SMA Chargers...) + return wb.enabled, nil + } + + return false, nil +} + +// Enable implements the api.Charger interface +func (wb *SEMP) Enable(enable bool) error { + if err := wb.conn.SendDeviceControl(wb.deviceID, wb.calcPower(enable, wb.current, wb.phases)); err != nil { + return err + } + + wb.enabled = enable + wb.deviceG.Reset() + wb.parametersG.Reset() + + return nil +} + +// MaxCurrent implements the api.Charger interface +func (wb *SEMP) MaxCurrent(current int64) error { + return wb.MaxCurrentMillis(float64(current)) +} + +var _ api.ChargerEx = (*SEMP)(nil) + +// MaxCurrentMillis implements the api.ChargerEx interface +func (wb *SEMP) MaxCurrentMillis(current float64) error { + if err := wb.conn.SendDeviceControl(wb.deviceID, wb.calcPower(wb.enabled, current, wb.phases)); err != nil { + return err + } + + wb.current = current + wb.deviceG.Reset() + wb.parametersG.Reset() + + return nil +} + +var _ api.Meter = (*SEMP)(nil) + +// CurrentPower implements the api.Meter interface +func (wb *SEMP) CurrentPower() (float64, error) { + status, err := wb.getDeviceStatus() + if err != nil { + return 0, err + } + + return status.PowerInfo.AveragePower, nil +} + +// chargedEnergy implements the api.ChargeRater interface (via decorator) +func (wb *SEMP) chargedEnergy() (float64, error) { + value, err := wb.getParameter("Measurement.ChaSess.WhIn") + if err != nil { + return 0, err + } + + var energy float64 + if _, err := fmt.Sscanf(value, "%f", &energy); err != nil { + return 0, fmt.Errorf("failed to parse energy value '%s': %w", value, err) + } + + // Convert Wh to kWh + return energy / 1000, nil +} + +// phases1p3p implements the api.PhaseSwitcher interface +func (wb *SEMP) phases1p3p(phases int) error { + // SEMP protocol doesn't have explicit phase switching + if err := wb.conn.SendDeviceControl(wb.deviceID, wb.calcPower(wb.enabled, wb.current, phases)); err != nil { + return err + } + + wb.phases = phases + wb.deviceG.Reset() + wb.parametersG.Reset() + + return nil +} + +func (wb *SEMP) getPhases() (int, error) { + return wb.phases, nil +} + +var _ api.Diagnosis = (*SEMP)(nil) + +// Diagnose implements the api.Diagnosis interface +func (wb *SEMP) Diagnose() { + if status, err := wb.getDeviceStatus(); err == nil { + fmt.Printf("Device Status: %+v\n", status) + } else { + fmt.Printf("Device Status Error: %v\n", err) + } + + if info, err := wb.getDeviceInfo(); err == nil { + fmt.Printf("Device Info: %+v\n", info) + } else { + fmt.Printf("Device Info Error: %v\n", err) + } + + if hasPlanning, err := wb.hasPlanningRequest(); err == nil { + fmt.Printf("Planning Request: %t\n", hasPlanning) + } else { + fmt.Printf("Planning Request Error: %v\n", err) + } +} diff --git a/charger/semp/connection.go b/charger/semp/connection.go new file mode 100644 index 000000000..11fac830b --- /dev/null +++ b/charger/semp/connection.go @@ -0,0 +1,93 @@ +package semp + +import ( + "net/http" + "strings" + "sync" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" +) + +// Connection represents a SEMP HTTP connection with helper methods +type Connection struct { + *request.Helper + uri string + mu sync.Mutex + updated time.Time +} + +// NewConnection creates a new SEMP client +func NewConnection(log *util.Logger, uri string) *Connection { + // Ensure URI ends with exactly one trailing slash + uri = strings.TrimRight(uri, "/") + "/" + + return &Connection{ + Helper: request.NewHelper(log), + uri: uri, + } +} + +// GetDeviceXML retrieves the complete SEMP document from the base URL +func (c *Connection) GetDeviceXML() (Device2EM, error) { + var response Device2EM + if err := c.GetXML(c.uri, &response); err != nil { + return Device2EM{}, err + } + return response, nil +} + +// GetParametersXML retrieves device parameters from the /Parameters endpoint +func (c *Connection) GetParametersXML() ([]Parameter, error) { + var response Device2EM + uri := c.uri + "Parameters" + if err := c.GetXML(uri, &response); err != nil { + return nil, err + } + + if response.Parameters == nil { + return []Parameter{}, nil + } + + return response.Parameters.Parameter, nil +} + +// SendDeviceControl sends a control message to the SEMP device +// power is optional - if nil, RecommendedPowerConsumption will be omitted +func (c *Connection) SendDeviceControl(deviceId string, power int) error { + control := DeviceControl{ + DeviceID: deviceId, + On: power > 0, + Timestamp: int(time.Now().Unix()), + } + + if power > 0 { + control.RecommendedPowerConsumption = &power + } + + message := EM2Device{ + Xmlns: "http://www.sma.de/communication/schema/SEMP/v1", + DeviceControl: []DeviceControl{control}, + } + + req, err := request.New(http.MethodPost, c.uri, request.MarshalXML(message), request.XMLEncoding) + if err != nil { + return err + } + + _, err = c.DoBody(req) + if err == nil { + c.mu.Lock() + c.updated = time.Now() + c.mu.Unlock() + } + return err +} + +// TimeSinceLastUpdate returns the duration since the last successful device control update +func (c *Connection) TimeSinceLastUpdate() time.Duration { + c.mu.Lock() + defer c.mu.Unlock() + return time.Since(c.updated) +} diff --git a/charger/semp/types.go b/charger/semp/types.go new file mode 100644 index 000000000..ef2d19f4d --- /dev/null +++ b/charger/semp/types.go @@ -0,0 +1,113 @@ +package semp + +import ( + "encoding/xml" +) + +// Status constants +const ( + StatusOn = "On" + StatusOff = "Off" +) + +// DeviceInfo represents SEMP device information +type DeviceInfo struct { + Identification Identification `xml:"Identification"` + Characteristics Characteristics `xml:"Characteristics"` + Capabilities Capabilities `xml:"Capabilities"` +} + +// Identification represents SEMP device identification +type Identification struct { + DeviceID string `xml:"DeviceId"` + DeviceName string `xml:"DeviceName"` + DeviceType string `xml:"DeviceType"` + DeviceSerial string `xml:"DeviceSerial"` + DeviceVendor string `xml:"DeviceVendor"` +} + +// Characteristics represents SEMP device characteristics +type Characteristics struct { + MinPowerConsumption int `xml:"MinPowerConsumption"` + MaxPowerConsumption int `xml:"MaxPowerConsumption"` + MinOnTime int `xml:"MinOnTime,omitempty"` + MinOffTime int `xml:"MinOffTime,omitempty"` +} + +// Capabilities represents SEMP device capabilities +type Capabilities struct { + CurrentPowerMethod string `xml:"CurrentPower>Method"` + AbsoluteTimestamps bool `xml:"Timestamps>AbsoluteTimestamps"` + InterruptionsAllowed bool `xml:"Interruptions>InterruptionsAllowed"` + OptionalEnergy bool `xml:"Requests>OptionalEnergy"` +} + +// DeviceStatus represents SEMP device status +type DeviceStatus struct { + DeviceID string `xml:"DeviceId"` + Status string `xml:"Status"` + EMSignalsAccepted bool `xml:"EMSignalsAccepted"` + PowerInfo PowerInfo `xml:"PowerConsumption>PowerInfo"` +} + +// PowerInfo represents SEMP power information +type PowerInfo struct { + AveragePower float64 `xml:"AveragePower"` + Timestamp int `xml:"Timestamp"` + AveragingInterval int `xml:"AveragingInterval"` +} + +// DeviceControl represents SEMP device control message +type DeviceControl struct { + DeviceID string `xml:"DeviceId"` + On bool `xml:"On"` + RecommendedPowerConsumption *int `xml:"RecommendedPowerConsumption,omitempty"` + Timestamp int `xml:"Timestamp"` +} + +// PlanningRequest represents SEMP planning request +type PlanningRequest struct { + Timeframe []Timeframe `xml:"Timeframe"` +} + +// Timeframe represents SEMP timeframe +type Timeframe struct { + DeviceID string `xml:"DeviceId"` + EarliestStart int `xml:"EarliestStart"` + LatestEnd int `xml:"LatestEnd"` + MinRunningTime *int `xml:"MinRunningTime,omitempty"` + MaxRunningTime *int `xml:"MaxRunningTime,omitempty"` + MinEnergy *float64 `xml:"MinEnergy,omitempty"` + MaxEnergy *float64 `xml:"MaxEnergy,omitempty"` + MaxPowerConsumption *float64 `xml:"MaxPowerConsumption,omitempty"` + MinPowerConsumption *float64 `xml:"MinPowerConsumption,omitempty"` +} + +// Parameter represents a SEMP parameter with channel ID, timestamp and value +type Parameter struct { + ChannelID string `xml:"channelId"` + Timestamp string `xml:"timestamp"` + Value string `xml:"value"` +} + +// Parameters represents SEMP parameters collection +type Parameters struct { + Parameter []Parameter `xml:"Parameter"` +} + +// Device2EM represents the device to energy manager message +type Device2EM struct { + XMLName xml.Name `xml:"Device2EM"` + Xmlns string `xml:"xmlns,attr"` + DeviceInfo []DeviceInfo `xml:"DeviceInfo,omitempty"` + DeviceStatus []DeviceStatus `xml:"DeviceStatus,omitempty"` + PlanningRequest []PlanningRequest `xml:"PlanningRequest,omitempty"` + Parameters *Parameters `xml:"Parameters,omitempty"` +} + +// EM2Device represents the energy manager to device message +type EM2Device struct { + XMLName xml.Name `xml:"EM2Device"` + Xmlns string `xml:"xmlns,attr"` + DeviceControl []DeviceControl `xml:"DeviceControl,omitempty"` +} diff --git a/charger/semp_decorators.go b/charger/semp_decorators.go new file mode 100644 index 000000000..07fca7fb2 --- /dev/null +++ b/charger/semp_decorators.go @@ -0,0 +1,111 @@ +package charger + +// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT. + +import ( + "github.com/evcc-io/evcc/api" +) + +func decorateSEMP(base *SEMP, phaseSwitcher func(int) error, phaseGetter func() (int, error), chargeRater func() (float64, error)) api.Charger { + switch { + case chargeRater == nil && phaseSwitcher == nil: + return base + + case chargeRater == nil && phaseGetter == nil && phaseSwitcher != nil: + return &struct { + *SEMP + api.PhaseSwitcher + }{ + SEMP: base, + PhaseSwitcher: &decorateSEMPPhaseSwitcherImpl{ + phaseSwitcher: phaseSwitcher, + }, + } + + case chargeRater == nil && phaseGetter != nil && phaseSwitcher != nil: + return &struct { + *SEMP + api.PhaseGetter + api.PhaseSwitcher + }{ + SEMP: base, + PhaseGetter: &decorateSEMPPhaseGetterImpl{ + phaseGetter: phaseGetter, + }, + PhaseSwitcher: &decorateSEMPPhaseSwitcherImpl{ + phaseSwitcher: phaseSwitcher, + }, + } + + case chargeRater != nil && phaseSwitcher == nil: + return &struct { + *SEMP + api.ChargeRater + }{ + SEMP: base, + ChargeRater: &decorateSEMPChargeRaterImpl{ + chargeRater: chargeRater, + }, + } + + case chargeRater != nil && phaseGetter == nil && phaseSwitcher != nil: + return &struct { + *SEMP + api.ChargeRater + api.PhaseSwitcher + }{ + SEMP: base, + ChargeRater: &decorateSEMPChargeRaterImpl{ + chargeRater: chargeRater, + }, + PhaseSwitcher: &decorateSEMPPhaseSwitcherImpl{ + phaseSwitcher: phaseSwitcher, + }, + } + + case chargeRater != nil && phaseGetter != nil && phaseSwitcher != nil: + return &struct { + *SEMP + api.ChargeRater + api.PhaseGetter + api.PhaseSwitcher + }{ + SEMP: base, + ChargeRater: &decorateSEMPChargeRaterImpl{ + chargeRater: chargeRater, + }, + PhaseGetter: &decorateSEMPPhaseGetterImpl{ + phaseGetter: phaseGetter, + }, + PhaseSwitcher: &decorateSEMPPhaseSwitcherImpl{ + phaseSwitcher: phaseSwitcher, + }, + } + } + + return nil +} + +type decorateSEMPChargeRaterImpl struct { + chargeRater func() (float64, error) +} + +func (impl *decorateSEMPChargeRaterImpl) ChargedEnergy() (float64, error) { + return impl.chargeRater() +} + +type decorateSEMPPhaseGetterImpl struct { + phaseGetter func() (int, error) +} + +func (impl *decorateSEMPPhaseGetterImpl) GetPhases() (int, error) { + return impl.phaseGetter() +} + +type decorateSEMPPhaseSwitcherImpl struct { + phaseSwitcher func(int) error +} + +func (impl *decorateSEMPPhaseSwitcherImpl) Phases1p3p(p0 int) error { + return impl.phaseSwitcher(p0) +} diff --git a/charger/semp_test.go b/charger/semp_test.go new file mode 100644 index 000000000..8ecb7c6d6 --- /dev/null +++ b/charger/semp_test.go @@ -0,0 +1,513 @@ +package charger + +import ( + "context" + "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.NewServer(handler) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + 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 := wb.(api.Meter) + 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.NewServer(handler) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + 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 := wb.(api.Meter) + 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.NewServer(handler) + defer server.Close() + + ctx := context.Background() + + // NewSEMP now calls Enabled() which will fail if device is not found + _, err := NewSEMP(ctx, 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.NewServer(handler) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + 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.NewServer(handler) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + 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 := wb.(api.PhaseSwitcher) + 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.NewServer(handler) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + 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 := wb.(api.ChargeRater) + 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.NewServer(handler2) + defer server2.Close() + + ctx2 := context.Background() + + wb2, err := NewSEMP(ctx2, 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 := wb2.(api.ChargeRater) + 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.NewServer(handler) + defer server.Close() + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // 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) +} diff --git a/templates/definition/charger/semp-sma.yaml b/templates/definition/charger/semp-sma.yaml new file mode 100644 index 000000000..c00ca7bd1 --- /dev/null +++ b/templates/definition/charger/semp-sma.yaml @@ -0,0 +1,28 @@ +template: semp-sma +products: + - brand: SMA + description: + generic: EV Charger (SEMP) + - brand: SMA + description: + generic: eCharger (SEMP) +capabilities: ["mA", "1p3p"] +requirements: + evcc: ["sponsorship"] + description: + en: | + Configure the SEMP base URL (e.g. http://192.168.178.100/SEMP) and the device ID of the charger. + IMPORTANT: The charger must NOT be registered directly as a consumer in the Sunny Home Manager / Sunny Portal at the same time! + de: | + Konfiguriere die SEMP Basis-URL (z.B. http://192.168.178.100/SEMP) und die Geräte-ID des Chargers. + WICHTIG: Der Charger darf NICHT gleichzeitig im Sunny Home Manager bzw. im Sunny Portal direkt als Verbraucher registriert sein! +params: + - name: uri + description: + en: SEMP base URL + de: SEMP Basis-URL + example: http://192.168.178.100/SEMP + required: true +render: | + type: semp + uri: {{ .uri }} diff --git a/templates/definition/charger/semp.yaml b/templates/definition/charger/semp.yaml new file mode 100644 index 000000000..7ffb2782a --- /dev/null +++ b/templates/definition/charger/semp.yaml @@ -0,0 +1,42 @@ +template: semp-charger +products: + - description: + de: SEMP kompatibel + en: SEMP compatible +group: generic +capabilities: ["mA", "1p3p"] +requirements: + evcc: ["sponsorship"] + description: + en: | + SEMP (Smart Energy Management Protocol) compatible wallbox or device that can be controlled directly via HTTP/XML API. + Configure the SEMP base URL (e.g. http://192.168.1.100:8000/semp) and the device ID of the wallbox. + IMPORTANT: The wallbox must NOT be registered directly as a consumer in the Sunny Home Manager / Sunny Portal at the same time! + de: | + SEMP (Smart Energy Management Protocol) kompatible Wallbox oder Verbraucher, die direkt über HTTP/XML API gesteuert werden kann. + Konfiguriere die SEMP Basis-URL (z.B. http://192.168.1.100:8000/semp) und die Geräte-ID der Wallbox. + WICHTIG: Die Wallbox darf NICHT gleichzeitig im Sunny Home Manager bzw. im Sunny Portal direkt als Verbraucher registriert sein! +params: + - name: uri + description: + en: SEMP base URL + de: SEMP Basis-URL + example: http://192.168.0.10:8080/semp + required: true + - name: deviceid + description: + en: Device ID (auto-detected if empty) + de: Geräte-ID (automatische Erkennung wenn leer) + help: + en: Explicit specification of a specific Device ID is only required for SEMP gateways with multiple subordinate devices. + de: Die explizite Angabe einer bestimmten Device ID ist nur bei SEMP-Gateways mit mehreren untergeordneten Geräten erforderlich. + example: F-12345678-ABCDEF123456-00 + default: "" + required: false + advanced: true +render: | + type: semp + uri: {{ .uri }} + {{- if .deviceid }} + deviceId: {{ .deviceid }} + {{- end }} diff --git a/util/request/helper.go b/util/request/helper.go index 74af89647..c9c065627 100644 --- a/util/request/helper.go +++ b/util/request/helper.go @@ -2,6 +2,7 @@ package request import ( "encoding/json" + "encoding/xml" "net/http" "time" @@ -62,6 +63,16 @@ func decodeJSON(resp *http.Response, res interface{}) error { return json.NewDecoder(resp.Body).Decode(&res) } +// decodeXML reads HTTP response and decodes XML body if error is nil +func decodeXML(resp *http.Response, res interface{}) error { + if err := ResponseError(resp); err != nil { + _ = xml.NewDecoder(resp.Body).Decode(&res) + return err + } + + return xml.NewDecoder(resp.Body).Decode(&res) +} + // DoJSON executes HTTP request and decodes JSON response. // It returns a StatusError on response codes other than HTTP 2xx. func (r *Helper) DoJSON(req *http.Request, res interface{}) error { @@ -88,3 +99,30 @@ func (r *Helper) GetJSON(url string, res interface{}) error { return r.DoJSON(req, &res) } + +// DoXML executes HTTP request and decodes XML response. +// It returns a StatusError on response codes other than HTTP 2xx. +func (r *Helper) DoXML(req *http.Request, res interface{}) error { + resp, err := r.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNoContent { + return nil + } + + return decodeXML(resp, res) +} + +// GetXML executes HTTP GET request and decodes XML response. +// It returns a StatusError on response codes other than HTTP 2xx. +func (r *Helper) GetXML(url string, res interface{}) error { + req, err := New(http.MethodGet, url, nil, AcceptXML) + if err != nil { + return err + } + + return r.DoXML(req, res) +} diff --git a/util/request/xml.go b/util/request/xml.go new file mode 100644 index 000000000..ba8a39de8 --- /dev/null +++ b/util/request/xml.go @@ -0,0 +1,21 @@ +package request + +import ( + "bytes" + "encoding/xml" + "io" +) + +// MarshalXML marshals XML into an io.ReadSeeker +func MarshalXML(data interface{}) io.ReadSeeker { + if data == nil { + return nil + } + + body, err := xml.Marshal(data) + if err != nil { + return &errorReader{err: err} + } + + return bytes.NewReader(body) +}