chore: Typescript: Rewrite views (#21714)
Co-authored-by: Michael Geers <michael@geers.tv>
This commit is contained in:
parent
f593848c09
commit
4c73cbe197
16 changed files with 459 additions and 241 deletions
|
|
@ -90,7 +90,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from "vue";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import FormRow from "./FormRow.vue";
|
||||
import PropertyEntry from "./PropertyEntry.vue";
|
||||
import PropertyCollapsible from "./PropertyCollapsible.vue";
|
||||
|
|
@ -102,9 +102,9 @@ import SponsorTokenRequired from "./DeviceModal/SponsorTokenRequired.vue";
|
|||
import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSelector.vue";
|
||||
import YamlEntry from "./DeviceModal/YamlEntry.vue";
|
||||
import { initialTestState, performTest } from "./utils/test";
|
||||
import { ConfigType } from "@/types/evcc";
|
||||
import {
|
||||
handleError,
|
||||
ConfigType,
|
||||
type DeviceValues,
|
||||
type Template,
|
||||
type Product,
|
||||
|
|
@ -121,7 +121,7 @@ import switchsocketHeaterYaml from "./defaultYaml/switchsocketHeater.yaml?raw";
|
|||
import switchsocketChargerYaml from "./defaultYaml/switchsocketCharger.yaml?raw";
|
||||
import sgreadyYaml from "./defaultYaml/sgready.yaml?raw";
|
||||
import sgreadyBoostYaml from "./defaultYaml/sgreadyBoost.yaml?raw";
|
||||
import { LOADPOINT_TYPE } from "@/types/evcc";
|
||||
import { LOADPOINT_TYPE, type LoadpointType } from "@/types/evcc";
|
||||
|
||||
const initialValues = { type: ConfigType.Template };
|
||||
const device = createDeviceUtils("charger");
|
||||
|
|
@ -165,7 +165,7 @@ export default defineComponent({
|
|||
props: {
|
||||
id: Number,
|
||||
name: String,
|
||||
loadpointType: { type: String as () => LOADPOINT_TYPE | null, default: null },
|
||||
loadpointType: { type: String as PropType<LoadpointType>, default: null },
|
||||
fade: String,
|
||||
isSponsor: Boolean,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@
|
|||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import FormRow from "../FormRow.vue";
|
||||
import type { DeviceType } from "@/types/evcc";
|
||||
import { type DeviceType } from "@/types/evcc";
|
||||
|
||||
export interface TemplateOption {
|
||||
name: string;
|
||||
|
|
|
|||
|
|
@ -14,8 +14,7 @@
|
|||
import { defineComponent } from "vue";
|
||||
import YamlEditorContainer from "../YamlEditorContainer.vue";
|
||||
import { docsPrefix } from "@/i18n";
|
||||
|
||||
type DeviceType = "vehicle" | "charger" | "meter";
|
||||
import { type DeviceType } from "@/types/evcc";
|
||||
|
||||
export default defineComponent({
|
||||
name: "YamlEntry",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { DeviceType } from "@/types/evcc";
|
||||
import { ConfigType } from "@/types/evcc";
|
||||
import api from "@/api";
|
||||
|
||||
export type Product = {
|
||||
|
|
@ -54,15 +55,6 @@ export function handleError(e: any, msg: string) {
|
|||
|
||||
export const timeout = 15000;
|
||||
|
||||
export enum ConfigType {
|
||||
Template = "template",
|
||||
Custom = "custom",
|
||||
Heatpump = "heatpump",
|
||||
SwitchSocket = "switchsocket",
|
||||
SgReady = "sgready",
|
||||
SgReadyBoost = "sgready-boost",
|
||||
}
|
||||
|
||||
export function applyDefaultsFromTemplate(template: Template | null, values: DeviceValues) {
|
||||
const params = template?.Params || [];
|
||||
params
|
||||
|
|
|
|||
|
|
@ -557,6 +557,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import type { PropType } from "vue";
|
||||
import FormRow from "./FormRow.vue";
|
||||
import PropertyField from "./PropertyField.vue";
|
||||
import SelectGroup from "../Helper/SelectGroup.vue";
|
||||
|
|
@ -567,8 +568,16 @@ import deepEqual from "@/utils/deepEqual";
|
|||
import formatter, { POWER_UNIT } from "@/mixins/formatter";
|
||||
import EditIcon from "../MaterialIcon/Edit.vue";
|
||||
import NewDeviceButton from "./NewDeviceButton.vue";
|
||||
import { type ConfigType, handleError, customChargerName } from "./DeviceModal";
|
||||
import { LOADPOINT_TYPE } from "@/types/evcc";
|
||||
import { handleError, customChargerName } from "./DeviceModal";
|
||||
import {
|
||||
LOADPOINT_TYPE,
|
||||
type DeviceType,
|
||||
type LoadpointType,
|
||||
type ConfigCharger,
|
||||
type ConfigMeter,
|
||||
type ConfigCircuit,
|
||||
type ConfigLoadpoint,
|
||||
} from "@/types/evcc";
|
||||
|
||||
const nsPerMin = 60 * 1e9;
|
||||
|
||||
|
|
@ -592,33 +601,13 @@ const defaultValues = {
|
|||
charger: "",
|
||||
circuit: "",
|
||||
meter: "",
|
||||
};
|
||||
} as ConfigLoadpoint;
|
||||
|
||||
const defaultThresholds = {
|
||||
enable: { delay: 1 * nsPerMin, threshold: 0 },
|
||||
disable: { delay: 3 * nsPerMin, threshold: 0 },
|
||||
};
|
||||
|
||||
type LoadpointValues = typeof defaultValues;
|
||||
|
||||
type Charger = {
|
||||
name: string;
|
||||
deviceProduct: string;
|
||||
type: ConfigType;
|
||||
config: { template: string };
|
||||
};
|
||||
|
||||
type Meter = {
|
||||
name: string;
|
||||
deviceProduct: string;
|
||||
config: { template: string };
|
||||
};
|
||||
|
||||
type Circuit = {
|
||||
name: string;
|
||||
config: { title: string };
|
||||
};
|
||||
|
||||
export default {
|
||||
name: "LoadpointModal",
|
||||
components: { FormRow, PropertyField, GenericModal, SelectGroup, EditIcon, NewDeviceButton },
|
||||
|
|
@ -629,19 +618,22 @@ export default {
|
|||
vehicleOptions: { type: Array, default: () => [] },
|
||||
loadpointCount: { type: Number, default: 0 },
|
||||
fade: String,
|
||||
chargers: { type: Array as () => Charger[], default: () => [] },
|
||||
chargers: { type: Array as PropType<ConfigCharger[]>, default: () => [] },
|
||||
chargerValues: { type: Object, default: () => {} },
|
||||
meters: { type: Array as () => Meter[], default: () => [] },
|
||||
circuits: { type: Array as () => Circuit[], default: () => [] },
|
||||
hasDeviceError: { type: Function, default: () => false },
|
||||
meters: { type: Array as PropType<ConfigMeter[]>, default: () => [] },
|
||||
circuits: { type: Array as PropType<ConfigCircuit[]>, default: () => [] },
|
||||
hasDeviceError: {
|
||||
type: Function as PropType<(type: DeviceType, name: string) => boolean>,
|
||||
default: () => false,
|
||||
},
|
||||
},
|
||||
emits: ["updated", "openMeterModal", "openChargerModal", "opened"],
|
||||
data() {
|
||||
return {
|
||||
isModalVisible: false,
|
||||
saving: false,
|
||||
selectedType: null as LOADPOINT_TYPE | null,
|
||||
values: deepClone(defaultValues) as LoadpointValues,
|
||||
selectedType: null as LoadpointType | null,
|
||||
values: deepClone(defaultValues) as ConfigLoadpoint,
|
||||
chargerPower: "11kw",
|
||||
solarMode: "default",
|
||||
tab: "solar",
|
||||
|
|
@ -742,10 +734,10 @@ export default {
|
|||
...this.vehicleOptions,
|
||||
];
|
||||
},
|
||||
typeChoices(): LOADPOINT_TYPE[] {
|
||||
typeChoices(): LoadpointType[] {
|
||||
return Object.values(LOADPOINT_TYPE);
|
||||
},
|
||||
loadpointType(): LOADPOINT_TYPE | null {
|
||||
loadpointType(): LoadpointType | null {
|
||||
return this.selectedType ?? this.chargerType;
|
||||
},
|
||||
addChargerLabel() {
|
||||
|
|
@ -894,7 +886,7 @@ export default {
|
|||
return;
|
||||
}
|
||||
},
|
||||
selectType(type: LOADPOINT_TYPE) {
|
||||
selectType(type: LoadpointType) {
|
||||
this.selectedType = type;
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -137,9 +137,9 @@ import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSe
|
|||
import YamlEntry from "./DeviceModal/YamlEntry.vue";
|
||||
import { ICONS } from "../VehicleIcon/VehicleIcon.vue";
|
||||
import { initialTestState, performTest } from "./utils/test";
|
||||
import { ConfigType, type SelectedMeterType } from "@/types/evcc";
|
||||
import {
|
||||
handleError,
|
||||
ConfigType,
|
||||
type DeviceValues,
|
||||
type Template,
|
||||
type Product,
|
||||
|
|
@ -197,7 +197,7 @@ export default defineComponent({
|
|||
id: Number,
|
||||
name: String,
|
||||
type: {
|
||||
type: String as () => "grid" | "pv" | "battery" | undefined,
|
||||
type: String as () => SelectedMeterType | undefined,
|
||||
default: undefined,
|
||||
},
|
||||
typeChoices: {
|
||||
|
|
|
|||
|
|
@ -185,9 +185,9 @@ import TemplateSelector, { customTemplateOption } from "./DeviceModal/TemplateSe
|
|||
import DeviceModalActions from "./DeviceModal/Actions.vue";
|
||||
import YamlEntry from "./DeviceModal/YamlEntry.vue";
|
||||
import { initialTestState, performTest } from "./utils/test";
|
||||
import { ConfigType } from "@/types/evcc";
|
||||
import {
|
||||
handleError,
|
||||
ConfigType,
|
||||
type DeviceValues,
|
||||
type Template,
|
||||
type Product,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,8 @@ Fatal.args = {
|
|||
error: `cannot create charger 'wallbox_cphl': cannot create charger type 'template': cannot create
|
||||
charger type 'hardybarth-ecbl: Post http://192.168.2.219/api/v1/chargecontrols/1/mode: context deadline exceeded (Client.Timeout
|
||||
exceeded while awaiting headers)`,
|
||||
class: "",
|
||||
device: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -136,7 +136,7 @@ export default defineComponent({
|
|||
|
||||
auth: { type: Object as PropType<Auth>, default: () => ({ vehicles: {} }) },
|
||||
|
||||
currency: { type: String as PropType<CURRENCY>, required: true },
|
||||
currency: { type: String as PropType<CURRENCY> },
|
||||
statistics: Object,
|
||||
tariffFeedIn: Number,
|
||||
tariffGrid: Number,
|
||||
|
|
|
|||
|
|
@ -26,9 +26,8 @@ function setProperty(obj: object, props: string[], value: any) {
|
|||
|
||||
const initialState: State = {
|
||||
offline: false,
|
||||
// ensure array type
|
||||
loadpoints: [],
|
||||
vehicles: [],
|
||||
vehicles: {},
|
||||
};
|
||||
|
||||
const state = reactive(initialState);
|
||||
|
|
|
|||
|
|
@ -26,6 +26,21 @@ export interface Auth {
|
|||
|
||||
export type VehicleLogins = Record<string, { authenticated: boolean; uri: string }>;
|
||||
|
||||
export interface MqttConfig {
|
||||
broker: string;
|
||||
topic: string;
|
||||
}
|
||||
|
||||
export interface InfluxConfig {
|
||||
url: string;
|
||||
database: any;
|
||||
org: any;
|
||||
}
|
||||
|
||||
export interface HemsConfig {
|
||||
type: any;
|
||||
}
|
||||
|
||||
export interface FatalError {
|
||||
error: string;
|
||||
class?: string;
|
||||
|
|
@ -40,7 +55,109 @@ export interface State {
|
|||
currency?: CURRENCY;
|
||||
fatal?: FatalError[];
|
||||
auth?: Auth;
|
||||
vehicles: Vehicle[];
|
||||
version?: string;
|
||||
battery?: Battery[];
|
||||
tariffGrid?: number;
|
||||
tariffFeedIn?: number;
|
||||
tariffCo2?: number;
|
||||
tariffSolar?: number;
|
||||
mqtt?: MqttConfig;
|
||||
influx?: InfluxConfig;
|
||||
hems?: HemsConfig;
|
||||
sponsor?: Sponsor;
|
||||
eebus?: any;
|
||||
modbusproxy?: [];
|
||||
messaging?: any;
|
||||
interval?: number;
|
||||
circuits?: Record<string, Circuit>;
|
||||
siteTitle?: string;
|
||||
vehicles: Record<string, Vehicle>;
|
||||
authDisabled?: boolean;
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
template?: string;
|
||||
title?: string;
|
||||
icon?: string;
|
||||
[key: string]: number | string | undefined;
|
||||
}
|
||||
|
||||
export interface Circuit {
|
||||
name: string;
|
||||
maxPower: number;
|
||||
power?: number;
|
||||
maxCurrent: number;
|
||||
current?: number;
|
||||
config?: Config;
|
||||
}
|
||||
|
||||
export interface Entity {
|
||||
name: string;
|
||||
type: string;
|
||||
id: number;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
export enum ConfigType {
|
||||
Template = "template",
|
||||
Custom = "custom",
|
||||
Heatpump = "heatpump",
|
||||
SwitchSocket = "switchsocket",
|
||||
SgReady = "sgready",
|
||||
SgReadyBoost = "sgready-boost",
|
||||
}
|
||||
|
||||
export type ConfigVehicle = Entity;
|
||||
|
||||
// Configuration-specific types for device setup/configuration contexts
|
||||
export interface ConfigCharger extends Omit<Entity, "type"> {
|
||||
deviceProduct: string;
|
||||
type: ConfigType;
|
||||
}
|
||||
|
||||
export interface ConfigMeter extends Entity {
|
||||
deviceProduct: string;
|
||||
deviceTitle?: string;
|
||||
deviceIcon?: string;
|
||||
}
|
||||
|
||||
export type ConfigCircuit = Entity;
|
||||
|
||||
export interface LoadpointThreshold {
|
||||
delay: number;
|
||||
threshold: number;
|
||||
}
|
||||
|
||||
export interface ConfigLoadpoint {
|
||||
id?: number;
|
||||
name?: string;
|
||||
charger: string;
|
||||
meter: string;
|
||||
vehicle: string;
|
||||
title: string;
|
||||
defaultMode: string;
|
||||
priority: number;
|
||||
phasesConfigured: number;
|
||||
minCurrent: number;
|
||||
maxCurrent: number;
|
||||
smartCostLimit: number | null;
|
||||
planEnergy?: number;
|
||||
planTime?: string;
|
||||
planPrecondition?: number;
|
||||
limitEnergy?: number;
|
||||
limitSoc?: number;
|
||||
circuit?: string;
|
||||
thresholds: {
|
||||
enable: LoadpointThreshold;
|
||||
disable: LoadpointThreshold;
|
||||
};
|
||||
soc: {
|
||||
poll: {
|
||||
mode: string;
|
||||
interval: number;
|
||||
};
|
||||
estimate: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export enum SMART_COST_TYPE {
|
||||
|
|
@ -120,6 +237,8 @@ export enum LOADPOINT_TYPE {
|
|||
HEATING = "heating",
|
||||
}
|
||||
|
||||
export type LoadpointType = ValueOf<typeof LOADPOINT_TYPE>;
|
||||
|
||||
export type SessionInfoKey =
|
||||
| "remaining"
|
||||
| "finished"
|
||||
|
|
@ -203,10 +322,20 @@ export interface SelectOption<T> {
|
|||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export type DeviceType = "charger" | "meter" | "vehicle";
|
||||
export type DeviceType = "charger" | "meter" | "vehicle" | "loadpoint";
|
||||
export type SelectedMeterType = "grid" | "pv" | "battery" | "charge" | "aux" | "ext";
|
||||
|
||||
// see https://stackoverflow.com/a/54178819
|
||||
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
|
||||
export type PartialBy<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
|
||||
|
||||
export interface SiteConfig {
|
||||
grid: string;
|
||||
pv: string[];
|
||||
battery: string[];
|
||||
title: string;
|
||||
aux: string[] | null;
|
||||
ext: string[] | null;
|
||||
}
|
||||
|
||||
export type ValueOf<T> = T[keyof T];
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import store from "../store";
|
||||
import GlobalSettingsModal from "../components/GlobalSettings/GlobalSettingsModal.vue";
|
||||
import BatterySettingsModal from "../components/Battery/BatterySettingsModal.vue";
|
||||
|
|
@ -22,18 +22,19 @@ import PasswordModal from "../components/Auth/PasswordModal.vue";
|
|||
import LoginModal from "../components/Auth/LoginModal.vue";
|
||||
import HelpModal from "../components/HelpModal.vue";
|
||||
import collector from "../mixins/collector";
|
||||
import { defineComponent } from "vue";
|
||||
|
||||
// assume offline if not data received for 5 minutes
|
||||
let lastDataReceived = new Date();
|
||||
const maxDataAge = 60 * 1000 * 5;
|
||||
setInterval(() => {
|
||||
if (new Date() - lastDataReceived > maxDataAge) {
|
||||
if (new Date().getTime() - lastDataReceived.getTime() > maxDataAge) {
|
||||
console.log("no data received, assume we are offline");
|
||||
window.app.setOffline();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "App",
|
||||
components: {
|
||||
GlobalSettingsModal,
|
||||
|
|
@ -50,7 +51,11 @@ export default {
|
|||
offline: Boolean,
|
||||
},
|
||||
data: () => {
|
||||
return { reconnectTimeout: null, ws: null, authNotConfigured: false };
|
||||
return {
|
||||
reconnectTimeout: null as number | null,
|
||||
ws: null as WebSocket | null,
|
||||
authNotConfigured: false,
|
||||
};
|
||||
},
|
||||
head() {
|
||||
return { title: "...", titleTemplate: "%s | evcc" };
|
||||
|
|
@ -98,20 +103,25 @@ export default {
|
|||
},
|
||||
unmounted() {
|
||||
this.disconnect();
|
||||
window.clearTimeout(this.reconnectTimeout);
|
||||
this.clearReconnectTimeout();
|
||||
document.removeEventListener("visibilitychange", this.pageVisibilityChanged, false);
|
||||
},
|
||||
methods: {
|
||||
clearReconnectTimeout() {
|
||||
if (this.reconnectTimeout) {
|
||||
window.clearTimeout(this.reconnectTimeout);
|
||||
}
|
||||
},
|
||||
pageVisibilityChanged() {
|
||||
if (document.hidden) {
|
||||
window.clearTimeout(this.reconnectTimeout);
|
||||
this.clearReconnectTimeout();
|
||||
this.disconnect();
|
||||
} else {
|
||||
this.connect();
|
||||
}
|
||||
},
|
||||
reconnect() {
|
||||
window.clearTimeout(this.reconnectTimeout);
|
||||
this.clearReconnectTimeout();
|
||||
this.reconnectTimeout = window.setTimeout(() => {
|
||||
this.disconnect();
|
||||
this.connect();
|
||||
|
|
@ -155,7 +165,7 @@ export default {
|
|||
this.ws = new WebSocket(uri);
|
||||
this.ws.onerror = () => {
|
||||
console.log({ message: "Websocket error. Trying to reconnect." });
|
||||
this.ws.close();
|
||||
this.ws?.close();
|
||||
};
|
||||
this.ws.onopen = () => {
|
||||
console.log("websocket connected");
|
||||
|
|
@ -174,8 +184,9 @@ export default {
|
|||
store.update(msg);
|
||||
lastDataReceived = new Date();
|
||||
} catch (error) {
|
||||
const e = error as Error;
|
||||
window.app.raise({
|
||||
message: `Failed to parse web socket data: ${error.message} [${evt.data}]`,
|
||||
message: `Failed to parse web socket data: ${e.message} [${evt.data}]`,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -184,7 +195,7 @@ export default {
|
|||
window.location.reload();
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.app {
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@
|
|||
:editable="!!gridMeter.id"
|
||||
:error="hasDeviceError('meter', gridMeter.name)"
|
||||
data-testid="grid"
|
||||
@edit="editMeter(gridMeter.id, 'grid')"
|
||||
@edit="editMeter('grid', gridMeter.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-powersupply></shopicon-regular-powersupply>
|
||||
|
|
@ -135,7 +135,7 @@
|
|||
:editable="!!meter.id"
|
||||
:error="hasDeviceError('meter', meter.name)"
|
||||
data-testid="pv"
|
||||
@edit="editMeter(meter.id, 'pv')"
|
||||
@edit="editMeter('pv', meter.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-sun></shopicon-regular-sun>
|
||||
|
|
@ -156,7 +156,7 @@
|
|||
:editable="!!meter.id"
|
||||
:error="hasDeviceError('meter', meter.name)"
|
||||
data-testid="battery"
|
||||
@edit="editMeter(meter.id, 'battery')"
|
||||
@edit="editMeter('battery', meter.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<shopicon-regular-batterythreequarters></shopicon-regular-batterythreequarters>
|
||||
|
|
@ -185,7 +185,7 @@
|
|||
:editable="!!meter.id"
|
||||
:error="hasDeviceError('meter', meter.name)"
|
||||
data-testid="aux"
|
||||
@edit="editMeter(meter.id, 'aux')"
|
||||
@edit="editMeter('aux', meter.id)"
|
||||
>
|
||||
<template #icon>
|
||||
<VehicleIcon :name="meter.deviceIcon || 'smartconsumer'" />
|
||||
|
|
@ -330,7 +330,7 @@
|
|||
:vehicleOptions="vehicleOptions"
|
||||
:loadpointCount="loadpoints.length"
|
||||
:chargers="chargers"
|
||||
:chargerValues="deviceValues.charger"
|
||||
:chargerValues="deviceValues['charger']"
|
||||
:meters="meters"
|
||||
:circuits="circuits"
|
||||
:fade="loadpointSubModalOpen ? 'left' : ''"
|
||||
|
|
@ -381,7 +381,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import "@h2d2/shopicons/es/regular/sun";
|
||||
import "@h2d2/shopicons/es/regular/batterythreequarters";
|
||||
import "@h2d2/shopicons/es/regular/powersupply";
|
||||
|
|
@ -421,12 +421,27 @@ import TariffsModal from "../components/Config/TariffsModal.vue";
|
|||
import Header from "../components/Top/Header.vue";
|
||||
import VehicleIcon from "../components/VehicleIcon";
|
||||
import VehicleModal from "../components/Config/VehicleModal.vue";
|
||||
import { defineComponent } from "vue";
|
||||
import type {
|
||||
ConfigCharger,
|
||||
ConfigVehicle,
|
||||
ConfigCircuit,
|
||||
ConfigLoadpoint,
|
||||
ConfigMeter,
|
||||
LoadpointType,
|
||||
Timeout,
|
||||
SelectedMeterType,
|
||||
SiteConfig,
|
||||
DeviceType,
|
||||
} from "@/types/evcc";
|
||||
|
||||
type DeviceValuesMap = Record<DeviceType, Record<string, any>>;
|
||||
import BackupRestoreModal from "@/components/Config/BackupRestoreModal.vue";
|
||||
import WelcomeBanner from "../components/Config/WelcomeBanner.vue";
|
||||
import ExperimentalBanner from "../components/Config/ExperimentalBanner.vue";
|
||||
import PasswordModal from "../components/Auth/PasswordModal.vue";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Config",
|
||||
components: {
|
||||
NewDeviceButton,
|
||||
|
|
@ -470,22 +485,34 @@ export default {
|
|||
},
|
||||
data() {
|
||||
return {
|
||||
vehicles: [],
|
||||
meters: [],
|
||||
loadpoints: [],
|
||||
chargers: [],
|
||||
circuits: [],
|
||||
selectedVehicleId: undefined,
|
||||
selectedMeterId: undefined,
|
||||
selectedMeterType: undefined,
|
||||
selectedMeterTypeChoices: [],
|
||||
selectedChargerId: undefined,
|
||||
selectedLoadpointId: undefined,
|
||||
selectedLoadpointType: undefined,
|
||||
vehicles: [] as ConfigVehicle[],
|
||||
meters: [] as ConfigMeter[],
|
||||
loadpoints: [] as ConfigLoadpoint[],
|
||||
chargers: [] as ConfigCharger[],
|
||||
circuits: [] as ConfigCircuit[],
|
||||
selectedVehicleId: undefined as number | undefined,
|
||||
selectedMeterId: undefined as number | undefined,
|
||||
selectedMeterType: undefined as SelectedMeterType | undefined,
|
||||
selectedMeterTypeChoices: [] as string[],
|
||||
selectedChargerId: undefined as number | undefined,
|
||||
selectedLoadpointId: undefined as number | undefined,
|
||||
selectedLoadpointType: undefined as LoadpointType | undefined,
|
||||
loadpointSubModalOpen: false,
|
||||
site: { grid: "", pv: [], battery: [], title: "" },
|
||||
deviceValueTimeout: undefined,
|
||||
deviceValues: {},
|
||||
site: {
|
||||
grid: "",
|
||||
pv: [] as string[],
|
||||
battery: [] as string[],
|
||||
title: "",
|
||||
aux: null as string[] | null,
|
||||
ext: null as string[] | null,
|
||||
} as SiteConfig,
|
||||
deviceValueTimeout: null as Timeout,
|
||||
deviceValues: {
|
||||
meter: {},
|
||||
vehicle: {},
|
||||
charger: {},
|
||||
loadpoint: {},
|
||||
} as DeviceValuesMap,
|
||||
isComponentMounted: true,
|
||||
isPageVisible: true,
|
||||
};
|
||||
|
|
@ -536,7 +563,13 @@ export default {
|
|||
) {
|
||||
return null;
|
||||
}
|
||||
const tags = {};
|
||||
const tags = {
|
||||
currency: {},
|
||||
gridPrice: {},
|
||||
feedinPrice: {},
|
||||
co2: {},
|
||||
solarForecast: {},
|
||||
};
|
||||
if (currency) {
|
||||
tags.currency = { value: currency };
|
||||
}
|
||||
|
|
@ -565,7 +598,7 @@ export default {
|
|||
influxTags() {
|
||||
const { url, database, org } = store.state?.influx || {};
|
||||
if (!url) return { configured: { value: false } };
|
||||
const result = { url: { value: url } };
|
||||
const result = { url: { value: url }, bucket: {}, org: {} };
|
||||
if (database) result.bucket = { value: database };
|
||||
if (org) result.org = { value: org };
|
||||
return result;
|
||||
|
|
@ -574,7 +607,7 @@ export default {
|
|||
return this.vehicles.map((v) => ({ key: v.name, name: v.config?.title || v.name }));
|
||||
},
|
||||
hemsTags() {
|
||||
const result = { configured: { value: false } };
|
||||
const result = { configured: { value: false }, hemsType: {} };
|
||||
const { type } = store.state?.hems || {};
|
||||
if (type) {
|
||||
result.configured.value = true;
|
||||
|
|
@ -621,14 +654,16 @@ export default {
|
|||
unmounted() {
|
||||
this.isComponentMounted = false;
|
||||
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
||||
clearTimeout(this.deviceValueTimeout);
|
||||
if (this.deviceValueTimeout) {
|
||||
clearTimeout(this.deviceValueTimeout);
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
handleVisibilityChange() {
|
||||
this.isPageVisible = document.visibilityState === "visible";
|
||||
if (this.isPageVisible) {
|
||||
this.updateValues();
|
||||
} else {
|
||||
} else if (this.deviceValueTimeout) {
|
||||
clearTimeout(this.deviceValueTimeout);
|
||||
}
|
||||
},
|
||||
|
|
@ -666,7 +701,7 @@ export default {
|
|||
},
|
||||
async loadSite() {
|
||||
const response = await api.get("/config/site", {
|
||||
validateStatus: (status) => status < 500,
|
||||
validateStatus: (status: number) => status < 500,
|
||||
});
|
||||
if (response.status === 200) {
|
||||
this.site = response.data;
|
||||
|
|
@ -676,37 +711,43 @@ export default {
|
|||
const response = await api.get("/config/loadpoints");
|
||||
this.loadpoints = response.data || [];
|
||||
},
|
||||
getMetersByNames(names) {
|
||||
getMetersByNames(names: string[] | null) {
|
||||
if (!names || !this.meters) {
|
||||
return [];
|
||||
}
|
||||
return this.meters.filter((m) => names.includes(m.name));
|
||||
},
|
||||
getMeterById(id) {
|
||||
getMeterById(id?: number) {
|
||||
if (!id || !this.meters) {
|
||||
return undefined;
|
||||
}
|
||||
return this.meters.find((m) => m.id === id);
|
||||
},
|
||||
getChargerById(id) {
|
||||
getChargerById(id?: number) {
|
||||
if (!id || !this.chargers) {
|
||||
return undefined;
|
||||
}
|
||||
return this.chargers.find((c) => c.id === id);
|
||||
},
|
||||
vehicleModal() {
|
||||
return Modal.getOrCreateInstance(document.getElementById("vehicleModal"));
|
||||
return Modal.getOrCreateInstance(
|
||||
document.getElementById("vehicleModal") as HTMLElement
|
||||
);
|
||||
},
|
||||
meterModal() {
|
||||
return Modal.getOrCreateInstance(document.getElementById("meterModal"));
|
||||
return Modal.getOrCreateInstance(document.getElementById("meterModal") as HTMLElement);
|
||||
},
|
||||
loadpointModal() {
|
||||
return Modal.getOrCreateInstance(document.getElementById("loadpointModal"));
|
||||
return Modal.getOrCreateInstance(
|
||||
document.getElementById("loadpointModal") as HTMLElement
|
||||
);
|
||||
},
|
||||
chargerModal() {
|
||||
return Modal.getOrCreateInstance(document.getElementById("chargerModal"));
|
||||
return Modal.getOrCreateInstance(
|
||||
document.getElementById("chargerModal") as HTMLElement
|
||||
);
|
||||
},
|
||||
editLoadpointCharger(name, loadpointType) {
|
||||
editLoadpointCharger(name: string, loadpointType?: LoadpointType) {
|
||||
this.loadpointSubModalOpen = true;
|
||||
const charger = this.chargers.find((c) => c.name === name);
|
||||
if (charger && charger.id === undefined) {
|
||||
|
|
@ -718,7 +759,7 @@ export default {
|
|||
this.loadpointModal().hide();
|
||||
this.$nextTick(() => this.editCharger(charger?.id, loadpointType));
|
||||
},
|
||||
editLoadpointMeter(name) {
|
||||
editLoadpointMeter(name: string) {
|
||||
this.loadpointSubModalOpen = true;
|
||||
const meter = this.meters.find((m) => m.name === name);
|
||||
if (meter && meter.id === undefined) {
|
||||
|
|
@ -726,14 +767,14 @@ export default {
|
|||
return;
|
||||
}
|
||||
this.loadpointModal().hide();
|
||||
this.$nextTick(() => this.editMeter(meter?.id, "charge"));
|
||||
this.$nextTick(() => this.editMeter("charge", meter?.id));
|
||||
},
|
||||
editMeter(id, type) {
|
||||
this.selectedMeterId = id;
|
||||
editMeter(type: SelectedMeterType, id?: number) {
|
||||
this.selectedMeterType = type;
|
||||
this.selectedMeterId = id;
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
newMeter(type) {
|
||||
newMeter(type: SelectedMeterType) {
|
||||
this.selectedMeterId = undefined;
|
||||
this.selectedMeterType = type;
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
|
|
@ -750,7 +791,7 @@ export default {
|
|||
this.selectedMeterTypeChoices = ["aux", "ext"];
|
||||
this.$nextTick(() => this.meterModal().show());
|
||||
},
|
||||
editCharger(id, loadpointType) {
|
||||
editCharger(id?: number, loadpointType?: LoadpointType) {
|
||||
this.selectedChargerId = id;
|
||||
this.selectedLoadpointType = loadpointType;
|
||||
this.$nextTick(() => this.chargerModal().show());
|
||||
|
|
@ -765,13 +806,16 @@ export default {
|
|||
await this.loadDirty();
|
||||
this.updateValues();
|
||||
},
|
||||
editLoadpoint(id) {
|
||||
editLoadpoint(id?: number) {
|
||||
if (!id) alert("missing loadpoint id");
|
||||
this.selectedLoadpointId = id;
|
||||
this.$nextTick(() => this.loadpointModal().show());
|
||||
},
|
||||
newLoadpoint() {
|
||||
this.selectedLoadpointId = undefined;
|
||||
this.$refs.loadpointModal.reset();
|
||||
(
|
||||
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
|
||||
)?.reset();
|
||||
this.$nextTick(() => this.loadpointModal().show());
|
||||
},
|
||||
async loadpointChanged() {
|
||||
|
|
@ -779,7 +823,7 @@ export default {
|
|||
await this.loadLoadpoints();
|
||||
this.loadDirty();
|
||||
},
|
||||
editVehicle(id) {
|
||||
editVehicle(id: number) {
|
||||
this.selectedVehicleId = id;
|
||||
this.$nextTick(() => this.vehicleModal().show());
|
||||
},
|
||||
|
|
@ -799,28 +843,46 @@ export default {
|
|||
yamlChanged() {
|
||||
this.loadDirty();
|
||||
},
|
||||
meterAdded(type, name) {
|
||||
meterAdded(type: SelectedMeterType, name: string) {
|
||||
if (type === "charge") {
|
||||
// update loadpoint
|
||||
this.$refs.loadpointModal?.setMeter(name);
|
||||
(
|
||||
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
|
||||
)?.setMeter(name);
|
||||
} else if (type === "grid") {
|
||||
// update site grid
|
||||
this.site.grid = name;
|
||||
this.saveSite(type);
|
||||
} else {
|
||||
// update site pv, battery, aux
|
||||
if (!this.site[type]) {
|
||||
this.site[type] = [];
|
||||
// update site pv, battery, aux, ext with type-safe approach
|
||||
switch (type) {
|
||||
case "pv":
|
||||
if (!this.site.pv) this.site.pv = [];
|
||||
this.site.pv.push(name);
|
||||
break;
|
||||
case "battery":
|
||||
if (!this.site.battery) this.site.battery = [];
|
||||
this.site.battery.push(name);
|
||||
break;
|
||||
case "aux":
|
||||
if (!this.site.aux) this.site.aux = [];
|
||||
this.site.aux.push(name);
|
||||
break;
|
||||
case "ext":
|
||||
if (!this.site.ext) this.site.ext = [];
|
||||
this.site.ext.push(name);
|
||||
break;
|
||||
}
|
||||
this.site[type].push(name);
|
||||
this.saveSite(type);
|
||||
}
|
||||
this.meterChanged();
|
||||
},
|
||||
meterRemoved(type) {
|
||||
meterRemoved(type: SelectedMeterType) {
|
||||
if (type === "charge") {
|
||||
// update loadpoint
|
||||
this.$refs.loadpointModal?.setMeter("");
|
||||
(
|
||||
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
|
||||
)?.setMeter("");
|
||||
} else {
|
||||
// update site grid, pv, battery, aux, ext
|
||||
this.loadSite();
|
||||
|
|
@ -828,12 +890,16 @@ export default {
|
|||
}
|
||||
this.meterChanged();
|
||||
},
|
||||
async chargerAdded(name) {
|
||||
async chargerAdded(name: string) {
|
||||
await this.chargerChanged();
|
||||
this.$refs.loadpointModal?.setCharger(name);
|
||||
(
|
||||
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
|
||||
)?.setCharger(name);
|
||||
},
|
||||
chargerRemoved() {
|
||||
this.$refs.loadpointModal?.setCharger("");
|
||||
(
|
||||
this.$refs["loadpointModal"] as InstanceType<typeof LoadpointModal> | undefined
|
||||
)?.setCharger("");
|
||||
this.chargerChanged();
|
||||
},
|
||||
meterModalClosed() {
|
||||
|
|
@ -846,7 +912,7 @@ export default {
|
|||
// reopen loadpoint modal
|
||||
this.loadpointModal().show();
|
||||
},
|
||||
async saveSite(key) {
|
||||
async saveSite(key: keyof SiteConfig) {
|
||||
const body = key ? { [key]: this.site[key] } : this.site;
|
||||
await api.put("/config/site", body);
|
||||
await this.loadSite();
|
||||
|
|
@ -859,28 +925,29 @@ export default {
|
|||
async restart() {
|
||||
await performRestart();
|
||||
},
|
||||
async updateDeviceValue(type, name) {
|
||||
async updateDeviceValue(type: DeviceType, name: string) {
|
||||
try {
|
||||
const response = await api.get(`/config/devices/${type}/${name}/status`);
|
||||
if (!this.deviceValues[type]) this.deviceValues[type] = {};
|
||||
this.deviceValues[type][name] = response.data;
|
||||
} catch (error) {
|
||||
console.error("Error fetching device values for", type, name, error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async updateValues() {
|
||||
clearTimeout(this.deviceValueTimeout);
|
||||
if (this.deviceValueTimeout) {
|
||||
clearTimeout(this.deviceValueTimeout);
|
||||
}
|
||||
if (!this.offline) {
|
||||
const devices = {
|
||||
meter: this.meters,
|
||||
vehicle: this.vehicles,
|
||||
charger: this.chargers,
|
||||
};
|
||||
} as Record<DeviceType, any[]>;
|
||||
for (const type in devices) {
|
||||
for (const device of devices[type]) {
|
||||
for (const device of devices[type as DeviceType]) {
|
||||
if (this.isComponentMounted && this.isPageVisible) {
|
||||
await this.updateDeviceValue(type, device.name);
|
||||
await this.updateDeviceValue(type as DeviceType, device.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -891,16 +958,16 @@ export default {
|
|||
this.deviceValueTimeout = setTimeout(this.updateValues, interval);
|
||||
}
|
||||
},
|
||||
deviceTags(type, id) {
|
||||
return this.deviceValues[type]?.[id] || {};
|
||||
deviceTags(type: DeviceType, id: string) {
|
||||
return this.deviceValues[type][id] || {};
|
||||
},
|
||||
loadpointTags(loadpoint) {
|
||||
loadpointTags(loadpoint: ConfigLoadpoint) {
|
||||
const { charger, meter } = loadpoint;
|
||||
const chargerTags = charger ? this.deviceTags("charger", charger) : {};
|
||||
const meterTags = meter ? this.deviceTags("meter", meter) : {};
|
||||
return { ...chargerTags, ...meterTags };
|
||||
},
|
||||
openModal(id) {
|
||||
openModal(id: string) {
|
||||
const $el = document.getElementById(id);
|
||||
if ($el) {
|
||||
Modal.getOrCreateInstance($el).show();
|
||||
|
|
@ -908,41 +975,43 @@ export default {
|
|||
console.error(`modal ${id} not found`);
|
||||
}
|
||||
},
|
||||
circuitTags(circuit) {
|
||||
circuitTags(circuit: ConfigCircuit) {
|
||||
const circuits = store.state?.circuits || {};
|
||||
const data = circuits[circuit.name] || {};
|
||||
const result = {};
|
||||
const result: Record<string, object> = {};
|
||||
const p = data.power || 0;
|
||||
if (data.maxPower) {
|
||||
result.powerRange = {
|
||||
value: [data.power || 0, data.maxPower],
|
||||
warning: data.power >= data.maxPower,
|
||||
result["powerRange"] = {
|
||||
value: [p, data.maxPower],
|
||||
warning: data.power && data.power >= data.maxPower,
|
||||
};
|
||||
} else {
|
||||
result.power = { value: data.power || 0, muted: true };
|
||||
result["power"] = { value: p, muted: true };
|
||||
}
|
||||
if (data.maxCurrent) {
|
||||
result.currentRange = {
|
||||
result["currentRange"] = {
|
||||
value: [data.current || 0, data.maxCurrent],
|
||||
warning: data.current >= data.maxCurrent,
|
||||
warning: data.current && data.current >= data.maxCurrent,
|
||||
};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
hasDeviceError(type, name) {
|
||||
hasDeviceError(type: DeviceType, name?: string) {
|
||||
if (!name) return false;
|
||||
const fatals = store.state?.fatal || [];
|
||||
return fatals.some((fatal) => fatal.class === type && fatal.device === name);
|
||||
},
|
||||
hasClassError(className) {
|
||||
hasClassError(className: string) {
|
||||
const fatals = store.state?.fatal || [];
|
||||
return fatals.some((fatal) => fatal.class === className);
|
||||
},
|
||||
chargerIcon(chargerName) {
|
||||
chargerIcon(chargerName: string) {
|
||||
const charger = this.chargers.find((c) => c.name === chargerName);
|
||||
|
||||
return charger?.config?.icon || this.deviceValues?.charger?.[chargerName]?.icon?.value;
|
||||
return charger?.config?.icon || this.deviceValues["charger"][chargerName]?.icon?.value;
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.config-list {
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import "@h2d2/shopicons/es/regular/download";
|
||||
import Header from "../components/Top/Header.vue";
|
||||
import Play from "../components/MaterialIcon/Play.vue";
|
||||
|
|
@ -114,6 +114,8 @@ import Record from "../components/MaterialIcon/Record.vue";
|
|||
import MultiSelect from "../components/Helper/MultiSelect.vue";
|
||||
import api from "../api";
|
||||
import store from "../store";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import type { Timeout } from "@/types/evcc";
|
||||
|
||||
const LEVELS = ["fatal", "error", "warn", "info", "debug", "trace"];
|
||||
const DEFAULT_LEVEL = "debug";
|
||||
|
|
@ -121,7 +123,7 @@ const DEFAULT_COUNT = 1000;
|
|||
|
||||
const levelMatcher = new RegExp(`\\[.*?\\] (${LEVELS.map((l) => l.toUpperCase()).join("|")})`);
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Log",
|
||||
components: {
|
||||
TopHeader: Header,
|
||||
|
|
@ -130,15 +132,15 @@ export default {
|
|||
MultiSelect,
|
||||
},
|
||||
props: {
|
||||
areas: { type: Array, default: () => [] },
|
||||
areas: { type: Array as PropType<string[]>, default: () => [] },
|
||||
level: { type: String, default: DEFAULT_LEVEL },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
lines: [],
|
||||
availableAreas: [],
|
||||
lines: [] as string[],
|
||||
availableAreas: [] as string[],
|
||||
search: "",
|
||||
timeout: null,
|
||||
timeout: null as Timeout,
|
||||
levels: LEVELS,
|
||||
busy: false,
|
||||
};
|
||||
|
|
@ -216,7 +218,7 @@ export default {
|
|||
this.stopInterval();
|
||||
},
|
||||
methods: {
|
||||
async updateLogs(showAll) {
|
||||
async updateLogs(showAll: boolean = false) {
|
||||
// prevent concurrent requests
|
||||
if (this.busy) return;
|
||||
|
||||
|
|
@ -263,20 +265,20 @@ export default {
|
|||
console.error(e);
|
||||
}
|
||||
},
|
||||
onScroll(e) {
|
||||
onScroll(e: Event) {
|
||||
const t = e.target as HTMLElement;
|
||||
// disable follow when not at the bottom
|
||||
if (
|
||||
this.autoFollow &&
|
||||
e.target.scrollTop + e.target.clientHeight < e.target.scrollHeight
|
||||
) {
|
||||
if (this.autoFollow && t && t.scrollTop + t.clientHeight < t.scrollHeight) {
|
||||
this.stopInterval();
|
||||
}
|
||||
},
|
||||
scrollToTop() {
|
||||
this.$refs.log.scrollTop = 0;
|
||||
const log = this.$refs["log"] as HTMLElement;
|
||||
log.scrollTop = 0;
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.$refs.log.scrollTop = this.$refs.log.scrollHeight;
|
||||
const log = this.$refs["log"] as HTMLElement;
|
||||
log.scrollTop = log.scrollHeight;
|
||||
},
|
||||
toggleAutoFollow() {
|
||||
if (this.autoFollow) {
|
||||
|
|
@ -286,29 +288,30 @@ export default {
|
|||
this.startInterval();
|
||||
}
|
||||
},
|
||||
updateQuery({ level, areas }) {
|
||||
let newLevel = level || this.level;
|
||||
let newAreas = areas || this.areas;
|
||||
updateQuery({ level: l, areas: a }: { level?: string; areas?: string[] }) {
|
||||
const newLevel = l || this.level;
|
||||
const newAreas = a || this.areas;
|
||||
|
||||
// reset to default level
|
||||
if (newLevel === DEFAULT_LEVEL) newLevel = undefined;
|
||||
newAreas = newAreas.length ? newAreas.join(",") : undefined;
|
||||
this.$router.push({
|
||||
query: { level: newLevel, areas: newAreas },
|
||||
});
|
||||
const level = newLevel === DEFAULT_LEVEL ? undefined : newLevel;
|
||||
const areas = newAreas.length ? newAreas.join(",") : undefined;
|
||||
|
||||
this.$router.push({ query: { level, areas } });
|
||||
},
|
||||
changeLevel(event) {
|
||||
this.updateQuery({ level: event.target.value });
|
||||
changeLevel(event: Event) {
|
||||
this.updateQuery({ level: (event.target as HTMLSelectElement).value });
|
||||
},
|
||||
changeAreas(areas) {
|
||||
changeAreas(areas: string[]) {
|
||||
this.updateQuery({ areas });
|
||||
},
|
||||
onCopy(event) {
|
||||
const selection = window.getSelection().toString();
|
||||
event.clipboardData.setData("text/plain", "```\n" + selection + "\n```");
|
||||
onCopy(e: Event) {
|
||||
const selection = window.getSelection()?.toString();
|
||||
const event = e as ClipboardEvent;
|
||||
event.clipboardData?.setData("text/plain", "```\n" + selection + "\n```");
|
||||
event.preventDefault();
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
<style scoped>
|
||||
.logs {
|
||||
|
|
|
|||
|
|
@ -7,15 +7,17 @@
|
|||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import Site from "../components/Site/Site.vue";
|
||||
import store from "../store";
|
||||
import type { Notification } from "@/types/evcc";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Main",
|
||||
components: { Site },
|
||||
props: {
|
||||
notifications: Array,
|
||||
notifications: Array as PropType<Notification[]>,
|
||||
selectedLoadpointIndex: Number,
|
||||
},
|
||||
data() {
|
||||
|
|
@ -29,5 +31,5 @@ export default {
|
|||
// no custom title
|
||||
return { title: "evcc", titleTemplate: null };
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -112,7 +112,7 @@
|
|||
v-else
|
||||
:sessions="currentSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:group-by="selectedGroup"
|
||||
:group-by="selectedGroupWithoutNone"
|
||||
/>
|
||||
</div>
|
||||
<AvgCostGroupedChart
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
:sessions="currentTypeSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:suggested-max-price="suggestedMaxAvgCost"
|
||||
:group-by="selectedGroup"
|
||||
:group-by="selectedGroupWithoutNone"
|
||||
:cost-type="activeType"
|
||||
:currency="currency"
|
||||
/>
|
||||
|
|
@ -131,13 +131,13 @@
|
|||
v-if="activeType === types.SOLAR"
|
||||
:sessions="currentSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:group-by="selectedGroup"
|
||||
:group-by="selectedGroupWithoutNone"
|
||||
/>
|
||||
<CostGroupedChart
|
||||
v-else
|
||||
:sessions="currentTypeSessions"
|
||||
:color-mappings="colorMappings"
|
||||
:group-by="selectedGroup"
|
||||
:group-by="selectedGroupWithoutNone"
|
||||
:cost-type="activeType"
|
||||
:currency="currency"
|
||||
/>
|
||||
|
|
@ -176,7 +176,7 @@
|
|||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
<script lang="ts">
|
||||
import Modal from "bootstrap/js/dist/modal";
|
||||
import "@h2d2/shopicons/es/regular/cablecharge";
|
||||
import "@h2d2/shopicons/es/regular/car3";
|
||||
|
|
@ -205,9 +205,11 @@ import PeriodSelector from "../components/Sessions/PeriodSelector.vue";
|
|||
import DateNavigator from "../components/Sessions/DateNavigator.vue";
|
||||
import DynamicPriceIcon from "../components/MaterialIcon/DynamicPrice.vue";
|
||||
import TotalIcon from "../components/MaterialIcon/Total.vue";
|
||||
import { TYPES, GROUPS, PERIODS } from "../components/Sessions/types";
|
||||
import { TYPES, GROUPS, PERIODS, type Session } from "../components/Sessions/types";
|
||||
import { defineComponent, type PropType } from "vue";
|
||||
import { CURRENCY } from "@/types/evcc";
|
||||
|
||||
export default {
|
||||
export default defineComponent({
|
||||
name: "Sessions",
|
||||
components: {
|
||||
SessionDetailsModal,
|
||||
|
|
@ -230,20 +232,20 @@ export default {
|
|||
},
|
||||
mixins: [formatter],
|
||||
props: {
|
||||
notifications: Array,
|
||||
notifications: Array as PropType<Notification[]>,
|
||||
month: { type: Number, default: () => new Date().getMonth() + 1 },
|
||||
year: { type: Number, default: () => new Date().getFullYear() },
|
||||
period: { type: String, default: PERIODS.MONTH },
|
||||
period: { type: String as PropType<PERIODS>, default: PERIODS.MONTH },
|
||||
loadpointFilter: { type: String, default: "" },
|
||||
vehicleFilter: { type: String, default: "" },
|
||||
offline: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
sessions: [],
|
||||
selectedType: settings.sessionsType || TYPES.SOLAR,
|
||||
selectedGroup: settings.sessionsGroup || GROUPS.NONE,
|
||||
selectedSessionId: undefined,
|
||||
sessions: [] as Session[],
|
||||
selectedType: (settings.sessionsType || TYPES.SOLAR) as TYPES,
|
||||
selectedGroup: (settings.sessionsGroup || GROUPS.NONE) as GROUPS,
|
||||
selectedSessionId: undefined as number | undefined,
|
||||
periods: PERIODS,
|
||||
types: TYPES,
|
||||
groups: GROUPS,
|
||||
|
|
@ -253,8 +255,11 @@ export default {
|
|||
return { title: this.$t("sessions.title") };
|
||||
},
|
||||
computed: {
|
||||
selectedGroupWithoutNone() {
|
||||
return this.selectedGroup !== this.groups.NONE ? this.selectedGroup : undefined;
|
||||
},
|
||||
currency() {
|
||||
return store.state.currency || "EUR";
|
||||
return store.state.currency || CURRENCY.EUR;
|
||||
},
|
||||
energyTitle() {
|
||||
return this.$t("sessions.chartTitle.energy");
|
||||
|
|
@ -369,24 +374,24 @@ export default {
|
|||
}
|
||||
},
|
||||
totalPrice() {
|
||||
return this.currentSessionsWithPrice.reduce((acc, s) => acc + s.price, 0);
|
||||
return this.currentSessionsWithPrice.reduce((acc, s) => acc + (s.price ?? 0), 0);
|
||||
},
|
||||
pricePerKWh() {
|
||||
const list = this.currentSessionsWithPrice;
|
||||
const energy = list.reduce((acc, s) => acc + s.chargedEnergy, 0);
|
||||
return energy ? this.totalPrice / energy : null;
|
||||
return energy ? this.totalPrice / energy : 0;
|
||||
},
|
||||
currentSessionsWithCo2() {
|
||||
return this.currentSessions.filter((s) => s.co2PerKWh !== null);
|
||||
},
|
||||
totalCo2() {
|
||||
const list = this.currentSessionsWithCo2;
|
||||
return list.reduce((acc, s) => acc + s.co2PerKWh * s.chargedEnergy, 0);
|
||||
return list.reduce((acc, s) => acc + (s.co2PerKWh ?? 0) * s.chargedEnergy, 0);
|
||||
},
|
||||
co2PerKWh() {
|
||||
const list = this.currentSessionsWithCo2;
|
||||
const energy = list.reduce((acc, s) => acc + s.chargedEnergy, 0);
|
||||
return energy ? this.totalCo2 / energy : null;
|
||||
return energy ? this.totalCo2 / energy : 0;
|
||||
},
|
||||
costTitle() {
|
||||
const type = this.activeType === TYPES.PRICE ? "Price" : "Co2";
|
||||
|
|
@ -426,10 +431,6 @@ export default {
|
|||
startDate() {
|
||||
return new Date(this.sessions[0]?.created || Date.now());
|
||||
},
|
||||
topNavigation() {
|
||||
const vehicleLogins = store.state.auth ? store.state.auth.vehicles : {};
|
||||
return { vehicleLogins, ...this.collectProps(TopNavigation, store.state) };
|
||||
},
|
||||
sessionsWithDefaults() {
|
||||
return this.sessions.map((session) => {
|
||||
const loadpoint = session.loadpoint || this.$t("main.loadpoint.fallbackName");
|
||||
|
|
@ -456,7 +457,7 @@ export default {
|
|||
},
|
||||
vehicleList() {
|
||||
const vehicles = store.state.vehicles || {};
|
||||
return Object.entries(vehicles).map(([name, vehicle]) => ({ name, ...vehicle }));
|
||||
return Object.entries(vehicles).map(([name, vehicle]) => ({ ...vehicle, name }));
|
||||
},
|
||||
loadpointList() {
|
||||
const loadpoints = store.state.loadpoints || [];
|
||||
|
|
@ -468,7 +469,7 @@ export default {
|
|||
monthName() {
|
||||
const date = new Date();
|
||||
date.setMonth(this.month - 1, 1);
|
||||
return this.fmtMonth(date);
|
||||
return this.fmtMonth(date, false);
|
||||
},
|
||||
csvLinkLabel() {
|
||||
if (this.period === PERIODS.MONTH) {
|
||||
|
|
@ -497,8 +498,8 @@ export default {
|
|||
lastThreeMonths.setMonth(lastThreeMonths.getMonth() - 3);
|
||||
|
||||
// Aggregate energy to get sorted list of loadpoints/vehicles for coloring
|
||||
const aggregateEnergy = (group) => {
|
||||
return this.sessionsWithDefaults.reduce((acc, session) => {
|
||||
const aggregateEnergy = (group: Exclude<GROUPS, GROUPS.NONE>) => {
|
||||
return this.sessionsWithDefaults.reduce((acc: Record<string, number>, session) => {
|
||||
if (new Date(session.created) >= lastThreeMonths) {
|
||||
const key = session[group];
|
||||
acc[key] = (acc[key] || 0) + session.chargedEnergy;
|
||||
|
|
@ -508,8 +509,11 @@ export default {
|
|||
};
|
||||
|
||||
// Assign colors based on energy usage
|
||||
const assignColors = (energyAggregation, colorType) => {
|
||||
const result = {};
|
||||
const assignColors = (
|
||||
energyAggregation: Record<string, number>,
|
||||
colorType: Exclude<GROUPS, GROUPS.NONE>
|
||||
) => {
|
||||
const result: Record<string, string> = {};
|
||||
let colorIndex = 0;
|
||||
|
||||
// Assign colors by used energy in the last three months
|
||||
|
|
@ -533,11 +537,11 @@ export default {
|
|||
return result;
|
||||
};
|
||||
|
||||
const loadpointEnergy = aggregateEnergy("loadpoint");
|
||||
const loadpointColors = assignColors(loadpointEnergy, "loadpoint");
|
||||
const loadpointEnergy = aggregateEnergy(GROUPS.LOADPOINT);
|
||||
const loadpointColors = assignColors(loadpointEnergy, GROUPS.LOADPOINT);
|
||||
|
||||
const vehicleEnergy = aggregateEnergy("vehicle");
|
||||
const vehicleColors = assignColors(vehicleEnergy, "vehicle");
|
||||
const vehicleEnergy = aggregateEnergy(GROUPS.VEHICLE);
|
||||
const vehicleColors = assignColors(vehicleEnergy, GROUPS.VEHICLE);
|
||||
|
||||
const solar = { self: colors.self, grid: colors.grid };
|
||||
const cost = { price: colors.price, co2: colors.co2 };
|
||||
|
|
@ -583,14 +587,24 @@ export default {
|
|||
},
|
||||
groupEntriesAvailable() {
|
||||
if (this.selectedGroup === GROUPS.NONE || !this.currentSessions.length) return false;
|
||||
return new Set(this.currentSessions.map((s) => s[this.selectedGroup])).size > 1;
|
||||
return (
|
||||
new Set(
|
||||
this.currentSessions.map(
|
||||
(s) => s[this.selectedGroup as Exclude<GROUPS, GROUPS.NONE>]
|
||||
)
|
||||
).size > 1
|
||||
);
|
||||
},
|
||||
showSolarYearChart() {
|
||||
return this.period !== PERIODS.MONTH && this.selectedGroup === GROUPS.NONE;
|
||||
},
|
||||
showExtraCharts() {
|
||||
const hasMultipleEntries =
|
||||
new Set(this.currentTypeSessions.map((s) => s[this.selectedGroup])).size > 1;
|
||||
new Set(
|
||||
this.currentTypeSessions.map(
|
||||
(s) => s[this.selectedGroup as Exclude<GROUPS, GROUPS.NONE>]
|
||||
)
|
||||
).size > 1;
|
||||
const isGrouped = [GROUPS.LOADPOINT, GROUPS.VEHICLE].includes(this.selectedGroup);
|
||||
const isSolar = this.activeType === TYPES.SOLAR;
|
||||
const isNotMonth = this.period !== PERIODS.MONTH;
|
||||
|
|
@ -600,14 +614,14 @@ export default {
|
|||
suggestedMaxAvgPrice() {
|
||||
// returns the 98th percentile of avg prices for all sessions
|
||||
const sessionsWithPrice = this.sessions.filter((s) => s.pricePerKWh !== null);
|
||||
const prices = sessionsWithPrice.map((s) => s.pricePerKWh);
|
||||
return this.percentile(prices, 98);
|
||||
const prices = sessionsWithPrice.map((s) => s.pricePerKWh ?? 0);
|
||||
return this.percentile(prices, 98) ?? 0;
|
||||
},
|
||||
suggestedMaxAvgCo2() {
|
||||
// returns the 98th percentile of avg co2 emissions for all sessions
|
||||
const sessionsWithCo2 = this.sessions.filter((s) => s.co2PerKWh !== null);
|
||||
const co2 = sessionsWithCo2.map((s) => s.co2PerKWh);
|
||||
return this.percentile(co2, 98);
|
||||
const co2 = sessionsWithCo2.map((s) => s.co2PerKWh ?? 0);
|
||||
return this.percentile(co2, 98) ?? 0;
|
||||
},
|
||||
suggestedMaxAvgCost() {
|
||||
return this.activeType === TYPES.PRICE
|
||||
|
|
@ -617,22 +631,22 @@ export default {
|
|||
suggestedMaxCo2() {
|
||||
// returns the 98th percentile of total co2 emissions by time period
|
||||
const sessionsWithCo2 = this.sessions.filter((s) => s.co2PerKWh !== null);
|
||||
const co2Map = sessionsWithCo2.reduce((acc, s) => {
|
||||
const co2Map = sessionsWithCo2.reduce((acc: Record<string, number>, s) => {
|
||||
const key = this.dateToPeriodKey(new Date(s.created));
|
||||
acc[key] = (acc[key] || 0) + s.co2PerKWh * s.chargedEnergy;
|
||||
acc[key] = (acc[key] || 0) + (s.co2PerKWh ?? 0) * s.chargedEnergy;
|
||||
return acc;
|
||||
}, {});
|
||||
return Math.max(this.percentile(Object.values(co2Map), 98), 5); // 5kg default
|
||||
return Math.max(this.percentile(Object.values(co2Map), 98) ?? 0, 5); // 5kg default
|
||||
},
|
||||
suggestedMaxPrice() {
|
||||
// returns the 98th percentile of total price by time period
|
||||
const sessionsWithPrice = this.sessions.filter((s) => s.price !== null);
|
||||
const priceMap = sessionsWithPrice.reduce((acc, s) => {
|
||||
const priceMap = sessionsWithPrice.reduce((acc: Record<string, number>, s) => {
|
||||
const key = this.dateToPeriodKey(new Date(s.created));
|
||||
acc[key] = (acc[key] || 0) + s.price;
|
||||
acc[key] = (acc[key] || 0) + (s.price || 0);
|
||||
return acc;
|
||||
}, {});
|
||||
return Math.max(this.percentile(Object.values(priceMap), 98), 1); // 1 CURRENCY default
|
||||
return Math.max(this.percentile(Object.values(priceMap), 98) ?? 0, 1); // 1 CURRENCY default
|
||||
},
|
||||
suggestedMaxCost() {
|
||||
return this.activeType === TYPES.PRICE ? this.suggestedMaxPrice : this.suggestedMaxCo2;
|
||||
|
|
@ -647,10 +661,10 @@ export default {
|
|||
this.loadSessions();
|
||||
},
|
||||
methods: {
|
||||
changePeriod(newPeriod) {
|
||||
let month = this.month;
|
||||
let year = this.year;
|
||||
let period = newPeriod;
|
||||
changePeriod(newPeriod: PERIODS) {
|
||||
let month: number | undefined = this.month;
|
||||
let year: number | undefined = this.year;
|
||||
let period: PERIODS | undefined = newPeriod;
|
||||
switch (period) {
|
||||
case PERIODS.TOTAL:
|
||||
month = undefined;
|
||||
|
|
@ -664,8 +678,12 @@ export default {
|
|||
}
|
||||
this.$router.push({ query: { ...this.$route.query, period, month, year } });
|
||||
},
|
||||
dateToPeriodKey(date) {
|
||||
const options = { year: "numeric", month: "numeric", day: "numeric" };
|
||||
dateToPeriodKey(date: Date) {
|
||||
const options: Intl.DateTimeFormatOptions = {
|
||||
year: "numeric",
|
||||
month: "numeric",
|
||||
day: "numeric",
|
||||
};
|
||||
if (this.period === PERIODS.YEAR) options.day = undefined;
|
||||
if (this.period === PERIODS.TOTAL) options.month = undefined;
|
||||
return date.toLocaleDateString(undefined, options);
|
||||
|
|
@ -673,44 +691,46 @@ export default {
|
|||
async loadSessions() {
|
||||
const response = await api.get("sessions");
|
||||
// ensure sessions are sorted by created date
|
||||
const sortedSessions = response.data.sort((a, b) => {
|
||||
return new Date(a.created) - new Date(b.created);
|
||||
const sortedSessions = response.data?.sort((a: Session, b: Session) => {
|
||||
return new Date(a.created).getTime() - new Date(b.created).getTime();
|
||||
});
|
||||
this.sessions = sortedSessions;
|
||||
},
|
||||
showDetails(sessionId) {
|
||||
showDetails(sessionId: number) {
|
||||
this.selectedSessionId = sessionId;
|
||||
const modal = Modal.getOrCreateInstance(document.getElementById("sessionDetailsModal"));
|
||||
const modal = Modal.getOrCreateInstance(
|
||||
document.getElementById("sessionDetailsModal") as HTMLElement
|
||||
);
|
||||
modal.show();
|
||||
},
|
||||
csvHrefLink(year, month) {
|
||||
csvHrefLink(year?: number, month?: number) {
|
||||
const params = new URLSearchParams({
|
||||
format: "csv",
|
||||
lang: this.$i18n?.locale,
|
||||
});
|
||||
if (year) params.append("year", year);
|
||||
if (month) params.append("month", month);
|
||||
if (year) params.append("year", year.toString());
|
||||
if (month) params.append("month", month.toString());
|
||||
return `./api/sessions?${params.toString()}`;
|
||||
},
|
||||
updateType(type) {
|
||||
updateType(type: TYPES) {
|
||||
this.selectedType = type;
|
||||
settings.sessionsType = type;
|
||||
},
|
||||
updateGroup(group) {
|
||||
updateGroup(group: GROUPS) {
|
||||
this.selectedGroup = group;
|
||||
settings.sessionsGroup = group;
|
||||
},
|
||||
updateDate({ year, month }) {
|
||||
updateDate({ year, month }: { year: number; month: number }) {
|
||||
this.$router.push({ query: { ...this.$route.query, year, month } });
|
||||
},
|
||||
percentile(arr, p) {
|
||||
percentile(arr: number[], p: number): number | null {
|
||||
if (arr.length === 0) return null;
|
||||
const sorted = arr.sort((a, b) => a - b);
|
||||
const index = (p / 100) * (sorted.length - 1);
|
||||
return sorted[Math.floor(index)];
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue