Chore: Typescript: Rewrite Loadpoints (#20859)

Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
Maschga 2025-04-28 23:34:05 +02:00 • committed by GitHub
parent e0421cc7d8
commit 67449ed79a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 636 additions and 425 deletions

View file

@ -23,9 +23,30 @@ GridAndPV.args = {
gridPower: -2300,
homePower: 800,
loadpointsCompact: [
{ power: 1000, icon: "car", charging: true, title: "Garage" },
{ power: 1000, icon: "bike", charging: true, title: "Garage" },
{ power: 2200, icon: "car", charging: true, title: "Garage" },
{
power: 1000,
icon: "car",
charging: true,
title: "Garage",
chargePower: 1000,
connected: true,
},
{
power: 1000,
icon: "bike",
charging: true,
title: "Garage",
chargePower: 1000,
connected: true,
},
{
power: 2200,
icon: "car",
charging: true,
title: "Garage",
chargePower: 2200,
connected: true,
},
],
tariffGrid: 0.25,
tariffFeedIn: 0.08,
@ -61,7 +82,16 @@ BatteryCharging.args = {
pvPower: 5000,
gridPower: -1300,
homePower: 800,
loadpointsCompact: [{ power: 1400, icon: "car", charging: true, title: "Garage" }],
loadpointsCompact: [
{
power: 1400,
icon: "car",
charging: true,
title: "Garage",
chargePower: 1400,
connected: true,
},
],
batteryPower: -1500,
batterySoc: 75,
};
@ -86,8 +116,22 @@ BatteryThresholds.args = {
pvPower: 8700,
gridPower: -500,
loadpointsCompact: [
{ power: 5000, icon: "car", charging: true, title: "Garage" },
{ power: 2500, icon: "bus", charging: true, title: "Garage" },
{
power: 5000,
icon: "car",
charging: true,
title: "Garage",
chargePower: 5000,
connected: true,
},
{
power: 2500,
icon: "bus",
charging: true,
title: "Garage",
chargePower: 2500,
connected: true,
},
],
batteryPower: -700,
batterySoc: 95,
@ -102,8 +146,22 @@ PVThresholds.args = {
gridPower: 6500,
homePower: 1000,
loadpointsCompact: [
{ power: 5000, icon: "car", charging: true, title: "Garage" },
{ power: 1600, icon: "car", charging: true, title: "Garage" },
{
power: 5000,
icon: "car",
charging: true,
title: "Garage",
chargePower: 5000,
connected: true,
},
{
power: 1600,
icon: "car",
charging: true,
title: "Garage",
chargePower: 1600,
connected: true,
},
],
batteryPower: 800,
batterySoc: 76,
@ -118,10 +176,38 @@ GridOnly.args = {
gridPower: 6500,
homePower: 1000,
loadpointsCompact: [
{ power: 5500, icon: "car", charging: true, title: "Garage" },
{ power: 0, icon: "car", charging: false, title: "Garage" },
{ power: 0, icon: "car", charging: false, title: "Garage" },
{ power: 0, icon: "car", charging: false, title: "Garage" },
{
power: 5500,
icon: "car",
charging: true,
title: "Garage",
chargePower: 5500,
connected: true,
},
{
power: 0,
icon: "car",
charging: false,
title: "Garage",
chargePower: 0,
connected: false,
},
{
power: 0,
icon: "car",
charging: false,
title: "Garage",
chargePower: 0,
connected: false,
},
{
power: 0,
icon: "car",
charging: false,
title: "Garage",
chargePower: 0,
connected: false,
},
],
batteryPower: 0,
batterySoc: 0,
@ -150,9 +236,30 @@ CO2.args = {
gridPower: -2300,
homePower: 800,
loadpointsCompact: [
{ power: 1000, icon: "car", charging: true, title: "Garage" },
{ power: 1000, icon: "car", charging: true, title: "Garage" },
{ power: 2200, icon: "car", charging: true, title: "Garage" },
{
power: 1000,
icon: "car",
charging: true,
title: "Garage",
chargePower: 1000,
connected: true,
},
{
power: 1000,
icon: "car",
charging: true,
title: "Garage",
chargePower: 1000,
connected: true,
},
{
power: 2200,
icon: "car",
charging: true,
title: "Garage",
chargePower: 2200,
connected: true,
},
],
tariffGrid: 0.25,
tariffFeedIn: 0.08,
@ -168,7 +275,16 @@ UnknownInput.args = {
pvConfigured: true,
pvPower: 2000,
gridPower: -2000,
loadpointsCompact: [{ power: 1000, icon: "car", charging: true, title: "Garage" }],
loadpointsCompact: [
{
power: 1000,
icon: "car",
charging: true,
title: "Garage",
chargePower: 1000,
connected: true,
},
],
};
export const UnknownInputFill = Template.bind({});
@ -188,7 +304,16 @@ UnknownOutput.args = {
pvConfigured: true,
pvPower: 3000,
gridPower: -1000,
loadpointsCompact: [{ power: 1700, icon: "car", charging: true, title: "Garage" }],
loadpointsCompact: [
{
power: 1700,
icon: "car",
charging: true,
title: "Garage",
chargePower: 1700,
connected: true,
},
],
};
export const UnknownOutputLessThan10Percent = Template.bind({});
@ -197,5 +322,14 @@ UnknownOutputLessThan10Percent.args = {
pvConfigured: true,
pvPower: 3000,
gridPower: -1000,
loadpointsCompact: [{ power: 1800, icon: "car", charging: true, title: "Garage" }],
loadpointsCompact: [
{
power: 1800,
icon: "car",
charging: true,
title: "Garage",
chargePower: 1800,
connected: true,
},
],
};

View file

@ -6,7 +6,11 @@
<slot>
<h3 class="value m-0" :class="valueClass">
<slot name="value">
<AnimatedNumber v-if="valueFmt" :to="value" :format="valueFmt" />
<AnimatedNumber
v-if="valueFmt && typeof value === 'number'"
:to="value"
:format="valueFmt"
/>
<span v-else>{{ value }}</span>
<div v-if="extraValue != null" class="extraValue text-nowrap">
{{ extraValue || "&nbsp;" }}
@ -26,7 +30,7 @@ export default defineComponent({
components: { AnimatedNumber },
props: {
label: String,
value: [Number],
value: [Number, String],
valueFmt: Function as PropType<(n: number) => string>,
extraValue: String,
align: { type: String, default: "center" },

View file

@ -1,74 +0,0 @@
import Loadpoint from "./Loadpoint.vue";
export default {
title: "Loadpoints/Loadpoint",
component: Loadpoint,
parameters: {
layout: "centered",
},
};
const baseState = {
id: 0,
pvConfigured: true,
chargePower: 2800,
chargedEnergy: 11e3,
chargeDuration: 95 * 60,
vehicleTitle: "Mein Auto",
vehicleName: "meinauto",
enabled: true,
connected: true,
mode: "pv",
charging: true,
vehicleSoc: 66,
limitSoc: 90,
offeredCurrent: 7,
minCurrent: 6,
maxCurrent: 16,
activePhases: 2,
};
const createStory = (props) => {
const story = (args) => ({
components: { Loadpoint },
setup() {
return { args };
},
template: '<Loadpoint v-bind="args" />',
});
story.args = { ...baseState, ...props };
return story;
};
export const Standard = createStory({});
export const WithoutSoc = createStory({
vehicleTitle: "",
vehicleName: "",
});
export const Idle = createStory({
enabled: false,
connected: false,
vehicleName: "",
mode: "off",
charging: false,
offeredCurrent: 0,
});
export const DisabledLongTitle = createStory({
title: "Charging point with a very very very long title!!!1!",
remoteDisabled: "soft",
remoteDisabledSource: "Sunny Home Manager",
mode: "now",
enabled: false,
charging: false,
chargePower: 0,
});
export const ChargerIconNoVehicle = createStory({
chargerIcon: "heater",
title: "Heating device with long name",
mode: "now",
chargerFeatureIntegratedDevice: true,
});

View file

@ -0,0 +1,78 @@
import Loadpoint from "./Loadpoint.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Loadpoints/Loadpoint",
component: Loadpoint,
parameters: {
layout: "centered",
},
} as Meta<typeof Loadpoint>;
const baseState = {
id: 0,
pvConfigured: true,
chargePower: 2800,
chargedEnergy: 11e3,
chargeDuration: 95 * 60,
vehicleTitle: "Mein Auto",
vehicleName: "meinauto",
enabled: true,
connected: true,
mode: "pv",
charging: true,
vehicleSoc: 66,
limitSoc: 90,
offeredCurrent: 7,
minCurrent: 6,
maxCurrent: 16,
activePhases: 2,
};
const Template: StoryFn<typeof Loadpoint> = (args) => {
const story = () => ({
components: { Loadpoint },
setup() {
return { args };
},
template: '<Loadpoint v-bind="args" />',
});
story.args = { ...baseState, ...args };
return story;
};
export const Standard = Template.bind({});
export const WithoutSoc = Template.bind({});
WithoutSoc.args = {
vehicleName: "",
};
export const Idle = Template.bind({});
Idle.args = {
enabled: false,
connected: false,
vehicleName: "",
mode: "off",
charging: false,
offeredCurrent: 0,
};
export const DisabledLongTitle = Template.bind({});
DisabledLongTitle.args = {
title: "Charging point with a very very very long title!!!1!",
remoteDisabled: "soft",
remoteDisabledSource: "Sunny Home Manager",
mode: "now",
enabled: false,
charging: false,
chargePower: 0,
};
export const ChargerIconNoVehicle = Template.bind({});
ChargerIconNoVehicle.args = {
chargerIcon: "heater",
title: "Heating device with long name",
mode: "now",
chargerFeatureIntegratedDevice: true,
};

View file

@ -72,7 +72,8 @@
<LabelAndValue
v-show="socBasedCharging"
:label="$t('main.loadpoint.charged')"
:value="fmtEnergy(chargedEnergy)"
:value="chargedEnergy"
:valueFmt="fmtEnergy"
align="center"
/>
<LoadpointSessionInfo v-bind="sessionInfoProps" />
@ -90,12 +91,12 @@
</div>
</template>
<script>
<script lang="ts">
import "@h2d2/shopicons/es/regular/lightning";
import "@h2d2/shopicons/es/regular/adjust";
import api from "@/api";
import Mode from "./Mode.vue";
import Vehicle from "../Vehicles/Vehicle.vue";
import VehicleComponent from "../Vehicles/Vehicle.vue";
import Phases from "./Phases.vue";
import LabelAndValue from "../Helper/LabelAndValue.vue";
import formatter, { POWER_UNIT } from "@/mixins/formatter";
@ -106,12 +107,14 @@ import VehicleIcon from "../VehicleIcon";
import SessionInfo from "./SessionInfo.vue";
import smartCostAvailable from "@/utils/smartCostAvailable";
import Modal from "bootstrap/js/dist/modal";
import { defineComponent, type PropType } from "vue";
import type { CHARGE_MODE, PHASES, Timeout, Vehicle, Forecast } from "@/types/evcc";
export default {
export default defineComponent({
name: "Loadpoint",
components: {
Mode,
Vehicle,
Vehicle: VehicleComponent,
Phases,
LabelAndValue,
LoadpointSettingsButton: SettingsButton,
@ -129,9 +132,9 @@ export default {
mode: String,
effectiveLimitSoc: Number,
limitEnergy: Number,
remoteDisabled: Boolean,
remoteDisabled: String,
remoteDisabledSource: String,
chargeDuration: Number,
chargeDuration: { type: Number, default: 0 },
charging: Boolean,
batteryBoost: Boolean,
batteryConfigured: Boolean,
@ -155,15 +158,15 @@ export default {
enabled: Boolean,
vehicleDetectionActive: Boolean,
vehicleRange: Number,
vehicleSoc: Number,
vehicleSoc: { type: Number, default: 0 },
vehicleName: String,
vehicleIcon: String,
vehicleLimitSoc: Number,
vehicles: Array,
vehicles: Array as PropType<Vehicle[]>,
planActive: Boolean,
planProjectedStart: String,
planProjectedEnd: String,
planOverrun: Number,
planOverrun: { type: Number, default: 0 },
planEnergy: Number,
planPrecondition: Number,
planTime: String,
@ -176,9 +179,9 @@ export default {
// details
vehicleClimaterActive: Boolean,
vehicleWelcomeActive: Boolean,
chargePower: Number,
chargedEnergy: Number,
chargeRemainingDuration: Number,
chargePower: { type: Number, default: 0 },
chargedEnergy: { type: Number, default: 0 },
chargeRemainingDuration: { type: Number, default: 0 },
// other information
phasesConfigured: Number,
@ -192,8 +195,8 @@ export default {
chargeCurrents: Array,
chargeRemainingEnergy: Number,
phaseAction: String,
phaseRemaining: Number,
pvRemaining: Number,
phaseRemaining: { type: Number, default: 0 },
pvRemaining: { type: Number, default: 0 },
pvAction: String,
smartCostLimit: { type: Number, default: null },
smartCostType: String,
@ -205,11 +208,11 @@ export default {
multipleLoadpoints: Boolean,
gridConfigured: Boolean,
pvConfigured: Boolean,
forecast: Object, // as PropType<Forecast>,
forecast: Object as PropType<Forecast>,
},
data() {
return {
tickerHandler: null,
tickerHandler: null as Timeout,
phaseRemainingInterpolated: this.phaseRemaining,
pvRemainingInterpolated: this.pvRemaining,
chargeDurationInterpolated: this.chargeDuration,
@ -245,7 +248,7 @@ export default {
return this.collectProps(SettingsModal);
},
vehicleProps() {
return this.collectProps(Vehicle);
return this.collectProps(VehicleComponent);
},
showChargingIndicator() {
return this.charging && this.chargePower > 0;
@ -269,7 +272,7 @@ export default {
return this.vehicleHasSoc || this.vehicleSoc > 0;
},
socBasedPlanning() {
return this.socBasedCharging && this.vehicle?.capacity > 0;
return this.socBasedCharging && this.vehicle?.capacity && this.vehicle?.capacity > 0;
},
pvPossible() {
return this.pvConfigured || this.gridConfigured;
@ -281,7 +284,12 @@ export default {
return this.batteryConfigured && this.$hiddenFeatures();
},
batteryBoostActive() {
return this.batteryBoost && this.charging && !["off", "now"].includes(this.mode);
return (
this.batteryBoost &&
this.charging &&
this.mode &&
!["off", "now"].includes(this.mode)
);
},
plannerForecast() {
return this.forecast?.planner;
@ -305,7 +313,9 @@ export default {
this.tickerHandler = setInterval(this.tick, 1000);
},
unmounted() {
clearInterval(this.tickerHandler);
if (this.tickerHandler) {
clearInterval(this.tickerHandler);
}
},
methods: {
tick() {
@ -322,50 +332,50 @@ export default {
this.chargeRemainingDurationInterpolated--;
}
},
apiPath(func) {
apiPath(func: string) {
return "loadpoints/" + this.id + "/" + func;
},
setTargetMode(mode) {
setTargetMode(mode: CHARGE_MODE) {
api.post(this.apiPath("mode") + "/" + mode);
},
setLimitSoc(soc) {
setLimitSoc(soc: number) {
api.post(this.apiPath("limitsoc") + "/" + soc);
},
setLimitEnergy(kWh) {
setLimitEnergy(kWh: number) {
api.post(this.apiPath("limitenergy") + "/" + kWh);
},
setMaxCurrent(maxCurrent) {
setMaxCurrent(maxCurrent: number) {
api.post(this.apiPath("maxcurrent") + "/" + maxCurrent);
},
setMinCurrent(minCurrent) {
setMinCurrent(minCurrent: number) {
api.post(this.apiPath("mincurrent") + "/" + minCurrent);
},
setPhasesConfigured(phases) {
setPhasesConfigured(phases: PHASES) {
api.post(this.apiPath("phases") + "/" + phases);
},
changeVehicle(name) {
changeVehicle(name: string) {
api.post(this.apiPath("vehicle") + `/${name}`);
},
removeVehicle() {
api.delete(this.apiPath("vehicle"));
},
setBatteryBoost(batteryBoost) {
setBatteryBoost(batteryBoost: boolean) {
api.post(this.apiPath("batteryboost") + `/${batteryBoost ? "1" : "0"}`);
},
fmtPower(value) {
fmtPower(value: number) {
return this.fmtW(value, POWER_UNIT.AUTO);
},
fmtEnergy(value) {
fmtEnergy(value: number) {
return this.fmtWh(value, POWER_UNIT.AUTO);
},
openSettingsModal() {
const modal = Modal.getOrCreateInstance(
document.getElementById(`loadpointSettingsModal_${this.id}`)
document.getElementById(`loadpointSettingsModal_${this.id}`) as HTMLElement
);
modal.show();
},
},
};
});
</script>
<style scoped>

View file

@ -1,53 +0,0 @@
import Loadpoints from "./Loadpoints.vue";
function loadpoint(opts = {}) {
const base = {
id: 0,
pvConfigured: true,
chargePower: 2800,
chargedEnergy: 11e3,
chargeDuration: 95 * 60,
vehicleName: "tesla",
vehicleTitle: "Tesla Model 3",
enabled: true,
connected: true,
mode: "pv",
charging: true,
vehicleSoc: 66,
limitSoc: 90,
offeredCurrent: 7,
minCurrent: 6,
maxCurrent: 16,
activePhases: 2,
};
return { ...base, ...opts };
}
export default {
title: "Loadpoints/Loadpoints",
component: Loadpoints,
parameters: {
layout: "centered",
},
};
const Template = (args) => ({
components: { Loadpoints },
setup() {
return { args };
},
template: '<Loadpoints v-bind="args" />',
});
export const Standard = Template.bind({});
Standard.args = {
vehicles: [],
loadpoints: [
loadpoint({ title: "Carport" }),
loadpoint({
title: "Water Heater",
chargerFeatureIntegratedDevice: true,
chargerIcon: "waterheater",
}),
],
};

View file

@ -0,0 +1,57 @@
import Loadpoints from "./Loadpoints.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
function loadpoint(opts = {}) {
const base = {
id: 0,
pvConfigured: true,
chargePower: 2800,
chargedEnergy: 11e3,
chargeDuration: 95 * 60,
vehicleName: "tesla",
vehicleTitle: "Tesla Model 3",
enabled: true,
connected: true,
mode: "pv",
charging: true,
vehicleSoc: 66,
limitSoc: 90,
offeredCurrent: 7,
minCurrent: 6,
maxCurrent: 16,
activePhases: 2,
icon: "car",
title: "Garage",
power: 2100,
};
return { ...base, ...opts };
}
export default {
title: "Loadpoints/Loadpoints",
component: Loadpoints,
parameters: {
layout: "centered",
},
} as Meta<typeof Loadpoints>;
const Template: StoryFn<typeof Loadpoints> = (args) => ({
components: { Loadpoints },
setup() {
return { args };
},
template: '<Loadpoints v-bind="args" />',
});
export const Standard = Template.bind({});
Standard.args = {
vehicles: [],
loadpoints: [
loadpoint({ title: "Carport" }),
loadpoint({
title: "Water Heater",
chargerFeatureIntegratedDevice: true,
chargerIcon: "waterheater",
}),
],
};

View file

@ -54,24 +54,26 @@
</div>
</template>
<script>
<script lang="ts">
import "@h2d2/shopicons/es/filled/circle";
import "@h2d2/shopicons/es/bold/circle";
import "@h2d2/shopicons/es/filled/lightning";
import Loadpoint from "./Loadpoint.vue";
import { defineComponent, type PropType } from "vue";
import type { LoadpointCompact, Timeout, Vehicle } from "@/types/evcc";
export default {
export default defineComponent({
name: "Loadpoints",
components: { Loadpoint },
props: {
loadpoints: Array,
vehicles: Array,
loadpoints: { type: Array as PropType<LoadpointCompact[]>, default: () => [] },
vehicles: { type: Array as PropType<Vehicle[]> },
smartCostType: String,
tariffGrid: Number,
tariffCo2: Number,
currency: String,
selectedIndex: Number,
selectedIndex: { type: Number, default: 0 },
gridConfigured: Boolean,
pvConfigured: Boolean,
batteryConfigured: Boolean,
@ -79,7 +81,11 @@ export default {
},
emits: ["index-changed"],
data() {
return { snapTimeout: null, scrollTimeout: null, highlightedIndex: 0 };
return {
snapTimeout: null as Timeout,
scrollTimeout: null as Timeout,
highlightedIndex: 0,
};
},
watch: {
selectedIndex(newIndex) {
@ -88,54 +94,60 @@ export default {
},
mounted() {
if (this.selectedIndex > 0) {
this.$refs.carousel.scrollTo({ top: 0, left: this.left(this.selectedIndex) });
this.$refs["carousel"]?.scrollTo({ top: 0, left: this.left(this.selectedIndex) });
}
this.$refs.carousel.addEventListener("scroll", this.handleCarouselScroll);
this.$refs["carousel"]?.addEventListener("scroll", this.handleCarouselScroll);
},
unmounted() {
if (this.$refs.carousel) {
this.$refs.carousel.removeEventListener("scroll", this.handleCarouselScroll);
}
this.$refs["carousel"]?.removeEventListener("scroll", this.handleCarouselScroll);
},
methods: {
handleCarouselScroll() {
const { scrollLeft } = this.$refs.carousel;
const { offsetWidth } = this.$refs.carousel.children[0];
const { scrollLeft } = this.$refs["carousel"] as HTMLElement;
const { offsetWidth } = this.$refs["carousel"]?.children[0] as HTMLElement;
this.highlightedIndex = Math.round((scrollLeft - 7.5) / offsetWidth);
// save scroll position to url if not changing for 2s
clearTimeout(this.scrollTimeout);
if (this.scrollTimeout) {
clearTimeout(this.scrollTimeout);
}
this.scrollTimeout = setTimeout(() => {
if (this.highlightedIndex !== this.selectedIndex) {
this.$emit("index-changed", this.highlightedIndex);
}
}, 2000);
},
goTo(index) {
goTo(index: number) {
this.$emit("index-changed", index);
},
isCharging(lp) {
isCharging(lp: LoadpointCompact) {
return lp.charging && lp.chargePower > 0;
},
selected(index) {
selected(index: number) {
return this.highlightedIndex === index;
},
left(index) {
return this.$refs.carousel.children[0].offsetWidth * index;
left(index: number) {
return (this.$refs["carousel"]?.children[0] as HTMLElement).offsetWidth * index;
},
scrollTo(index) {
scrollTo(index: number) {
this.highlightedIndex = index;
const $carousel = this.$refs.carousel;
$carousel.style.scrollSnapType = "none";
$carousel.scrollTo({ top: 0, left: this.left(index), behavior: "smooth" });
const $carousel = this.$refs["carousel"];
if ($carousel) {
$carousel.style.scrollSnapType = "none";
$carousel?.scrollTo({ top: 0, left: this.left(index), behavior: "smooth" });
}
clearTimeout(this.snapTimeout);
if (this.snapTimeout) {
clearTimeout(this.snapTimeout);
}
this.snapTimeout = setTimeout(() => {
this.$refs.carousel.style.scrollSnapType = "x mandatory";
if (this.$refs["carousel"]) {
this.$refs["carousel"].style.scrollSnapType = "x mandatory";
}
}, 1000);
},
},
};
});
</script>
<style scoped>
.container--loadpoint {

View file

@ -1,44 +0,0 @@
import Mode from "./Mode.vue";
export default {
title: "Loadpoints/Mode",
component: Mode,
argTypes: {
mode: {
control: "select",
options: ["off", "now", "minpv", "pv"],
description: "Charging mode",
},
pvPossible: { control: "boolean", description: "Whether PV is possible" },
hasSmartCost: { control: "boolean", description: "Whether smart cost is available" },
},
parameters: {
layout: "centered",
},
};
const createStory = (props) => {
const story = (args) => ({
components: { Mode },
setup() {
return { args };
},
template: '<Mode v-bind="args" />',
});
story.args = props;
return story;
};
export const Minimal = createStory({ mode: "now" });
export const Full = createStory({
mode: "pv",
pvPossible: true,
hasSmartCost: true,
});
export const SmartGridOnly = createStory({
mode: "pv",
pvPossible: false,
hasSmartCost: true,
});

View file

@ -0,0 +1,48 @@
import Mode from "./Mode.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Loadpoints/Mode",
component: Mode,
argTypes: {
mode: {
control: "select",
options: ["off", "now", "minpv", "pv"],
description: "Charging mode",
},
pvPossible: { control: "boolean", description: "Whether PV is possible" },
hasSmartCost: { control: "boolean", description: "Whether smart cost is available" },
},
parameters: {
layout: "centered",
},
} as Meta<typeof Mode>;
const Template: StoryFn<typeof Mode> = (args) => {
const story = () => ({
components: { Mode },
setup() {
return { args };
},
template: '<Mode v-bind="args" />',
});
story.args = args;
return story;
};
export const Minimal = Template.bind({});
Minimal.args = { mode: "now" };
export const Full = Template.bind({});
Full.args = {
mode: "pv",
pvPossible: true,
hasSmartCost: true,
};
export const SmartGridOnly = Template.bind({});
SmartGridOnly.args = {
mode: "pv",
pvPossible: false,
hasSmartCost: true,
};

View file

@ -14,8 +14,13 @@
</div>
</template>
<script>
export default {
<script lang="ts">
import { CHARGE_MODE } from "@/types/evcc";
import { defineComponent } from "vue";
const { OFF, PV, MINPV, NOW } = CHARGE_MODE;
export default defineComponent({
name: "Mode",
props: {
mode: String,
@ -25,33 +30,33 @@ export default {
emits: ["updated"],
computed: {
modes() {
modes(): CHARGE_MODE[] {
if (this.pvPossible) {
return ["off", "pv", "minpv", "now"];
return [OFF, PV, MINPV, NOW];
}
if (this.hasSmartCost) {
return ["off", "pv", "now"];
return [OFF, PV, NOW];
}
return ["off", "now"];
return [OFF, NOW];
},
},
methods: {
label(mode) {
label(mode: CHARGE_MODE) {
// rename pv mode to smart for non-pv and dynamic tariffs scenarios
// TODO: rollout smart name for everyting later
if (mode === "pv" && !this.pvPossible && this.hasSmartCost) {
if (mode === PV && !this.pvPossible && this.hasSmartCost) {
return this.$t("main.mode.smart");
}
return this.$t(`main.mode.${mode}`);
},
isActive(mode) {
isActive(mode: CHARGE_MODE) {
return this.mode === mode;
},
setTargetMode(mode) {
setTargetMode(mode: CHARGE_MODE) {
this.$emit("updated", mode);
},
},
};
});
</script>
<style scoped>

View file

@ -1,94 +0,0 @@
import Phases from "./Phases.vue";
export default {
title: "Loadpoints/Phases",
component: Phases,
parameters: {
layout: "centered",
},
argTypes: {
phasesActive: { control: { type: "number" } },
minCurrent: { control: { type: "number" } },
maxCurrent: { control: { type: "number" } },
offeredCurrent: { control: { type: "number" } },
chargeCurrents: { control: { type: "object" } },
},
};
const Template = (args) => ({
components: { Phases },
setup() {
return { args };
},
template: '<Phases v-bind="args" />',
});
export const OnePhase = Template.bind({});
OnePhase.args = {
phasesActive: 1,
minCurrent: 6,
maxCurrent: 16,
offeredCurrent: 8,
chargeCurrents: null,
};
export const TwoPhase = Template.bind({});
TwoPhase.args = {
...OnePhase.args,
phasesActive: 2,
};
export const ThreePhase = Template.bind({});
ThreePhase.args = {
...OnePhase.args,
phasesActive: 3,
};
export const RealCurrents = Template.bind({});
RealCurrents.args = {
...OnePhase.args,
phasesActive: 3,
offeredCurrent: 13,
chargeCurrents: [11, 9, 12],
};
export const OnePhaseMoreAvailable = Template.bind({});
OnePhaseMoreAvailable.args = {
...OnePhase.args,
offeredCurrent: 12,
chargeCurrents: [6, 0.2, 0],
};
export const TwoPhasesActive = Template.bind({});
TwoPhasesActive.args = {
...OnePhase.args,
phasesActive: 2,
offeredCurrent: 16,
chargeCurrents: [16, 16, 0.3],
};
export const AsymetricPhases = Template.bind({});
AsymetricPhases.args = {
...OnePhase.args,
phasesActive: 2,
offeredCurrent: 16,
chargeCurrents: [8, 0.9, 14],
};
export const OnlySecondPhase = Template.bind({});
OnlySecondPhase.args = {
...OnePhase.args,
phasesActive: 1,
offeredCurrent: 13,
chargeCurrents: [0, 13, 0],
};
export const MainlyThirdPhase = Template.bind({});
MainlyThirdPhase.args = {
...OnePhase.args,
phasesActive: 1,
offeredCurrent: 10,
chargeCurrents: [0.007, 0.009, 5.945],
minCurrent: 6,
maxCurrent: 20,
};

View file

@ -0,0 +1,95 @@
import Phases from "./Phases.vue";
import type { Meta, StoryFn } from "@storybook/vue3";
export default {
title: "Loadpoints/Phases",
component: Phases,
parameters: {
layout: "centered",
},
argTypes: {
phasesActive: { control: { type: "number" } },
minCurrent: { control: { type: "number" } },
maxCurrent: { control: { type: "number" } },
offeredCurrent: { control: { type: "number" } },
chargeCurrents: { control: { type: "object" } },
},
} as Meta<typeof Phases>;
const Template: StoryFn<typeof Phases> = (args) => ({
components: { Phases },
setup() {
return { args };
},
template: '<Phases v-bind="args" />',
});
export const OnePhase = Template.bind({});
OnePhase.args = {
phasesActive: 1,
minCurrent: 6,
maxCurrent: 16,
offeredCurrent: 8,
chargeCurrents: undefined,
};
export const TwoPhase = Template.bind({});
TwoPhase.args = {
...OnePhase.args,
phasesActive: 2,
};
export const ThreePhase = Template.bind({});
ThreePhase.args = {
...OnePhase.args,
phasesActive: 3,
};
export const RealCurrents = Template.bind({});
RealCurrents.args = {
...OnePhase.args,
phasesActive: 3,
offeredCurrent: 13,
chargeCurrents: [11, 9, 12],
};
export const OnePhaseMoreAvailable = Template.bind({});
OnePhaseMoreAvailable.args = {
...OnePhase.args,
offeredCurrent: 12,
chargeCurrents: [6, 0.2, 0],
};
export const TwoPhasesActive = Template.bind({});
TwoPhasesActive.args = {
...OnePhase.args,
phasesActive: 2,
offeredCurrent: 16,
chargeCurrents: [16, 16, 0.3],
};
export const AsymetricPhases = Template.bind({});
AsymetricPhases.args = {
...OnePhase.args,
phasesActive: 2,
offeredCurrent: 16,
chargeCurrents: [8, 0.9, 14],
};
export const OnlySecondPhase = Template.bind({});
OnlySecondPhase.args = {
...OnePhase.args,
phasesActive: 1,
offeredCurrent: 13,
chargeCurrents: [0, 13, 0],
};
export const MainlyThirdPhase = Template.bind({});
MainlyThirdPhase.args = {
...OnePhase.args,
phasesActive: 1,
offeredCurrent: 10,
chargeCurrents: [0.007, 0.009, 5.945],
minCurrent: 6,
maxCurrent: 20,
};

View file

@ -12,21 +12,24 @@
</div>
</template>
<script>
<script lang="ts">
import type { PHASES } from "@/types/evcc";
import { defineComponent, type PropType } from "vue";
const MIN_ACTIVE_CURRENT = 1;
export default {
export default defineComponent({
name: "Phases",
props: {
offeredCurrent: { type: Number },
chargeCurrents: { type: Array },
phasesActive: { type: Number },
minCurrent: { type: Number },
maxCurrent: { type: Number },
offeredCurrent: { type: Number, default: 0 },
chargeCurrents: { type: Array as PropType<number[]> },
phasesActive: { type: Number as PropType<PHASES> },
minCurrent: { type: Number, default: 6 },
maxCurrent: { type: Number, default: 16 },
},
computed: {
chargeCurrentsActive() {
return this.chargeCurrents?.filter((c) => c >= MIN_ACTIVE_CURRENT).length > 0;
if (!this.chargeCurrents) return false;
return this.chargeCurrents.filter((c) => c >= MIN_ACTIVE_CURRENT).length > 0;
},
},
methods: {
@ -37,21 +40,21 @@ export default {
);
return (100 / this.maxCurrent) * current;
},
realWidth(num) {
realWidth(num: number) {
if (this.chargeCurrents) {
const current = this.chargeCurrents[num - 1] || 0;
return (100 / this.maxCurrent) * current;
}
return this.targetWidth();
},
isPhaseActive(num) {
if (this.chargeCurrentsActive) {
isPhaseActive(num: number) {
if (this.chargeCurrentsActive && this.chargeCurrents) {
return this.chargeCurrents[num - 1] >= MIN_ACTIVE_CURRENT;
}
return num <= this.phasesActive;
return num <= (this.phasesActive || 0);
},
},
};
});
</script>
<style scoped>

View file

@ -26,13 +26,15 @@
</div>
</template>
<script>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import LabelAndValue from "../Helper/LabelAndValue.vue";
import CustomSelect from "../Helper/CustomSelect.vue";
import formatter from "@/mixins/formatter";
import { getSessionInfo, setSessionInfo } from "./session";
import type { CURRENCY, SelectOption } from "@/types/evcc";
export default {
export default defineComponent({
name: "LoadpointSessionInfo",
components: {
LabelAndValue,
@ -40,13 +42,13 @@ export default {
},
mixins: [formatter],
props: {
id: Number,
id: { type: Number, default: 0 },
sessionCo2PerKWh: { type: Number, default: 0 },
sessionPricePerKWh: { type: Number, default: 0 },
sessionPrice: { type: Number, default: 0 },
currency: String,
currency: String as PropType<CURRENCY>,
sessionSolarPercentage: { type: Number, default: 0 },
chargeRemainingDurationInterpolated: Number,
chargeRemainingDurationInterpolated: { type: Number, default: 0 },
chargeDurationInterpolated: Number,
tariffCo2: Number,
tariffGrid: Number,
@ -101,7 +103,7 @@ export default {
optionKeys() {
return this.options.map((option) => option.key);
},
selectOptions() {
selectOptions(): SelectOption<string>[] {
return this.optionKeys.map((key) => ({
name: this.$t(`main.loadpoint.${key}`),
value: key,
@ -126,11 +128,11 @@ export default {
},
finishTime() {
const remainingSeconds = this.chargeRemainingDurationInterpolated;
const now = new Date();
if (remainingSeconds > 0) {
const now = new Date();
return new Date(now.getTime() + remainingSeconds * 1000);
}
return undefined;
return now;
},
solarFormatted() {
return this.fmtPercentage(this.sessionSolarPercentage, 1);
@ -142,10 +144,10 @@ export default {
},
},
methods: {
fmtAvgPrice(value) {
fmtAvgPrice(value: number) {
return this.fmtPricePerKWh(value, this.currency, false);
},
fmtAvgPriceShort(value) {
fmtAvgPriceShort(value: number) {
return this.fmtPricePerKWh(value, this.currency, true);
},
nextSessionInfo() {
@ -153,7 +155,7 @@ export default {
this.selectedKey = this.optionKeys[index + 1] || this.optionKeys[0];
this.presist();
},
selectOption(value) {
selectOption(value: string) {
this.selectedKey = value;
this.presist();
},
@ -161,7 +163,7 @@ export default {
setSessionInfo(this.id, this.selectedKey);
},
},
};
});
</script>
<style scoped>

View file

@ -37,14 +37,16 @@
</div>
</template>
<script>
<script lang="ts">
import { defineComponent, type PropType } from "vue";
import formatter from "@/mixins/formatter";
import { CHARGE_MODE } from "@/types/evcc";
export default {
export default defineComponent({
mixins: [formatter],
props: {
formId: Function,
mode: String,
formId: { type: Function as PropType<(s: string) => string>, default: (s: string) => s },
mode: String as PropType<CHARGE_MODE>,
batteryBoost: Boolean,
},
emits: ["batteryboost-updated"],
@ -55,7 +57,7 @@ export default {
},
computed: {
disabled() {
return ["off", "now"].includes(this.mode);
return this.mode && [CHARGE_MODE.OFF, CHARGE_MODE.NOW].includes(this.mode);
},
},
watch: {
@ -68,5 +70,5 @@ export default {
this.$emit("batteryboost-updated", this.selectedEnabled);
},
},
};
});
</script>

View file

@ -8,13 +8,14 @@
</button>
</template>
<script>
<script lang="ts">
import "@h2d2/shopicons/es/regular/adjust";
import { defineComponent } from "vue";
export default {
export default defineComponent({
name: "LoadpointSettingsButton",
props: {
id: [String, Number],
},
};
});
</script>

View file

@ -29,6 +29,7 @@
v-if="isModalVisible"
v-bind="smartCostLimitProps"
:possible="smartCostAvailable"
:smartCostLimit="smartCostLimit"
class="mt-2"
/>
<LoadpointSettingsBatteryBoost
@ -155,23 +156,21 @@
</Teleport>
</template>
<script>
<script lang="ts">
import collector from "@/mixins/collector";
import formatter from "@/mixins/formatter";
import SmartCostLimit from "../Tariff/SmartCostLimit.vue";
import smartCostAvailable from "@/utils/smartCostAvailable";
import SettingsBatteryBoost from "./SettingsBatteryBoost.vue";
import { defineComponent, type PropType } from "vue";
import { PHASES } from "@/types/evcc";
const V = 230;
const PHASES_AUTO = 0;
const PHASES_1 = 1;
const PHASES_3 = 3;
const range = (start, stop, step = -1) =>
const range = (start: number, stop: number, step = -1) =>
Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step);
const insertSorted = (arr, num) => {
const insertSorted = (arr: number[], num: number) => {
const uniqueSet = new Set(arr);
uniqueSet.add(num);
return [...uniqueSet].sort((a, b) => b - a);
@ -180,23 +179,25 @@ const insertSorted = (arr, num) => {
// TODO: add max physical current to loadpoint (config ui) and only allow user to select values in side that range (main ui, here)
const MAX_CURRENT = 64;
export default {
const { AUTO, THREE_PHASES, ONE_PHASE } = PHASES;
export default defineComponent({
name: "LoadpointSettingsModal",
components: { SmartCostLimit, LoadpointSettingsBatteryBoost: SettingsBatteryBoost },
mixins: [formatter, collector],
props: {
id: [String, Number],
phasesConfigured: Number,
phasesConfigured: { type: Number, default: 0 },
chargerPhases1p3p: Boolean,
chargerSinglePhase: Boolean,
batteryBoost: Boolean,
batteryBoostAvailable: Boolean,
mode: String,
minSoc: Number,
maxCurrent: Number,
minCurrent: Number,
maxCurrent: { type: Number, default: 0 },
minCurrent: { type: Number, default: 0 },
title: String,
smartCostLimit: Number,
smartCostLimit: { type: Number as PropType<number | null>, default: null },
smartCostType: String,
tariffGrid: Number,
currency: String,
@ -223,20 +224,20 @@ export default {
}
if (this.chargerPhases1p3p) {
// automatic switching
return [PHASES_AUTO, PHASES_3, PHASES_1];
return [AUTO, THREE_PHASES, ONE_PHASE];
}
// 1p or 3p possible
return [PHASES_3, PHASES_1];
return [THREE_PHASES, ONE_PHASE];
},
batteryBoostProps() {
return this.collectProps(SettingsBatteryBoost);
},
maxPower() {
if (this.chargerPhases1p3p) {
if (this.phasesConfigured === PHASES_AUTO) {
return this.maxPowerPhases(3);
if (this.phasesConfigured === AUTO) {
return this.maxPowerPhases(THREE_PHASES);
}
if ([PHASES_3, PHASES_1].includes(this.phasesConfigured)) {
if ([THREE_PHASES, ONE_PHASE].includes(this.phasesConfigured)) {
return this.maxPowerPhases(this.phasesConfigured);
}
}
@ -244,10 +245,10 @@ export default {
},
minPower() {
if (this.chargerPhases1p3p) {
if (this.phasesConfigured === PHASES_AUTO) {
return this.minPowerPhases(1);
if (this.phasesConfigured === AUTO) {
return this.minPowerPhases(ONE_PHASE);
}
if ([PHASES_3, PHASES_1].includes(this.phasesConfigured)) {
if ([THREE_PHASES, ONE_PHASE].includes(this.phasesConfigured)) {
return this.minPowerPhases(this.phasesConfigured);
}
}
@ -285,26 +286,23 @@ export default {
phasesConfigured(value) {
this.selectedPhases = value;
},
minSoc(value) {
this.selectedMinSoc = value;
},
},
mounted() {
this.$refs.modal.addEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal.addEventListener("hidden.bs.modal", this.modalInvisible);
this.$refs["modal"]?.addEventListener("show.bs.modal", this.modalVisible);
this.$refs["modal"]?.addEventListener("hidden.bs.modal", this.modalInvisible);
},
unmounted() {
this.$refs.modal?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs.modal?.removeEventListener("hidden.bs.modal", this.modalInvisible);
this.$refs["modal"]?.removeEventListener("show.bs.modal", this.modalVisible);
this.$refs["modal"]?.removeEventListener("hidden.bs.modal", this.modalInvisible);
},
methods: {
maxPowerPhases(phases) {
maxPowerPhases(phases: PHASES) {
return this.fmtW(this.maxCurrent * V * phases);
},
minPowerPhases(phases) {
minPowerPhases(phases: PHASES) {
return this.fmtW(this.minCurrent * V * phases);
},
formId(name) {
formId(name: string) {
return `loadpoint_${this.id}_${name}`;
},
changeMaxCurrent() {
@ -316,8 +314,8 @@ export default {
changePhasesConfigured() {
this.$emit("phasesconfigured-updated", this.selectedPhases);
},
currentOption(value, isDefault) {
let name = `${this.fmtNumber(value)} A`;
currentOption(value: number, isDefault: boolean) {
let name = `${this.fmtNumber(value, 0)} A`;
if (isDefault) {
name += ` (${this.$t("main.loadpointSettings.default")})`;
}
@ -329,11 +327,11 @@ export default {
modalInvisible() {
this.isModalVisible = false;
},
changeBatteryBoost(boost) {
changeBatteryBoost(boost: boolean) {
this.$emit("batteryboost-updated", boost);
},
},
};
});
</script>
<style scoped>
.container {

View file

@ -1,6 +1,6 @@
import settings from "@/settings";
export function getSessionInfo(index: number, fallback: string) {
export function getSessionInfo(index: number, fallback?: string) {
return settings.sessionInfo[index - 1] || fallback;
}

View file

@ -29,12 +29,37 @@ export interface LoadpointCompact {
heating?: boolean;
}
export interface LoadpointCompact {
icon: string;
title: string;
charging: boolean;
soc?: number;
power: number;
heating?: boolean;
chargePower: number;
connected: boolean;
}
export enum CURRENCY {
EUR = "EUR",
USD = "USD",
DKK = "DKK",
}
export enum CHARGE_MODE {
OFF = "off",
NOW = "now",
MINPV = "minpv",
PV = "pv",
}
export enum PHASES {
AUTO = 0,
ONE_PHASE = 1,
TWO_PHASES = 2,
THREE_PHASES = 3,
}
export interface Sponsor {
name: string;
expiresAt: Date;
@ -55,6 +80,8 @@ export interface Vehicle {
plan?: StaticPlan;
repeatingPlans: RepeatingPlan[];
title: string;
features?: string[];
capacity?: number;
}
export type Timeout = ReturnType<typeof setInterval> | null;