Config UI: split SHM HEMS into separate config (BC) (#23190)
This commit is contained in:
parent
018b07ce62
commit
15958df887
24 changed files with 407 additions and 114 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
90
assets/js/components/Config/ShmModal.vue
Normal file
90
assets/js/components/Config/ShmModal.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
|
|
|
|||
22
assets/js/components/MaterialIcon/Shm.vue
Normal file
22
assets/js/components/MaterialIcon/Shm.vue
Normal 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>
|
||||
|
|
@ -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?: [];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ const (
|
|||
ClassJavascript
|
||||
ClassGo
|
||||
ClassHEMS
|
||||
ClassSHM
|
||||
ClassInflux
|
||||
ClassMessenger
|
||||
ClassSponsorship
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
20
cmd/setup.go
20
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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const (
|
|||
Influx = "influx"
|
||||
EEBus = "eebus"
|
||||
Hems = "hems"
|
||||
Shm = "shm"
|
||||
Messaging = "messaging"
|
||||
ModbusProxy = "modbusproxy"
|
||||
Tariffs = "tariffs"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package semp
|
||||
package shm
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
15
i18n/de.json
15
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",
|
||||
|
|
|
|||
15
i18n/en.json
15
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",
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
88
tests/config-shm.spec.ts
Normal 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(""));
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue