From 15958df88718f973eda352f0ae6ed23d60ffe592 Mon Sep 17 00:00:00 2001 From: Maschga <88616799+Maschga@users.noreply.github.com> Date: Sun, 14 Sep 2025 12:10:56 +0200 Subject: [PATCH] Config UI: split SHM HEMS into separate config (BC) (#23190) --- api/globalconfig/types.go | 2 + assets/js/components/Config/DeviceTags.vue | 1 + assets/js/components/Config/ShmModal.vue | 90 +++++++++++++++ .../components/Config/defaultYaml/hems.yaml | 7 -- assets/js/components/MaterialIcon/Shm.vue | 22 ++++ assets/js/types/evcc.ts | 7 ++ assets/js/views/Config.vue | 25 ++++- cmd/class_enumer.go | 44 ++++---- cmd/configure/main.go | 4 +- cmd/error.go | 1 + cmd/root.go | 8 +- cmd/setup.go | 20 +++- core/keys/global.go | 1 + hems/config.go | 10 +- hems/eebus/eebus.go | 4 +- hems/relay/relay.go | 4 +- hems/{semp => shm}/messages.go | 2 +- hems/{semp/semp.go => shm/shm.go} | 105 ++++++------------ i18n/de.json | 15 +++ i18n/en.json | 15 +++ server/http.go | 2 + server/http_config_helper.go | 8 ++ server/http_config_helper_test.go | 36 ++++++ tests/config-shm.spec.ts | 88 +++++++++++++++ 24 files changed, 407 insertions(+), 114 deletions(-) create mode 100644 assets/js/components/Config/ShmModal.vue create mode 100644 assets/js/components/MaterialIcon/Shm.vue rename hems/{semp => shm}/messages.go (99%) rename hems/{semp/semp.go => shm/shm.go} (85%) create mode 100644 tests/config-shm.spec.ts diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go index e47082c64..700147998 100644 --- a/api/globalconfig/types.go +++ b/api/globalconfig/types.go @@ -7,6 +7,7 @@ import ( "time" "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/hems/shm" "github.com/evcc-io/evcc/plugin/mqtt" "github.com/evcc-io/evcc/push" "github.com/evcc-io/evcc/server/eebus" @@ -33,6 +34,7 @@ type All struct { Influx Influx EEBus eebus.Config HEMS Hems + SHM shm.Config Messaging Messaging Meters []config.Named Chargers []config.Named diff --git a/assets/js/components/Config/DeviceTags.vue b/assets/js/components/Config/DeviceTags.vue index 36fa3354a..2395d5290 100644 --- a/assets/js/components/Config/DeviceTags.vue +++ b/assets/js/components/Config/DeviceTags.vue @@ -92,6 +92,7 @@ export default { case "singlePhase": case "enabled": case "configured": + case "allowControl": return value ? this.$t("config.deviceValue.yes") : this.$t("config.deviceValue.no"); diff --git a/assets/js/components/Config/ShmModal.vue b/assets/js/components/Config/ShmModal.vue new file mode 100644 index 000000000..c6d357cef --- /dev/null +++ b/assets/js/components/Config/ShmModal.vue @@ -0,0 +1,90 @@ + + + diff --git a/assets/js/components/Config/defaultYaml/hems.yaml b/assets/js/components/Config/defaultYaml/hems.yaml index 7eebf62c4..0cdd128a9 100644 --- a/assets/js/components/Config/defaultYaml/hems.yaml +++ b/assets/js/components/Config/defaultYaml/hems.yaml @@ -10,10 +10,3 @@ #type: eebus # general EEBus setup (cert gen) required #ski: "1234-5678-90AB-CDEF" # SKI of control box (grid operator) - -## SMA Sunny Home Manager 2.0, will change soon https://github.com/evcc-io/evcc/pull/23190 - -#type: sma -#allowcontrol: false -#vendorid: AAAAAAAA -#deviceid: BBBBBBBB diff --git a/assets/js/components/MaterialIcon/Shm.vue b/assets/js/components/MaterialIcon/Shm.vue new file mode 100644 index 000000000..260684ce6 --- /dev/null +++ b/assets/js/components/MaterialIcon/Shm.vue @@ -0,0 +1,22 @@ + + + diff --git a/assets/js/types/evcc.ts b/assets/js/types/evcc.ts index cfb963749..ee21fc617 100644 --- a/assets/js/types/evcc.ts +++ b/assets/js/types/evcc.ts @@ -37,6 +37,12 @@ export interface HemsConfig { type: any; } +export interface ShmConfig { + allowControl: boolean; + vendorId: string; + deviceId: string; +} + export interface FatalError { error: string; class?: string; @@ -61,6 +67,7 @@ export interface State { mqtt?: MqttConfig; influx?: InfluxConfig; hems?: HemsConfig; + shm?: ShmConfig; sponsor?: Sponsor; eebus?: any; modbusproxy?: []; diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue index d69f7f234..6452a68aa 100644 --- a/assets/js/views/Config.vue +++ b/assets/js/views/Config.vue @@ -294,6 +294,18 @@ + + + + + @@ -408,6 +421,8 @@ import formatter from "../mixins/formatter"; import GeneralConfig from "../components/Config/GeneralConfig.vue"; import HemsIcon from "../components/MaterialIcon/Hems.vue"; import HemsModal from "../components/Config/HemsModal.vue"; +import ShmIcon from "../components/MaterialIcon/Shm.vue"; +import ShmModal from "@/components/Config/ShmModal.vue"; import InfluxIcon from "../components/MaterialIcon/Influx.vue"; import InfluxModal from "../components/Config/InfluxModal.vue"; import LoadpointModal from "../components/Config/LoadpointModal.vue"; @@ -466,6 +481,8 @@ export default defineComponent({ GeneralConfig, HemsIcon, HemsModal, + ShmModal, + ShmIcon, InfluxIcon, InfluxModal, MessagingModal, @@ -614,6 +631,10 @@ export default defineComponent({ vehicleOptions() { return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name })); }, + shmTags() { + const { allowControl } = store.state?.shm || {}; + return { allowControl: { value: allowControl || false } }; + }, hemsTags() { const { type } = store.state?.hems || {}; if (!type) { @@ -623,7 +644,9 @@ export default defineComponent({ hemsType: {}, hemsActiveLimit: { value: null as number | null }, }; - result.hemsType = { value: type }; + if (["relay", "eebus"].includes(type)) { + result.hemsType = { value: type }; + } const lpc = store.state?.circuits?.["lpc"]; if (lpc) { const value = lpc.maxPower || null; diff --git a/cmd/class_enumer.go b/cmd/class_enumer.go index 475663f59..a3b8fecb2 100644 --- a/cmd/class_enumer.go +++ b/cmd/class_enumer.go @@ -7,11 +7,11 @@ import ( "strings" ) -const _ClassName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorshiploadpoint" +const _ClassName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsshminfluxmessengersponsorshiploadpoint" -var _ClassIndex = [...]uint8{0, 10, 15, 22, 29, 35, 42, 46, 50, 58, 69, 74, 84, 86, 90, 96, 105, 116, 125} +var _ClassIndex = [...]uint8{0, 10, 15, 22, 29, 35, 42, 46, 50, 58, 69, 74, 84, 86, 90, 93, 99, 108, 119, 128} -const _ClassLowerName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorshiploadpoint" +const _ClassLowerName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsshminfluxmessengersponsorshiploadpoint" func (i Class) String() string { i -= 1 @@ -39,13 +39,14 @@ func _ClassNoOp() { _ = x[ClassJavascript-(12)] _ = x[ClassGo-(13)] _ = x[ClassHEMS-(14)] - _ = x[ClassInflux-(15)] - _ = x[ClassMessenger-(16)] - _ = x[ClassSponsorship-(17)] - _ = x[ClassLoadpoint-(18)] + _ = x[ClassSHM-(15)] + _ = x[ClassInflux-(16)] + _ = x[ClassMessenger-(17)] + _ = x[ClassSponsorship-(18)] + _ = x[ClassLoadpoint-(19)] } -var _ClassValues = []Class{ClassConfigFile, ClassMeter, ClassCharger, ClassVehicle, ClassTariff, ClassCircuit, ClassSite, ClassMqtt, ClassDatabase, ClassModbusProxy, ClassEEBus, ClassJavascript, ClassGo, ClassHEMS, ClassInflux, ClassMessenger, ClassSponsorship, ClassLoadpoint} +var _ClassValues = []Class{ClassConfigFile, ClassMeter, ClassCharger, ClassVehicle, ClassTariff, ClassCircuit, ClassSite, ClassMqtt, ClassDatabase, ClassModbusProxy, ClassEEBus, ClassJavascript, ClassGo, ClassHEMS, ClassSHM, ClassInflux, ClassMessenger, ClassSponsorship, ClassLoadpoint} var _ClassNameToValueMap = map[string]Class{ _ClassName[0:10]: ClassConfigFile, @@ -76,14 +77,16 @@ var _ClassNameToValueMap = map[string]Class{ _ClassLowerName[84:86]: ClassGo, _ClassName[86:90]: ClassHEMS, _ClassLowerName[86:90]: ClassHEMS, - _ClassName[90:96]: ClassInflux, - _ClassLowerName[90:96]: ClassInflux, - _ClassName[96:105]: ClassMessenger, - _ClassLowerName[96:105]: ClassMessenger, - _ClassName[105:116]: ClassSponsorship, - _ClassLowerName[105:116]: ClassSponsorship, - _ClassName[116:125]: ClassLoadpoint, - _ClassLowerName[116:125]: ClassLoadpoint, + _ClassName[90:93]: ClassSHM, + _ClassLowerName[90:93]: ClassSHM, + _ClassName[93:99]: ClassInflux, + _ClassLowerName[93:99]: ClassInflux, + _ClassName[99:108]: ClassMessenger, + _ClassLowerName[99:108]: ClassMessenger, + _ClassName[108:119]: ClassSponsorship, + _ClassLowerName[108:119]: ClassSponsorship, + _ClassName[119:128]: ClassLoadpoint, + _ClassLowerName[119:128]: ClassLoadpoint, } var _ClassNames = []string{ @@ -101,10 +104,11 @@ var _ClassNames = []string{ _ClassName[74:84], _ClassName[84:86], _ClassName[86:90], - _ClassName[90:96], - _ClassName[96:105], - _ClassName[105:116], - _ClassName[116:125], + _ClassName[90:93], + _ClassName[93:99], + _ClassName[99:108], + _ClassName[108:119], + _ClassName[119:128], } // ClassString retrieves an enum value from the enum constants string name. diff --git a/cmd/configure/main.go b/cmd/configure/main.go index 9f0d1ffc8..af40d29e4 100644 --- a/cmd/configure/main.go +++ b/cmd/configure/main.go @@ -13,7 +13,7 @@ import ( "github.com/BurntSushi/toml" "github.com/cloudfoundry/jibber_jabber" - "github.com/evcc-io/evcc/hems/semp" + "github.com/evcc-io/evcc/hems/shm" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/machine" "github.com/evcc-io/evcc/util/templates" @@ -277,7 +277,7 @@ func (c *CmdConfigure) configureDevices(deviceCategory DeviceCategory, askAdding // configureSMAHems asks the user if he wants to add the SMA HEMS func (c *CmdConfigure) configureSMAHems() { // check if the system provides a machine-id - if _, err := semp.UniqueDeviceID(); err != nil { + if _, err := shm.UniqueDeviceID(); err != nil { return } diff --git a/cmd/error.go b/cmd/error.go index 22459b0cc..f7d403d1d 100644 --- a/cmd/error.go +++ b/cmd/error.go @@ -24,6 +24,7 @@ const ( ClassJavascript ClassGo ClassHEMS + ClassSHM ClassInflux ClassMessenger ClassSponsorship diff --git a/cmd/root.go b/cmd/root.go index d8e2b0b92..0cc93bdce 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -252,9 +252,14 @@ func runRoot(cmd *cobra.Command, args []string) { err = configureMDNS(conf.Network) } + // start SHM server + if err == nil { + err = wrapErrorWithClass(ClassSHM, configureSHM(&conf.SHM, site, httpd)) + } + // start HEMS server if err == nil { - err = wrapErrorWithClass(ClassHEMS, configureHEMS(&conf.HEMS, site, httpd)) + err = wrapErrorWithClass(ClassHEMS, configureHEMS(&conf.HEMS, site)) } // setup MCP @@ -277,6 +282,7 @@ func runRoot(cmd *cobra.Command, args []string) { // publish initial settings valueChan <- util.Param{Key: keys.EEBus, Val: conf.EEBus.Configured()} valueChan <- util.Param{Key: keys.Hems, Val: conf.HEMS} + valueChan <- util.Param{Key: keys.Shm, Val: conf.SHM} valueChan <- util.Param{Key: keys.Influx, Val: conf.Influx} valueChan <- util.Param{Key: keys.Interval, Val: conf.Interval} valueChan <- util.Param{Key: keys.Messaging, Val: conf.Messaging.Configured()} diff --git a/cmd/setup.go b/cmd/setup.go index 77ee06ff7..35ed78c76 100644 --- a/cmd/setup.go +++ b/cmd/setup.go @@ -26,6 +26,7 @@ import ( "github.com/evcc-io/evcc/core/session" coresettings "github.com/evcc-io/evcc/core/settings" "github.com/evcc-io/evcc/hems" + "github.com/evcc-io/evcc/hems/shm" "github.com/evcc-io/evcc/meter" "github.com/evcc-io/evcc/plugin/golang" "github.com/evcc-io/evcc/plugin/javascript" @@ -674,6 +675,21 @@ func configureMqtt(conf *globalconfig.Mqtt) error { return nil } +// setup SHM +func configureSHM(conf *shm.Config, site *core.Site, httpd *server.HTTPd) error { + if settings.Exists(keys.Shm) { + if err := settings.Json(keys.Shm, &conf); err != nil { + return err + } + } + + if err := shm.NewFromConfig(*conf, site, httpd.Addr, httpd.Router()); err != nil { + return fmt.Errorf("failed configuring shm: %w", err) + } + + return nil +} + // setup javascript func configureJavascript(conf []globalconfig.Javascript) error { for _, cc := range conf { @@ -695,7 +711,7 @@ func configureGo(conf []globalconfig.Go) error { } // setup HEMS -func configureHEMS(conf *globalconfig.Hems, site *core.Site, httpd *server.HTTPd) error { +func configureHEMS(conf *globalconfig.Hems, site *core.Site) error { // migrate settings if settings.Exists(keys.Hems) { *conf = globalconfig.Hems{} @@ -713,7 +729,7 @@ func configureHEMS(conf *globalconfig.Hems, site *core.Site, httpd *server.HTTPd return fmt.Errorf("cannot decode custom hems '%s': %w", conf.Type, err) } - hems, err := hems.NewFromConfig(context.TODO(), conf.Type, props, site, httpd) + hems, err := hems.NewFromConfig(context.TODO(), conf.Type, props, site) if err != nil { return fmt.Errorf("failed configuring hems: %w", err) } diff --git a/core/keys/global.go b/core/keys/global.go index 74e8a7d46..1f9cf4b66 100644 --- a/core/keys/global.go +++ b/core/keys/global.go @@ -10,6 +10,7 @@ const ( Influx = "influx" EEBus = "eebus" Hems = "hems" + Shm = "shm" Messaging = "messaging" ModbusProxy = "modbusproxy" Tariffs = "tariffs" diff --git a/hems/config.go b/hems/config.go index d388a065a..4f7e9f5dc 100644 --- a/hems/config.go +++ b/hems/config.go @@ -8,8 +8,6 @@ import ( "github.com/evcc-io/evcc/core/site" "github.com/evcc-io/evcc/hems/eebus" "github.com/evcc-io/evcc/hems/relay" - "github.com/evcc-io/evcc/hems/semp" - "github.com/evcc-io/evcc/server" ) // HEMS describes the HEMS system interface @@ -18,14 +16,14 @@ type HEMS interface { } // NewFromConfig creates new HEMS from config -func NewFromConfig(ctx context.Context, typ string, other map[string]interface{}, site site.API, httpd *server.HTTPd) (HEMS, error) { +func NewFromConfig(ctx context.Context, typ string, other map[string]interface{}, site site.API) (HEMS, error) { switch strings.ToLower(typ) { case "sma", "shm", "semp": - return semp.New(other, site, httpd) + return nil, errors.New("breaking change: Sunny Home Manager integration is always on. See https://github.com/evcc-io/evcc/releases and https://docs.evcc.io/en/docs/integrations/sma-sunny-home-manager") case "eebus": - return eebus.New(ctx, other, site) + return eebus.NewFromConfig(ctx, other, site) case "relay": - return relay.New(ctx, other, site) + return relay.NewFromConfig(ctx, other, site) default: return nil, errors.New("unknown hems: " + typ) } diff --git a/hems/eebus/eebus.go b/hems/eebus/eebus.go index 7edd4e470..12c8d5bae 100644 --- a/hems/eebus/eebus.go +++ b/hems/eebus/eebus.go @@ -42,8 +42,8 @@ type Limits struct { FailsafeDurationMinimum time.Duration } -// New creates an EEBus HEMS from generic config -func New(ctx context.Context, other map[string]interface{}, site site.API) (*EEBus, error) { +// NewFromConfig creates an EEBus HEMS from generic config +func NewFromConfig(ctx context.Context, other map[string]interface{}, site site.API) (*EEBus, error) { cc := struct { Ski string Limits `mapstructure:",squash"` diff --git a/hems/relay/relay.go b/hems/relay/relay.go index 7b098caa0..3e72fb458 100644 --- a/hems/relay/relay.go +++ b/hems/relay/relay.go @@ -22,8 +22,8 @@ type Relay struct { interval time.Duration } -// New creates an Relay HEMS from generic config -func New(ctx context.Context, other map[string]interface{}, site site.API) (*Relay, error) { +// NewFromConfig creates an Relay HEMS from generic config +func NewFromConfig(ctx context.Context, other map[string]interface{}, site site.API) (*Relay, error) { cc := struct { MaxPower float64 Limit plugin.Config diff --git a/hems/semp/messages.go b/hems/shm/messages.go similarity index 99% rename from hems/semp/messages.go rename to hems/shm/messages.go index ef7a53df3..5fe5cb596 100644 --- a/hems/semp/messages.go +++ b/hems/shm/messages.go @@ -1,4 +1,4 @@ -package semp +package shm import "encoding/xml" diff --git a/hems/semp/semp.go b/hems/shm/shm.go similarity index 85% rename from hems/semp/semp.go rename to hems/shm/shm.go index 91459bfc6..3e7cdc984 100644 --- a/hems/semp/semp.go +++ b/hems/shm/shm.go @@ -1,4 +1,4 @@ -package semp +package shm import ( "encoding/binary" @@ -15,7 +15,6 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/core/site" - "github.com/evcc-io/evcc/server" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/machine" "github.com/google/uuid" @@ -39,8 +38,6 @@ var serverName = "EVCC SEMP Server " + util.Version // SEMP is the SMA SEMP server type SEMP struct { log *util.Logger - closeC chan struct{} - doneC chan struct{} controllable bool vid string did []byte @@ -50,65 +47,66 @@ type SEMP struct { site site.API } -// New generates SEMP Gateway listening at /semp endpoint -func New(conf map[string]interface{}, site site.API, httpd *server.HTTPd) (*SEMP, error) { - cc := struct { - VendorID string - DeviceID string - AllowControl bool - }{ - VendorID: "28081973", - } +type Config struct { + AllowControl bool `json:"allowControl"` + VendorId string `json:"vendorId"` + DeviceId string `json:"deviceId"` +} - if err := util.DecodeOther(conf, &cc); err != nil { - return nil, err +// NewFromConfig creates a new SEMP instance from configuration and starts it +func NewFromConfig(cfg Config, site site.API, addr string, router *mux.Router) error { + vendorId := cfg.VendorId + if vendorId == "" { + vendorId = "28081973" + } else if len(vendorId) != 8 { + return fmt.Errorf("invalid vendor id: %v. Must be 8 characters HEX string", vendorId) } uid, err := uuid.NewUUID() if err != nil { - return nil, err - } - - if len(cc.VendorID) != 8 { - return nil, fmt.Errorf("invalid vendor id: %v", cc.VendorID) + return err } var did []byte - if cc.DeviceID == "" { + if cfg.DeviceId == "" { if did, err = UniqueDeviceID(); err != nil { - return nil, fmt.Errorf("creating device id: %w", err) + return fmt.Errorf("creating device id: %w", err) } } else { - if did, err = hex.DecodeString(cc.DeviceID); err != nil { - return nil, fmt.Errorf("device id: %w", err) + if did, err = hex.DecodeString(cfg.DeviceId); err != nil { + return fmt.Errorf("device id: %w", err) } } if len(did) != 6 { - return nil, fmt.Errorf("invalid device id: %v", cc.DeviceID) + return fmt.Errorf("invalid device id: %v. Must be 12 characters HEX string", cfg.DeviceId) } s := &SEMP{ - doneC: make(chan struct{}), log: util.NewLogger("semp"), site: site, uid: uid.String(), - vid: cc.VendorID, + vid: vendorId, did: did, - controllable: cc.AllowControl, + controllable: cfg.AllowControl, } // find external port - _, port, err := net.SplitHostPort(httpd.Addr) + // TODO refactor network config + _, port, err := net.SplitHostPort(addr) if err == nil { s.port, err = strconv.Atoi(port) } + if err != nil { + return err + } s.hostURI = s.callbackURI() - s.handlers(httpd.Router()) + s.handlers(router) - return s, err + go s.run() + return nil } func (s *SEMP) advertise(st, usn string) (*ssdp.Advertiser, error) { @@ -116,13 +114,8 @@ func (s *SEMP) advertise(st, usn string) (*ssdp.Advertiser, error) { return ssdp.Advertise(st, usn, descriptor, serverName, maxAge) } -// Run executes the SEMP runtime -func (s *SEMP) Run() { - if s.closeC != nil { - panic("already running") - } - s.closeC = make(chan struct{}) - +// run executes the SEMP runtime +func (s *SEMP) run() { uid := "uuid:" + s.uid var ads []*ssdp.Advertiser @@ -139,40 +132,13 @@ func (s *SEMP) Run() { ads = append(ads, ad) } -ANNOUNCE: - for tick := time.Tick(maxAge * time.Second / 2); ; { - select { - case <-tick: - for _, ad := range ads { - if err := ad.Alive(); err != nil { - s.log.ERROR.Println(err) - } + for range time.Tick(maxAge * time.Second / 2) { + for _, ad := range ads { + if err := ad.Alive(); err != nil { + s.log.ERROR.Println(err) } - case <-s.closeC: - break ANNOUNCE } } - - for _, ad := range ads { - if err := ad.Bye(); err != nil { - s.log.ERROR.Println(err) - } - } - - close(s.doneC) -} - -// Stop stops the SEMP runtime -func (s *SEMP) Stop() { - if s.closeC == nil { - panic("not running") - } - close(s.closeC) -} - -// Done returns the done channel. The channel is closed after byebye has been sent. -func (s *SEMP) Done() chan struct{} { - return s.doneC } func (s *SEMP) callbackURI() string { @@ -189,7 +155,6 @@ func (s *SEMP) callbackURI() string { } uri := fmt.Sprintf("http://%s:%d", ip, s.port) - s.log.WARN.Printf("%s unspecified, using %s instead", sempBaseURLEnv, uri) return uri } diff --git a/i18n/de.json b/i18n/de.json index 76ddba559..a2fe29152 100644 --- a/i18n/de.json +++ b/i18n/de.json @@ -77,6 +77,7 @@ "title": "Regelverhalten" }, "deviceValue": { + "allowControl": "Steuerung erlauben", "amount": "Anzahl", "broker": "Broker", "bucket": "Bucket", @@ -431,6 +432,20 @@ "system": "System", "vehicles": "Fahrzeuge" }, + "shm": { + "cardTitle": "Sunny Home Manager", + "description": "evcc verfügt über eine Integration für den SMA Sunny Home Manager 2.0. Bei Betrieb im selben Netzwerk sollte deine Sunny Home Manager-Oberfläche dich auffordern, evcc hinzuzufügen. Es sind keine weiteren Anpassungen erforderlich.", + "descriptionDeviceId": "12-stelliger HEX-String. Präfix für alle Geräte (Ladepunkt, ..).", + "descriptionIdPattern": "Kennungsmuster", + "descriptionIds": "Im Sunny Home Manager benötigt jedes Gerät (Ladepunkt, ..) eine eindeutige Kennung. evcc generiert basierend auf deiner Hardware eine eindeutige Kennung. Wenn du evcc auf andere Hardware migrierst, können sich diese Kennungen ändern. Um den Verlauf beizubehalten, kannst du die generierten Kennungen hier überschreiben. Öffne die SEMP-URL, um deine aktuellen Kennungen zu überprüfen.", + "descriptionSempUrl": "SEMP-URL", + "descriptionVendorId": "8-stelliger HEX-String. Allgemeines Präfix aller Entitäten.", + "labelAllowControl": "Globale Leistungsbegrenzungsanfragen von SHM akzeptieren.", + "labelControl": "Steuerung erlauben", + "labelDeviceId": "Geräte-ID", + "labelVendorId": "Hersteller-ID", + "title": "SMA Sunny Home Manager 2.0" + }, "sponsor": { "addToken": "Token eingeben", "changeToken": "Token ändern", diff --git a/i18n/en.json b/i18n/en.json index a9f2cbcd7..e40a24381 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -77,6 +77,7 @@ "title": "Control behavior" }, "deviceValue": { + "allowControl": "Allow control", "amount": "Amount", "broker": "Broker", "bucket": "Bucket", @@ -431,6 +432,20 @@ "system": "System", "vehicles": "Vehicles" }, + "shm": { + "cardTitle": "Sunny Home Manager", + "description": "evcc comes with integration for the SMA Sunny Home Manager 2.0. When run in the same network your Sunny Home Manager interface should prompt you to add evcc. Everything should work out of the box without need for adjustments here.", + "descriptionDeviceId": "12 characters HEX string. Prefix for all devices (charging point, ..).", + "descriptionIdPattern": "Identifier pattern", + "descriptionIds": "In Sunny Home Manager every device (charging point, ..) needs a unique identifier. evcc generates a unique identifier based on your hardware. If you migrate evcc to another hardware these identifiers might change. If you want to maintain history you can override the generated identifiers here. Open the SEMP URL to check your current identifiers.", + "descriptionSempUrl": "SEMP URL", + "descriptionVendorId": "8 characters HEX string. General prefix of all entities.", + "labelAllowControl": "Accept global power limit requests from SHM.", + "labelControl": "Allow control", + "labelDeviceId": "Device ID", + "labelVendorId": "Vendor ID", + "title": "SMA Sunny Home Manager 2.0" + }, "sponsor": { "addToken": "Enter token", "changeToken": "Change token", diff --git a/server/http.go b/server/http.go index 327246f1a..d601d1b7a 100644 --- a/server/http.go +++ b/server/http.go @@ -12,6 +12,7 @@ import ( "github.com/evcc-io/evcc/core/keys" "github.com/evcc-io/evcc/core/loadpoint" "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/hems/shm" "github.com/evcc-io/evcc/server/assets" "github.com/evcc-io/evcc/server/eebus" "github.com/evcc-io/evcc/util" @@ -308,6 +309,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Par for key, fun := range map[string]func() any{ keys.Network: func() any { return new(globalconfig.Network) }, // has default keys.Mqtt: func() any { return new(globalconfig.Mqtt) }, // has default + keys.Shm: func() any { return new(shm.Config) }, keys.Influx: func() any { return new(globalconfig.Influx) }, } { routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetJsonHandler(key, valueChan, fun)} diff --git a/server/http_config_helper.go b/server/http_config_helper.go index 701809216..a22d754df 100644 --- a/server/http_config_helper.go +++ b/server/http_config_helper.go @@ -325,6 +325,14 @@ func mergeMaskedAny(old, new any) error { type maskedTransformer struct{} func (maskedTransformer) Transformer(typ reflect.Type) func(dst, src reflect.Value) error { + // Only provide transformer for booleans to prevent them from being merged + if typ.Kind() == reflect.Bool { + return func(dst, src reflect.Value) error { + // Keep dst value, don't merge + return nil + } + } + if typ.Kind() != reflect.String { return nil } diff --git a/server/http_config_helper_test.go b/server/http_config_helper_test.go index 95adb6cec..4cd433437 100644 --- a/server/http_config_helper_test.go +++ b/server/http_config_helper_test.go @@ -52,6 +52,12 @@ type testStruct struct { Field2 int } +type testStructWithBool struct { + Field1 string + Field2 int + Field3 bool +} + func TestMergeMaskedAny(t *testing.T) { tests := []struct { old any @@ -73,6 +79,36 @@ func TestMergeMaskedAny(t *testing.T) { require.NoError(t, mergeMaskedAny(tc.old, tc.new)) assert.Equal(t, tc.expected, tc.new) } + + // Test boolean field handling + boolTests := []struct { + old any + new, expected *testStructWithBool + }{ + { + // Boolean false should not be overwritten by true + old: &testStructWithBool{"oldValue", 24, true}, + new: &testStructWithBool{"newValue", 42, false}, + expected: &testStructWithBool{"newValue", 42, false}, + }, + { + // Boolean true should be preserved + old: &testStructWithBool{"oldValue", 24, false}, + new: &testStructWithBool{"newValue", 42, true}, + expected: &testStructWithBool{"newValue", 42, true}, + }, + { + // Masked string should be restored, boolean should not be merged + old: &testStructWithBool{"oldValue", 24, true}, + new: &testStructWithBool{masked, 42, false}, + expected: &testStructWithBool{"oldValue", 42, false}, + }, + } + + for _, tc := range boolTests { + require.NoError(t, mergeMaskedAny(tc.old, tc.new)) + assert.Equal(t, tc.expected, tc.new) + } } func TestSquashedMergeMaskedAny(t *testing.T) { diff --git a/tests/config-shm.spec.ts b/tests/config-shm.spec.ts new file mode 100644 index 000000000..2ff062678 --- /dev/null +++ b/tests/config-shm.spec.ts @@ -0,0 +1,88 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, restart, baseUrl } from "./evcc"; +import { expectModalVisible, expectModalHidden, enableExperimental } from "./utils"; + +test.use({ baseURL: baseUrl() }); + +test.afterEach(async () => { + await stop(); +}); + +const CONFIG = "basics.evcc.yaml"; +const VALID_VENDOR_ID = "ABCD1234"; +const VALID_DEVICE_ID = "1234567890AB"; +const INVALID_VENDOR_ID = "INVALID"; +const INVALID_DEVICE_ID = "NOTVALID"; + +test.describe("SHM", () => { + test("configure SHM with validation and persistence", async ({ page }) => { + await start(CONFIG); + await page.goto("/#/config"); + await enableExperimental(page, false); + + const shmCard = page.getByTestId("shm"); + await expect(shmCard).toContainText(["Allow control", "no"].join("")); + + // configure SHM with allow control and IDs + await shmCard.getByRole("button", { name: "edit" }).click(); + const modal = page.getByTestId("shm-modal"); + await expectModalVisible(modal); + + const allowControl = modal.getByRole("checkbox", { name: "Allow control" }); + await expect(allowControl).not.toBeChecked(); + await allowControl.check(); + await expect(allowControl).toBeChecked(); + + await modal.getByRole("button", { name: "Show advanced settings" }).click(); + + // test vendor ID validation + const vendor = modal.getByLabel("Vendor ID"); + await vendor.fill(INVALID_VENDOR_ID); + await modal.getByRole("button", { name: "Save" }).click(); + expect(await vendor.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(false); + await vendor.fill(VALID_VENDOR_ID); + expect(await vendor.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(true); + + // test device ID validation + const device = modal.getByLabel("Device ID"); + await device.fill(INVALID_DEVICE_ID); + expect(await device.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(false); + await device.fill(VALID_DEVICE_ID); + expect(await device.evaluate((el: HTMLInputElement) => el.validity.valid)).toBe(true); + + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + await expect(shmCard).toContainText(["Allow control", "yes"].join("")); + + // verify persistence after restart + await restart(CONFIG); + await page.goto("/#/config"); + + await expect(shmCard).toContainText(["Allow control", "yes"].join("")); + await shmCard.getByRole("button", { name: "edit" }).click(); + await expectModalVisible(modal); + await expect(allowControl).toBeChecked(); + await expect(vendor).toHaveValue(VALID_VENDOR_ID); + await expect(device).toHaveValue(VALID_DEVICE_ID); + + // verify SEMP endpoint contains configured IDs + const [sempPage] = await Promise.all([ + page.context().waitForEvent("page"), + modal.getByTestId("semp-url").click(), + ]); + const xml = await sempPage.content(); + expect(xml).toContain( + `F-${VALID_VENDOR_ID}-${VALID_DEVICE_ID.toLowerCase()}-00` + ); + await sempPage.close(); + + // uncheck allow control + await allowControl.uncheck(); + await expect(allowControl).not.toBeChecked(); + await modal.getByRole("button", { name: "Save" }).click(); + await expectModalHidden(modal); + + // verify it shows as disabled + await expect(shmCard).toContainText(["Allow control", "no"].join("")); + }); +});