Add SEMP charger (#23919)
This commit is contained in:
parent
a8e77a91b3
commit
9a9f73d6c2
9 changed files with 1374 additions and 0 deletions
415
charger/semp.go
Normal file
415
charger/semp.go
Normal 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)
|
||||
}
|
||||
}
|
||||
93
charger/semp/connection.go
Normal file
93
charger/semp/connection.go
Normal 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
113
charger/semp/types.go
Normal 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
111
charger/semp_decorators.go
Normal 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
513
charger/semp_test.go
Normal 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)
|
||||
}
|
||||
28
templates/definition/charger/semp-sma.yaml
Normal file
28
templates/definition/charger/semp-sma.yaml
Normal 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 }}
|
||||
42
templates/definition/charger/semp.yaml
Normal file
42
templates/definition/charger/semp.yaml
Normal 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 }}
|
||||
|
|
@ -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
21
util/request/xml.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue