Add SEMP charger (#23919)

This commit is contained in:
premultiply 2025-10-12 14:42:51 +02:00 • committed by GitHub
parent a8e77a91b3
commit 9a9f73d6c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 1374 additions and 0 deletions

415
charger/semp.go Normal file
View file

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

View file

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

113
charger/semp/types.go Normal file
View file

@ -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"`
}

111
charger/semp_decorators.go Normal file
View file

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

513
charger/semp_test.go Normal file
View file

@ -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 = `<?xml version="1.0" encoding="UTF-8"?>
<Device2EM xmlns="http://www.sma.de/communication/schema/SEMP/v1">
<DeviceStatus>
<DeviceId>F-12345678-ABCDEF123456-00</DeviceId>
<Status>On</Status>
<EMSignalsAccepted>true</EMSignalsAccepted>
<PowerConsumption>
<PowerInfo>
<AveragePower>7400</AveragePower>
<Timestamp>1640995200</Timestamp>
<AveragingInterval>60</AveragingInterval>
</PowerInfo>
</PowerConsumption>
</DeviceStatus>
</Device2EM>`
mockDeviceStatusOffResponse = `<?xml version="1.0" encoding="UTF-8"?>
<Device2EM xmlns="http://www.sma.de/communication/schema/SEMP/v1">
<DeviceStatus>
<DeviceId>F-12345678-ABCDEF123456-00</DeviceId>
<Status>Off</Status>
<EMSignalsAccepted>false</EMSignalsAccepted>
<PowerConsumption>
<PowerInfo>
<AveragePower>0</AveragePower>
<Timestamp>1640995200</Timestamp>
<AveragingInterval>60</AveragingInterval>
</PowerInfo>
</PowerConsumption>
</DeviceStatus>
</Device2EM>`
mockDeviceStatusReadyResponse = `<?xml version="1.0" encoding="UTF-8"?>
<Device2EM xmlns="http://www.sma.de/communication/schema/SEMP/v1">
<DeviceStatus>
<DeviceId>F-12345678-ABCDEF123456-00</DeviceId>
<Status>Ready</Status>
<EMSignalsAccepted>true</EMSignalsAccepted>
<PowerConsumption>
<PowerInfo>
<AveragePower>0</AveragePower>
<Timestamp>1640995200</Timestamp>
<AveragingInterval>60</AveragingInterval>
</PowerInfo>
</PowerConsumption>
</DeviceStatus>
</Device2EM>`
mockPlanningRequestResponse = `<?xml version="1.0" encoding="UTF-8"?>
<Device2EM xmlns="http://www.sma.de/communication/schema/SEMP/v1">
<PlanningRequest>
<Timeframe>
<DeviceId>F-12345678-ABCDEF123456-00</DeviceId>
<EarliestStart>1640995200</EarliestStart>
<LatestEnd>1641006000</LatestEnd>
<MaxEnergy>10000</MaxEnergy>
</Timeframe>
</PlanningRequest>
</Device2EM>`
mockEmptyPlanningRequestResponse = `<?xml version="1.0" encoding="UTF-8"?>
<Device2EM xmlns="http://www.sma.de/communication/schema/SEMP/v1">
</Device2EM>`
mockDeviceInfoResponse = `<?xml version="1.0" encoding="UTF-8"?>
<Device2EM xmlns="http://www.sma.de/communication/schema/SEMP/v1">
<DeviceInfo>
<Identification>
<DeviceId>F-12345678-ABCDEF123456-00</DeviceId>
<DeviceName>Test Wallbox</DeviceName>
<DeviceType>EVCharger</DeviceType>
<DeviceSerial>123456</DeviceSerial>
<DeviceVendor>Test Vendor</DeviceVendor>
</Identification>
<Characteristics>
<MinPowerConsumption>0</MinPowerConsumption>
<MaxPowerConsumption>11000</MaxPowerConsumption>
</Characteristics>
<Capabilities>
<CurrentPower>
<Method>Measurement</Method>
</CurrentPower>
<Timestamps>
<AbsoluteTimestamps>true</AbsoluteTimestamps>
</Timestamps>
<Interruptions>
<InterruptionsAllowed>true</InterruptionsAllowed>
</Interruptions>
<Requests>
<OptionalEnergy>true</OptionalEnergy>
</Requests>
</Capabilities>
</DeviceInfo>
</Device2EM>`
mockDeviceInfoPhases1p3pResponse = `<?xml version="1.0" encoding="UTF-8"?>
<Device2EM xmlns="http://www.sma.de/communication/schema/SEMP/v1">
<DeviceInfo>
<Identification>
<DeviceId>F-12345678-ABCDEF123456-00</DeviceId>
<DeviceName>Test Wallbox</DeviceName>
<DeviceType>EVCharger</DeviceType>
<DeviceSerial>123456</DeviceSerial>
<DeviceVendor>Test Vendor</DeviceVendor>
</Identification>
<Characteristics>
<MinPowerConsumption>3600</MinPowerConsumption>
<MaxPowerConsumption>11000</MaxPowerConsumption>
</Characteristics>
<Capabilities>
<CurrentPower>
<Method>Measurement</Method>
</CurrentPower>
<Timestamps>
<AbsoluteTimestamps>true</AbsoluteTimestamps>
</Timestamps>
<Interruptions>
<InterruptionsAllowed>true</InterruptionsAllowed>
</Interruptions>
<Requests>
<OptionalEnergy>true</OptionalEnergy>
</Requests>
</Capabilities>
</DeviceInfo>
</Device2EM>`
mockParametersResponse = `<?xml version="1.0" encoding="UTF-8"?>
<Device2EM xmlns="http://www.sma.de/communication/schema/SEMP/v1">
<Parameters>
<Parameter>
<channelId>Measurement.ChaSess.WhIn</channelId>
<timestamp>2024-04-30T15:00:36.213Z</timestamp>
<value>12500.0</value>
</Parameter>
<Parameter>
<channelId>Measurement.Operation.Health</channelId>
<timestamp>2024-04-30T15:00:36.347Z</timestamp>
<value>307</value>
</Parameter>
</Parameters>
</Device2EM>`
)
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, "<DeviceInfo>"); start != -1 {
if end := strings.Index(h.infoResponse, "</Device2EM>"); end != -1 {
parts = append(parts, h.infoResponse[start:end])
}
}
// Add DeviceStatus
if start := strings.Index(h.statusResponse, "<DeviceStatus>"); start != -1 {
if end := strings.Index(h.statusResponse, "</Device2EM>"); end != -1 {
parts = append(parts, h.statusResponse[start:end])
}
}
// Add PlanningRequest if exists
if start := strings.Index(h.planningResponse, "<PlanningRequest>"); start != -1 {
if end := strings.Index(h.planningResponse, "</Device2EM>"); end != -1 {
parts = append(parts, h.planningResponse[start:end])
}
}
fullDoc := `<?xml version="1.0" encoding="UTF-8"?>
<Device2EM xmlns="http://www.sma.de/communication/schema/SEMP/v1">` +
strings.Join(parts, "") +
`</Device2EM>`
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(`<?xml version="1.0" encoding="UTF-8"?>
<Device2EM xmlns="http://www.sma.de/communication/schema/SEMP/v1">
</Device2EM>`))
}
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, "<On>true</On>")
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, "<RecommendedPowerConsumption>4140</RecommendedPowerConsumption>")
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, "<RecommendedPowerConsumption>11000</RecommendedPowerConsumption>")
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, "<RecommendedPowerConsumption>3680</RecommendedPowerConsumption>")
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, "<RecommendedPowerConsumption>11000</RecommendedPowerConsumption>")
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)
}

View file

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

View file

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

View file

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

21
util/request/xml.go Normal file
View file

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