415 lines
11 KiB
Go
415 lines
11 KiB
Go
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 time.Since(wb.conn.Updated()) >= 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)
|
|
}
|
|
}
|