Config UI: split SHM HEMS into separate config (BC) (#23190)

This commit is contained in:
Maschga 2025-09-14 12:10:56 +02:00 • committed by GitHub
parent 018b07ce62
commit 15958df887
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
24 changed files with 407 additions and 114 deletions

View file

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

View file

@ -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");

View file

@ -0,0 +1,90 @@
<template>
<JsonModal
id="shmModal"
:title="$t('config.shm.title')"
:description="$t('config.shm.description')"
docs="/docs/reference/configuration/hems"
endpoint="/config/shm"
state-key="shm"
data-testid="shm-modal"
@changed="$emit('changed')"
>
<template #default="{ values }">
<FormRow id="shmControl" :label="$t('config.shm.labelControl')" optional>
<div class="d-flex">
<input
id="shmControl"
v-model="values.allowControl"
class="form-check-input"
type="checkbox"
/>
<label class="form-check-label ms-2" for="shmControl">
{{ $t("config.shm.labelAllowControl") }}
</label>
</div>
</FormRow>
<PropertyCollapsible>
<template #advanced>
<p>{{ $t("config.shm.descriptionIds") }}</p>
<p>
{{ $t("config.shm.descriptionIdPattern") }}<br />
<code>F-AAAAAAAA-BBBBBBBBBBBB-00</code>
</p>
<p>
{{ $t("config.shm.descriptionSempUrl") }}<br />
<a :href="sempUrl" target="_blank" data-testid="semp-url">{{ sempUrl }}</a>
</p>
<FormRow
id="shmVendorid"
:label="$t('config.shm.labelVendorId')"
:help="$t('config.shm.descriptionVendorId')"
example="AAAAAAAA"
optional
>
<input
id="shmVendorid"
v-model="values.vendorId"
class="form-control"
minlength="8"
maxlength="8"
pattern="[A-Fa-f0-9]{8}"
/>
</FormRow>
<FormRow
id="shmDeviceid"
:label="$t('config.shm.labelDeviceId')"
:help="$t('config.shm.descriptionDeviceId')"
example="BBBBBBBBBBBB"
optional
>
<input
id="shmDeviceid"
v-model="values.deviceId"
class="form-control"
minlength="12"
maxlength="12"
pattern="[A-Fa-f0-9]{12}"
/> </FormRow
></template>
</PropertyCollapsible>
</template>
</JsonModal>
</template>
<script lang="ts">
import FormRow from "./FormRow.vue";
import JsonModal from "./JsonModal.vue";
import PropertyCollapsible from "./PropertyCollapsible.vue";
export default {
name: "ShmModal",
components: { JsonModal, FormRow, PropertyCollapsible },
emits: ["changed"],
computed: {
sempUrl() {
// TOOD: combine with url-gen logic coming in https://github.com/evcc-io/evcc/pull/23093
return `${window.location.protocol}//${window.location.hostname}:${window.location.port}/semp/`;
},
},
};
</script>

View file

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

View file

@ -0,0 +1,22 @@
<template>
<svg :style="svgStyle" viewBox="0 0 32 32">
<path
d="M3.046,6.43c-1.365,-0 -2.513,1.149 -2.513,2.513l0,15.66c2.847,-1.757 15.172,-8.696 30.933,-8.411l0,-9.762l-28.42,-0Z"
fill="currentColor"
/>
<path
d="M1.5,25.57l27.454,0c1.365,0 2.513,-1.149 2.513,-2.513l-0.001,-5.568c-14.933,-0.278 -26.74,6.137 -29.965,8.082"
fill="currentColor"
/>
</svg>
</template>
<script lang="ts">
import { defineComponent } from "vue";
import icon from "@/mixins/icon";
export default defineComponent({
name: "Shm",
mixins: [icon],
});
</script>

View file

@ -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?: [];

View file

@ -294,6 +294,18 @@
<DeviceTags :tags="modbusproxyTags" />
</template>
</DeviceCard>
<DeviceCard
:title="$t('config.shm.cardTitle')"
editable
:error="hasClassError('shm')"
data-testid="shm"
@edit="openModal('shmModal')"
>
<template #icon><ShmIcon /></template>
<template #tags>
<DeviceTags :tags="shmTags" />
</template>
</DeviceCard>
<DeviceCard
:title="$t('config.hems.title')"
editable
@ -372,6 +384,7 @@
<ControlModal @changed="loadDirty" />
<SponsorModal :error="hasClassError('sponsorship')" @changed="loadDirty" />
<HemsModal @changed="yamlChanged" />
<ShmModal @changed="loadDirty" />
<MessagingModal @changed="yamlChanged" />
<TariffsModal @changed="yamlChanged" />
<ModbusProxyModal @changed="yamlChanged" />
@ -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;

View file

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

View file

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

View file

@ -24,6 +24,7 @@ const (
ClassJavascript
ClassGo
ClassHEMS
ClassSHM
ClassInflux
ClassMessenger
ClassSponsorship

View file

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

View file

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

View file

@ -10,6 +10,7 @@ const (
Influx = "influx"
EEBus = "eebus"
Hems = "hems"
Shm = "shm"
Messaging = "messaging"
ModbusProxy = "modbusproxy"
Tariffs = "tariffs"

View file

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

View file

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

View file

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

View file

@ -1,4 +1,4 @@
package semp
package shm
import "encoding/xml"

View file

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

View file

@ -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",

View file

@ -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",

View file

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

View file

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

View file

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

88
tests/config-shm.spec.ts Normal file
View file

@ -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(
`<DeviceId>F-${VALID_VENDOR_ID}-${VALID_DEVICE_ID.toLowerCase()}-00</DeviceId>`
);
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(""));
});
});