Repository: evcc-io/evcc
Pull request #13803 (2024-05-07 19:15:00)

Add Victron OS support


Patch:
diff --git a/api/proto/pb/victron.pb.go b/api/proto/pb/victron.pb.go
new file mode 100644
index 00000000..fa4d17af
--- /dev/null
+++ b/api/proto/pb/victron.pb.go
@@ -0,0 +1,249 @@
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// 	protoc-gen-go v1.34.1
+// 	protoc        v5.26.1
+// source: proto/victron.proto
+
+package pb
+
+import (
+	protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+	protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+	reflect "reflect"
+	sync "sync"
+)
+
+const (
+	// Verify that this generated code is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+	// Verify that runtime/protoimpl is sufficiently up-to-date.
+	_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+type VictronRequest struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	ProductId string `protobuf:"bytes,1,opt,name=productId,proto3" json:"productId,omitempty"`
+	VrmId     string `protobuf:"bytes,2,opt,name=vrmId,proto3" json:"vrmId,omitempty"`
+	Serial    string `protobuf:"bytes,3,opt,name=serial,proto3" json:"serial,omitempty"`
+	Board     string `protobuf:"bytes,4,opt,name=board,proto3" json:"board,omitempty"`
+}
+
+func (x *VictronRequest) Reset() {
+	*x = VictronRequest{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_proto_victron_proto_msgTypes[0]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *VictronRequest) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VictronRequest) ProtoMessage() {}
+
+func (x *VictronRequest) ProtoReflect() protoreflect.Message {
+	mi := &file_proto_victron_proto_msgTypes[0]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VictronRequest.ProtoReflect.Descriptor instead.
+func (*VictronRequest) Descriptor() ([]byte, []int) {
+	return file_proto_victron_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *VictronRequest) GetProductId() string {
+	if x != nil {
+		return x.ProductId
+	}
+	return ""
+}
+
+func (x *VictronRequest) GetVrmId() string {
+	if x != nil {
+		return x.VrmId
+	}
+	return ""
+}
+
+func (x *VictronRequest) GetSerial() string {
+	if x != nil {
+		return x.Serial
+	}
+	return ""
+}
+
+func (x *VictronRequest) GetBoard() string {
+	if x != nil {
+		return x.Board
+	}
+	return ""
+}
+
+type VictronReply struct {
+	state         protoimpl.MessageState
+	sizeCache     protoimpl.SizeCache
+	unknownFields protoimpl.UnknownFields
+
+	Authorized bool   `protobuf:"varint,1,opt,name=authorized,proto3" json:"authorized,omitempty"`
+	Subject    string `protobuf:"bytes,2,opt,name=subject,proto3" json:"subject,omitempty"`
+}
+
+func (x *VictronReply) Reset() {
+	*x = VictronReply{}
+	if protoimpl.UnsafeEnabled {
+		mi := &file_proto_victron_proto_msgTypes[1]
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		ms.StoreMessageInfo(mi)
+	}
+}
+
+func (x *VictronReply) String() string {
+	return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VictronReply) ProtoMessage() {}
+
+func (x *VictronReply) ProtoReflect() protoreflect.Message {
+	mi := &file_proto_victron_proto_msgTypes[1]
+	if protoimpl.UnsafeEnabled && x != nil {
+		ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+		if ms.LoadMessageInfo() == nil {
+			ms.StoreMessageInfo(mi)
+		}
+		return ms
+	}
+	return mi.MessageOf(x)
+}
+
+// Deprecated: Use VictronReply.ProtoReflect.Descriptor instead.
+func (*VictronReply) Descriptor() ([]byte, []int) {
+	return file_proto_victron_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *VictronReply) GetAuthorized() bool {
+	if x != nil {
+		return x.Authorized
+	}
+	return false
+}
+
+func (x *VictronReply) GetSubject() string {
+	if x != nil {
+		return x.Subject
+	}
+	return ""
+}
+
+var File_proto_victron_proto protoreflect.FileDescriptor
+
+var file_proto_victron_proto_rawDesc = []byte{
+	0x0a, 0x13, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x76, 0x69, 0x63, 0x74, 0x72, 0x6f, 0x6e, 0x2e,
+	0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x72, 0x0a, 0x0e, 0x56, 0x69, 0x63, 0x74, 0x72, 0x6f, 0x6e,
+	0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x70, 0x72, 0x6f, 0x64, 0x75,
+	0x63, 0x74, 0x49, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x64,
+	0x75, 0x63, 0x74, 0x49, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x72, 0x6d, 0x49, 0x64, 0x18, 0x02,
+	0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x72, 0x6d, 0x49, 0x64, 0x12, 0x16, 0x0a, 0x06, 0x73,
+	0x65, 0x72, 0x69, 0x61, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x73, 0x65, 0x72,
+	0x69, 0x61, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01,
+	0x28, 0x09, 0x52, 0x05, 0x62, 0x6f, 0x61, 0x72, 0x64, 0x22, 0x48, 0x0a, 0x0c, 0x56, 0x69, 0x63,
+	0x74, 0x72, 0x6f, 0x6e, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x12, 0x1e, 0x0a, 0x0a, 0x61, 0x75, 0x74,
+	0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0a, 0x61,
+	0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x65, 0x64, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x62,
+	0x6a, 0x65, 0x63, 0x74, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x62, 0x6a,
+	0x65, 0x63, 0x74, 0x32, 0x3c, 0x0a, 0x07, 0x56, 0x69, 0x63, 0x74, 0x72, 0x6f, 0x6e, 0x12, 0x31,
+	0x0a, 0x0d, 0x49, 0x73, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x12,
+	0x0f, 0x2e, 0x56, 0x69, 0x63, 0x74, 0x72, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74,
+	0x1a, 0x0d, 0x2e, 0x56, 0x69, 0x63, 0x74, 0x72, 0x6f, 0x6e, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22,
+	0x00, 0x42, 0x0a, 0x5a, 0x08, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x70, 0x62, 0x62, 0x06, 0x70,
+	0x72, 0x6f, 0x74, 0x6f, 0x33,
+}
+
+var (
+	file_proto_victron_proto_rawDescOnce sync.Once
+	file_proto_victron_proto_rawDescData = file_proto_victron_proto_rawDesc
+)
+
+func file_proto_victron_proto_rawDescGZIP() []byte {
+	file_proto_victron_proto_rawDescOnce.Do(func() {
+		file_proto_victron_proto_rawDescData = protoimpl.X.CompressGZIP(file_proto_victron_proto_rawDescData)
+	})
+	return file_proto_victron_proto_rawDescData
+}
+
+var file_proto_victron_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
+var file_proto_victron_proto_goTypes = []interface{}{
+	(*VictronRequest)(nil), // 0: VictronRequest
+	(*VictronReply)(nil),   // 1: VictronReply
+}
+var file_proto_victron_proto_depIdxs = []int32{
+	0, // 0: Victron.IsValidDevice:input_type -> VictronRequest
+	1, // 1: Victron.IsValidDevice:output_type -> VictronReply
+	1, // [1:2] is the sub-list for method output_type
+	0, // [0:1] is the sub-list for method input_type
+	0, // [0:0] is the sub-list for extension type_name
+	0, // [0:0] is the sub-list for extension extendee
+	0, // [0:0] is the sub-list for field type_name
+}
+
+func init() { file_proto_victron_proto_init() }
+func file_proto_victron_proto_init() {
+	if File_proto_victron_proto != nil {
+		return
+	}
+	if !protoimpl.UnsafeEnabled {
+		file_proto_victron_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*VictronRequest); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+		file_proto_victron_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
+			switch v := v.(*VictronReply); i {
+			case 0:
+				return &v.state
+			case 1:
+				return &v.sizeCache
+			case 2:
+				return &v.unknownFields
+			default:
+				return nil
+			}
+		}
+	}
+	type x struct{}
+	out := protoimpl.TypeBuilder{
+		File: protoimpl.DescBuilder{
+			GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+			RawDescriptor: file_proto_victron_proto_rawDesc,
+			NumEnums:      0,
+			NumMessages:   2,
+			NumExtensions: 0,
+			NumServices:   1,
+		},
+		GoTypes:           file_proto_victron_proto_goTypes,
+		DependencyIndexes: file_proto_victron_proto_depIdxs,
+		MessageInfos:      file_proto_victron_proto_msgTypes,
+	}.Build()
+	File_proto_victron_proto = out.File
+	file_proto_victron_proto_rawDesc = nil
+	file_proto_victron_proto_goTypes = nil
+	file_proto_victron_proto_depIdxs = nil
+}
diff --git a/api/proto/pb/victron_grpc.pb.go b/api/proto/pb/victron_grpc.pb.go
new file mode 100644
index 00000000..c93af944
--- /dev/null
+++ b/api/proto/pb/victron_grpc.pb.go
@@ -0,0 +1,109 @@
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.3.0
+// - protoc             v5.26.1
+// source: proto/victron.proto
+
+package pb
+
+import (
+	context "context"
+	grpc "google.golang.org/grpc"
+	codes "google.golang.org/grpc/codes"
+	status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.32.0 or later.
+const _ = grpc.SupportPackageIsVersion7
+
+const (
+	Victron_IsValidDevice_FullMethodName = "/Victron/IsValidDevice"
+)
+
+// VictronClient is the client API for Victron service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type VictronClient interface {
+	IsValidDevice(ctx context.Context, in *VictronRequest, opts ...grpc.CallOption) (*VictronReply, error)
+}
+
+type victronClient struct {
+	cc grpc.ClientConnInterface
+}
+
+func NewVictronClient(cc grpc.ClientConnInterface) VictronClient {
+	return &victronClient{cc}
+}
+
+func (c *victronClient) IsValidDevice(ctx context.Context, in *VictronRequest, opts ...grpc.CallOption) (*VictronReply, error) {
+	out := new(VictronReply)
+	err := c.cc.Invoke(ctx, Victron_IsValidDevice_FullMethodName, in, out, opts...)
+	if err != nil {
+		return nil, err
+	}
+	return out, nil
+}
+
+// VictronServer is the server API for Victron service.
+// All implementations must embed UnimplementedVictronServer
+// for forward compatibility
+type VictronServer interface {
+	IsValidDevice(context.Context, *VictronRequest) (*VictronReply, error)
+	mustEmbedUnimplementedVictronServer()
+}
+
+// UnimplementedVictronServer must be embedded to have forward compatible implementations.
+type UnimplementedVictronServer struct {
+}
+
+func (UnimplementedVictronServer) IsValidDevice(context.Context, *VictronRequest) (*VictronReply, error) {
+	return nil, status.Errorf(codes.Unimplemented, "method IsValidDevice not implemented")
+}
+func (UnimplementedVictronServer) mustEmbedUnimplementedVictronServer() {}
+
+// UnsafeVictronServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to VictronServer will
+// result in compilation errors.
+type UnsafeVictronServer interface {
+	mustEmbedUnimplementedVictronServer()
+}
+
+func RegisterVictronServer(s grpc.ServiceRegistrar, srv VictronServer) {
+	s.RegisterService(&Victron_ServiceDesc, srv)
+}
+
+func _Victron_IsValidDevice_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+	in := new(VictronRequest)
+	if err := dec(in); err != nil {
+		return nil, err
+	}
+	if interceptor == nil {
+		return srv.(VictronServer).IsValidDevice(ctx, in)
+	}
+	info := &grpc.UnaryServerInfo{
+		Server:     srv,
+		FullMethod: Victron_IsValidDevice_FullMethodName,
+	}
+	handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+		return srv.(VictronServer).IsValidDevice(ctx, req.(*VictronRequest))
+	}
+	return interceptor(ctx, in, info, handler)
+}
+
+// Victron_ServiceDesc is the grpc.ServiceDesc for Victron service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var Victron_ServiceDesc = grpc.ServiceDesc{
+	ServiceName: "Victron",
+	HandlerType: (*VictronServer)(nil),
+	Methods: []grpc.MethodDesc{
+		{
+			MethodName: "IsValidDevice",
+			Handler:    _Victron_IsValidDevice_Handler,
+		},
+	},
+	Streams:  []grpc.StreamDesc{},
+	Metadata: "proto/victron.proto",
+}
diff --git a/api/proto/victron.proto b/api/proto/victron.proto
new file mode 100644
index 00000000..9c6614c6
--- /dev/null
+++ b/api/proto/victron.proto
@@ -0,0 +1,21 @@
+syntax = "proto3";
+
+// protoc proto/victron.proto --go_out=. --go-grpc_out=.
+
+option go_package = "proto/pb";
+
+service Victron {
+	rpc IsValidDevice (VictronRequest) returns (VictronReply) {}
+}
+
+message VictronRequest {
+	string productId = 1;
+	string vrmId = 2;
+	string serial = 3;
+	string board = 4;
+}
+
+message VictronReply {
+	bool authorized = 1;
+	string subject = 2;
+}
diff --git a/assets/js/components/LoginModal.vue b/assets/js/components/LoginModal.vue
index e3ccb1b7..83a67be6 100644
--- a/assets/js/components/LoginModal.vue
+++ b/assets/js/components/LoginModal.vue
@@ -26,6 +26,15 @@
 			</div>
 
 			<p v-if="error" class="text-danger my-4">{{ $t("loginModal.error") }}{{ error }}</p>
+			<a
+				v-if="iframeHint"
+				class="text-muted my-4 d-block text-center"
+				:href="evccUrl"
+				target="_blank"
+				data-testid="login-iframe-hint"
+			>
+				{{ $t("loginModal.iframeHint") }}
+			</a>
 
 			<button type="submit" class="btn btn-primary w-100 mb-3" :disabled="loading">
 				<span
@@ -52,7 +61,7 @@
 import GenericModal from "./GenericModal.vue";
 import Modal from "bootstrap/js/dist/modal";
 import api from "../api";
-import { updateAuthStatus, getAndClearNextUrl } from "../auth";
+import { updateAuthStatus, getAndClearNextUrl, isLoggedIn } from "../auth";
 import { docsPrefix } from "../i18n";
 
 export default {
@@ -64,6 +73,7 @@ export default {
 			password: "",
 			loading: false,
 			resetHint: false,
+			iframeHint: false,
 			error: "",
 		};
 	},
@@ -71,6 +81,9 @@ export default {
 		resetUrl() {
 			return `${docsPrefix()}/docs/faq#password-reset`;
 		},
+		evccUrl() {
+			return window.location.href;
+		},
 	},
 	methods: {
 		open() {
@@ -82,6 +95,7 @@ export default {
 			this.loading = false;
 			this.error = "";
 			this.resetHint = false;
+			this.iframeHint = false;
 		},
 		focus() {
 			console.log(this.$refs.password);
@@ -99,13 +113,20 @@ export default {
 					validateStatus: (code) => [200, 401].includes(code),
 				});
 				this.resetHint = false;
+				this.iframeHint = false;
 				this.error = "";
 				if (res.status === 200) {
-					this.closeModal();
 					await updateAuthStatus();
-					const target = getAndClearNextUrl();
-					if (target) this.$router.push(target);
-					this.password = "";
+					if (isLoggedIn()) {
+						this.closeModal();
+						const target = getAndClearNextUrl();
+						if (target) this.$router.push(target);
+						this.password = "";
+					} else {
+						// login successful but auth cookie doesnt work
+						this.error = this.$t("loginModal.iframeIssue");
+						this.iframeHint = true;
+					}
 				}
 				if (res.status === 401) {
 					this.error = this.$t("loginModal.invalid");
diff --git a/assets/js/components/SmartCostLimit.vue b/assets/js/components/SmartCostLimit.vue
index bd549a07..08491e01 100644
--- a/assets/js/components/SmartCostLimit.vue
+++ b/assets/js/components/SmartCostLimit.vue
@@ -111,12 +111,12 @@ export default {
 			for (let i = 1; i <= 100; i++) {
 				const value = this.optionStartValue + stepSize * i;
 				if (value != 0) {
-					values.push(value.toFixed(2));
+					values.push(this.roundLimit(value));
 				}
 			}
 			// add special entry if currently selected value is not in the scale
 			const selected = this.selectedSmartCostLimit;
-			if (selected !== undefined && !values.includes(selected)) {
+			if (selected && !values.includes(selected)) {
 				values.push(selected);
 			}
 			values.sort((a, b) => a - b);
@@ -134,16 +134,18 @@ export default {
 				return 0;
 			}
 			const { min } = this.costRange(this.totalSlots);
-			const minValue = Math.min(0, min);
 			const stepSize = this.optionStepSize;
-			return Math.ceil(minValue / stepSize) * stepSize;
+			// always show some negative values for price
+			const start = this.isCo2 ? 0 : stepSize * -11;
+			const minValue = Math.min(start, min);
+			return Math.floor(minValue / stepSize) * stepSize;
 		},
 		optionStepSize() {
 			if (!this.tariff) {
 				return 1;
 			}
 			const { min, max } = this.costRange(this.totalSlots);
-			for (const scale of [0.1, 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000]) {
+			for (const scale of [0.1, 0.5, 1, 10, 50, 100, 200, 500, 1000, 2000, 5000, 10000]) {
 				if (max - Math.min(0, min) < scale) {
 					return scale / 100;
 				}
@@ -225,14 +227,17 @@ export default {
 			this.updateTariff();
 		},
 		smartCostLimit(limit) {
-			this.selectedSmartCostLimit = limit?.toFixed(2);
+			this.selectedSmartCostLimit = this.roundLimit(limit);
 		},
 	},
 	mounted() {
 		this.updateTariff();
-		this.selectedSmartCostLimit = this.smartCostLimit?.toFixed(2);
+		this.selectedSmartCostLimit = this.roundLimit(this.smartCostLimit);
 	},
 	methods: {
+		roundLimit(limit) {
+			return limit ? Math.round(limit * 1000) / 1000 : 0;
+		},
 		updateTariff: async function () {
 			try {
 				this.tariff = (await api.get(`tariff/planner`)).data.result;
diff --git a/assets/js/components/VehicleIcon/Airpurifier.vue b/assets/js/components/VehicleIcon/Airpurifier.vue
new file mode 100644
index 00000000..a3abc4cf
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Airpurifier.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M3 21V6q0-1.25.875-2.125T6 3h4q1.25 0 2.125.875T13 6v15zm7-6h1v-5h-1zm-5 4h6v-2h-1q-.825 0-1.412-.587T8 15v-5q0-.825.588-1.412T10 8h1V6q0-.425-.288-.712T10 5H6q-.425 0-.712.288T5 6zm12.2-5.425q-.65 0-1.275-.175t-1.225-.45l.625-1.9q.5.225.988.375t.912.15q.3 0 .6-.1t.625-.3q.6-.425 1.2-.575t1.15-.15q.625 0 1.288.163t1.237.437l-.625 1.9q-.575-.2-1.062-.35t-.838-.15q-.3 0-.662.113t-.763.387q-.525.35-1.062.488t-1.113.137m.025-3.9q-.65 0-1.3-.175T14.7 9.05l.625-1.9q.65.275 1.1.4t.8.125q.3 0 .6-.087t.625-.313q.625-.425 1.213-.575t1.137-.15q.625 0 1.25.163t1.275.437l-.625 1.9q-.65-.225-1.1-.363t-.8-.137q-.325 0-.663.1t-.762.4q-.45.325-1.012.475t-1.138.15m0 7.8q-.65 0-1.287-.175t-1.238-.45l.625-1.9q.55.25 1.025.388t.875.137q.3 0 .6-.088t.625-.312q.575-.4 1.225-.562t1.15-.163q.625 0 1.275.175t1.225.425l-.625 1.9q-.65-.225-1.112-.363t-.788-.137q-.35 0-.712.113t-.713.387q-.425.3-.987.463t-1.163.162M11 19V5.788V6v-.212z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Airpurifier",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Climate.vue b/assets/js/components/VehicleIcon/Climate.vue
new file mode 100644
index 00000000..8754870d
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Climate.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M20 12H4q-.825 0-1.412-.587T2 10V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v6q0 .825-.587 1.413T20 12M4 19v-2q1.25 0 2.125-.875T7 14h2q0 2.075-1.463 3.538T4 19m16 0q-2.075 0-3.537-1.463T15 14h2q0 1.25.875 2.125T20 17zm-9 1v-6h2v6zm9-10H4zM6 10V8q0-.825.588-1.412T8 6h8q.825 0 1.413.588T18 8v2h-2V8H8v2zm-2 0h16V4H4z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Climate",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Compute.vue b/assets/js/components/VehicleIcon/Compute.vue
new file mode 100644
index 00000000..873f838d
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Compute.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M8.075 20q-.275 0-.513-.137q-.237-.138-.362-.388L5.25 16H6.7l1 2H10v-1H8.3l-1-2H4.7l-1.425-2.5q-.05-.125-.087-.25q-.038-.125-.038-.25t.038-.25q.037-.125.087-.25L4.7 9h2.6l1-2H10V6H7.7l-1 2H5.25L7.2 4.525q.125-.25.362-.388Q7.8 4 8.075 4H10.5q.425 0 .713.287q.287.288.287.713v4H10l-1 1h2.5v3H9.3l-1-2H6l-1 1h2.7l1 2h2.8v5q0 .425-.287.712q-.288.288-.713.288Zm5.425 0q-.425 0-.712-.288q-.288-.287-.288-.712v-5h2.8l1-2H19l-1-1h-2.3l-1 2h-2.2v-3H15l-1-1h-1.5V5q0-.425.288-.713Q13.075 4 13.5 4h2.425q.275 0 .513.137q.237.138.362.388L18.75 8H17.3l-1-2H14v1h1.7l1 2h2.6l1.425 2.5q.05.125.087.25q.038.125.038.25t-.038.25q-.037.125-.087.25L19.3 15h-2.6l-1 2H14v1h2.3l1-2h1.45l-1.95 3.475q-.125.25-.362.388q-.238.137-.513.137Z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Compute",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Device.vue b/assets/js/components/VehicleIcon/Device.vue
new file mode 100644
index 00000000..75b7b9b4
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Device.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M4 21q-1.25 0-2.125-.875T1 18V6q0-1.25.875-2.125T4 3h8q1.25 0 2.125.875T15 6v6.3h1q.825 0 1.413.588T18 14.3v3.2q0 .2.15.35t.35.15t.35-.15t.15-.35V11q-.825 0-1.412-.587T17 9V6h1V4h1.5v2h1V4H22v2h1v3q0 .825-.587 1.413T21 11v6.4q0 1.05-.725 1.775T18.5 19.9t-1.775-.725T16 17.4v-3.2h-1V17q0 1.65-1.175 2.825T11 21zm0-2h7q.825 0 1.413-.587T13 17t-.587-1.412T11 15H6.5q-.2 0-.35.15T6 15.5t.15.35t.35.15H11q.425 0 .713.288T12 17t-.288.713T11 18H6.5q-1.05 0-1.775-.725T4 15.5t.725-1.775T6.5 13H11q.525 0 1.038.15t.962.45V6q0-.425-.288-.712T12 5H4q-.425 0-.712.288T3 6v12q0 .425.288.713T4 19m0 0h7h-8z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Device",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Dishwasher.vue b/assets/js/components/VehicleIcon/Dishwasher.vue
new file mode 100644
index 00000000..0d472b91
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Dishwasher.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M8 7q.425 0 .713-.288T9 6t-.288-.712T8 5t-.712.288T7 6t.288.713T8 7m3 0q.425 0 .713-.288T12 6t-.288-.712T11 5t-.712.288T10 6t.288.713T11 7m1 11q1.575 0 2.688-1.075T15.8 14.3q0-.725-.25-1.412T14.8 11.7L12 8.9l-2.7 2.7q-.55.55-.837 1.25T8.2 14.3q.05 1.55 1.15 2.625T12 18m0-1.9q-.75 0-1.275-.525T10.2 14.3q0-.375.138-.712t.412-.613l1.25-1.25l1.225 1.225q.275.275.425.625t.15.725q0 .75-.525 1.275T12 16.1M6 22q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h12q.825 0 1.413.588T20 4v16q0 .825-.587 1.413T18 22zm0-2h12V4H6zm0 0V4z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Dishwasher",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Heatpump.vue b/assets/js/components/VehicleIcon/Heatpump.vue
new file mode 100644
index 00000000..1bf2bd54
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Heatpump.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M12 18q-2.5 0-4.25-1.75T6 12t1.75-4.25T12 6t4.25 1.75T18 12t-1.75 4.25T12 18m-.75-2.075V13.8l-1.5 1.5q.35.225.713.388t.787.237m1.5 0q.4-.075.775-.238t.725-.387l-1.5-1.5zm2.55-1.675q.225-.35.388-.725t.237-.775H13.8zm-1.5-3h2.125q-.075-.4-.238-.775T15.3 9.75zm-1.05-1.05l1.5-1.5q-.35-.225-.712-.387t-.788-.238zM12 13q.425 0 .713-.288T13 12t-.288-.712T12 11t-.712.288T11 12t.288.713T12 13m-.75-2.8V8.075q-.4.075-.775.238T9.75 8.7zm-3.175 1.05H10.2l-1.5-1.5q-.225.35-.387.725t-.238.775m.625 3l1.5-1.5H8.075q.075.4.238.775t.387.725M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h14q.825 0 1.413.588T21 5v14q0 .825-.587 1.413T19 21zm0-2h14V5H5zM5 5v14z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Heatpump",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Laundry.vue b/assets/js/components/VehicleIcon/Laundry.vue
new file mode 100644
index 00000000..3d72f55d
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Laundry.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M6 22q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h12q.825 0 1.413.588T20 4v16q0 .825-.587 1.413T18 22zm0-2h12V4H6zm6-1q2.075 0 3.538-1.463T17 14t-1.463-3.537T12 9t-3.537 1.463T7 14t1.463 3.538T12 19m0-1.7q-.65 0-1.263-.238T9.65 16.35l4.7-4.7q.475.475.713 1.088T15.3 14q0 1.375-.962 2.338T12 17.3M8 7q.425 0 .713-.288T9 6t-.288-.712T8 5t-.712.288T7 6t.288.713T8 7m3 0q.425 0 .713-.288T12 6t-.288-.712T11 5t-.712.288T10 6t.288.713T11 7M6 20V4z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Laundry",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Meter.vue b/assets/js/components/VehicleIcon/Meter.vue
new file mode 100644
index 00000000..89758a3f
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Meter.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M9 21.95v-2.5q-2.65-.925-4.325-3.237T3 10.95q0-1.875.713-3.512t1.924-2.85t2.85-1.925t3.488-.713t3.5.713t2.875 1.925t1.938 2.85T21 10.95q0 2.95-1.687 5.238T15 19.425v2.525h-2V19.9q-.25.05-.5.05h-.525q-.25 0-.488-.012T11 19.9v2.05zM12 18q2.9 0 4.95-2.05T19 11t-2.05-4.95T12 4T7.05 6.05T5 11t2.05 4.95T12 18M8 9h8V7H8zm3.25 8l3-3L13 12.75l1.25-1.25l-1.5-1.5l-3 3L11 14.25L9.75 15.5zm.75-6"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Meter",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Shuttle.vue b/assets/js/components/VehicleIcon/Shuttle.vue
new file mode 100644
index 00000000..14d77934
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Shuttle.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M6 19q-1.25 0-2.125-.875T3 16q-.825 0-1.412-.587T1 14V7q0-.825.588-1.412T3 5h13.175q.4 0 .763.15t.637.425l4.85 4.85q.275.275.425.638t.15.762V14q0 .825-.587 1.413T21 16q0 1.25-.875 2.125T18 19t-2.125-.875T15 16H9q0 1.25-.875 2.125T6 19m9-9h4l-3-3h-1zm-6 0h4V7H9zm-6 0h4V7H3zm3 7.25q.525 0 .888-.363T7.25 16t-.363-.888T6 14.75t-.888.363T4.75 16t.363.888t.887.362m12 0q.525 0 .888-.363T19.25 16t-.363-.888T18 14.75t-.888.363t-.362.887t.363.888t.887.362M8.2 14h7.6q.425-.45.975-.725T18 13t1.225.275t.975.725h.8v-2H3v2h.8q.425-.45.975-.725T6 13t1.225.275T8.2 14M21 12H3z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Shuttle",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/Tool.vue b/assets/js/components/VehicleIcon/Tool.vue
new file mode 100644
index 00000000..f240984e
--- /dev/null
+++ b/assets/js/components/VehicleIcon/Tool.vue
@@ -0,0 +1,14 @@
+<template>
+	<svg width="1em" height="1em" viewBox="0 0 24 24">
+		<path
+			fill="currentColor"
+			d="M6 19h6v-1H6zm.75-9h4.5q.3 0 .525-.225T12 9.25t-.225-.525t-.525-.225h-4.5q-.3 0-.525.225T6 9.25t.225.525t.525.225m0-2.5h4.5q.3 0 .525-.225T12 6.75t-.225-.525T11.25 6h-4.5q-.3 0-.525.225T6 6.75t.225.525t.525.225M16 11V9h2V7h-2V5h2q.825 0 1.413.588T20 7h2q.425 0 .713.288T23 8t-.288.713T22 9h-2q0 .825-.587 1.413T18 11zm-4 5h-2v-5h4V5H6q-.825 0-1.412.588T4 7v2q0 .825.588 1.413T6 11h2v5H6v-3q-1.65 0-2.825-1.175T2 9V7q0-1.65 1.175-2.825T6 3h8q.825 0 1.413.588T16 5v6q0 .825-.587 1.413T14 13h-2zm-6.5 5q-.625 0-1.062-.437T4 19.5v-2q0-.625.438-1.062T5.5 16h7q.625 0 1.063.438T14 17.5v2q0 .625-.437 1.063T12.5 21zm6.5-2H6z"
+		/>
+	</svg>
+</template>
+
+<script>
+export default {
+	name: "Tool",
+};
+</script>
diff --git a/assets/js/components/VehicleIcon/VehicleIcon.story.vue b/assets/js/components/VehicleIcon/VehicleIcon.story.vue
index 01b5f26a..37a8eba4 100644
--- a/assets/js/components/VehicleIcon/VehicleIcon.story.vue
+++ b/assets/js/components/VehicleIcon/VehicleIcon.story.vue
@@ -4,16 +4,26 @@ import VehicleIcon from "./VehicleIcon.vue";
 const icons = [
 	"car",
 	"bike",
-	"scooter",
+	"bus",
 	"moped",
 	"motorcycle",
-	"van",
-	"bus",
+	"scooter",
 	"tractor",
-	"generic",
+	"shuttle",
+	"van",
 	"heater",
-	"cooler",
+	"heatpump",
 	"waterheater",
+	"laundry",
+	"dishwasher",
+	"airpurifier",
+	"climate",
+	"cooler",
+	"generic",
+	"device",
+	"meter",
+	"compute",
+	"tool",
 ];
 </script>
 
diff --git a/assets/js/components/VehicleIcon/VehicleIcon.vue b/assets/js/components/VehicleIcon/VehicleIcon.vue
index 9c27ac03..bf61803d 100644
--- a/assets/js/components/VehicleIcon/VehicleIcon.vue
+++ b/assets/js/components/VehicleIcon/VehicleIcon.vue
@@ -7,28 +7,48 @@
 import "@h2d2/shopicons/es/regular/car3";
 import MultiIcon from "../MultiIcon";
 
+import airpurifier from "./Airpurifier.vue";
 import bike from "./Bike.vue";
 import bus from "./Bus.vue";
+import climate from "./Climate.vue";
+import compute from "./Compute.vue";
 import cooler from "./Cooler.vue";
+import device from "./Device.vue";
+import dishwasher from "./Dishwasher.vue";
 import generic from "./Generic.vue";
 import heater from "./Heater.vue";
+import heatpump from "./Heatpump.vue";
+import laundry from "./Laundry.vue";
+import meter from "./Meter.vue";
 import moped from "./Moped.vue";
 import motorcycle from "./Motorcycle.vue";
 import scooter from "./Scooter.vue";
+import shuttle from "./Shuttle.vue";
+import tool from "./Tool.vue";
 import tractor from "./Tractor.vue";
 import van from "./Van.vue";
 import waterheater from "./WaterHeater.vue";
 
 const icons = {
+	airpurifier,
 	bike,
 	bus,
 	car: "shopicon-regular-car3",
+	climate,
+	compute,
 	cooler,
+	device,
+	dishwasher,
 	generic,
 	heater,
+	heatpump,
+	laundry,
+	meter,
 	moped,
 	motorcycle,
 	scooter,
+	shuttle,
+	tool,
 	tractor,
 	van,
 	waterheater,
diff --git a/i18n/de.toml b/i18n/de.toml
index 90c1bd1c..694e24bf 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -234,6 +234,8 @@ update = "Aktualisieren"
 [loginModal]
 cancel = "Abbrechen"
 error = "Login fehlgeschlagen: "
+iframeHint = "Öffne evcc in einem neuen Tab."
+iframeIssue = "Das Passwort ist korrekt, aber dein Browser hat das Authentifizierungscookie abgelehnt. Dies kann passieren, wenn du evcc in einem iframe über HTTP verwendest."
 invalid = "Passwort ist ungültig."
 login = "Anmelden"
 password = "Passwort"
diff --git a/i18n/en.toml b/i18n/en.toml
index b543a807..2aff5e36 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -233,6 +233,8 @@ update = "Auto update"
 [loginModal]
 cancel = "Cancel"
 error = "Login failed: "
+iframeHint = "Open evcc in a new tab."
+iframeIssue = "Your password is correct, but your browser seems to have dropped the authentication cookie. This can happen if you run evcc in an iframe via HTTP."
 invalid = "Password is invalid."
 login = "Login"
 password = "Password"
diff --git a/templates/definition/meter/tasmota-sml.yaml b/templates/definition/meter/tasmota-sml.yaml
new file mode 100644
index 00000000..1ff9a4bb
--- /dev/null
+++ b/templates/definition/meter/tasmota-sml.yaml
@@ -0,0 +1,75 @@
+template: tasmota-sml
+products:
+  - brand: Tasmota
+    description:
+      de: SML IR-Lesekopf für smarte Stromzähler bspw Hichi
+      en: SML IR-reader for smartmeters e.g. Hichi
+requirements:
+  description:
+    de: |
+      Um die Werte des Smartmeters für evcc korrekt auslesen zu können, muss das Lesekopf-Script so geändert werden, dass folgende JSON-Tags erzeugt werden:
+      - **SML** als Gruppenname der ausgelesenen Parameter
+      - **Total_in** für den Gesamtverbrauch in KWh (4 Nachkommastellen)
+      - **Total_out** für den Gesamteinspeisung in KWh mit (4 Nachkommastellen)
+      - **Power_curr** für den aktuellen Verbrauch bzw. die aktuelle Einspeisung (0 Nachkommastellen)
+
+      Ein entsprechendes Lesekopf-Script sieht wie folgt aus:
+      ```
+      >D
+      >B
+      =>sensor53 r
+      >M 1
+      // highlight-next-line
+      +1,3,s,16,9600,SML
+      // highlight-next-line
+      1,77070100010800ff@1000,Gesamtverbrauch,KWh,Total_in,4
+      // highlight-next-line
+      1,77070100020800ff@1000,Gesamteinspeisung,KWh,Total_out,4
+      // highlight-next-line
+      1,77070100100700ff@1,Verbrauch,W,Power_curr,0
+      1,77070100600100ff@#,Zählernummer,,Meter_Id,0
+      #
+      ```
+    en: |
+      To be able to read the values of the smart meter for evcc correctly, the IR reader script must be changed so that the following JSON tags are generated:
+      - **SML** as the group name of the read parameters
+      - **Total_in** for the total consumption in KWh (4 decimal places)
+      - **Total_out** for the total feed-in in KWh (4 decimal places)
+      - **Power_curr** for the current consumption or the current feed-in in W  (0 decimal places)
+
+      A corresponding IR reader script looks like this:
+      ```
+      >D
+      >B
+      =>sensor53 r
+      >M 1
+      // highlight-next-line
+      +1,3,s,16,9600,SML
+      // highlight-next-line
+      1,77070100010800ff@1000,Gesamtverbrauch,KWh,Total_in,4
+      // highlight-next-line
+      1,77070100020800ff@1000,Gesamteinspeisung,KWh,Total_out,4
+      // highlight-next-line
+      1,77070100100700ff@1,Verbrauch,W,Power_curr,0
+      1,77070100600100ff@#,Zählernummer,,Meter_Id,0
+      #
+      ```
+params:
+  - name: usage
+    choice: ["grid"]
+  - name: host
+  - name: user
+    required: false
+    help:
+      de: Standard-User ist admin
+      en: admin is default
+  - name: password
+    required: false
+    mask: true
+render: |
+  type: tasmota
+  uri: http://{{ .host }}
+  usage: {{ .usage }}
+  user: {{ .user }}
+  password: {{ .password }}
+  channel: [1]  # list of meter channels [1,2,....,8]
diff --git a/templates/definition/meter/tasmota.yaml b/templates/definition/meter/tasmota.yaml
index 20274ad7..fd71e6ee 100644
--- a/templates/definition/meter/tasmota.yaml
+++ b/templates/definition/meter/tasmota.yaml
@@ -5,7 +5,7 @@ products:
 group: switchsockets
 params:
   - name: usage
-    choice: ["grid", "pv", "battery", "charge"]
+    choice: ["pv", "battery", "charge"]
   - name: host
   - name: user
     required: false
diff --git a/util/sponsor/auth.go b/util/sponsor/auth.go
index a2f4b14f..56243153 100644
--- a/util/sponsor/auth.go
+++ b/util/sponsor/auth.go
@@ -16,19 +16,27 @@ var (
 	ExpiresAt      time.Time
 )
 
-const unavailable = "sponsorship unavailable"
+const (
+	unavailable = "sponsorship unavailable"
+	victron     = "victron-device"
+)
 
 func IsAuthorized() bool {
 	return len(Subject) > 0
 }
 
 func IsAuthorizedForApi() bool {
-	return IsAuthorized() && Subject != unavailable
+	return IsAuthorized() && Token != ""
 }
 
 // check and set sponsorship token
 func ConfigureSponsorship(token string) error {
 	if token == "" {
+		if sub := checkVictron(); sub != "" {
+			Subject = sub
+			return nil
+		}
+
 		var err error
 		if token, err = readSerial(); token == "" || err != nil {
 			return err
diff --git a/util/sponsor/victron.go b/util/sponsor/victron.go
new file mode 100644
index 00000000..c6a77478
--- /dev/null
+++ b/util/sponsor/victron.go
@@ -0,0 +1,114 @@
+package sponsor
+
+import (
+	"context"
+	"errors"
+	"os/exec"
+	"runtime"
+	"strings"
+	"time"
+
+	"github.com/evcc-io/evcc/api/proto/pb"
+	"github.com/evcc-io/evcc/util/cloud"
+	"github.com/evcc-io/evcc/util/request"
+	"google.golang.org/grpc/codes"
+	"google.golang.org/grpc/status"
+)
+
+// checkVictron checks if the hardware is a supported victron device and returns sponsor subject
+func checkVictron() string {
+	vd, err := victronDeviceInfo()
+	if err != nil {
+		// unable to retrieve all device info
+		return ""
+	}
+
+	conn, err := cloud.Connection()
+	if err != nil {
+		// unable to connect to cloud
+		return unavailable
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), request.Timeout)
+	defer cancel()
+
+	client := pb.NewVictronClient(conn)
+	res, err := client.IsValidDevice(ctx, &pb.VictronRequest{
+		ProductId: vd.ProductId,
+		VrmId:     vd.VrmId,
+		Serial:    vd.Serial,
+		Board:     vd.Board,
+	})
+
+	if err == nil && res.Authorized {
+		// cloud check successful
+		return victron
+	}
+
+	if s, ok := status.FromError(err); ok && s.Code() != codes.Unknown {
+		// technical error during validation
+		return unavailable
+	}
+
+	return ""
+}
+
+type victronDevice struct {
+	ProductId string
+	VrmId     string
+	Serial    string
+	Board     string
+}
+
+func commandExists(cmd string) error {
+	_, err := exec.LookPath(cmd)
+	return err
+}
+
+func executeCommand(ctx context.Context, cmd string, args ...string) (string, error) {
+	command := exec.CommandContext(ctx, cmd, args...)
+	output, err := command.Output()
+	if err != nil {
+		return "", err
+	}
+	result := strings.TrimSpace(string(output))
+	if result == "" {
+		return "", errors.New("empty output")
+	}
+	return result, nil
+}
+
+func victronDeviceInfo() (victronDevice, error) {
+	if runtime.GOOS != "linux" {
+		return victronDevice{}, errors.New("non-linux os")
+	}
+
+	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+	defer cancel()
+
+	var vd victronDevice
+
+	commands := []struct {
+		field *string
+		cmd   string
+		args  []string
+	}{
+		{field: &vd.Board, cmd: "/usr/bin/board-compat"},
+		{field: &vd.ProductId, cmd: "/usr/bin/product-id"},
+		{field: &vd.VrmId, cmd: "/sbin/get-unique-id"},
+		{field: &vd.Serial, cmd: "/opt/victronenergy/venus-eeprom/eeprom", args: []string{"--show", "serial-number"}},
+	}
+
+	for _, detail := range commands {
+		if err := commandExists(detail.cmd); err != nil {
+			return vd, errors.New("cmd not found: " + detail.cmd)
+		}
+		output, err := executeCommand(ctx, detail.cmd, detail.args...)
+		if err != nil {
+			return vd, err
+		}
+		*detail.field = output
+	}
+
+	return vd, nil
+}
diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml
index e33c58d2..c9fe40ec 100644
--- a/util/templates/defaults.yaml
+++ b/util/templates/defaults.yaml
@@ -245,17 +245,27 @@ params:
     type: choice
     validvalues:
       - car
-      - bus
-      - van
-      - tractor
       - bike
-      - scooter
+      - bus
       - moped
       - motorcycle
-      - generic
-      - waterheater
+      - scooter
+      - tractor
+      - shuttle
+      - van
       - heater
+      - heatpump
+      - waterheater
+      - laundry
+      - dishwasher
+      - airpurifier
+      - climate
       - cooler
+      - generic
+      - device
+      - meter
+      - compute
+      - tool
   - name: ski
     required: true
     description:
diff --git a/vehicle/jlr.go b/vehicle/jlr.go
index 67492128..b2af9475 100644
--- a/vehicle/jlr.go
+++ b/vehicle/jlr.go
@@ -100,15 +100,12 @@ func (v *JLR) RegisterDevice(log *util.Logger, user, device string, t jlr.Token)
 
 	uri := fmt.Sprintf("%s/users/%s/clients", jlr.IFOP_BASE_URL, url.PathEscape(user))
 
-	req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), map[string]string{
+	req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), jlr.Headers(device, map[string]string{
 		"Authorization":           "Bearer " + t.AccessToken,
 		"Content-type":            "application/json",
 		"Accept":                  "application/json",
-		"X-Device-Id":             device,
 		"x-telematicsprogramtype": "jlrpy",
-		"x-App-Id":                "ICR_JAGUAR_ANDROID",
-		"x-App-Secret":            "7bf6f544-1926-4714-8066-ceceb40d538d",
-	})
+	}))
 	if err == nil {
 		_, err = c.DoBody(req)
 	}
diff --git a/vehicle/jlr/api.go b/vehicle/jlr/api.go
index e8e14889..e3a150f7 100644
--- a/vehicle/jlr/api.go
+++ b/vehicle/jlr/api.go
@@ -32,13 +32,10 @@ func NewAPI(log *util.Logger, device string, ts oauth2.TokenSource) *API {
 		Decorator: func(req *http.Request) error {
 			token, err := ts.Token()
 			if err == nil {
-				for k, v := range map[string]string{
-					"Authorization":           fmt.Sprintf("Bearer %s", token.AccessToken),
-					"X-Device-Id":             device,
+				for k, v := range Headers(device, map[string]string{
+					"Authorization":           "Bearer " + token.AccessToken,
 					"x-telematicsprogramtype": "jlrpy",
-					"x-App-Id":                "ICR_JAGUAR_ANDROID",
-					"x-App-Secret":            "7bf6f544-1926-4714-8066-ceceb40d538d",
-				} {
+				}) {
 					req.Header.Set(k, v)
 				}
 			}
diff --git a/vehicle/jlr/identity.go b/vehicle/jlr/identity.go
index e2422cb7..008a432c 100644
--- a/vehicle/jlr/identity.go
+++ b/vehicle/jlr/identity.go
@@ -2,6 +2,7 @@ package jlr
 
 import (
 	"fmt"
+	"maps"
 	"net/http"
 	"time"
 
@@ -21,6 +22,18 @@ type Identity struct {
 	oauth2.TokenSource
 }
 
+func Headers(device string, headers map[string]string) map[string]string {
+	res := map[string]string{
+		"X-Device-Id":  device,
+		"x-App-Id":     "ICR_JAGUAR",
+		"x-App-Secret": "018dd168-6271-707f-9fd4-aed2bf76905e",
+	}
+
+	maps.Copy(res, headers)
+
+	return res
+}
+
 // NewIdentity creates Fiat identity
 func NewIdentity(log *util.Logger, user, password, device string) *Identity {
 	return &Identity{
@@ -32,15 +45,12 @@ func NewIdentity(log *util.Logger, user, password, device string) *Identity {
 }
 
 // Login authenticates with given payload
-func (v *Identity) login(data map[string]string) (Token, error) {
+func (v *Identity) login(data any) (Token, error) {
 	uri := fmt.Sprintf("%s/tokens", IFAS_BASE_URL)
-	req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), map[string]string{
+	req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), Headers(v.device, map[string]string{
 		"Authorization": "Basic YXM6YXNwYXNz",
 		"Content-type":  request.JSONContent,
-		"X-Device-Id":   v.device,
-		"x-App-Id":      "ICR_JAGUAR_ANDROID",
-		"x-App-Secret":  "7bf6f544-1926-4714-8066-ceceb40d538d",
-	})
+	}))
 
 	var token Token
 	if err == nil {
diff --git a/vehicle/vag/vwidentity/forms.go b/vehicle/vag/vwidentity/forms.go
index 1e06929c..8c662929 100644
--- a/vehicle/vag/vwidentity/forms.go
+++ b/vehicle/vag/vwidentity/forms.go
@@ -89,7 +89,7 @@ func ParseCredentialsPage(r io.ReadCloser) (CredentialParams, error) {
 	re2 := regexp.MustCompile(`(?s),\s+}`)
 	tmpl = re2.ReplaceAllString(tmpl, "}")
 
-	for _, v := range []string{"templateModel", "disabledFeatures", "isFooterEnabled", "currentLocale", "csrf_parameterName", "csrf_token", "userSession", "userId", "countryOfResidence", "baseUrl", "consentBaseUrl"} {
+	for _, v := range []string{"templateModel", "disabledFeatures", "isFooterEnabled", "currentLocale", "csrf_parameterName", "csrf_token", "userSession", "userId", "countryOfResidence", "baseUrl", "consentBaseUrl", "isRTLEnabled"} {
 		tmpl = strings.Replace(tmpl, v, fmt.Sprintf(`"%s"`, v), 1)
 	}
 


Test patch:
diff --git a/tests/auth.spec.js b/tests/auth.spec.js
index e7163cff..32233cb2 100644
--- a/tests/auth.spec.js
+++ b/tests/auth.spec.js
@@ -63,6 +63,35 @@ test("login", async ({ page }) => {
   await expect(page.getByRole("heading", { name: "Configuration" })).toBeVisible();
 });
 
+test("http iframe hint", async ({ page }) => {
+  // set initial password
+  const modal = page.getByTestId("password-modal");
+  await modal.getByLabel("New password").fill("secret");
+  await modal.getByLabel("Repeat password").fill("secret");
+  await modal.getByRole("button", { name: "Create Password" }).click();
+
+  // go to config
+  await page.getByTestId("topnavigation-button").click();
+  await page.getByRole("link", { name: "Configuration" }).click();
+
+  // login modal
+  const login = page.getByTestId("login-modal");
+  await expect(login).toBeVisible();
+  await expect(login.getByRole("heading", { name: "Authentication" })).toBeVisible();
+
+  // rewrite api call to simulate lost auth cookie
+  await page.route("**/api/auth/status", (route) => {
+    route.fulfill({ status: 200, body: "false" });
+  });
+
+  // enter correct password
+  await login.getByLabel("Password").fill("secret");
+  await login.getByRole("button", { name: "Login" }).click();
+
+  // iframe hint visible (login-iframe-hint)
+  await expect(login.getByTestId("login-iframe-hint")).toBeVisible();
+});
+
 test("update password", async ({ page }) => {
   const oldPassword = "secret";
   const newPassword = "newsecret";
diff --git a/tests/smart-cost.spec.js b/tests/smart-cost.spec.js
index 8e62838d..a0f3ae58 100644
--- a/tests/smart-cost.spec.js
+++ b/tests/smart-cost.spec.js
@@ -34,11 +34,11 @@ test.describe("smart cost limit", async () => {
     await page
       .getByTestId("loadpoint-settings-modal")
       .getByLabel("Price limit")
-      .selectOption("≤ 50.0 ct/kWh");
+      .selectOption("≤ 40.0 ct/kWh");
     await page.getByTestId("loadpoint-settings-modal").getByLabel("Close").click();
     await expect(page.getByTestId("loadpoint-settings-modal")).not.toBeVisible();
     await expect(page.getByTestId("vehicle-status")).toContainText("Charging cheap energy");
-    await expect(page.getByTestId("vehicle-status")).toContainText("(limit 50.0 ct)");
+    await expect(page.getByTestId("vehicle-status")).toContainText("(limit 40.0 ct)");
   });
   test("price above limit", async ({ page }) => {
     await page.goto("/");