diff --git a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue index b20fbf4e6..71e615a7a 100644 --- a/assets/js/components/Config/DeviceModal/DeviceModalBase.vue +++ b/assets/js/components/Config/DeviceModal/DeviceModalBase.vue @@ -52,7 +52,7 @@
+ + +

@@ -330,6 +343,12 @@ export default defineComponent({ const { params = [] } = this.template?.Auth ?? {}; return this.templateParams.filter((p) => params.includes(p.Name)); }, + authNormalParams() { + return this.authParams.filter((p: TemplateParam) => !p.Advanced && !p.Deprecated); + }, + authAdvancedParams() { + return this.authParams.filter((p: TemplateParam) => p.Advanced || p.Deprecated); + }, normalParams() { return this.templateParams.filter((p) => !p.Advanced && !p.Deprecated); }, @@ -429,7 +448,13 @@ export default defineComponent({ return this.template?.Auth && !this.auth.ok; }, authValuesMissing() { - return this.template?.Auth && Object.values(this.authValues).some((value) => !value); + const authParamNames: string[] = this.template?.Auth?.params ?? []; + return ( + authParamNames.length > 0 && + this.templateParams + .filter((p: TemplateParam) => authParamNames.includes(p.Name) && p.Required) + .some((p: TemplateParam) => !this.values[p.Name]) + ); }, authValues() { const params = this.template?.Auth?.params ?? []; diff --git a/assets/js/components/Config/DeviceModal/index.ts b/assets/js/components/Config/DeviceModal/index.ts index bf2cd6edb..3f4e16a4e 100644 --- a/assets/js/components/Config/DeviceModal/index.ts +++ b/assets/js/components/Config/DeviceModal/index.ts @@ -28,6 +28,7 @@ export type TemplateParam = { Advanced: boolean; Deprecated: boolean; Default?: string | number | boolean; + Type?: string; Choice?: string[]; Service?: string; Usages?: TemplateParamUsage[]; @@ -89,11 +90,13 @@ export function handleError(e: any, msg: string) { export function applyDefaultsFromTemplate(template: Template | null, values: DeviceValues) { const params = template?.Params || []; - params - .filter((p) => p.Default && !values[p.Name]) - .forEach((p) => { + params.forEach((p) => { + if (p.Default && !values[p.Name]) { values[p.Name] = p.Default; - }); + } else if (p.Type === "Bool" && values[p.Name] === undefined) { + values[p.Name] = false; // initialize + } + }); } export function customChargerName(type: ConfigType, isHeating: boolean) { diff --git a/assets/js/components/Config/utils/authProvider.ts b/assets/js/components/Config/utils/authProvider.ts index e542cfba6..60030864f 100644 --- a/assets/js/components/Config/utils/authProvider.ts +++ b/assets/js/components/Config/utils/authProvider.ts @@ -30,7 +30,12 @@ export const prepareAuthLogin = async (state: AuthState, providerId: string) => state.loading = true; state.error = null; - const url = `providerauth/login?id=${encodeURIComponent(providerId)}`; + let url = `providerauth/login?id=${encodeURIComponent(providerId)}`; + // restore the config modal stack on callback + const returnTo = window.location.hash.split("?")[1]; + if (returnTo) { + url += `&return=${encodeURIComponent(returnTo)}`; + } const { status, data } = await baseApi.get(url, { validateStatus: (code) => [200, 400].includes(code), }); diff --git a/charger/homeassistant-switch.go b/charger/homeassistant-switch.go index f0af4c75b..acc9e6dcc 100644 --- a/charger/homeassistant-switch.go +++ b/charger/homeassistant-switch.go @@ -21,23 +21,21 @@ func init() { func NewHomeAssistantSwitchFromConfig(other map[string]any) (api.Charger, error) { var cc struct { - embed `mapstructure:",squash"` - URI string - Token_ string `mapstructure:"token"` // TODO deprecated - Home_ string `mapstructure:"home"` // TODO deprecated - Enable string - Power string - StandbyPower float64 + embed `mapstructure:",squash"` + homeassistant.Config `mapstructure:",squash"` + Enable string + Power string + StandbyPower float64 } if err := util.DecodeOther(other, &cc); err != nil { return nil, err } - return NewHomeAssistantSwitch(cc.embed, cc.URI, cc.Home_, cc.Enable, cc.Power, cc.StandbyPower) + return NewHomeAssistantSwitch(cc.embed, cc.URI, cc.Home_, cc.Insecure, cc.Enable, cc.Power, cc.StandbyPower) } -func NewHomeAssistantSwitch(embed embed, uri, home, enable, power string, standbypower float64) (api.Charger, error) { +func NewHomeAssistantSwitch(embed embed, uri, home string, insecure bool, enable, power string, standbypower float64) (api.Charger, error) { if enable == "" { return nil, errors.New("missing enable switch entity") } @@ -49,7 +47,7 @@ func NewHomeAssistantSwitch(embed embed, uri, home, enable, power string, standb log := util.NewLogger("ha-switch") - conn, err := homeassistant.NewConnection(log, uri, home) + conn, err := homeassistant.NewConnection(log, uri, home, insecure) if err != nil { return nil, err } diff --git a/charger/homeassistant.go b/charger/homeassistant.go index 9da621a4f..c61b359ff 100644 --- a/charger/homeassistant.go +++ b/charger/homeassistant.go @@ -28,18 +28,16 @@ func init() { // NewHomeAssistantFromConfig creates a HomeAssistant charger from generic config func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) { var cc struct { - URI string - Token_ string `mapstructure:"token"` // TODO deprecated - Home_ string `mapstructure:"home"` // TODO deprecated - Status string // required - sensor for charge status - Enabled string // required - sensor for enabled state - Enable string // required - switch/input_boolean for enable/disable - MaxCurrent string // required - number entity for setting max current - Power string // optional - power sensor - Energy string // optional - energy sensor - Currents []string // optional - current sensors for L1, L2, L3 - Voltages []string // optional - voltage sensors for L1, L2, L3 - Phases string // optional - select entity for 1p/3p phase switching + homeassistant.Config `mapstructure:",squash"` + Status string // required - sensor for charge status + Enabled string // required - sensor for enabled state + Enable string // required - switch/input_boolean for enable/disable + MaxCurrent string // required - number entity for setting max current + Power string // optional - power sensor + Energy string // optional - energy sensor + Currents []string // optional - current sensors for L1, L2, L3 + Voltages []string // optional - voltage sensors for L1, L2, L3 + Phases string // optional - select entity for 1p/3p phase switching } if err := util.DecodeOther(other, &cc); err != nil { @@ -61,7 +59,7 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Charger, error) { log := util.NewLogger("ha-charger") - conn, err := homeassistant.NewConnection(log, cc.URI, cc.Home_) + conn, err := cc.Config.NewConnection(log) if err != nil { return nil, err } diff --git a/messenger/homeassistant.go b/messenger/homeassistant.go index fd3a6f219..d8599ad39 100644 --- a/messenger/homeassistant.go +++ b/messenger/homeassistant.go @@ -25,9 +25,9 @@ type HomeAssistant struct { // NewHomeAssistantFromConfig creates a new Home Assistant messenger func NewHomeAssistantFromConfig(other map[string]any) (api.Messenger, error) { var cc struct { - URI string - Notify string - Data map[string]any + homeassistant.Config `mapstructure:",squash"` + Notify string + Data map[string]any } if err := util.DecodeOther(other, &cc); err != nil { @@ -40,7 +40,7 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Messenger, error) { log := util.NewLogger("homeassistant") - conn, err := homeassistant.NewConnection(log, cc.URI, "") + conn, err := cc.Config.NewConnection(log) if err != nil { return nil, err } diff --git a/meter/homeassistant.go b/meter/homeassistant.go index 8f42dad89..2f638240e 100644 --- a/meter/homeassistant.go +++ b/meter/homeassistant.go @@ -18,15 +18,13 @@ func init() { // NewHomeAssistantFromConfig creates a HomeAssistant meter from generic config func NewHomeAssistantFromConfig(other map[string]any) (api.Meter, error) { cc := struct { - URI string - Token_ string `mapstructure:"token"` // TODO deprecated - Home_ string `mapstructure:"home"` // TODO deprecated - Power string - Energy string - Currents []string - Voltages []string - Powers []string - Soc string + homeassistant.Config `mapstructure:",squash"` + Power string + Energy string + Currents []string + Voltages []string + Powers []string + Soc string // pv pvMaxACPower `mapstructure:",squash"` @@ -57,7 +55,7 @@ func NewHomeAssistantFromConfig(other map[string]any) (api.Meter, error) { log := util.NewLogger("ha-meter") - conn, err := homeassistant.NewConnection(log, cc.URI, cc.Home_) + conn, err := cc.Config.NewConnection(log) if err != nil { return nil, err } diff --git a/plugin/auth/demo.go b/plugin/auth/demo.go index deaa7cb7a..66c719c27 100644 --- a/plugin/auth/demo.go +++ b/plugin/auth/demo.go @@ -34,6 +34,7 @@ func NewDemoFromConfig(_ context.Context, other map[string]any) (oauth2.TokenSou Method string RedirectUri string Secret string + Scope string // advanced auth param, used by e2e tests } if err := util.DecodeOther(other, &cc); err != nil { diff --git a/server/providerauth/handler.go b/server/providerauth/handler.go index 888eb8e05..15336b98c 100644 --- a/server/providerauth/handler.go +++ b/server/providerauth/handler.go @@ -44,10 +44,15 @@ type Handler struct { log *util.Logger secret []byte providers map[string]api.AuthProvider - states map[string]string + states map[string]stateEntry updateC chan string } +type stateEntry struct { + id string + returnTo string // config modal query to restore on callback +} + // TODO get status from update channel func (a *Handler) run(paramC chan<- util.Param) { for range a.updateC { @@ -97,7 +102,7 @@ func (a *Handler) handleLogin(w http.ResponseWriter, r *http.Request) { // Generate a new state and store the provider state := NewState() encryptedState := state.Encrypt(a.secret) - a.states[encryptedState] = id + a.states[encryptedState] = stateEntry{id: id, returnTo: r.URL.Query().Get("return")} // Schedule cleanup for stale state entries after state becomes invalid time.AfterFunc(stateValidity, func() { @@ -176,11 +181,12 @@ func (a *Handler) handleCallback(w http.ResponseWriter, r *http.Request) { defer a.mu.Unlock() // Find the corresponding provider - id, ok := a.states[encryptedState] + entry, ok := a.states[encryptedState] if !ok { a.redirectToError(w, r, "no provider found for state") return } + id := entry.id provider, ok := a.providers[id] if !ok { @@ -198,5 +204,11 @@ func (a *Handler) handleCallback(w http.ResponseWriter, r *http.Request) { return } - http.Redirect(w, r, "/#/config?callbackCompleted="+url.QueryEscape(id), http.StatusFound) + // restore the config modal stack alongside the completion marker + query := "callbackCompleted=" + url.QueryEscape(id) + if entry.returnTo != "" { + query = entry.returnTo + "&" + query + } + + http.Redirect(w, r, "/#/config?"+query, http.StatusFound) } diff --git a/server/providerauth/providerauth.go b/server/providerauth/providerauth.go index d3239a41e..7dc22b435 100644 --- a/server/providerauth/providerauth.go +++ b/server/providerauth/providerauth.go @@ -27,7 +27,7 @@ func init() { log: util.NewLogger("providerauth"), secret: secret[:], providers: make(map[string]api.AuthProvider), - states: make(map[string]string), + states: make(map[string]stateEntry), updateC: make(chan string, 1), } } diff --git a/templates/definition/charger/homeassistant-switch.yaml b/templates/definition/charger/homeassistant-switch.yaml index f626107c1..c77edce01 100644 --- a/templates/definition/charger/homeassistant-switch.yaml +++ b/templates/definition/charger/homeassistant-switch.yaml @@ -11,7 +11,7 @@ requirements: de: Home Assistant Instanzen in deinem Netzwerk werden automatisch erkannt und passende Entitäten (z.B. `switch.*`, `sensor.*`) werden vorgeschlagen. auth: type: homeassistant - params: [uri] + params: [uri, insecure] params: - name: uri description: @@ -27,24 +27,26 @@ params: deprecated: true - name: home deprecated: true + - name: insecure - name: switch description: de: Entity ID des schaltbaren Geräts en: Entity ID of the switch device - service: homeassistant/entities?uri={uri}&domain=switch + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=switch example: switch.smartsocket required: true - name: power description: de: Entity ID für Leistungsmessung en: Entity ID for power measurement - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: sensor.smartsocket_power - preset: switchsocket render: | type: homeassistant-switch uri: {{ .uri }} home: {{ .home }} # deprecated + insecure: {{ .insecure }} enable: {{ .switch }} power: {{ .power }} {{ include "switchsocket" . }} diff --git a/templates/definition/charger/homeassistant.yaml b/templates/definition/charger/homeassistant.yaml index 6b6b1ce60..79d1b0318 100644 --- a/templates/definition/charger/homeassistant.yaml +++ b/templates/definition/charger/homeassistant.yaml @@ -11,7 +11,7 @@ requirements: de: Home Assistant Instanzen in deinem Netzwerk werden automatisch erkannt und passende Wallbox-Entitäten und Services (z.B. `sensor.*`, `switch.*`, `number.*`) werden vorgeschlagen. auth: type: homeassistant - params: [uri] + params: [uri, insecure] params: - name: uri description: @@ -23,11 +23,12 @@ params: de: " " # overwrite default service: homeassistant/instances required: true + - name: insecure - name: status description: de: Ladestatus-Sensor en: Charging status sensor - service: homeassistant/entities?uri={uri}&domain=sensor,binary_sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,binary_sensor example: "sensor.charger_status" required: true help: @@ -37,7 +38,7 @@ params: description: de: Aktivierungsstatus-Sensor en: Enabled status sensor - service: homeassistant/entities?uri={uri}&domain=sensor,binary_sensor,switch + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor,binary_sensor,switch example: "binary_sensor.charger_enabled" required: true help: @@ -47,7 +48,7 @@ params: description: de: Aktivierungsschalter en: Enable switch - service: homeassistant/entities?uri={uri}&domain=switch,input_boolean + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=switch,input_boolean example: "switch.charger_enable" required: true help: @@ -57,7 +58,7 @@ params: description: de: Maximale Stromstärke-Entität [A] en: Maximum current entity [A] - service: homeassistant/entities?uri={uri}&domain=number,input_number + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number example: "number.charger_max_current" required: true help: @@ -67,7 +68,7 @@ params: description: de: Leistungsentität en: Power entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.charger_power" advanced: true help: @@ -77,7 +78,7 @@ params: description: de: Energieentität en: Energy entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.charger_energy" advanced: true help: @@ -87,7 +88,7 @@ params: description: de: L1 Stromentität en: L1 current entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.charger_current_l1" advanced: true help: @@ -97,7 +98,7 @@ params: description: de: L2 Stromentität en: L2 current entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.charger_current_l2" advanced: true help: @@ -107,7 +108,7 @@ params: description: de: L3 Stromentität en: L3 current entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.charger_current_l3" advanced: true help: @@ -117,7 +118,7 @@ params: description: de: L1 Spannungsentität en: L1 voltage entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.charger_voltage_l1" advanced: true help: @@ -127,7 +128,7 @@ params: description: de: L2 Spannungsentität en: L2 voltage entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.charger_voltage_l2" advanced: true help: @@ -137,7 +138,7 @@ params: description: de: L3 Spannungsentität en: L3 voltage entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.charger_voltage_l3" advanced: true help: @@ -152,7 +153,7 @@ params: description: de: Phasenumschaltungs-Entität en: Phase switching entity - service: homeassistant/entities?uri={uri}&domain=select,input_select + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=select,input_select example: "select.charger_phases" advanced: true help: @@ -162,6 +163,7 @@ params: render: | type: homeassistant uri: {{ .uri }} + insecure: {{ .insecure }} status: {{ .status }} enabled: {{ .enabled }} enable: {{ .enable }} diff --git a/templates/definition/messenger/homeassistant.yaml b/templates/definition/messenger/homeassistant.yaml index 2394a32be..e5bd49ced 100644 --- a/templates/definition/messenger/homeassistant.yaml +++ b/templates/definition/messenger/homeassistant.yaml @@ -3,7 +3,7 @@ products: - brand: Home Assistant auth: type: homeassistant - params: [uri] + params: [uri, insecure] params: - name: uri required: true @@ -27,7 +27,8 @@ params: en: > Home Assistant notify service in the form `domain.service` (e.g. `notify.mobile_app_android`). Leave empty to create a persistent notification in the HA UI. - service: homeassistant/services?uri={uri}&domain=notify + service: homeassistant/services?uri={uri}&insecure={insecure}&domain=notify + - name: insecure - name: critical type: bool default: false @@ -56,6 +57,7 @@ params: render: | type: homeassistant uri: {{ .uri }} + insecure: {{ .insecure }} {{- if .notify }} notify: {{ .notify }} {{- end }} diff --git a/templates/definition/meter/homeassistant.yaml b/templates/definition/meter/homeassistant.yaml index f62309f2b..61166af50 100644 --- a/templates/definition/meter/homeassistant.yaml +++ b/templates/definition/meter/homeassistant.yaml @@ -8,7 +8,7 @@ requirements: de: Home Assistant Instanzen in deinem Netzwerk werden automatisch erkannt und passende Entitäten (z.B. `sensor.*`) werden vorgeschlagen. auth: type: homeassistant - params: [uri] + params: [uri, insecure] params: - name: usage choice: ["grid", "pv", "battery", "aux", "charge"] @@ -31,7 +31,7 @@ params: de: Leistungsentität en: Power Entity required: true - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.house_power" help: en: Entity ID for instantaneous power measurement in watts. The entity must provide numeric values only (e.g., "1234", not "1234 W"). @@ -40,7 +40,7 @@ params: description: de: Energieentität en: Energy Entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.house_energy" advanced: true help: @@ -50,7 +50,7 @@ params: description: de: L1 Stromentität en: L1 Current Entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.house_current_l1" advanced: true help: @@ -60,7 +60,7 @@ params: description: de: L2 Stromentität en: L2 Current Entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.house_current_l2" advanced: true help: @@ -70,7 +70,7 @@ params: description: de: L3 Stromentität en: L3 Current Entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.house_current_l3" advanced: true help: @@ -80,7 +80,7 @@ params: description: de: L1 Spannungsentität en: L1 Voltage Entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.house_voltage_l1" advanced: true help: @@ -90,7 +90,7 @@ params: description: de: L2 Spannungsentität en: L2 Voltage Entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.house_voltage_l2" advanced: true help: @@ -100,7 +100,7 @@ params: description: de: L3 Spannungsentität en: L3 Voltage Entity - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.house_voltage_l3" advanced: true help: @@ -111,7 +111,7 @@ params: description: de: Batterieladestand en: Battery State of Charge - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.battery_soc" advanced: true help: @@ -122,7 +122,7 @@ params: description: de: Normalbetrieb Skript en: Normal Mode Script - service: homeassistant/entities?uri={uri}&domain=script + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=script example: "script.battery_normal" advanced: true help: @@ -133,7 +133,7 @@ params: description: de: Halten Skript en: Hold Mode Script - service: homeassistant/entities?uri={uri}&domain=script + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=script example: "script.battery_hold" advanced: true help: @@ -144,18 +144,20 @@ params: description: de: Netzladung Skript en: Grid Charge Mode Script - service: homeassistant/entities?uri={uri}&domain=script + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=script example: "script.battery_grid_charge" advanced: true help: en: Optional script to grid-charge the home battery. de: Optionales Skript um die Heimbatterie aus dem Netz zu laden. + - name: insecure - name: maxacpower - preset: battery-params render: | type: homeassistant uri: {{ .uri }} home: {{ .home }} # deprecated + insecure: {{ .insecure }} power: {{ .power }} energy: {{ .energy }} currents: diff --git a/templates/definition/vehicle/homeassistant.yaml b/templates/definition/vehicle/homeassistant.yaml index 06ee35609..2e8f2000d 100644 --- a/templates/definition/vehicle/homeassistant.yaml +++ b/templates/definition/vehicle/homeassistant.yaml @@ -8,7 +8,7 @@ requirements: de: Home Assistant Instanzen in deinem Netzwerk werden automatisch erkannt und passende Entitäten und Services (z.B. `sensor.*`) werden vorgeschlagen. auth: type: homeassistant - params: [uri] + params: [uri, insecure] params: - preset: vehicle-common - name: uri @@ -25,11 +25,12 @@ params: deprecated: true - name: home deprecated: true + - name: insecure - name: soc description: de: Ladezustand [%] en: State of charge [%] - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.vehicle_soc" required: true help: @@ -39,7 +40,7 @@ params: description: de: Restreichweite [km] en: Remaining range [km] - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.vehicle_range" help: en: Entity ID for the vehicle's remaining range in kilometers @@ -48,7 +49,7 @@ params: description: de: Ladestatus en: Charging status - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.vehicle_charging" help: en: Entity ID for charging status (A=disconnected, B=connected, C=charging) @@ -57,7 +58,7 @@ params: description: de: Ziel-Ladezustand [%] en: Target state of charge [%] - service: homeassistant/entities?uri={uri}&domain=number,input_number + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number example: "number.vehicle_target_state_of_charge" help: en: Entity ID for the vehicle's target state of charge in percent (`number` or `input_number` entity) @@ -66,7 +67,7 @@ params: description: de: Kilometerstand [km] en: Odometer [km] - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.vehicle_odometer" help: en: Entity ID for the vehicle's odometer reading in kilometers @@ -75,7 +76,7 @@ params: description: de: Klimatisierung aktiv en: Climatisation active - service: homeassistant/entities?uri={uri}&domain=binary_sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=binary_sensor example: "binary_sensor.vehicle_climater" help: en: Entity ID for the vehicle's climatisation state (`binary_sensor` with `on`/`off` state) @@ -84,7 +85,7 @@ params: description: de: Ladeende en: Finish time - service: homeassistant/entities?uri={uri}&domain=sensor + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=sensor example: "sensor.vehicle_finish_time" help: en: Entity ID for the estimated charging finish time (ISO8601 or Unix timestamp) @@ -93,7 +94,7 @@ params: description: de: Service zum Laden starten en: Service to start charging - service: homeassistant/entities?uri={uri}&domain=script,switch + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=script,switch example: "script.vehicle_start_charge; switch.vehicle_charger" help: en: Entity ID for a script or a switch that starts charging the vehicle. Only useful with the [docs.evcc.io](https://docs.evcc.io/en/docs/devices/chargers#vehicle-api-only-charger). If a switch is provided, Service to stop charging must be left empty. @@ -102,7 +103,7 @@ params: description: de: Service zum Laden stoppen en: Service to stop charging - service: homeassistant/entities?uri={uri}&domain=script + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=script example: "script.vehicle_stop_charge" help: en: Entity ID for a script that stops charging the vehicle. Only useful with the [docs.evcc.io](https://docs.evcc.io/en/docs/devices/chargers#vehicle-api-only-charger). @@ -111,7 +112,7 @@ params: description: de: Service zum Aufwecken en: Service to wake up vehicle - service: homeassistant/entities?uri={uri}&domain=script,button + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=script,button example: "script.vehicle_wakeup; button.wakeup_vehicle" help: en: Entity ID for a script or a button that wakes up the vehicle @@ -120,7 +121,7 @@ params: description: de: Ladestromstärke setzen [A] en: Set charging current [A] - service: homeassistant/entities?uri={uri}&domain=number,input_number + service: homeassistant/entities?uri={uri}&insecure={insecure}&domain=number,input_number example: "number.vehicle_charging_current" help: en: Entity ID for setting the maximum charging current in amperes (`number` or `input_number` entity) @@ -134,6 +135,7 @@ render: | {{ include "vehicle-features" . }} uri: {{ .uri }} home: {{ .home }} # deprecated + insecure: {{ .insecure }} sensors: soc: {{ .soc }} range: {{ .range }} diff --git a/tests/backup-restore.spec.ts b/tests/backup-restore.spec.ts index 8d5615f3b..f11f57516 100644 --- a/tests/backup-restore.spec.ts +++ b/tests/backup-restore.spec.ts @@ -153,8 +153,8 @@ test.describe("backup and restore", async () => { const backupConfirmModal = page.getByTestId("backup-restore-confirm-modal"); await expectModalVisible(backupConfirmModal); await backupConfirmModal.getByRole("button", { name: "Download backup" }).click(); - await expectModalHidden(backupConfirmModal); const download = await downloadPromise; + await expectModalHidden(backupConfirmModal); await expectModalVisible(backupModal); await backupModal.locator(".btn-close").click(); await expectModalHidden(backupModal); diff --git a/tests/config-bool-service-demo.tpl.yaml b/tests/config-bool-service-demo.tpl.yaml new file mode 100644 index 000000000..12d60af6c --- /dev/null +++ b/tests/config-bool-service-demo.tpl.yaml @@ -0,0 +1,17 @@ +template: bool-service-demo +group: generic +products: + - description: + generic: Bool Service Meter +params: + - name: usage + choice: ["grid"] + - name: insecure + type: bool + advanced: true + - name: entity + description: + generic: Entity + service: demo/insecure?insecure={insecure} +render: | + type: custom diff --git a/tests/config-bool-service.spec.ts b/tests/config-bool-service.spec.ts new file mode 100644 index 000000000..710e9289e --- /dev/null +++ b/tests/config-bool-service.spec.ts @@ -0,0 +1,42 @@ +import { test, expect } from "@playwright/test"; +import { start, stop, baseUrl } from "./evcc"; +import { expectModalVisible, getDatalistOptions } from "./utils"; + +test.use({ baseURL: baseUrl() }); + +const templateFlags = [ + "--disable-auth", + "--template-type", + "meter", + "--template", + "tests/config-bool-service-demo.tpl.yaml", +]; + +test.beforeAll(async () => { + await start(undefined, undefined, templateFlags); +}); + +test.afterAll(async () => { + await stop(); +}); + +test.describe("config bool service param", async () => { + test("unset bool defaults to false and fills service placeholder", async ({ page }) => { + // service call fires with insecure=false even though the bool was never touched + const servicePromise = page.waitForRequest( + /\/config\/service\/demo\/insecure\?insecure=false$/ + ); + + await page.goto("/#/config"); + await page.getByRole("button", { name: "Add grid meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); + await meterModal.getByLabel("Manufacturer").selectOption("Bool Service Meter"); + + await servicePromise; + + // entity datalist is populated from the echoed flag + const entity = meterModal.getByLabel("Entity"); + expect(await getDatalistOptions(entity)).toEqual(["insecure=false"]); + }); +}); diff --git a/tests/config-device-auth-demo.tpl.yaml b/tests/config-device-auth-demo.tpl.yaml index b33aa1fba..f05f70fbc 100644 --- a/tests/config-device-auth-demo.tpl.yaml +++ b/tests/config-device-auth-demo.tpl.yaml @@ -6,7 +6,7 @@ products: en: Auth Demo Meter auth: type: demo - params: ["server", "method", "redirectUri", "secret"] + params: ["server", "method", "redirectUri", "secret", "scope"] params: - name: usage choice: ["grid"] @@ -30,6 +30,11 @@ params: generic: Secret required: true mask: true + - name: scope + description: + generic: Scope + advanced: true + default: "read" - name: power description: generic: Power diff --git a/tests/config-device-auth.spec.ts b/tests/config-device-auth.spec.ts index d5b1b7267..02d824f8c 100644 --- a/tests/config-device-auth.spec.ts +++ b/tests/config-device-auth.spec.ts @@ -79,8 +79,7 @@ test.describe("config device auth", async () => { await expect(successBanner).toContainText("Authorization Successful"); await expect(successBanner).toContainText("Demo Auth is now connected and ready to use"); - // After successful auth, reopen the meter modal to continue configuration - await page.getByRole("button", { name: "Add grid meter" }).click(); + // modal reopens automatically after auth; continue configuration await expectModalVisible(meterModal); await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); @@ -135,6 +134,58 @@ test.describe("config device auth", async () => { await expect(meterModal.getByRole("button", { name: "Validate & save" })).not.toBeVisible(); }); + test("redirect auth restores the open modal on return", async ({ page }) => { + await page.goto("/#/config"); + + // open the grid meter modal and prepare auth + await page.getByRole("button", { name: "Add grid meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); + await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); + await meterModal.getByLabel("Server").fill(simulatorUrl()); + await meterModal.getByLabel("Redirect URI").fill(getRedirectUri(page.url())); + await meterModal.getByLabel("Authentication Method").selectOption("redirect"); + await meterModal.getByLabel("Secret").fill(secret); + await meterModal.getByRole("button", { name: "Prepare connection" }).click(); + + // jump to provider in same page + const loginLink = meterModal.getByRole("link", { name: "Connect to localhost" }); + await expect(loginLink).toBeVisible(); + await loginLink.evaluate((el) => el.removeAttribute("target")); + await loginLink.click(); + + await page.waitForLoadState("networkidle"); + const loginButton = page.getByRole("button", { name: "Login Successfully" }); + await expect(loginButton).toBeVisible(); + await loginButton.evaluate((btn: HTMLButtonElement) => btn.click()); + + // callback restores the modal query alongside the completion marker + await page.waitForURL(/.*\/#\/config.*callbackCompleted.*/); + expect(page.url()).toContain("meter[type:grid]"); + + // modal reopens automatically and success banner is shown + await expectModalVisible(meterModal); + await expect(page.getByTestId("auth-success-banner")).toBeVisible(); + }); + + test("advanced auth field is hidden until expanded", async ({ page }) => { + await page.goto("/#/config"); + + await page.getByRole("button", { name: "Add grid meter" }).click(); + const meterModal = page.getByTestId("meter-modal"); + await expectModalVisible(meterModal); + await meterModal.getByLabel("Manufacturer").selectOption("Auth Demo Meter"); + + // normal auth fields are shown, advanced "Scope" is collapsed + await expect(meterModal.getByLabel("Server")).toBeVisible(); + await expect(meterModal.getByLabel("Scope")).not.toBeVisible(); + + // expand advanced section manually + await meterModal.getByRole("button", { name: "Show advanced settings" }).click(); + await expect(meterModal.getByLabel("Scope")).toBeVisible(); + await expect(meterModal.getByLabel("Scope")).toHaveValue("read"); + }); + test("create grid meter with device-code auth", async ({ page }) => { await page.goto("/#/config"); diff --git a/util/homeassistant/config.go b/util/homeassistant/config.go new file mode 100644 index 000000000..77d7bcb98 --- /dev/null +++ b/util/homeassistant/config.go @@ -0,0 +1,17 @@ +package homeassistant + +import "github.com/evcc-io/evcc/util" + +// Config holds the common Home Assistant instance connection parameters. +// It is embedded into device configs via mapstructure squash. +type Config struct { + URI string + Token_ string `mapstructure:"token"` // TODO deprecated + Home_ string `mapstructure:"home"` // TODO deprecated + Insecure bool // optional - allow self-signed certificates +} + +// NewConnection creates a Home Assistant connection from the config +func (c Config) NewConnection(log *util.Logger) (*Connection, error) { + return NewConnection(log, c.URI, c.Home_, c.Insecure) +} diff --git a/util/homeassistant/connection.go b/util/homeassistant/connection.go index 4098cf005..3b0973eb7 100644 --- a/util/homeassistant/connection.go +++ b/util/homeassistant/connection.go @@ -12,6 +12,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" "github.com/samber/lo" "golang.org/x/oauth2" ) @@ -23,7 +24,7 @@ type Connection struct { } // NewConnection creates a new Home Assistant connection -func NewConnection(log *util.Logger, uri, home string) (*Connection, error) { +func NewConnection(log *util.Logger, uri, home string, insecure bool) (*Connection, error) { if home != "" { log.WARN.Printf("using deprecated 'home' parameter '%s', please use 'uri' instead", home) } @@ -35,11 +36,17 @@ func NewConnection(log *util.Logger, uri, home string) (*Connection, error) { c := &Connection{ Helper: request.NewHelper(log), instance: &proxyInstance{ - home: home, - uri: util.DefaultScheme(strings.TrimSuffix(uri, "/"), "http"), + home: home, + uri: util.DefaultScheme(strings.TrimSuffix(uri, "/"), "http"), + insecure: insecure, }, } + // override the transport to accept self-signed certificates + if insecure { + c.Client.Transport = request.NewTripper(log, transport.Insecure()) + } + // Set up authentication headers c.Client.Transport = &oauth2.Transport{ Base: c.Client.Transport, diff --git a/util/homeassistant/instance.go b/util/homeassistant/instance.go index 6ea15eb8f..c28c0ad87 100644 --- a/util/homeassistant/instance.go +++ b/util/homeassistant/instance.go @@ -10,6 +10,7 @@ import ( type proxyInstance struct { mu sync.Mutex home, uri string + insecure bool oauth2.TokenSource } @@ -42,7 +43,7 @@ func (inst *proxyInstance) Token() (*oauth2.Token, error) { defer inst.mu.Unlock() if inst.TokenSource == nil { - ts, err := NewHomeAssistant(uri) + ts, err := NewHomeAssistant(uri, inst.insecure) if err != nil { return nil, err } diff --git a/util/homeassistant/oauth2.go b/util/homeassistant/oauth2.go index 241369f53..f021eb493 100644 --- a/util/homeassistant/oauth2.go +++ b/util/homeassistant/oauth2.go @@ -4,12 +4,14 @@ import ( "context" "fmt" "net" + "net/http" "net/url" "strings" "github.com/evcc-io/evcc/plugin/auth" "github.com/evcc-io/evcc/server/network" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/transport" "golang.org/x/oauth2" ) @@ -21,8 +23,9 @@ func init() { func NewHomeAssistantFromConfig(other map[string]any) (oauth2.TokenSource, error) { var cc struct { - URI string - Home string // TODO remove deprecated + URI string + Home string // TODO remove deprecated + Insecure bool } if err := util.DecodeOther(other, &cc); err != nil { @@ -38,10 +41,10 @@ func NewHomeAssistantFromConfig(other map[string]any) (oauth2.TokenSource, error } } - return NewHomeAssistant(uri) + return NewHomeAssistant(uri, cc.Insecure) } -func NewHomeAssistant(uri string) (oauth2.TokenSource, error) { +func NewHomeAssistant(uri string, insecure bool) (oauth2.TokenSource, error) { uri = strings.TrimRight(uri, "/") // normalize extUrl := network.Config().ExternalURL() @@ -76,5 +79,11 @@ func NewHomeAssistant(uri string) (oauth2.TokenSource, error) { log := util.NewLogger("homeassistant") ctx := util.WithLogger(context.Background(), log) + if insecure { + log.WARN.Println("insecure mode enabled - TLS certificate verification is disabled, use only for trusted local/self-signed instances") + httpClient := &http.Client{Transport: transport.Insecure()} + ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + } + return auth.NewOAuth(ctx, "HomeAssistant", host, &oc) } diff --git a/util/homeassistant/service.go b/util/homeassistant/service.go index 72f0d4837..c93f879f7 100644 --- a/util/homeassistant/service.go +++ b/util/homeassistant/service.go @@ -6,6 +6,7 @@ import ( "maps" "net/http" "slices" + "strconv" "strings" "github.com/evcc-io/evcc/server/service" @@ -35,7 +36,14 @@ func connectionFromRequest(req *http.Request) (*Connection, error) { if uri == "" { return nil, errors.New("missing uri") } - return NewConnection(log, uri, "") + var insecure bool + if s := req.URL.Query().Get("insecure"); s != "" { + var err error + if insecure, err = strconv.ParseBool(s); err != nil { + return nil, err + } + } + return NewConnection(log, uri, "", insecure) } // domainsFromRequest parses the comma-separated "domain" query parameter. diff --git a/util/service/demo.go b/util/service/demo.go index 7f4fe3dea..a0a593a71 100644 --- a/util/service/demo.go +++ b/util/service/demo.go @@ -13,6 +13,7 @@ func init() { mux.HandleFunc("GET /country", getCountry) mux.HandleFunc("GET /{country}/city", getCity) mux.HandleFunc("GET /modbus", getModbus) + mux.HandleFunc("GET /insecure", getInsecure) service.Register("demo", mux) } @@ -39,6 +40,10 @@ func getCity(w http.ResponseWriter, req *http.Request) { json.NewEncoder(w).Encode(cities) } +func getInsecure(w http.ResponseWriter, req *http.Request) { + json.NewEncoder(w).Encode([]string{"insecure=" + req.URL.Query().Get("insecure")}) +} + func getModbus(w http.ResponseWriter, req *http.Request) { // Verify that either uri or device is provided (mimics modbus connection params) uri := req.URL.Query().Get("uri") diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index f29ea1599..f89602f06 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -353,6 +353,12 @@ params: help: en: Integrated device. No charging sessions de: Fest angeschlossenes Gerät. Keine Ladevorgänge + - name: insecure + type: bool + advanced: true + description: + en: Accept self-signed certificates + de: Selbstsignierte Zertifikate akzeptieren - name: defaultmode type: int advanced: true diff --git a/util/templates/render_testing.go b/util/templates/render_testing.go index 8f49d4845..e43452a6d 100644 --- a/util/templates/render_testing.go +++ b/util/templates/render_testing.go @@ -38,7 +38,8 @@ func test(t *testing.T, tmpl Template, values map[string]any, cb func(values map cb(values) } -func testAuth(other map[string]any) error { +func testAuth(tmpl Template) error { + other := tmpl.Auth if len(other) == 0 { return nil } @@ -54,7 +55,11 @@ func testAuth(other map[string]any) error { params := make(map[string]any) for _, p := range cc.Params { - params[p] = "foo" + if _, param := tmpl.ParamByName(p); param.Type == TypeBool { + params[p] = true + } else { + params[p] = "foo" + } } _, err := auth.NewFromConfig(context.TODO(), cc.Type, params) @@ -94,7 +99,7 @@ func TestClass(t *testing.T, class Class, instantiate func(t *testing.T, values values["host"] = "localhost" // test auth configuration - if err := testAuth(tmpl.Auth); err != nil { + if err := testAuth(tmpl); err != nil { t.Error("authorization:", err) } diff --git a/vehicle/homeassistant.go b/vehicle/homeassistant.go index 4c05caf8e..b2946e52f 100644 --- a/vehicle/homeassistant.go +++ b/vehicle/homeassistant.go @@ -26,11 +26,9 @@ func init() { // Constructor from YAML config func NewHomeAssistantVehicleFromConfig(other map[string]any) (api.Vehicle, error) { var cc struct { - embed `mapstructure:",squash"` - URI string - Token_ string `mapstructure:"token"` // TODO deprecated - Home_ string `mapstructure:"home"` // TODO deprecated - Sensors struct { + embed `mapstructure:",squash"` + homeassistant.Config `mapstructure:",squash"` + Sensors struct { Soc string // required Range string // optional Status string // optional @@ -57,7 +55,7 @@ func NewHomeAssistantVehicleFromConfig(other map[string]any) (api.Vehicle, error log := util.NewLogger("ha-vehicle") - conn, err := homeassistant.NewConnection(log, cc.URI, cc.Home_) + conn, err := cc.Config.NewConnection(log) if err != nil { return nil, err }