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